feat(bootstrap): the tenant mechanism converges from fetched definitions — the agent case arms are cut (#110)

bootstrap-tenant.sh is one mechanism parameterized by a registry definition
(template.env parsed against the allowlist, install.sh run as root with the
tenant in its environment, creds.md spliced into the shared context
skeleton). staging-box stays in-tree — mechanism-adjacent, no agent. The
dispatch in bootstrap.sh moves to the '-box' family-suffix rule so a
template added to the registry is mintable with zero code changes here; an
unknown role's refusal lists what the resolved source actually contains,
naming the source. lib/tenant-config.sh — the per-tenant case-arm table —
is deleted: the hard cut, #76's discipline.
This commit is contained in:
claude-bot-andresmgsl 2026-07-24 23:00:41 +00:00
parent c9c8ad9ba9
commit b99d08ea3d
4 changed files with 237 additions and 273 deletions

View file

@ -1,24 +1,34 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> — the box TENANT # rig bootstrap <role>-box — the box TENANT roles ('-box' names the family: a
# roles ('-box' names the family: a guest, vs the '-server' machine roles): what a # guest, vs the '-server' machine roles): what a box-minted guest becomes
# box-minted guest becomes (issue #31). box mints the thin, creds-free seed # (issue #31). box mints the thin, creds-free seed (base image, user, rig
# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the # preinstalled — heavy-duty/box#81); rig converges the tenant content that
# tenant content that used to live in the templates' cloud-init, idempotent and # used to live in the templates' cloud-init, idempotent and effective-state
# effective-state asserted, so an EXISTING box can be re-run to a new spec # asserted, so an EXISTING box can be re-run to a new spec instead of
# instead of re-minted. One mechanism, parameterized per tenant through # re-minted.
# lib/tenant-config.sh — never four hand-maintained copies. #
# One MECHANISM, parameterized per tenant by a fetched DEFINITION (#110): the
# agent-tenant registry lives in heavy-duty/rig-templates — one directory per
# role (template.env, install.sh, creds.md), resolved through lib/templates.sh
# (RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin) — so adding a
# tenant is a data PR there, never an edit here (#109 is the scar: adding
# kimi, pure data, meant editing six files in this repo). staging-box is the
# one in-tree tenant: it is mechanism-adjacent (sshd hardening, docker — no
# agent, no CLI, no context file), so it converges from rig's own tree.
# #
# Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig # Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig
# bootstrap claude-box'), so every path here is non-interactive and nothing joins # 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 # or admits — no tailnet, no keys, no prompts. That is also why the registry
# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the # fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
# creds split box#69 designed. # 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. # Convergent: safe to re-run; a second run changes nothing.
set -euo pipefail set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh # shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context . "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh # shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of . "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
# shellcheck source=SCRIPTDIR/lib/sshd.sh # shellcheck source=SCRIPTDIR/lib/sshd.sh
@ -32,26 +42,35 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
usage: rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>] usage: rig bootstrap <role>-box [--user <name>]
Box TENANT roles — what a box-minted guest becomes. box mints the thin, Box TENANT roles — what a box-minted guest becomes. box mints the thin,
creds-free seed (base image, user, rig preinstalled); this converges the 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. tenant on top, and re-runs converge an existing box to a new spec.
claude-box|codex-box|grok-box|kimi-box <role>-box an agent tenant DEFINED IN THE REGISTRY
the agent tenants: base tooling (git, gh, tmux, …), (heavy-duty/rig-templates — claude-box, codex-box,
grok-box, kimi-box, …): base tooling (git, gh, tmux, …),
docker, the agent's CLI on the system PATH, and the docker, the agent's CLI on the system PATH, and the
agent-context file — including the box#80 guard: never agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box. run `box setup-host` or the drill inside a box.
staging-box the server tenant (box#69's posture): docker + sshd staging-box the server tenant (box#69's posture), in rig's own
hardening. The tailnet workload join is deliberately tree: docker + sshd hardening. The tailnet workload
NOT here — it holds a credential, so it stays join is deliberately NOT here — it holds a credential,
operator-run: `box shell``sudo rig bootstrap so it stays operator-run: `box shell``sudo rig
workload-server` with a tagged pre-auth key. bootstrap workload-server` with a tagged pre-auth key.
--user <name> the tenant user the box seed created (default: the --user <name> the tenant user the box seed created (default: the
role's name minus the suffix; staging-box defaults to definition's USER; staging-box defaults to `ops`)
`ops`)
The registry source is three knobs, precedence high to low:
RIG_TEMPLATES_DIR a local folder (no fetch — the offline/test path, and
"try a template before it exists anywhere")
RIG_TEMPLATES_REF a ref of RIG_TEMPLATES_REPO (default
heavy-duty/rig-templates), fetched as a tarball
(neither set) the ref pinned in rig's tree (lib/templates.sh
RIG_TEMPLATES_PIN — bumped by ordinary rig PR, so a
rig release freezes the mechanism+registry pair)
Tenant roles are creds-free and non-interactive by contract — box auto-runs Tenant roles are creds-free and non-interactive by contract — box auto-runs
them at mint (`box exec … rig bootstrap claude-box`). They take none of the them at mint (`box exec … rig bootstrap claude-box`). They take none of the
@ -63,19 +82,29 @@ EOF
# --- args (validated before the root check, so errors are testable) --------- # --- args (validated before the root check, so errors are testable) ---------
ROLE="${1:-}" ROLE="${1:-}"
case "$ROLE" in case "$ROLE" in
claude-box|codex-box|grok-box|kimi-box|staging-box) shift ;; staging-box) shift ;;
*-box)
# The family suffix is the whole gate here — WHICH '-box' roles exist is
# the resolved registry's fact, checked below, so a template added to the
# registry is mintable with zero code changes in rig (#110).
shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; "") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;;
*) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; *) die "unknown tenant role: $ROLE — tenant roles carry the '-box' family suffix (#76); the machine roles are control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom" 2 ;;
esac esac
# The suffix rule above admits ANY '-box' name, so the charset is pinned
# before the name is ever used as a path component: a crafted role dies HERE,
# never in a registry lookup (the valid_version discipline, bin/rig).
[[ "$ROLE" =~ ^[a-z][a-z0-9-]*-box$ ]] \
|| die "invalid tenant role name: '$ROLE' — must match ^[a-z][a-z0-9-]*-box\$" 2
TENANT_USER="$(tenant_user "$ROLE")" TENANT_USER_OVERRIDE=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
--user) --user)
[ $# -ge 2 ] || die "--user needs a value" 2 [ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 2 ;; TENANT_USER_OVERRIDE="$2"; shift 2 ;;
--hostname|--root-door|--host|--join) --hostname|--root-door|--host|--join)
# The machine-role traits, refused with a story rather than "unknown # The machine-role traits, refused with a story rather than "unknown
# flag": a tenant is a guest, not a tailnet machine — its shape comes # flag": a tenant is a guest, not a tailnet machine — its shape comes
@ -89,6 +118,32 @@ while [ $# -gt 0 ]; do
*) die "unknown flag: $1" 2 ;; *) die "unknown flag: $1" 2 ;;
esac esac
done done
# --- the definition ----------------------------------------------------------
# Resolved and parsed BEFORE the root check, so the two refusals a definition
# can earn — unknown role (listing what the resolved source actually
# contains) and malformed data (naming the failing key) — are testable
# non-root, offline, via RIG_TEMPLATES_DIR fixtures. The parse is the mint's
# own guard, deliberately duplicating the registry CI's lint: CI protects the
# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that
# CI never saw. template.env is parsed, NEVER sourced — a definition cannot
# execute arbitrary shell through its data file; install.sh is the one
# deliberately executable part, and it runs only after the root check below.
trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT
TPL_DIR=""
if [ "$ROLE" = "staging-box" ]; then
TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops
else
templates_resolve \
|| die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2
TPL_DIR="$REGISTRY_DIR/$ROLE"
if [ ! -f "$TPL_DIR/template.env" ]; then
die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2
fi
template_parse_env "$TPL_DIR/template.env" \
|| die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2
TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}"
fi
# Same charset the users file enforces, for the same reasons (a leading '-' # Same charset the users file enforces, for the same reasons (a leading '-'
# reads as a usermod flag; '|', ':' corrupt things downstream). # reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ [[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
@ -178,19 +233,18 @@ append_line_once() {
export DEBIAN_FRONTEND=noninteractive export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})" log "installing base packages (tenant ${ROLE})"
apt-get update -qq apt-get update -qq
case "$ROLE" in if [ "$ROLE" = "staging-box" ]; then
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-box|grok-box|kimi-box)
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;;
staging-box)
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/, # openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM # which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it. # images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server ;; apt-get install -y -qq curl ca-certificates tmux openssh-server
esac else
# The shared agent toolbelt the templates carried, plus the definition's
# APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a
# word list, every word already vetted by the parser's package-name gate.
# shellcheck disable=SC2086
apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS
fi
# Assert the effective toolbelt, not apt's exit code — tmux is the box#65 # Assert the effective toolbelt, not apt's exit code — tmux is the box#65
# contract ('box tmux' runs tmux new-session inside every box) and gh is how # contract ('box tmux' runs tmux new-session inside every box) and gh is how
# the operator's git credential lands. # the operator's git credential lands.
@ -232,17 +286,19 @@ else
warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install" warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install"
fi fi
# --- node (claude-box, codex-box) ---------------------------------------------------- # --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified # An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
# upstream when the template was written); the claude-box tenant ships node as part # verified upstream when the template was written); claude ships node as part
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node. # of its toolbelt, same pin. Whether a tenant needs it is the DEFINITION's
# fact (NEEDS_NODE), never a role list here — grok's CLI is a self-contained
# binary and kimi's is uv-managed Python, so both say no.
node_ok() { node_ok() {
command -v node >/dev/null 2>&1 || return 1 command -v node >/dev/null 2>&1 || return 1
local major local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')" major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null [ "${major:-0}" -ge 22 ] 2>/dev/null
} }
if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
if node_ok; then if node_ok; then
log "node $(node --version) already present" log "node $(node --version) already present"
else else
@ -254,60 +310,53 @@ if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
fi fi
# --- the agent CLI ----------------------------------------------------------- # --- the agent CLI -----------------------------------------------------------
# Per-agent install, shared discipline: install only when the CLI is absent # Per-definition install, shared discipline: install only when the CLI is
# (upgrades are the CLI's own business), then put it on the SYSTEM path — # absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc # definition names one, `command -v` when it does not (an npm global's path
# files, so a PATH export alone is invisible to it (the #15 lesson) — and # is the prefix's fact, not the data file's) — then put it on the SYSTEM
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is # path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
# what cost the last drill (the grok-box template's scar). # 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-box template's scar).
#
# install.sh — the definition's one executable part — runs AS ROOT with the
# tenant named in its environment (TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE);
# each definition drops to the tenant user itself (runuser -l) where the
# vendor's layout demands it, because some installs are inherently root's
# (codex's npm -g writes the global prefix). This is the trade #110 states in
# bold — a registry definition executes as root inside every future mint —
# and it is why install.sh diffs there are the highest-trust review surface
# in the org, why the default ref is a reviewed in-tree pin, and why the data
# file beside it is parsed rather than sourced.
CLI="" CLI_SRC="" CLI="" CLI_SRC=""
case "$ROLE" in if [ "$ROLE" != "staging-box" ]; then
claude-box) CLI="$TPL_CLI_NAME"
CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude" # '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
if [ ! -e "$CLI_SRC" ]; then # substitution, never by the shell (hence the literal quoted tilde, SC2088).
log "installing the Claude Code CLI as ${TENANT_USER}" # shellcheck disable=SC2088
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash' case "$TPL_CLI_SRC" in
else '~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
log "claude CLI already installed" *) CLI_SRC="$TPL_CLI_SRC" ;;
fi ;;
codex-box)
CLI=codex
if ! command -v codex >/dev/null 2>&1; then
log "installing the Codex CLI (npm global)"
npm install -g @openai/codex
else
log "codex CLI already installed"
fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;;
grok-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
# would be a CLI that exists and cannot run.
CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Grok CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash'
else
log "grok CLI already installed"
fi ;;
kimi-box)
# The OFFICIAL installer (code.kimi.com/install.sh): a uv-managed Python
# tool (kimi-cli), landing `kimi` in ~/.local/bin — uv's tool bin — with
# uv bringing its own managed CPython, so no apt python pin here (the
# node section above stays claude/codex-only for the same reason). Run AS
# the tenant user, never root: grok's lesson — a root-owned install under
# a 0700 home is a CLI that exists and cannot run.
CLI=kimi CLI_SRC="$TENANT_HOME/.local/bin/kimi"
if [ ! -e "$CLI_SRC" ]; then
log "installing the Kimi CLI as ${TENANT_USER}"
runuser -l "$TENANT_USER" -c 'curl -LsSf https://code.kimi.com/install.sh | bash'
else
log "kimi CLI already installed"
fi ;;
staging-box) ;; # no agent lives on the staging-box tenant
esac esac
if [ -n "$CLI" ]; then installed=""
if [ -n "$CLI_SRC" ]; then
[ -e "$CLI_SRC" ] && installed=1
elif command -v "$CLI" >/dev/null 2>&1; then
installed=1
fi
if [ -z "$installed" ]; then
log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
bash "$TPL_DIR/install.sh" \
|| die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
else
log "${CLI} CLI already installed"
fi
if [ -z "$CLI_SRC" ]; then
CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
[ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
fi
[ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?" [ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI" ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# One capture serves both the assert and the log line; emptiness IS the # One capture serves both the assert and the log line; emptiness IS the
@ -316,29 +365,23 @@ if [ -n "$CLI" ]; then
[ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target" [ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target"
log "${CLI} CLI on the system PATH and answering (${CLI_VER})" log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
# The interactive-shell PATH exports the templates carried, converged as # The interactive-shell PATH export the templates carried, converged as a
# literal rc lines (written once, never duplicated). Single quotes are the # literal rc line (written once, never duplicated). The definition's
# point: the line must expand in the USER's shell, not here. # PATH_LINE is DATA, appended verbatim: it must expand in the USER's
# shellcheck disable=SC2016 # shell, not here.
case "$ROLE" in append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
claude-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
codex-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;;
grok-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;;
kimi-box)
append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;;
esac
fi fi
# --- the agent-context file -------------------------------------------------- # --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. Rendered from # The one file every agent reads before touching anything. The skeleton —
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or # including the box#80 guard note ("never run box setup-host or the drill
# the drill inside a box; the box you are in is not a host you own") lives # inside a box; the box you are in is not a host you own") — is MECHANISM,
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded # rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
# like every file rig converges. # template; only the creds paragraph is the definition's (creds.md).
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then # cmp-guarded like every file rig converges. staging-box has no agent and no
# context file.
if [ "$ROLE" != "staging-box" ]; then
CTX_PATH="$TENANT_HOME/$TPL_CONTEXT_PATH"
CTX_DIR="$(dirname "$CTX_PATH")" CTX_DIR="$(dirname "$CTX_PATH")"
if [ ! -d "$CTX_DIR" ]; then if [ ! -d "$CTX_DIR" ]; then
mkdir -p "$CTX_DIR" mkdir -p "$CTX_DIR"
@ -348,7 +391,7 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
# its ownership is converged on every run, not only on creation. # its ownership is converged on every run, not only on creation.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR" chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
CTX_TMP="$(mktemp)" CTX_TMP="$(mktemp)"
render_tenant_context "$ROLE" > "$CTX_TMP" render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP"
if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH" install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH"
log "agent-context file written: ${CTX_PATH}" log "agent-context file written: ${CTX_PATH}"
@ -358,32 +401,6 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
rm -f "$CTX_TMP" rm -f "$CTX_TMP"
fi fi
# --- 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-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}"
else
log "login shell already zsh for ${TENANT_USER}"
fi
if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then
log "installing oh-my-zsh for ${TENANT_USER}"
# Single quotes on purpose: the $(...) must run in the USER's shell.
# shellcheck disable=SC2016
runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \
|| warn "oh-my-zsh install failed — cosmetic only; continuing"
else
log "oh-my-zsh already installed"
fi
# After oh-my-zsh (it rewrites .zshrc on first install).
# shellcheck disable=SC2016
append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"'
append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on'
fi
# --- staging-box server posture -------------------------------------------------- # --- staging-box server posture --------------------------------------------------
# box#69's posture, minus the join: docker (above) + sshd hardening, through # box#69's posture, minus the join: docker (above) + sshd hardening, through
# the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a # the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a

View file

@ -29,9 +29,11 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
(--users <path> | --no-users) (--users <path> | --no-users)
[--hostname <name>] [--root-door <closed|open>] [--hostname <name>] [--root-door <closed|open>]
[--host <yes|no>] [--join <authkey|login>] [--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>] rig bootstrap <role>-box [--user <name>]
(the box TENANT roles — see their own --help; they take (the box TENANT roles — the agent tenants come from the
no --users, see below) heavy-duty/rig-templates registry, staging-box from
rig's own tree; see their own --help — they take no
--users, see below)
rig bootstrap --undo rig bootstrap --undo
leave the tailnet only when the role marker proves rig leave the tailnet only when the role marker proves rig
performed the join, then remove the role marker performed the join, then remove the role marker
@ -59,7 +61,8 @@ 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 in. So the complete path is the default path and skipping it is a deliberate
--no-users, not an omission. --no-users, not an omission.
--users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|kimi-box|staging-box). A --users does NOT reach the box TENANT roles (any '-box' name, e.g.
claude-box, staging-box). A
tenant is a box-minted GUEST: box auto-runs its bootstrap at mint, 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 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 and has no SSH door of its own — entry is `box shell`, gated by the HOST's
@ -122,15 +125,18 @@ case "$ROLE" in
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2 [ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
exec "$HERE/bootstrap-undo.sh" ;; exec "$HERE/bootstrap-undo.sh" ;;
control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;; control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;;
claude-box|codex-box|grok-box|kimi-box|staging-box) *-box)
# The box TENANT roles (#31) are a different family — guests a box mints, # The box TENANT roles (#31) are a different family — guests a box mints,
# never tailnet machines — and live in their own mechanism, one script # never tailnet machines — and live in their own mechanism, one script
# parameterized per tenant. Dispatched here so `rig bootstrap <role>` # parameterized per DEFINITION fetched from the template registry (#110;
# stays the single entrypoint for both families. # staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not
# an enumerated list: which '-box' roles exist is the registry's fact, so
# a template added there is mintable with zero code changes here.
# `rig bootstrap <role>` stays the single entrypoint for both families.
exec "$HERE/bootstrap-tenant.sh" "$@" ;; exec "$HERE/bootstrap-tenant.sh" "$@" ;;
-h|--help) usage; exit 0 ;; -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-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-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|kimi-box|staging-box)" 2 ;; *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
esac esac
# Role→traits map — the single place a role's shape is declared (issue #26). # Role→traits map — the single place a role's shape is declared (issue #26).

View file

@ -55,23 +55,27 @@ templates_source_desc() {
} }
# templates_resolve — resolve the three knobs to a LOCAL directory holding # templates_resolve — resolve the three knobs to a LOCAL directory holding
# the registry, printed on stdout. RIG_TEMPLATES_DIR wins and is used as-is; # the registry, left in the REGISTRY_DIR global (a global, not stdout: a
# otherwise the repo@ref tarball is fetched and extracted under a temp dir, # $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
# recorded in TEMPLATES_TMP for the caller to rm. Candidate URLs follow # the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
# install.sh's precedence — a tag outranks a branch that shares its name — # used as-is; otherwise the repo@ref tarball is fetched and extracted under
# plus the bare archive/<ref> form, which is how a commit-SHA pin (the # a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's
# default) downloads. Failure lists every URL tried: the fetch is # precedence — a tag outranks a branch that shares its name — plus the bare
# unauthenticated by contract (box auto-runs bootstrap at mint, holding # archive/<ref> form, which is how a commit-SHA pin (the default) downloads.
# nothing), so "is the repo public and the ref real" is the whole diagnosis. # Failure lists every URL tried: the fetch is unauthenticated by contract
# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public
# and the ref real" is the whole diagnosis.
TEMPLATES_TMP="" TEMPLATES_TMP=""
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
REGISTRY_DIR=""
templates_resolve() { templates_resolve() {
local repo ref url got="" d local repo ref url got=""
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
[ -d "$RIG_TEMPLATES_DIR" ] || { [ -d "$RIG_TEMPLATES_DIR" ] || {
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2 printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
return 1 return 1
} }
printf '%s\n' "$RIG_TEMPLATES_DIR" REGISTRY_DIR="$RIG_TEMPLATES_DIR"
return 0 return 0
fi fi
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
@ -104,8 +108,8 @@ templates_resolve() {
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2 printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
return 1 return 1
} }
d="${1%/}" # shellcheck disable=SC2034 # the function's output global, read by the sourcing script
printf '%s\n' "$d" REGISTRY_DIR="${1%/}"
} }
# templates_roles <registry-dir> — the roles a registry defines: its # templates_roles <registry-dir> — the roles a registry defines: its
@ -188,6 +192,49 @@ template_parse_env() {
esac esac
[ -n "$TPL_PATH_LINE" ] \ [ -n "$TPL_PATH_LINE" ] \
|| { printf 'template.env: PATH_LINE: must not be empty\n' >&2; return 1; } || { printf 'template.env: PATH_LINE: must not be empty\n' >&2; return 1; }
# Every word must be a sane package name — the list is handed to apt-get
# unquoted by design, and this is what keeps an option ('-o …') or a path
# from riding in through the data file.
local pkg
for pkg in $TPL_APT_EXTRAS; do
[[ "$pkg" =~ ^[a-z0-9][a-z0-9.+-]*$ ]] \
|| { printf 'template.env: APT_EXTRAS: not a sane package name: %s\n' "$pkg" >&2; return 1; }
done
}
# render_tenant_context <role> <creds.md> — the agent-context file's
# content, on stdout: the one file every agent reads before touching
# anything. The skeleton is MECHANISM and lives here once — the box#80 guard
# note ("never run box setup-host or the drill inside a box; the box you are
# in is not a host you own") must never be copy-pasted per template again —
# and only the creds paragraph is per-vendor DATA, spliced in from the
# definition's creds.md.
render_tenant_context() {
local role="$1" creds_file="$2"
cat <<EOF
# You are running inside a box (tenant: ${role})
A box is a trust-less, network-isolated, ephemeral VM created by the
\`box\` CLI. Keep this context in mind:
$(cat "$creds_file")
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via \`box snapshot\`.
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
or the drill inside a box. The box you are in is not a host you own: a
nested box stack claims the guest's own uplink subnet and gateway, and
silently breaks this box's networking with intermittent egress blackouts
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
and testing never needs the host stack; host setup belongs to the operator's
machine, never this one.
- **Bootstrap runbook.** If the repository you are working in contains a
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
runbook — how to install dependencies, start services, template environment
files, seed data, and smoke-test — and follow it. It is documentation for
you, not a script the host runs.
EOF
} }
# template_lint <role-dir> — the whole-definition check the registry repo's # template_lint <role-dir> — the whole-definition check the registry repo's

View file

@ -1,106 +0,0 @@
#!/usr/bin/env bash
# Shared parameters for the box TENANT roles (claude-box, codex-box, grok-box,
# kimi-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 (minus the suffix — the USER is not
# the role); staging-box keeps box#69's `ops`.
tenant_user() {
case "$1" in
claude-box) printf 'claude' ;;
codex-box) printf 'codex' ;;
grok-box) printf 'grok' ;;
kimi-box) printf 'kimi' ;;
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 (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-box) printf '%s/.claude/CLAUDE.md' "$2" ;;
codex-box) printf '%s/.codex/AGENTS.md' "$2" ;;
grok-box) printf '%s/.grok/AGENTS.md' "$2" ;;
# kimi documents only PROJECT-level AGENTS.md today (no global file); its
# dotdir is ~/.kimi (config.toml, sessions/, credentials/). The context
# file lands at the <dotdir>/AGENTS.md convention the other CLIs converged
# on, so it is where an operator (or a future global-read) will look — an
# honest placement, not a claim that the CLI auto-loads it.
kimi-box) printf '%s/.kimi/AGENTS.md' "$2" ;;
*) return 1 ;;
esac
}
# render_tenant_context <role> — the agent-context file's content, on stdout.
# One renderer for all four agents: only the creds paragraph is per-vendor,
# and the box#80 guard note lives HERE once — never copy-pasted per template.
# 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-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-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-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
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.' ;;
kimi-box)
creds='- **Creds-free by default.** The box starts with no Moonshot and no git
credentials. If you need to authenticate, the operator runs `kimi` and
its `/login` flow interactively (Kimi Code OAuth, or an API key). For
git, the operator adds their own credentials (a PAT or `gh auth login`).
Never assume credentials are present; never ask for or store secrets on
disk beyond what the operator sets up.' ;;
*) return 1 ;;
esac
cat <<EOF
# You are running inside a box (tenant: ${role})
A box is a trust-less, network-isolated, ephemeral VM created by the
\`box\` CLI. Keep this context in mind:
${creds}
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via \`box snapshot\`.
- **Not a host you own.** Never run \`box setup-host\`, \`box teardown-host\`,
or the drill inside a box. The box you are in is not a host you own: a
nested box stack claims the guest's own uplink subnet and gateway, and
silently breaks this box's networking with intermittent egress blackouts
(heavy-duty/box#80). Working ON the box repo from in here is fine — editing
and testing never needs the host stack; host setup belongs to the operator's
machine, never this one.
- **Bootstrap runbook.** If the repository you are working in contains a
\`.box/\` folder (older repos may use \`.claudebox/\`), read it as your setup
runbook — how to install dependencies, start services, template environment
files, seed data, and smoke-test — and follow it. It is documentation for
you, not a script the host runs.
EOF
}