Merge pull request #88 from dan-claude-bot/feat/thin-templates
feat: thin templates — box mints, rig converges (#81)
This commit is contained in:
commit
9cf10d58a7
13 changed files with 519 additions and 215 deletions
41
CHANGELOG.md
41
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
|
The agent-context guard for the templates (suggested fix 4) lands in
|
||||||
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
|
heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81).
|
||||||
|
|
||||||
|
- **The `staging` template** (#81, the re-cut of #69's layering) — a
|
||||||
|
server-class, creds-free seed: Debian 13, user `ops`, tmux, rig,
|
||||||
|
`BOX_REQUIRE_VM=1` (the VM is its trust boundary), `BOX_AUTOSTART=1` (a
|
||||||
|
server returns from a host reboot without an operator), and
|
||||||
|
`BOX_BOOTSTRAP_ROLE="staging"` — the server posture (docker, sshd
|
||||||
|
hardening) converges via `rig bootstrap staging` after mint. The tailnet
|
||||||
|
workload join holds a pre-auth key and therefore **stays operator-run**
|
||||||
|
(`box shell` → `sudo rig bootstrap workload`), printed as a next step —
|
||||||
|
box never sees the key.
|
||||||
|
- **`BOX_BOOTSTRAP_ROLE` template key + mint-time auto-run** (#81) — a
|
||||||
|
template names the **creds-free** rig tenant role box runs inside the guest
|
||||||
|
after cloud-init settles (`incus exec … rig bootstrap <role>`); the value
|
||||||
|
is a role *name* by allowlist (anything shell-shaped dies at parse time, on
|
||||||
|
the host). A failed role leaves the box up and names the re-run — the roles
|
||||||
|
are convergent by contract (rig#31). `blank` names no role and auto-runs
|
||||||
|
nothing.
|
||||||
|
- **The rig pin point: `RIG_REPO` / `RIG_REF`** (#81) — the tenant seeds
|
||||||
|
preinstall rig, inverting the rig→box install edge (rig#28), and the new
|
||||||
|
edge gets the same honest treatment rig#29 gave box's unpinned install:
|
||||||
|
`@RIG_REPO@`/`@RIG_REF@` tokens in the seed resolve at mint from the
|
||||||
|
environment (default `heavy-duty/rig` @ `main` — unpinned, tracking main,
|
||||||
|
until a release flow exists, rig#32/#83). The pin covers both the installer
|
||||||
|
fetched and the tree it installs, so a rig branch under review is testable
|
||||||
|
end to end; values are allowlist-validated before touching the YAML.
|
||||||
- **Server-posture template keys** (#81, carved from #69) — two optional
|
- **Server-posture template keys** (#81, carved from #69) — two optional
|
||||||
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
|
`box.env` allowlist keys. `BOX_REQUIRE_VM=1` refuses both the silent
|
||||||
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
|
container fallback (no `/dev/kvm`, exit 1) and an explicit `--container`
|
||||||
|
|
@ -122,6 +146,23 @@ which records not just what changed but what each drill run proved.
|
||||||
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
|
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
|
||||||
into *driven* tests instead of greps (154 checks).
|
into *driven* tests instead of greps (154 checks).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Thin templates — box mints, rig converges** (#81, companion rig#31) —
|
||||||
|
the tenant content that lived in `claude`/`codex`/`grok`'s cloud-init (the
|
||||||
|
agent CLI installs, docker, node, the per-template agent-context heredocs)
|
||||||
|
**moves to rig's bootstrap roles**, where it is convergent, idempotent and
|
||||||
|
testable end to end instead of parse-only YAML. What remains per template
|
||||||
|
is a thin, creds-free seed: the tenant user, tmux (#65), and rig
|
||||||
|
preinstalled — nothing that joins a tailnet or admits credentials. The #80
|
||||||
|
agent-context guard ("never run `box setup-host` or the drill inside a
|
||||||
|
box") now lives once, in rig's roles, not copy-pasted per template. The
|
||||||
|
template test sweep grew the contract's teeth: per-template seed asserts
|
||||||
|
(user matches, rig pinned via both tokens) and fail-closed **absence
|
||||||
|
greps** over effective cloud-init lines — no agent CLI, no docker, no
|
||||||
|
tailscale/authkey/ssh, no `write_files` heredocs — so tenant content
|
||||||
|
cannot quietly grow back.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **`revoke --purge` re-checks the incus-user state** — the purge removed
|
- **`revoke --purge` re-checks the incus-user state** — the purge removed
|
||||||
|
|
|
||||||
74
README.md
74
README.md
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||||
network-isolated Incus box from a **template**; the coding-agent templates
|
network-isolated Incus box from a **template**; the coding-agent templates
|
||||||
ship a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
|
hand you a CLI agent on Debian 13 — `claude` (Claude Code), `codex` (OpenAI
|
||||||
Codex), `grok` (xAI Grok). The box is the product — you log in and work;
|
Codex), `grok` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
||||||
destroying it loses nothing you didn't push.
|
converges**: the template is a thin seed, and the agent tooling lands via a
|
||||||
|
creds-free `rig bootstrap` role auto-run at mint
|
||||||
|
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
||||||
|
— you log in and work; destroying it loses nothing you didn't push.
|
||||||
|
|
||||||
**Strictly creds-free.** A box ships with everything installed and **no**
|
**Strictly creds-free.** A box ships with everything installed and **no**
|
||||||
credentials — no agent token, no git PAT, nothing. You authenticate
|
credentials — no agent token, no git PAT, nothing. You authenticate
|
||||||
|
|
@ -215,23 +218,64 @@ claude # if the repo has .box/, the agent reads it and
|
||||||
No coding agent is special — each is one template among several, and adding
|
No coding agent is special — each is one template among several, and adding
|
||||||
another is just another directory. What ships today:
|
another is just another directory. What ships today:
|
||||||
|
|
||||||
| Template | What's in it |
|
| Template | What it becomes |
|
||||||
| -------- | --------------------------------------------------------- |
|
| --------- | ---------------------------------------------------------------------- |
|
||||||
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||||
| `claude` | Claude Code, creds-free — where this project started |
|
| `claude` | Claude Code, creds-free — where this project started |
|
||||||
| `codex` | OpenAI Codex CLI, creds-free |
|
| `codex` | OpenAI Codex CLI, creds-free |
|
||||||
| `grok` | xAI Grok CLI, creds-free |
|
| `grok` | xAI Grok CLI, creds-free |
|
||||||
|
| `staging` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
||||||
|
|
||||||
A template is a directory under `templates/`: a `box.env` (image, user,
|
**Templates are thin seeds; rig does the becoming**
|
||||||
resources — parsed against a strict allowlist, never sourced) and a
|
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
||||||
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI
|
directory under `templates/`: a `box.env` (image, user, resources, boot
|
||||||
templates are all the same shape — install the CLI, put it on PATH, drop an
|
demands, tenant role — parsed against a strict allowlist, never sourced) and
|
||||||
agent-context file; none of them carry credentials.
|
a `user-data.yaml` (cloud-init, passed to Incus verbatim except the two rig
|
||||||
|
pin tokens below). The seed is deliberately small — the tenant user, tmux,
|
||||||
|
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
||||||
|
joins a tailnet or admits credentials — and after cloud-init settles, box
|
||||||
|
auto-runs the template's **creds-free** tenant role inside the guest
|
||||||
|
(`rig bootstrap claude` / `codex` / `grok` / `staging`,
|
||||||
|
[rig#31](https://github.com/heavy-duty/rig/issues/31)). The agent CLI,
|
||||||
|
docker, the server posture and the agent-context file all come from that
|
||||||
|
role — convergent and idempotent, so the same command re-run later converges
|
||||||
|
an *existing* box to a newer spec (`box shell <box>` →
|
||||||
|
`sudo rig bootstrap <role>`). The agent-context file carries the
|
||||||
|
[#80](https://github.com/heavy-duty/box/issues/80) guard — never run
|
||||||
|
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
|
||||||
|
from rig's roles, instead of copy-pasted per template.
|
||||||
|
|
||||||
|
**Anything that joins or admits stays operator-run.** The `staging` box's
|
||||||
|
tailnet workload join holds a pre-auth key, so box only prints it as the
|
||||||
|
next step — `box shell <name>`, then `sudo rig bootstrap workload` — and
|
||||||
|
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
|
||||||
|
split, kept).
|
||||||
|
|
||||||
|
**The rig pin point** (`RIG_REPO` / `RIG_REF`). The seeds preinstall rig,
|
||||||
|
which inverts the rig→box install edge
|
||||||
|
([rig#28](https://github.com/heavy-duty/rig/issues/28): rig installs box on
|
||||||
|
host-class machines; box guests now install rig). The seed's install line
|
||||||
|
carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
|
||||||
|
environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box new --name work --template claude # heavy-duty/rig @ main
|
||||||
|
RIG_REPO=you/rig RIG_REF=my-branch \
|
||||||
|
box new --name trial --template claude # a rig branch under review
|
||||||
|
```
|
||||||
|
|
||||||
|
Both directions of that edge track `main` unpinned today — said honestly,
|
||||||
|
the same way rig documents box's unpinned install
|
||||||
|
([rig#29](https://github.com/heavy-duty/rig/issues/29)) — until the release
|
||||||
|
flow lands ([rig#32](https://github.com/heavy-duty/rig/issues/32),
|
||||||
|
[#83](https://github.com/heavy-duty/box/issues/83)). The pin covers both the
|
||||||
|
installer fetched and the tree it installs, and the values are
|
||||||
|
allowlist-validated on the host before they touch the YAML.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
box templates # list what this install can mint
|
box templates # list what this install can mint
|
||||||
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
||||||
# same isolation, nobody home
|
# same isolation, nobody home — no rig, no role
|
||||||
```
|
```
|
||||||
|
|
||||||
A template **cannot** name a network, a profile, or a `security.*` flag —
|
A template **cannot** name a network, a profile, or a `security.*` flag —
|
||||||
|
|
|
||||||
104
bin/box
104
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,35 @@ rides along deliberately — and because scrubbing a disk image is a promise
|
||||||
tarball surgery cannot keep, export shouts that the file is a credential
|
tarball surgery cannot keep, export shouts that the file is a credential
|
||||||
instead of pretending to sanitize it.
|
instead of pretending to sanitize it.
|
||||||
|
|
||||||
|
## Thin templates: box mints, rig converges (#81)
|
||||||
|
|
||||||
|
A template is a **thin, creds-free seed** — base image, the tenant user,
|
||||||
|
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
|
||||||
|
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
|
||||||
|
template's creds-free tenant role after cloud-init (`rig bootstrap claude` /
|
||||||
|
`codex` / `grok` / `staging`), which installs the agent CLI or server
|
||||||
|
posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
||||||
|
not convergent, not re-runnable, only parse-and-grep testable — while a rig
|
||||||
|
role is an idempotent script with effective-state asserts that can also
|
||||||
|
converge an *existing* box to a newer spec. Anything that joins a tailnet or
|
||||||
|
holds a key (staging's workload join) stays operator-run through
|
||||||
|
`box shell`; box prints it as a next step and never sees the key. The seed's
|
||||||
|
rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
||||||
|
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
|
||||||
|
and box's template suite holds the line with fail-closed absence greps: no
|
||||||
|
agent CLI, no docker, no tailscale, no context-file heredocs in any
|
||||||
|
template, ever again.
|
||||||
|
|
||||||
## The box announces itself to the agent
|
## The box announces itself to the agent
|
||||||
|
|
||||||
cloud-init installs a global agent-context file in every coding-agent box
|
Every coding-agent box gets a global agent-context file
|
||||||
(`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the
|
(`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the
|
||||||
agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a
|
agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a
|
||||||
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
|
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
|
||||||
execution.
|
execution. The file is rendered by rig's tenant roles from one shared
|
||||||
|
template (#81) — including the #80 guard: never run `box setup-host`,
|
||||||
|
`box teardown-host` or the drill inside a box; a nested box stack claims the
|
||||||
|
guest's own uplink subnet and silently breaks its networking.
|
||||||
|
|
||||||
## `.box/` is optional, agent-facing documentation
|
## `.box/` is optional, agent-facing documentation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,33 @@
|
||||||
#cloud-config
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap claude' (heavy-duty/rig#31), which box auto-runs after
|
||||||
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
|
# once, instead of being copy-pasted per template.
|
||||||
users:
|
users:
|
||||||
- name: claude
|
- name: claude
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
lock_passwd: true
|
lock_passwd: true
|
||||||
write_files:
|
|
||||||
- path: /home/claude/.claude/CLAUDE.md
|
|
||||||
owner: "claude:claude"
|
|
||||||
permissions: '0644'
|
|
||||||
defer: true
|
|
||||||
content: |
|
|
||||||
# You are running inside a box (template: claude)
|
|
||||||
|
|
||||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
|
||||||
`box` CLI. Keep this context in mind:
|
|
||||||
|
|
||||||
- **Creds-free by default.** The box starts with no Claude and no git
|
|
||||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
|
||||||
interactively. For git, the operator adds their own credentials (a PAT or
|
|
||||||
`gh auth login`). Never assume credentials are present; never ask for or
|
|
||||||
store secrets on disk beyond what the operator sets up.
|
|
||||||
- **Isolated.** The box reaches the public internet but nothing on the host or
|
|
||||||
local network. There is no inbound path.
|
|
||||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
|
||||||
removed; the operator persists work via git push and via `box snapshot`.
|
|
||||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
|
||||||
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
|
|
||||||
dependencies, start services, template environment files, seed data, and
|
|
||||||
smoke-test — and follow it. It is documentation for you, not a script the
|
|
||||||
host runs.
|
|
||||||
package_update: true
|
package_update: true
|
||||||
|
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||||
|
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||||
|
# cloud image is not guaranteed to ship either.
|
||||||
packages:
|
packages:
|
||||||
- git
|
- tmux
|
||||||
- gh
|
|
||||||
- curl
|
- curl
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
- gnupg
|
|
||||||
- ripgrep
|
|
||||||
- jq
|
|
||||||
- tmux
|
|
||||||
- age
|
|
||||||
- unzip
|
|
||||||
- build-essential
|
|
||||||
- zsh
|
|
||||||
runcmd:
|
runcmd:
|
||||||
- curl -fsSL https://get.docker.com | sh
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
- usermod -aG docker claude
|
# 'rig bootstrap claude'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
- apt-get install -y nodejs
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
- sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash'
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
# 'box exec <box> -- claude …' runs through a NON-interactive shell, which
|
# release flow). The pin covers both the installer fetched AND the tree
|
||||||
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The
|
# it installs, so a branch under review is testable end to end.
|
||||||
# symlink makes claude reachable from every shell, interactive or not.
|
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||||
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude
|
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||||
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.bashrc
|
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||||
- chsh -s /usr/bin/zsh claude
|
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||||
- sudo -u claude bash -lc 'RUNZSH=no CHSH=no sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"'
|
|
||||||
- echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/claude/.zshrc
|
|
||||||
- echo 'set -g mouse on' > /home/claude/.tmux.conf
|
|
||||||
- chown claude:claude /home/claude/.tmux.conf
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,33 @@
|
||||||
#cloud-config
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap codex' (heavy-duty/rig#31), which box auto-runs after
|
||||||
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
|
# once, instead of being copy-pasted per template.
|
||||||
users:
|
users:
|
||||||
- name: codex
|
- name: codex
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
lock_passwd: true
|
lock_passwd: true
|
||||||
write_files:
|
|
||||||
- path: /home/codex/.codex/AGENTS.md
|
|
||||||
owner: "codex:codex"
|
|
||||||
permissions: '0644'
|
|
||||||
defer: true
|
|
||||||
content: |
|
|
||||||
# You are running inside a box (template: codex)
|
|
||||||
|
|
||||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
|
||||||
`box` CLI. Keep this context in mind:
|
|
||||||
|
|
||||||
- **Creds-free by default.** The box starts with no OpenAI and no git
|
|
||||||
credentials. If you need to authenticate Codex, the operator runs the
|
|
||||||
login flow (`codex`) interactively. For git, the operator adds their own
|
|
||||||
credentials (a PAT or `gh auth login`). Never assume credentials are
|
|
||||||
present; never ask for or store secrets on disk beyond what the operator
|
|
||||||
sets up.
|
|
||||||
- **Isolated.** The box reaches the public internet but nothing on the host
|
|
||||||
or local network. There is no inbound path.
|
|
||||||
- **Disposable.** Nothing here is backed up. State is discarded when the box
|
|
||||||
is removed; the operator persists work via git push and via `box snapshot`.
|
|
||||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
|
||||||
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
|
|
||||||
dependencies, start services, template environment files, seed data, and
|
|
||||||
smoke-test — and follow it. It is documentation for you, not a script the
|
|
||||||
host runs.
|
|
||||||
package_update: true
|
package_update: true
|
||||||
|
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||||
|
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||||
|
# cloud image is not guaranteed to ship either.
|
||||||
packages:
|
packages:
|
||||||
- git
|
- tmux
|
||||||
- gh
|
|
||||||
- curl
|
- curl
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
- gnupg
|
|
||||||
- ripgrep
|
|
||||||
- jq
|
|
||||||
- tmux
|
|
||||||
- age
|
|
||||||
- unzip
|
|
||||||
- build-essential
|
|
||||||
runcmd:
|
runcmd:
|
||||||
- curl -fsSL https://get.docker.com | sh
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
- usermod -aG docker codex
|
# 'rig bootstrap codex'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
# Codex CLI is an npm global and needs Node 22+ (verified upstream:
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# npmjs.com/package/@openai/codex — the SCOPED @openai/codex, not the
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# unrelated 2012 'codex' package).
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
# release flow). The pin covers both the installer fetched AND the tree
|
||||||
- apt-get install -y nodejs
|
# it installs, so a branch under review is testable end to end.
|
||||||
- npm install -g @openai/codex
|
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||||
# 'box exec <b> -- codex …' runs a NON-interactive shell that reads no
|
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||||
# rc files — npm's global bin must be reachable from every shell. Symlink
|
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||||
# the installed binary into /usr/local/bin (the same fix the claude
|
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||||
# template needed for its own CLI).
|
|
||||||
- ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex
|
|
||||||
- echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> /home/codex/.bashrc
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,33 @@
|
||||||
#cloud-config
|
#cloud-config
|
||||||
|
# A thin, creds-free seed (#81): the tenant user, tmux (#65), and rig —
|
||||||
|
# nothing that joins a tailnet or admits credentials, no agent CLI, no
|
||||||
|
# docker, no context-file heredoc. Everything this box becomes comes from
|
||||||
|
# 'rig bootstrap grok' (heavy-duty/rig#31), which box auto-runs after
|
||||||
|
# mint; the agent-context file — including the #80 guard (never run
|
||||||
|
# 'box setup-host' or the drill inside a box) — is rendered by that role,
|
||||||
|
# once, instead of being copy-pasted per template.
|
||||||
users:
|
users:
|
||||||
- name: grok
|
- name: grok
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
lock_passwd: true
|
lock_passwd: true
|
||||||
write_files:
|
|
||||||
- path: /home/grok/.grok/AGENTS.md
|
|
||||||
owner: "grok:grok"
|
|
||||||
permissions: '0644'
|
|
||||||
defer: true
|
|
||||||
content: |
|
|
||||||
# You are running inside a box (template: grok)
|
|
||||||
|
|
||||||
A box is a trust-less, network-isolated, ephemeral VM created by the
|
|
||||||
`box` CLI. Keep this context in mind:
|
|
||||||
|
|
||||||
- **Creds-free by default.** The box starts with no xAI and no git
|
|
||||||
credentials. If you need to authenticate, the operator runs
|
|
||||||
`grok login` interactively (SuperGrok / X Premium+). For git, the
|
|
||||||
operator adds their own credentials (a PAT or `gh auth login`). Never
|
|
||||||
assume credentials are present; never ask for or store secrets on disk
|
|
||||||
beyond what the operator sets up.
|
|
||||||
- **Isolated.** The box reaches the public internet but nothing on the host
|
|
||||||
or local network. There is no inbound path.
|
|
||||||
- **Disposable.** Nothing here is backed up. State is discarded when the box
|
|
||||||
is removed; the operator persists work via git push and via `box snapshot`.
|
|
||||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
|
||||||
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
|
|
||||||
dependencies, start services, template environment files, seed data, and
|
|
||||||
smoke-test — and follow it. It is documentation for you, not a script the
|
|
||||||
host runs.
|
|
||||||
package_update: true
|
package_update: true
|
||||||
|
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65).
|
||||||
|
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||||
|
# cloud image is not guaranteed to ship either.
|
||||||
packages:
|
packages:
|
||||||
- git
|
- tmux
|
||||||
- gh
|
|
||||||
- curl
|
- curl
|
||||||
- ca-certificates
|
- ca-certificates
|
||||||
- gnupg
|
|
||||||
- ripgrep
|
|
||||||
- jq
|
|
||||||
- tmux
|
|
||||||
- age
|
|
||||||
- unzip
|
|
||||||
- build-essential
|
|
||||||
runcmd:
|
runcmd:
|
||||||
- curl -fsSL https://get.docker.com | sh
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
- usermod -aG docker grok
|
# 'rig bootstrap grok'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
# guessed at. What it actually does:
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build`
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a
|
# release flow). The pin covers both the installer fetched AND the tree
|
||||||
# SYMLINK into its versioned download dir (so `find -type f` misses it)
|
# it installs, so a branch under review is testable end to end.
|
||||||
# · GROK_BIN_DIR overrides that directory
|
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||||
#
|
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||||
# Run it AS grok, not root: the binary symlink points into the invoking
|
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||||
# user's download dir, and root's home is 0700 — a symlink into it would be
|
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||||
# unreadable to the grok user, giving a CLI that exists and cannot run.
|
|
||||||
- sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash'
|
|
||||||
# 'box exec <b> -- grok …' is a NON-interactive shell: it reads no rc files,
|
|
||||||
# so ~/.grok/bin is never on its PATH. Symlink onto the system PATH — the
|
|
||||||
# same fix the claude template needed (#15). Assert the result: a CLI that
|
|
||||||
# silently is not on PATH is what cost the last drill run.
|
|
||||||
- |
|
|
||||||
if [ -e /home/grok/.grok/bin/grok ]; then
|
|
||||||
ln -sf /home/grok/.grok/bin/grok /usr/local/bin/grok
|
|
||||||
echo "grok: linked /usr/local/bin/grok -> /home/grok/.grok/bin/grok"
|
|
||||||
/usr/local/bin/grok --version >/dev/null 2>&1 \
|
|
||||||
&& echo "grok: 'grok --version' answers from the system PATH" \
|
|
||||||
|| echo "grok: WARNING - linked, but 'grok --version' does not answer" >&2
|
|
||||||
else
|
|
||||||
echo "grok: installer produced no ~/.grok/bin/grok - upstream layout changed?" >&2
|
|
||||||
find /home/grok -maxdepth 4 \( -type f -o -type l \) -perm -u+x 2>/dev/null | head -20 >&2
|
|
||||||
fi
|
|
||||||
- echo 'export PATH="$HOME/.grok/bin:$PATH"' >> /home/grok/.bashrc
|
|
||||||
|
|
|
||||||
24
templates/staging/box.env
Normal file
24
templates/staging/box.env
Normal 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"
|
||||||
34
templates/staging/user-data.yaml
Normal file
34
templates/staging/user-data.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
#cloud-config
|
||||||
|
# A thin, creds-free, server-class seed (#81): the 'ops' user, tmux (#65),
|
||||||
|
# and rig — nothing that joins a tailnet or admits credentials, no docker,
|
||||||
|
# no sshd config, no keys. The server posture comes from
|
||||||
|
# 'rig bootstrap staging' (heavy-duty/rig#31), which box auto-runs after
|
||||||
|
# mint; the tailnet workload join holds a pre-auth key and stays
|
||||||
|
# operator-run ('box shell' → 'sudo rig bootstrap workload'), exactly as
|
||||||
|
# #69 designed it — box never sees the key.
|
||||||
|
users:
|
||||||
|
- name: ops
|
||||||
|
shell: /bin/bash
|
||||||
|
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||||
|
lock_passwd: true
|
||||||
|
package_update: true
|
||||||
|
# tmux: 'box tmux' runs 'tmux new-session' INSIDE the box (#65) — and the
|
||||||
|
# operator babysits the workload join through it.
|
||||||
|
# curl + ca-certificates: the rig installer below rides them, and a bare
|
||||||
|
# cloud image is not guaranteed to ship either.
|
||||||
|
packages:
|
||||||
|
- tmux
|
||||||
|
- curl
|
||||||
|
- ca-certificates
|
||||||
|
runcmd:
|
||||||
|
# Preinstall rig so the box can converge — and re-converge — via
|
||||||
|
# 'rig bootstrap staging'. @RIG_REPO@/@RIG_REF@ are the pin point (#81):
|
||||||
|
# box substitutes them at mint from the RIG_REPO/RIG_REF environment
|
||||||
|
# (default heavy-duty/rig @ main — unpinned, tracking main, the same
|
||||||
|
# honest edge as rig's own unpinned box install, until rig#32 ships a
|
||||||
|
# release flow). The pin covers both the installer fetched AND the tree
|
||||||
|
# it installs, so a branch under review is testable end to end.
|
||||||
|
# HOME=/root: cloud-init runs runcmd as root but with NO $HOME in the
|
||||||
|
# environment, and the rig installer (set -u) reads $HOME for its DEST —
|
||||||
|
# measured live: the mint died with "HOME: unbound variable" without it.
|
||||||
|
- curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | HOME=/root RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash
|
||||||
165
test/cli.sh
165
test/cli.sh
|
|
@ -150,8 +150,8 @@ tpl() {
|
||||||
root="$1" bash -c '
|
root="$1" bash -c '
|
||||||
die() { echo "box: $*" >&2; exit 1; }
|
die() { echo "box: $*" >&2; exit 1; }
|
||||||
. "$0"; load_template "$1"
|
. "$0"; load_template "$1"
|
||||||
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s\n" \
|
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s ROLE=%s\n" \
|
||||||
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART"
|
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART" "$T_BOOTSTRAP_ROLE"
|
||||||
' "$TPLFN" "$2"
|
' "$TPLFN" "$2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,17 +165,64 @@ check "load_template: an unknown key dies (no template grows a network)" 1 "unkn
|
||||||
tpl "$EVILROOT" evil
|
tpl "$EVILROOT" evil
|
||||||
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
|
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
|
||||||
check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil
|
check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil
|
||||||
# The green path the two new keys exist for: no in-tree template sets them yet
|
# The boot demands' green path, kept as a fixture even now that staging sets
|
||||||
# (the seed lands after rig#31), so without this fixture the case arms could be
|
# them in-tree: fixtures survive a template rename, and a deleted case arm
|
||||||
# deleted and the suite would stay green while the keys silently died as
|
# must fail HERE, through the real parser, not at first use on a host.
|
||||||
# "unknown key" at first use. Accepted AND surfaced, through the real parser.
|
|
||||||
mkdir -p "$EVILROOT/templates/server"
|
mkdir -p "$EVILROOT/templates/server"
|
||||||
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
|
||||||
> "$EVILROOT/templates/server/box.env"
|
> "$EVILROOT/templates/server/box.env"
|
||||||
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
|
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
|
||||||
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server
|
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server
|
||||||
|
# BOX_BOOTSTRAP_ROLE (#81): accepted and surfaced through the real parser —
|
||||||
|
# and the value is a rig role NAME, nothing more. It is handed to
|
||||||
|
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
|
||||||
|
# it must die at parse time, on the host, before a guest exists.
|
||||||
|
mkdir -p "$EVILROOT/templates/tenant"
|
||||||
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude"\n' \
|
||||||
|
> "$EVILROOT/templates/tenant/box.env"
|
||||||
|
check "load_template: BOX_BOOTSTRAP_ROLE round-trips (accepted + surfaced)" \
|
||||||
|
0 "ROLE=claude" tpl "$EVILROOT" tenant
|
||||||
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude; rm -rf /"\n' \
|
||||||
|
> "$EVILROOT/templates/tenant/box.env"
|
||||||
|
check "load_template: a shell-shaped BOX_BOOTSTRAP_ROLE dies at the gate" \
|
||||||
|
1 "not a sane role name" tpl "$EVILROOT" tenant
|
||||||
rm -rf "$EVILROOT"
|
rm -rf "$EVILROOT"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# render_userdata (#81) — the seed's ONE substitution, driven for real: the
|
||||||
|
# rig pin point. Defaults resolve to heavy-duty/rig@main; RIG_REPO/RIG_REF
|
||||||
|
# override at mint (how a rig branch under review reaches a guest); and a
|
||||||
|
# hostile value — the tokens land inside a runcmd shell line — dies on the
|
||||||
|
# host before touching the YAML. bash's =~ anchors the WHOLE string, so a
|
||||||
|
# multi-line value cannot sneak one clean line past it (the line-oriented
|
||||||
|
# grep -q failure mode).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
RUFN="$(mktemp)"
|
||||||
|
awk '/^render_userdata\(\) \{/,/^\}/' "$ROOT/bin/box" > "$RUFN"
|
||||||
|
check "render_userdata: extracted from bin/box (guards the awk)" 0 "RIG_REPO" cat "$RUFN"
|
||||||
|
check "render_userdata: the extracted function is valid bash" 0 "" bash -n "$RUFN"
|
||||||
|
|
||||||
|
SEED="$(mktemp)"
|
||||||
|
printf '#cloud-config\nruncmd:\n - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash\n' > "$SEED"
|
||||||
|
# shellcheck disable=SC2016 # $0/$1 expand in the child shell, by design
|
||||||
|
rud() { # rud [VAR=val ...] — render the fixture seed through the real function
|
||||||
|
env "$@" bash -c 'die() { echo "box: $*" >&2; exit 1; }; . "$0"; render_userdata "$1"' "$RUFN" "$SEED"
|
||||||
|
}
|
||||||
|
check "render_userdata: defaults pin heavy-duty/rig" 0 "githubusercontent.com/heavy-duty/rig/main/install.sh" rud
|
||||||
|
check "render_userdata: defaults feed the installer's own env too" 0 'RIG_REPO="heavy-duty/rig" RIG_REF="main"' rud
|
||||||
|
check "render_userdata: RIG_REPO/RIG_REF override at mint" 0 "dan-claude-bot/rig/feat/bootstrap-roles/install.sh" \
|
||||||
|
rud RIG_REPO=dan-claude-bot/rig RIG_REF=feat/bootstrap-roles
|
||||||
|
# shellcheck disable=SC2016 # $0/$1 expand in the child shells, by design
|
||||||
|
check "render_userdata: no token survives the render" 1 "" \
|
||||||
|
bash -c 'env bash -c "die() { echo box: \$*; exit 1; }; . \"\$0\"; render_userdata \"\$1\"" "$1" "$2" | grep -q @RIG_' _ "$RUFN" "$SEED"
|
||||||
|
check "render_userdata: a shell-shaped RIG_REPO dies on the host" 1 "RIG_REPO" \
|
||||||
|
rud 'RIG_REPO=evil"; rm -rf /; "/rig'
|
||||||
|
check "render_userdata: a spaced RIG_REF dies on the host" 1 "RIG_REF" \
|
||||||
|
rud 'RIG_REF=main plus junk'
|
||||||
|
check "render_userdata: a newline-smuggled RIG_REPO dies (whole-string anchor)" 1 "RIG_REPO" \
|
||||||
|
rud "RIG_REPO=$(printf 'a/b\nevil')"
|
||||||
|
rm -f "$RUFN" "$SEED"
|
||||||
|
|
||||||
# YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip
|
# YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip
|
||||||
# gracefully (never silently) where they are missing.
|
# gracefully (never silently) where they are missing.
|
||||||
HAVE_YAML=0
|
HAVE_YAML=0
|
||||||
|
|
@ -189,8 +236,9 @@ for d in "$ROOT"/templates/*/; do
|
||||||
check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t"
|
check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t"
|
||||||
check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env"
|
check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env"
|
||||||
check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env"
|
check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env"
|
||||||
# cloud-init is passed to Incus verbatim, so it must exist, declare itself,
|
# cloud-init is passed to Incus verbatim (modulo the two rig pin tokens),
|
||||||
# and be well-formed — a mint is far too late to learn about a typo.
|
# so it must exist, declare itself, and be well-formed — a mint is far too
|
||||||
|
# late to learn about a typo.
|
||||||
check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml"
|
check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml"
|
||||||
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||||||
check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \
|
check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \
|
||||||
|
|
@ -205,8 +253,80 @@ for d in "$ROOT"/templates/*/; do
|
||||||
# template's package list must carry tmux or the verb dies inside.
|
# template's package list must carry tmux or the verb dies inside.
|
||||||
check "template '$t': installs tmux (#65)" 0 "" \
|
check "template '$t': installs tmux (#65)" 0 "" \
|
||||||
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml"
|
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml"
|
||||||
|
# BOX_USER is duplicated into the cloud-init by hand (the file reaches
|
||||||
|
# Incus verbatim) — assert the two halves actually agree, per template.
|
||||||
|
tuser="$(tpl "$ROOT" "$t" | sed -n 's/.*USER=\([^ ]*\).*/\1/p')"
|
||||||
|
check "template '$t': user-data.yaml creates BOX_USER ('$tuser')" 0 "" \
|
||||||
|
grep -qE "^[[:space:]]*-[[:space:]]+name:[[:space:]]+$tuser\$" "$d/user-data.yaml"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# The thin-template contract (#81), both halves per template:
|
||||||
|
#
|
||||||
|
# THE SEED — a template that names a tenant role (BOX_BOOTSTRAP_ROLE) must
|
||||||
|
# preinstall rig carrying BOTH pin tokens, on the installer URL and on the
|
||||||
|
# installer's own env, or the pin is a half-truth: a mint would fetch one
|
||||||
|
# ref's installer and install another ref's tree.
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
trole="$(tpl "$ROOT" "$t" | sed -n 's/.*ROLE=\([^ ]*\).*/\1/p')"
|
||||||
|
if [ -n "$trole" ]; then
|
||||||
|
check "template '$t': the seed installs rig (role '$trole')" 0 "" \
|
||||||
|
grep -q 'install.sh' "$d/user-data.yaml"
|
||||||
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||||||
|
check "template '$t': the rig install carries the @RIG_REPO@ pin token" 0 "" \
|
||||||
|
bash -c 'grep "install.sh" "$1" | grep -q "@RIG_REPO@/@RIG_REF@"' _ "$d/user-data.yaml"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "template '$t': the pin reaches the installer's env too" 0 "" \
|
||||||
|
bash -c 'grep "install.sh" "$1" | grep -q "RIG_REPO=\"@RIG_REPO@\" RIG_REF=\"@RIG_REF@\""' _ "$d/user-data.yaml"
|
||||||
|
# HOME=/root: a scar found live — cloud-init's runcmd has no $HOME and
|
||||||
|
# rig's installer (set -u) dies on it (rig#39). The pin must survive
|
||||||
|
# every seed rewrite.
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "template '$t': the rig install pins HOME=/root (runcmd has no \$HOME)" 0 "" \
|
||||||
|
bash -c 'grep "install.sh" "$1" | grep -q "HOME=/root "' _ "$d/user-data.yaml"
|
||||||
|
fi
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# THE ABSENCE — no tenant content in ANY template, ever again. Everything a
|
||||||
|
# box becomes lives in rig's roles (rig#31); a template that grows an agent
|
||||||
|
# CLI, docker, node, a tailnet join or a context-file heredoc is the
|
||||||
|
# regression this suite exists to refuse. Greps run over EFFECTIVE
|
||||||
|
# cloud-init lines (comments may name what they refuse — #69's idiom), and
|
||||||
|
# they fail CLOSED: the want-exit is 1, so re-adding any of it goes red.
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
||||||
|
check "template '$t': no agent CLI install (rig's job, rig#31)" 1 "" \
|
||||||
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "claude\.ai|x\.ai|@openai|npm|nodesource|nodejs"' _ "$d/user-data.yaml"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "template '$t': no docker (rig's job, rig#31)" 1 "" \
|
||||||
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qi docker' _ "$d/user-data.yaml"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "template '$t': nothing that joins or admits (no tailscale/authkey/ssh)" 1 "" \
|
||||||
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "tailscale|authkey|ssh"' _ "$d/user-data.yaml"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "template '$t': no context-file heredoc (the #80 guard lives in rig's roles)" 1 "" \
|
||||||
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "write_files|CLAUDE\.md|AGENTS\.md"' _ "$d/user-data.yaml"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The staging seed's boot demands are part of its contract (#68/#69): the VM
|
||||||
|
# is its trust boundary (its guest runs docker, via rig) and a server returns
|
||||||
|
# from a host reboot without an operator. Pinned to the FILE so neither can
|
||||||
|
# quietly vanish in a rewrite.
|
||||||
|
check "staging: demands VM mode (BOX_REQUIRE_VM=1)" 0 "" \
|
||||||
|
grep -qx 'BOX_REQUIRE_VM="1"' "$ROOT/templates/staging/box.env"
|
||||||
|
check "staging: demands autostart (BOX_AUTOSTART=1)" 0 "" \
|
||||||
|
grep -qx 'BOX_AUTOSTART="1"' "$ROOT/templates/staging/box.env"
|
||||||
|
check "staging: the tenant role is 'staging'" 0 "ROLE=staging" tpl "$ROOT" staging
|
||||||
|
check "staging: the seed user is rig's default for the role ('ops')" 0 "USER=ops" tpl "$ROOT" staging
|
||||||
|
# The agent tenants: role = user = template name, rig's default mapping.
|
||||||
|
for t in claude codex grok; do
|
||||||
|
check "$t: role and user are '$t' (rig's default tenant mapping)" 0 "USER=$t REQUIRE_VM= AUTOSTART= ROLE=$t" \
|
||||||
|
tpl "$ROOT" "$t"
|
||||||
|
done
|
||||||
|
# blank stays a box with NOBODY home: no rig, no role — same isolation, no
|
||||||
|
# tooling, and nothing auto-runs in it.
|
||||||
|
check "blank: names no bootstrap role" 1 "" \
|
||||||
|
grep -q '^BOX_BOOTSTRAP_ROLE=' "$ROOT/templates/blank/box.env"
|
||||||
|
check "blank: does not preinstall rig" 1 "" grep -q 'install.sh' "$ROOT/templates/blank/user-data.yaml"
|
||||||
|
|
||||||
rm -f "$TPLFN"
|
rm -f "$TPLFN"
|
||||||
|
|
||||||
# The keys' cmd_new half, grepped the way the expose guard is (line order —
|
# The keys' cmd_new half, grepped the way the expose guard is (line order —
|
||||||
|
|
@ -232,6 +352,35 @@ check "new: boot.autostart is stamped under the T_AUTOSTART guard" 0 "" bash -c
|
||||||
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||||||
| grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"'
|
| grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"'
|
||||||
|
|
||||||
|
# The auto-run half of #81, grepped the same way (a daemon-free run cannot
|
||||||
|
# mint). The seed reaches Incus through render_userdata — the pin point — not
|
||||||
|
# through a raw cat; and the tenant convergence must order AFTER the
|
||||||
|
# cloud-init wait (rig is installed by the seed's runcmd, so exec'ing the
|
||||||
|
# role before cloud-init settles would race its own installer) and sit under
|
||||||
|
# the T_BOOTSTRAP_ROLE guard (blank must never auto-run anything).
|
||||||
|
check "new: cloud-init user-data goes through render_userdata (the rig pin)" 0 "" bash -c '
|
||||||
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||||||
|
| grep -F "cloud-init.user-data" | grep -q "render_userdata"'
|
||||||
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
||||||
|
check "new: the tenant auto-run orders after the cloud-init wait" 0 "" bash -c '
|
||||||
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
||||||
|
wait="$(printf "%s\n" "$fn" | grep -n "cloud-init status --wait" | head -1 | cut -d: -f1)"
|
||||||
|
run="$(printf "%s\n" "$fn" | grep -n "rig bootstrap" | head -1 | cut -d: -f1)"
|
||||||
|
[ -n "$wait" ] && [ -n "$run" ] && [ "$wait" -lt "$run" ]'
|
||||||
|
check "new: the auto-run sits under the T_BOOTSTRAP_ROLE guard" 0 "" bash -c '
|
||||||
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||||||
|
| grep -B2 "incus exec .* rig bootstrap" | grep -q "T_BOOTSTRAP_ROLE"'
|
||||||
|
check "new: a failed tenant role names the re-run (the role converges)" 0 "" bash -c '
|
||||||
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
||||||
|
| grep -q "sudo rig bootstrap"'
|
||||||
|
# staging's creds-holding join stays OPERATOR-run: cmd_new may print it as a
|
||||||
|
# next step, but no template and no code path auto-runs "rig bootstrap
|
||||||
|
# workload" — the one absence that keeps box creds-free end to end.
|
||||||
|
check "new: the workload join is printed, never exec'd" 1 "" bash -c '
|
||||||
|
grep "rig bootstrap workload" "'"$ROOT"'/bin/box" | grep -q "incus exec"'
|
||||||
|
check "templates: no template names a creds-holding role" 1 "" bash -c '
|
||||||
|
grep -h "^BOX_BOOTSTRAP_ROLE=" "'"$ROOT"'"/templates/*/box.env | grep -qE "workload|host|custom"'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The restricted tier (#74). box_tier() is the decision the whole tier hangs
|
# The restricted tier (#74). box_tier() is the decision the whole tier hangs
|
||||||
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run
|
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue