forked from heavy-duty/rig
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:
commit
435693860a
7 changed files with 201 additions and 152 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
49
README.md
49
README.md
|
|
@ -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
13
bin/rig
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
test/cli.sh
125
test/cli.sh
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue