From 29563a124626f101f3fc90b34881d84dce84d507 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 22:53:48 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(templates):=20the=20registry=20lib=20?= =?UTF-8?q?=E2=80=94=20three-knob=20resolution,=20the=20allowlist=20parser?= =?UTF-8?q?,=20the=20definition=20lint=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role-template registry moves out of rig's tree (heavy-duty/rig-templates, ruled 2026-07-24: pinned to the rig version by default, overridable per mint). This is the schema-and-resolution half rig keeps: RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree RIG_TEMPLATES_PIN (the BOX_RELEASE discipline), template.env parsed against an allowlist and never sourced, every refusal naming the failing key, and the lint the registry repo's CI will run on every definition. The pin currently names the registry's pre-seed head; it bumps to the seeded tree in this PR's course (the seed PR is the other half of the build task). --- commands/lib/templates.sh | 215 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 commands/lib/templates.sh diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh new file mode 100644 index 0000000..8f1505a --- /dev/null +++ b/commands/lib/templates.sh @@ -0,0 +1,215 @@ +#!/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: tenant-config, +# runner-config). +# +# 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: +# +# /template.env KEY="value" data, parsed against the allowlist +# below and NEVER sourced — a definition cannot +# execute shell through its data file +# /install.sh the CLI install (the one inherently executable part) +# /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. +RIG_TEMPLATES_PIN=30f4fa4dcb4e9f104058ad9dd5b7c42bafa98e73 + +# 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, printed on stdout. RIG_TEMPLATES_DIR wins and is used as-is; +# otherwise the repo@ref tarball is fetched and extracted under a temp dir, +# recorded in TEMPLATES_TMP for the caller to rm. Candidate URLs follow +# install.sh's precedence — a tag outranks a branch that shares its name — +# plus the bare archive/ 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="" +templates_resolve() { + local repo ref url got="" d + if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then + [ -d "$RIG_TEMPLATES_DIR" ] || { + printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2 + return 1 + } + printf '%s\n' "$RIG_TEMPLATES_DIR" + 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= 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 (-, + # 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 + } + d="${1%/}" + printf '%s\n' "$d" +} + +# templates_roles — 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 — 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; } +} + +# template_lint — 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 +} From c9c8ad9ba9b63b7d57f69a732ed432c71715ff98 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 22:54:34 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(templates):=20rig=20template-lint=20?= =?UTF-8?q?=E2=80=94=20the=20registry=20repo's=20CI=20gate,=20dispatched?= =?UTF-8?q?=20from=20bin/rig=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/rig | 10 ++++++++ commands/template-lint.sh | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100755 commands/template-lint.sh diff --git a/bin/rig b/bin/rig index 690432e..70d2513 100755 --- a/bin/rig +++ b/bin/rig @@ -95,6 +95,12 @@ commands: 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 — and while no admin holds a key. Run as root. + template-lint ... + 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 [] Print /etc/rig/manifest — which rig converged this machine and when (bootstrapped_by/_at pin the FIRST convergence forever; converged_by/_at @@ -455,6 +461,10 @@ case "$cmd" in ;; esac ;; + template-lint) + shift + exec "$ROOT/commands/template-lint.sh" "$@" + ;; manifest) shift exec "$ROOT/commands/manifest.sh" "$@" diff --git a/commands/template-lint.sh b/commands/template-lint.sh new file mode 100755 index 0000000..3350130 --- /dev/null +++ b/commands/template-lint.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# rig template-lint ... — 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 ... + +Validate tenant-role definitions (the heavy-duty/rig-templates shape): +each 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" From b99d08ea3d1cb69ee8d61f45198cc64b7b680308 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 23:00:41 +0000 Subject: [PATCH 3/7] =?UTF-8?q?feat(bootstrap):=20the=20tenant=20mechanism?= =?UTF-8?q?=20converges=20from=20fetched=20definitions=20=E2=80=94=20the?= =?UTF-8?q?=20agent=20case=20arms=20are=20cut=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bootstrap-tenant.sh is one mechanism parameterized by a registry definition (template.env parsed against the allowlist, install.sh run as root with the tenant in its environment, creds.md spliced into the shared context skeleton). staging-box stays in-tree — mechanism-adjacent, no agent. The dispatch in bootstrap.sh moves to the '-box' family-suffix rule so a template added to the registry is mintable with zero code changes here; an unknown role's refusal lists what the resolved source actually contains, naming the source. lib/tenant-config.sh — the per-tenant case-arm table — is deleted: the hard cut, #76's discipline. --- commands/bootstrap-tenant.sh | 309 ++++++++++++++++++---------------- commands/bootstrap.sh | 24 ++- commands/lib/templates.sh | 71 ++++++-- commands/lib/tenant-config.sh | 106 ------------ 4 files changed, 237 insertions(+), 273 deletions(-) delete mode 100644 commands/lib/tenant-config.sh diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index 17a6ccb..716b4c7 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -1,24 +1,34 @@ #!/usr/bin/env bash -# rig bootstrap — the box TENANT -# roles ('-box' names the family: a guest, vs the '-server' machine roles): what a -# box-minted guest becomes (issue #31). box mints the thin, creds-free seed -# (base image, user, rig preinstalled — heavy-duty/box#81); rig converges the -# tenant content that used to live in the templates' cloud-init, idempotent and -# effective-state asserted, so an EXISTING box can be re-run to a new spec -# instead of re-minted. One mechanism, parameterized per tenant through -# lib/tenant-config.sh — never four hand-maintained copies. +# rig bootstrap -box — the box TENANT roles ('-box' names the family: a +# guest, vs the '-server' machine roles): what a box-minted guest becomes +# (issue #31). box mints the thin, creds-free seed (base image, user, rig +# preinstalled — heavy-duty/box#81); rig converges the tenant content that +# used to live in the templates' cloud-init, idempotent and effective-state +# asserted, so an EXISTING box can be re-run to a new spec instead of +# re-minted. +# +# One MECHANISM, parameterized per tenant by a fetched DEFINITION (#110): the +# agent-tenant registry lives in heavy-duty/rig-templates — one directory per +# role (template.env, install.sh, creds.md), resolved through lib/templates.sh +# (RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin) — so adding a +# tenant is a data PR there, never an edit here (#109 is the scar: adding +# kimi, pure data, meant editing six files in this repo). staging-box is the +# one in-tree tenant: it is mechanism-adjacent (sshd hardening, docker — no +# agent, no CLI, no context file), so it converges from rig's own tree. # # Creds-free BY CONTRACT: box auto-runs these at mint ('box exec … rig # bootstrap claude-box'), so every path here is non-interactive and nothing joins -# or admits — no tailnet, no keys, no prompts. staging-box's tailnet join stays -# operator-run ('rig bootstrap workload-server' through 'box shell'), exactly the -# creds split box#69 designed. +# or admits — no tailnet, no keys, no prompts. That is also why the registry +# fetch is UNAUTHENTICATED: a mint holds nothing to authenticate with. +# staging-box's tailnet join stays operator-run ('rig bootstrap +# workload-server' through 'box shell'), exactly the creds split box#69 +# designed. # Convergent: safe to re-run; a second run changes nothing. set -euo pipefail HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)" -# shellcheck source=SCRIPTDIR/lib/tenant-config.sh -. "$HERE/lib/tenant-config.sh" # tenant_user / tenant_context_path / render_tenant_context +# shellcheck source=SCRIPTDIR/lib/templates.sh +. "$HERE/lib/templates.sh" # templates_resolve / template_parse_env / render_tenant_context # shellcheck source=SCRIPTDIR/lib/users-config.sh . "$HERE/lib/users-config.sh" # read_role_marker / root_door_of # shellcheck source=SCRIPTDIR/lib/sshd.sh @@ -32,26 +42,35 @@ die() { printf 'rig-bootstrap: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; } usage() { cat <<'EOF' -usage: rig bootstrap [--user ] +usage: rig bootstrap -box [--user ] Box TENANT roles — what a box-minted guest becomes. box mints the thin, creds-free seed (base image, user, rig preinstalled); this converges the tenant on top, and re-runs converge an existing box to a new spec. - claude-box|codex-box|grok-box|kimi-box - the agent tenants: base tooling (git, gh, tmux, …), + -box an agent tenant DEFINED IN THE REGISTRY + (heavy-duty/rig-templates — claude-box, codex-box, + grok-box, kimi-box, …): base tooling (git, gh, tmux, …), docker, the agent's CLI on the system PATH, and the agent-context file — including the box#80 guard: never run `box setup-host` or the drill inside a box. - staging-box the server tenant (box#69's posture): docker + sshd - hardening. The tailnet workload join is deliberately - NOT here — it holds a credential, so it stays - operator-run: `box shell` → `sudo rig bootstrap - workload-server` with a tagged pre-auth key. + staging-box the server tenant (box#69's posture), in rig's own + tree: docker + sshd hardening. The tailnet workload + join is deliberately NOT here — it holds a credential, + so it stays operator-run: `box shell` → `sudo rig + bootstrap workload-server` with a tagged pre-auth key. --user the tenant user the box seed created (default: the - role's name minus the suffix; staging-box defaults to - `ops`) + definition's USER; staging-box defaults to `ops`) + +The registry source is three knobs, precedence high to low: + RIG_TEMPLATES_DIR a local folder (no fetch — the offline/test path, and + "try a template before it exists anywhere") + RIG_TEMPLATES_REF a ref of RIG_TEMPLATES_REPO (default + heavy-duty/rig-templates), fetched as a tarball + (neither set) the ref pinned in rig's tree (lib/templates.sh + RIG_TEMPLATES_PIN — bumped by ordinary rig PR, so a + rig release freezes the mechanism+registry pair) Tenant roles are creds-free and non-interactive by contract — box auto-runs them at mint (`box exec … rig bootstrap claude-box`). They take none of the @@ -63,19 +82,29 @@ EOF # --- args (validated before the root check, so errors are testable) --------- ROLE="${1:-}" case "$ROLE" in - claude-box|codex-box|grok-box|kimi-box|staging-box) shift ;; + staging-box) shift ;; + *-box) + # The family suffix is the whole gate here — WHICH '-box' roles exist is + # the resolved registry's fact, checked below, so a template added to the + # registry is mintable with zero code changes in rig (#110). + shift ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "tenant role required (claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; - *) die "unknown tenant role: $ROLE (want claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; + "") usage >&2; die "tenant role required (a '-box' role from the template registry, or staging-box)" 2 ;; + *) die "unknown tenant role: $ROLE — tenant roles carry the '-box' family suffix (#76); the machine roles are control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom" 2 ;; esac +# The suffix rule above admits ANY '-box' name, so the charset is pinned +# before the name is ever used as a path component: a crafted role dies HERE, +# never in a registry lookup (the valid_version discipline, bin/rig). +[[ "$ROLE" =~ ^[a-z][a-z0-9-]*-box$ ]] \ + || die "invalid tenant role name: '$ROLE' — must match ^[a-z][a-z0-9-]*-box\$" 2 -TENANT_USER="$(tenant_user "$ROLE")" +TENANT_USER_OVERRIDE="" while [ $# -gt 0 ]; do case "$1" in -h|--help) usage; exit 0 ;; --user) [ $# -ge 2 ] || die "--user needs a value" 2 - TENANT_USER="$2"; shift 2 ;; + TENANT_USER_OVERRIDE="$2"; shift 2 ;; --hostname|--root-door|--host|--join) # The machine-role traits, refused with a story rather than "unknown # flag": a tenant is a guest, not a tailnet machine — its shape comes @@ -89,6 +118,32 @@ while [ $# -gt 0 ]; do *) die "unknown flag: $1" 2 ;; esac done + +# --- the definition ---------------------------------------------------------- +# Resolved and parsed BEFORE the root check, so the two refusals a definition +# can earn — unknown role (listing what the resolved source actually +# contains) and malformed data (naming the failing key) — are testable +# non-root, offline, via RIG_TEMPLATES_DIR fixtures. The parse is the mint's +# own guard, deliberately duplicating the registry CI's lint: CI protects the +# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that +# CI never saw. template.env is parsed, NEVER sourced — a definition cannot +# execute arbitrary shell through its data file; install.sh is the one +# deliberately executable part, and it runs only after the root check below. +trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT +TPL_DIR="" +if [ "$ROLE" = "staging-box" ]; then + TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops +else + templates_resolve \ + || die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2 + TPL_DIR="$REGISTRY_DIR/$ROLE" + if [ ! -f "$TPL_DIR/template.env" ]; then + die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2 + fi + template_parse_env "$TPL_DIR/template.env" \ + || die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2 + TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}" +fi # Same charset the users file enforces, for the same reasons (a leading '-' # reads as a usermod flag; '|', ':' corrupt things downstream). [[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ @@ -178,19 +233,18 @@ append_line_once() { export DEBIAN_FRONTEND=noninteractive log "installing base packages (tenant ${ROLE})" apt-get update -qq -case "$ROLE" in - claude-box) - # The claude-box tenant keeps zsh (its shell UX ships with the box); the - # remaining list is the shared agent toolbelt the templates carried. - apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential zsh ;; - codex-box|grok-box|kimi-box) - apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential ;; - staging-box) - # openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/, - # which only exists once the package is installed — pristine container/VM - # images (and thin seeds) do not ship it. - apt-get install -y -qq curl ca-certificates tmux openssh-server ;; -esac +if [ "$ROLE" = "staging-box" ]; then + # openssh-server: the hardening drop-in below targets /etc/ssh/sshd_config.d/, + # which only exists once the package is installed — pristine container/VM + # images (and thin seeds) do not ship it. + apt-get install -y -qq curl ca-certificates tmux openssh-server +else + # The shared agent toolbelt the templates carried, plus the definition's + # APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a + # word list, every word already vetted by the parser's package-name gate. + # shellcheck disable=SC2086 + apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS +fi # Assert the effective toolbelt, not apt's exit code — tmux is the box#65 # contract ('box tmux' runs tmux new-session inside every box) and gh is how # the operator's git credential lands. @@ -232,17 +286,19 @@ else warn "no docker group after install — skipping the ${TENANT_USER} group add; check docker's install" fi -# --- node (claude-box, codex-box) ---------------------------------------------------- -# Codex is an npm global needing Node 22+ (the SCOPED @openai/codex — verified -# upstream when the template was written); the claude-box tenant ships node as part -# of its toolbelt, same pin. grok's CLI is a self-contained binary: no node. +# --- node (definitions carrying NEEDS_NODE="yes") ---------------------------- +# An npm-installed CLI needs Node 22+ (codex — the SCOPED @openai/codex, +# verified upstream when the template was written); claude ships node as part +# of its toolbelt, same pin. Whether a tenant needs it is the DEFINITION's +# fact (NEEDS_NODE), never a role list here — grok's CLI is a self-contained +# binary and kimi's is uv-managed Python, so both say no. node_ok() { command -v node >/dev/null 2>&1 || return 1 local major major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+)\..*$/\1/')" [ "${major:-0}" -ge 22 ] 2>/dev/null } -if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then +if [ "$ROLE" != "staging-box" ] && [ "$TPL_NEEDS_NODE" = "yes" ]; then if node_ok; then log "node $(node --version) already present" else @@ -254,60 +310,53 @@ if [ "$ROLE" = "claude-box" ] || [ "$ROLE" = "codex-box" ]; then fi # --- the agent CLI ----------------------------------------------------------- -# Per-agent install, shared discipline: install only when the CLI is absent -# (upgrades are the CLI's own business), then put it on the SYSTEM path — -# 'box exec -- …' runs a NON-interactive shell that reads no rc -# files, so a PATH export alone is invisible to it (the #15 lesson) — and -# assert it ANSWERS as the tenant user: a CLI that exists but cannot run is -# what cost the last drill (the grok-box template's scar). +# Per-definition install, shared discipline: install only when the CLI is +# absent (upgrades are the CLI's own business) — presence is CLI_SRC when the +# definition names one, `command -v` when it does not (an npm global's path +# is the prefix's fact, not the data file's) — then put it on the SYSTEM +# path: 'box exec -- …' runs a NON-interactive shell that reads +# no rc files, so a PATH export alone is invisible to it (the #15 lesson). +# And assert it ANSWERS as the tenant user: a CLI that exists but cannot run +# is what cost the last drill (the grok-box template's scar). +# +# install.sh — the definition's one executable part — runs AS ROOT with the +# tenant named in its environment (TENANT_USER/TENANT_HOME/TENANT_GROUP/ROLE); +# each definition drops to the tenant user itself (runuser -l) where the +# vendor's layout demands it, because some installs are inherently root's +# (codex's npm -g writes the global prefix). This is the trade #110 states in +# bold — a registry definition executes as root inside every future mint — +# and it is why install.sh diffs there are the highest-trust review surface +# in the org, why the default ref is a reviewed in-tree pin, and why the data +# file beside it is parsed rather than sourced. CLI="" CLI_SRC="" -case "$ROLE" in - claude-box) - CLI=claude CLI_SRC="$TENANT_HOME/.local/bin/claude" - if [ ! -e "$CLI_SRC" ]; then - log "installing the Claude Code CLI as ${TENANT_USER}" - runuser -l "$TENANT_USER" -c 'curl -fsSL https://claude.ai/install.sh | bash' - else - log "claude CLI already installed" - fi ;; - codex-box) - CLI=codex - if ! command -v codex >/dev/null 2>&1; then - log "installing the Codex CLI (npm global)" - npm install -g @openai/codex - else - log "codex CLI already installed" - fi - CLI_SRC="$(npm prefix -g)/bin/codex" ;; - grok-box) - # The OFFICIAL installer (x.ai/cli/install.sh): installs the CLI as `grok`, - # a SYMLINK under $HOME/.grok/bin pointing into its versioned download dir. - # Run it AS the tenant user, never root: a symlink into root's 0700 home - # would be a CLI that exists and cannot run. - CLI=grok CLI_SRC="$TENANT_HOME/.grok/bin/grok" - if [ ! -e "$CLI_SRC" ]; then - log "installing the Grok CLI as ${TENANT_USER}" - runuser -l "$TENANT_USER" -c 'curl -fsSL https://x.ai/cli/install.sh | bash' - else - log "grok CLI already installed" - fi ;; - kimi-box) - # The OFFICIAL installer (code.kimi.com/install.sh): a uv-managed Python - # tool (kimi-cli), landing `kimi` in ~/.local/bin — uv's tool bin — with - # uv bringing its own managed CPython, so no apt python pin here (the - # node section above stays claude/codex-only for the same reason). Run AS - # the tenant user, never root: grok's lesson — a root-owned install under - # a 0700 home is a CLI that exists and cannot run. - CLI=kimi CLI_SRC="$TENANT_HOME/.local/bin/kimi" - if [ ! -e "$CLI_SRC" ]; then - log "installing the Kimi CLI as ${TENANT_USER}" - runuser -l "$TENANT_USER" -c 'curl -LsSf https://code.kimi.com/install.sh | bash' - else - log "kimi CLI already installed" - fi ;; - staging-box) ;; # no agent lives on the staging-box tenant -esac -if [ -n "$CLI" ]; then +if [ "$ROLE" != "staging-box" ]; then + CLI="$TPL_CLI_NAME" + # '~/' in CLI_SRC is data — expanded to the tenant home HERE, by string + # substitution, never by the shell (hence the literal quoted tilde, SC2088). + # shellcheck disable=SC2088 + case "$TPL_CLI_SRC" in + '~/'*) CLI_SRC="$TENANT_HOME/${TPL_CLI_SRC#'~/'}" ;; + *) CLI_SRC="$TPL_CLI_SRC" ;; + esac + installed="" + if [ -n "$CLI_SRC" ]; then + [ -e "$CLI_SRC" ] && installed=1 + elif command -v "$CLI" >/dev/null 2>&1; then + installed=1 + fi + if [ -z "$installed" ]; then + log "installing the ${CLI} CLI (${ROLE}'s install.sh)" + TENANT_USER="$TENANT_USER" TENANT_HOME="$TENANT_HOME" \ + TENANT_GROUP="$TENANT_GROUP" ROLE="$ROLE" \ + bash "$TPL_DIR/install.sh" \ + || die "${ROLE}'s install.sh failed — the definition is $(templates_source_desc)" + else + log "${CLI} CLI already installed" + fi + if [ -z "$CLI_SRC" ]; then + CLI_SRC="$(command -v "$CLI" 2>/dev/null || true)" + [ -n "$CLI_SRC" ] || die "the ${CLI} installer put no '${CLI}' on root's PATH and the definition names no CLI_SRC — upstream layout changed?" + fi [ -e "$CLI_SRC" ] || die "the ${CLI} installer produced no ${CLI_SRC} — upstream layout changed?" ln -sf "$CLI_SRC" "/usr/local/bin/$CLI" # One capture serves both the assert and the log line; emptiness IS the @@ -316,29 +365,23 @@ if [ -n "$CLI" ]; then [ -n "$CLI_VER" ] || die "'$CLI --version' does not answer for ${TENANT_USER} — the CLI landed but cannot run; check /usr/local/bin/$CLI and its target" log "${CLI} CLI on the system PATH and answering (${CLI_VER})" - # The interactive-shell PATH exports the templates carried, converged as - # literal rc lines (written once, never duplicated). Single quotes are the - # point: the line must expand in the USER's shell, not here. - # shellcheck disable=SC2016 - case "$ROLE" in - claude-box) - append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;; - codex-box) - append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$(npm prefix -g)/bin:$PATH"' ;; - grok-box) - append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.grok/bin:$PATH"' ;; - kimi-box) - append_line_once "$TENANT_HOME/.bashrc" 'export PATH="$HOME/.local/bin:$PATH"' ;; - esac + # The interactive-shell PATH export the templates carried, converged as a + # literal rc line (written once, never duplicated). The definition's + # PATH_LINE is DATA, appended verbatim: it must expand in the USER's + # shell, not here. + append_line_once "$TENANT_HOME/.bashrc" "$TPL_PATH_LINE" fi # --- the agent-context file -------------------------------------------------- -# The one file every agent reads before touching anything. Rendered from -# lib/tenant-config.sh — the box#80 guard note ("never run box setup-host or -# the drill inside a box; the box you are in is not a host you own") lives -# there ONCE, for all agents, instead of copy-pasted per template. cmp-guarded -# like every file rig converges. -if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then +# The one file every agent reads before touching anything. The skeleton — +# including the box#80 guard note ("never run box setup-host or the drill +# inside a box; the box you are in is not a host you own") — is MECHANISM, +# rendered from lib/templates.sh ONCE for all agents, never copy-pasted per +# template; only the creds paragraph is the definition's (creds.md). +# cmp-guarded like every file rig converges. staging-box has no agent and no +# context file. +if [ "$ROLE" != "staging-box" ]; then + CTX_PATH="$TENANT_HOME/$TPL_CONTEXT_PATH" CTX_DIR="$(dirname "$CTX_PATH")" if [ ! -d "$CTX_DIR" ]; then mkdir -p "$CTX_DIR" @@ -348,7 +391,7 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then # its ownership is converged on every run, not only on creation. chown "$TENANT_USER:$TENANT_GROUP" "$CTX_DIR" CTX_TMP="$(mktemp)" - render_tenant_context "$ROLE" > "$CTX_TMP" + render_tenant_context "$ROLE" "$TPL_DIR/creds.md" > "$CTX_TMP" if ! cmp -s "$CTX_TMP" "$CTX_PATH" 2>/dev/null; then install -m 0644 -o "$TENANT_USER" -g "$TENANT_GROUP" "$CTX_TMP" "$CTX_PATH" log "agent-context file written: ${CTX_PATH}" @@ -358,32 +401,6 @@ if CTX_PATH="$(tenant_context_path "$ROLE" "$TENANT_HOME")"; then rm -f "$CTX_TMP" fi -# --- claude-box shell niceties --------------------------------------------------- -# The claude-box template shipped zsh + oh-my-zsh + tmux mouse mode; they move with -# the tenant. oh-my-zsh is a cosmetic EXTRA: its failure warns, never aborts a -# bootstrap whose real work (CLI, context, docker) already converged. -if [ "$ROLE" = "claude-box" ]; then - if [ "$(getent passwd "$TENANT_USER" | cut -d: -f7)" != "/usr/bin/zsh" ]; then - chsh -s /usr/bin/zsh "$TENANT_USER" - log "login shell set to zsh for ${TENANT_USER}" - else - log "login shell already zsh for ${TENANT_USER}" - fi - if [ ! -d "$TENANT_HOME/.oh-my-zsh" ]; then - log "installing oh-my-zsh for ${TENANT_USER}" - # Single quotes on purpose: the $(...) must run in the USER's shell. - # shellcheck disable=SC2016 - runuser -l "$TENANT_USER" -c 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"' \ - || warn "oh-my-zsh install failed — cosmetic only; continuing" - else - log "oh-my-zsh already installed" - fi - # After oh-my-zsh (it rewrites .zshrc on first install). - # shellcheck disable=SC2016 - append_line_once "$TENANT_HOME/.zshrc" 'export PATH="$HOME/.local/bin:$PATH"' - append_line_once "$TENANT_HOME/.tmux.conf" 'set -g mouse on' -fi - # --- staging-box server posture -------------------------------------------------- # box#69's posture, minus the join: docker (above) + sshd hardening, through # the SAME code the machine roles use (lib/sshd.sh) — the staging-box guest is a diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 81ace2c..04f63ac 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -29,9 +29,11 @@ usage: rig bootstrap | --no-users) [--hostname ] [--root-door ] [--host ] [--join ] - rig bootstrap [--user ] - (the box TENANT roles — see their own --help; they take - no --users, see below) + rig bootstrap -box [--user ] + (the box TENANT roles — the agent tenants come from the + heavy-duty/rig-templates registry, staging-box from + rig's own tree; see their own --help — they take no + --users, see below) rig bootstrap --undo leave the tailnet only when the role marker proves rig performed the join, then remove the role marker @@ -59,7 +61,8 @@ and per-human accounts keep attribution intact for the times someone does go in. So the complete path is the default path and skipping it is a deliberate --no-users, not an omission. ---users does NOT reach the box TENANT roles (claude-box|codex-box|grok-box|kimi-box|staging-box). A +--users does NOT reach the box TENANT roles (any '-box' name, e.g. +claude-box, staging-box). A tenant is a box-minted GUEST: box auto-runs its bootstrap at mint, non-interactively, with no file to hand it; the guest never joins the tailnet and has no SSH door of its own — entry is `box shell`, gated by the HOST's @@ -122,15 +125,18 @@ case "$ROLE" in [ $# -eq 0 ] || die "bootstrap --undo takes no arguments" 2 exec "$HERE/bootstrap-undo.sh" ;; control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom) shift ;; - claude-box|codex-box|grok-box|kimi-box|staging-box) + *-box) # The box TENANT roles (#31) are a different family — guests a box mints, # never tailnet machines — and live in their own mechanism, one script - # parameterized per tenant. Dispatched here so `rig bootstrap ` - # stays the single entrypoint for both families. + # parameterized per DEFINITION fetched from the template registry (#110; + # staging-box stays in-tree). Dispatched on the FAMILY SUFFIX (#76), not + # an enumerated list: which '-box' roles exist is the registry's fact, so + # a template added there is mintable with zero code changes here. + # `rig bootstrap ` stays the single entrypoint for both families. exec "$HERE/bootstrap-tenant.sh" "$@" ;; -h|--help) usage; exit 0 ;; - "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; - *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a tenant role: claude-box|codex-box|grok-box|kimi-box|staging-box)" 2 ;; + "") usage >&2; die "role required (control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;; + *) die "unknown role: $ROLE (want control-plane-server|workload-server|runner-server|staging-server|dev-server|workstation|custom — or a '-box' tenant role from the template registry, e.g. claude-box)" 2 ;; esac # Role→traits map — the single place a role's shape is declared (issue #26). diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh index 8f1505a..7dba675 100644 --- a/commands/lib/templates.sh +++ b/commands/lib/templates.sh @@ -55,23 +55,27 @@ templates_source_desc() { } # templates_resolve — resolve the three knobs to a LOCAL directory holding -# the registry, printed on stdout. RIG_TEMPLATES_DIR wins and is used as-is; -# otherwise the repo@ref tarball is fetched and extracted under a temp dir, -# recorded in TEMPLATES_TMP for the caller to rm. Candidate URLs follow -# install.sh's precedence — a tag outranks a branch that shares its name — -# plus the bare archive/ form, which is how a commit-SHA pin (the -# default) downloads. Failure lists every URL tried: the fetch is -# unauthenticated by contract (box auto-runs bootstrap at mint, holding -# nothing), so "is the repo public and the ref real" is the whole diagnosis. +# the registry, left in the REGISTRY_DIR global (a global, not stdout: a +# $(…) call site would run the fetch in a subshell and lose TEMPLATES_TMP, +# the path the caller's cleanup trap must rm). RIG_TEMPLATES_DIR wins and is +# used as-is; otherwise the repo@ref tarball is fetched and extracted under +# a temp dir, recorded in TEMPLATES_TMP. Candidate URLs follow install.sh's +# precedence — a tag outranks a branch that shares its name — plus the bare +# archive/ form, which is how a commit-SHA pin (the default) downloads. +# Failure lists every URL tried: the fetch is unauthenticated by contract +# (box auto-runs bootstrap at mint, holding nothing), so "is the repo public +# and the ref real" is the whole diagnosis. TEMPLATES_TMP="" +# shellcheck disable=SC2034 # REGISTRY_DIR is this function's OUTPUT, read by the sourcing script +REGISTRY_DIR="" templates_resolve() { - local repo ref url got="" d + local repo ref url got="" if [ -n "${RIG_TEMPLATES_DIR:-}" ]; then [ -d "$RIG_TEMPLATES_DIR" ] || { printf 'RIG_TEMPLATES_DIR is not a directory: %s\n' "$RIG_TEMPLATES_DIR" >&2 return 1 } - printf '%s\n' "$RIG_TEMPLATES_DIR" + REGISTRY_DIR="$RIG_TEMPLATES_DIR" return 0 fi repo="${RIG_TEMPLATES_REPO:-heavy-duty/rig-templates}" @@ -104,8 +108,8 @@ templates_resolve() { printf 'the registry tarball from %s does not hold exactly one top-level directory\n' "$got" >&2 return 1 } - d="${1%/}" - printf '%s\n' "$d" + # shellcheck disable=SC2034 # the function's output global, read by the sourcing script + REGISTRY_DIR="${1%/}" } # templates_roles — the roles a registry defines: its @@ -188,6 +192,49 @@ template_parse_env() { esac [ -n "$TPL_PATH_LINE" ] \ || { printf 'template.env: PATH_LINE: must not be empty\n' >&2; return 1; } + # Every word must be a sane package name — the list is handed to apt-get + # unquoted by design, and this is what keeps an option ('-o …') or a path + # from riding in through the data file. + local pkg + for pkg in $TPL_APT_EXTRAS; do + [[ "$pkg" =~ ^[a-z0-9][a-z0-9.+-]*$ ]] \ + || { printf 'template.env: APT_EXTRAS: not a sane package name: %s\n' "$pkg" >&2; return 1; } + done +} + +# render_tenant_context — 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 < — the whole-definition check the registry repo's diff --git a/commands/lib/tenant-config.sh b/commands/lib/tenant-config.sh deleted file mode 100644 index 2b3760b..0000000 --- a/commands/lib/tenant-config.sh +++ /dev/null @@ -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 — 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 — 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 /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 — 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 < Date: Fri, 24 Jul 2026 23:02:12 +0000 Subject: [PATCH 4/7] feat(drill): the record cites the rig-templates SHA the converge read (#110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override via RIG_TEMPLATES_REF when the drill was pointed somewhere; else the pin read from the INSTALLED candidate tree — what actually landed, never this checkout's copy. --- drill/drill.sh | 25 +++++++++++++++++++++++++ test/drill.sh | 2 ++ 2 files changed, 27 insertions(+) diff --git a/drill/drill.sh b/drill/drill.sh index 50d2832..7671d53 100644 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -51,6 +51,14 @@ REPO="${RIG_REPO:-heavy-duty/rig}" REF="${RIG_REF:-}" BOXREPO="${BOX_REPO:-heavy-duty/box}" 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 USERS_FILE="${DRILL_USERS_FILE:-}" 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 'Candidate refs: rig@%s (RIG_REF=%s), box@%s (BOX_REF=%s).\n' \ "${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 '| Leg | Result |\n' printf '| --- | --- |\n' @@ -378,6 +388,21 @@ RIG_TREE="$(tree_of "$(command -v rig)")" assert_installed_from rig "$RIG_TREE" "$REPO@$REF" || exit 1 DRILL_VERSION="$(head -n1 "$RIG_TREE/VERSION" 2>/dev/null || echo unknown)" 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" # ============================================================================= diff --git a/test/drill.sh b/test/drill.sh index dbb6477..b189931 100644 --- a/test/drill.sh +++ b/test/drill.sh @@ -159,6 +159,7 @@ check "…and the diff names the drifted sshd keyword, not just 'differs'" 1 "pa emit() { # emit — emit_record with the harness globals staged 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" \ + TPLREPO="heavy-duty/rig-templates" TPLREF="9f8e7d6c5b4a39281706f5e4d3c2b1a098765432" TPL_SHA="9f8e7d6" \ bash -c ' . "$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: 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: 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: 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" From 79363c2631099d93d02c55431fe4a229aa72772e Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 23:08:47 +0000 Subject: [PATCH 5/7] =?UTF-8?q?test:=20the=20tenant=20surface=20re-proven?= =?UTF-8?q?=20against=20fixture=20registries=20=E2=80=94=20offline,=20by?= =?UTF-8?q?=20refusal=20and=20by=20identity=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite drives the whole new surface without a network: the family-suffix dispatch and hard cut, the marker guards firing with the registry unreachable, the unknown-role refusal listing the resolved source, every malformed-definition refusal named by key, DIR-vs-REF identity through a stubbed curl (tags-first precedence pinned), the renderer's box#80 guard, the one-line pin, and template-lint's whole gate. --- bin/rig | 18 +- commands/bootstrap-tenant.sh | 66 ++++---- test/cli.sh | 313 ++++++++++++++++++++++++++--------- 3 files changed, 279 insertions(+), 118 deletions(-) diff --git a/bin/rig b/bin/rig index 70d2513..4ec2cb3 100755 --- a/bin/rig +++ b/bin/rig @@ -37,15 +37,19 @@ commands: Leave the tailnet only when /etc/rig/role proves rig performed the join, then remove that marker. Refuses for pre-existing or old unknown joins, and while a GitHub runner is installed. Run as root. - bootstrap [--user ] + bootstrap -box [--user ] The box TENANT roles: converge a box-minted guest. The '-box' suffix names the family (a guest, vs the '-server' machine roles above). - claude-box|codex-box|grok-box|kimi-box land the agent toolbelt (git, gh, tmux, docker), the agent's CLI on - the system PATH, and the agent-context file (with the box#80 guard: - never run box setup-host inside a box). staging-box lands box#69's - server posture — docker + sshd hardening; its tailnet join stays - operator-run via 'rig bootstrap workload-server'. Creds-free and non-interactive: box - auto-runs these at mint. Run as root, inside the box. + The agent tenants (claude-box, codex-box, grok-box, kimi-box, …) are + DEFINED in the heavy-duty/rig-templates registry — resolved via + RIG_TEMPLATES_DIR > RIG_TEMPLATES_REF > the in-tree pin — and land + the agent toolbelt (git, gh, tmux, docker), the agent's CLI on the + system PATH, and the agent-context file (with the box#80 guard: + never run box setup-host inside a box). staging-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 Pinned Coolify install (AUTOUPDATE=false). Control-plane box only. coolify backup install [options] diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index 716b4c7..1eb9d12 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -104,7 +104,13 @@ while [ $# -gt 0 ]; do -h|--help) usage; exit 0 ;; --user) [ $# -ge 2 ] || die "--user needs a value" 2 - TENANT_USER_OVERRIDE="$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) # The machine-role traits, refused with a story rather than "unknown # flag": a tenant is a guest, not a tailnet machine — its shape comes @@ -119,36 +125,6 @@ while [ $# -gt 0 ]; do esac done -# --- the definition ---------------------------------------------------------- -# Resolved and parsed BEFORE the root check, so the two refusals a definition -# can earn — unknown role (listing what the resolved source actually -# contains) and malformed data (naming the failing key) — are testable -# non-root, offline, via RIG_TEMPLATES_DIR fixtures. The parse is the mint's -# own guard, deliberately duplicating the registry CI's lint: CI protects the -# registry, this protects a mint served through RIG_TEMPLATES_REPO/_DIR that -# CI never saw. template.env is parsed, NEVER sourced — a definition cannot -# execute arbitrary shell through its data file; install.sh is the one -# deliberately executable part, and it runs only after the root check below. -trap '[ -n "$TEMPLATES_TMP" ] && rm -rf "$TEMPLATES_TMP"' EXIT -TPL_DIR="" -if [ "$ROLE" = "staging-box" ]; then - TENANT_USER="${TENANT_USER_OVERRIDE:-ops}" # box#69's ops -else - templates_resolve \ - || die "cannot resolve the template registry ($(templates_source_desc)) — see above" 2 - TPL_DIR="$REGISTRY_DIR/$ROLE" - if [ ! -f "$TPL_DIR/template.env" ]; then - die "unknown tenant role: $ROLE — the resolved registry ($(templates_source_desc)) defines: $(templates_roles "$REGISTRY_DIR" | tr '\n' ' ')— and staging-box is in rig's own tree. A misconfigured RIG_TEMPLATES_REPO/_REF/_DIR looks exactly like this; check the source before the spelling." 2 - fi - template_parse_env "$TPL_DIR/template.env" \ - || die "invalid definition for $ROLE in $(templates_source_desc) — the failing key is named above. The registry's CI lints every PR ('rig template-lint'); a malformed definition reaching a mint means the source above was never linted." 2 - TENANT_USER="${TENANT_USER_OVERRIDE:-$TPL_USER}" -fi -# Same charset the users file enforces, for the same reasons (a leading '-' -# reads as a usermod flag; '|', ':' corrupt things downstream). -[[ "$TENANT_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] \ - || die "invalid user: '$TENANT_USER' — must match ^[a-z_][a-z0-9_-]{0,31}\$" 2 - # --- guards ------------------------------------------------------------------ # A tenant role converges a box GUEST. A box already carrying a machine-role # marker is a tailnet machine rig built on purpose, and quietly turning it into @@ -195,6 +171,34 @@ if [ -n "$EXISTING_ROOT_DOOR" ]; then 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" if [ -r /etc/os-release ]; then # Sourced in a subshell: os-release defines VERSION, NAME, ID, etc. — diff --git a/test/cli.sh b/test/cli.sh index 54acc30..450f064 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -572,19 +572,53 @@ else echo "skip: bootstrap non-root refusals (running as root)" fi -# --- box tenant roles (#31/#76): claude-box|codex-box|grok-box|kimi-box|staging-box --- +# --- box tenant roles (#31/#76/#110): -box from the registry + staging-box --- # What a box-minted guest becomes — ONE mechanism (bootstrap-tenant.sh), -# parameterized per tenant through lib/tenant-config.sh, dispatched from -# bootstrap.sh so `rig bootstrap ` stays the single entrypoint. 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: the whole -# arg/refusal surface, the pure parameter table, the rendered agent-context -# file (guard note included), and grep-pins on the shipped script. +# parameterized per DEFINITION fetched from the template registry +# (heavy-duty/rig-templates; lib/templates.sh resolves RIG_TEMPLATES_DIR > +# RIG_TEMPLATES_REF > the in-tree pin), dispatched from bootstrap.sh on the +# '-box' FAMILY SUFFIX so `rig bootstrap ` stays the single entrypoint +# and a template added to the registry is mintable with zero code changes +# 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 # as UNKNOWN — asserted per name, because an alias left in for one tenant is the -# shape that survives review: the taxonomy reads complete while one old name -# still quietly converges. Checked at BOTH entrypoints, since bootstrap.sh has -# its own dispatch list and a name could survive in one and not the other. +# shape that survives review. And the #110 cut on top: the mechanism no longer +# KNOWS any agent tenant by name — which '-box' roles exist is the registry's +# fact, so the old names die on the family-suffix rule, not an enumerated list. for r in claude codex grok staging; do check "tenant: the pre-#76 name '$r' is gone (tenant entrypoint)" 2 "unknown tenant role" \ "$ROOT/commands/bootstrap-tenant.sh" "$r" @@ -593,39 +627,46 @@ for r in claude codex grok staging; do done check "tenant: --help exits 0" 0 "usage:" "$ROOT/commands/bootstrap-tenant.sh" --help check "tenant: role required, exit 2" 2 "tenant role required" "$ROOT/commands/bootstrap-tenant.sh" -check "tenant: unknown role exits 2" 2 "unknown tenant role" "$ROOT/commands/bootstrap-tenant.sh" potato +check "tenant: 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: --user needs value" 2 "needs a value" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user check "tenant: bad --user charset exits 2" 2 "invalid user" "$ROOT/commands/bootstrap-tenant.sh" claude-box --user 'fo|o' -# The docker converge asserts the DAEMON answers, not just the client binary — -# a dead dockerd passing `docker --version` is the "linked but cannot run" -# scar in daemon form. Grep-pinned so the assert cannot ship deleted. +# The suffix rule admits ANY '-box' name, so the charset gate must catch a +# crafted one BEFORE it is used as a path component (the valid_version +# 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 "" \ grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh" # 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" \ "$ROOT/commands/bootstrap-tenant.sh" claude-box --root-door closed check "tenant: --hostname dies the same way" 2 "have no traits" \ "$ROOT/commands/bootstrap-tenant.sh" staging-box --hostname my-guest -# Dispatch: the machine-role entrypoint hands tenant roles to the tenant -# mechanism with args intact (--help reaching the TENANT usage proves both). -check "bootstrap: tenant roles dispatch through bootstrap.sh" 0 "claude-box|codex-box|grok-box|kimi-box|staging-box" \ +# Dispatch: the machine-role entrypoint hands ANY '-box' role to the tenant +# mechanism on the family suffix — enumerating them would re-chain template +# 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 -# The marker guard fires BEFORE the root check (repo precedent: the coolify -# marker warning), so the refusals are provable here off fixture markers. A +check "bootstrap: an unheard-of '-box' role still dispatches (zero code changes)" 0 "Box TENANT roles" \ + "$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, -# 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 -# tolerates ONLY root-door=open with host=no — that is the guest after its -# operator-run workload join, and re-converging it is what convergence is for. -# A closed-door machine (root-door=closed via custom) is NOT that guest, and -# open-door hardening would die at it with root-door=open-specific messaging — -# refuse instead. +# tolerates exactly the workload-joined guest (root-door=open host=no) and refuses the +# rest. These need no registry: the guards run before the resolution, so a +# poisoning converge is refused even when the registry is unreachable. 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=custom root-door=closed host=no join=login\n' > "$TEN_FIX/closed" +printf 'role=workload-server root-door=open host=no join=authkey\n' > "$TEN_FIX/machine" +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" 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 @@ -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 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 - # 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 -# must ask it in BOTH vocabularies. Kept deliberately at the retired spelling, -# same reason as the close-root fixtures below: a pre-#77 box that stops -# looking like a machine here is the fail-OPEN direction of this rename — the -# 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" +# a root-door policy?" through the resolver, so the pre-#77 spelling counts — +# pattern-matching one spelling would fail OPEN here: the marker stops looking +# like a machine's, the refusal never fires, and a tenant converge clobbers a +# live fleet box's marker. 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 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 +# ...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 # RIG_ROLE_MARKER pinned to the absent fixture: the marker guard runs before # the root check, and the harness machine may carry a real /etc/rig/role. - check "tenant: claude-box parses, refuses non-root" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" claude-box - check "tenant: codex-box parses, refuses non-root" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" codex-box - check "tenant: grok-box parses, refuses non-root" 1 "must run as root" \ - env RIG_ROLE_MARKER="$TEN_FIX/absent" "$ROOT/commands/bootstrap-tenant.sh" grok-box + # Reaching the root check proves the whole pre-root surface passed: the + # name, the flags, the guard, the resolution AND the parse. + check "tenant: a valid definition parses, refuses non-root" 1 "must run as root" \ + env RIG_ROLE_MARKER="$TEN_FIX/absent" RIG_TEMPLATES_DIR="$TPL_FIX" \ + "$ROOT/commands/bootstrap-tenant.sh" scratch-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" \ 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 @@ -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" \ 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" \ - 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 echo "skip: tenant non-root refusals (running as root)" fi rm -rf "$TEN_FIX" -# The per-tenant parameter table and the agent-context renderer are pure lib -# functions on purpose (repo precedent: parse_users_file, json_string_array): -# the CLI path to them sits behind root + a real tenant user, so the harness -# proves them here, sourced, non-root and network-free. -tuser() { bash -c 'set -euo pipefail - . "$1/commands/lib/tenant-config.sh"; tenant_user "$2"' _ "$ROOT" "$1"; } -tpath() { bash -c 'set -euo pipefail - . "$1/commands/lib/tenant-config.sh"; tenant_context_path "$2" "$3"' _ "$ROOT" "$1" "$2"; } -tctx() { bash -c 'set -euo pipefail - . "$1/commands/lib/tenant-config.sh"; render_tenant_context "$2"' _ "$ROOT" "$1"; } -check "tenant params: agent users are named after their agent" 0 "claude" tuser claude-box -check "tenant params: kimi's user drops the suffix too" 0 "kimi" tuser kimi-box -check "tenant params: staging's user is box#69's ops" 0 "ops" tuser staging-box -check "tenant params: claude context lands in ~/.claude/CLAUDE.md" 0 "/home/claude/.claude/CLAUDE.md" tpath claude-box /home/claude -check "tenant params: codex context lands in ~/.codex/AGENTS.md" 0 "/home/codex/.codex/AGENTS.md" tpath codex-box /home/codex -check "tenant params: grok context lands in ~/.grok/AGENTS.md" 0 "/home/grok/.grok/AGENTS.md" tpath grok-box /home/grok -check "tenant params: kimi context lands in ~/.kimi/AGENTS.md" 0 "/home/kimi/.kimi/AGENTS.md" tpath kimi-box /home/kimi -check "tenant params: staging has no context file" 1 "" tpath staging-box /home/ops -# The box#80 guard note lives ONCE, in the renderer, and every agent's file -# carries it — the layering decision's whole point: never per-template again. -check "tenant context: claude carries the box#80 guard" 0 "box setup-host" tctx claude-box -check "tenant context: codex carries the box#80 guard" 0 "box setup-host" tctx codex-box -check "tenant context: grok carries the box#80 guard" 0 "box setup-host" tctx grok-box -check "tenant context: kimi carries the box#80 guard" 0 "box setup-host" tctx kimi-box -check "tenant context: the guard says whose host this is not" 0 "not a host you own" tctx claude-box -check "tenant context: the guard cites box#80" 0 "box#80" tctx claude-box -check "tenant context: the creds-free contract is stated" 0 "Creds-free by default" tctx claude-box -check "tenant context: claude names /login as the operator's flow" 0 "/login" tctx claude-box -check "tenant context: codex names its login flow" 0 "login flow (\`codex\`)" tctx codex-box -check "tenant context: grok names its login flow" 0 "grok login" tctx grok-box -check "tenant context: kimi names its login flow" 0 "Kimi Code OAuth" tctx kimi-box -check "tenant context: staging renders nothing (no agent lives there)" 1 "" tctx staging-box +# The same definition served from a REF (tarball fetch, curl stubbed — the +# release.sh discipline) and from a local DIR must resolve to identical +# converge inputs: the parsed TPL_* table and the rendered context file are +# everything the mechanism consumes, so identical inputs ARE the identical +# converge (#110's acceptance criterion, provable offline). +TPL_WORK="$(mktemp -d)" +mkdir -p "$TPL_WORK/bin" "$TPL_WORK/stage/rig-templates-testref" +cp -r "$TPL_FIX"/. "$TPL_WORK/stage/rig-templates-testref/" +tar -czf "$TPL_WORK/reg.tar.gz" -C "$TPL_WORK/stage" rig-templates-testref +cat > "$TPL_WORK/bin/curl" <<'CURLEOF' +#!/usr/bin/env bash +# stub: invoked as `curl -fsSL -o ` by templates_resolve +echo "$2" >> "${CURL_LOG:?}" +cp "${CURL_TARBALL:?}" "$4" +CURLEOF +chmod +x "$TPL_WORK/bin/curl" +# Single quotes deliberate throughout (SC2016): the $-expressions expand in +# the INNER bash, against the sourced lib's state, never in the harness. +# shellcheck disable=SC2016 +tpl_inputs_script='set -euo pipefail + . "$1/commands/lib/templates.sh" + templates_resolve + template_parse_env "$REGISTRY_DIR/scratch-box/template.env" + printf "USER=%s|CTX=%s|CLI=%s|SRC=%s|PATH=%s|NODE=%s|APT=%s\n" \ + "$TPL_USER" "$TPL_CONTEXT_PATH" "$TPL_CLI_NAME" "$TPL_CLI_SRC" \ + "$TPL_PATH_LINE" "$TPL_NEEDS_NODE" "$TPL_APT_EXTRAS" + render_tenant_context scratch-box "$REGISTRY_DIR/scratch-box/creds.md"' +tpl_from_dir() { # tpl_from_dir — the local-folder path + env RIG_TEMPLATES_DIR="$TPL_FIX" bash -c "$tpl_inputs_script" _ "$ROOT" > "$1" +} +tpl_from_ref() { # tpl_from_ref — the tarball path, curl stubbed + 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 # 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 "" \ grep -nE 'tailscale|TS_AUTHKEY' "$ROOT/commands/bootstrap-tenant.sh" check "tenant: non-interactive — nothing prompts" 1 "" \ grep -nE '\bread -r' "$ROOT/commands/bootstrap-tenant.sh" check "tenant: never apt-installs incus (box owns the daemon)" 1 "" \ grep -nE 'apt-get install.* incus' "$ROOT/commands/bootstrap-tenant.sh" +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 # 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 "" \ @@ -746,7 +900,6 @@ check "tenant: the marker write follows the context-file converge" \ # shellcheck disable=SC2016 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" - 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: version needs value" 2 "needs a value" "$ROOT/commands/coolify-install.sh" --version From e7f31046d95d55ac216c3a8fa71c3873c15a054f Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 23:13:17 +0000 Subject: [PATCH 6/7] chore: RIG_TEMPLATES_PIN names the seeded registry (rig-templates#1's head) (#110) --- commands/lib/templates.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh index 7dba675..1cf70d2 100644 --- a/commands/lib/templates.sh +++ b/commands/lib/templates.sh @@ -33,7 +33,11 @@ # 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. -RIG_TEMPLATES_PIN=30f4fa4dcb4e9f104058ad9dd5b7c42bafa98e73 +# +# 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. From af81f8f4cf47cd4f4479a30a15ef9dc3a2208c05 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 23:14:40 +0000 Subject: [PATCH 7/7] docs: README points the tenant table at the registry; the drill-record doctrine and the changelog fragment (#110) The security-trade paragraph lands verbatim in the README (its twin is in rig-templates' README, per the issue's docs task), with the 2026-07-24 ruling recorded in place. --- README.md | 50 +++++++++++++++++++++++++++++---------- changelog.d/110.md | 8 +++++++ commands/lib/templates.sh | 4 ++-- drills/README.md | 6 ++++- 4 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 changelog.d/110.md diff --git a/README.md b/README.md index e358237..2247c6a 100644 --- a/README.md +++ b/README.md @@ -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 tailnet as *your* device rather than the fleet's. -### `rig bootstrap ` — the box tenants +### `rig bootstrap -box` — the box tenants Run as root, **inside** a [box](https://github.com/heavy-duty/box)-minted guest. Convergent — safe to re-run; a second run changes nothing. @@ -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; the guests were the hole. -It is **one mechanism, parameterized per tenant** (`lib/tenant-config.sh` -holds the whole per-tenant table), not four hand-maintained scripts: +It is **one mechanism, parameterized per DEFINITION** (#110), not four +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 -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 | -|---------------|----------|------------| -| `claude-box` | `claude` | the agent toolbelt (git, gh, tmux, ripgrep, jq, age, unzip, build-essential), docker, node 22, the Claude Code CLI on the system PATH, zsh + oh-my-zsh, and `~/.claude/CLAUDE.md` | -| `codex-box` | `codex` | the toolbelt, docker, node 22, `@openai/codex` on the system PATH, and `~/.codex/AGENTS.md` | -| `grok-box` | `grok` | the toolbelt, docker, the grok CLI on the system PATH, and `~/.grok/AGENTS.md` | -| `kimi-box` | `kimi` | the toolbelt, docker, the kimi CLI (uv-managed) on the system PATH, and `~/.kimi/AGENTS.md` | -| `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) | +**Where the registry comes from — three knobs, precedence high to low:** + +| knob | meaning | +|------|---------| +| `RIG_TEMPLATES_DIR` | a local folder — no fetch: the offline-test path, and "try a template before it exists anywhere" | +| `RIG_TEMPLATES_REF` | any ref of `RIG_TEMPLATES_REPO` (default `heavy-duty/rig-templates`), fetched as an unauthenticated tarball at bootstrap time | +| *(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 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 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 -`lib/tenant-config.sh` exactly once, not copy-pasted per template — that was -the point of moving it here. +`lib/templates.sh`'s renderer exactly once — mechanism, not template data — +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=yes host=no` — no `root-door=`, because a guest has no root-door policy of its own diff --git a/changelog.d/110.md b/changelog.d/110.md new file mode 100644 index 0000000..bc59e68 --- /dev/null +++ b/changelog.d/110.md @@ -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) diff --git a/commands/lib/templates.sh b/commands/lib/templates.sh index 1cf70d2..688019c 100644 --- a/commands/lib/templates.sh +++ b/commands/lib/templates.sh @@ -3,8 +3,8 @@ # 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: tenant-config, -# runner-config). +# 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 diff --git a/drills/README.md b/drills/README.md index 3928494..5b2927a 100644 --- a/drills/README.md +++ b/drills/README.md @@ -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` with `BOX_REF=release/`, so it exercises the box that will actually ship; box's drill mints with `RIG_REF=release/`, 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 static identifiers that exist as soon as the release branches do, long before