box/bin/box
claude-hdb 455fbc656e feat: host lifecycle as verbs (setup-host/teardown-host/migrate-host) + .box/ convention
Two things:

1. The host scripts are first-class verbs now — 'box setup-host',
   'box teardown-host [--purge-incus]', 'box migrate-host --box <n>'.
   Nobody should have to run ~/.local/share/box/host/<script>.sh; that
   read like an external script and exposed an install path. Each verb
   execs the installed script with its flags passed through (same
   pattern as 'box doctor'). README, doctor hints, and the uninstall
   section point at the verbs now.

2. The repo-runbook convention is '.box/', not '.claudebox/'. Renamed
   across docs and the templates' agent briefing; the briefing tells
   the agent to read either, and box-recipe.md notes the rename, so
   repos still shipping '.claudebox/' keep working through the
   transition. (Consuming repos rename their own folder — tracked
   separately.)

Also widened the help command column for the longer verb names, and
fixed one sed-casualty where a broad '.claudebox'→'.box' pass had
turned the README's legacy user.claudebox tag into user.box.
2026-07-14 18:01:34 +00:00

1032 lines
44 KiB
Bash
Executable file

#!/usr/bin/env bash
# box — trust-less, isolated Incus VMs with Claude Code, creds-free.
# The command surface is the CMDS table below: it is the single source of truth
# for what exists, what it looks like, what the help says, and what runs. The
# help cannot drift from the code, because it is rendered from the same rows.
set -euo pipefail
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
inst="" # the resolved Incus instance, set by the 'box' precondition
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
# ---------------------------------------------------------------------------
# The command table.
#
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message
#
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
#
# preconditions (comma-separated):
# box first positional is a box: resolve it, and REFUSE if the instance
# isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
# arg2 a second positional is required
# stopped the box must not be running
# confirm destructive: prompt unless --force
#
# action:
# incus:<subcommand> run `incus <subcommand> <instance> [rest...]`
# fn:<function> call a shell function (it has real work to do)
#
# ok message: printed on success; {} = the box, {1} = the second positional.
#
# Adding a thin verb is one row. If a request can't be expressed as a row and
# doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for.
CMDS=(
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
"templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
"shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:restore^restored {} to {1}"
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
"start^<box>^box^Start a stopped box^incus:start^started {}"
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
)
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
verbs() { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
is_command() { cmd_row "$1" >/dev/null 2>&1; }
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
# error paths inside it — a global read would clobber the row being dispatched.
field() {
local r f_syn f_pre f_sum f_act f_ok
r="$(cmd_row "$1")" || return 1
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok <<<"$r"
case "$2" in
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
esac
}
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
# Nearest command by edit distance — a typo should point somewhere, not just fail.
suggest() {
verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
function dist(a, b, la, lb, i, j, c, prev, cur) {
la = length(a); lb = length(b)
for (j = 0; j <= lb; j++) prev[j] = j
for (i = 1; i <= la; i++) {
cur[0] = i
for (j = 1; j <= lb; j++) {
c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
cur[j] = prev[j] + 1
if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
}
for (j = 0; j <= lb; j++) prev[j] = cur[j]
}
return prev[lb]
}
BEGIN { best = 99 }
{ d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
END { if (best <= 2) print hit }'
}
unknown_command() {
local hint; hint="$(suggest "$1")"
if [ -n "$hint" ]; then
echo "box: unknown command: $1 — did you mean '$hint'?" >&2
else
echo "box: unknown command: $1" >&2
fi
echo "try 'box help' for the command list." >&2
exit 2
}
usage() {
cat <<'EOF'
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
USAGE
box <command> [<args>] [options]
COMMANDS
EOF
local r v sum
for r in "${CMDS[@]}"; do
IFS='^' read -r v _ _ sum _ _ <<<"$r"
printf ' %-13s %s\n' "$v" "$sum"
done
cat <<'EOF'
OPTIONS
--name <box> Name for the new box (new)
--template <t> Template to mint from (default: blank) (new)
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
--vm Force VM mode: the trust-less target (new)
--container Force container mode: weaker isolation, (new)
dev/test only. Default where /dev/kvm is absent.
--json Emit Incus JSON instead of a table (list, info)
--force, -f Delete without the confirmation prompt (rm)
--remote <r> Act on Incus remote <r> (any)
--help, -h Help; after a command, help for that command
--version, -V Print the box version
Options come after the command: 'box list --json', not 'box --json list'.
EXAMPLES
# mint a claude box and log in inside it — the tool never handles your token
box new --name work --template claude
box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it
box snapshot work authed
box new --name feature --from work/authed
# the default: a blank box — same isolation, nobody home
box new --name scratch
box templates
# what have I got, and what can I clone?
box list
box info work
# run something without opening a shell
box exec work -- git -C project pull
# anything box doesn't wrap: boxes are plain Incus instances
box incus work -- config show
EXIT STATUS
0 ok
1 it went wrong (Incus failed, no such box, aborted at a prompt)
2 you asked wrong (unknown command, bad flag, destructive act without --force)
THE MODEL
A box carries NO credentials. You authenticate interactively inside it
('claude' then /login; 'gh auth login'); box never stores or injects a
secret. A box reaches the public internet and nothing else — there is no
inbound path. Destroying a box loses nothing you didn't push.
box owns a command when it must enforce something Incus cannot see: the
user.box=1 boundary, the isolation stack, or the creds-free snapshot
workflow. Everything else is Incus's job — and 'box incus' is the door.
Docs: https://github.com/heavy-duty/box
EOF
}
# The synopsis and the summary come from the table; only prose lives here, and
# only where a command has something to say beyond its summary.
help_cmd() {
echo "usage: $(synopsis_of "$1")"
echo
case "$1" in
new) cat <<'EOF'
Mint a box. Without --from, launches a fresh box from a template (default:
blank — bare Debian 13, nobody home; --template claude gets Claude Code
installed, creds-free, ~10 min cold). With --from, clones an existing box or
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.
--name <box> Required. The box's name.
--template <t> Template to mint from; 'box templates' lists them.
A template sets image, user and resources — never
the network: every template gets the same isolation.
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
--vm | --container Force the mode. VM is the trust boundary and the
default wherever /dev/kvm exists; container mode
(security.nesting=true) is the fallback for hosts
without nested virt — weaker isolation, dev/test only.
Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
environment variables override them at mint time (a small host shrinks a box
without editing a template it doesn't own).
box new --name scratch # blank, the default
box new --name work --template claude
box new --name feature --from work/authed
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 — 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.
box templates
box new --name scratch --template blank
EOF
;;
list) cat <<'EOF'
List the boxes box minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'box info <box>'.
--json Incus's JSON, straight through, for scripting.
box list
EOF
;;
info) cat <<'EOF'
Show one box: state, type, IP address, and — the reason this exists — the
labels of its snapshots, with the --from line to clone one.
--json Incus's JSON, straight through, for scripting.
box info work
EOF
;;
shell) cat <<'EOF'
Open an interactive shell in a running box as the 'claude' user. This is the
only entry path — there is no SSH and no inbound route to a box.
box shell work
EOF
;;
exec) cat <<'EOF'
Run a command inside a box as the 'claude' user. Everything after -- is passed
through untouched; the -- is required, or box will read your command's
flags as its own.
box exec work -- git -C project pull
box exec work -- claude --version
EOF
;;
tmux) cat <<'EOF'
A shell that survives you. 'shell' is a child of the exec connection — if your
terminal or SSH session drops, everything running in it is SIGHUP'd, and a
long Claude run dies with it. This attaches a tmux session instead
(new-session -A): created if new, reattached if it exists — so starting work
and resuming after a disconnect are the same command, with no state to
remember.
The session name (default: main) buys parallel streams in one box:
box tmux work # attach or create 'main'
box tmux work run-1 # a second, independent stream, same box
Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
tmux's semantics, 'box shell' is unchanged.
EOF
;;
snapshot) cat <<'EOF'
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have.
box snapshot work authed
EOF
;;
restore) cat <<'EOF'
Roll a box back to one of its snapshots, in place. Anything in the box since
that snapshot is lost. 'box info <box>' lists the labels.
box restore work authed
EOF
;;
rename) cat <<'EOF'
Rename a box. Incus cannot rename a running instance, so stop it first:
box down work
box rename work archive
box start archive
Snapshots and Claude auth follow the box; anything referring to the old name by
hand (a --from line, a script) does not.
EOF
;;
rm) cat <<'EOF'
Delete a box and every snapshot it has. This cannot be undone, so it asks for
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
(a script, a pipe), it refuses unless --force is given.
box rm work
box rm work --force
EOF
;;
expose) cat <<'EOF'
Open a deliberate, loopback-only door to a port inside a box — for when you
are coding in a box and want to see its dev server in your browser.
box expose <box> <port> [<host-port>] # forward 127.0.0.1:<host-port> → box:<port>
box expose <box> --list # what doors are open
box expose <box> --remove <port> # close one
The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the
box, only this host's loopback. There is no flag to widen that; if you need
LAN exposure you are leaving the tool's threat model, and 'box incus' is the
door (with its warning).
The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
a VM's forwarder connects to the box over the network. Inside an isolated box
that is safe: nothing but this proxy can reach the port.
box new --name web --template claude
box shell web # inside: run a dev server on 0.0.0.0:3000
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
EOF
;;
incus) cat <<'EOF'
The door out. box wraps the box lifecycle and the isolation model, not
all of Incus — so when you need something it doesn't wrap, run Incus through
here and keep the safety rail that matters: the box name is resolved and
checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
box didn't mint.
Everything after -- is passed to incus verbatim. A literal {} is replaced with
the resolved instance name; with no {}, the instance is appended at the end.
The command that will run is echoed before it runs.
box incus work -- config show
box incus work -- config device add {} extra disk source=/data path=/data
Changing the profile, the network, a device or a security.* key can take a box
outside the isolation stack. box warns and then does as you asked — from
there, the trust boundary is yours to keep.
EOF
;;
doctor) cat <<'EOF'
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
the kernel's bridge port say 'isolated on', is the resolver pinned or is a
host VPN's DNS leaking into boxes, can a box actually resolve names. Every
check exists because its fault has happened — most kill a cold mint with a
cloud-init error that names none of them.
--fix also revert what a drill run may have left behind
--pin-dns pin boxnet's resolver to public upstreams and re-test
(setup-host.sh now pins by default; this is the quick test)
box doctor
box doctor --fix
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given.
EOF
;;
setup-host) cat <<'EOF'
Prepare this host to mint boxes — one time. Installs Incus and builds the
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
Idempotent — safe to re-run after a box upgrade to pick up stack changes.
If it has to add you to the incus-admin group it will say so and exit; log
back in (or 'sg incus-admin') and run it again.
box setup-host
EOF
;;
teardown-host) cat <<'EOF'
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
ACLs, the profiles, and the firewall rules of BOTH name generations (current
and pre-0.4.0). Asks first.
box teardown-host # the stack; leaves Incus installed
box teardown-host --purge-incus # ...and uninstall Incus too
EOF
;;
migrate-host) cat <<'EOF'
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
preserves a box's authed state (no re-login) — it only re-tags and reassigns
the profile, then verifies the box works on its new network leg.
box migrate-host --box <name> # re-home one legacy box
box migrate-host --all-boxes # re-home every legacy box
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
EOF
;;
status) cat <<'EOF'
Deprecated alias for 'box list'. It ignored the <box> argument it
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
still works, and forwards to 'list'.
EOF
;;
help) cat <<'EOF'
Print the general help, or the help for one command.
box help
box help rename
EOF
;;
*) field "$1" sum ;; # no prose: the table's summary is the help
esac
}
show_help() { # "" → general help
case "${1:-}" in
""|help) usage ;;
*) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
esac
}
cmd="${1:-help}"; shift || true
case "$cmd" in
-h|--help) usage; exit 0 ;;
-V|--version) version; exit 0 ;;
-*) usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
esac
args=()
while [ $# -gt 0 ]; do
case "$1" in
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
--vm) mode=vm; shift ;;
--container) mode=container; shift ;;
--force|-f) force=1; shift ;;
--json) json=1; shift ;;
--help|-h) want_help=1; shift ;;
--version|-V) version; exit 0 ;;
--) shift; args+=("$@"); break ;;
# An unrecognized flag used to be swallowed as a positional — so a typo'd
# --labl silently became a snapshot's label. Say so instead.
-*)
# doctor's flags belong to the doctor script, not to box
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# the host verbs delegate their flags to the scripts they wrap
case "$cmd" in setup-host|teardown-host|migrate-host) args+=("$1"); shift; continue ;; esac
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
usage_error "unknown option: $1 (see 'box help $cmd')" ;;
*) args+=("$1"); shift ;;
esac
done
if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
# --- preconditions ---------------------------------------------------------
iname_of() { echo "$remote$1"; } # instance name = box name
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
# somebody's VM, and box will not stop, rename or delete it by accident.
resolve_box() {
local box="$1" i tag
i="$(iname_of "$box")"
tag="$(incus config get "$i" user.box 2>/dev/null || true)"
# A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
# old boxes outlive the release that minted them — the legacy tag is honored
# forever, or an old box stops being a box at all.
[ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
[ "$tag" = "1" ] || die "no such box: $box (see 'box list')"
echo "$i"
}
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
require_stopped() {
local i="$1" box="$2" st; st="$(box_state "$i")"
case "$st" in
STOPPED|Stopped|stopped) return 0 ;;
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
esac
}
need_name() {
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
usage_error "usage: $(synopsis_of "$cmd")"
fi
}
need_arg2() {
if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
usage_error "usage: $(synopsis_of "$cmd")"
fi
}
confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
if [ "$force" -eq 1 ]; then return 0; fi
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
local reply
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
read -r reply
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
# --- commands with real work -----------------------------------------------
pick_mode() {
if [ "$mode" != auto ]; then echo "$mode"; return; fi
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
echo "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
echo container
fi
}
# Five minutes, not three: the first VM launch on a fresh pool unpacks the
# image into a pool volume and takes the coldest possible boot — measured
# live, an agent can need past the 3-minute mark exactly once per pool while
# every later boot answers in seconds. And when it still fails, ship the
# forensics: the VM's console says why, and the box is torn down by whoever
# called us before anyone can read it.
wait_agent() {
local n="$1" i clog
echo "box: waiting for instance agent..."
for i in $(seq 1 150); do
if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
if [ "$i" -eq 150 ]; then
# The console log is FULL of terminal escape sequences (boot messages,
# a firmware menu). Dumping it raw scrambles the operator's terminal —
# and doubly so when it lands in a log someone is tail -f'ing. Capture
# it to a file, STRIP everything but printable ASCII + tab/newline, and
# print only a short sanitized tail. Nothing raw ever reaches a terminal.
clog="/tmp/box-console-$n.log"
timeout -k 5 15 incus console "$n" --show-log </dev/null >"$clog.raw" 2>/dev/null || true
# Strip whole escape sequences FIRST (while the ESC byte is present), then
# drop any residual control bytes — otherwise 'tr' alone leaves the visible
# '[1m[37m' halves behind. Result is clean, readable text.
sed -E $'s/\x1b\\[[0-9;:?]*[ -/]*[@-~]//g; s/\x1b[()#][0-9A-Za-z]//g; s/\x1b[=>PX^_].*?(\x1b\\\\|\x07)//g; s/\x1b.//g' \
"$clog.raw" 2>/dev/null | tr -cd '\11\12\40-\176' >"$clog"
rm -f "$clog.raw"
echo "box: instance agent never came up after 5 minutes." >&2
echo "box: sanitized console log → $clog (last non-blank lines:)" >&2
grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/ /' >&2
# A box sitting at the GRUB/firmware menu is not slow — it never booted.
if grep -qiE 'GNU GRUB|Press enter to boot|UEFI Firmware Settings' "$clog" 2>/dev/null; then
echo "box: the VM is stuck at the GRUB/firmware menu — it is not booting." >&2
echo "box: this is the IMAGE, not box. Try re-pulling it (incus image delete …)" >&2
echo "box: or pin a known-good build/alias in the template's BOX_IMAGE." >&2
fi
die "agent unreachable (inspect live: incus console $n)"
fi
sleep 2
done
}
# A clone must not BE its source. Incus regenerates the MAC, but /etc/machine-id
# rides along inside the disk — and systemd derives its DHCP client identifier
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
# to the second on the lease timer. Every box cloned from one snapshot collided
# on the network, which is exactly the workflow box exists for (log in
# once, snapshot, clone forever).
#
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
# the reset costs one reboot. Do it before handing the box over, never after.
reset_identity() {
local i="$1"
echo "box: giving the clone its own identity (machine-id, DHCP lease)..."
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
# down cleanly, so the graceful stop hangs and the reboot never happens —
# leaving the clone on its source's identity, which is the bug we are here to
# fix. 'systemd-machine-id-setup' writes a fresh VALID id instead; in a VM it
# derives from the DMI product UUID, which Incus makes unique per instance.
incus exec "$i" -- sh -c '
rm -f /etc/machine-id /var/lib/dbus/machine-id
systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
ln -sf /etc/machine-id /var/lib/dbus/machine-id
test -s /etc/machine-id
' </dev/null || die "could not reset the clone's machine-id"
# The new id only takes effect at boot. Ask nicely, then insist — a clone that
# keeps its source's DHCP lease is worse than an unclean stop of a box that
# booted 30 seconds ago.
incus restart --timeout 60 "$i" >/dev/null 2>&1 || incus restart -f "$i"
wait_agent "$i"
}
# Templates set image, user, resources 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.
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=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|\#*) continue ;; esac
case "$line" in
*=*) key="${line%%=*}"; val="${line#*=}" ;;
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
esac
val="${val#\"}"; val="${val%\"}"
case "$key" in
BOX_DESCRIPTION) T_DESC="$val" ;;
BOX_IMAGE) T_IMAGE="$val" ;;
BOX_USER) T_USER="$val" ;;
BOX_CPU) T_CPU="$val" ;;
BOX_MEMORY) T_MEMORY="$val" ;;
BOX_DISK) T_DISK="$val" ;;
*) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
esac
done <"$dir/box.env"
[ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
# Environment overrides beat the file — this is how a small host (or the
# drill) shrinks a box without editing a template it doesn't own.
T_CPU="${BOX_CPU:-${T_CPU:-4}}"
T_MEMORY="${BOX_MEMORY:-${T_MEMORY:-8GiB}}"
T_DISK="${BOX_DISK:-${T_DISK:-60GiB}}"
}
cmd_templates() {
local d t desc
echo "TEMPLATES"
for d in "$root/templates"/*/; do
t="$(basename "$d")"
desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
printf ' %-10s %s\n' "$t" "$desc"
done
echo
echo "mint one: box new --name <box> --template <template>"
}
cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
local src="${from%%/*}" snap="" srcref
case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
incus copy "$srcref" "$instance"
incus start "$instance"
wait_agent "$instance"
reset_identity "$instance"
echo "box: cloned $srcref — isolation and auth state carry over from the source."
else
local t="${template:-blank}" m extra=()
load_template "$t"
m="$(pick_mode)"
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
# security.secureboot=false: Incus defaults VMs to secureboot ON, and a
# Debian cloud image whose shim is signed with a key the host's OVMF does
# not trust dies with "bad shim signature / prohibited by secure boot
# policy" and drops to the GRUB menu forever — the kernel never loads. It
# is not part of a throwaway box's threat model (the VM boundary is), and
# turning it off boots reliably across image rebuilds. Container mode has
# no firmware, so it does not apply there.
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi
# The template's identity is stamped ONTO the instance: which template,
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
# knows what it is without ever consulting the template again.
incus launch "$T_IMAGE" "$instance" --profile box-net \
--config user.box=1 \
--config user.box.template="$t" \
--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")" \
"${extra[@]}"
wait_agent "$instance"
echo "box: waiting for phase-1 (cloud-init)..."
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
# A failed cloud-init used to print a screen of dots and the word "error",
# with nothing to act on — the box's own log holds the reason, and nobody
# was told it existed. Show it, and leave the box up to inspect.
# Every non-interactive exec pins stdin. With a TTY on stdin, 'incus exec'
# goes interactive — and when box's own output is redirected (a script, the
# drill), the session can wedge open after the remote command has exited,
# blocking forever on a websocket that will never close. Caught live: a
# mint stuck at 'status: done'. Only shell/exec/tmux may own the terminal.
# PYTHONUNBUFFERED: cloud-init's progress dots are block-buffered the
# moment stdout is not a tty — a redirected mint (a script, the drill)
# shows NOTHING for the whole install and then one burst at the end,
# which reads exactly like a hang. Unbuffered, the dots arrive as dots.
if ! incus exec "$instance" -- env PYTHONUNBUFFERED=1 cloud-init status --wait </dev/null; then
echo >&2
echo "box: cloud-init FAILED in $name. What it says:" >&2
incus exec "$instance" -- cloud-init status --long </dev/null 2>&1 | sed 's/^/ /' >&2
echo >&2
echo "box: the errors, from the box's log:" >&2
incus exec "$instance" -- sh -c \
"grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
</dev/null 2>/dev/null | sed 's/^/ /' >&2
echo >&2
echo "box: '$name' is still up — inspect it, then delete it:" >&2
echo " box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
echo " box rm $name" >&2
echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&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"
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
# blank box is not told to run a binary it doesn't have.
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
[ -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."
else
echo "box: ready — 'box shell $name'."
fi
}
# Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
# thing that makes them ours, so every read below is filtered by it and we
# never report on (or touch) an instance box didn't mint. Pre-rename boxes
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
# tags via any path we mint, but the dedupe costs nothing.
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
# several addresses and Incus quotes them across lines. It's fetched separately.)
boxes_csv() {
{
incus list ${remote:+"$remote"} "user.box=1" --format csv --columns nstS
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
} 2>/dev/null | awk -F, '!seen[$1]++'
}
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
incus list "$1" --format csv --columns 4 2>/dev/null \
| tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
| grep . || echo "-"
}
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
short_type() {
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
virtual-machine|virtualmachine) echo VM ;;
container) echo CT ;;
*) echo "$1" ;;
esac
}
list_all() {
local rows; rows="$(boxes_csv)"
if [ -z "$rows" ]; then
echo "box: no boxes yet — create one with: box new --name work" >&2
return 0
fi
{
echo "NAME,STATE,TYPE,SNAPSHOTS"
while IFS=, read -r n s t snaps; do
[ -n "$n" ] || continue
echo "$n,${s:--},$(short_type "$t"),${snaps:-0}"
done <<<"$rows"
} | awk -F, '
{ for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR }
END { for (r = 1; r <= n; r++) { line = ""
for (i = 1; i <= 4; i++) line = line sprintf("%-*s ", w[i], cell[r, i])
sub(/ +$/, "", line); print line } }'
}
# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
# guess we can answer, not a surprise: point at the command that does want one.
cmd_list() {
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
die "list takes no box — for one box, use: box info ${args[0]}"
fi
if [ "$json" -eq 1 ]; then
incus list ${remote:+"$remote"} "user.box=1" --format json
else
list_all
fi
}
cmd_info() {
local box="${args[0]}" row
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
[ -n "$row" ] || die "no such box: $box (see 'box list')"
local state type snaps
IFS=, read -r _ state type snaps <<<"$row"
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
IPV4 "$(box_ipv4 "$inst")"
echo
case "${snaps:-0}" in
''|0)
echo "SNAPSHOTS (none)"
echo
echo "Take one: box snapshot $box authed"
return 0 ;;
esac
echo "SNAPSHOTS"
local first="" sname taken
while IFS=, read -r sname taken _; do
[ -n "$sname" ] || continue
[ -n "$first" ] || first="$sname"
printf ' %-14s%s\n' "$sname" "$taken"
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
echo
echo "Clone one: box new --name <new> --from $box/${first:-<snapshot>}"
}
# Which user does a shell land in? The template stamped it on the instance at
# mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
# knows without consulting the template. Two subtleties, both from the audit:
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
# so the legacy tag maps to 'claude'. The root fallback is effectively
# unreachable (every template sets a user) — anything that truly needs root
# goes through the 'box incus' escape hatch.
box_user() {
local u
u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
u=claude
fi
echo "${u:-root}"
}
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
# A shell is a child of the exec connection: drop the terminal and everything
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
# attaches when the session exists and creates it when it doesn't, so starting
# work and reattaching after a disconnect are the same command. 'shell' stays
# bare on purpose — two verbs, two contracts.
cmd_tmux() {
local session="${args[1]:-main}"
case "$session" in
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
esac
incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
}
cmd_snapshot() {
local label="${args[1]:-manual-$(date +%s)}"
incus snapshot create "$inst" "$label"
echo "$label"
}
cmd_status() {
echo "box: 'status' is deprecated — use 'box list'." >&2
list_all
}
# The host-health checks live in drill/doctor.sh — grown by the drill, but
# every fault they diagnose (a wedged daemon, a dnsmasq that isn't serving,
# a VPN resolver boxes inherit, isolation off in the kernel) is a USER's
# fault first: each one has killed a cold mint or silently weakened a
# boundary. The install tree ships the whole repo, so delegate — one
# hardened script, two audiences.
cmd_doctor() {
local script="$root/drill/doctor.sh"
[ -f "$script" ] || die "doctor script not found at $script — re-run install.sh"
exec bash "$script" "${args[@]}"
}
# The host lifecycle scripts, as first-class verbs — nobody should have to know
# where the install tree keeps its scripts. Each execs the installed script
# with its flags passed through; the script owns its own behavior (setup-host's
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
host_script() { # $1 = script basename under host/
local script="$root/host/$1"
[ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
exec bash "$script" "${args[@]}"
}
cmd_setup_host() { host_script setup-host.sh; }
cmd_teardown_host() { host_script teardown-host.sh; }
cmd_migrate_host() { host_script migrate-host.sh; }
cmd_help() { show_help "${args[0]:-}"; }
# The escape hatch. The box is resolved and tag-checked; everything else is
# yours. {} is the instance name; without it, the instance goes last.
warn_isolation() {
case " $* " in
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
echo "box: warning: this can move the box off the isolation stack" >&2
echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
esac
}
cmd_incus() {
local rest=("${args[@]:1}") out=() a replaced=0
[ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
for a in "${rest[@]}"; do
case "$a" in
*"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
*) out+=("$a") ;;
esac
done
[ "$replaced" -eq 1 ] || out+=("$inst")
warn_isolation "${out[@]}"
echo "box: incus ${out[*]}" >&2 # no magic: show what runs
incus "${out[@]}"
}
# A deliberate, loopback-only door to a box's port — for the one workflow the
# "no inbound path" contract is too absolute for: you are coding in a box and
# want to open its dev server in your browser.
#
# Two decisions, both load-bearing:
# · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays
# true — no other machine can reach the box; only THIS host's loopback gets
# a door. There is no flag to widen it; that is the escape hatch's job, with
# its warning.
# · Each exposure is a named proxy device (expose-<port>), so 'box info' and
# --list can see it and --remove can undo it. A box with a hole says so.
#
# Mechanism (VMs): an Incus 'proxy' device forwards host 127.0.0.1:<hostport>
# to the box's <ip>:<port>. Because a VM's forkproxy connects over the network
# to the guest, the in-box server must listen on 0.0.0.0 (not just its own
# loopback) — inside an isolated box that is safe: boxnet + port-isolation +
# the ingress drop mean only this proxy can reach it. And that ingress drop is
# exactly what would eat the forkproxy's connection, so expose adds a SCOPED
# ACL allow (this box's ip + this port only) beside the device. Whether the
# proxy needs that allow, or bypasses the ACL, is settled by the drill, not
# here — the allow is scoped and harmless if redundant.
exposure_dev() { echo "expose-$1"; } # device name for a port
cmd_expose() {
local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"
# --list
if [ "$a2" = "--list" ]; then
local found=0 d listen connect
while IFS= read -r d; do
case "$d" in expose-*) : ;; *) continue ;; esac
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)"
[ "$found" = 0 ] && echo "EXPOSURES for $box"
found=1
printf ' %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect"
done < <(incus config device list "$inst" 2>/dev/null)
[ "$found" = 0 ] && echo "box: $box has no exposed ports"
return 0
fi
# --remove <port>
if [ "$a2" = "--remove" ]; then
local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
local dev; dev="$(exposure_dev "$port")"
incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \
|| die "$box has no exposure on port $port (see 'box expose $box --list')"
incus config device remove "$inst" "$dev" >/dev/null \
&& echo "box: closed the door on port $port"
# Remove the scoped ACL allow, if one was added. Best-effort: its absence
# is not an error (the drill may show the allow was never needed).
local ip; ip="$(box_ipv4 "$inst")"
[ "$ip" != "-" ] && incus network acl rule remove box-isolate ingress \
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
return 0
fi
# expose <port> [<host-port>]
local port="$a2" hport="${a3:-$a2}"
[ -n "$port" ] || usage_error "usage: $(synopsis_of expose)"
case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac
local ip; ip="$(box_ipv4 "$inst")"
[ "$ip" != "-" ] || die "$box has no IP yet — is it running? (box info $box)"
local dev; dev="$(exposure_dev "$port")"
if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then
die "$box already exposes port $port (change or remove it: box expose $box --remove $port)"
fi
# The scoped ACL allow FIRST, so the door is open by the time the proxy uses
# it. Scoped to this box's IP and this port — it does not widen any other box.
incus network acl rule add box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true
if incus config device add "$inst" "$dev" proxy \
"listen=tcp:127.0.0.1:$hport" "connect=tcp:$ip:$port" bind=host >/dev/null; then
echo "box: 127.0.0.1:$hport$box:$port"
echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)"
else
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "could not add the proxy device"
fi
}
# --- dispatch: driven by the table, not by a hand-written case --------------
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
IFS='^' read -r _ _ pre _ action ok <<<"$row"
case ",$pre," in *,box,*) need_name; inst="$(resolve_box "${args[0]}")" ;; esac
case ",$pre," in *,arg2,*) need_arg2 ;; esac
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
case ",$pre," in *,confirm,*) confirm "delete $inst and all its snapshots" ;; esac
case "$action" in
fn:*)
"${action#fn:}"
;;
incus:*)
sub="${action#incus:}"
# word-split intentionally: a subcommand may carry a flag ("delete -f")
# shellcheck disable=SC2086
incus $sub "$inst" "${args[@]:1}"
if [ -n "$ok" ]; then
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
echo "box: $msg"
fi
;;
esac