Merge pull request #80 from dan-claude-bot/feat/tenant-role-suffixes

feat(bootstrap)!: box tenant roles carry a `-box` suffix
This commit is contained in:
Daniel Marin 2026-07-20 13:05:24 +01:00 committed by GitHub
commit 435693860a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 201 additions and 152 deletions

View file

@ -8,6 +8,29 @@ on the way to cutting its first release, and this file starts there.
### Changed
- **BREAKING: the box tenant roles carry a `-box` suffix** (#76) — the other
half of the rename below. `claude``claude-box`, `codex``codex-box`,
`grok``grok-box`, `staging``staging-box`, so a role name always says
which family it belongs to: `-server` builds a fleet machine, `-box`
converges a guest a box minted.
**The role carries the suffix; nothing inside the guest does.** A tenant user
is the account the box *seed* created (`BOX_USER`) and each agent CLI reads
its own dotdir, so `claude-box` still converges the `claude` user and still
writes `~/.claude/CLAUDE.md`. The suffix is rig's word for "this is a guest",
not a rename of anything the guest contains — no path, no account, and no CLI
binary moved.
**Migration: hard cut, no aliases**, same as the machine roles. The old names
are refused as unknown tenant roles at both entrypoints — `rig bootstrap
<name>` and the tenant script directly — and the suite asserts each one at
both, because an alias left in for a single tenant is exactly the shape that
survives review: the taxonomy reads complete while one old name still quietly
converges. The practical consequence is cross-repo: a box seed carrying
`BOX_BOOTSTRAP_ROLE="claude"` now fails its own mint-time bootstrap, so
heavy-duty/box#125 (closing heavy-duty/box#123) updates the seeds and must
land after this.
- **BREAKING: machine roles carry a `-server` suffix, and the VM host gets its
name back** (#76) — rig builds two kinds of thing that sit on opposite sides
of a trust boundary: tailnet **machines** it converges, and **guests** a box

View file

@ -3,8 +3,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. 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).
guest, the same verb converges the **box tenants** — claude-box, codex-box,
grok-box, staging-box — 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
@ -93,9 +94,9 @@ itself is untouched — what bootstrap converged stays converged.
### `rig bootstrap <control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom>`
Run as root on the fresh box (over SSH). Convergent — safe to re-run; a
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.)
second run changes nothing. (The box TENANT roles — `claude-box`, `codex-box`,
`grok-box`, `staging-box` — share the verb but are their own family; the
`-box` suffix says so. See *the box tenants* below.)
```sh
rig bootstrap control-plane-server --hostname my-coolify-box --users ./users
@ -167,8 +168,8 @@ its own account; the box CLI's own installer does that (see the `host`
trait), and every other way that step can fail lands in apply's existing
refusal at the end.
`--users` does **not** reach the box TENANT roles (`claude`, `codex`, `grok`,
`staging`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at
`--users` does **not** reach the box TENANT roles (`claude-box`, `codex-box`,
`grok-box`, `staging-box`). A tenant is a box-minted *guest*: box auto-runs its bootstrap at
mint, non-interactively, with no file to hand it; the guest never joins the
tailnet and has no SSH door of its own — you enter with `box shell`, gated by
the **host's** `incus` grants, which the host's own users file already
@ -373,15 +374,15 @@ 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
### `rig bootstrap <claude-box|codex-box|grok-box|staging-box>` — 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
rig bootstrap claude-box # or codex-box, grok-box — the agent tenants
rig bootstrap staging-box # the server tenant (docker + sshd hardening)
rig bootstrap claude-box --user dev # when the seed's BOX_USER differs
```
**The layering** (rig#31 ↔ box#81): a box template stops being where tenant
@ -397,12 +398,18 @@ 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) |
| tenant role | user | what lands |
|---------------|----------|------------|
| `claude-box` | `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-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` |
| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` |
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `class=server` acceptance) |
**The role carries the suffix; the user does not.** A tenant user is the
account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
hangs off it — `claude-box` converges the `claude` user and writes
`~/.claude/CLAUDE.md`. The suffix is rig's word for "this is a guest", not a
rename of anything inside the box, so nothing in the guest's filesystem moved.
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
@ -412,14 +419,14 @@ hardening. The CLI also lands on the **system** PATH (`/usr/local/bin`):
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
(`box exec … rig bootstrap claude-box`), 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-server --hostname
<name> --users <path>` (or `--no-users` — a guest's door is `box shell`, gated
by the host's grants) with a single-use tagged pre-auth key. After that join, re-running
`rig bootstrap staging` still converges docker + hardening and leaves the
`rig bootstrap staging-box` still converges docker + hardening and leaves the
workload marker alone — the machine role is the truer statement of what the
box became.
@ -1033,6 +1040,6 @@ the real converge belongs to the rehearsal. The end-to-end rehearsal is a
throwaway VM/container: pristine Debian → install → `bootstrap workload-server` 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
is the same shape, creds-free: container + seed user → `rig bootstrap claude-box`
/ `staging-box` → assert the CLI answers, docker answers, `sshd -T`, the context
file — then re-run and watch it no-op.

13
bin/rig
View file

@ -33,13 +33,14 @@ commands:
tenant roles); custom and workstation take none. 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
bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
The box TENANT roles: converge a box-minted guest. The '-box' suffix
names the family (a guest, vs the '-server' machine roles above).
claude-box|codex-box|grok-box 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-server'. Creds-free and non-interactive: box
never run box setup-host inside a box). staging-box lands box#69's
server posture — docker + sshd hardening; its tailnet join stays
operator-run via 'rig bootstrap workload-server'. 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.

View file

@ -1,5 +1,6 @@
#!/usr/bin/env bash
# rig bootstrap <claude|codex|grok|staging> — the box TENANT roles: what a
# rig bootstrap <claude-box|codex-box|grok-box|staging-box> — the box TENANT
# roles ('-box' names the family: a guest, vs the '-server' machine 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
@ -8,8 +9,8 @@
# 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
# bootstrap claude-box'), so every path here is non-interactive and nothing joins
# or admits — no tailnet, no keys, no prompts. staging-box's tailnet join stays
# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the
# creds split box#69 designed.
# Convergent: safe to re-run; a second run changes nothing.
@ -21,7 +22,7 @@ HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# 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)
. "$HERE/lib/sshd.sh" # harden_sshd (the staging-box tenant)
log() { printf 'rig-bootstrap: %s\n' "$*"; }
warn() { printf 'rig-bootstrap: WARNING: %s\n' "$*" >&2; }
@ -29,27 +30,29 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig bootstrap <claude|codex|grok|staging> [--user <name>]
usage: rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--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, …),
claude-box|codex-box|grok-box
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
staging-box 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-server` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the
role's name; staging defaults to `ops`)
role's name minus the suffix; staging-box 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
them at mint (`box exec … rig bootstrap claude-box`). 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
@ -58,10 +61,10 @@ EOF
# --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}"
case "$ROLE" in
claude|codex|grok|staging) shift ;;
claude-box|codex-box|grok-box|staging-box) 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 ;;
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|staging-box)" 2 ;;
*) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|staging-box)" 2 ;;
esac
TENANT_USER="$(tenant_user "$ROLE")"
@ -74,13 +77,13 @@ while [ $# -gt 0 ]; do
--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
# from the box seed, and the one trait-shaped thing a staging-box 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-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 2 ;;
die "tenant roles have no traits: $1 belongs to the machine roles (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom). A tenant box's shape comes from its seed; staging-box's tailnet join is operator-run via 'rig bootstrap workload-server'. The METAL that hosts these guests is 'rig bootstrap staging-server'." 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-server', where the tag comes from the pre-auth key." 2 ;;
die "--ts-tag is gone and tenant roles never join the tailnet anyway. staging-box's join is operator-run via 'rig bootstrap workload-server', where the tag comes from the pre-auth key." 2 ;;
*) die "unknown flag: $1" 2 ;;
esac
done
@ -97,28 +100,28 @@ done
# 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.
# Names the staging PAIR out loud, because whoever lands here has the two
# halves confused: the metal is `staging-server`, the guest `staging-box`.
# - 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
# - class=server with host=no (staging-box only) → PROCEED, and leave the
# marker alone: that is the 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
# machine rig built on purpose, and staging-box 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')." ;;
die "this box hosts VMs (${EXISTING_MARKER}) — a tenant role converges box GUESTS, never the host under them. You want the other half of the pair: the metal is 'rig bootstrap staging-server', and the guests it mints are 'staging-box'." ;;
*class=*)
if [ "$ROLE" != "staging" ]; then
if [ "$ROLE" != "staging-box" ]; 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." ;;
die "this box carries a non-server machine role (${EXISTING_MARKER}) — staging-box tolerates only the workload-joined guest (class=server host=no). If this really is a staging-box guest, remove ${MARKER_PATH} and re-run." ;;
esac ;;
esac
@ -161,13 +164,13 @@ 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
claude-box)
# The claude-box 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)
codex-box|grok-box)
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
staging)
staging-box)
# 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.
@ -177,13 +180,13 @@ esac
# 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
if [ "$ROLE" != "staging-box" ]; 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
# Every tenant gets docker (the templates all carried it; staging-box's workloads 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
@ -214,9 +217,9 @@ else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi
# --- node (claude, codex) ----------------------------------------------------
# --- node (claude-box, codex-box) ----------------------------------------------------
# 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
# upstream when the template was written); the claude-box 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
@ -224,7 +227,7 @@ node_ok() {
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 [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
if node_ok; then
log "node $(node --version) already present"
else
@ -241,10 +244,10 @@ fi
# '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).
# what cost the last drill (the grok-box template's scar).
CLI="" CLI_SRC=""
case "$ROLE" in
claude)
claude-box)
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Claude Code CLI as ${TENANT_USER}"
@ -252,7 +255,7 @@ case "$ROLE" in
else
log "claude CLI already installed"
fi ;;
codex)
codex-box)
CLI=codex
if ! command -v codex >/dev/null 2>&1; then
log "installing the Codex CLI (npm global)"
@ -261,7 +264,7 @@ case "$ROLE" in
log "codex CLI already installed"
fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
grok)
grok-box)
# 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
@ -273,7 +276,7 @@ case "$ROLE" in
else
log "grok CLI already installed"
fi ;;
staging) ;; # no agent lives on the staging tenant
staging-box) ;; # no agent lives on the staging-box tenant
esac
if [ -n "$CLI" ]; then
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
@ -289,11 +292,11 @@ if [ -n "$CLI" ]; then
# point: the line must expand in the USER's shell, not here.
# shellcheck disable=SC2016
case "$ROLE" in
claude)
claude-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
codex)
codex-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
grok)
grok-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
esac
fi
@ -324,11 +327,11 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
rm -f "$CTX_TMP"
fi
# --- claude shell niceties ---------------------------------------------------
# The claude template shipped zsh + oh-my-zsh + tmux mouse mode; they move with
# --- claude-box shell niceties ---------------------------------------------------
# The claude-box 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 [ "$ROLE" = "claude-box" ]; 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}"
@ -350,20 +353,20 @@ if [ "$ROLE" = "claude" ]; then
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
fi
# --- staging server posture --------------------------------------------------
# --- staging-box 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
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box 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
if [ "$ROLE" = "staging-box" ]; 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
# host=no so `rig users apply` box-role gating keeps working. staging-box 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.
@ -383,7 +386,7 @@ else
fi
log "done — tenant ${ROLE}, user ${TENANT_USER}"
if [ "$ROLE" = "staging" ]; then
if [ "$ROLE" = "staging-box" ]; then
log "next (operator-run, holds a credential): box shell → sudo rig bootstrap workload-server --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"

View file

@ -27,7 +27,7 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
(--users <path> | --no-users)
[--hostname <name>] [--class <human|server>]
[--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude|codex|grok|staging> [--user <name>]
rig bootstrap <claude-box|codex-box|grok-box|staging-box> [--user <name>]
(the box TENANT roles — see their own --help; they take
no --users, see below)
@ -51,7 +51,7 @@ and per-human accounts keep attribution intact for the times someone does go
in. So the complete path is the default path and skipping it is a deliberate
--no-users, not an omission.
--users does NOT reach the box TENANT roles (claude|codex|grok|staging). A
--users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|staging-box). A
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint,
non-interactively, with no file to hand it; the guest never joins the tailnet
and has no SSH door of its own — entry is `box shell`, gated by the HOST's
@ -109,15 +109,15 @@ EOF
ROLE="${1:-}"
case "$ROLE" in
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
claude|codex|grok|staging)
claude-box|codex-box|grok-box|staging-box)
# 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-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude|codex|grok|staging)" 2 ;;
"") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|staging-box)" 2 ;;
*) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|staging-box)" 2 ;;
esac
# Role→traits map — the single place a role's shape is declared (issue #26).

View file

@ -1,30 +1,34 @@
#!/usr/bin/env bash
# Shared parameters for the box TENANT roles (claude, codex, grok, staging) —
# Shared parameters for the box TENANT roles (claude-box, codex-box, grok-box,
# staging-box) — the '-box' suffix names the FAMILY (a guest a box mints, vs the
# '-server' machine roles rig converges); see #76.
# 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`.
# agent tenants are named after their agent (minus the suffix — the USER is not
# the role); staging-box keeps box#69's `ops`.
tenant_user() {
case "$1" in
claude) printf 'claude' ;;
codex) printf 'codex' ;;
grok) printf 'grok' ;;
staging) printf 'ops' ;;
claude-box) printf 'claude' ;;
codex-box) printf 'codex' ;;
grok-box) printf 'grok' ;;
staging-box) 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).
# agent CLI reads its own instructions file from its own dotdir (named for the
# agent, not the role — the dotdir is the CLI's, and the suffix is rig's);
# staging-box 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" ;;
claude-box) printf '%s/.claude/CLAUDE.md' "$2" ;;
codex-box) printf '%s/.codex/AGENTS.md' "$2" ;;
grok-box) printf '%s/.grok/AGENTS.md' "$2" ;;
*) return 1 ;;
esac
}
@ -32,27 +36,27 @@ tenant_context_path() {
# 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.
# staging-box 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)
claude-box)
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)
codex-box)
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)
grok-box)
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

View file

@ -47,12 +47,12 @@ check "bootstrap: --ts-tag is removed (with value), exit 2" 2 "comes from the pr
"$ROOT/commands/bootstrap.sh" runner-server --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-server --ts-tag
# staging is a box TENANT role since #31 (the guest, not the VM host), and it
# staging-box is a box TENANT role (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
check "bootstrap: staging-box + removed --ts-tag exits 2" 2 "never join the tailnet" \
"$ROOT/commands/bootstrap.sh" staging-box --ts-tag tag:server
# The VM-HOST shape has a named role again (staging-server, #76), but it is
# still not one of the two the control plane manages, so the catch-all
# tag:server refusal must own it. Grep-pinned so a deleted guard cannot ship
@ -80,8 +80,8 @@ fi
# UNKNOWN rather than quietly resolving to anything. Asserted per name because
# an alias accidentally left in for one role is exactly the shape that survives
# review — the taxonomy reads as complete while one old name still works.
# 'staging' is deliberately absent: it still routes to the TENANT mechanism at
# this point in the stack, and the tenant rename lands in its own change.
# 'staging' is deliberately absent HERE: it is a TENANT name, and its own hard
# cut is asserted in the tenant section below, at both entrypoints.
for r in control-plane workload runner dev; do
check "bootstrap: the pre-#76 name '$r' is gone (hard cut)" 2 "unknown role" \
"$ROOT/commands/bootstrap.sh" "$r"
@ -416,7 +416,7 @@ check "rig usage documents the bootstrap users flags" 0 "(--users <path> | --no-
# decision (a box-minted guest has no SSH door of its own; entry is `box shell`,
# gated by the HOST's incus grants) is documented in usage and the README.
check "bootstrap: --users does not reach the tenant roles" 2 "unknown flag" \
"$ROOT/commands/bootstrap.sh" claude --users "$BOOT_USERS/ok"
"$ROOT/commands/bootstrap.sh" claude-box --users "$BOOT_USERS/ok"
check "bootstrap: usage explains why tenants take no --users" 0 "box-minted GUEST" \
"$ROOT/commands/bootstrap.sh" --help
# --- README: the box rename (#12) --------------------------------------------
@ -451,13 +451,13 @@ if [ "$(id -u)" -ne 0 ]; then
check "bootstrap: --users file reaches the root check" 1 "must run as root" \
env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload-server --users "$BOOT_USERS/ok"
check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner-server --no-users
# staging dispatches to the tenant mechanism now; reaching ITS root check
# staging-box dispatches to the tenant mechanism; 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: staging-box 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-box
check "bootstrap: dev role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" dev-server --no-users
check "bootstrap: workstation parses, refuses non-root" 1 "must run as root" env -u TS_AUTHKEY "$ROOT/commands/bootstrap.sh" workstation --no-users
check "bootstrap: custom parses, refuses non-root" 1 "must run as root" \
@ -466,7 +466,7 @@ else
echo "skip: bootstrap non-root refusals (running as root)"
fi
# --- box tenant roles (#31): claude|codex|grok|staging ------------------------
# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|staging-box ---
# 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
@ -474,12 +474,23 @@ fi
# 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.
# THE HARD CUT, tenant half (#76). The pre-rename names are gone and must fail
# as UNKNOWN — asserted per name, because an alias left in for one tenant is the
# shape that survives review: the taxonomy reads complete while one old name
# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has
# its own dispatch list and a name could survive in one and not the other.
for r in claude codex grok staging; do
check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \
"$ROOT/commands/bootstrap-tenant.sh" "$r"
check "tenant: the pre-#76 name '$r' is gone (bootstrap dispatch)" 2 "unknown role" \
"$ROOT/commands/bootstrap.sh" "$r"
done
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'
check "tenant: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/bootstrap-tenant.sh" claude-box --nope
check "tenant: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user
check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --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.
@ -488,19 +499,19 @@ check "tenant: dockerd effective-state assert is present" 0 "" \
# 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
"$ROOT/commands/bootstrap-tenant.sh" claude-box --class human
check "tenant: --hostname dies the same way" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" staging --hostname my-guest
"$ROOT/commands/bootstrap-tenant.sh" staging-box --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
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|staging-box" \
"$ROOT/commands/bootstrap.sh" claude-box --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
# VM host (host=yes) refuses for every tenant — and names the staging PAIR,
# because whoever lands here has the two halves confused and wants the metal
# (staging-server). An agent tenant refuses ANY machine-role box; staging-box
# tolerates ONLY class=server with host=no — that is the 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.
@ -508,28 +519,28 @@ TEN_FIX="$(mktemp -d)"
printf 'role=dev-server class=human host=yes join=authkey\n' > "$TEN_FIX/host"
printf 'role=workload-server 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
printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
check "tenant: staging-box refuses a non-server machine box" 1 "non-server machine role" \
env RIG_ROLE_MARKER="$TEN_FIX/human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
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
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" claude-box
check "tenant: the host refusal sends you to the metal half of the pair" 1 "staging-server" \
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box
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
env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
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: claude-box parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box
check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box
check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box
check "tenant: staging-box 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-box
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
env RIG_ROLE_MARKER="$TEN_FIX/tenant" "$ROOT/commands/bootstrap-tenant.sh" claude-box
else
echo "skip: tenant non-root refusals (running as root)"
fi
@ -545,24 +556,24 @@ 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
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok
check "tenant params: staging has no context file" 1 "" tpath staging-box /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
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box
check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box
# 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.
@ -572,15 +583,15 @@ 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
# staging-box'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 "" \
check "tenant: staging-box 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
# it must ANSWER as the tenant user (the grok-box 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 "" \
@ -1473,7 +1484,7 @@ fi
# post-close-root state, strictly harder than what bootstrap installs. Byte-grep
# 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
# and the staging-box 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"