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
# rig bootstrap <claude-box|codex-box|grok-box|kimi-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
# effective-state asserted, so an EXISTING box can be re-run to a new spec
# instead of re-minted. One mechanism, parameterized per tenant through
# lib/tenant-config.sh — never four hand-maintained copies.
# rig bootstrap <role>-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 effective-state
# asserted, so an EXISTING box can be re-run to a new spec instead of
# re-minted.
#
# 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
# 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.
# or admits — no tailnet, no keys, no prompts. That is also why the registry
# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with.
# 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.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/tenant-config.sh
. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context
# shellcheck source=SCRIPTDIR/lib/users-config.sh
. "$HERE/lib/users-config.sh" # read_role_marker / root_door_of
# shellcheck source=SCRIPTDIR/lib/sshd.sh
@ -32,26 +42,35 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
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,
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-box|codex-box|grok-box|kimi-box
the agent tenants: base tooling (git, gh, tmux, …),
<role>-box an agent tenant DEFINED IN THE REGISTRY
(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
agent-context file — including the box#80 guard: never
run `box setup-host` or the drill inside a box.
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.
staging-box the server tenant (box#69's posture), in rig's own
tree: 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 minus the suffix; staging-box defaults to
`ops`)
definition's USER; staging-box defaults to `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
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) ---------
ROLE="${1:-}"
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 ;;
"") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;;
*) die "unknown tenant role: $ROLE (want 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 — 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
# 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
case "$1" in
-h|--help) usage; exit 0 ;;
--user)
[ $# -ge 2 ] || die "--user needs a value" 2
TENANT_USER="$2"; shift 2 ;;
TENANT_USER_OVERRIDE="$2"; shift 2 ;;
--hostname|--root-door|--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
@ -89,6 +118,32 @@ while [ $# -gt 0 ]; do
*) die "unknown flag: $1" 2 ;;
esac
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 '-'
# reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
@ -178,19 +233,18 @@ append_line_once() {
export DEBIAN_FRONTEND=noninteractive
log "installing base packages (tenant ${ROLE})"
apt-get update -qq
case "$ROLE" in
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)
if [ "$ROLE" = "staging-box" ]; then
# openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/,
# which only exists once the package is installed — pristine container/VM
# images (and thin seeds) do not ship it.
apt-get install -y -qq curl ca-certificates tmux openssh-server ;;
esac
apt-get install -y -qq curl ca-certificates tmux openssh-server
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
# contract ('box tmux' runs tmux new-session inside every box) and gh is how
# 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"
fi
# --- 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-box tenant ships node as part
# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node.
# --- node (definitions carrying NEEDS_NODE="yes") ----------------------------
# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex,
# verified upstream when the template was written); claude ships node as part
# 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() {
command -v node >/dev/null 2>&1 || return 1
local major
major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')"
[ "${major:-0}" -ge 22 ] 2>/dev/null
}
if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then
if node_ok; then
log "node $(node --version) already present"
else
@ -254,60 +310,53 @@ if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then
fi
# --- the agent CLI -----------------------------------------------------------
# Per-agent install, shared discipline: install only when the CLI is absent
# (upgrades are the CLI's own business), then put it on the SYSTEM path —
# 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads no rc
# files, so a PATH export alone is invisible to it (the #15 lesson) — and
# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is
# what cost the last drill (the grok-box template's scar).
# Per-definition install, shared discipline: install only when the CLI is
# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the
# definition names one, `command -v` when it does not (an npm global's path
# is the prefix's fact, not the data file's) — then put it on the SYSTEM
# path: 'box exec <box> -- <cli> …' runs a NON-interactive shell that reads
# no rc files, so a PATH export alone is invisible to it (the #15 lesson).
# And assert it ANSWERS as the tenant user: a CLI that exists but cannot run
# is what cost the last drill (the grok-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=""
case "$ROLE" in
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}"
runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash'
else
log "claude CLI already installed"
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"
if [ "$ROLE" != "staging-box" ]; then
CLI="$TPL_CLI_NAME"
# '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string
# substitution, never by the shell (hence the literal quoted tilde, SC2088).
# shellcheck disable=SC2088
case "$TPL_CLI_SRC" in
'~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;;
*) CLI_SRC="$TPL_CLI_SRC" ;;
esac
installed=""
if [ -n "$CLI_SRC" ]; then
[ -e "$CLI_SRC" ] && installed=1
elif command -v "$CLI" >/dev/null 2>&1; then
installed=1
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'
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 "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
if [ -n "$CLI" ]; then
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?"
ln -sf "$CLI_SRC" "/usr/local/bin/$CLI"
# 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"
log "${CLI} CLI on the system PATH and answering (${CLI_VER})"
# The interactive-shell PATH exports the templates carried, converged as
# literal rc lines (written once, never duplicated). Single quotes are the
# point: the line must expand in the USER's shell, not here.
# shellcheck disable=SC2016
case "$ROLE" in
claude-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
# The interactive-shell PATH export the templates carried, converged as a
# literal rc line (written once, never duplicated). The definition's
# PATH_LINE is DATA, appended verbatim: it must expand in the USER's
# shell, not here.
append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE"
fi
# --- the agent-context file --------------------------------------------------
# The one file every agent reads before touching anything. Rendered from
# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or
# the drill inside a box; the box you are in is not a host you own") lives
# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded
# like every file rig converges.
if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then
# The one file every agent reads before touching anything. The skeleton —
# including 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") — is MECHANISM,
# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per
# template; only the creds paragraph is the definition's (creds.md).
# 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")"
if [ ! -d "$CTX_DIR" ]; then
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.
chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR"
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
install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$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"
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 --------------------------------------------------
# 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

View file

@ -29,9 +29,11 @@ usage: rig bootstrap <control-plane-server|workload-server|runner-server|
(--users <path> | --no-users)
[--hostname <name>] [--root-door <closed|open>]
[--host <yes|no>] [--join <authkey|login>]
rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>]
(the box TENANT roles — see their own --help; they take
no --users, see below)
rig bootstrap <role>-box [--user <name>]
(the box TENANT roles — the agent tenants come from the
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
leave the tailnet only when the role marker proves rig
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
--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,
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
@ -122,15 +125,18 @@ case "$ROLE" in
[ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2
exec "$HERE/bootstrap-undo.sh" ;;
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,
# 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.
# parameterized per DEFINITION fetched from the template registry (#110;
# 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" "$@" ;;
-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 ;;
*) 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 ;;
"") 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 '-box' tenant role from the template registry, e.g. claude-box)" 2 ;;
esac
# 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
# the registry, printed on stdout. RIG_TEMPLATES_DIR wins and is used as-is;
# otherwise the repo@ref tarball is fetched and extracted under a temp dir,
# recorded in TEMPLATES_TMP for the caller to rm. Candidate URLs follow
# install.sh's precedence — a tag outranks a branch that shares its name —
# plus the bare archive/<ref> form, which is how a commit-SHA pin (the
# default) downloads. 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.
# the registry, left in the REGISTRY_DIR global (a global, not stdout: a
# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP,
# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is
# used as-is; otherwise the repo@ref tarball is fetched and extracted under
# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's
# precedence — a tag outranks a branch that shares its name — plus the bare
# archive/<ref> form, which is how a commit-SHA pin (the default) downloads.
# 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=""
# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script
REGISTRY_DIR=""
templates_resolve() {
local repo ref url got="" d
local repo ref url got=""
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
[ -d "$RIG_TEMPLATES_DIR" ] || {
printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2
return 1
}
printf '%s\n' "$RIG_TEMPLATES_DIR"
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
return 0
fi
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
return 1
}
d="${1%/}"
printf '%s\n' "$d"
# shellcheck disable=SC2034 # the function's output global, read by the sourcing script
REGISTRY_DIR="${1%/}"
}
# templates_roles <registry-dir> — the roles a registry defines: its
@ -188,6 +192,49 @@ template_parse_env() {
esac
[ -n "$TPL_PATH_LINE" ] \
|| { 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

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
}