diff --git a/CHANGELOG.md b/CHANGELOG.md index 03fdaa0..05e2744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `); 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 diff --git a/README.md b/README.md index 71e6f2e..8ef93b6 100644 --- a/README.md +++ b/README.md @@ -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 | -| -------- | --------------------------------------------------------- | -| `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 | +| 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 ` → +`sudo rig bootstrap `). 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 `, 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 — diff --git a/bin/box b/bin/box index b8fd45a..2618795 100755 --- a/bin/box +++ b/bin/box @@ -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 ', 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 Required. The box's name. --template 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 [/] Clone src's live state, or its snapshot . @@ -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 '. 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 ' 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" &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 diff --git a/docs/box-design.md b/docs/box-design.md index 44b0f9e..92a0bca 100644 --- a/docs/box-design.md +++ b/docs/box-design.md @@ -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 diff --git a/templates/claude/box.env b/templates/claude/box.env index 3cbfdd4..6ae89fc 100644 --- a/templates/claude/box.env +++ b/templates/claude/box.env @@ -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" diff --git a/templates/claude/user-data.yaml b/templates/claude/user-data.yaml index 970d898..f5ae844 100644 --- a/templates/claude/user-data.yaml +++ b/templates/claude/user-data.yaml @@ -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 -- 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 diff --git a/templates/codex/box.env b/templates/codex/box.env index 08d5e58..21b30d4 100644 --- a/templates/codex/box.env +++ b/templates/codex/box.env @@ -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" diff --git a/templates/codex/user-data.yaml b/templates/codex/user-data.yaml index 550e28a..aaedfe8 100644 --- a/templates/codex/user-data.yaml +++ b/templates/codex/user-data.yaml @@ -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 -- 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 diff --git a/templates/grok/box.env b/templates/grok/box.env index 52912d3..d0a7dd9 100644 --- a/templates/grok/box.env +++ b/templates/grok/box.env @@ -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" diff --git a/templates/grok/user-data.yaml b/templates/grok/user-data.yaml index 26dad6f..24b4178 100644 --- a/templates/grok/user-data.yaml +++ b/templates/grok/user-data.yaml @@ -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 -- 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 diff --git a/templates/staging/box.env b/templates/staging/box.env new file mode 100644 index 0000000..2996662 --- /dev/null +++ b/templates/staging/box.env @@ -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 # then: sudo rig bootstrap workload --hostname +# 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" diff --git a/templates/staging/user-data.yaml b/templates/staging/user-data.yaml new file mode 100644 index 0000000..444c35d --- /dev/null +++ b/templates/staging/user-data.yaml @@ -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 diff --git a/test/cli.sh b/test/cli.sh index f16b5d6..b23b782 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -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 ' 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