Merge pull request #37 from dan-claude-bot/feat/bootstrap-roles

feat(bootstrap): box tenant roles — claude, codex, grok, staging (#31)
This commit is contained in:
Daniel Marin 2026-07-18 21:32:13 +01:00 committed by GitHub
commit e6b630b9f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 892 additions and 116 deletions

153
README.md
View file

@ -2,7 +2,9 @@
A CLI that turns a **pristine Debian server into a hardened, tailnet-joined
node** — one curl, one command. A second command installs a version-pinned
Coolify on a control-plane box.
Coolify on a control-plane box. And inside a [box](https://github.com/heavy-duty/box)-minted
guest, the same verb converges the **box tenants** — claude, codex, grok,
staging — from thin, creds-free seeds (see *the box tenants* below).
Philosophy (shared with [box](https://github.com/heavy-duty/box)):
**public tool, private state**. rig carries plumbing logic only — no
@ -68,19 +70,20 @@ itself is untouched — what bootstrap converged stays converged.
## Commands
### `rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>`
### `rig bootstrap <control-plane|workload|runner|dev|workstation|custom>`
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
second run changes nothing.
second run changes nothing. (The box TENANT roles — `claude`, `codex`,
`grok`, `staging` — share the verb but are their own family; see *the box
tenants* below.)
```sh
rig bootstrap control-plane --hostname my-coolify-box
rig bootstrap workload --hostname my-prod-box
rig bootstrap runner --hostname my-ci-box
rig bootstrap staging --hostname my-vm-host
rig bootstrap dev --hostname my-dev-box
rig bootstrap workstation --hostname my-laptop
rig bootstrap custom --hostname odd-duck --class server --host yes --join authkey
rig bootstrap custom --hostname my-vm-host --class server --host yes --join authkey
```
- `--hostname <name>` — system + tailnet hostname (default: the role name;
@ -107,10 +110,20 @@ presets nothing and requires `--hostname` plus all three traits.
| `control-plane` | server | no | authkey | `tag:server` |
| `workload` | server | no | authkey | `tag:server` |
| `runner` | server | no | authkey | `tag:ci` — refuses `tag:server` |
| `staging` | server | yes | authkey | `tag:local` — refuses `tag:server` |
| `dev` | human | yes | authkey | `tag:local` — refuses `tag:server` |
| `workstation` | human | yes | login | untagged — any tag refused |
> **Where the `staging` preset went.** Before #31, `staging` was the VM-host
> preset (`class=server host=yes join=authkey`). The name now belongs to the
> box TENANT family — the *guest*, not the host under it — because that is
> what "staging" mostly names in practice (the box the control plane will
> manage), and two meanings on one word was the worse bug. The host shape
> lost nothing: it is one flag away — `rig bootstrap dev --class server
> --hostname my-vm-host` — or fully spelled as `custom --class server --host
> yes --join authkey`. Tag policy is unchanged: mint the host's key with
> `tag:local`; an effective `tag:server` is refused on every role but
> `control-plane` and `workload`.
The tag column is **derived policy, not a fourth trait**: `tag:server` means
"the control plane manages this box", and `control-plane` and `workload` are
the only shapes it manages — every other role refuses an effective
@ -208,19 +221,20 @@ grant `tag:server` to repo-controlled code." A runner executes that code, and
the check turns the worst misconfiguration from a documentation warning into a
hard, post-join error.
`staging` is the box that *hosts* staging boxes — Incus VMs minted by the
[`box`](https://github.com/heavy-duty/box) CLI, each converged from inside with
`rig bootstrap workload` and registered in the control plane as its own server.
It is `class=server`: an unattended VM appliance — operators converge it and
leave; nobody lives there. Mint its key with `tag:local`: the host and its
guests sit on opposite sides of a trust boundary, and the *host* is never
managed by the control plane — so the role **refuses an effective
`tag:server`**, same mechanism as `runner`.
The VM-host shape — the box that *hosts* staging boxes: Incus VMs minted by
the [`box`](https://github.com/heavy-duty/box) CLI, each converged from inside
with the tenant roles and (for staging guests) `rig bootstrap workload`
rides the traits since #31 (`--class server --host yes --join authkey`; see
the note above). It is `class=server`: an unattended VM appliance — operators
converge it and leave; nobody lives there. Mint its key with `tag:local`: the
host and its guests sit on opposite sides of a trust boundary, and the *host*
is never managed by the control plane — so an effective **`tag:server` is
refused**, same mechanism as `runner`.
On a host-class box (`host=yes`), bootstrap finishes the job instead of leaving
a to-do: after the role marker is written it **installs the `box` CLI globally
and runs box's own `setup-host`**, so the Incus stack is ready for
`box new --template staging` when bootstrap returns. rig **delegates to box; it
`box new` when bootstrap returns. rig **delegates to box; it
never touches Incus itself** — it does not `apt-get install incus`, does not
configure the daemon, does not create the `incus` group. It runs box's global
installer (`curl … | BOX_YES=1 bash`) as root, and box installs Incus via its
@ -248,11 +262,88 @@ merges box's root install lands in `/root`.)
> fork); `RIG_SKIP_BOX_INSTALL=1` opts out entirely for a host whose box you
> manage by hand.
`dev` is `staging`'s human-class sibling — the same VM-hosting, `tag:local`
shape with a person living on it, box CLI installed the same way — and
`workstation` is the machine at the keyboard end of all the SSH connections:
human-class, `join=login`, entering the tailnet as *your* device rather than the
fleet's.
`dev` is the human-class VM-hosting shape — `tag:local`, box CLI installed as
above, a person living on it (`--class server` turns it into the unattended
VM-host appliance) — and `workstation` is the machine at the keyboard end of
all the SSH connections: human-class, `join=login`, entering the tailnet as
*your* device rather than the fleet's.
### `rig bootstrap <claude|codex|grok|staging>` — the box tenants
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
guest. Convergent — safe to re-run; a second run changes nothing.
```sh
rig bootstrap claude # or codex, grok — the agent tenants
rig bootstrap staging # the server tenant (docker + sshd hardening)
rig bootstrap claude --user dev # when the seed's BOX_USER differs
```
**The layering** (rig#31 ↔ box#81): a box template stops being where tenant
content lives. box mints a **thin, creds-free seed** — base image, the
`BOX_USER`, rig (+ tmux) preinstalled, and nothing that joins or admits — and
everything the guest *becomes* is a rig tenant role. cloud-init is a first-boot
one-shot: not convergent, not re-runnable, and only parse-and-grep testable.
rig roles are idempotent scripts with effective-state asserts, driven by the
same harness as everything else — and re-runnable on an *existing* box to
converge it to a new spec instead of re-minting it. One convergence engine;
the guests were the hole.
It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh`
holds the whole per-tenant table), not four hand-maintained scripts:
| tenant | user | what lands |
|-----------|----------|------------|
| `claude` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` |
| `codex` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
| `grok` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
| `staging` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) |
Every install is **asserted on effective state**, not exit codes: the CLI must
*answer* (`--version`, run as the tenant user — a CLI that exists but cannot
run has already cost a drill), docker must answer, `sshd -T` must resolve the
hardening. The CLI also lands on the **system** PATH (`/usr/local/bin`):
`box exec <box> -- claude …` runs a non-interactive shell that reads no rc
files, so a PATH export alone is invisible to it.
**Creds-free and non-interactive, by contract.** box auto-runs these at mint
(`box exec … rig bootstrap claude`), so nothing here prompts, joins, or admits
— no tailnet, no keys (the harness pins this by *absence*: no `tailscale`, no
prompt, in the shipped script). The one creds-holding step a staging guest
eventually needs — the tailnet workload join — stays **operator-run**, exactly
as box#69 designed it: `box shell` → `sudo rig bootstrap workload --hostname
<name>` with a single-use tagged pre-auth key. After that join, re-running
`rig bootstrap staging` still converges docker + hardening and leaves the
workload marker alone — the machine role is the truer statement of what the
box became.
**The agent-context file carries the box#80 guard, once.** Every agent tenant
writes its agent's instructions file (`CLAUDE.md` / `AGENTS.md`), rendered
from one shared template: the creds-free contract, the isolation and
disposability facts, and the guard note — **never run `box setup-host`,
`box teardown-host`, or the drill inside a box; the box you are in is not a
host you own**. A nested box stack claims the guest's own uplink subnet and
silently breaks its networking (box#80). The note lives in
`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was
the point of moving it here.
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
host=no` — no `class=`, because a guest has no root-door policy of its own
(`rig users close-root` fails closed on it, by design). The guard runs the
other way too: a box already carrying a **machine** role refuses the agent
tenants outright, and *any* tenant refuses a `host=yes` box — a VM host is
the opposite of a guest, and a pre-#31 staging *host* re-running its old
command is exactly who that refusal catches (it names the new spelling).
> **The rig install in the seed is unpinned — same honesty as the box note
> above.** The seed preinstalls rig via its curl installer, which resolves
> `RIG_REPO`/`RIG_REF` (default `heavy-duty/rig@main`, branches only — rig
> cuts no tags yet). That inverts the install edge on this page: rig installs
> box on host-class machines, and box guests now install rig — both tracking
> a moving `main` until a release flow exists (rig#32). `RIG_REPO`/`RIG_REF`
> are the pin points the day there is something to pin to, or point them at a
> frozen branch of your own fork. The seed side of this edge is box#81's to
> document.
### The identity model
@ -271,10 +362,15 @@ comparison, translated onto the traits that replaced the class binary:
| `control-plane` | server | no | authkey | nobody — Coolify runs here | open — the automation door |
| `workload` | server | no | authkey | nobody — deployed services run here | open — the automation door |
| `runner` | server | no | authkey | nobody — CI jobs as `github-runner` | open — the automation door |
| `staging` | server | yes | authkey | nobody — an unattended VM appliance | open — the automation door |
| `dev` | human | yes | authkey | operators, minting boxes | closed by `rig users close-root` |
| `workstation` | human | yes | login | its owner | closed by `rig users close-root` |
(The unattended VM-host appliance — formerly the `staging` preset — is the
`class=server host=yes join=authkey` shape: nobody lives there, root SSH stays
open as the automation door. The box TENANT roles sit outside this table on
purpose: a guest is not a tailnet machine, and its marker carries no `class=`,
so `rig users close-root` fails closed on it.)
Who installs what, and who runs as what: **bootstrap is always root** and
installs everything a role needs — on `host=yes` that includes the box CLI
(globally) and box's own `setup-host`. **Humans always run as themselves**:
@ -774,7 +870,14 @@ uninstalls with their absence asserts. The
`rig users` family is covered the same way: the harness drives its refusal
matrix — users-file parsing, the marker gates, the lexical drop-in-name
assertion, the validate-then-apply ordering — through the sourced lib
functions, non-root and network-free. The end-to-end rehearsal is a throwaway
VM/container: pristine Debian → install → `bootstrap workload` with a real
single-use key → assert the sshd drop-in, tailnet join, and a no-op second
run → destroy, remove the node from the tailnet.
functions, non-root and network-free. The tenant family follows the same
split: the harness proves the arg/refusal surface, the marker guards (off
fixture markers), the pure parameter table, and the rendered agent-context
file — guard note included — plus absence-greps for the creds-free contract;
the real converge belongs to the rehearsal. The end-to-end rehearsal is a
throwaway VM/container: pristine Debian → install → `bootstrap workload` with
a real single-use key → assert the sshd drop-in, tailnet join, and a no-op
second run → destroy, remove the node from the tailnet. The tenant rehearsal
is the same shape, creds-free: container + seed user → `rig bootstrap claude`
/ `staging` → assert the CLI answers, docker answers, `sshd -T`, the context
file — then re-run and watch it no-op.

10
bin/rig
View file

@ -13,7 +13,7 @@ usage() {
usage: rig <command> [args]
commands:
bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
bootstrap <control-plane|workload|runner|dev|workstation|custom>
[--hostname <name>] [--class <human|server>] [--host <yes|no>]
[--join <authkey|login>]
OS plumbing on a pristine Debian box: hardening, unattended-upgrades,
@ -24,6 +24,14 @@ commands:
only control-plane and workload may carry tag:server. join=login
(workstation) needs no key: interactive login, node must come up
untagged. Run as root.
bootstrap <claude|codex|grok|staging> [--user <name>]
The box TENANT roles: converge a box-minted guest. claude|codex|grok
land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on
the system PATH, and the agent-context file (with the box#80 guard:
never run box setup-host inside a box). staging lands box#69's server
posture — docker + sshd hardening; its tailnet join stays operator-run
via 'rig bootstrap workload'. Creds-free and non-interactive: box
auto-runs these at mint. Run as root, inside the box.
coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
coolify backup install [options]

390
commands/bootstrap-tenant.sh Executable file
View file

@ -0,0 +1,390 @@
#!/usr/bin/env bash
# rig bootstrap <claude|codex|grok|staging> — the box TENANT roles: what a
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the
# tenant content that used to live in the templates' cloud-init, idempotent and
# effective-state asserted, so an EXISTING box can be re-run to a new spec
# instead of re-minted. One mechanism, parameterized per tenant through
# lib/tenant-config.sh — never four hand-maintained copies.
#
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
# bootstrap claude'), so every path here is non-interactive and nothing joins
# or admits — no tailnet, no keys, no prompts. staging's tailnet join stays
# operator-run ('rig bootstrap workload' through 'box shell'), exactly the
# creds split box#69 designed.
# Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd (the staging tenant)
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <claude|codex|grok|staging> [--user <name>]
Box TENANT roles — what a box-minted guest becomes. box mints the thin,
creds-free seed (base image, user, rig preinstalled); this converges the
tenant on top, and re-runs converge an existing box to a new spec.
claude|codex|grok the agent tenants: base tooling (git, gh, tmux, …),
docker, the agent's CLI on the system PATH, and the
agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box.
staging the server tenant (box#69's posture): docker + sshd
hardening. The tailnet workload join is deliberately
NOT here — it holds a credential, so it stays
operator-run: `box shell``sudo rig bootstrap
workload` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the
role's name; staging defaults to `ops`)
Tenant roles are creds-free and non-interactive by contract — box auto-runs
them at mint (`box exec … rig bootstrap claude`). They take none of the
machine-role traits (--hostname/--class/--host/--join): a tenant is a guest,
not a tailnet machine. Run as root, inside the box.
EOF
}
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
claude|codex|grok|staging) shift ;;
-h|--help) usage; exit 0 ;;
"") usage >&2; die "tenant role required (claude|codex|grok|staging)" 2 ;;
*) die "unknown tenant role: $ROLE (want claude|codex|grok|staging)" 2 ;;
esac
TENANT_USER="$(tenant_user "$ROLE")"
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 2 ;;
--hostname|--class|--host|--join)
# The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes
# from the box seed, and the one trait-shaped thing a staging guest
# eventually does (join the tailnet as a workload) is deliberately not
# here: it holds a credential, so it stays operator-run.
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane|workload|runner|dev|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload'." 2 ;;
--ts-tag)
[ $# -ge 2 ] && shift
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging's join is operator-run via 'rig bootstrap workload', where the tag comes from the pre-auth key." 2 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
# Same charset the users file enforces, for the same reasons (a leading '-'
# reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
# --- guards ------------------------------------------------------------------
# A tenant role converges a box GUEST. A box already carrying a machine-role
# marker is a tailnet machine rig built on purpose, and quietly turning it into
# a tenant (or clobbering its marker) is how a fleet box gets poisoned. Checked
# BEFORE the root check so the refusals are testable non-root, off fixture
# markers (repo precedent: the coolify marker warning). Two refusals, one
# tolerance:
# - host=yes → refuse, every tenant: a VM HOST is the opposite of a guest.
# Names the staging rename out loud — before #31, `staging` was the VM-host
# PRESET; that shape is now spelled through the traits.
# - class= (agent tenants) → refuse: an agent box is never a tailnet machine.
# - class=server with host=no (staging only) → PROCEED, and leave the marker
# alone: that is the staging guest AFTER its operator-run workload join, and
# re-converging docker+hardening on it is exactly what convergence is for.
# ONLY that shape — any other class (say class=human, via `custom`) is a
# machine rig built on purpose, and staging hardening it with server rules
# would die with server-specific messaging on a box that was never one.
MARKER_PATH="${RIG_ROLE_MARKER:-/etc/rig/role}"
EXISTING_MARKER="$(read_role_marker "$MARKER_PATH")"
case "$EXISTING_MARKER" in
*host=yes*)
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. Note: before rig#31, 'staging' was the VM-host preset; that shape is now 'rig bootstrap custom --class server --host yes --join authkey' (or 'dev --class server')." ;;
*class=*)
if [ "$ROLE" != "staging" ]; then
die "this box already carries a machine role (${EXISTING_MARKER}) — the agent tenants converge box guests, never tailnet machines. If this really is a guest, remove ${MARKER_PATH} and re-run."
fi
case "$EXISTING_MARKER" in
*class=server*) ;;
*)
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging tolerates only the workload-joined guest (class=server host=no). If this really is a staging guest, remove ${MARKER_PATH} and re-run." ;;
esac ;;
esac
[ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
# sourcing it in the main shell silently clobbers same-named script vars.
# shellcheck source=/dev/null
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
case "$OS_FAMILY" in
*debian*) ;;
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
esac
else
warn "cannot read /etc/os-release; proceeding anyway"
fi
# The tenant user is the SEED's to create (box.env BOX_USER + cloud-init), not
# rig's to conjure: a missing user means the seed and the role disagree, and
# inventing an account here would paper over exactly that mismatch.
id -u "$TENANT_USER" >/dev/null 2>&1 \
|| die "user '$TENANT_USER' does not exist — the box seed creates it (BOX_USER); pass --user <name> if this box's user differs"
TENANT_HOME="$(getent passwd "$TENANT_USER" | cut -d: -f6)"
TENANT_GROUP="$(id -gn "$TENANT_USER")"
[ -d "$TENANT_HOME" ] || die "user '$TENANT_USER' has no home directory ($TENANT_HOME)"
# append_line_once <file> <line> — converge a literal rc line: present exactly
# once, appended only when missing, ownership converged to the tenant user.
append_line_once() {
local file="$1" line="$2"
if [ ! -e "$file" ] || ! grep -qxF "$line" "$file"; then
printf '%s\n' "$line" >> "$file"
log "appended to ${file}: ${line}"
fi
chown "$TENANT_USER:$TENANT_GROUP" "$file"
}
# --- packages ----------------------------------------------------------------
export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})"
apt-get update -qq
case "$ROLE" in
claude)
# The claude tenant keeps zsh (its shell UX ships with the box); the
# remaining list is the shared agent toolbelt the templates carried.
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;;
codex|grok)
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
staging)
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
esac
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
# the operator's git credential lands.
command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — 'box tmux' (box#65) needs it"
if [ "$ROLE" != "staging" ]; then
command -v gh >/dev/null 2>&1 || die "gh missing after package install"
command -v git >/dev/null 2>&1 || die "git missing after package install"
fi
# --- docker ------------------------------------------------------------------
# Every tenant gets docker (the templates all carried it; staging's guests run
# their workloads in it). Docker's own installer, convergence-guarded — its
# script is not a no-op when docker exists, so rig supplies the guard.
if ! command -v docker >/dev/null 2>&1; then
log "installing docker (get.docker.com)"
curl -fsSL https://get.docker.com | sh
else
log "docker already installed"
fi
docker --version >/dev/null 2>&1 || die "docker installed but 'docker --version' does not answer"
# The client answering is not the effective state — a dead dockerd would still
# pass it. Ask the daemon, with a bounded settle for the freshly-installed case
# (get.docker.com starts it, but not instantaneously on a slow guest).
docker_up=""
for _ in 1 2 3 4 5 6; do
if docker info >/dev/null 2>&1; then docker_up=1; break; fi
sleep 5
done
[ -n "$docker_up" ] || die "dockerd does not answer 'docker info' after 30s — the daemon is not running; check 'systemctl status docker' (or the container's init) before re-running"
log "dockerd answering"
if getent group docker >/dev/null 2>&1; then
if id -nG "$TENANT_USER" | tr ' ' '\n' | grep -qx docker; then
log "${TENANT_USER} already in the docker group"
else
usermod -aG docker "$TENANT_USER"
log "added ${TENANT_USER} to the docker group"
fi
else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi
# --- node (claude, codex) ----------------------------------------------------
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified
# upstream when the template was written); the claude tenant ships node as part
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
node_ok() {
command -v node >/dev/null 2>&1 || return 1
local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null
}
if [ "$ROLE" = "claude" ] || [ "$ROLE" = "codex" ]; then
if node_ok; then
log "node $(node --version) already present"
else
log "installing node 22 (nodesource)"
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
node_ok || die "node >= 22 still missing after install — check the nodesource setup"
fi
# --- the agent CLI -----------------------------------------------------------
# Per-agent install, shared discipline: install only when the CLI is absent
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
# what cost the last drill (the grok template's scar).
CLI="" CLI_SRC=""
case "$ROLE" in
claude)
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Claude Code CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
else
log "claude CLI already installed"
fi ;;
codex)
CLI=codex
if ! command -v codex >/dev/null 2>&1; then
log "installing the Codex CLI (npm global)"
npm install -g @openai/codex
else
log "codex CLI already installed"
fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
grok)
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`,
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir.
# Run it AS the tenant user, never root: a symlink into root's 0700 home
# would be a CLI that exists and cannot run.
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Grok CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
else
log "grok CLI already installed"
fi ;;
staging) ;; # no agent lives on the staging tenant
esac
if [ -n "$CLI" ]; then
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# One capture serves both the assert and the log line; emptiness IS the
# failure signal (head exits 0 regardless, so a pipeline status can't be).
CLI_VER="$(runuser -l "$TENANT_USER" -c "$CLI --version" 2>/dev/null | head -n1)"
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
# The interactive-shell PATH exports the templates carried, converged as
# literal rc lines (written once, never duplicated). Single quotes are the
# point: the line must expand in the USER's shell, not here.
# shellcheck disable=SC2016
case "$ROLE" in
claude)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
codex)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
grok)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
esac
fi
# --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. Rendered from
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
# the drill inside a box; the box you are in is not a host you own") lives
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
# like every file rig converges.
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
CTX_DIR="$(dirname "$CTX_PATH")"
if [ ! -d "$CTX_DIR" ]; then
mkdir -p "$CTX_DIR"
log "created ${CTX_DIR}"
fi
# The dotdir is the AGENT's (it writes state next to its instructions), so
# its ownership is converged on every run, not only on creation.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
CTX_TMP="$(mktemp)"
render_tenant_context "$ROLE" > "$CTX_TMP"
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
log "agent-context file written: ${CTX_PATH}"
else
log "agent-context file already current"
fi
rm -f "$CTX_TMP"
fi
# --- claude shell niceties ---------------------------------------------------
# The claude template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a
# bootstrap whose real work (CLI, context, docker) already converged.
if [ "$ROLE" = "claude" ]; then
if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then
chsh -s /usr/bin/zsh "$TENANT_USER"
log "login shell set to zsh for ${TENANT_USER}"
else
log "login shell already zsh for ${TENANT_USER}"
fi
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
log "installing oh-my-zsh for ${TENANT_USER}"
# Single quotes on purpose: the $(...) must run in the USER's shell.
# shellcheck disable=SC2016
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
else
log "oh-my-zsh already installed"
fi
# After oh-my-zsh (it rewrites .zshrc on first install).
# shellcheck disable=SC2016
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
fi
# --- staging server posture --------------------------------------------------
# box#69's posture, minus the join: docker (above) + sshd hardening, through
# the SAME code the machine roles use (lib/sshd.sh) — the staging guest is a
# workload server in waiting, and its door must never be password-open even
# before the operator joins it. class=server: root SSH stays the control
# plane's future automation door.
if [ "$ROLE" = "staging" ]; then
harden_sshd server
fi
# --- role marker --------------------------------------------------------------
# Same ground truth the machine roles write, tenant-shaped: no class= (a tenant
# has no root-door policy of its own — close-root fails closed on it), and
# host=no so `rig users apply` box-role gating keeps working. staging SKIPS the
# write when a machine marker is already present: after the operator-run
# workload join, the workload marker is the truer statement and rig never
# clobbers state a joined box earned.
if [ -z "$EXISTING_MARKER" ] || [ "${EXISTING_MARKER#*class=}" = "$EXISTING_MARKER" ]; then
MARKER_TMP="$(mktemp)"
printf 'role=%s tenant=yes host=no\n' "$ROLE" > "$MARKER_TMP"
if ! cmp -s "$MARKER_TMP" "$MARKER_PATH" 2>/dev/null; then
mkdir -p "$(dirname "$MARKER_PATH")"
install -m 0644 "$MARKER_TMP" "$MARKER_PATH"
log "role marker written: role=$ROLE tenant=yes host=no"
else
log "role marker already current"
fi
rm -f "$MARKER_TMP"
else
log "machine role marker present (${EXISTING_MARKER}); leaving it alone"
fi
log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload --hostname <name> with a tagged pre-auth key"
else
log "next: creds stay with the operator — ${CLI} authenticates through its own interactive login when a human decides"
fi

View file

@ -6,6 +6,8 @@ set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/runner-config.sh
. "$HERE/lib/runner-config.sh" # json_field / json_string_array read the netmap
# shellcheck source=SCRIPTDIR/lib/sshd.sh
. "$HERE/lib/sshd.sh" # harden_sshd — shared with the staging tenant
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
@ -13,9 +15,11 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <control-plane|workload|runner|staging|dev|workstation|custom>
usage: rig bootstrap <control-plane|workload|runner|dev|workstation|custom>
[--hostname <name>] [--class <human|server>]
[--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude|codex|grok|staging> [--user <name>]
(the box TENANT roles — see their own --help)
--hostname system + tailnet hostname (default: the role name; custom has
no default and requires it)
@ -32,10 +36,13 @@ custom presets nothing and requires --hostname plus all three traits.
control-plane server no authkey
workload server no authkey
runner server no authkey
staging server yes authkey
dev human yes authkey
workstation human yes login
The former staging VM-host preset is now spelled through the traits:
'custom --class server --host yes --join authkey' (or 'dev --class server').
'staging' names the box TENANT role today — the guest, not the host.
The tailnet tag is NOT a rig argument. A pre-auth key is minted WITH its tags,
so the key is the single source of truth: rig no longer requests a tag it might
disagree with. After the box joins, rig reads the tag control actually GRANTED
@ -56,10 +63,16 @@ EOF
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
control-plane|workload|runner|staging|dev|workstation|custom) shift ;;
control-plane|workload|runner|dev|workstation|custom) shift ;;
claude|codex|grok|staging)
# The box TENANT roles (#31) are a different family — guests a box mints,
# never tailnet machines — and live in their own mechanism, one script
# parameterized per tenant. Dispatched here so `rig bootstrap <role>`
# stays the single entrypoint for both families.
exec "$HERE/bootstrap-tenant.sh" "$@" ;;
-h|--help) usage; exit 0 ;;
"") usage >&2; die "role required (control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;;
*) die "unknown role: $ROLE (want control-plane|workload|runner|staging|dev|workstation|custom)" 2 ;;
"") usage >&2; die "role required (control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
*) die "unknown role: $ROLE (want control-plane|workload|runner|dev|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
esac
# Role→traits map — the single place a role's shape is declared (issue #26).
@ -71,7 +84,6 @@ case "$ROLE" in
control-plane) CLASS=server HOST=no JOIN=authkey ;;
workload) CLASS=server HOST=no JOIN=authkey ;;
runner) CLASS=server HOST=no JOIN=authkey ;;
staging) CLASS=server HOST=yes JOIN=authkey ;;
dev) CLASS=human HOST=yes JOIN=authkey ;;
workstation) CLASS=human HOST=yes JOIN=login ;;
custom) ;;
@ -182,74 +194,13 @@ APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
# --- sshd hardening (restart only when the drop-in actually changed) ---------
# The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins
# ("for each keyword, the first obtained value will be used" — sshd_config(5)),
# and Include expands the glob in lexical order. Cloud images ship
# /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication
# yes`, so the old 99-rig.conf was read second and silently lost every keyword
# it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped
# was still serving `passwordauthentication yes`. The Incus rehearsal never
# caught it — a pristine Debian container has no cloud-init drop-in.)
DROPIN=/etc/ssh/sshd_config.d/00-rig.conf
LEGACY_DROPIN=/etc/ssh/sshd_config.d/99-rig.conf
TMP="$(mktemp)"
cat > "$TMP" <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
EOF
if ! cmp -s "$TMP" "$DROPIN" 2>/dev/null || [ -e "$LEGACY_DROPIN" ]; then
BACKUP=""
[ -e "$DROPIN" ] && { BACKUP="$(mktemp)"; cp -a "$DROPIN" "$BACKUP"; }
install -m 0644 "$TMP" "$DROPIN"
rm -f "$LEGACY_DROPIN" # sweep the losing file from already-bootstrapped boxes
# Validate the MERGED config BEFORE bouncing the daemon. On a box whose only
# door is SSH, `systemctl restart ssh` against a config sshd refuses to parse
# leaves no listener and no way back in. `sshd -t` parses everything sshd
# would parse — our drop-in, cloud-init's, and any third-party file — so a
# broken neighbour is caught here rather than after the door has shut.
if ! sshd -t 2>/dev/null; then
if [ -n "$BACKUP" ]; then cp -a "$BACKUP" "$DROPIN"; else rm -f "$DROPIN"; fi
rm -f "$TMP" "$BACKUP"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
fi
rm -f "$BACKUP"
systemctl restart ssh
log "sshd hardening drop-in installed"
else
log "sshd hardening drop-in already in place"
fi
rm -f "$TMP"
# Assert the EFFECTIVE config, not the file's existence — asserting the file is
# what let the first-wins bug ship green. `sshd -T` is what the daemon actually
# resolved, cloud-init and all.
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${DROPIN}; check ls /etc/ssh/sshd_config.d/"
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
# things on the two classes. class=human: `no` is the post-`rig users
# close-root` state — strictly harder than the prohibit-password this script
# installs. Bootstrap must never read a closed door as a broken one, and it
# cannot reopen one either: by first-wins its own drop-in loses to
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
# management silently dead, and the likely culprit is a drop-in left over from
# a former class=human life on a repurposed box. rig can DETECT that but must
# not FIX it: silently reopening a root door is worse than a loud stop, so —
# same doctrine as the tag checks — detect, refuse, and name the repair.
if [ "$CLASS" = "human" ]; then
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
else
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
fi
log "sshd hardening verified (sshd -T: passwordauthentication no)"
# --- sshd hardening ----------------------------------------------------------
# The whole block lives in lib/sshd.sh, shared with the staging TENANT role —
# one drop-in, one converger, never two copies drifting apart. Everything the
# block learned the hard way (00- beats cloud-init's 50- under first-wins,
# validate-then-restart, assert sshd -T not the file, the class-gated
# permitrootlogin acceptance) moved with it, verbatim.
harden_sshd "$CLASS"
# --- system hostname ----------------------------------------------------------
# Set the SYSTEM hostname too, not just the tailnet one. Until 2026-07-12 rig
@ -342,9 +293,11 @@ verify_effective_tag() {
control-plane|workload) ;;
runner)
die "role runner joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). The key you used grants tag:server to repo-controlled code; that must never happen. Re-run bootstrap with a key minted for a CI tag (e.g. tag:ci)." ;;
staging)
die "role staging joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). A staging host is never managed by the control plane — its guest VMs are. Re-run bootstrap with a key minted for tag:local." ;;
*)
# This arm now also owns the VM-host shape the old staging preset
# covered (custom/dev --class server): a host is never managed by the
# control plane — its guest VMs are — so tag:server is refused there
# like everywhere else outside control-plane|workload.
die "role $ROLE joined with tag:server (effective tags: $(printf '%s' "$tags" | tr '\n' ' ')). Only control-plane and workload are managed by the control plane; tag:server on this box extends every server grant to it. Re-run bootstrap with a key minted for a non-server tag (e.g. tag:local)." ;;
esac
fi

83
commands/lib/sshd.sh Normal file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Shared sshd hardening — sourced by bootstrap.sh (machine roles) and by
# bootstrap-tenant.sh (the staging tenant). Root-requiring, unlike the pure
# parsing libs: it converges /etc/ssh and bounces the daemon. Extracted so the
# two roles converging ONE drop-in stay literally the same code — two copies of
# a hardening block is drift by construction, the same law that keeps rig's
# hands off Incus. Callers provide log/warn/die.
# harden_sshd <human|server> — install the 00-rig.conf hardening drop-in,
# validate the merged config before touching the daemon, restart only when the
# drop-in actually changed, and assert the EFFECTIVE config (sshd -T), with the
# permitrootlogin acceptance gated on the class passed in.
harden_sshd() {
local class="$1"
local dropin=/etc/ssh/sshd_config.d/00-rig.conf
local legacy_dropin=/etc/ssh/sshd_config.d/99-rig.conf
local tmp backup eff
# The name must sort BEFORE cloud-init's drop-in. sshd_config is FIRST-wins
# ("for each keyword, the first obtained value will be used" — sshd_config(5)),
# and Include expands the glob in lexical order. Cloud images ship
# /etc/ssh/sshd_config.d/50-cloud-init.conf carrying `PasswordAuthentication
# yes`, so the old 99-rig.conf was read second and silently lost every keyword
# it set. 00- wins. (Found 2026-07-12: every Hetzner box rig had bootstrapped
# was still serving `passwordauthentication yes`. The Incus rehearsal never
# caught it — a pristine Debian container has no cloud-init drop-in.)
tmp="$(mktemp)"
cat > "$tmp" <<'EOF'
PermitRootLogin prohibit-password
PasswordAuthentication no
EOF
if ! cmp -s "$tmp" "$dropin" 2>/dev/null || [ -e "$legacy_dropin" ]; then
backup=""
[ -e "$dropin" ] && { backup="$(mktemp)"; cp -a "$dropin" "$backup"; }
install -m 0644 "$tmp" "$dropin"
rm -f "$legacy_dropin" # sweep the losing file from already-bootstrapped boxes
# Validate the MERGED config BEFORE bouncing the daemon. On a box whose only
# door is SSH, `systemctl restart ssh` against a config sshd refuses to parse
# leaves no listener and no way back in. `sshd -t` parses everything sshd
# would parse — our drop-in, cloud-init's, and any third-party file — so a
# broken neighbour is caught here rather than after the door has shut.
if ! sshd -t 2>/dev/null; then
if [ -n "$backup" ]; then cp -a "$backup" "$dropin"; else rm -f "$dropin"; fi
rm -f "$tmp" "$backup"
die "sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad."
fi
rm -f "$backup"
systemctl restart ssh
log "sshd hardening drop-in installed"
else
log "sshd hardening drop-in already in place"
fi
rm -f "$tmp"
# Assert the EFFECTIVE config, not the file's existence — asserting the file is
# what let the first-wins bug ship green. `sshd -T` is what the daemon actually
# resolved, cloud-init and all.
eff="$(sshd -T 2>/dev/null)" || die "sshd -T failed; refusing to claim a hardened box"
echo "$eff" | grep -qx 'passwordauthentication no' \
|| die "sshd still resolves passwordauthentication=yes — a drop-in is beating ${dropin}; check ls /etc/ssh/sshd_config.d/"
# The permitrootlogin acceptance is CLASS-gated, because `no` means opposite
# things on the two classes. class=human: `no` is the post-`rig users
# close-root` state — strictly harder than the prohibit-password this function
# installs. Hardening must never read a closed door as a broken one, and it
# cannot reopen one either: by first-wins its own drop-in loses to
# 00-rig-users.conf. class=server: root SSH is the control plane's automation
# door (Coolify SSHes in as root), so `no` is not hardening — it is fleet
# management silently dead, and the likely culprit is a drop-in left over from
# a former class=human life on a repurposed box. rig can DETECT that but must
# not FIX it: silently reopening a root door is worse than a loud stop, so —
# same doctrine as the tag checks — detect, refuse, and name the repair.
if [ "$class" = "human" ]; then
echo "$eff" | grep -qxE 'permitrootlogin (no|prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
elif echo "$eff" | grep -qx 'permitrootlogin no'; then
die "sshd resolves permitrootlogin=no, but this is a class=server box: root SSH is the control plane's automation door, and with it shut the fleet cannot manage this box. Likely cause: a leftover /etc/ssh/sshd_config.d/00-rig-users.conf from a former class=human life ('rig users close-root' ran here once). Remove that drop-in and re-run bootstrap."
else
echo "$eff" | grep -qxE 'permitrootlogin (prohibit-password|without-password)' \
|| die "sshd still permits root password login — check ls /etc/ssh/sshd_config.d/"
fi
log "sshd hardening verified (sshd -T: passwordauthentication no)"
}

View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Shared parameters for the box TENANT roles (claude, codex, grok, staging) —
# sourced by bootstrap-tenant.sh and by the test harness. Pure text→text, no
# side effects: the per-tenant differences live HERE, in one table, so the
# mechanism stays one script parameterized per tenant instead of four
# hand-maintained copies (repo precedent: parse_users_file, runner-config).
# tenant_user <role> — the user the box seed creates (box.env BOX_USER). The
# agent tenants are named after their agent; staging keeps box#69's `ops`.
tenant_user() {
case "$1" in
claude) printf 'claude' ;;
codex) printf 'codex' ;;
grok) printf 'grok' ;;
staging) printf 'ops' ;;
*) return 1 ;;
esac
}
# tenant_context_path <role> <home> — where the agent-context file lands. Each
# agent CLI reads its own instructions file from its own dotdir; staging has no
# agent and no context file (return 1).
tenant_context_path() {
case "$1" in
claude) printf '%s/.claude/CLAUDE.md' "$2" ;;
codex) printf '%s/.codex/AGENTS.md' "$2" ;;
grok) printf '%s/.grok/AGENTS.md' "$2" ;;
*) return 1 ;;
esac
}
# render_tenant_context <role> — the agent-context file's content, on stdout.
# One renderer for all three agents: only the creds paragraph is per-vendor,
# and the box#80 guard note lives HERE once — never copy-pasted per template.
# staging renders nothing (return 1): no agent lives there.
render_tenant_context() {
local role="$1" creds
# The single-quoted markdown below carries literal `$`-free backtick prose;
# single quotes are deliberate — nothing in it may expand here.
# shellcheck disable=SC2016
case "$role" in
claude)
creds='- **Creds-free by default.** The box starts with no Claude and no git
credentials. If you need to authenticate Claude, the operator runs `/login`
interactively. For git, the operator adds their own credentials (a PAT or
`gh auth login`). Never assume credentials are present; never ask for or
store secrets on disk beyond what the operator sets up.' ;;
codex)
creds='- **Creds-free by default.** The box starts with no OpenAI and no git
credentials. If you need to authenticate Codex, the operator runs the
login flow (`codex`) interactively. For git, the operator adds their own
credentials (a PAT or `gh auth login`). Never assume credentials are
present; never ask for or store secrets on disk beyond what the operator
sets up.' ;;
grok)
creds='- **Creds-free by default.** The box starts with no xAI and no git
credentials. If you need to authenticate, the operator runs
`grok login` interactively (SuperGrok / X Premium+). For git, the
operator adds their own credentials (a PAT or `gh auth login`). Never
assume credentials are present; never ask for or store secrets on disk
beyond what the operator sets up.' ;;
*) return 1 ;;
esac
cat <<EOF
# You are running inside a box (tenant: ${role})
A box is a trust-less, network-isolated, ephemeral VM created by the
\`box\` CLI. Keep this context in mind:
${creds}
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via \`box snapshot\`.
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
or the drill inside a box. The box you are in is not a host you own: a
nested box stack claims the guest's own uplink subnet and gateway, and
silently breaks this box's networking with intermittent egress blackouts
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
and testing never needs the host stack; host setup belongs to the operator's
machine, never this one.
- **Bootstrap runbook.** If the repository you are working in contains a
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
runbook — how to install dependencies, start services, template environment
files, seed data, and smoke-test — and follow it. It is documentation for
you, not a script the host runs.
EOF
}

View file

@ -47,15 +47,17 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr
"$ROOT/commands/bootstrap.sh" runner --ts-tag tag:server
check "bootstrap: --ts-tag is removed (no value), exit 2" 2 "comes from the pre-auth key" \
"$ROOT/commands/bootstrap.sh" runner --ts-tag
check "bootstrap: staging + removed --ts-tag exits 2" 2 "comes from the pre-auth key" \
# staging is a box TENANT role since #31 (the guest, not the VM host), and it
# never joins the tailnet — but --ts-tag on it must still die with a story,
# not an "unknown flag": scripts from its trait-preset life may pass it, and
# the message must say where both the tag AND the join went.
check "bootstrap: staging + removed --ts-tag exits 2" 2 "never join the tailnet" \
"$ROOT/commands/bootstrap.sh" staging --ts-tag tag:server
# The staging tag:server refusal rides the EFFECTIVE tag, inside
# verify_effective_tag — a path that needs a real tailnet, so it belongs to the
# rehearsal. What the harness CAN prove is that the refusal exists in the
# shipped script: grep the die message, so a deleted guard cannot ship green
# (the same reason the runner-install repo guard is grepped below).
check "bootstrap: staging effective-tag refusal is present" 0 "" \
grep -q "role staging joined with tag:server" "$ROOT/commands/bootstrap.sh"
# The old staging effective-tag refusal guarded the VM-HOST shape, which now
# rides the traits (custom/dev --class server) — the catch-all tag:server
# refusal must still own that shape, so grep the general die instead.
check "bootstrap: the catch-all tag:server refusal is present" 0 "" \
grep -q "Only control-plane and workload are managed by the control plane" "$ROOT/commands/bootstrap.sh"
# --- traits: roles are presets, every trait individually settable (#26) -----
check "bootstrap: unknown role still exits 2" 2 "unknown role" "$ROOT/commands/bootstrap.sh" potato
check "bootstrap: bad --class value exits 2" 2 "human|server" "$ROOT/commands/bootstrap.sh" workload --class potato
@ -178,7 +180,13 @@ check "README: points at heavy-duty/box" 0 "" \
if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner
check "bootstrap: staging role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" staging
# staging dispatches to the tenant mechanism now; reaching ITS root check
# through bootstrap.sh proves the dispatch and the tenant arg pass in one go.
# RIG_ROLE_MARKER points at an absent fixture: the tenant marker guard runs
# before the root check, and the machine running this harness may well have
# a real /etc/rig/role of its own.
check "bootstrap: staging dispatches to the tenant mechanism, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER=/nonexistent/rig-role "$ROOT/commands/bootstrap.sh" staging
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
@ -187,6 +195,137 @@ else
echo "skip: bootstrap non-root refusals (running as root)"
fi
# --- box tenant roles (#31): claude|codex|grok|staging ------------------------
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
# parameterized per tenant through lib/tenant-config.sh, dispatched from
# bootstrap.sh so `rig bootstrap <role>` stays the single entrypoint. The real
# converge needs root, a tenant user, and the network — the container
# rehearsal's job — so the harness proves what it can non-root: the whole
# arg/refusal surface, the pure parameter table, the rendered agent-context
# file (guard note included), and grep-pins on the shipped script.
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help
check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude --nope
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude --user
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude --user 'fo|o'
# The docker converge asserts the DAEMON answers, not just the client binary —
# a dead dockerd passing `docker --version` is the "linked but cannot run"
# scar in daemon form. Grep-pinned so the assert cannot ship deleted.
check "tenant: dockerd effective-state assert is present" 0 "" \
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
# The machine-role traits die with the tenant story, never "unknown flag" — an
# operator reaching for --hostname must learn where the trait family went.
check "tenant: trait flags die with the tenant story" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" claude --class human
check "tenant: --hostname dies the same way" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" staging --hostname my-guest
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant
# mechanism with args intact (--help reaching the TENANT usage proves both).
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude|codex|grok|staging" \
"$ROOT/commands/bootstrap.sh" claude --help
# The marker guard fires BEFORE the root check (repo precedent: the coolify
# marker warning), so the refusals are provable here off fixture markers. A
# VM host (host=yes) refuses for every tenant — and names the staging rename,
# because a pre-#31 staging HOST re-running its old command is exactly who
# lands here. An agent tenant refuses ANY machine-role box; staging tolerates
# ONLY class=server with host=no — that is the staging guest after its
# operator-run workload join, and re-converging it is what convergence is for.
# A non-server machine (class=human via custom) is NOT that guest, and server
# hardening would die at it with server-specific messaging — refuse instead.
TEN_FIX="$(mktemp -d)"
printf 'role=dev class=human host=yes join=authkey\n' > "$TEN_FIX/host"
printf 'role=workload class=server host=no join=authkey\n' > "$TEN_FIX/machine"
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/human"
printf 'role=claude tenant=yes host=no\n' > "$TEN_FIX/tenant"
check "tenant: staging refuses a non-server machine box" 1 "non-server machine role" \
env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging
check "tenant: refuses a host=yes box (a VM host is never a guest)" 1 "hosts VMs" \
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude
check "tenant: the host refusal names the old staging preset's new spelling" 1 "custom --class server --host yes" \
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging
check "tenant: an agent role refuses a machine-role box" 1 "never tailnet machines" \
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude
if [ "$(id -u)" -ne 0 ]; then
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before
# the root check, and the harness machine may carry a real /etc/rig/role.
check "tenant: claude parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude
check "tenant: codex parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex
check "tenant: grok parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok
check "tenant: staging tolerates a workload-joined guest's marker" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude
else
echo "skip: tenant non-root refusals (running as root)"
fi
rm -rf "$TEN_FIX"
# The per-tenant parameter table and the agent-context renderer are pure lib
# functions on purpose (repo precedent: parse_users_file, json_string_array):
# the CLI path to them sits behind root + a real tenant user, so the harness
# proves them here, sourced, non-root and network-free.
tuser() { bash -c 'set -euo pipefail
. "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; }
tpath() { bash -c 'set -euo pipefail
. "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; }
tctx() { bash -c 'set -euo pipefail
. "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; }
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude /home/claude
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex /home/codex
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok /home/grok
check "tenant params: staging has no context file" 1 "" tpath staging /home/ops
# The box#80 guard note lives ONCE, in the renderer, and every agent's file
# carries it — the layering decision's whole point: never per-template again.
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex
check "tenant context: grok names its login flow" 0 "grok login" tctx grok
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
# apt-installs incus. A grep that finds nothing (exit 1) is the pass.
check "tenant: never touches the tailnet" 1 "" \
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: non-interactive — nothing prompts" 1 "" \
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
# staging's posture rides the SAME hardening code as the machine roles — the
# shared lib call is the anti-drift property, so pin the call, not the words.
check "tenant: staging hardens through the shared sshd lib" 0 "" \
grep -qE '^[[:space:]]*harden_sshd server$' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: docker lands via docker's own installer" 0 "" \
grep -q "get.docker.com" "$ROOT/commands/bootstrap-tenant.sh"
# The #15 lesson pinned: 'box exec' shells read no rc files, so the CLI must
# land on the SYSTEM path — and a claimed install is verified, not trusted:
# it must ANSWER as the tenant user (the grok template's scar: linked but
# cannot run). The $CLI/$TENANT_USER are literals we grep for in the script.
# shellcheck disable=SC2016
check "tenant: the agent CLI lands on the system PATH" 0 "" \
grep -qF '/usr/local/bin/$CLI' "$ROOT/commands/bootstrap-tenant.sh"
# shellcheck disable=SC2016
check "tenant: the CLI install is verified as the tenant user" 0 "" \
grep -qF 'runuser -l "$TENANT_USER" -c "$CLI --version"' "$ROOT/commands/bootstrap-tenant.sh"
# Ordering is the safety property, as with bootstrap's marker-then-box assert:
# the tenant marker may only describe converges that already happened, so the
# write sits after the context-file converge. Defaults fail closed.
ten_ctx_at="$(grep -n 'agent-context file written' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
# shellcheck disable=SC2016
ten_marker_at="$(grep -nF 'install -m 0644 "$MARKER_TMP" "$MARKER_PATH"' "$ROOT/commands/bootstrap-tenant.sh" | head -n1 | cut -d: -f1)"
check "tenant: the marker write follows the context-file converge" \
0 "" test "${ten_ctx_at:-999999}" -lt "${ten_marker_at:-0}"
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version
@ -743,16 +882,28 @@ else
fi
# Bootstrap must read the closed door as hardened, not broken: `no` is the
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
# the widened assertion so a revert cannot ship green.
check "bootstrap: permitrootlogin assertion accepts the closed state" 0 "" \
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/bootstrap.sh"
# the widened assertion so a revert cannot ship green. The hardening block
# lives in lib/sshd.sh since #31 — ONE converger shared by the machine roles
# and the staging tenant — so the greps pin the lib, and a call-site grep pins
# that bootstrap actually runs it (a function nobody calls is not hardening).
check "sshd lib: permitrootlogin assertion accepts the closed state" 0 "" \
grep -qF "permitrootlogin (no|prohibit-password|without-password)" "$ROOT/commands/lib/sshd.sh"
# ...but only for class=human. On class=server a closed root door is a BROKEN
# box — root SSH is the control plane's automation door — and the usual cause
# is a 00-rig-users.conf left over from a former class=human life. The refusal
# must name that drop-in or the operator greps sshd configs blind; the path
# needs root + a doctored sshd, so grep the die message (repo precedent above).
check "bootstrap: class=server refusal names the stale close-root drop-in" 0 "" \
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/bootstrap.sh"
check "sshd lib: class=server refusal names the stale close-root drop-in" 0 "" \
grep -q "leftover /etc/ssh/sshd_config.d/00-rig-users.conf" "$ROOT/commands/lib/sshd.sh"
# Validate-then-apply survived the extraction: sshd -t on the merged config
# must still precede the restart (same idiom as the close-root ordering check).
libt_at="$(grep -nE '^[[:space:]]*if ! sshd -t' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)"
librestart_at="$(grep -nE '^[[:space:]]*systemctl restart ssh$' "$ROOT/commands/lib/sshd.sh" | head -n1 | cut -d: -f1)"
check "sshd lib: sshd -t precedes the ssh restart" \
0 "" test "${libt_at:-999999}" -lt "${librestart_at:-0}"
# shellcheck disable=SC2016
check "bootstrap: hardening runs through the shared lib" 0 "" \
grep -qE '^harden_sshd "\$CLASS"$' "$ROOT/commands/bootstrap.sh"
# The dump script ships to control-plane boxes as an embedded heredoc. A syntax
# error in it would be invisible here and would first surface at 04:00 on a live