Merge pull request #146 from claude-bot-andresmgsl/build/110-rig-templates

feat: role templates move to heavy-duty/rig-templates — pinned by default, overridable per mint
This commit is contained in:
Daniel Marin 2026-07-25 00:37:40 +01:00 committed by GitHub
commit e250f7f7da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 836 additions and 365 deletions

View file

@ -398,7 +398,7 @@ unattended VM-host appliance) — and `workstation` is the machine at the keyboa
end of all the SSH connections: `root-door=closed`, `join=login`, entering the end of all the SSH connections: `root-door=closed`, `join=login`, entering the
tailnet as *your* device rather than the fleet's. tailnet as *your* device rather than the fleet's.
### `rig bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box>` — the box tenants ### `rig bootstrap <role>-box` — the box tenants
Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted
guest. Convergent — safe to re-run; a second run changes nothing. guest. Convergent — safe to re-run; a second run changes nothing.
@ -419,16 +419,41 @@ same harness as everything else — and re-runnable on an *existing* box to
converge it to a new spec instead of re-minting it. One convergence engine; converge it to a new spec instead of re-minting it. One convergence engine;
the guests were the hole. the guests were the hole.
It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh` It is **one mechanism, parameterized per DEFINITION** (#110), not four
holds the whole per-tenant table), not four hand-maintained scripts: hand-maintained scripts. The agent-tenant definitions live in the
**[heavy-duty/rig-templates](https://github.com/heavy-duty/rig-templates)
registry** — one directory per role (`template.env`, the allowlisted data
table rig parses and never sources; `install.sh`, the CLI install; `creds.md`,
the per-vendor context paragraph) — so adding an agent tenant is a data PR
there, never a mechanism edit here (#109 was the evidence: adding kimi, pure
data, meant editing six files in this repo). Which `-box` roles exist is the
registry's fact; `rig bootstrap <anything>-box` dispatches on the family
suffix and refuses an unknown role by listing what the resolved registry
actually contains. `staging-box` is the one in-tree tenant — mechanism-adjacent
(sshd hardening through the shared `lib/sshd.sh`, docker, no agent), user
`ops`, box#69's server posture with `root-door=open` acceptance.
| tenant role | user | what lands | **Where the registry comes from — three knobs, precedence high to low:**
|---------------|----------|------------|
| `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` | | knob | meaning |
| `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` | | `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" |
| `kimi-box` | `kimi` | the toolbelt, docker, the kimi CLI (uv-managed) on the system PATH, and `~/.kimi/AGENTS.md` | | `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time |
| `staging-box` | `ops` | box#69's server posture: docker + the same sshd hardening the machine roles get (shared `lib/sshd.sh`, `root-door=open` acceptance) | | *(neither set)* | **the in-tree pin**`RIG_TEMPLATES_PIN` in `commands/lib/templates.sh`, the `BOX_RELEASE` discipline: bumped by ordinary reviewed rig PR, so a rig release freezes the mechanism+registry pair, and a newer rig matches newer templates by default (the #110 ruling) |
**The security trade — in bold, not a footnote.** **A main-tracked
rig-templates repo means every merged PR there executes as root inside every
future mint.** This is acceptable — and an improvement — only because of
three facts together: (1) it *narrows* today's surface, where all of rig is
main-tracked-as-root; (2) the repo is small, single-purpose, and
ceremony-governed with a **human merge** as the gate and the review panel
ahead of it; (3) drills pin the SHA they proved. If any of those three
weakens, the default flips to a pinned `RIG_TEMPLATES_REF`. install.sh diffs
in that repo are the highest-trust review surface in the org — the reviewer
doctrine should say so. *(2026-07-24: the flip this paragraph reserves was
taken, before the migration and by the decider — the default IS the pin
above, so a merged template reaches mints only through a reviewed pin bump
here, or an explicit per-mint `RIG_TEMPLATES_REF`.)*
**The role carries the suffix; the user does not.** A tenant user is the **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 account the box *seed* created (`BOX_USER`) and the agent CLI's own dotdir
@ -462,8 +487,9 @@ disposability facts, and the guard note — **never run `box setup-host`,
`box teardown-host`, or the drill inside a box; the box you are in is not a `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 host you own**. A nested box stack claims the guest's own uplink subnet and
silently breaks its networking (box#80). The note lives in silently breaks its networking (box#80). The note lives in
`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was `lib/templates.sh`'s renderer exactly once — mechanism, not template data —
the point of moving it here. never copy-pasted per definition; that was the point of moving it here. Only
the creds paragraph is the definition's (`creds.md`).
**Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes **Tenants and the role marker.** A tenant run writes `role=<tenant> tenant=yes
host=no` — no `root-door=`, because a guest has no root-door policy of its own host=no` — no `root-door=`, because a guest has no root-door policy of its own

28
bin/rig
View file

@ -37,15 +37,19 @@ commands:
Leave the tailnet only when /etc/rig/role proves rig performed the Leave the tailnet only when /etc/rig/role proves rig performed the
join, then remove that marker. Refuses for pre-existing or old unknown join, then remove that marker. Refuses for pre-existing or old unknown
joins, and while a GitHub runner is installed. Run as root. joins, and while a GitHub runner is installed. Run as root.
bootstrap <claude-box|codex-box|grok-box|kimi-box|staging-box> [--user <name>] bootstrap <role>-box [--user <name>]
The box TENANT roles: converge a box-minted guest. The '-box' suffix The box TENANT roles: converge a box-minted guest. The '-box' suffix
names the family (a guest, vs the '-server' machine roles above). names the family (a guest, vs the '-server' machine roles above).
claude-box|codex-box|grok-box|kimi-box land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are
the system PATH, and the agent-context file (with the box#80 guard: DEFINED in the heavy-duty/rig-templates registry — resolved via
never run box setup-host inside a box). staging-box lands box#69's RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and land
server posture — docker + sshd hardening; its tailnet join stays the agent toolbelt (git, gh, tmux, docker), the agent's CLI on the
operator-run via 'rig bootstrap workload-server'. Creds-free and non-interactive: box system PATH, and the agent-context file (with the box#80 guard:
auto-runs these at mint. Run as root, inside the box. never run box setup-host inside a box). staging-box, in rig's own
tree, 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> coolify install --version <pin>
Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. Pinned Coolify install (AUTOUPDATE=false). Control-plane box only.
coolify backup install [options] coolify backup install [options]
@ -95,6 +99,12 @@ commands:
Shut root SSH on a class=human box once an admin key works. Refuses Shut root SSH on a class=human box once an admin key works. Refuses
on class=server — root there is the control plane's automation door — on class=server — root there is the control plane's automation door —
and while no admin holds a key. Run as root. and while no admin holds a key. Run as root.
template-lint <role-dir>...
Validate tenant-role definitions (the heavy-duty/rig-templates
shape): template.env against the allowlist (data, never sourced),
install.sh present with a shebang, creds.md non-blank. Every refusal
names the failing key. The registry repo's CI runs this on every PR;
no root, no network, no writes.
manifest [<key>] manifest [<key>]
Print /etc/rig/manifest — which rig converged this machine and when Print /etc/rig/manifest — which rig converged this machine and when
(bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at (bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at
@ -455,6 +465,10 @@ case "$cmd" in
;; ;;
esac esac
;; ;;
template-lint)
shift
exec "$ROOT/commands/template-lint.sh" "$@"
;;
manifest) manifest)
shift shift
exec "$ROOT/commands/manifest.sh" "$@" exec "$ROOT/commands/manifest.sh" "$@"

8
changelog.d/110.md Normal file
View file

@ -0,0 +1,8 @@
### Changed
- Agent-tenant definitions live in heavy-duty/rig-templates, pinned in-tree and overridable per mint (`RIG_TEMPLATES_DIR`/`_REF`/`_REPO`); the in-tree case arms are gone, `staging-box` stays (#110)
### Added
- `rig template-lint` validates role definitions; rig-templates CI runs it on every PR (#110)
- Drill records cite the rig-templates SHA the converge read (#110)

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,35 @@ 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
# Same charset the users file enforces, for the same reasons (a leading
# '-' reads as a usermod flag; '|', ':' corrupt things downstream).
# Checked HERE, at parse — the definition's USER is checked by the
# parser — so the refusal needs no registry and no network.
[[ "$TENANT_USER_OVERRIDE" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER_OVERRIDE' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 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,10 +124,6 @@ while [ $# -gt 0 ]; do
*) die "unknown flag: $1" 2 ;; *) die "unknown flag: $1" 2 ;;
esac esac
done done
# Same charset the users file enforces, for the same reasons (a leading '-'
# reads as a usermod flag; '|', ':' corrupt things downstream).
[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2
# --- guards ------------------------------------------------------------------ # --- guards ------------------------------------------------------------------
# A tenant role converges a box GUEST. A box already carrying a machine-role # A tenant role converges a box GUEST. A box already carrying a machine-role
@ -140,6 +171,34 @@ if [ -n "$EXISTING_ROOT_DOOR" ]; then
fi fi
fi fi
# --- the definition ----------------------------------------------------------
# Resolved and parsed BEFORE the root check (but after the marker guards,
# which need no definition and must stay refusable with no registry in
# reach), 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
[ "$(id -u)" -eq 0 ] || die "must run as root" [ "$(id -u)" -eq 0 ] || die "must run as root"
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
# Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. — # Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. —
@ -178,19 +237,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 +290,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 +314,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 ;; esac
codex-box) installed=""
CLI=codex if [ -n "$CLI_SRC" ]; then
if ! command -v codex >/dev/null 2>&1; then [ -e "$CLI_SRC" ] && installed=1
log "installing the Codex CLI (npm global)" elif command -v "$CLI" >/dev/null 2>&1; then
npm install -g @openai/codex installed=1
else
log "codex CLI already installed"
fi fi
CLI_SRC="$(npm prefix -g)/bin/codex" ;; if [ -z "$installed" ]; then
grok-box) log "installing the ${CLI} CLI (${ROLE}'s install.sh)"
# The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`, TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \
# a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir. TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \
# Run it AS the tenant user, never root: a symlink into root's 0700 home bash "$TPL_DIR/install.sh" \
# would be a CLI that exists and cannot run. || die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)"
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 else
log "grok CLI already installed" log "${CLI} CLI already installed"
fi ;; fi
kimi-box) if [ -z "$CLI_SRC" ]; then
# The OFFICIAL installer (code.kimi.com/install.sh): a uv-managed Python CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)"
# tool (kimi-cli), landing `kimi` in ~/.local/bin — uv's tool bin — with [ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?"
# uv bringing its own managed CPython, so no apt python pin here (the fi
# 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
[ -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 +369,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 +395,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 +405,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).

266
commands/lib/templates.sh Normal file
View file

@ -0,0 +1,266 @@
#!/usr/bin/env bash
# The tenant-template REGISTRY (#110): resolve where role definitions come
# from, parse a definition's template.env against an allowlist, and lint a
# whole definition. Sourced by bootstrap-tenant.sh (the mint-time consumer)
# and template-lint.sh (the registry repo's CI gate) — pure functions plus
# one pin, no side effects at source time (repo precedent: runner-config,
# and the tenant-config table this lib replaces).
#
# The registry moved out of rig's tree so mechanism and data can move at
# different cadences (#109 is the evidence: adding kimi — pure data — meant
# editing six files here). rig keeps the mechanism and this schema; the
# definitions live in heavy-duty/rig-templates, one directory per role:
#
# <role>/template.env KEY="value" data, parsed against the allowlist
# below and NEVER sourced — a definition cannot
# execute shell through its data file
# <role>/install.sh the CLI install (the one inherently executable part)
# <role>/creds.md the per-vendor creds-free paragraph the context
# renderer splices in
#
# THE SOURCE IS THREE KNOBS, precedence _DIR > _REF > pin:
# RIG_TEMPLATES_DIR a local folder — bypasses the fetch entirely (the
# offline-test path, and "try a template before it
# exists anywhere")
# RIG_TEMPLATES_REF a ref in the registry repo, fetched as a tarball at
# bootstrap time (the same shape as the rig preinstall)
# RIG_TEMPLATES_REPO which repo that ref lives in (default
# heavy-duty/rig-templates)
# and, absent both overrides, the PIN below.
# The default registry ref a mint converges — the BOX_RELEASE discipline
# (#103): one line, bumped deliberately by ordinary rig PR after review, so a
# rig release freezes the mechanism+registry pair and a newer rig matches
# newer templates by default (ruled 2026-07-24 on #110: pinned, not
# main-tracked). RIG_TEMPLATES_REF overrides it per mint.
#
# Currently the seed tree (rig-templates#1's head — fetchable from the
# upstream archive already, an ancestor of its main once merged): the four
# agent tenants ported byte-equivalent from the case arms this PR cut.
RIG_TEMPLATES_PIN=be749f7fd1ff8dd7c2359bbce7fd6abd3f403eb0
# The template.env schema. Grammar: blank lines, '#' comments, and
# KEY="value" — nothing else. Parsed by regex, never sourced.
TEMPLATE_KEYS_REQUIRED=(USER CONTEXT_PATH CLI_NAME PATH_LINE)
TEMPLATE_KEYS_OPTIONAL=(CLI_SRC NEEDS_NODE APT_EXTRAS)
# templates_source_desc — where the resolved registry came from, for error
# messages and logs: a misconfigured RIG_TEMPLATES_REPO must be visible in
# the unknown-role refusal rather than looking like a typo.
templates_source_desc() {
if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then
printf 'local dir %s (RIG_TEMPLATES_DIR)' "$RIG_TEMPLATES_DIR"
else
printf '%s@%s%s' \
"${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" \
"${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}" \
"$([ -n "${RIG_TEMPLATES_REF:-}" ] && printf ' (RIG_TEMPLATES_REF)' || printf ' (the in-tree pin)')"
fi
}
# templates_resolve — resolve the three knobs to a LOCAL directory holding
# 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=""
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
}
REGISTRY_DIR="$RIG_TEMPLATES_DIR"
return 0
fi
repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
ref="${RIG_TEMPLATES_REF:-$RIG_TEMPLATES_PIN}"
command -v curl >/dev/null 2>&1 || { printf 'curl is required to fetch the template registry\n' >&2; return 1; }
command -v tar >/dev/null 2>&1 || { printf 'tar is required to extract the template registry\n' >&2; return 1; }
TEMPLATES_TMP="$(mktemp -d)"
for url in \
"https://github.com/$repo/archive/refs/tags/$ref.tar.gz" \
"https://github.com/$repo/archive/refs/heads/$ref.tar.gz" \
"https://github.com/$repo/archive/$ref.tar.gz"; do
if curl -fsSL "$url" -o "$TEMPLATES_TMP/templates.tar.gz" 2>/dev/null; then got="$url"; break; fi
done
if [ -z "$got" ]; then
printf 'cannot fetch the template registry %s@%s — tried:\n' "$repo" "$ref" >&2
printf ' https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$repo" "$ref" >&2
printf ' https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$repo" "$ref" >&2
printf ' https://github.com/%s/archive/%s.tar.gz\n' "$repo" "$ref" >&2
printf 'the fetch is unauthenticated by contract (a mint holds no credentials): the repo must be public and the ref must exist. RIG_TEMPLATES_DIR=<dir> bypasses the fetch.\n' >&2
return 1
fi
tar -xzf "$TEMPLATES_TMP/templates.tar.gz" -C "$TEMPLATES_TMP" || {
printf 'cannot extract the registry tarball from %s\n' "$got" >&2
return 1
}
# A GitHub archive holds exactly one top-level directory (<repo>-<ref>,
# slashes flattened) — assert that shape instead of assuming the name.
set -- "$TEMPLATES_TMP"/*/
{ [ $# -eq 1 ] && [ -d "$1" ]; } || {
printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2
return 1
}
# 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
# immediate subdirectories that carry a template.env. This list IS the
# unknown-role refusal's body, so it reflects what the resolved source
# actually contains — never a hardcoded set.
templates_roles() {
local d
for d in "$1"/*/; do
[ -f "$d/template.env" ] || continue
basename "$d"
done
}
# template_parse_env <template.env> — parse against the allowlist. Sets
# TPL_USER, TPL_CONTEXT_PATH, TPL_CLI_NAME, TPL_CLI_SRC, TPL_PATH_LINE,
# TPL_NEEDS_NODE (default no), TPL_APT_EXTRAS. Every refusal names the
# failing key (or line): the box.env discipline — a definition is data, and
# bad data is refused loudly, never executed to find out.
# shellcheck disable=SC2034 # the TPL_* globals are this function's OUTPUT, read by the sourcing script
template_parse_env() {
local file="$1" line key val n=0 seen=" " k ok
TPL_USER="" TPL_CONTEXT_PATH="" TPL_CLI_NAME="" TPL_CLI_SRC=""
TPL_PATH_LINE="" TPL_NEEDS_NODE="no" TPL_APT_EXTRAS=""
[ -f "$file" ] || { printf 'template.env missing: %s\n' "$file" >&2; return 1; }
while IFS= read -r line || [ -n "$line" ]; do
n=$((n+1))
case "$line" in ''|'#'*) continue ;; esac
if [[ ! "$line" =~ ^([A-Z_]+)=\"(.*)\"$ ]]; then
printf 'template.env:%d: not KEY="value": %s\n' "$n" "$line" >&2
return 1
fi
key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
ok=""
for k in "${TEMPLATE_KEYS_REQUIRED[@]}" "${TEMPLATE_KEYS_OPTIONAL[@]}"; do
[ "$key" = "$k" ] && ok=1
done
[ -n "$ok" ] || { printf 'template.env:%d: unknown key: %s (allowed: %s %s)\n' \
"$n" "$key" "${TEMPLATE_KEYS_REQUIRED[*]}" "${TEMPLATE_KEYS_OPTIONAL[*]}" >&2; return 1; }
case "$seen" in *" $key "*)
printf 'template.env:%d: duplicate key: %s\n' "$n" "$key" >&2; return 1 ;;
esac
seen="$seen$key "
case "$key" in
USER) TPL_USER="$val" ;;
CONTEXT_PATH) TPL_CONTEXT_PATH="$val" ;;
CLI_NAME) TPL_CLI_NAME="$val" ;;
CLI_SRC) TPL_CLI_SRC="$val" ;;
PATH_LINE) TPL_PATH_LINE="$val" ;;
NEEDS_NODE) TPL_NEEDS_NODE="$val" ;;
APT_EXTRAS) TPL_APT_EXTRAS="$val" ;;
esac
done < "$file"
for k in "${TEMPLATE_KEYS_REQUIRED[@]}"; do
case "$seen" in *" $k "*) ;; *)
printf 'template.env: missing required key: %s\n' "$k" >&2; return 1 ;;
esac
done
# Value shapes — each refusal names its key. USER shares the charset the
# users file enforces (a leading '-' reads as a usermod flag; '|', ':'
# corrupt things downstream).
[[ "$TPL_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \
|| { printf 'template.env: USER: invalid user name: %s (want ^[a-z_][a-z0-9_-]{0,31}$)\n' "$TPL_USER" >&2; return 1; }
case "$TPL_CONTEXT_PATH" in
/*|*..*|'') printf 'template.env: CONTEXT_PATH: must be relative to the tenant home, without "..": %s\n' "$TPL_CONTEXT_PATH" >&2; return 1 ;;
esac
[[ "$TPL_CLI_NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] \
|| { printf 'template.env: CLI_NAME: not a sane command name: %s\n' "$TPL_CLI_NAME" >&2; return 1; }
# A literal '~/' on purpose (SC2088): the value is DATA — the mechanism
# expands it to the tenant home by string substitution, never the shell.
# shellcheck disable=SC2088
case "$TPL_CLI_SRC" in
*..*) printf 'template.env: CLI_SRC: must not contain "..": %s\n' "$TPL_CLI_SRC" >&2; return 1 ;;
''|'~/'*|/*) ;;
*) printf 'template.env: CLI_SRC: must be absolute or ~/-relative: %s\n' "$TPL_CLI_SRC" >&2; return 1 ;;
esac
case "$TPL_NEEDS_NODE" in
yes|no) ;;
*) printf 'template.env: NEEDS_NODE: want yes or no, got: %s\n' "$TPL_NEEDS_NODE" >&2; return 1 ;;
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
# CI runs on every PR (rig defines what a valid template is; rig-templates
# CI enforces it, so a broken definition is refused before it can reach a
# mint). Same parser the mint runs — the two gates are not redundant: CI
# protects the registry, the mint-time parse protects a mint served through
# RIG_TEMPLATES_REPO/_DIR that CI never saw.
template_lint() {
local dir="${1%/}" role
role="$(basename "$dir")"
[ -d "$dir" ] || { printf '%s: not a directory\n' "$dir" >&2; return 1; }
case "$role" in
*-box|*-server) ;;
*) printf '%s: role directories carry a family suffix (-box for box tenants, -server for fleet machines — rig#76)\n' "$role" >&2; return 1 ;;
esac
template_parse_env "$dir/template.env" || return 1
[ -s "$dir/install.sh" ] \
|| { printf '%s: install.sh missing or empty\n' "$role" >&2; return 1; }
head -n1 "$dir/install.sh" | grep -q '^#!' \
|| { printf '%s: install.sh has no shebang\n' "$role" >&2; return 1; }
grep -q '[^[:space:]]' "$dir/creds.md" 2>/dev/null \
|| { printf '%s: creds.md missing or blank (the context renderer splices it in — a blank paragraph would ship a context file with a hole)\n' "$role" >&2; return 1; }
return 0
}

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
}

52
commands/template-lint.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# rig template-lint <role-dir>... — is this a valid tenant-role definition?
#
# rig defines what a valid template is (the schema lives in
# lib/templates.sh, beside the mint-time parser that enforces it); the
# heavy-duty/rig-templates repo's CI runs this on every definition on every
# PR, so a broken definition is refused before it can ever reach a mint
# (#110). The two gates are deliberate: CI protects the registry, the
# mint-time parse protects a mint served through RIG_TEMPLATES_REPO/_DIR
# that CI never saw.
#
# Pure read: no root, no network, no writes — lintable anywhere, including
# the registry repo's checkout, where rig's tree is only a fetched tool.
set -euo pipefail
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# shellcheck source=SCRIPTDIR/lib/templates.sh
. "$HERE/lib/templates.sh" # template_lint (and the schema it enforces)
die() { printf 'rig-template-lint: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
usage() {
cat <<'EOF'
usage: rig template-lint <role-dir>...
Validate tenant-role definitions (the heavy-duty/rig-templates shape):
each <role-dir> must carry a family-suffixed name (rig#76), a template.env
that parses against the allowlist (KEY="value" only — the file is data,
never sourced), an install.sh with a shebang, and a non-blank creds.md.
Every refusal names the failing key or file. Exits non-zero if any
definition fails; nothing is written.
EOF
}
case "${1:-}" in
-h|--help) usage; exit 0 ;;
"") usage >&2; die "at least one role directory required" 2 ;;
esac
fail=0
for dir in "$@"; do
case "$dir" in
-*) usage >&2; die "unknown flag: $dir" 2 ;;
esac
if template_lint "$dir"; then
printf 'rig-template-lint: OK: %s\n' "$dir"
else
printf 'rig-template-lint: FAIL: %s\n' "$dir" >&2
fail=1
fi
done
exit "$fail"

View file

@ -51,6 +51,14 @@ REPO="${RIG_REPO:-heavy-duty/rig}"
REF="${RIG_REF:-}" REF="${RIG_REF:-}"
BOXREPO="${BOX_REPO:-heavy-duty/box}" BOXREPO="${BOX_REPO:-heavy-duty/box}"
BOXREF="${BOX_REF:-}" BOXREF="${BOX_REF:-}"
# The template registry the converge will read (#110). No explicitness
# demand here, unlike the two refs above: the DEFAULT is already a pin — the
# candidate tree's RIG_TEMPLATES_PIN, read after install from what actually
# landed — so an unset override means "the ref the release will really use",
# not "whatever main was that afternoon".
TPLREPO="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}"
TPLREF="${RIG_TEMPLATES_REF:-}"
TPL_SHA=""
ROLE=staging-server ROLE=staging-server
USERS_FILE="${DRILL_USERS_FILE:-}" USERS_FILE="${DRILL_USERS_FILE:-}"
RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}" RUN_ID="${DRILL_RUN_ID:-drill-$(date -u +%F)}"
@ -266,6 +274,8 @@ emit_record() {
printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt" printf 'Run ID: %s. Host: %s, %s vCPU / %s GB RAM (%s).\n' "$RUN_ID" "${os:-unknown}" "$cpus" "$ram" "$virt"
printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \ printf 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \
"${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF" "${RIG_SHA:-unresolved}" "$REF" "${BOX_SHA:-unresolved}" "$BOXREF"
printf 'Template registry: %s@%s (ref %s) — the rig-templates the converge read (#110).\n' \
"${TPLREPO:-heavy-duty/rig-templates}" "${TPL_SHA:-unresolved}" "${TPLREF:-unresolved}"
printf 'Instrument: drill/drill.sh, legs in execution order.\n\n' printf 'Instrument: drill/drill.sh, legs in execution order.\n\n'
printf '| Leg | Result |\n' printf '| Leg | Result |\n'
printf '| --- | --- |\n' printf '| --- | --- |\n'
@ -378,6 +388,21 @@ RIG_TREE="$(tree_of "$(command -v rig)")"
assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1 assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1
DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)" DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)"
ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)" ok "installed tree confirms: $REPO@$REF (version $DRILL_VERSION)"
# The rig-templates ref this candidate converges (#110), for the record: the
# env override when the drill was pointed somewhere, else the pin read from
# the INSTALLED tree — what actually landed, never this checkout's copy. A
# 40-hex ref IS its own SHA (the pin's normal shape); anything else resolves
# through ref_sha like the two candidates above.
if [ -z "$TPLREF" ]; then
TPLREF="$(sed -n 's/^RIG_TEMPLATES_PIN=//p' "$RIG_TREE/commands/lib/templates.sh" 2>/dev/null | head -n1)"
fi
if [[ "$TPLREF" =~ ^[0-9a-f]{40}$ ]]; then
TPL_SHA="${TPLREF:0:7}"
elif [ -n "$TPLREF" ]; then
TPL_SHA="$(ref_sha "$TPLREPO" "$TPLREF")"
fi
inf "templates: $TPLREPO@${TPLREF:-unresolved} (${TPL_SHA:-unresolved})"
[ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md" [ -n "$RECORD" ] || RECORD="$ROOT/drills/$DRILL_VERSION.md"
# ============================================================================= # =============================================================================

View file

@ -63,7 +63,11 @@ schedule, in separate sittings. What makes that safe is that every drill
**pins the same fixed set of candidate refs**: rig's drill runs `--host yes` **pins the same fixed set of candidate refs**: rig's drill runs `--host yes`
with `BOX_REF=release/<box-version>`, so it exercises the box that will with `BOX_REF=release/<box-version>`, so it exercises the box that will
actually ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it actually ship; box's drill mints with `RIG_REF=release/<rig-version>`, so it
exercises the rig that will actually ship. Both measure the same pair. exercises the rig that will actually ship. Both measure the same pair. The
record also cites the **rig-templates SHA** the converge read (#110) — the
candidate tree's `RIG_TEMPLATES_PIN` unless the drill was pointed elsewhere
via `RIG_TEMPLATES_REF` — so the mechanism+registry pair a release freezes
is the pair the drill proved.
That — not sequencing — is what dissolves the box↔rig recursion. The refs are That — not sequencing — is what dissolves the box↔rig recursion. The refs are
static identifiers that exist as soon as the release branches do, long before static identifiers that exist as soon as the release branches do, long before

View file

@ -572,19 +572,53 @@ else
echo "skip: bootstrap non-root refusals (running as root)" echo "skip: bootstrap non-root refusals (running as root)"
fi fi
# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|kimi-box|staging-box --- # --- box tenant roles (#31/#76/#110): <role>-box from the registry + staging-box ---
# What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh), # What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh),
# parameterized per tenant through lib/tenant-config.sh, dispatched from # parameterized per DEFINITION fetched from the template registry
# bootstrap.sh so `rig bootstrap <role>` stays the single entrypoint. The real # (heavy-duty/rig-templates; lib/templates.sh resolves RIG_TEMPLATES_DIR >
# converge needs root, a tenant user, and the network — the container # RIG_TEMPLATES_REF > the in-tree pin), dispatched from bootstrap.sh on the
# rehearsal's job — so the harness proves what it can non-root: the whole # '-box' FAMILY SUFFIX so `rig bootstrap <role>` stays the single entrypoint
# arg/refusal surface, the pure parameter table, the rendered agent-context # and a template added to the registry is mintable with zero code changes
# file (guard note included), and grep-pins on the shipped script. # here. The real converge needs root, a tenant user, and the network — the
# container rehearsal's job — so the harness proves what it can non-root and
# OFFLINE: the whole refusal surface, the resolution precedence, the parser
# and the renderer, against fixture definitions via RIG_TEMPLATES_DIR.
# The fixture registry: one valid scratch definition, plus broken ones the
# parser must refuse BY NAME. Synthetic on purpose — the real definitions
# live in heavy-duty/rig-templates, and this suite must hold whatever those
# say (offline is the point: no fetch, no network, no coupling).
TPL_FIX="$(mktemp -d)"
mkdir -p "$TPL_FIX/scratch-box"
cat > "$TPL_FIX/scratch-box/template.env" <<'TPLEOF'
# comments and blank lines are the only non-KEY="value" grammar
USER="scratch"
CONTEXT_PATH=".scratch/AGENTS.md"
CLI_NAME="scratch"
CLI_SRC="~/.local/bin/scratch"
PATH_LINE="export PATH="$HOME/.local/bin:$PATH""
NEEDS_NODE="no"
APT_EXTRAS="zsh"
TPLEOF
printf '#!/usr/bin/env bash\nexit 0\n' > "$TPL_FIX/scratch-box/install.sh"
printf -- '- **Creds-free by default.** The scratch vendor paragraph.\n' > "$TPL_FIX/scratch-box/creds.md"
mkdir -p "$TPL_FIX/badkey-box"
printf 'USER="x"\nCOLOR="red"\n' > "$TPL_FIX/badkey-box/template.env"
mkdir -p "$TPL_FIX/missing-box"
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nPATH_LINE="p"\n' > "$TPL_FIX/missing-box/template.env"
mkdir -p "$TPL_FIX/garbled-box"
printf 'USER=unquoted\n' > "$TPL_FIX/garbled-box/template.env"
mkdir -p "$TPL_FIX/badnode-box"
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nCLI_NAME="x"\nPATH_LINE="p"\nNEEDS_NODE="maybe"\n' > "$TPL_FIX/badnode-box/template.env"
mkdir -p "$TPL_FIX/badapt-box"
printf 'USER="x"\nCONTEXT_PATH=".x/A.md"\nCLI_NAME="x"\nPATH_LINE="p"\nAPT_EXTRAS="zsh -o"\n' > "$TPL_FIX/badapt-box/template.env"
# THE HARD CUT, tenant half (#76). The pre-rename names are gone and must fail # 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 # 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 # shape that survives review. And the #110 cut on top: the mechanism no longer
# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has # KNOWS any agent tenant by name — which '-box' roles exist is the registry's
# its own dispatch list and a name could survive in one and not the other. # fact, so the old names die on the family-suffix rule, not an enumerated list.
for r in claude codex grok staging; do for r in claude codex grok staging; do
check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \ check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \
"$ROOT/commands/bootstrap-tenant.sh" "$r" "$ROOT/commands/bootstrap-tenant.sh" "$r"
@ -593,39 +627,46 @@ for r in claude codex grok staging; do
done done
check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help 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: 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: a suffix-less 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-box --nope 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: --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' 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 — # The suffix rule admits ANY '-box' name, so the charset gate must catch a
# a dead dockerd passing `docker --version` is the "linked but cannot run" # crafted one BEFORE it is used as a path component (the valid_version
# scar in daemon form. Grep-pinned so the assert cannot ship deleted. # discipline): uppercase, dots, a leading '-' all die at the name, never in a
# registry lookup.
check "tenant: a crafted role name dies at the charset gate" 2 "invalid tenant role name" \
"$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box'
check "tenant: dockerd effective-state assert is present" 0 "" \ check "tenant: dockerd effective-state assert is present" 0 "" \
grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh" grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh"
# The machine-role traits die with the tenant story, never "unknown flag" — an # The machine-role traits die with the tenant story, never "unknown flag" — an
# operator reaching for --hostname must learn where the trait family went. # operator coming from the machine families needs the boundary, not a shrug.
check "tenant: trait flags die with the tenant story" 2 "have no traits" \ check "tenant: trait flags die with the tenant story" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed "$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed
check "tenant: --hostname dies the same way" 2 "have no traits" \ check "tenant: --hostname dies the same way" 2 "have no traits" \
"$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest "$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest
# Dispatch: the machine-role entrypoint hands tenant roles to the tenant # Dispatch: the machine-role entrypoint hands ANY '-box' role to the tenant
# mechanism with args intact (--help reaching the TENANT usage proves both). # mechanism on the family suffix — enumerating them would re-chain template
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|kimi-box|staging-box" \ # velocity to rig edits, the exact coupling #110 removes.
check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "Box TENANT roles" \
"$ROOT/commands/bootstrap.sh" claude-box --help "$ROOT/commands/bootstrap.sh" claude-box --help
# The marker guard fires BEFORE the root check (repo precedent: the coolify check "bootstrap: an unheard-of '-box' role still dispatches (zero code changes)" 0 "Box TENANT roles" \
# marker warning), so the refusals are provable here off fixture markers. A "$ROOT/commands/bootstrap.sh" scratch-box --help
# The tenant marker guard (#83), against marker FIXTURES (never the harness
# machine's real /etc/rig/role): converging a tenant onto a machine-role box or a
# VM host (host=yes) refuses for every tenant — and names the staging PAIR, # 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 # because whoever hits it has the halves confused: the guest (staging-box), the metal
# (staging-server). An agent tenant refuses ANY machine-role box; staging-box # (staging-server). An agent tenant refuses ANY machine-role box; staging-box
# tolerates ONLY root-door=open with host=no — that is the guest after its # tolerates exactly the workload-joined guest (root-door=open host=no) and refuses the
# operator-run workload join, and re-converging it is what convergence is for. # rest. These need no registry: the guards run before the resolution, so a
# A closed-door machine (root-door=closed via custom) is NOT that guest, and # poisoning converge is refused even when the registry is unreachable.
# open-door hardening would die at it with root-door=open-specific messaging —
# refuse instead.
TEN_FIX="$(mktemp -d)" TEN_FIX="$(mktemp -d)"
printf 'role=dev-server root-door=closed host=yes join=authkey\n' > "$TEN_FIX/host"
printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine" printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine"
printf 'role=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed" printf 'role=custom root-door=closed host=no join=authkey\n' > "$TEN_FIX/closed"
printf 'role=staging-server root-door=open host=yes join=authkey\n' > "$TEN_FIX/host"
printf 'role=workload class=server\n' > "$TEN_FIX/pre77-machine"
printf 'role=dev class=human\n' > "$TEN_FIX/pre77-human"
printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant" printf 'role=claude-box tenant=yes host=no\n' > "$TEN_FIX/tenant"
check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \ check "tenant: staging-box refuses a closed-door machine box" 1 "root door is not open" \
env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box env RIG_ROLE_MARKER="$TEN_FIX/closed" "$ROOT/commands/bootstrap-tenant.sh" staging-box
@ -635,30 +676,69 @@ check "tenant: the host refusal sends you to the metal half of the pair" 1 "stag
env RIG_ROLE_MARKER="$TEN_FIX/host" "$ROOT/commands/bootstrap-tenant.sh" staging-box 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" \ 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-box env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
# The tenant guard's compat read (#77). This guard asks "does this marker name # The tenant guard's compat read (#77). This guard asks "does this marker name
# a root-door policy?" as its proxy for "is this a real fleet machine?", and it # a root-door policy?" through the resolver, so the pre-#77 spelling counts —
# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling, # pattern-matching one spelling would fail OPEN here: the marker stops looking
# same reason as the close-root fixtures below: a pre-#77 box that stops # like a machine's, the refusal never fires, and a tenant converge clobbers a
# looking like a machine here is the fail-OPEN direction of this rename — the # live fleet box's marker.
# agent-tenant refusal never fires, and `rig bootstrap claude-box` converges a
# tenant straight over a live fleet box, clobbering the marker that holds its
# root-door policy. Do not modernize these two fixtures.
printf 'role=workload-server class=server host=no join=authkey\n' > "$TEN_FIX/pre77-machine"
printf 'role=custom class=human host=no join=login\n' > "$TEN_FIX/pre77-human"
check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \ check "tenant: an agent role refuses a PRE-#77 machine marker" 1 "never tailnet machines" \
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" claude-box
check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \ check "tenant: staging-box refuses a PRE-#77 closed-door machine box" 1 "root door is not open" \
env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box env RIG_ROLE_MARKER="$TEN_FIX/pre77-human" "$ROOT/commands/bootstrap-tenant.sh" staging-box
# ...and the guard needs no registry: an unreachable RIG_TEMPLATES_DIR must
# not stop a refusal that protects a live fleet box.
check "tenant: the marker guard fires even with the registry unreachable" 1 "never tailnet machines" \
env RIG_ROLE_MARKER="$TEN_FIX/machine" RIG_TEMPLATES_DIR=/nonexistent/registry \
"$ROOT/commands/bootstrap-tenant.sh" claude-box
# The definition surface (#110), offline via RIG_TEMPLATES_DIR. An unknown
# role's refusal LISTS what the resolved source actually contains and names
# the source — a misconfigured RIG_TEMPLATES_REPO/_REF/_DIR must be visible
# in the error rather than looking like a typo.
check "tenant: unknown role lists the resolved registry" 2 "scratch-box" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
check "tenant: the unknown-role refusal names the source" 2 "RIG_TEMPLATES_DIR" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
check "tenant: an unreadable RIG_TEMPLATES_DIR refuses loudly" 2 "not a directory" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
# _DIR outranks _REF: with both set, resolution must not touch the network —
# provable offline exactly because a fetch attempt would fail here.
check "tenant: RIG_TEMPLATES_DIR outranks RIG_TEMPLATES_REF" 2 "scratch-box" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" RIG_TEMPLATES_REF=some-branch \
"$ROOT/commands/bootstrap-tenant.sh" nosuch-box
# A malformed definition is refused at bootstrap BY KEY (the box.env
# discipline: parsed, never sourced — so a template cannot execute arbitrary
# shell through the data file). The registry CI's lint is the other gate;
# this one protects a mint served through a source CI never saw.
check "tenant: an unknown key is refused by name" 2 "unknown key: COLOR" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" badkey-box
check "tenant: a missing required key is refused by name" 2 "missing required key: CLI_NAME" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" missing-box
check "tenant: a non-KEY=\"value\" line is refused with its line number" 2 'not KEY="value"' \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" garbled-box
check "tenant: a bad NEEDS_NODE value is refused by key" 2 "NEEDS_NODE" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" badnode-box
check "tenant: an option riding APT_EXTRAS is refused by key" 2 "APT_EXTRAS" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" badapt-box
if [ "$(id -u)" -ne 0 ]; then if [ "$(id -u)" -ne 0 ]; then
# RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before # 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. # the root check, and the harness machine may carry a real /etc/rig/role.
check "tenant: claude-box parses, refuses non-root" 1 "must run as root" \ # Reaching the root check proves the whole pre-root surface passed: the
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box # name, the flags, the guard, the resolution AND the parse.
check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \ check "tenant: a valid definition parses, refuses non-root" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \
check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \ "$ROOT/commands/bootstrap-tenant.sh" scratch-box
env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box check "tenant: staging-box needs no registry at all" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR=/nonexistent/registry \
"$ROOT/commands/bootstrap-tenant.sh" staging-box
check "tenant: staging-box tolerates a workload-joined guest's marker" 1 "must run as root" \ 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 env RIG_ROLE_MARKER="$TEN_FIX/machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
# ...and the same guest joined before #77: reaching the root check (rather # ...and the same guest joined before #77: reaching the root check (rather
@ -666,53 +746,127 @@ if [ "$(id -u)" -ne 0 ]; then
check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \ check "tenant: staging-box tolerates a PRE-#77 workload-joined guest" 1 "must run as root" \
env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box env RIG_ROLE_MARKER="$TEN_FIX/pre77-machine" "$ROOT/commands/bootstrap-tenant.sh" staging-box
check "tenant: a tenant marker re-runs fine (convergence)" 1 "must run as root" \ 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-box env RIG_ROLE_MARKER="$TEN_FIX/tenant" RIG_TEMPLATES_DIR="$TPL_FIX" \
"$ROOT/commands/bootstrap-tenant.sh" scratch-box
else else
echo "skip: tenant non-root refusals (running as root)" echo "skip: tenant non-root refusals (running as root)"
fi fi
rm -rf "$TEN_FIX" rm -rf "$TEN_FIX"
# The per-tenant parameter table and the agent-context renderer are pure lib # The same definition served from a REF (tarball fetch, curl stubbed — the
# functions on purpose (repo precedent: parse_users_file, json_string_array): # release.sh discipline) and from a local DIR must resolve to identical
# the CLI path to them sits behind root + a real tenant user, so the harness # converge inputs: the parsed TPL_* table and the rendered context file are
# proves them here, sourced, non-root and network-free. # everything the mechanism consumes, so identical inputs ARE the identical
tuser() { bash -c 'set -euo pipefail # converge (#110's acceptance criterion, provable offline).
. "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; } TPL_WORK="$(mktemp -d)"
tpath() { bash -c 'set -euo pipefail mkdir -p "$TPL_WORK/bin" "$TPL_WORK/stage/rig-templates-testref"
. "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; } cp -r "$TPL_FIX"/. "$TPL_WORK/stage/rig-templates-testref/"
tctx() { bash -c 'set -euo pipefail tar -czf "$TPL_WORK/reg.tar.gz" -C "$TPL_WORK/stage" rig-templates-testref
. "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; } cat > "$TPL_WORK/bin/curl" <<'CURLEOF'
check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box #!/usr/bin/env bash
check "tenant params: kimi's user drops the suffix too" 0 "kimi" tuser kimi-box # stub: invoked as `curl -fsSL <url> -o <out>` by templates_resolve
check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box echo "$2" >> "${CURL_LOG:?}"
check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude cp "${CURL_TARBALL:?}" "$4"
check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex CURLEOF
check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok chmod +x "$TPL_WORK/bin/curl"
check "tenant params: kimi context lands in ~/.kimi/AGENTS.md" 0 "/home/kimi/.kimi/AGENTS.md" tpath kimi-box /home/kimi # Single quotes deliberate throughout (SC2016): the $-expressions expand in
check "tenant params: staging has no context file" 1 "" tpath staging-box /home/ops # the INNER bash, against the sourced lib's state, never in the harness.
# The box#80 guard note lives ONCE, in the renderer, and every agent's file # shellcheck disable=SC2016
# carries it — the layering decision's whole point: never per-template again. tpl_inputs_script='set -euo pipefail
check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box . "$1/commands/lib/templates.sh"
check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box templates_resolve
check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box template_parse_env "$REGISTRY_DIR/scratch-box/template.env"
check "tenant context: kimi carries the box#80 guard" 0 "box setup-host" tctx kimi-box printf "USER=%s|CTX=%s|CLI=%s|SRC=%s|PATH=%s|NODE=%s|APT=%s\n" \
check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box "$TPL_USER" "$TPL_CONTEXT_PATH" "$TPL_CLI_NAME" "$TPL_CLI_SRC" \
check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box "$TPL_PATH_LINE" "$TPL_NEEDS_NODE" "$TPL_APT_EXTRAS"
check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box render_tenant_context scratch-box "$REGISTRY_DIR/scratch-box/creds.md"'
check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box tpl_from_dir() { # tpl_from_dir <outfile> — the local-folder path
check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box env RIG_TEMPLATES_DIR="$TPL_FIX" bash -c "$tpl_inputs_script" _ "$ROOT" > "$1"
check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box }
check "tenant context: kimi names its login flow" 0 "Kimi Code OAuth" tctx kimi-box tpl_from_ref() { # tpl_from_ref <outfile> — the tarball path, curl stubbed
check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box env PATH="$TPL_WORK/bin:$PATH" CURL_LOG="$TPL_WORK/curl.log" \
CURL_TARBALL="$TPL_WORK/reg.tar.gz" RIG_TEMPLATES_REF=testref \
bash -c "$tpl_inputs_script" _ "$ROOT" > "$1"
}
check "templates: a local DIR resolves and parses" 0 "" tpl_from_dir "$TPL_WORK/from-dir"
check "templates: a REF resolves through the tarball fetch (stubbed curl)" 0 "" \
tpl_from_ref "$TPL_WORK/from-ref"
check "templates: DIR and REF yield byte-identical converge inputs" 0 "" \
diff "$TPL_WORK/from-dir" "$TPL_WORK/from-ref"
# The fetch's first candidate is refs/tags — a tag must outrank a branch that
# happens to share its name (install.sh's own precedence, the pin must win).
check "templates: the fetch asks refs/tags first" 0 "/archive/refs/tags/testref.tar.gz" \
head -n1 "$TPL_WORK/curl.log"
check "templates: the rendered context carries the box#80 guard" 0 "box setup-host" \
cat "$TPL_WORK/from-dir"
check "templates: the guard says whose host this is not" 0 "not a host you own" \
cat "$TPL_WORK/from-dir"
check "templates: the guard cites box#80" 0 "box#80" cat "$TPL_WORK/from-dir"
check "templates: the definition's creds paragraph is spliced in" 0 "The scratch vendor paragraph" \
cat "$TPL_WORK/from-dir"
check "templates: the bootstrap runbook note survives the split" 0 "Bootstrap runbook" \
cat "$TPL_WORK/from-dir"
# The default ref is the IN-TREE PIN (the BOX_RELEASE discipline, ruled on
# #110: pinned, not main-tracked): exactly one greppable assignment, so a pin
# bump is a one-line PR and the drill can read the pin from an installed tree.
# shellcheck disable=SC2016
check "templates: the pin is one greppable line" 0 "1" \
bash -c 'grep -c "^RIG_TEMPLATES_PIN=" "$1/commands/lib/templates.sh"' _ "$ROOT"
# shellcheck disable=SC2016
check "templates: unset knobs fall back to the pin" 0 "the in-tree pin" \
bash -c '. "$1/commands/lib/templates.sh" && templates_source_desc' _ "$ROOT"
# rig template-lint — the registry repo's CI gate, same schema as the mint's
# parser (rig defines validity; rig-templates CI enforces it on every PR).
check "template-lint: --help exits 0" 0 "usage:" "$ROOT/commands/template-lint.sh" --help
check "template-lint: a directory is required" 2 "role directory required" "$ROOT/commands/template-lint.sh"
check "template-lint: dispatched from bin/rig" 0 "usage:" "$ROOT/bin/rig" template-lint --help
check "template-lint: a valid definition passes" 0 "OK: " "$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box"
check "template-lint: an unknown key fails by name" 1 "unknown key: COLOR" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/badkey-box"
check "template-lint: one bad definition fails the whole run" 1 "FAIL: " \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/scratch-box" "$TPL_FIX/badkey-box"
mkdir -p "$TPL_FIX/plain"
cp "$TPL_FIX/scratch-box"/* "$TPL_FIX/plain/"
check "template-lint: a suffix-less role directory is refused (#76)" 1 "family suffix" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/plain"
mkdir -p "$TPL_FIX/noinstall-box"
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noinstall-box/"
check "template-lint: a missing install.sh is refused by name" 1 "install.sh missing" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noinstall-box"
mkdir -p "$TPL_FIX/blankcreds-box"
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/install.sh" "$TPL_FIX/blankcreds-box/"
printf ' \n\t\n' > "$TPL_FIX/blankcreds-box/creds.md"
check "template-lint: a blank creds.md is refused by name" 1 "creds.md missing or blank" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/blankcreds-box"
mkdir -p "$TPL_FIX/noshebang-box"
cp "$TPL_FIX/scratch-box/template.env" "$TPL_FIX/scratch-box/creds.md" "$TPL_FIX/noshebang-box/"
printf 'exit 0\n' > "$TPL_FIX/noshebang-box/install.sh"
check "template-lint: an install.sh without a shebang is refused" 1 "no shebang" \
"$ROOT/commands/template-lint.sh" "$TPL_FIX/noshebang-box"
rm -rf "$TPL_FIX" "$TPL_WORK"
# Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal # Creds-free BY CONSTRUCTION, provable by absence (box#69's grep-refusal
# idiom): nothing in the tenant mechanism touches the tailnet, prompts, or # idiom): nothing in the tenant mechanism touches the tailnet, prompts, or
# apt-installs incus. A grep that finds nothing (exit 1) is the pass. # apt-installs incus. A grep that finds nothing (exit 1) is the pass. The
# same absences hold for the templates lib — it fetches DATA, unauthenticated
# by contract, and must never grow a credential to do it.
check "tenant: never touches the tailnet" 1 "" \ check "tenant: never touches the tailnet" 1 "" \
grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh" grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: non-interactive — nothing prompts" 1 "" \ check "tenant: non-interactive — nothing prompts" 1 "" \
grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh" grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh"
check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \ check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \
grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh" grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh"
check "templates lib: the fetch carries no credential" 1 "" \
grep -nE 'Authorization|gh api|GITHUB_TOKEN' "$ROOT/commands/lib/templates.sh"
# The data file is PARSED, never executed: the parse loop reads lines, and
# no source statement may ever reach template.env. Grep-pinned because the
# failure is silent and total — a sourced template.env is arbitrary shell
# running as root at every mint.
check "templates lib: the parser READS template.env line by line" 0 "" \
grep -qF 'while IFS= read -r line' "$ROOT/commands/lib/templates.sh"
check "templates lib: template.env is never sourced" 1 "" \
grep -nE '(source|^[[:space:]]*\.)[[:space:]]+[^#]*template\.env' "$ROOT/commands/lib/templates.sh" "$ROOT/commands/bootstrap-tenant.sh"
# staging-box'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. # shared lib call is the anti-drift property, so pin the call, not the words.
check "tenant: staging-box hardens through the shared sshd lib" 0 "" \ check "tenant: staging-box hardens through the shared sshd lib" 0 "" \
@ -746,7 +900,6 @@ check "tenant: the marker write follows the context-file converge" \
# shellcheck disable=SC2016 # shellcheck disable=SC2016
check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \ check "tenant: the marker write is gated on the resolved root-door, not a spelling" 0 "" \
grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh" grep -qxF 'if [ -z "$EXISTING_ROOT_DOOR" ]; then' "$ROOT/commands/bootstrap-tenant.sh"
check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh" check "coolify: version required, exit 2" 2 "--version" "$ROOT/commands/coolify-install.sh"
check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help check "coolify: --help exits 0" 0 "usage:" "$ROOT/commands/coolify-install.sh" --help
check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version check "coolify: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version

View file

@ -159,6 +159,7 @@ check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "pa
emit() { # emit <outfile> — emit_record with the harness globals staged emit() { # emit <outfile> — emit_record with the harness globals staged
DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \ DRILL_VERSION="9.9.9" RUN_ID="drill-2026-01-01-a" \
REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \ REF="release/9.9.9" BOXREF="release/0.4.0" RIG_SHA="5d6e7f8" BOX_SHA="1a2b3c4" \
TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" \
bash -c ' bash -c '
. "$1" . "$1"
pass=12 fail=1 skipped=1 pass=12 fail=1 skipped=1
@ -173,6 +174,7 @@ check "record: the version-and-date heading" 0 "# Release drill — 9.9.9 — "
check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md" check "record: the run ID that joins the family's records" 0 "Run ID: drill-2026-01-01-a" cat "$WORK/record.md"
check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md" check "record: both pinned refs with their SHAs" 0 "rig@5d6e7f8 (RIG_REF=release/9.9.9)" cat "$WORK/record.md"
check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md" check "record: …box's too" 0 "box@1a2b3c4 (BOX_REF=release/0.4.0)" cat "$WORK/record.md"
check "record: the template registry SHA rides alongside the pair (#110)" 0 "rig-templates@9f8e7d6 (ref 9f8e7d6c5b4a39281706f5e4d3c2b1a098765432)" cat "$WORK/record.md"
check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md" check "record: one table row per leg, result verbatim" 0 "| re-converge (idempotence) | clean, no changes |" cat "$WORK/record.md"
check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md" check "record: the numbers, skips counted apart from passes" 0 "12 passed, 1 failed, 1 skipped" cat "$WORK/record.md"
check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md" check "record: a FAILED run still names what failed (evidence, not success)" 0 "FAIL: coolify container state: absent" cat "$WORK/record.md"