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
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
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
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
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
- **`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,
network-isolated Incus box from a **template**; the coding-agent templates
ship 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;
destroying it loses nothing you didn't push.
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
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**
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
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. |
| `claude` | Claude Code, creds-free — where this project started |
| `codex` | OpenAI Codex 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,
resources — parsed against a strict allowlist, never sourced) and a
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI
templates are all the same shape — install the CLI, put it on PATH, drop an
agent-context file; none of them carry credentials.
**Templates are thin seeds; rig does the becoming**
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
directory under `templates/`: a `box.env` (image, user, resources, boot
demands, tenant role — parsed against a strict allowlist, never sourced) and
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
box templates # list what this install can mint
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 —

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
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.
--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
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
isolation.
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
@ -267,10 +276,15 @@ EOF
templates) cat <<'EOF'
List the templates this install can mint, with their descriptions. A template
is a directory under templates/: a box.env (image, user, resources, boot
demands — parsed against an allowlist, never sourced) and a user-data.yaml
(cloud-init, passed to Incus verbatim). Templates cannot touch the network
or security flags — the shared box-net profile is the placement contract, so
every template gets the same isolation.
demands, tenant role — parsed against an allowlist, never sourced) and a
user-data.yaml (cloud-init, passed to Incus verbatim except the rig pin
tokens @RIG_REPO@/@RIG_REF@, resolved at mint from the environment).
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 new --name scratch --template blank
@ -842,23 +856,26 @@ reset_identity() {
wait_agent "$i"
}
# Templates set image, user, resources, boot demands and cloud-init — NOTHING
# else. The box.env file is parsed against this allowlist, never sourced:
# sourcing would hand every template arbitrary bash execution on the HOST at
# mint time. And there is deliberately no key for a network or a security flag
# — the shared box-net profile is the placement contract, so no template can
# weaken isolation. 'blank' is a box with nobody home, not a box with the
# safety off. The two boot demands are for server-class templates (#68):
# BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust
# Templates set image, user, resources, boot demands, a tenant role and
# cloud-init — NOTHING else. The box.env file is parsed against this allowlist,
# never sourced: sourcing would hand every template arbitrary bash execution on
# the HOST at mint time. And there is deliberately no key for a network or a
# security flag — the shared box-net profile is the placement contract, so no
# template can weaken isolation. 'blank' is a box with nobody home, not a box
# with the safety off. The two boot demands are for server-class templates
# (#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
# 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() {
local t="$1" dir line key val
dir="$root/templates/$t"
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
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
case "$line" in ''|\#*) continue ;; esac
case "$line" in
@ -882,7 +899,8 @@ load_template() {
BOX_DISK) T_DISK="$val" ;;
BOX_REQUIRE_VM) T_REQUIRE_VM="$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
done <"$dir/box.env"
# 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
die "template '$t': BOX_IMAGE and BOX_USER are required"
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)
# > BOX_* environment (how a small host or the drill shrinks every box it
# 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}}}"
}
# 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() {
local d t desc
echo "TEMPLATES"
@ -969,7 +1018,7 @@ cmd_new() {
--config user.box.user="$T_USER" \
--config limits.cpu="$T_CPU" \
--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[@]}"
wait_agent "$instance"
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
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
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
# 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
@ -1011,6 +1078,9 @@ cmd_new() {
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
if [ "$eff" = claude ]; then
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
echo "box: ready — 'box shell $name'."
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
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
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
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
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

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 for a network or a security flag, on purpose — the shared box-net
# 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
# passed to Incus verbatim, so the duplication is deliberate and by hand).
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free — where this project started"
# 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="Claude Code on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="claude"

View file

@ -1,61 +1,33 @@
#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:
- name: claude
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
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
# 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:
- git
- gh
- tmux
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
- zsh
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker claude
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash'
# 'box exec <box> -- claude …' runs through a NON-interactive shell, which
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The
# symlink makes claude reachable from every shell, interactive or not.
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.bashrc
- chsh -s /usr/bin/zsh claude
- 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
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap claude'. @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

@ -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 for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates.
BOX_DESCRIPTION="OpenAI Codex CLI on Debian 13, creds-free"
# 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="OpenAI Codex CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="codex"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="codex"

View file

@ -1,60 +1,33 @@
#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:
- name: codex
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
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
# 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:
- git
- gh
- tmux
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker codex
# Codex CLI is an npm global and needs Node 22+ (verified upstream:
# npmjs.com/package/@openai/codex — the SCOPED @openai/codex, not the
# unrelated 2012 'codex' package).
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- npm install -g @openai/codex
# 'box exec <b> -- codex …' runs a NON-interactive shell that reads no
# rc files — npm's global bin must be reachable from every shell. Symlink
# the installed binary into /usr/local/bin (the same fix the claude
# 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
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap codex'. @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

@ -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 for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates.
BOX_DESCRIPTION="xAI Grok CLI on Debian 13, creds-free"
# 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="xAI Grok CLI on Debian 13, creds-free — box mints, rig converges"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="grok"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"
BOX_BOOTSTRAP_ROLE="grok"

View file

@ -1,75 +1,33 @@
#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:
- name: grok
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
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
# 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:
- git
- gh
- tmux
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker grok
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than
# guessed at. What it actually does:
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build`
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a
# SYMLINK into its versioned download dir (so `find -type f` misses it)
# · GROK_BIN_DIR overrides that directory
#
# Run it AS grok, not root: the binary symlink points into the invoking
# user's download dir, and root's home is 0700 — a symlink into it would be
# 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
# Preinstall rig so the box can converge — and re-converge — via
# 'rig bootstrap grok'. @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

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 '
die() { echo "box: $*" >&2; exit 1; }
. "$0"; load_template "$1"
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s\n" \
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART"
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s ROLE=%s\n" \
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART" "$T_BOOTSTRAP_ROLE"
' "$TPLFN" "$2"
}
@ -165,17 +165,64 @@ check "load_template: an unknown key dies (no template grows a network)" 1 "unkn
tpl "$EVILROOT" evil
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
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 seed lands after rig#31), so without this fixture the case arms could be
# deleted and the suite would stay green while the keys silently died as
# "unknown key" at first use. Accepted AND surfaced, through the real parser.
# The boot demands' green path, kept as a fixture even now that staging sets
# them in-tree: fixtures survive a template rename, and a deleted case arm
# must fail HERE, through the real parser, not at first use on a host.
mkdir -p "$EVILROOT/templates/server"
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
> "$EVILROOT/templates/server/box.env"
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
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"
# ---------------------------------------------------------------------------
# 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
# gracefully (never silently) where they are missing.
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 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"
# cloud-init is passed to Incus verbatim, so it must exist, declare itself,
# and be well-formed — a mint is far too late to learn about a typo.
# cloud-init is passed to Incus verbatim (modulo the two rig pin tokens),
# 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"
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
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.
check "template '$t': installs tmux (#65)" 0 "" \
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
# 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"
# 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" \
| 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
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run