feat(templates): thin creds-free seeds — box mints, rig converges (#81)

The tenant content that lived in claude/codex/grok's cloud-init — agent CLI
installs, docker, node, the per-template agent-context heredocs — moves to
rig's bootstrap roles (rig#31), where it is convergent, idempotent and
testable end to end. What remains per template is a thin seed: the tenant
user, tmux (#65), and rig preinstalled — nothing that joins a tailnet or
admits credentials.

- BOX_BOOTSTRAP_ROLE: a template names the creds-free rig role cmd_new
  auto-runs inside the guest after cloud-init settles. 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.
- render_userdata: the seed's ONE substitution. @RIG_REPO@/@RIG_REF@
  resolve from the mint environment (default heavy-duty/rig @ main —
  unpinned, the honest rig#29 treatment, until rig#32's releases); values
  are whole-string-validated before touching the YAML, because they land
  inside a runcmd shell line.
- templates/staging: the re-cut of #69's layering — user ops,
  BOX_REQUIRE_VM=1, BOX_AUTOSTART=1, role staging. The tailnet workload
  join holds a key and stays operator-run; cmd_new prints it as the next
  step and box never sees the key.
- blank stays a box with nobody home: no rig, no role, nothing auto-runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dan-claude-bot 2026-07-18 20:03:41 +00:00
parent a4d6388b02
commit f4f25016d6
9 changed files with 226 additions and 190 deletions

104
bin/box
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,75 +1,30 @@
#cloud-config #cloud-config
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
# nothing that joins a tailnet or admits credentials, no agent CLI, no
# docker, no context-file heredoc. Everything this box becomes comes from
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after
# mint; the agent-context file — including the #80 guard (never run
# 'box setup-host' or the drill inside a box) — is rendered by that role,
# once, instead of being copy-pasted per template.
users: users:
- name: grok - name: grok
shell: /bin/bash shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL" sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true lock_passwd: true
write_files:
- path: /home/grok/.grok/AGENTS.md
owner: "grok:grok"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: grok)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **Creds-free by default.** The box starts with no xAI and no git
credentials. If you need to authenticate, the operator runs
`grok login` interactively (SuperGrok / X Premium+). For git, the
operator adds their own credentials (a PAT or `gh auth login`). Never
assume credentials are present; never ask for or store secrets on disk
beyond what the operator sets up.
- **Isolated.** The box reaches the public internet but nothing on the host
or local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box
is removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true package_update: true
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
# curl + ca-certificates: the rig installer below rides them, and a bare
# cloud image is not guaranteed to ship either.
packages: packages:
- git - tmux
- gh
- curl - curl
- ca-certificates - ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd: runcmd:
- curl -fsSL https://get.docker.com | sh # Preinstall rig so the box can converge — and re-converge — via
- usermod -aG docker grok # 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than # box substitutes them at mint from the RIG_REPO/RIG_REF environment
# guessed at. What it actually does: # (default heavy-duty/rig @ main — unpinned, tracking main, the same
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build` # honest edge as rig's own unpinned box install, until rig#32 ships a
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a # release flow). The pin covers both the installer fetched AND the tree
# SYMLINK into its versioned download dir (so `find -type f` misses it) # it installs, so a branch under review is testable end to end.
# · GROK_BIN_DIR overrides that directory - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
#
# 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

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,31 @@
#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.
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash