Merge pull request #88 from dan-claude-bot/feat/thin-templates

feat: thin templates — box mints, rig converges (#81)
This commit is contained in:
Daniel Marin 2026-07-18 22:08:43 +01:00 committed by GitHub
commit 9cf10d58a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 519 additions and 215 deletions

View file

@ -35,6 +35,30 @@ which records not just what changed but what each drill run proved.
The agent-context guard for the templates (suggested fix 4) lands in The agent-context guard for the templates (suggested fix 4) lands in
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81). heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
- **The `staging` template** (#81, the re-cut of #69's layering) — a
server-class, creds-free seed: Debian 13, user `ops`, tmux, rig,
`BOX_REQUIRE_VM=1` (the VM is its trust boundary), `BOX_AUTOSTART=1` (a
server returns from a host reboot without an operator), and
`BOX_BOOTSTRAP_ROLE="staging"` — the server posture (docker, sshd
hardening) converges via `rig bootstrap staging` after mint. The tailnet
workload join holds a pre-auth key and therefore **stays operator-run**
(`box shell` → `sudo rig bootstrap workload`), printed as a next step —
box never sees the key.
- **`BOX_BOOTSTRAP_ROLE` template key + mint-time auto-run** (#81) — a
template names the **creds-free** rig tenant role box runs inside the guest
after cloud-init settles (`incus exec … rig bootstrap <role>`); the value
is a role *name* by allowlist (anything shell-shaped dies at parse time, on
the host). A failed role leaves the box up and names the re-run — the roles
are convergent by contract (rig#31). `blank` names no role and auto-runs
nothing.
- **The rig pin point: `RIG_REPO` / `RIG_REF`** (#81) — the tenant seeds
preinstall rig, inverting the rig→box install edge (rig#28), and the new
edge gets the same honest treatment rig#29 gave box's unpinned install:
`@RIG_REPO@`/`@RIG_REF@` tokens in the seed resolve at mint from the
environment (default `heavy-duty/rig` @ `main` — unpinned, tracking main,
until a release flow exists, rig#32/#83). The pin covers both the installer
fetched and the tree it installs, so a rig branch under review is testable
end to end; values are allowlist-validated before touching the YAML.
- **Server-posture template keys** (#81, carved from #69) — two optional - **Server-posture template keys** (#81, carved from #69) — two optional
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent `box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container` container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
@ -122,6 +146,23 @@ which records not just what changed but what each drill run proved.
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
into *driven* tests instead of greps (154 checks). into *driven* tests instead of greps (154 checks).
### Changed
- **Thin templates — box mints, rig converges** (#81, companion rig#31) —
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
agent CLI installs, docker, node, the per-template agent-context heredocs)
**moves to rig's bootstrap roles**, where it is convergent, idempotent and
testable end to end instead of parse-only YAML. What remains per template
is a thin, creds-free seed: the tenant user, tmux (#65), and rig
preinstalled — nothing that joins a tailnet or admits credentials. The #80
agent-context guard ("never run `box setup-host` or the drill inside a
box") now lives once, in rig's roles, not copy-pasted per template. The
template test sweep grew the contract's teeth: per-template seed asserts
(user matches, rig pinned via both tokens) and fail-closed **absence
greps** over effective cloud-init lines — no agent CLI, no docker, no
tailscale/authkey/ssh, no `write_files` heredocs — so tenant content
cannot quietly grow back.
### Fixed ### Fixed
- **`revoke --purge` re-checks the incus-user state** — the purge removed - **`revoke --purge` re-checks the incus-user state** — the purge removed

View file

@ -2,9 +2,12 @@
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh, **Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the coding-agent templates network-isolated Incus box from a **template**; the coding-agent templates
ship a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
Codex), `grok` (xAI Grok). The box is the product — you log in and work; Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
destroying it loses nothing you didn't push. converges**: the template is a thin seed, and the agent tooling lands via a
creds-free `rig bootstrap` role auto-run at mint
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
— you log in and work; destroying it loses nothing you didn't push.
**Strictly creds-free.** A box ships with everything installed and **no** **Strictly creds-free.** A box ships with everything installed and **no**
credentials — no agent token, no git PAT, nothing. You authenticate credentials — no agent token, no git PAT, nothing. You authenticate
@ -215,23 +218,64 @@ claude # if the repo has .box/, the agent reads it and
No coding agent is special — each is one template among several, and adding No coding agent is special — each is one template among several, and adding
another is just another directory. What ships today: another is just another directory. What ships today:
| Template | What's in it | | Template | What it becomes |
| -------- | --------------------------------------------------------- | | --------- | ---------------------------------------------------------------------- |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. | | `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude` | Claude Code, creds-free — where this project started | | `claude` | Claude Code, creds-free — where this project started |
| `codex` | OpenAI Codex CLI, creds-free | | `codex` | OpenAI Codex CLI, creds-free |
| `grok` | xAI Grok CLI, creds-free | | `grok` | xAI Grok CLI, creds-free |
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
A template is a directory under `templates/`: a `box.env` (image, user, **Templates are thin seeds; rig does the becoming**
resources — parsed against a strict allowlist, never sourced) and a ([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI directory under `templates/`: a `box.env` (image, user, resources, boot
templates are all the same shape — install the CLI, put it on PATH, drop an demands, tenant role — parsed against a strict allowlist, never sourced) and
agent-context file; none of them carry credentials. a `user-data.yaml` (cloud-init, passed to Incus verbatim except the two rig
pin tokens below). The seed is deliberately small — the tenant user, tmux,
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
joins a tailnet or admits credentials — and after cloud-init settles, box
auto-runs the template's **creds-free** tenant role inside the guest
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
docker, the server posture and the agent-context file all come from that
role — convergent and idempotent, so the same command re-run later converges
an *existing* box to a newer spec (`box shell <box>` →
`sudo rig bootstrap <role>`). The agent-context file carries the
[#80](https://github.com/heavy-duty/box/issues/80) guard — never run
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
from rig's roles, instead of copy-pasted per template.
**Anything that joins or admits stays operator-run.** The `staging` box's
tailnet workload join holds a pre-auth key, so box only prints it as the
next step — `box shell <name>`, then `sudo rig bootstrap workload` — and
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
split, kept).
**The rig pin point** (`RIG_REPO` / `RIG_REF`). The seeds preinstall rig,
which inverts the rig→box install edge
([rig#28](https://github.com/heavy-duty/rig/issues/28): rig installs box on
host-class machines; box guests now install rig). The seed's install line
carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
environment:
```sh
box new --name work --template claude # heavy-duty/rig @ main
RIG_REPO=you/rig RIG_REF=my-branch \
box new --name trial --template claude # a rig branch under review
```
Both directions of that edge track `main` unpinned today — said honestly,
the same way rig documents box's unpinned install
([rig#29](https://github.com/heavy-duty/rig/issues/29)) — until the release
flow lands ([rig#32](https://github.com/heavy-duty/rig/issues/32),
[#83](https://github.com/heavy-duty/box/issues/83)). The pin covers both the
installer fetched and the tree it installs, and the values are
allowlist-validated on the host before they touch the YAML.
```sh ```sh
box templates # list what this install can mint box templates # list what this install can mint
box new --name scratch # the DEFAULT template is blank: bare Debian, box new --name scratch # the DEFAULT template is blank: bare Debian,
# same isolation, nobody home # same isolation, nobody home — no rig, no role
``` ```
A template **cannot** name a network, a profile, or a `security.*` flag — A template **cannot** name a network, a profile, or a `security.*` flag —

104
bin/box
View file

@ -233,11 +233,20 @@ one of its snapshots — login state, git creds and clones carry over,
isolation is preserved, and the clone knows its template's user without isolation is preserved, and the clone knows its template's user without
being told. being told.
A tenant template (claude, codex, grok, staging) is a THIN seed — the user,
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
role inside it ('rig bootstrap <role>', rig#31): that role installs the
agent CLI / server posture and the agent-context file. rig is preinstalled
from RIG_REPO/RIG_REF in the mint environment (default heavy-duty/rig@main,
unpinned — an honest edge until rig has releases). Anything that joins a
tailnet or holds a key stays operator-run, never auto-run.
--name <box> Required. The box's name. --name <box> Required. The box's name.
--template <t> Template to mint from; 'box templates' lists them. --template <t> Template to mint from; 'box templates' lists them.
A template sets image, user, resources and boot A template sets image, user, resources, boot
demands (independently: BOX_REQUIRE_VM insists on demands (independently: BOX_REQUIRE_VM insists on
VM mode, BOX_AUTOSTART survives host reboots) — VM mode, BOX_AUTOSTART survives host reboots) and
a creds-free tenant role (BOX_BOOTSTRAP_ROLE) —
never the network: every template gets the same never the network: every template gets the same
isolation. isolation.
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>. --from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
@ -267,10 +276,15 @@ EOF
templates) cat <<'EOF' templates) cat <<'EOF'
List the templates this install can mint, with their descriptions. A template List the templates this install can mint, with their descriptions. A template
is a directory under templates/: a box.env (image, user, resources, boot is a directory under templates/: a box.env (image, user, resources, boot
demands — parsed against an allowlist, never sourced) and a user-data.yaml demands, tenant role — parsed against an allowlist, never sourced) and a
(cloud-init, passed to Incus verbatim). Templates cannot touch the network user-data.yaml (cloud-init, passed to Incus verbatim except the rig pin
or security flags — the shared box-net profile is the placement contract, so tokens @RIG_REPO@/@RIG_REF@, resolved at mint from the environment).
every template gets the same isolation. Templates cannot touch the network or security flags — the shared box-net
profile is the placement contract, so every template gets the same isolation.
Templates are thin, creds-free seeds (#81): the user, tmux and rig — what a
box BECOMES lives in rig's bootstrap roles (rig#31), auto-run at mint via
BOX_BOOTSTRAP_ROLE. box mints; rig converges.
box templates box templates
box new --name scratch --template blank box new --name scratch --template blank
@ -842,23 +856,26 @@ reset_identity() {
wait_agent "$i" wait_agent "$i"
} }
# Templates set image, user, resources, boot demands and cloud-init — NOTHING # Templates set image, user, resources, boot demands, a tenant role and
# else. The box.env file is parsed against this allowlist, never sourced: # cloud-init — NOTHING else. The box.env file is parsed against this allowlist,
# sourcing would hand every template arbitrary bash execution on the HOST at # never sourced: sourcing would hand every template arbitrary bash execution on
# mint time. And there is deliberately no key for a network or a security flag # the HOST at mint time. And there is deliberately no key for a network or a
# — the shared box-net profile is the placement contract, so no template can # security flag — the shared box-net profile is the placement contract, so no
# weaken isolation. 'blank' is a box with nobody home, not a box with the # template can weaken isolation. 'blank' is a box with nobody home, not a box
# safety off. The two boot demands are for server-class templates (#68): # with the safety off. The two boot demands are for server-class templates
# BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust # (#68): BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust
# boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps # boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps
# boot.autostart so the box survives a host reboot without an operator. # boot.autostart so the box survives a host reboot without an operator.
# BOX_BOOTSTRAP_ROLE (#81) names the rig role box auto-runs after mint — the
# thin-template split: the seed is user + tmux + rig, and what the box BECOMES
# is 'rig bootstrap <role>'. Only creds-free roles belong here, by contract.
load_template() { load_template() {
local t="$1" dir line key val local t="$1" dir line key val
dir="$root/templates/$t" dir="$root/templates/$t"
[ -d "$dir" ] || die "no such template: $t (see 'box templates')" [ -d "$dir" ] || die "no such template: $t (see 'box templates')"
[ -f "$dir/box.env" ] || die "template '$t' has no box.env" [ -f "$dir/box.env" ] || die "template '$t' has no box.env"
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK="" T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
T_REQUIRE_VM=""; T_AUTOSTART="" T_REQUIRE_VM=""; T_AUTOSTART=""; T_BOOTSTRAP_ROLE=""
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|\#*) continue ;; esac case "$line" in ''|\#*) continue ;; esac
case "$line" in case "$line" in
@ -882,7 +899,8 @@ load_template() {
BOX_DISK) T_DISK="$val" ;; BOX_DISK) T_DISK="$val" ;;
BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;; BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;;
BOX_AUTOSTART) T_AUTOSTART="$val" ;; BOX_AUTOSTART) T_AUTOSTART="$val" ;;
*) die "template '$t': unknown key '$key' — a template sets image, user, resources and boot demands, nothing else (there is no key for a network, on purpose)" ;; BOX_BOOTSTRAP_ROLE) T_BOOTSTRAP_ROLE="$val" ;;
*) die "template '$t': unknown key '$key' — a template sets image, user, resources, boot demands and a tenant role, nothing else (there is no key for a network, on purpose)" ;;
esac esac
done <"$dir/box.env" done <"$dir/box.env"
# Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still # Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
@ -891,6 +909,12 @@ load_template() {
if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
die "template '$t': BOX_IMAGE and BOX_USER are required" die "template '$t': BOX_IMAGE and BOX_USER are required"
fi fi
# A bootstrap role is a rig role NAME and nothing more — it is handed to
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
# the value must die here, on the host, before a guest ever sees it.
if [ -n "$T_BOOTSTRAP_ROLE" ] && ! [[ "$T_BOOTSTRAP_ROLE" =~ ^[a-z][a-z0-9-]*$ ]]; then
die "template '$t': BOX_BOOTSTRAP_ROLE is not a sane role name: $T_BOOTSTRAP_ROLE"
fi
# Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57) # Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
# > BOX_* environment (how a small host or the drill shrinks every box it # > BOX_* environment (how a small host or the drill shrinks every box it
# mints) > the template's file > defaults. Values pass to Incus verbatim — # mints) > the template's file > defaults. Values pass to Incus verbatim —
@ -901,6 +925,31 @@ load_template() {
T_DISK="${disk:-${BOX_DISK:-${T_DISK:-60GiB}}}" T_DISK="${disk:-${BOX_DISK:-${T_DISK:-60GiB}}}"
} }
# The ONE substitution a template gets — user-data.yaml is otherwise passed to
# Incus verbatim. The tenant seeds preinstall rig, which inverts the rig→box
# install edge (rig#28: rig installs box on hosts; now box guests install rig),
# and that edge needs a pin point (#81): the seed carries @RIG_REPO@ /
# @RIG_REF@ tokens, resolved here from the mint environment — RIG_REPO
# (default heavy-duty/rig) and RIG_REF (default main). Both directions track
# main unpinned today, said honestly (the same treatment rig#29 gave box's own
# unpinned install) until a release flow exists (rig#32 / #83). The values are
# allowlist-validated BEFORE touching the YAML: they land inside a runcmd
# shell line, so a quote, a space or a newline smuggled through the
# environment must die on the host, never execute in the guest. bash's =~
# anchors to the whole string — a multi-line value cannot sneak one clean
# line past it the way a line-oriented grep would.
render_userdata() {
local f="$1" repo="${RIG_REPO:-heavy-duty/rig}" ref="${RIG_REF:-main}" data
[[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|| die "RIG_REPO must look like owner/repo: $repo"
[[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
|| die "RIG_REF must be a plain ref name (letters, digits, . _ / -): $ref"
data="$(cat "$f")"
data="${data//@RIG_REPO@/$repo}"
data="${data//@RIG_REF@/$ref}"
printf '%s\n' "$data"
}
cmd_templates() { cmd_templates() {
local d t desc local d t desc
echo "TEMPLATES" echo "TEMPLATES"
@ -969,7 +1018,7 @@ cmd_new() {
--config user.box.user="$T_USER" \ --config user.box.user="$T_USER" \
--config limits.cpu="$T_CPU" \ --config limits.cpu="$T_CPU" \
--config limits.memory="$T_MEMORY" \ --config limits.memory="$T_MEMORY" \
--config cloud-init.user-data="$(cat "$root/templates/$t/user-data.yaml")" \ --config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
"${extra[@]}" "${extra[@]}"
wait_agent "$instance" wait_agent "$instance"
echo "box: waiting for phase-1 (cloud-init)..." echo "box: waiting for phase-1 (cloud-init)..."
@ -1003,6 +1052,24 @@ cmd_new() {
echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2 echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
die "cloud-init failed — the box is incomplete, so refusing to hand it over" die "cloud-init failed — the box is incomplete, so refusing to hand it over"
fi fi
# The tenant convergence (#81): the seed above is thin — the user, tmux,
# rig — and what the box BECOMES is rig's job. A template that names a
# bootstrap role gets it auto-run here, as root inside the guest, because
# the tenant roles are creds-free and non-interactive BY CONTRACT
# (rig#31): no prompts, no tailnet, no keys — nothing that joins or
# admits may ever ride this hook. The creds-holding steps (staging's
# workload join) stay operator-run through 'box shell', and the role is
# idempotent, so a failed or interrupted run is re-runnable as-is.
if [ -n "$T_BOOTSTRAP_ROLE" ]; then
echo "box: converging the tenant — rig bootstrap $T_BOOTSTRAP_ROLE (rig's own narration follows)..."
if ! incus exec "$instance" -- rig bootstrap "$T_BOOTSTRAP_ROLE" </dev/null; then
echo >&2
echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
echo " box shell $name # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
fi
fi
fi fi
# The login hint belongs to the claude template — read the EFFECTIVE # The login hint belongs to the claude template — read the EFFECTIVE
# template off the instance, so a clone of a claude box gets it too and a # template off the instance, so a clone of a claude box gets it too and a
@ -1011,6 +1078,9 @@ cmd_new() {
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude [ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
if [ "$eff" = claude ]; then if [ "$eff" = claude ]; then
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login." echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
elif [ "$eff" = staging ]; then
echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
echo " box shell $name # then: sudo rig bootstrap workload --hostname $name"
else else
echo "box: ready — 'box shell $name'." echo "box: ready — 'box shell $name'."
fi fi

View file

@ -55,13 +55,35 @@ rides along deliberately — and because scrubbing a disk image is a promise
tarball surgery cannot keep, export shouts that the file is a credential tarball surgery cannot keep, export shouts that the file is a credential
instead of pretending to sanitize it. instead of pretending to sanitize it.
## Thin templates: box mints, rig converges (#81)
A template is a **thin, creds-free seed** — base image, the tenant user,
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
template's creds-free tenant role after cloud-init (`rig bootstrap claude` /
`codex` / `grok` / `staging`), which installs the agent CLI or server
posture. The split is deliberate: cloud-init is a first-boot one-shot —
not convergent, not re-runnable, only parse-and-grep testable — while a rig
role is an idempotent script with effective-state asserts that can also
converge an *existing* box to a newer spec. Anything that joins a tailnet or
holds a key (staging's workload join) stays operator-run through
`box shell`; box prints it as a next step and never sees the key. The seed's
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
and box's template suite holds the line with fail-closed absence greps: no
agent CLI, no docker, no tailscale, no context-file heredocs in any
template, ever again.
## The box announces itself to the agent ## The box announces itself to the agent
cloud-init installs a global agent-context file in every coding-agent box Every coding-agent box gets a global agent-context file
(`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the
agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
execution. execution. The file is rendered by rig's tenant roles from one shared
template (#81) — including the #80 guard: never run `box setup-host`,
`box teardown-host` or the drill inside a box; a nested box stack claims the
guest's own uplink subnet and silently breaks its networking.
## `.box/` is optional, agent-facing documentation ## `.box/` is optional, agent-facing documentation

View file

@ -1,12 +1,17 @@
# The claude template — Debian 13 + Claude Code, creds-free. # The claude template — a thin, creds-free seed (#81): Debian 13, the
# 'claude' user, tmux and rig. What the box BECOMES — the Claude Code CLI,
# docker, node, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap claude' after mint (heavy-duty/rig#31).
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates (the cloud-init file is # BOX_USER must match the user user-data.yaml creates (the duplication is
# passed to Incus verbatim, so the duplication is deliberate and by hand). # deliberate and by hand) — and it is the tenant user the rig role converges
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free — where this project started" # (rig dies loudly if the seed did not create it).
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud" BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude" BOX_USER="claude"
BOX_CPU="4" BOX_CPU="4"
BOX_MEMORY="8GiB" BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="claude"

View file

@ -1,61 +1,33 @@
#cloud-config #cloud-config
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap claude' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
users: users:
- name: claude - name: claude
shell: /bin/bash shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL" sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true lock_passwd: true
write_files:
- path: /home/claude/.claude/CLAUDE.md
owner: "claude:claude"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: claude)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **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.
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages: packages:
- git - tmux
- gh
- curl - curl
- ca-certificates - ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
- zsh
runcmd: runcmd:
- curl -fsSL https://get.docker.com | sh # Preinstall rig so the box can converge — and re-converge — via
- usermod -aG docker claude # 'rig bootstrap claude'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash - # box substitutes them at mint from the RIG_REPO/RIG_REF environment
- apt-get install -y nodejs # (default heavy-duty/rig @ main — unpinned, tracking main, the same
- sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash' # honest edge as rig's own unpinned box install, until rig#32 ships a
# 'box exec <box> -- claude …' runs through a NON-interactive shell, which # release flow). The pin covers both the installer fetched AND the tree
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The # it installs, so a branch under review is testable end to end.
# symlink makes claude reachable from every shell, interactive or not. # HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude # environment, and the rig installer (set -u) reads $HOME for its DEST —
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.bashrc # measured live: the mint died with "HOME: unbound variable" without it.
- chsh -s /usr/bin/zsh claude - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
- sudo -u claude bash -lc 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"'
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.zshrc
- echo 'set -g mouse on' > /home/claude/.tmux.conf
- chown claude:claude /home/claude/.tmux.conf

View file

@ -1,11 +1,17 @@
# The codex template — Debian 13 + OpenAI Codex CLI, creds-free. # The codex template — a thin, creds-free seed (#81): Debian 13, the
# 'codex' user, tmux and rig. What the box BECOMES — the OpenAI Codex CLI,
# docker, node, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap codex' after mint (heavy-duty/rig#31).
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates. # BOX_USER must match the user user-data.yaml creates (the duplication is
BOX_DESCRIPTION="OpenAI Codex CLI on Debian 13, creds-free" # deliberate and by hand) — and it is the tenant user the rig role converges
# (rig dies loudly if the seed did not create it).
BOX_DESCRIPTION="OpenAI Codex CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud" BOX_IMAGE="images:debian/13/cloud"
BOX_USER="codex" BOX_USER="codex"
BOX_CPU="4" BOX_CPU="4"
BOX_MEMORY="8GiB" BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="codex"

View file

@ -1,60 +1,33 @@
#cloud-config #cloud-config
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap codex' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
users: users:
- name: codex - name: codex
shell: /bin/bash shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL" sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true lock_passwd: true
write_files:
- path: /home/codex/.codex/AGENTS.md
owner: "codex:codex"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: codex)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **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.
- **Isolated.** The box reaches the public internet but nothing on the host
or local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages: packages:
- git - tmux
- gh
- curl - curl
- ca-certificates - ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd: runcmd:
- curl -fsSL https://get.docker.com | sh # Preinstall rig so the box can converge — and re-converge — via
- usermod -aG docker codex # 'rig bootstrap codex'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# Codex CLI is an npm global and needs Node 22+ (verified upstream: # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# npmjs.com/package/@openai/codex — the SCOPED @openai/codex, not the # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# unrelated 2012 'codex' package). # honest edge as rig's own unpinned box install, until rig#32 ships a
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash - # release flow). The pin covers both the installer fetched AND the tree
- apt-get install -y nodejs # it installs, so a branch under review is testable end to end.
- npm install -g @openai/codex # HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
# 'box exec <b> -- codex …' runs a NON-interactive shell that reads no # environment, and the rig installer (set -u) reads $HOME for its DEST —
# rc files — npm's global bin must be reachable from every shell. Symlink # measured live: the mint died with "HOME: unbound variable" without it.
# the installed binary into /usr/local/bin (the same fix the claude - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
# template needed for its own CLI).
- ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex
- echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> /home/codex/.bashrc

View file

@ -1,11 +1,17 @@
# The grok template — Debian 13 + xAI's Grok Build CLI, creds-free. # The grok template — a thin, creds-free seed (#81): Debian 13, the
# 'grok' user, tmux and rig. What the box BECOMES — the xAI Grok CLI,
# docker, the agent-context file with its #80 guard — is rig's job:
# box auto-runs 'rig bootstrap grok' after mint (heavy-duty/rig#31).
# KEY="value" only. Parsed against an allowlist, never sourced; there is no # KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net # key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it. # profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates. # BOX_USER must match the user user-data.yaml creates (the duplication is
BOX_DESCRIPTION="xAI Grok CLI on Debian 13, creds-free" # deliberate and by hand) — and it is the tenant user the rig role converges
# (rig dies loudly if the seed did not create it).
BOX_DESCRIPTION="xAI Grok CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud" BOX_IMAGE="images:debian/13/cloud"
BOX_USER="grok" BOX_USER="grok"
BOX_CPU="4" BOX_CPU="4"
BOX_MEMORY="8GiB" BOX_MEMORY="8GiB"
BOX_DISK="60GiB" BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="grok"

View file

@ -1,75 +1,33 @@
#cloud-config #cloud-config
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
users: users:
- name: grok - name: grok
shell: /bin/bash shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL" sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true lock_passwd: true
write_files:
- path: /home/grok/.grok/AGENTS.md
owner: "grok:grok"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: grok)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **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.
- **Isolated.** The box reaches the public internet but nothing on the host
or local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages: packages:
- git - tmux
- gh
- curl - curl
- ca-certificates - ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd: runcmd:
- curl -fsSL https://get.docker.com | sh # Preinstall rig so the box can converge — and re-converge — via
- usermod -aG docker grok # 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# guessed at. What it actually does: # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build` # honest edge as rig's own unpinned box install, until rig#32 ships a
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a # release flow). The pin covers both the installer fetched AND the tree
# SYMLINK into its versioned download dir (so `find -type f` misses it) # it installs, so a branch under review is testable end to end.
# · GROK_BIN_DIR overrides that directory # HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
# # environment, and the rig installer (set -u) reads $HOME for its DEST —
# Run it AS grok, not root: the binary symlink points into the invoking # measured live: the mint died with "HOME: unbound variable" without it.
# user's download dir, and root's home is 0700 — a symlink into it would be - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
# unreadable to the grok user, giving a CLI that exists and cannot run.
- sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash'
# 'box exec <b> -- grok …' is a NON-interactive shell: it reads no rc files,
# so ~/.grok/bin is never on its PATH. Symlink onto the system PATH — the
# same fix the claude template needed (#15). Assert the result: a CLI that
# silently is not on PATH is what cost the last drill run.
- |
if [ -e /home/grok/.grok/bin/grok ]; then
ln -sf /home/grok/.grok/bin/grok /usr/local/bin/grok
echo "grok: linked /usr/local/bin/grok -> /home/grok/.grok/bin/grok"
/usr/local/bin/grok --version >/dev/null 2>&1 \
&& echo "grok: 'grok --version' answers from the system PATH" \
|| echo "grok: WARNING - linked, but 'grok --version' does not answer" >&2
else
echo "grok: installer produced no ~/.grok/bin/grok - upstream layout changed?" >&2
find /home/grok -maxdepth 4 \( -type f -o -type l \) -perm -u+x 2>/dev/null | head -20 >&2
fi
- echo 'export PATH="$HOME/.grok/bin:$PATH"' >> /home/grok/.bashrc

24
templates/staging/box.env Normal file
View file

@ -0,0 +1,24 @@
# The staging template — a thin, creds-free, server-class seed (#81, the
# re-cut of #69's layering): Debian 13, the 'ops' user, tmux and rig. The
# server posture — docker, sshd hardening — is rig's job: box auto-runs
# 'rig bootstrap staging' after mint (heavy-duty/rig#31). The tailnet
# workload join holds a key and therefore STAYS operator-run:
# box shell <name> # then: sudo rig bootstrap workload --hostname <name>
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# The two boot demands (#68): the VM is this box's trust boundary and its
# guest runs docker, so no container fallback (BOX_REQUIRE_VM); and a server
# must return from a host reboot without an operator (BOX_AUTOSTART).
# BOX_USER must match the user user-data.yaml creates (the duplication is
# deliberate and by hand) — and it is the tenant user the rig role converges
# (rig dies loudly if the seed did not create it).
BOX_DESCRIPTION="Server-class Debian 13, creds-free — box mints, rig converges, the join stays yours"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="ops"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_REQUIRE_VM="1"
BOX_AUTOSTART="1"
BOX_BOOTSTRAP_ROLE="staging"

View file

@ -0,0 +1,34 @@
#cloud-config
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
# and rig — nothing that joins a tailnet or admits credentials, no docker,
# no sshd config, no keys. The server posture comes from
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after
# mint; the tailnet workload join holds a pre-auth key and stays
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as
# #69 designed it — box never sees the key.
users:
- name: ops
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65) — and the
# operator babysits the workload join through it.
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages:
- tmux
- curl
- ca-certificates
runcmd:
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap staging'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
# honest edge as rig's own unpinned box install, until rig#32 ships a
# release flow). The pin covers both the installer fetched AND the tree
# it installs, so a branch under review is testable end to end.
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
# environment, and the rig installer (set -u) reads $HOME for its DEST —
# measured live: the mint died with "HOME: unbound variable" without it.
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash

View file

@ -150,8 +150,8 @@ tpl() {
root="$1" bash -c ' root="$1" bash -c '
die() { echo "box: $*" >&2; exit 1; } die() { echo "box: $*" >&2; exit 1; }
. "$0"; load_template "$1" . "$0"; load_template "$1"
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s\n" \ printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s ROLE=%s\n" \
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART" "$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART" "$T_BOOTSTRAP_ROLE"
' "$TPLFN" "$2" ' "$TPLFN" "$2"
} }
@ -165,17 +165,64 @@ check "load_template: an unknown key dies (no template grows a network)" 1 "unkn
tpl "$EVILROOT" evil tpl "$EVILROOT" evil
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env" printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil
# The green path the two new keys exist for: no in-tree template sets them yet # The boot demands' green path, kept as a fixture even now that staging sets
# (the seed lands after rig#31), so without this fixture the case arms could be # them in-tree: fixtures survive a template rename, and a deleted case arm
# deleted and the suite would stay green while the keys silently died as # must fail HERE, through the real parser, not at first use on a host.
# "unknown key" at first use. Accepted AND surfaced, through the real parser.
mkdir -p "$EVILROOT/templates/server" mkdir -p "$EVILROOT/templates/server"
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \ printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
> "$EVILROOT/templates/server/box.env" > "$EVILROOT/templates/server/box.env"
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \ check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server 0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server
# BOX_BOOTSTRAP_ROLE (#81): accepted and surfaced through the real parser —
# and the value is a rig role NAME, nothing more. It is handed to
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
# it must die at parse time, on the host, before a guest exists.
mkdir -p "$EVILROOT/templates/tenant"
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude"\n' \
> "$EVILROOT/templates/tenant/box.env"
check "load_template: BOX_BOOTSTRAP_ROLE round-trips (accepted + surfaced)" \
0 "ROLE=claude" tpl "$EVILROOT" tenant
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude; rm -rf /"\n' \
> "$EVILROOT/templates/tenant/box.env"
check "load_template: a shell-shaped BOX_BOOTSTRAP_ROLE dies at the gate" \
1 "not a sane role name" tpl "$EVILROOT" tenant
rm -rf "$EVILROOT" rm -rf "$EVILROOT"
# ---------------------------------------------------------------------------
# render_userdata (#81) — the seed's ONE substitution, driven for real: the
# rig pin point. Defaults resolve to heavy-duty/rig@main; RIG_REPO/RIG_REF
# override at mint (how a rig branch under review reaches a guest); and a
# hostile value — the tokens land inside a runcmd shell line — dies on the
# host before touching the YAML. bash's =~ anchors the WHOLE string, so a
# multi-line value cannot sneak one clean line past it (the line-oriented
# grep -q failure mode).
# ---------------------------------------------------------------------------
RUFN="$(mktemp)"
awk '/^render_userdata\(\) \{/,/^\}/' "$ROOT/bin/box" > "$RUFN"
check "render_userdata: extracted from bin/box (guards the awk)" 0 "RIG_REPO" cat "$RUFN"
check "render_userdata: the extracted function is valid bash" 0 "" bash -n "$RUFN"
SEED="$(mktemp)"
printf '#cloud-config\nruncmd:\n - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash\n' > "$SEED"
# shellcheck disable=SC2016 # $0/$1 expand in the child shell, by design
rud() { # rud [VAR=val ...] — render the fixture seed through the real function
env "$@" bash -c 'die() { echo "box: $*" >&2; exit 1; }; . "$0"; render_userdata "$1"' "$RUFN" "$SEED"
}
check "render_userdata: defaults pin heavy-duty/rig" 0 "githubusercontent.com/heavy-duty/rig/main/install.sh" rud
check "render_userdata: defaults feed the installer's own env too" 0 'RIG_REPO="heavy-duty/rig" RIG_REF="main"' rud
check "render_userdata: RIG_REPO/RIG_REF override at mint" 0 "dan-claude-bot/rig/feat/bootstrap-roles/install.sh" \
rud RIG_REPO=dan-claude-bot/rig RIG_REF=feat/bootstrap-roles
# shellcheck disable=SC2016 # $0/$1 expand in the child shells, by design
check "render_userdata: no token survives the render" 1 "" \
bash -c 'env bash -c "die() { echo box: \$*; exit 1; }; . \"\$0\"; render_userdata \"\$1\"" "$1" "$2" | grep -q @RIG_' _ "$RUFN" "$SEED"
check "render_userdata: a shell-shaped RIG_REPO dies on the host" 1 "RIG_REPO" \
rud 'RIG_REPO=evil"; rm -rf /; "/rig'
check "render_userdata: a spaced RIG_REF dies on the host" 1 "RIG_REF" \
rud 'RIG_REF=main plus junk'
check "render_userdata: a newline-smuggled RIG_REPO dies (whole-string anchor)" 1 "RIG_REPO" \
rud "RIG_REPO=$(printf 'a/b\nevil')"
rm -f "$RUFN" "$SEED"
# YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip # YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip
# gracefully (never silently) where they are missing. # gracefully (never silently) where they are missing.
HAVE_YAML=0 HAVE_YAML=0
@ -189,8 +236,9 @@ for d in "$ROOT"/templates/*/; do
check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t" check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t"
check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env" check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env"
check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env" check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env"
# cloud-init is passed to Incus verbatim, so it must exist, declare itself, # cloud-init is passed to Incus verbatim (modulo the two rig pin tokens),
# and be well-formed — a mint is far too late to learn about a typo. # so it must exist, declare itself, and be well-formed — a mint is far too
# late to learn about a typo.
check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml" check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml"
# shellcheck disable=SC2016 # $1 expands in the child shell, by design # shellcheck disable=SC2016 # $1 expands in the child shell, by design
check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \ check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \
@ -205,8 +253,80 @@ for d in "$ROOT"/templates/*/; do
# template's package list must carry tmux or the verb dies inside. # template's package list must carry tmux or the verb dies inside.
check "template '$t': installs tmux (#65)" 0 "" \ check "template '$t': installs tmux (#65)" 0 "" \
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml" grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml"
# BOX_USER is duplicated into the cloud-init by hand (the file reaches
# Incus verbatim) — assert the two halves actually agree, per template.
tuser="$(tpl "$ROOT" "$t" | sed -n 's/.*USER=\([^ ]*\).*/\1/p')"
check "template '$t': user-data.yaml creates BOX_USER ('$tuser')" 0 "" \
grep -qE "^[[:space:]]*-[[:space:]]+name:[[:space:]]+$tuser\$" "$d/user-data.yaml"
# ------------------------------------------------------------------------
# The thin-template contract (#81), both halves per template:
#
# THE SEED — a template that names a tenant role (BOX_BOOTSTRAP_ROLE) must
# preinstall rig carrying BOTH pin tokens, on the installer URL and on the
# installer's own env, or the pin is a half-truth: a mint would fetch one
# ref's installer and install another ref's tree.
# ------------------------------------------------------------------------
trole="$(tpl "$ROOT" "$t" | sed -n 's/.*ROLE=\([^ ]*\).*/\1/p')"
if [ -n "$trole" ]; then
check "template '$t': the seed installs rig (role '$trole')" 0 "" \
grep -q 'install.sh' "$d/user-data.yaml"
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
check "template '$t': the rig install carries the @RIG_REPO@ pin token" 0 "" \
bash -c 'grep "install.sh" "$1" | grep -q "@RIG_REPO@/@RIG_REF@"' _ "$d/user-data.yaml"
# shellcheck disable=SC2016
check "template '$t': the pin reaches the installer's env too" 0 "" \
bash -c 'grep "install.sh" "$1" | grep -q "RIG_REPO=\"@RIG_REPO@\" RIG_REF=\"@RIG_REF@\""' _ "$d/user-data.yaml"
# HOME=/root: a scar found live — cloud-init's runcmd has no $HOME and
# rig's installer (set -u) dies on it (rig#39). The pin must survive
# every seed rewrite.
# shellcheck disable=SC2016
check "template '$t': the rig install pins HOME=/root (runcmd has no \$HOME)" 0 "" \
bash -c 'grep "install.sh" "$1" | grep -q "HOME=/root "' _ "$d/user-data.yaml"
fi
# ------------------------------------------------------------------------
# THE ABSENCE — no tenant content in ANY template, ever again. Everything a
# box becomes lives in rig's roles (rig#31); a template that grows an agent
# CLI, docker, node, a tailnet join or a context-file heredoc is the
# regression this suite exists to refuse. Greps run over EFFECTIVE
# cloud-init lines (comments may name what they refuse — #69's idiom), and
# they fail CLOSED: the want-exit is 1, so re-adding any of it goes red.
# ------------------------------------------------------------------------
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
check "template '$t': no agent CLI install (rig's job, rig#31)" 1 "" \
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "claude\.ai|x\.ai|@openai|npm|nodesource|nodejs"' _ "$d/user-data.yaml"
# shellcheck disable=SC2016
check "template '$t': no docker (rig's job, rig#31)" 1 "" \
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qi docker' _ "$d/user-data.yaml"
# shellcheck disable=SC2016
check "template '$t': nothing that joins or admits (no tailscale/authkey/ssh)" 1 "" \
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "tailscale|authkey|ssh"' _ "$d/user-data.yaml"
# shellcheck disable=SC2016
check "template '$t': no context-file heredoc (the #80 guard lives in rig's roles)" 1 "" \
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "write_files|CLAUDE\.md|AGENTS\.md"' _ "$d/user-data.yaml"
done done
# The staging seed's boot demands are part of its contract (#68/#69): the VM
# is its trust boundary (its guest runs docker, via rig) and a server returns
# from a host reboot without an operator. Pinned to the FILE so neither can
# quietly vanish in a rewrite.
check "staging: demands VM mode (BOX_REQUIRE_VM=1)" 0 "" \
grep -qx 'BOX_REQUIRE_VM="1"' "$ROOT/templates/staging/box.env"
check "staging: demands autostart (BOX_AUTOSTART=1)" 0 "" \
grep -qx 'BOX_AUTOSTART="1"' "$ROOT/templates/staging/box.env"
check "staging: the tenant role is 'staging'" 0 "ROLE=staging" tpl "$ROOT" staging
check "staging: the seed user is rig's default for the role ('ops')" 0 "USER=ops" tpl "$ROOT" staging
# The agent tenants: role = user = template name, rig's default mapping.
for t in claude codex grok; do
check "$t: role and user are '$t' (rig's default tenant mapping)" 0 "USER=$t REQUIRE_VM= AUTOSTART= ROLE=$t" \
tpl "$ROOT" "$t"
done
# blank stays a box with NOBODY home: no rig, no role — same isolation, no
# tooling, and nothing auto-runs in it.
check "blank: names no bootstrap role" 1 "" \
grep -q '^BOX_BOOTSTRAP_ROLE=' "$ROOT/templates/blank/box.env"
check "blank: does not preinstall rig" 1 "" grep -q 'install.sh' "$ROOT/templates/blank/user-data.yaml"
rm -f "$TPLFN" rm -f "$TPLFN"
# The keys' cmd_new half, grepped the way the expose guard is (line order — # The keys' cmd_new half, grepped the way the expose guard is (line order —
@ -232,6 +352,35 @@ check "new: boot.autostart is stamped under the T_AUTOSTART guard" 0 "" bash -c
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \ awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
| grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"' | grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"'
# The auto-run half of #81, grepped the same way (a daemon-free run cannot
# mint). The seed reaches Incus through render_userdata — the pin point — not
# through a raw cat; and the tenant convergence must order AFTER the
# cloud-init wait (rig is installed by the seed's runcmd, so exec'ing the
# role before cloud-init settles would race its own installer) and sit under
# the T_BOOTSTRAP_ROLE guard (blank must never auto-run anything).
check "new: cloud-init user-data goes through render_userdata (the rig pin)" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
| grep -F "cloud-init.user-data" | grep -q "render_userdata"'
# shellcheck disable=SC2016 # the $-strings are literals in the target file
check "new: the tenant auto-run orders after the cloud-init wait" 0 "" bash -c '
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
wait="$(printf "%s\n" "$fn" | grep -n "cloud-init status --wait" | head -1 | cut -d: -f1)"
run="$(printf "%s\n" "$fn" | grep -n "rig bootstrap" | head -1 | cut -d: -f1)"
[ -n "$wait" ] && [ -n "$run" ] && [ "$wait" -lt "$run" ]'
check "new: the auto-run sits under the T_BOOTSTRAP_ROLE guard" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
| grep -B2 "incus exec .* rig bootstrap" | grep -q "T_BOOTSTRAP_ROLE"'
check "new: a failed tenant role names the re-run (the role converges)" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
| grep -q "sudo rig bootstrap"'
# staging's creds-holding join stays OPERATOR-run: cmd_new may print it as a
# next step, but no template and no code path auto-runs "rig bootstrap
# workload" — the one absence that keeps box creds-free end to end.
check "new: the workload join is printed, never exec'd" 1 "" bash -c '
grep "rig bootstrap workload" "'"$ROOT"'/bin/box" | grep -q "incus exec"'
check "templates: no template names a creds-holding role" 1 "" bash -c '
grep -h "^BOX_BOOTSTRAP_ROLE=" "'"$ROOT"'"/templates/*/box.env | grep -qE "workload|host|custom"'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The restricted tier (#74). box_tier() is the decision the whole tier hangs # The restricted tier (#74). box_tier() is the decision the whole tier hangs
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run # on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run