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
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

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