box/bin/box

2556 lines
134 KiB
Text
Raw Normal View History

#!/usr/bin/env bash
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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)"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
cpu=""; memory=""; disk=""; instance_only=0
inst="" # the resolved Incus instance, set by the 'box' precondition
fix: offer the pristine rollback only when the mark actually exists Both reviewers landed on the same line independently, and they are right. cmd_new's hook-failure path offered `box restore $name pristine` unconditionally, but three mints reach that line with no pristine mark, all by this stack's own design: a `dir` pool (skipped, no CoW), BOX_SNAPSHOT_PRISTINE=0, and a create incus refused (warned, mint continued). On a `dir` host that is EVERY hook failure — so the operator standing at the one moment the message exists for copy-pastes a restore that errors. It could not simply be read off the return status. The never-fatal contract means snapshot_mark returns 0 on all four paths on purpose — taken, skipped, knob-disabled, refused — so `if snapshot_mark ...` cannot distinguish a mark that exists from one that does not, and making it distinguish would put the mint's success at the mercy of a checkpoint, which is what that contract exists to prevent. So the fact is recorded rather than inferred: `marks` accumulates the labels snapshot_mark actually created, and `mark_taken <label>` answers the only question a rollback offer may ask. Per-label, so one mark never answers for another. This is the same refusal the message one line earlier already makes when it declines to invent a `bootstrapped` it never watched — a promise the disk cannot deliver is the failure mode #130 was built to refuse. It just was not applied to the restore offer sitting under it. Pinned per path rather than once, because the three no-mark paths fail differently and a single case would let the other two regress silently: five drives over snapshot_mark + mark_taken (created / dir-skip / knob / refused / no bleed between labels) and a static check that the call site is gated. Verified by mutation — dropping the recording, and un-gating the offer, each fail the suite rather than passing quietly.
2026-07-21 11:40:37 +00:00
# Labels snapshot_mark actually CREATED this run, space-separated. Every path
# through snapshot_mark returns 0 on purpose — the never-fatal contract — so
# the exit status cannot tell a mark that was taken from one that was skipped
# (dir pool, knob=0) or refused (incus said no). Anything that offers the
# operator a mark must ask this, not assume the call happened.
marks=""
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# The tree's own version, read in ONE place. 'box --version' says it out loud;
# the mint stamp (#103) writes it onto every instance box creates, so a box can
# still name the release that made it long after that release is history.
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
version() { echo "box $(box_version) ($root)"; }
# The SHAPE of the mint stamp, not the box version — an integer that changes
# only when a key is removed or repurposed, never when one is added (a reader
# that does not know a key simply does not print it). Absent means pre-stamp:
# every box minted before #103 has no schema key at all, and must keep working
# under every verb, which is the same promise 'user.claudebox' carries.
BOX_STAMP_SCHEMA=1
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
# what the kernel will present when incus opens the socket), never from the
# group database — the two disagree for exactly as long as a re-login is
# pending, and that window is where every wrong answer lives.
# UID 0 / incus-admin -> admin (the full daemon socket)
# incus (only) -> restricted (incus-user: your own project, nothing else)
# neither -> none (no socket at all)
# host/setup-host.sh carries a byte-identical copy (it runs before any install
# tree exists); test/cli.sh diffs the two so they cannot drift.
box_tier() {
[ "$(id -u)" -eq 0 ] && { printf 'admin\n'; return; }
local groups; groups="$(id -nG 2>/dev/null | tr ' ' '\n')"
if printf '%s\n' "$groups" | grep -qx incus-admin; then printf 'admin\n'
elif printf '%s\n' "$groups" | grep -qx incus; then printf 'restricted\n'
else printf 'none\n'
fi
}
# ---------------------------------------------------------------------------
# The command table.
#
# verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message ^ confirm prompt
#
# 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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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. The row supplies the words
# (last field) — see 'confirm prompt' below.
#
# 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.
#
# confirm prompt: the words the 'confirm' precondition asks with, phrased as
# the act ("delete X", "roll X back to Y") — confirm() wraps it into
# "box: <prompt>? this cannot be undone." and into the no-TTY refusal
# ("refusing to <prompt> without --force"). It is a per-row field and not a
# shared string on purpose: the prompt was hardcoded to rm's wording once, and
# the whole reason 'restore' shipped ungated for four releases is that adding
# the token to its row would have asked the operator to confirm DELETING the
# box they were trying to rescue (#105). A gate that names the wrong act is
# worse than no gate — it teaches people to answer 'y' without reading. Same
# substitutions as the ok message, except {} is the RESOLVED instance: the
# prompt names the thing about to be destroyed, so under --remote it should
# say 'lab:work', not 'work'.
#
# Adding a thin verb is one row. If a request can't be expressed as a row and
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
"templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^"
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
"info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
"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> [--force]^box,arg2,confirm^Roll a box back to one of its snapshots — irreversible, and it asks first^incus:snapshot restore^restored {} to {1}^roll {} back to snapshot '{1}' and discard everything in the box since it was taken"
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
"export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
"import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
"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 {}^delete {} and all its snapshots"
"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^"
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
"grant^<user>^^Admin: give a host user the restricted tier — their own boxes, on the hardened boxnet^fn:cmd_grant^"
"revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
"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^"
"versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^"
"use^<version>^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^"
"uninstall^[<version>] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
"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 f_cnf
r="$(cmd_row "$1")" || return 1
IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok f_cnf <<<"$r"
case "$2" in
syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
act) echo "$f_act" ;; ok) echo "$f_ok" ;; cnf) echo "$f_cnf" ;;
esac
}
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
echo "box: unknown command: $1 — did you mean '$hint'?" >&2
else
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
echo "box: unknown command: $1" >&2
fi
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
echo "try 'box help' for the command list." >&2
exit 2
}
usage() {
cat <<'EOF'
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
USAGE
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
--name <box> Name for the new box (new, import)
--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)
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
--instance-only Export the live state only, no snapshots (export)
--force, -f Destroy without asking (rm, restore); overwrite the
file (export)
--remote <r> Act on Incus remote <r> (any)
--help, -h Help; after a command, help for that command
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
--version, -V Print the box version
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
box new --name work --template claude-box
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box snapshot work authed
box new --name feature --from work/authed
# the default: a blank box — same isolation, nobody home
box new --name scratch
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box templates
# what have I got, and what can I clone?
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box list
box info work
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# a box that outlives this host: one portable file, credentials inside
box down work
box export work # → work-<UTC stamp>.tar.gz — guard it like a credential
box import work-<stamp>.tar.gz --name work2
# run something without opening a shell
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box exec work -- git -C project pull
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
('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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
On a shared host, an admin hands out the restricted tier per user
('box grant <user>'): their own boxes, the same hardened network, and
no view of anyone else's. 'box help grant' has the contract.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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'
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
Mint a box. Without --from, launches a fresh box from a template (default:
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
blank — bare Debian 13, nobody home; --template claude-box 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.
A tenant template (claude-box, codex-box, grok-box, kimi-box, staging-box) 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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
--template <t> Template to mint from; 'box templates' lists them.
A template sets image, user, resources, boot
demands (independently: BOX_REQUIRE_VM insists on
VM mode, BOX_AUTOSTART survives host reboots) and
a creds-free tenant role (BOX_BOOTSTRAP_ROLE) —
never the network: every template gets the same
isolation.
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
--cpu <n> CPUs for this mint (limits.cpu, verbatim to Incus).
--memory <size> RAM for this mint, e.g. 3GiB (limits.memory).
--disk <size> Root disk size, e.g. 20GiB. VM mode only — a
container's root rides the storage pool.
--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.
A template that requires VM mode refuses both the
fallback and --container.
Resources resolve most-specific-first: these flags, then BOX_CPU /
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
template's box.env, then defaults. Flags shape a fresh mint only — a --from
clone carries its source's resources. Resources are all a flag can touch:
there is no flag for a network or a security key, on purpose.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
BOX_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
a launch that overruns it fails loudly instead of hanging forever (#93).
Every fresh mint marks a snapshot called 'pristine' (#104) at the one moment
it is true: after cloud-init, before rig converges anything. That is the
guest as pristine Debian plus box's thin seed, and it is what
box restore <box> pristine
rolls back to — undoing the tenant role and everything done since, without
paying a re-mint. It is an UNDO, not a backup: 'box rm' deletes a box and
every snapshot it has, so 'box export' remains the only state that outlives
the box. A --from clone takes no 'pristine' of its own — it has no pristine
moment; it inherits the source's snapshots (copying a box) or starts with
none (copying a snapshot). On a 'dir'-backend host, where a snapshot is a
full copy rather than a CoW mark, the mint skips it and says so.
BOX_SNAPSHOT_PRISTINE=0 skips it anywhere.
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
A mint whose template names a bootstrap role marks a second snapshot,
'bootstrapped' (#130), once the rig hook has run and box has watched it
SUCCEED — the box converged and not yet touched. It is the cheaper undo:
'pristine' throws the tenant role away, 'bootstrapped' keeps it and throws
away only what you did afterwards. Same rules: never fatal, skipped on a
'dir' pool, and BOX_SNAPSHOT_BOOTSTRAPPED=0 skips it anywhere.
It is deliberately NOT unconditional the way 'pristine' is. A blank box
runs no hook, so there is no convergence to mark and it gets none — the
label marks an event, not a mint. And if the hook FAILS, box tells you to
re-run it by hand and takes no mark afterwards: a by-hand run happens in a
shell box does not watch, so box will not label a convergence it never saw.
It names 'box snapshot <box> bootstrapped' at that moment instead. So read
the label one way only: its PRESENCE means the hook converged untouched;
its ABSENCE means nothing at all.
box new --name scratch # blank, the default
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
box new --name work --template claude-box
box new --name lean --template claude-box --cpu 2 --memory 3GiB
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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, boot
demands, tenant role — parsed against an allowlist, never sourced) and a
user-data.yaml (cloud-init, passed to Incus verbatim except the rig pin
tokens @RIG_REPO@/@RIG_REF@, resolved at mint from the environment).
Templates cannot touch the network or security flags — the shared box-net
profile is the placement contract, so every template gets the same isolation.
Templates are thin, creds-free seeds (#81): the user, tmux and rig — what a
box BECOMES lives in rig's bootstrap roles (rig#31), auto-run at mint via
BOX_BOOTSTRAP_ROLE. box mints; rig converges.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box templates
box new --name scratch --template blank
EOF
;;
list) cat <<'EOF'
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box shell work
EOF
;;
exec) cat <<'EOF'
Run a command inside a box as the 'claude' user. Everything after -- is passed
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
through untouched; the -- is required, or box will read your command's
flags as its own.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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:
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box snapshot work authed
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
Two labels are taken for you (see 'box help restore'): every fresh mint
marks 'pristine' after cloud-init and before rig's tenant role runs (#104),
and a mint with a bootstrap role marks 'bootstrapped' after box has watched
that role converge (#130). 'bootstrapped' is skipped where there was no hook
to watch — a blank box, or a hook box told you to re-run by hand — so its
absence proves nothing. Take it yourself in that case:
box snapshot work bootstrapped
Snapshots do not outlive their box: 'box rm' deletes a box and every
snapshot it has. They are an undo, not a backup — 'box export' is the
durable path.
EOF
;;
restore) cat <<'EOF'
Roll a box back to one of its snapshots, in place. Anything in the box since
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
that snapshot is lost. 'box info <box>' lists the labels.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
box restore work authed
box restore work authed --force
box restore work pristine
'pristine' is the one label box takes for you (#104): every fresh mint marks
it after cloud-init and before rig converges the tenant role, so it is the
guest as pristine Debian plus box's thin seed. Restoring it is the answer to
"I broke this box" for every tenant role — everything 'rig bootstrap
<tenant>' does is box-local and file-shaped, so a filesystem rollback undoes
all of it, without a ~10-minute re-mint.
What it does NOT undo is anything that left the box: a tailnet join, a
GitHub runner registration, a pushed commit. Those are off-box records a
filesystem rollback cannot reach (heavy-duty/rig#62). And 'pristine' is not
a backup — it dies with the box on 'box rm'; 'box export' is what survives.
A box minted with --from has no 'pristine' of its own unless it inherited
one from its source, because a clone has no pristine moment to capture.
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
'bootstrapped' is the other label box takes for you (#130), and it is the
one you probably want more often: it is the box AFTER the tenant role
converged and before you touched it.
box restore work bootstrapped # keep the role, undo what you did to it
box restore work pristine # throw the role away too
The same two caveats apply to it exactly as written above — it dies with the
box on 'box rm' ('box export' is what survives), and it cannot undo anything
that left the box, a tailnet join or a runner registration among them
(heavy-duty/rig#62).
Its absence proves NOTHING. box takes it only after a rig hook it ran and
watched succeed, so a blank box has none, a 'dir'-pool host has none, and a
box whose hook failed and was re-run by hand through 'box shell' has none
either — box will not label a convergence it did not watch. 'box info <box>'
tells you which labels a box actually has; take the mark yourself any time
with 'box snapshot <box> bootstrapped'.
Destructive, so it asks first — naming the snapshot it is rolling back to,
because the whole risk is picking the wrong label. --force (-f) skips the
prompt; with no TTY to ask on it refuses rather than assuming yes.
box does not require the box to be stopped for this. Snapshots here are
stateless — no live memory is captured — so a rollback is crash-consistent:
the box comes back the way a machine comes back from losing power. 'box down
<box>' first if that matters.
EOF
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
;;
export) cat <<'EOF'
One portable file that outlives the box AND the host. 'box rm' deletes a box
and every snapshot it has; 'box new --from' clones, but the clone still lives
on the same host. Export is the way out (#70): it wraps 'incus export' into a
backup tarball of the whole instance — snapshots included by default, because
the reuse workflow (log in once, snapshot, clone forever) lives in them.
box export <box> [<file>] # default file: <box>-<UTC timestamp>.tar.gz
--instance-only # live state only, leave the snapshots behind
--force # overwrite an existing <file> (refused otherwise)
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
The box must be stopped first ('box down <box>'). Incus can back up a running
instance, but a live root disk is a moving target — and this artifact's whole
job is to be trusted later, on a host that no longer has the box.
THE FILE IS A CREDENTIAL. A box's disk carries everything inside it — agent
logins, git PATs, SSH keys, shell history. Export scrubs nothing (a
"scrubbed" disk image is a promise tarball surgery cannot keep) and says so
loudly instead. Store and move the file like the secret it is.
The upgrade flow this unblocks (#66):
box down work && box export work # one file per box
box rm work # nothing is lost anymore
# ...upgrade box / rebuild the host / move machines...
box import work-<stamp>.tar.gz # the box is back, snapshots and all
EOF
;;
import) cat <<'EOF'
Mint a box from a 'box export' file — on this host or any other that has the
box stack ('box setup-host' builds it). The name inside the tarball is used
unless --name picks another; either way the name must be free: import will
not occupy a name ANY existing instance holds, box or not.
Everything 'incus import' restores is the artifact's truth (disk, config,
snapshots). What box then re-stamps is THIS host's truth:
· the user.box=1 boundary tag (a legacy user.claudebox=1 stays honored)
· the box-net placement — re-assigned if the artifact's profile list
differs, the same move migrate-host makes re-homing a legacy box
· a fresh machine identity (reset_identity, exactly like a clone), so its
DHCP lease can never collide with the box it was exported from
feat: box import records the trip, without rewriting who the box was An imported box kept the artifact's mint stamp verbatim (#103) — correct, the mint time, box version, image and origin belong to the originating host and should survive the trip. But nothing recorded the import, so an imported box was indistinguishable from one minted here at the artifact's mint time. Not origin=import. 'origin' answers how the instance came into BEING — mint or clone — and overwriting it would make an exported clone come back claiming to be an import, with nothing left saying it was ever a clone and an origin.from naming a lineage no key explains. The import is a third fact, orthogonal to the first two, so it takes its own keys and leaves every other one alone. Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the same repeated-event question: imported/imported.by pinned once and never rewritten, imported.last/.last.by refreshed on every arrival, imported.count for the trips in between. Last-wins alone would erase the evidence of the earlier trips, which is the same mistake origin=import makes one level up. box info prints IMPORTED directly under MINTED, because that adjacency is what stops the artifact's mint time being misread as this host's. It states only the ordering and never claims another host: box has no record of which host minted a box, and a re-import onto the same host is the documented upgrade flow. user.box.schema does not move — adding a key is not breaking — and is not written by the import at all, so a legacy artifact still reads as MINTED (not recorded) rather than acquiring a shape it does not have. Closes #131. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
· the import EVENT — when the box landed here, and by which box version
The mint stamp is NOT rewritten. The artifact's mint time, box version, image,
template and origin (mint or clone) are the originating host's truth and
survive the trip intact — 'box info' shows them unchanged, with an IMPORTED
line under them saying the mint predates the arrival. In particular the import
does not overwrite 'origin': a clone that was exported and imported still
reads as a clone, and still names the box it was cloned from.
A box that makes the trip more than once keeps both ends: the first import is
pinned forever, the latest is refreshed each time, and a count says how many.
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
Auth state rides along by design — the artifact carries the box's whole disk,
logins included. That is the point (log in once, keep the file), and the same
trust boundary as cloning an authed snapshot.
box import work-20260718T120000Z.tar.gz
box import work-20260718T120000Z.tar.gz --name work2
EOF
;;
rename) cat <<'EOF'
Rename a box. Incus cannot rename a running instance, so stop it first:
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
box new --name web --template claude-box
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'
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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 — and
the #80 nested-stack signature: a default gateway held as a LOCAL address,
or duplicate connected routes for the uplink subnet, judged on this machine
AND inside every box it probes (a box stack installed inside a box squats on
the guest's gateway and blackholes its egress, intermittently). 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)
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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;
install.sh runs it for you, so this is for re-applying by hand.
One run is enough. If it has to add you to the incus-admin group it re-runs
itself under that group — no re-login, no second invocation.
The stack's subnet: 10.88.0.0/24 when free; with an existing boxnet it
converges on the bridge's own subnet; and when the default is claimed by
something else — most tellingly this machine's own default gateway, i.e.
setup-host running INSIDE a box — it auto-picks the first free /24 from
10.89.0.0/24 through 10.127.0.0/24 and says so. A nested stack on the
guest's own uplink subnet would capture its gateway address and blackhole
its egress, intermittently (issue #80); the auto-pick is why a drill or
rehearsal inside a box now works with zero flags. BOX_SUBNET pins the
subnet explicitly (the bridge, the gateway carve-out and the firewall all
derive from it) — a pin is never overridden: setup-host REFUSES, before
touching anything, when the pinned subnet is claimed by a foreigner or
disagrees with an existing bridge.
box setup-host # picks/converges by itself
BOX_SUBNET=10.90.0.0/24 box setup-host # scripted hosts: pin it
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
Multi-user hosts: setup-host builds the stack once, for everyone. An admin
then hands individual users the restricted tier with 'box grant <user>' —
their own boxes, on this same hardened network, seeing nobody else's.
EOF
;;
grant) cat <<'EOF'
Give a host user the restricted tier. They get their own Incus project (via
incus-user), and every box they mint lands on the SAME hardened boxnet as an
admin's — the full isolation contract (ACL, DNS isolation, resolver pin,
port isolation, the box-to-box drop), with no view of anyone else's boxes.
What it converges, idempotently (safe to re-run, and re-run after upgrades):
· puts the user in the 'incus' group (not incus-admin — that is the point)
· creates their user-<uid> project by touching incus-user for them
· points the project at boxnet and ONLY boxnet — the private incusbr-<uid>
bridge incus-user auto-creates carries none of box's hardening, so it is
unreferenced and unreachable, not just unused
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
· allows snapshots and backups (incus-user blocks both; the clone workflow
rides snapshots, 'box export' rides backups — #70)
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
· installs the box-net profile into their project
An incus-admin member is provisioned too, not refused (#99): they are added
to 'incus' like anyone else — not a new privilege, since incus-admin already
opens the daemon, but the key to a FILE, because incus-user's socket is group
'incus' mode 0660 and nothing below can provision them without it. Everything
else converges, so they finally have a project of their own. What it is not
is a confinement:
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
incus-admin wins at the socket, so the restrictions are a default placement
they can step outside at will, and their own client keeps resolving to the
admin socket (and the default project) until incus-admin is taken away. The
grant says all of that out loud when it lands.
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
import/rm on their own boxes. Not theirs: expose (edits daemon-global
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
existing box-to-box drop means even their instances cannot reach a sibling.
box grant dev1
EOF
;;
revoke) cat <<'EOF'
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
Take the restricted tier back from a user. Without --purge, this removes
them from the 'incus' group: their project and boxes stay (still running!)
and 'box grant' restores access untouched. Group membership is read at
LOGIN, so a session they already hold keeps the socket until it ends —
revoke says so and names the loginctl command when it happens. With
--purge, their sessions are terminated first (a stale session could quietly
recreate the project, unhardened, afterwards — measured, not theoretical),
then their boxes, images, project, private bridge and trust-store
certificate are removed — irreversible, so it asks first.
On an incus-admin member a bare revoke takes back the 'incus' membership that
grant added — reported as 'partial:', because it ends no access: incus-admin
still opens every project on this host. (One who was never granted is a named
no-op instead.) Mind what that leaves behind: with 'incus' gone, a later
'gpasswd -d <user> incus-admin' drops them into NEITHER group and their ready
project becomes unreachable — grant's "no re-grant needed" holds only while
they still hold 'incus'. --purge unmakes the provisioning the same way. Only
'gpasswd -d <user> incus-admin' ends their access, and revoke says so.
fix: box grant provisions incus-admin members instead of refusing them The refusal at host/grant-user.sh conflated permission with provisioning. The 'incus' group is a strict subset of what incus-admin opens — true, and the whole of what the refusal reasoned about. The user-<uid> project, the boxnet narrowing, the snapshot and backup allowances and the box-net profile installed into that project are not permissions, and an incus-admin member had none of them: box_tier() resolves them to admin, so they worked in the shared default project with no world of their own, and the one command that provisions one refused to run for them. box grant now converges them fully. The group step is a reported no-op — adding 'incus' would grant nothing and leave a group list implying a restriction that was never in force — and steps 2-5 run unchanged. The incus-user touch is pinned at incus-user's socket, which this turns out to require: the incus client picks its socket by writability, so for an incus-admin member an unpinned client sails past incus-user entirely and the project is never created. The user-side proof names their project for the same reason. On success it prints the caveat the hard exit was gesturing at: the restrictions are a default placement, not a confinement, and their own commands keep landing in the default project until incus-admin goes. The backout learns the third case (nothing added, nothing rolled back, still loud), and box revoke mirrors the whole thing rather than claiming a lockout it did not perform. Unblocks heavy-duty/rig#49. Closes #99 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
revoke/grant: survive the live-session case — the review's one real hole (#74) Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a session the user already holds — and after --purge, a stale-group process could touch incus-user and lazily RECREATE the project with stock defaults: the unhardened NAT bridge, un-narrowed, strictly worse than the granted state. Adversarial review caught it; verified live, then closed: - revoke --purge terminates the user's sessions first (loginctl, then pkill), and refuses to purge under processes it cannot kill - bare revoke says out loud that held sessions keep the socket until they end, and names the loginctl command — instead of claiming a lockout it did not deliver (help/README/design doc reworded to match) - a failed grant backs out its own group-add on exit (trap, disarmed on success): no half-granted user holding an un-narrowed socket while the admin reads the error. Verified by injecting a bad profile YAML - the rehearsal now holds a session open across the purge and demands it dies with the tier (criterion l, 42nd check) Smaller review findings, same pass: the escape-hatch probes assert the refusal's REASON instead of any nonzero exit (an image hiccup must not read as 'the escape is closed'); probe_from maps an outer-timeout kill to dropped, not reachable; the rehearsal cleanup keeps the account when a purge fails so doctor can name the leftovers; the purge asserts the trust certificate's absence; cmd_new distinguishes a dead daemon from a missing stack before prescribing setup-host; grant's success message names the user-<uid> bridge variant correctly on big-uid hosts. Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
box revoke dev1 # take the tier; their boxes keep running
box revoke dev1 --purge # ...or end their sessions and delete everything
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
;;
versions) cat <<'EOF'
List the versions installed under this install root — install.sh lands each
one side by side at <root>/versions/<v>, and a 'current' symlink tracks the
default (what the box on your PATH runs). The default is marked (current);
the tree answering THIS command is marked (running) — they differ when your
PATH resolves a different install (say, a global /opt/box shadowing yours).
box versions
box use <version> # switch the default
# install another version side by side: re-run install.sh
EOF
;;
use) cat <<'EOF'
Switch the default box version — repoint the 'current' symlink (and the PATH
symlink riding it) at an installed version. Refuses while ANY box exists:
never change versions under a user's boxes (#66) — 'box down' what you keep,
'box export' what you keep (one
portable file per box, #70), 'box rm' each box, then switch. The flip is asserted afterwards:
current must resolve to the version you asked for, and the chain's
'box --version' must answer it.
box versions # what is installed
box use 0.6.0
EOF
;;
uninstall) cat <<'EOF'
Remove one installed version, or the whole install — the real uninstall,
replacing the old "rm -rf two paths by hand" instructions.
box uninstall <version> one NON-current version ('box use' another
first if you are on it)
box uninstall everything: every version, the current and
box uninstall --all PATH symlinks, and any legacy claudebox crumbs
box uninstall --purge-host run teardown-host first (all boxes, the boxnet
stack, the firewall — its own confirmation),
then remove the install
The full uninstall runs in the safe order: boxes first — it refuses while
any exist (and names them) unless --purge-host tears them down; then the
trees and symlinks; and it ENDS with an absence assert — every removed path
is re-checked, and anything still present makes it exit 1 naming the
leftovers instead of reporting a clean uninstall that wasn't (the same
discipline as 'box revoke --purge'). Asks before removing; --force or
BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users
first: 'box revoke <user> --purge'.
box uninstall 0.5.0
box uninstall --all --purge-host
EOF
;;
status) cat <<'EOF'
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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 ;;
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
-*) 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 ;;
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
--cpu) [ $# -ge 2 ] || usage_error "--cpu needs a value"; cpu="$2"; shift 2 ;;
--memory) [ $# -ge 2 ] || usage_error "--memory needs a value"; memory="$2"; shift 2 ;;
--disk) [ $# -ge 2 ] || usage_error "--disk needs a value"; disk="$2"; shift 2 ;;
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
--vm) mode=vm; shift ;;
--container) mode=container; shift ;;
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
--instance-only) instance_only=1; 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.
-*)
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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;
# uninstall parses its own (--all, --purge-host) the same way
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) 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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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")"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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; }
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# $3, when given, replaces the default "why" — the table's 'stopped' rows are
# stopped because INCUS insists (rename), but export is stopped by OUR
# decision (a consistent artifact), and the refusal should say the true reason.
require_stopped() {
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
local i="$1" box="$2" why="${3:-Incus needs it stopped for this}" st; st="$(box_state "$i")"
case "$st" in
STOPPED|Stopped|stopped) return 0 ;;
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
*) die "box '$box' is ${st:-not stopped} — $why. Stop it: box down $box" ;;
esac
}
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# The placement contract must exist before a mint OR an import lands — in the
# DEFAULT project for an admin (setup-host builds it), in YOUR project for a
# restricted user (box grant converges it). Its absence has a different fix
# per tier, and incus's own "Profile not found" at launch time names neither.
require_stack() {
if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
# A missing profile and a daemon that is not answering are different
# faults with different fixes — "run setup-host" at a wedged daemon
# (the #26 shape) is wrong advice. Separate them before diagnosing.
timeout 10 incus list >/dev/null 2>&1 </dev/null \
|| die "the incus daemon is not answering — diagnose it: box doctor"
if [ "$(box_tier)" = restricted ]; then
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
fi
die "no box-net profile — the host stack is missing. Build it: box setup-host"
fi
}
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
}
# Row templates: {} -> $2, {1} -> the second positional. The caller picks what
# {} means because the two uses differ on purpose — the ok message reports on
# the box the operator NAMED, the confirm prompt names the RESOLVED instance,
# because a prompt about to destroy something should say which machine.
fill() { # $1 = template, $2 = what {} stands for
local t="${1//\{\}/$2}"
printf '%s\n' "${t//\{1\}/${args[1]:-}}"
}
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
printf 'box: %s? this cannot be undone. [y/N] ' "$1"
# EOF (Ctrl-D) is an answer, and it means no. Unguarded, 'read' returns
# non-zero and 'set -e' ends the run in silence — heavy-duty/rig#43.
read -r reply || die "aborted."
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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 that never boots is NOT a slow box, and the console says which
# failure it is. Each of these cost hours to diagnose by hand once; the
# box that hits them next should be told the answer, not the symptom.
if grep -qiE 'Failed to decompress kernel|efi_stub_entry\(\) failed' "$clog" 2>/dev/null; then
echo "box: THE KERNEL WOULD NOT DECOMPRESS — the cached image is corrupt." >&2
echo "box: (a truncated/bad image download does exactly this). Re-pull it:" >&2
echo "box: incus image list # find the fingerprint" >&2
echo "box: incus image delete <fingerprint> # the next mint re-downloads" >&2
elif grep -qiE 'bad shim signature|prohibited by secure boot' "$clog" 2>/dev/null; then
echo "box: SECURE BOOT rejected the kernel — but box mints VMs with" >&2
echo "box: security.secureboot=false, so this box predates that fix or was" >&2
echo "box: created by hand. Re-mint it with a current box." >&2
elif 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 never booted." >&2
echo "box: this is the IMAGE, not box. Re-pull it (incus image delete …)," >&2
echo "box: or pin a known-good build 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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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 instance 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, boot demands, a tenant role and
# cloud-init — NOTHING else. The box.env file is parsed against this allowlist,
# never sourced: sourcing would hand every template arbitrary bash execution on
# the HOST at mint time. And there is deliberately no key for a network or a
# security flag — the shared box-net profile is the placement contract, so no
# template can weaken isolation. 'blank' is a box with nobody home, not a box
# with the safety off. The two boot demands are for server-class templates
# (#68): BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust
# boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps
# boot.autostart so the box survives a host reboot without an operator.
# BOX_BOOTSTRAP_ROLE (#81) names the rig role box auto-runs after mint — the
# thin-template split: the seed is user + tmux + rig, and what the box BECOMES
# is 'rig bootstrap <role>'. Only creds-free roles belong here, by contract.
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
load_template() {
local t="$1" dir line key val
dir="$root/templates/$t"
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
T_REQUIRE_VM=""; T_AUTOSTART=""; T_BOOTSTRAP_ROLE=""
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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%\"}"
# T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates
# re-reads BOX_DESCRIPTION straight from the file (a box is listed without
# ever loading its template), so the parsed value here is never read. Keep the
# row — deleting it would turn box.env's own key into an "unknown key" error at
# mint time. (SC2034 disabled for the branch below; the directive must sit on
# the whole case, not an individual arm.)
# shellcheck disable=SC2034
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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" ;;
BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;;
BOX_AUTOSTART) T_AUTOSTART="$val" ;;
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)" ;;
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
esac
done <"$dir/box.env"
# Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
# dies (which is what we want) — but it reads as an if-then-else it is not, so
# spell the guard out (SC2015).
if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
die "template '$t': BOX_IMAGE and BOX_USER are required"
fi
# A bootstrap role is a rig role NAME and nothing more — it is handed to
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
# the value must die here, on the host, before a guest ever sees it.
if [ -n "$T_BOOTSTRAP_ROLE" ] && ! [[ "$T_BOOTSTRAP_ROLE" =~ ^[a-z][a-z0-9-]*$ ]]; then
die "template '$t': BOX_BOOTSTRAP_ROLE is not a sane role name: $T_BOOTSTRAP_ROLE"
fi
# Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
# > BOX_* environment (how a small host or the drill shrinks every box it
# mints) > the template's file > defaults. Values pass to Incus verbatim —
# its units, its validation; box adds no parser of its own. Resources only:
# there is still no flag for a network or a security.* key, on purpose.
T_CPU="${cpu:-${BOX_CPU:-${T_CPU:-4}}}"
T_MEMORY="${memory:-${BOX_MEMORY:-${T_MEMORY:-8GiB}}}"
T_DISK="${disk:-${BOX_DISK:-${T_DISK:-60GiB}}}"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
}
# 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.
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
# substitutes it into the seed, and the mint stamp (#103) records it onto the
# instance. Two spellings of the same default would eventually disagree, and a
# stamp that disagrees with the seed is worse than no stamp at all.
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
rig_ref() { printf '%s\n' "${RIG_REF:-main}"; }
render_userdata() {
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
local f="$1" repo data ref
repo="$(rig_repo)"; ref="$(rig_ref)"
[[ "$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"
}
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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>"
}
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# When this instance came into being. A timestamp in a CONVERGENT file would be
# churn — the same run writing a different byte every time — but a mint is not
# convergent: it happens exactly once, to exactly one instance, and is never
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
# thing on every host that reads it back.
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# Which storage driver is under this box? The placement contract answers it:
# every box launches with --profile box-net, and that profile hardcodes the
# root disk's pool (profiles/box-net.yaml), so the pool is not a guess — it is
# read back from the profile that actually placed the instance, and only falls
# back to the name setup-host.sh creates. Two probes for the driver because
# the tiers see storage differently: 'incus storage show' is the admin read
# (the same line setup-host.sh:297 prints), 'incus storage list --format csv'
# is the one a restricted user's incus-user proxy is likelier to answer.
# Prints the driver, or nothing when neither probe answers.
storage_driver() {
local pool driver
pool="$(incus profile device get box-net root pool 2>/dev/null || true)"
[ -n "$pool" ] || pool=default
2026-07-20 11:29:18 +00:00
# '|| true' on all three probes, not just the first. A refusal here is an
# ANSWER ("this tier cannot read the pool"), never an error — this function
# must never be the thing that kills a mint. Today the two pipelines are
# safe only by accident: command substitution strips errexit, so a failing
# 'incus storage show' under pipefail falls through to the fallback instead
# of aborting. Add 'shopt -s inherit_errexit' to this file — exactly the
# robustness tweak #107 describes sailing through review — and that accident
# reverses into a fatal abort mid-mint, inside the function whose contract
# is NEVER fatal. Make the three probes read alike and depend on nothing.
driver="$(incus storage show "$pool" 2>/dev/null | awk '/^driver:/ {print $2; exit}' || true)"
[ -n "$driver" ] || driver="$(incus storage list --format csv 2>/dev/null \
2026-07-20 11:29:18 +00:00
| awk -F, -v p="$pool" '$1 == p {print $2; exit}' || true)"
printf '%s' "$driver"
}
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
# The shared policy behind every mark box takes for you (#104, #130):
# read the opt-out, read the storage driver, refuse to double the disk cost on
# a pool with no copy-on-write, and NEVER fail a mint over a checkpoint.
#
# It lives once on purpose. What generalises here is the MECHANISM, and the
# never-fatal contract inside it is exactly the property that must not be got
# subtly different in two places — one function, one place to review it, one
# place a future third mark inherits it from. What does NOT generalise is the
# PROSE: 'pristine' and 'bootstrapped' name different moments and are worth
# different sentences, so each wrapper below owns its own narration and passes
# it in. Two thin wrappers over one policy is the honest split; a single
# stringly-parameterised function taking five sentences would not be.
#
# The opt-out variable name is DERIVED from the label (BOX_SNAPSHOT_PRISTINE,
# BOX_SNAPSHOT_BOOTSTRAPPED) so the message can never drift from the knob the
# operator actually has to set.
#
# snapshot_mark <instance> <name> <label> <enabled> <what-this-state-is>
snapshot_mark() {
local instance="$1" name="$2" label="$3" enabled="$4" what="$5" driver knob
knob="BOX_SNAPSHOT_$(printf '%s' "$label" | tr '[:lower:]' '[:upper:]')"
if [ "$enabled" = 0 ]; then
echo "box: skipping the '$label' snapshot ($knob=0)."
return 0
fi
driver="$(storage_driver)"
# On btrfs (the deliberate default, host/setup-host.sh:261-298) this costs
# kilobytes until the guest diverges. On the documented 'dir' fallback
# (:294) there is no CoW at all: the snapshot is a full copy of the root,
# several GB and minutes, on EVERY mint. Skip it there — but loudly, and
# naming the by-hand command, because a silent skip teaches an operator to
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
# expect a mark that will not be there. With two marks the objection is
# twice the size, so the skip covers both: a 'dir' host is not asked to pay
# for one full root copy per mint, let alone two.
if [ "$driver" = dir ]; then
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
echo "box: NOT taking the '$label' snapshot — this host's storage pool is the 'dir'" >&2
echo "box: driver, which has no copy-on-write, so the mark would be a full multi-GB copy" >&2
echo "box: of the root disk on every mint rather than the near-free btrfs one." >&2
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
echo "box: take it by hand if you want it here: box snapshot $name $label" >&2
echo "box: or fix the host (btrfs is what 'box setup-host' installs by default)." >&2
return 0
fi
# Neither probe answered — an unusual host, or a tier that cannot read the
# pool. Proceed, and say what was assumed. The two mistakes are not
# symmetric: a snapshot taken on a 'dir' host wastes disk the operator can
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
# see in 'box info' and delete, while a snapshot NOT taken is the moment
# gone for good, which is the whole thing this exists to prevent.
[ -n "$driver" ] || echo "box: could not read the storage driver — taking '$label' anyway," \
"assuming copy-on-write ($knob=0 to skip)." >&2
echo "box: marking '$label' — $what"
if ! incus snapshot create "$instance" "$label" </dev/null; then
# An undo is not the mint's product: a mint that worked must not be
# failed by a checkpoint that didn't.
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
echo "box: WARNING — could not take the '$label' snapshot (incus said why above)." >&2
echo "box: the box is fine; it just has no $label mark. The mint continues." >&2
return 0
fi
fix: offer the pristine rollback only when the mark actually exists Both reviewers landed on the same line independently, and they are right. cmd_new's hook-failure path offered `box restore $name pristine` unconditionally, but three mints reach that line with no pristine mark, all by this stack's own design: a `dir` pool (skipped, no CoW), BOX_SNAPSHOT_PRISTINE=0, and a create incus refused (warned, mint continued). On a `dir` host that is EVERY hook failure — so the operator standing at the one moment the message exists for copy-pastes a restore that errors. It could not simply be read off the return status. The never-fatal contract means snapshot_mark returns 0 on all four paths on purpose — taken, skipped, knob-disabled, refused — so `if snapshot_mark ...` cannot distinguish a mark that exists from one that does not, and making it distinguish would put the mint's success at the mercy of a checkpoint, which is what that contract exists to prevent. So the fact is recorded rather than inferred: `marks` accumulates the labels snapshot_mark actually created, and `mark_taken <label>` answers the only question a rollback offer may ask. Per-label, so one mark never answers for another. This is the same refusal the message one line earlier already makes when it declines to invent a `bootstrapped` it never watched — a promise the disk cannot deliver is the failure mode #130 was built to refuse. It just was not applied to the restore offer sitting under it. Pinned per path rather than once, because the three no-mark paths fail differently and a single case would let the other two regress silently: five drives over snapshot_mark + mark_taken (created / dir-skip / knob / refused / no bleed between labels) and a static check that the call site is gated. Verified by mutation — dropping the recording, and un-gating the offer, each fail the suite rather than passing quietly.
2026-07-21 11:40:37 +00:00
marks="$marks $label"
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
echo "box: roll back to it any time with: box restore $name $label"
}
fix: offer the pristine rollback only when the mark actually exists Both reviewers landed on the same line independently, and they are right. cmd_new's hook-failure path offered `box restore $name pristine` unconditionally, but three mints reach that line with no pristine mark, all by this stack's own design: a `dir` pool (skipped, no CoW), BOX_SNAPSHOT_PRISTINE=0, and a create incus refused (warned, mint continued). On a `dir` host that is EVERY hook failure — so the operator standing at the one moment the message exists for copy-pastes a restore that errors. It could not simply be read off the return status. The never-fatal contract means snapshot_mark returns 0 on all four paths on purpose — taken, skipped, knob-disabled, refused — so `if snapshot_mark ...` cannot distinguish a mark that exists from one that does not, and making it distinguish would put the mint's success at the mercy of a checkpoint, which is what that contract exists to prevent. So the fact is recorded rather than inferred: `marks` accumulates the labels snapshot_mark actually created, and `mark_taken <label>` answers the only question a rollback offer may ask. Per-label, so one mark never answers for another. This is the same refusal the message one line earlier already makes when it declines to invent a `bootstrapped` it never watched — a promise the disk cannot deliver is the failure mode #130 was built to refuse. It just was not applied to the restore offer sitting under it. Pinned per path rather than once, because the three no-mark paths fail differently and a single case would let the other two regress silently: five drives over snapshot_mark + mark_taken (created / dir-skip / knob / refused / no bleed between labels) and a static check that the call site is gated. Verified by mutation — dropping the recording, and un-gating the offer, each fail the suite rather than passing quietly.
2026-07-21 11:40:37 +00:00
# Did THIS run actually create <label>? The only honest source for a message
# that offers a rollback: see 'marks' above for why the return status cannot
# answer it.
mark_taken() { case " $marks " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
# Take the 'pristine' snapshot, or say loudly why not. Default ON: the value
# of this mark only exists if it is already there on the bad day, and nobody
# takes it by hand at the one moment it is true. The escape hatch is the
# BOX_LAUNCH_TIMEOUT shape — an environment knob, not another flag on 'new'.
snapshot_pristine() {
snapshot_mark "$1" "$2" pristine "${BOX_SNAPSHOT_PRISTINE:-1}" \
"the guest after cloud-init, before rig converges anything."
}
# Take the 'bootstrapped' snapshot (#130), after the rig hook box RAN and
# WATCHED SUCCEED. Same default, same opt-out shape, same never-fatal
# contract as 'pristine' — it is the same policy, one function up.
#
# The label is deliberately CONDITIONAL where 'pristine' is unconditional, and
# the asymmetry is the point rather than an oversight. 'pristine' marks a
# MOMENT that every fresh mint has, so it is taken on every fresh mint.
# 'bootstrapped' marks an EVENT — "a tenant role converged, and nothing has
# happened since" — and a blank box has no such event. Minting the label there
# anyway would either duplicate 'pristine' byte for byte (nothing ran between
# them, so it is the same disk state at twice the cost, which is precisely the
# doubling the 'dir' skip above exists to refuse) or, worse, assert a
# convergence that never happened. So a hookless box gets nothing, and the
# label's presence is itself information: this box had a role and box saw it
# converge.
#
# Read only in that direction. PRESENCE means the mint-time hook succeeded and
# the box has not been touched since; ABSENCE means nothing at all — a blank
# template, a 'dir' pool, an opt-out, or a role converged by hand afterwards
# (see cmd_new's hook-failure path, which hands the operator the command
# because box cannot honestly take a mark for a run it did not watch). Nothing
# in box may treat a missing 'bootstrapped' as evidence a box is unconverged.
snapshot_bootstrapped() {
snapshot_mark "$1" "$2" bootstrapped \
"${BOX_SNAPSHOT_BOOTSTRAPPED:-1}" \
"the tenant role converged, and nothing has touched the box since."
}
cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
require_stack
local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
[ -z "$cpu$memory$disk" ] || usage_error "--cpu/--memory/--disk shape a fresh mint; a clone carries its source's resources ('box incus' can change them afterwards)"
local src="${from%%/*}" snap="" srcref
case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
incus copy "$srcref" "$instance"
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# 'incus copy' carries every user.* key forward (audit B2) — which is what
# makes a clone know its template and user for free, and is also why the
# mint stamp (#103) cannot simply ride along. A clone that inherited the
# stamp verbatim would claim to have been minted at the source's mint time,
# by the box version that minted the SOURCE, in a mint that never touched
# this instance. That is not a stale field, it is a false one.
#
# So re-stamp exactly the keys that describe THIS instance's coming into
# being, and leave the rest alone:
# · version / created / schema — the clone was made HERE, NOW, by THIS box
# · origin=clone, origin.from=<srcref> — how, and from what
# Deliberately NOT re-stamped, because they are lineage and stay true: the
# clone's disk really did come from that image, that template, that user and
# that rig role — reading them off the source is the whole point of a clone.
# ('incus copy' preserves the instance type too, so mode stays true as well.)
#
fix: a clone clears mode.asked — nobody asked THIS box anything (#103) The clone's re-stamp split provenance into two columns: event facts re-stamped (schema/version/created/origin/origin.from), lineage facts left alone (template/user/image/role/rig/mode). Review found one key that sits in neither: user.box.mode.asked. It is a mint-EVENT fact by the split's own criterion — only the mint knew whether a container was asked for or fallen back into for want of /dev/kvm — but the asker was the SOURCE's operator. A clone refuses --vm/--container outright, so nobody was asked anything about this instance. Riding through 'incus copy' untouched, it made 'box info' on a clone print MODE vm (asked: auto) describing a demand never made of it, with nothing marking it ancestral. There is no true value to re-stamp it with, so it is CLEARED — cleared and not set-to-empty, because an empty value is still a key a reader would find. The unset tolerates failure: a source that predates the stamp never carried the key, and a clone must not die over a key that was already absent. It lands with the re-stamp and before the start, the same rule and the same reason as the rest: a clone is never observable wearing an 'asked' its operator never gave. The read side needs no special case. The MODE line was already gated on 'asked', so absence renders as silence — the same absence-is-silence rule the whole provenance block uses — while TYPE still reports VM or CT off the instance type 'incus copy' preserves. +7 checks (529 -> 536), each proven to bite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
# 'mode.asked' is the one key that sits in NEITHER column, and so it is
# CLEARED rather than re-stamped or inherited. It is a mint-event fact —
# only the mint knew whether a container was asked for or fallen back into
# for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
# refuses --vm/--container outright (nobody was asked anything here), so an
# inherited 'asked' makes 'box info' print a demand that was never made of
# this instance. There is no true value to re-stamp it with: the honest
# answer is absence, and absence is already how the whole block renders
# what it does not know — the MODE line simply does not print, while TYPE
# above still says VM or CT off the preserved instance type.
#
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# origin.from records ONE hop. A clone of a clone names its parent and
# forgets its grandparent: the alternative is an unbounded chain in a config
# value, and the parent is the box an operator can actually go look at.
incus config set "$instance" \
user.box.schema="$BOX_STAMP_SCHEMA" \
user.box.version="$(box_version)" \
user.box.created="$(mint_time)" \
user.box.origin=clone \
user.box.origin.from="$srcref"
fix: a clone clears mode.asked — nobody asked THIS box anything (#103) The clone's re-stamp split provenance into two columns: event facts re-stamped (schema/version/created/origin/origin.from), lineage facts left alone (template/user/image/role/rig/mode). Review found one key that sits in neither: user.box.mode.asked. It is a mint-EVENT fact by the split's own criterion — only the mint knew whether a container was asked for or fallen back into for want of /dev/kvm — but the asker was the SOURCE's operator. A clone refuses --vm/--container outright, so nobody was asked anything about this instance. Riding through 'incus copy' untouched, it made 'box info' on a clone print MODE vm (asked: auto) describing a demand never made of it, with nothing marking it ancestral. There is no true value to re-stamp it with, so it is CLEARED — cleared and not set-to-empty, because an empty value is still a key a reader would find. The unset tolerates failure: a source that predates the stamp never carried the key, and a clone must not die over a key that was already absent. It lands with the re-stamp and before the start, the same rule and the same reason as the rest: a clone is never observable wearing an 'asked' its operator never gave. The read side needs no special case. The MODE line was already gated on 'asked', so absence renders as silence — the same absence-is-silence rule the whole provenance block uses — while TYPE still reports VM or CT off the instance type 'incus copy' preserves. +7 checks (529 -> 536), each proven to bite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
# Cleared, not set-to-empty: an empty value is still a key, and a reader
# that greps the config would find it. Tolerated failure because the source
# may predate the stamp and never have carried the key at all — a clone
# must not die over a key that was already absent.
incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
incus start "$instance"
wait_agent "$instance"
reset_identity "$instance"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
echo "box: cloned $srcref — isolation and auth state carry over from the source."
# A clone gets NO 'pristine' of its own (#104). It skips cloud-init and
# the rig hook entirely, so the pristine MOMENT never happens here —
# anything box marked at clone time would be "whatever the source was",
# converged and worked-in, wearing a label that promises pristine Debian.
# That is worse than no mark at all, so the mint-path snapshot lives
# strictly in the fresh-mint branch below and never runs here.
#
# What a clone gets instead is INHERITANCE, and it is already correct:
# 'incus copy <box>' carries the source's snapshots, so a source that has
# a 'pristine' hands the clone a real one — the true pristine state of
# the disk this clone descends from. Copying a SNAPSHOT ('--from box/snap')
# carries no snapshot list, so that clone starts with none. Both are
# honest; neither is invented. Say which one this is rather than leaving
# the operator to run 'box info' to find out.
2026-07-20 11:29:18 +00:00
# Capture first, THEN read (#124's class). Piping a multi-line incus
# writer straight into an early-exit reader lets grep close the pipe on
# the first match, SIGPIPE incus, and hand pipefail a 141 — which here
# would read as "no pristine" and narrate the WRONG inheritance shape on a
# clone that actually has one. Un-racy today at this writer's size, but
# the assignment costs nothing and does not depend on that staying true.
local snaps; snaps="$(incus snapshot list "$instance" --format csv 2>/dev/null || true)"
if printf '%s\n' "$snaps" | grep -q '^pristine,'; then
echo "box: it inherited the source's snapshots, 'pristine' among them (box restore $name pristine)."
else
echo "box: no 'pristine' mark here — a clone has no pristine moment to capture, and box"
echo "box: will not label the source's state as one. 'box info $name' lists what it did inherit."
fi
else
local t="${template:-blank}" m extra=()
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
load_template "$t"
m="$(pick_mode)"
# A server-class template (BOX_REQUIRE_VM, #68) has no weaker mode: the VM
# is its trust boundary, and its guest runs docker. Refuse the container
# fallback AND an explicit --container — never silently mint something
# lesser than what the template promises. The message holds for both
# tiers: /dev/kvm is a fact about the HOST, and an admin's default-project
# mint and a restricted user's incus-user mint go through the same daemon,
# so the fix is the same for both — a KVM-capable host, not a grant.
if [ "$T_REQUIRE_VM" = 1 ] && [ "$m" != vm ]; then
[ "$mode" != container ] || usage_error "template '$t' requires VM mode — it will not mint as a container (drop --container)"
die "template '$t' requires VM mode and this host has no /dev/kvm — mint it on a KVM-capable host (or via --remote)"
fi
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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
# BOX_AUTOSTART (#68): a server-class box must come back after a host
# reboot without an operator. Stamped per-instance like limits.*; a --from clone
# needs no code — 'incus copy' keeps every non-volatile config key, the
# same ride the user.* stamps take (audit B2).
[ "$T_AUTOSTART" != 1 ] || extra+=(--config boot.autostart=true)
# Root size is a VM launch concern; a container's root rides the pool. Say
# so instead of silently dropping an explicit --disk.
[ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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.
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93) Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the child 'incus launch' under 'box new' wedged with no server-side operation: 'incus operation list' empty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, the other was killed by a 540s wrapper. An immediate retry of the identical command succeeded in ~2-3 minutes, both times. box inherited that as an indefinite silent hang, indistinguishable from a cold mint working. The mint now prints "launching instance ..." before the call, and the call rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; overridable the same way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13. When the budget fires (124, or 137 when the KILL was needed) the failure says exactly what was measured — the client wedged with no server-side operation, an immediate retry has been observed to succeed — and points at 'box doctor' for host state. A non-timeout launch failure still surfaces incus's own stderr. The --from clone path is untouched: 'incus copy' of a local instance is a different operation and has never been observed to wedge this way. Proven the way the other mint-path guards are (a daemon-free run cannot mint): test/cli.sh greps that the narration orders before the launch, that the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and pinned stdin, and that the wedge message carries the retry hint, the doctor, and #93 — plus a live shim-incus drive of all three exits (wedge, plain refusal, success) during development. Fixes #93 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
#
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# And the rest of what this line knows and used to drop on the floor (#103).
# There is no host-side per-box store — the Incus instance config IS the
# database — so a fact not written here is simply gone the moment the mint
# returns. The stamp describes the MINT, not the outcome: it lands before
# cloud-init and before rig, and nothing later edits it.
#
# schema the stamp's shape, so a future reader knows what it is holding
# version the box that minted it — 'box --version' is a fact about the
# binary in front of you, never about the box you are looking at
# image the alias asked for. It is an UNPINNED alias on a moving
# remote: two boxes minted a month apart from "the same
# template" are not the same box, and the alias alone cannot
# say so. What it resolved to is pinned after the launch below.
# mode what it minted as, and what was ASKED — a container that fell
# back for want of /dev/kvm and one the operator asked for read
# identically afterwards, and only the mint knew which
# role the rig role box auto-runs at the hook below
# rig.* WHICH rig converged it, stamped only for a seed that actually
# installs rig from the pin ('blank' seeds none, so it gets none)
# created when. See mint_time() for why a timestamp belongs here.
# origin mint. A clone re-stamps it (see the --from branch above).
#
# NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
# duplicate drifts the first time someone edits the limit by hand); disk
# (a VM's is the root device size, and a container's does not exist — its
# root rides the pool, so a stamped value would be fiction); and tier,
# which box_tier() derives from whoever is ASKING, not from the box.
local stamp=(
--config user.box.schema="$BOX_STAMP_SCHEMA"
--config user.box.version="$(box_version)"
--config user.box.image="$T_IMAGE"
--config user.box.mode="$m"
--config user.box.mode.asked="$mode"
--config user.box.created="$(mint_time)"
--config user.box.origin=mint
)
[ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
fi
#
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93) Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the child 'incus launch' under 'box new' wedged with no server-side operation: 'incus operation list' empty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, the other was killed by a 540s wrapper. An immediate retry of the identical command succeeded in ~2-3 minutes, both times. box inherited that as an indefinite silent hang, indistinguishable from a cold mint working. The mint now prints "launching instance ..." before the call, and the call rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; overridable the same way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13. When the budget fires (124, or 137 when the KILL was needed) the failure says exactly what was measured — the client wedged with no server-side operation, an immediate retry has been observed to succeed — and points at 'box doctor' for host state. A non-timeout launch failure still surfaces incus's own stderr. The --from clone path is untouched: 'incus copy' of a local instance is a different operation and has never been observed to wedge this way. Proven the way the other mint-path guards are (a daemon-free run cannot mint): test/cli.sh greps that the narration orders before the launch, that the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and pinned stdin, and that the wedge message carries the retry hint, the doctor, and #93 — plus a live shim-incus drive of all three exits (wedge, plain refusal, success) during development. Fixes #93 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
# The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
# release drill the child 'incus launch' wedged before the create was
# even accepted — 'incus operation list' empty, the instance never
# existed, the daemon journal quiet — once for 56 minutes until killed
# by hand. Without a line here that wedge reads exactly like a cold mint
# working; without a budget it lasts forever. Ten minutes is generous —
# the coldest measured mint (first VM on a fresh pool, see wait_agent)
# is minutes, never an hour — and BOX_LAUNCH_TIMEOUT (seconds) overrides
# it, the same scripting knob shape as BOX_CPU / BOX_MEMORY. The drill's
# lore applies verbatim (RUNS.md trap 13): 'timeout -k' so a launch that
# shrugs off TERM still dies, and stdin pinned like every other
# non-interactive incus call — only shell/exec/tmux may own the terminal.
local budget="${BOX_LAUNCH_TIMEOUT:-600}" rc=0
echo "box: launching instance $instance (incus launch, $m mode)..."
timeout -k 5 "$budget" incus launch "$T_IMAGE" "$instance" --profile box-net \
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
--config user.box=1 \
--config user.box.template="$t" \
--config user.box.user="$T_USER" \
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
"${stamp[@]}" \
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
--config limits.cpu="$T_CPU" \
--config limits.memory="$T_MEMORY" \
--config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93) Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the child 'incus launch' under 'box new' wedged with no server-side operation: 'incus operation list' empty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, the other was killed by a 540s wrapper. An immediate retry of the identical command succeeded in ~2-3 minutes, both times. box inherited that as an indefinite silent hang, indistinguishable from a cold mint working. The mint now prints "launching instance ..." before the call, and the call rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; overridable the same way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13. When the budget fires (124, or 137 when the KILL was needed) the failure says exactly what was measured — the client wedged with no server-side operation, an immediate retry has been observed to succeed — and points at 'box doctor' for host state. A non-timeout launch failure still surfaces incus's own stderr. The --from clone path is untouched: 'incus copy' of a local instance is a different operation and has never been observed to wedge this way. Proven the way the other mint-path guards are (a daemon-free run cannot mint): test/cli.sh greps that the narration orders before the launch, that the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and pinned stdin, and that the wedge message carries the retry hint, the doctor, and #93 — plus a live shim-incus drive of all three exits (wedge, plain refusal, success) during development. Fixes #93 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
"${extra[@]}" </dev/null || rc=$?
# 124 = the budget fired (TERM landed); 137 = the -k KILL was needed —
# or, on 137, something external (an OOM kill) beat the budget to it.
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93) Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the child 'incus launch' under 'box new' wedged with no server-side operation: 'incus operation list' empty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, the other was killed by a 540s wrapper. An immediate retry of the identical command succeeded in ~2-3 minutes, both times. box inherited that as an indefinite silent hang, indistinguishable from a cold mint working. The mint now prints "launching instance ..." before the call, and the call rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; overridable the same way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13. When the budget fires (124, or 137 when the KILL was needed) the failure says exactly what was measured — the client wedged with no server-side operation, an immediate retry has been observed to succeed — and points at 'box doctor' for host state. A non-timeout launch failure still surfaces incus's own stderr. The --from clone path is untouched: 'incus copy' of a local instance is a different operation and has never been observed to wedge this way. Proven the way the other mint-path guards are (a daemon-free run cannot mint): test/cli.sh greps that the narration orders before the launch, that the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and pinned stdin, and that the wedge message carries the retry hint, the doctor, and #93 — plus a live shim-incus drive of all three exits (wedge, plain refusal, success) during development. Fixes #93 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
echo >&2
# timeout only proves the CLIENT overran the budget. 'incus launch' is
# create-then-start, so a slow-but-progressing launch (first mint
# pulling an uncached image, say) may already have REGISTERED the
# instance — in which case "never created, retry" would be exactly
# wrong: the retry collides with 'Instance already exists'. Probe, say
# which case this is, and best-effort delete either way (a no-op on
# the true #93 wedge, the cleanup on an overrun; also covers a create
# that lands in the race between the probe and the delete) so the
# retry advice below is safe in BOTH worlds. Review consensus on the
# first round of #94: all three reviewers converged on this hole.
if timeout -k 5 15 incus info "$instance" </dev/null >/dev/null 2>&1; then
echo "box: 'incus launch' OVERRAN its ${budget}s budget (killed) — but the instance WAS" >&2
echo "box: registered: this looks like a slow launch, not the #93 client wedge. Removing" >&2
echo "box: the partial instance so a retry starts clean..." >&2
else
echo "box: 'incus launch' WEDGED — killed after ${budget}s (or killed from outside), and" >&2
echo "box: the instance was never created. This is the #93 failure: the incus client" >&2
echo "box: hangs with NO server-side operation ('incus operation list' is empty, the" >&2
echo "box: daemon journal is quiet). An immediate retry of the exact same 'box new' has" >&2
echo "box: been observed to succeed, both times it was measured." >&2
fi
timeout -k 5 30 incus delete --force "$instance" </dev/null >/dev/null 2>&1 || true
echo "box: if it persists, diagnose the host: box doctor" >&2
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93) Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the child 'incus launch' under 'box new' wedged with no server-side operation: 'incus operation list' empty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, the other was killed by a 540s wrapper. An immediate retry of the identical command succeeded in ~2-3 minutes, both times. box inherited that as an indefinite silent hang, indistinguishable from a cold mint working. The mint now prints "launching instance ..." before the call, and the call rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; overridable the same way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13. When the budget fires (124, or 137 when the KILL was needed) the failure says exactly what was measured — the client wedged with no server-side operation, an immediate retry has been observed to succeed — and points at 'box doctor' for host state. A non-timeout launch failure still surfaces incus's own stderr. The --from clone path is untouched: 'incus copy' of a local instance is a different operation and has never been observed to wedge this way. Proven the way the other mint-path guards are (a daemon-free run cannot mint): test/cli.sh greps that the narration orders before the launch, that the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and pinned stdin, and that the wedge message carries the retry hint, the doctor, and #93 — plus a live shim-incus drive of all three exits (wedge, plain refusal, success) during development. Fixes #93 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
echo "box: (a genuinely slower mint can raise the budget: BOX_LAUNCH_TIMEOUT=<seconds>)" >&2
die "incus launch did not finish inside ${budget}s — retry the same command (#93)"
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93) Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the child 'incus launch' under 'box new' wedged with no server-side operation: 'incus operation list' empty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, the other was killed by a 540s wrapper. An immediate retry of the identical command succeeded in ~2-3 minutes, both times. box inherited that as an indefinite silent hang, indistinguishable from a cold mint working. The mint now prints "launching instance ..." before the call, and the call rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; overridable the same way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13. When the budget fires (124, or 137 when the KILL was needed) the failure says exactly what was measured — the client wedged with no server-side operation, an immediate retry has been observed to succeed — and points at 'box doctor' for host state. A non-timeout launch failure still surfaces incus's own stderr. The --from clone path is untouched: 'incus copy' of a local instance is a different operation and has never been observed to wedge this way. Proven the way the other mint-path guards are (a daemon-free run cannot mint): test/cli.sh greps that the narration orders before the launch, that the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and pinned stdin, and that the wedge message carries the retry hint, the doctor, and #93 — plus a live shim-incus drive of all three exits (wedge, plain refusal, success) during development. Fixes #93 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
elif [ "$rc" -ne 0 ]; then
# Not a wedge: incus refused and said why on stderr, right above.
die "incus launch failed (exit $rc)"
fi
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# The one field the launch line could not know: 'user.box.image' above is
# the ALIAS, and an alias on a moving remote is not a reproducible fact.
# Incus resolves it during the launch and records what it landed on in
# volatile.base_image — read it back and pin it into the stamp, so an
# incident six months from now can ask "was this box built on the image
# that broke?" and get an answer instead of a template name.
#
# Best-effort BY CONSTRUCTION, and that is the whole design of this line:
# it runs only after a launch that already succeeded, and a box that exists
# and boots must never be failed over a provenance field. Every failure
# here is silent and leaves the alias standing as the honest partial answer
# — a stamp with no fingerprint, which is exactly how cmd_info renders it.
local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
[ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
wait_agent "$instance"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
# The pristine mark (#104, child of rig#62). RIGHT HERE — cloud-init has
# finished and rig has not started — the guest is pristine Debian plus
# box's seed, and nothing else. That state exists for a few seconds on
# every mint and has never been captured; rig#62's "back to pristine
# Debian" is exactly this state, so take it while it is true.
#
# Unconditional within a fresh mint, not gated on T_BOOTSTRAP_ROLE: a
# blank box has no rig hook but it does have the same pristine moment,
# and 'box restore <box> pristine' should mean one thing on every box.
#
# NEVER fatal. This is an undo, not the mint's product — a mint that
# worked must not be failed by a checkpoint that didn't.
snapshot_pristine "$instance" "$name"
# 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
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
# The mark box cannot take for you (#130), said HERE because here is
# the only moment the operator is looking. 'bootstrapped' means "box
# ran the hook and watched it succeed" — a by-hand re-run happens in a
# shell box does not watch, so box cannot know when (or whether) it
# converged, and a mark taken on a guess would be a label asserting a
# convergence box never saw. That is the same lie #104 refused on the
# clone path. So: hand over the command instead of inventing the fact.
echo "box: no 'bootstrapped' mark was taken — box only marks a hook it watched succeed," >&2
echo " and a by-hand re-run happens in a shell it does not watch. Take it yourself once" >&2
echo " the role converges, at the moment it is true:" >&2
echo " box snapshot $name bootstrapped" >&2
fix: offer the pristine rollback only when the mark actually exists Both reviewers landed on the same line independently, and they are right. cmd_new's hook-failure path offered `box restore $name pristine` unconditionally, but three mints reach that line with no pristine mark, all by this stack's own design: a `dir` pool (skipped, no CoW), BOX_SNAPSHOT_PRISTINE=0, and a create incus refused (warned, mint continued). On a `dir` host that is EVERY hook failure — so the operator standing at the one moment the message exists for copy-pastes a restore that errors. It could not simply be read off the return status. The never-fatal contract means snapshot_mark returns 0 on all four paths on purpose — taken, skipped, knob-disabled, refused — so `if snapshot_mark ...` cannot distinguish a mark that exists from one that does not, and making it distinguish would put the mint's success at the mercy of a checkpoint, which is what that contract exists to prevent. So the fact is recorded rather than inferred: `marks` accumulates the labels snapshot_mark actually created, and `mark_taken <label>` answers the only question a rollback offer may ask. Per-label, so one mark never answers for another. This is the same refusal the message one line earlier already makes when it declines to invent a `bootstrapped` it never watched — a promise the disk cannot deliver is the failure mode #130 was built to refuse. It just was not applied to the restore offer sitting under it. Pinned per path rather than once, because the three no-mark paths fail differently and a single case would let the other two regress silently: five drives over snapshot_mark + mark_taken (created / dir-skip / knob / refused / no bleed between labels) and a static check that the call site is gated. Verified by mutation — dropping the recording, and un-gating the offer, each fail the suite rather than passing quietly.
2026-07-21 11:40:37 +00:00
# Only offer the rollback that EXISTS. Three mints reach this line
# with no pristine mark, all by this stack's own design: a 'dir' pool
# (skipped), BOX_SNAPSHOT_PRISTINE=0, and a refused create (warned,
# mint continued). On a 'dir' host that is EVERY hook failure, and an
# operator standing at the one moment this message exists for would
# copy-paste a restore that errors. Offering a mark the disk does not
# have is the same lie this PR refuses one message earlier, when it
# declines to invent a 'bootstrapped' it never watched.
if mark_taken pristine; then
echo "box: 'box restore $name pristine' is still there if you would rather start the role over." >&2
fi
die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
fi
feat: mark 'bootstrapped' after a rig hook box watched succeed converged anything. This takes the checkpoint one step later: after the rig bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged and untouched. 'box restore <box> bootstrapped' keeps the tenant role and undoes only what happened after it, which is the undo an operator reaches for far more often and which otherwise costs a ~10-minute re-mint. The policy is #128's, shared rather than copied: snapshot_pristine and the new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the never-fatal contract and the 'dir' skip exist in exactly one place. What does not generalise is the prose — the two marks name different moments — so each wrapper owns its own narration. Deliberately conditional where 'pristine' is unconditional. 'pristine' marks a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank box has no such event, so it gets no mark rather than a byte-identical duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none either: the failure message already sends the operator to 'box shell', which is a run box does not watch, so box hands over 'box snapshot <box> bootstrapped' instead of labelling a convergence it never saw. The label is therefore documented as one-directional on every surface: its presence means the hook converged untouched, its absence means nothing at all. Same durability caveats restated everywhere it appears — it dies with the box on 'box rm', and no filesystem rollback reaches off-box state (heavy-duty/rig#62). Closes #130 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:09:23 +00:00
# The hook RAN and box WATCHED it succeed — so right here the box is
# converged and untouched, the state an operator re-mints ten minutes
# for when they break a tenant role but not the OS under it. Never
# fatal, same as 'pristine': a mint that worked is not failed by a
# checkpoint that didn't.
snapshot_bootstrapped "$instance" "$name"
fi
fi
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
# The login hint belongs to the claude-box template — read the EFFECTIVE
# template off the instance, so a clone of a claude-box box gets it too and
# a blank box is not told to run a binary it doesn't have.
#
# Both spellings match, and that is not an alias for the ROLE. The role
# names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
# seeds ask for 'claude-box' and nothing here softens that. What these arms
# read is user.box.template, a stamp left on an INSTANCE at its own mint
# time: every box minted before the rename carries the bare name forever,
# and a clone carries it forward. Refusing the old spelling here would not
# cut anything over, it would only drop the hint on boxes that predate the
# rename — the same reason user.claudebox is honored everywhere else.
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
refactor(templates): the tenant seeds carry rig's -box family suffix rig is growing a second family of roles, and once a 'staging' role can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet machines, '-box' for box tenants. box's answer is that a template keeps being named for the role it converges, so the tenant templates move with it: claude -> claude-box codex -> codex-box grok -> grok-box staging -> staging-box Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one thing and a role key that says another is a trap with a 15-minute fuse: it mints clean and dies at convergence. Renamed with 'git mv' so the history of each seed follows it. 'blank' keeps its name. It seeds no tenant role and sets no BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only churn the default template's name for symmetry's sake. Two namespaces move apart here and only one of them moved: the template name and the role are now claude-box, while the seed USER stays 'claude' — that is the user rig's role converges and the one 'box shell' lands in. test/cli.sh pins the pair per tenant rather than each half alone, because a later rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what the pre-flight banner announces and what teardown deletes) and only moves the --template it passes. The mint-time hints in cmd_new match both spellings of user.box.template, and that is not an alias for the role: 'rig bootstrap claude' is gone and nothing here softens the cut. The stamp is a fact about an INSTANCE, written at its own mint time and carried forward by every clone; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate today — the same reason user.claudebox is honored everywhere else. migrate-host.sh stamps re-homed legacy boxes claude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil. Ordered AFTER rig's rename, and that is not a preference. The seeds install rig from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until rig#32's releases, so these templates ask whatever main happens to be for 'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist and cmd_new refuses to call the box ready. Merged in the other order the window closes instead of opening: rig's cut is hard, with no aliases, so the day it lands every unmerged box seed naming a bare role is the broken one. Closes #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
elif [ "$eff" = staging-box ] || [ "$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-server --hostname $name"
else
echo "box: ready — 'box shell $name'."
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
fi
}
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
# 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() {
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
{
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 "-"
}
# The box's address ON BOXNET — which is NOT the same as "its first address".
# A box running docker also carries 172.17.0.1 (docker0), and Incus happily
# lists that FIRST. box_ipv4() hands you the decoy, and pointing anything at it
# is pointing at the wrong interface: 'box expose' did exactly that until Incus
# refused with `Connect IP "172.17.0.1" must be one of the instance's static
# IPv4 addresses`. The drill has known this trap since run 4; the CLI had not.
# Derive the prefix from the network rather than hardcoding it.
box_net_ip() {
local pfx
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
[ -n "$pfx" ] || return 1
incus list "$1" --format csv --columns 4 2>/dev/null \
| tr -d '"' | tr ' ,' '\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
}
# 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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
die "list takes no box — for one box, use: box info ${args[0]}"
fi
if [ "$json" -eq 1 ]; then
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
incus list ${remote:+"$remote"} "user.box=1" --format json
else
list_all
fi
}
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# One instance config key. 'incus config get' on an UNSET key prints empty and
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
# key being absent, and every caller below reads absence as an empty string.
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }
# The mint stamp (#103), read back for 'box info'. Every key is optional and so
# is the whole block: a box minted before the stamp existed carries none of it
# and must render as a box with blanks — never as an error, and never as a box
# wearing a mint time it does not have. Legacy boxes are not a transitional
# case: a box outlives the release that minted it, which is exactly what the
# legacy 'user.claudebox' tag already says out loud at resolve_box.
box_provenance() {
local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
feat: box import records the trip, without rewriting who the box was An imported box kept the artifact's mint stamp verbatim (#103) — correct, the mint time, box version, image and origin belong to the originating host and should survive the trip. But nothing recorded the import, so an imported box was indistinguishable from one minted here at the artifact's mint time. Not origin=import. 'origin' answers how the instance came into BEING — mint or clone — and overwriting it would make an exported clone come back claiming to be an import, with nothing left saying it was ever a clone and an origin.from naming a lineage no key explains. The import is a third fact, orthogonal to the first two, so it takes its own keys and leaves every other one alone. Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the same repeated-event question: imported/imported.by pinned once and never rewritten, imported.last/.last.by refreshed on every arrival, imported.count for the trips in between. Last-wins alone would erase the evidence of the earlier trips, which is the same mistake origin=import makes one level up. box info prints IMPORTED directly under MINTED, because that adjacency is what stops the artifact's mint time being misread as this host's. It states only the ordering and never claims another host: box has no record of which host minted a box, and a re-import onto the same host is the documented upgrade flow. user.box.schema does not move — adding a key is not breaking — and is not written by the import at all, so a legacy artifact still reads as MINTED (not recorded) rather than acquiring a shape it does not have. Closes #131. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
local imp impby impl implby impn
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
schema="$(box_cfg "$i" user.box.schema)"
created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
img="$(box_cfg "$i" user.box.image)"; fp="$(box_cfg "$i" user.box.image.fingerprint)"
m="$(box_cfg "$i" user.box.mode)"; asked="$(box_cfg "$i" user.box.mode.asked)"
tpl="$(box_cfg "$i" user.box.template)"; u="$(box_cfg "$i" user.box.user)"
role="$(box_cfg "$i" user.box.role)"
rrepo="$(box_cfg "$i" user.box.rig.repo)"; rref="$(box_cfg "$i" user.box.rig.ref)"
origin="$(box_cfg "$i" user.box.origin)"; from="$(box_cfg "$i" user.box.origin.from)"
feat: box import records the trip, without rewriting who the box was An imported box kept the artifact's mint stamp verbatim (#103) — correct, the mint time, box version, image and origin belong to the originating host and should survive the trip. But nothing recorded the import, so an imported box was indistinguishable from one minted here at the artifact's mint time. Not origin=import. 'origin' answers how the instance came into BEING — mint or clone — and overwriting it would make an exported clone come back claiming to be an import, with nothing left saying it was ever a clone and an origin.from naming a lineage no key explains. The import is a third fact, orthogonal to the first two, so it takes its own keys and leaves every other one alone. Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the same repeated-event question: imported/imported.by pinned once and never rewritten, imported.last/.last.by refreshed on every arrival, imported.count for the trips in between. Last-wins alone would erase the evidence of the earlier trips, which is the same mistake origin=import makes one level up. box info prints IMPORTED directly under MINTED, because that adjacency is what stops the artifact's mint time being misread as this host's. It states only the ordering and never claims another host: box has no record of which host minted a box, and a re-import onto the same host is the documented upgrade flow. user.box.schema does not move — adding a key is not breaking — and is not written by the import at all, so a legacy artifact still reads as MINTED (not recorded) rather than acquiring a shape it does not have. Closes #131. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
# The import event (#131) — a THIRD fact, read from its own keys. 'origin'
# above still says mint or clone, which is what it has always meant.
imp="$(box_cfg "$i" user.box.imported)"; impby="$(box_cfg "$i" user.box.imported.by)"
impl="$(box_cfg "$i" user.box.imported.last)"
implby="$(box_cfg "$i" user.box.imported.last.by)"
impn="$(box_cfg "$i" user.box.imported.count)"
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# A pre-rename box has no metadata at all but is always a Claude box — the
# same mapping box_user() makes, for the same reason.
[ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }
echo
if [ -n "$created" ] || [ -n "$ver" ]; then
printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
else
printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
fi
feat: box import records the trip, without rewriting who the box was An imported box kept the artifact's mint stamp verbatim (#103) — correct, the mint time, box version, image and origin belong to the originating host and should survive the trip. But nothing recorded the import, so an imported box was indistinguishable from one minted here at the artifact's mint time. Not origin=import. 'origin' answers how the instance came into BEING — mint or clone — and overwriting it would make an exported clone come back claiming to be an import, with nothing left saying it was ever a clone and an origin.from naming a lineage no key explains. The import is a third fact, orthogonal to the first two, so it takes its own keys and leaves every other one alone. Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the same repeated-event question: imported/imported.by pinned once and never rewritten, imported.last/.last.by refreshed on every arrival, imported.count for the trips in between. Last-wins alone would erase the evidence of the earlier trips, which is the same mistake origin=import makes one level up. box info prints IMPORTED directly under MINTED, because that adjacency is what stops the artifact's mint time being misread as this host's. It states only the ordering and never claims another host: box has no record of which host minted a box, and a re-import onto the same host is the documented upgrade flow. user.box.schema does not move — adding a key is not breaking — and is not written by the import at all, so a legacy artifact still reads as MINTED (not recorded) rather than acquiring a shape it does not have. Closes #131. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
# Directly under MINTED, because adjacency is what defeats the misreading:
# the mint time above is the ARTIFACT's, carried across the trip on purpose,
# and a reader who meets it alone will take it for this host's.
#
# The qualifier states the one thing box actually knows — the ORDERING. It
# does not say "on another host": a box can be exported and re-imported onto
# the SAME host (that is the installer's own upgrade advice, #66), and
# nothing on the box records which host minted it. Inventing a location here
# would be precisely the fabricated fact the stamp exists to avoid.
if [ -n "$impl" ] || [ -n "$imp" ]; then
printf '%-11s%s\n' IMPORTED \
"${impl:-$imp} by box ${implby:-${impby:-unknown}} (the mint above predates it)"
# The birth of the import history, shown only when it is a DIFFERENT trip
# from the latest — on a single import both pairs hold the same values and
# a second line would be noise. Continuation indent, no second label.
if [ -n "$imp" ] && [ "$imp" != "${impl:-$imp}" ]; then
printf '%-11s%s\n' '' "import ${impn:-?} — the first was $imp by box ${impby:-unknown}"
fi
fi
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
if [ -n "$tpl" ]; then
local paren=""
[ -z "$u" ] || paren="user $u"
[ -z "$role" ] || paren="${paren:+$paren, }role $role"
printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
fi
[ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
# The mode is only worth a line alongside what was ASKED: TYPE above already
# says VM or CT, but only the mint knew whether a container was chosen or
# fallen back into for want of /dev/kvm.
[ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
[ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
[ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
# A schema box does not recognise is NEWER than box, not broken: show what is
# understood and say so, rather than refusing to describe a box that a later
# release minted perfectly well. (A non-integer lands here too, which is the
# right side to fail on.)
if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
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 }')"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
[ -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")"
# A box with a hole says so — an exposure visible only to --list is a hole
# info would deny. One line per open door.
local d listen
while IFS= read -r d; do
case "$d" in expose-*) : ;; *) continue ;; esac
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
done < <(incus config device list "$inst" 2>/dev/null)
feat: a minted box records how it was minted, and box info reads it back cmd_new knew a great deal at the moment it launched and wrote three user.* keys, dropping the rest: the box version that minted it, the base image (an unpinned alias on a moving remote), the rig role, which rig repo and ref converged it, the mint time, and whether a container was chosen or fallen back into for want of /dev/kvm. There is no host-side per-box store — the Incus instance config IS the database — so every one of those facts was gone the moment the mint returned. The same single write point now carries them as user.box.*, plus user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint is pinned in a second call after the launch, read from volatile.base_image, best-effort by construction: a box that exists and boots must never be failed over a provenance field. A clone re-stamps rather than inheriting. 'incus copy' carries every user.* key forward (audit B2), so an inherited stamp would not go stale, it would go false. --from now re-stamps schema/version/created/origin/origin.from on the copied instance before it starts, and leaves the lineage keys (template, user, image, role, rig pin) alone — the clone's disk genuinely came from them. origin.from records one hop. cmd_info grows a provenance block, tolerating absence everywhere: boxes minted before this stamp existed render as a box with blanks, and a schema this box does not recognise is treated as newer than it, not as broken. Closes #103. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
# What built this box, from what, when, with which box (#103). Nothing else
# on the host records it — the instance config IS the store — so a stamp
# nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
# for free: 'incus list --format json' includes config verbatim.
box_provenance "$inst"
echo
case "${snaps:-0}" in
''|0)
echo "SNAPSHOTS (none)"
echo
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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}"
}
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
# sudo -i joins its command argv into one shell string. In that join, a
# backslash-newline becomes a shell continuation and silently deletes the
# newline from a multi-line `box exec` payload (#169). Keep the login
# environment explicitly, but let the inner shell exec the original argv.
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -H bash -lc 'cd ~ && exec "$@"' _ "${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. Unlike cmd_exec, tmux has no
# caller-supplied command payload; its session name is validated below.
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
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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() {
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
echo "box: 'status' is deprecated — use 'box list'." >&2
list_all
}
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# --- export / import (#70): state that survives the box and the host --------
#
# 'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
# but the clone still lives on the same host under the same stack. So until
# now, nothing a box held could outlive a host teardown — and #66's installer
# refusal ("stop, export, remove every box before you upgrade") was only
# honest advice once a real export existed. 'incus export' is the primitive:
# a backup tarball of the instance and (by default) its snapshots.
cmd_export() {
local box="${args[0]}" file="${args[1]:-}"
# Down first, by OUR decision, not incus's necessity: incus can back up a
# running instance, but a live root disk is a moving target, and this
# artifact's whole job is to be trusted later, on a host that no longer
# has the box (#70 settled on require-down over snapshot-then-export).
require_stopped "$inst" "$box" "export wants a settled disk, so the artifact can be trusted later"
# Default filename: the box's name plus a UTC stamp. Sortable, never
# colliding across repeated exports, and it answers the question you will
# actually ask the file six months from now — WHEN is this state from?
[ -n "$file" ] || file="$box-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
if [ -e "$file" ] && [ "$force" -ne 1 ]; then
die "$file already exists — pick another name, or --force to overwrite"
fi
local extra=()
# Snapshots ride along by DEFAULT (#70's call): the reuse workflow lives in
# them (log in once, snapshot, clone forever), and an artifact that quietly
# dropped the authed checkpoint would defeat its own purpose. --instance-only
# is the explicit opt-out, passed through to incus verbatim.
[ "$instance_only" -eq 1 ] && extra+=(--instance-only)
echo "box: exporting $box → $file ..."
incus export "$inst" "$file" "${extra[@]}"
echo "box: exported $box → $file"
[ "$instance_only" -eq 1 ] || echo "box: (snapshots included — 'box import' brings them back too)"
# #70's credential decision: scrub or SHOUT — and box shouts. Scrubbing a
# disk image is a promise no tarball surgery can keep (dotfiles, keychains,
# tokens in shell history, deleted-but-unwiped blocks); handing someone a
# "sanitized" file that is not sanitized would be worse than the risk it
# hides. So the artifact carries everything, and box says so every time.
{
echo "box: ============================== WARNING =============================="
echo "box: this file contains the box's ENTIRE disk: agent logins (Claude,"
echo "box: Codex, Grok), git credentials, SSH keys, shell history — everything"
echo "box: that was inside the box. Nothing in it was scrubbed."
echo "box: treat the file itself as a credential: private storage, trusted"
echo "box: channels only."
echo "box: ====================================================================="
} >&2
}
# The way back in. Everything 'incus import' restores is the ARTIFACT's truth
# (disk, config, devices, snapshots); everything box then re-stamps is THIS
# host's truth (the boundary tag, the placement contract, a fresh machine
# identity). That split is the design (#70): state is portable, the trust
# boundary is not — it is re-established on the current stack, every time.
cmd_import() {
local file="${args[0]:-}"
[ -n "$file" ] || usage_error "usage: $(synopsis_of import)"
[ -f "$file" ] || die "no such file: $file"
# The artifact names its instance in backup/index.yaml — read it up front:
# the collision check and the re-stamping both need the final name BEFORE
# incus acts. GNU tar auto-detects the compression on read. '|| true'
# because pipefail would otherwise kill the script on a non-tarball with
# tar's status instead of reaching the die below that names the problem.
local embedded
embedded="$(tar -xOf "$file" backup/index.yaml 2>/dev/null | awk '$1 == "name:" { print $2; exit }' || true)"
[ -n "$embedded" ] || die "$file is not an incus/box export (no backup/index.yaml inside)"
local target="${name:-$embedded}"
# The boundary resolve_box enforces, seen from the other side: box will not
# occupy a name ANY existing instance holds — not a box's (import is not
# restore), and not an unmanaged VM's (not ours to shadow or clobber).
if incus config show "$target" >/dev/null 2>&1 </dev/null; then
die "an instance named '$target' already exists — import under another name: box import $file --name <new>"
fi
# The stack this lands on must exist first (a fresh host runs setup-host
# before it re-imports) — same pre-flight as a mint, same tier-aware fix.
require_stack
echo "box: importing $file as $target..."
if [ -n "$name" ]; then incus import "$file" "$name"; else incus import "$file"; fi
# Re-stamp the boundary tag. user.* keys ride inside the artifact, so a box
# export brings its template/user stamps back on its own, and a legacy
# user.claudebox=1 stays honored as it is everywhere else. Only an instance
# carrying NEITHER tag is stamped user.box=1 now — importing is minting,
# and a minted box is ours to manage.
local tag
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
[ "$tag" = 1 ] || incus config set "$target" user.box=1
feat: box import records the trip, without rewriting who the box was An imported box kept the artifact's mint stamp verbatim (#103) — correct, the mint time, box version, image and origin belong to the originating host and should survive the trip. But nothing recorded the import, so an imported box was indistinguishable from one minted here at the artifact's mint time. Not origin=import. 'origin' answers how the instance came into BEING — mint or clone — and overwriting it would make an exported clone come back claiming to be an import, with nothing left saying it was ever a clone and an origin.from naming a lineage no key explains. The import is a third fact, orthogonal to the first two, so it takes its own keys and leaves every other one alone. Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the same repeated-event question: imported/imported.by pinned once and never rewritten, imported.last/.last.by refreshed on every arrival, imported.count for the trips in between. Last-wins alone would erase the evidence of the earlier trips, which is the same mistake origin=import makes one level up. box info prints IMPORTED directly under MINTED, because that adjacency is what stops the artifact's mint time being misread as this host's. It states only the ordering and never claims another host: box has no record of which host minted a box, and a re-import onto the same host is the documented upgrade flow. user.box.schema does not move — adding a key is not breaking — and is not written by the import at all, so a legacy artifact still reads as MINTED (not recorded) rather than acquiring a shape it does not have. Closes #131. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
# The import EVENT (#131). Everything above either restores the ARTIFACT's
# truth or re-establishes THIS host's boundary; none of it records that the
# trip happened. The mint stamp (#103) rides inside the artifact verbatim —
# which is correct, mint facts belong to the originating host — and that is
# exactly what creates the gap: an imported box is otherwise indistinguishable
# from one minted here at the artifact's mint time.
#
# Deliberately NOT 'origin=import'. 'origin' answers how this instance came
# into BEING — mint or clone — and overwriting it destroys that answer: a
# clone that was exported and imported would come back claiming to be an
# import, with nothing left saying it was ever a clone and an 'origin.from'
# naming a lineage no key explains any more. The import is a THIRD fact,
# orthogonal to the first two, so it takes its OWN keys, and every key the
# artifact carried — origin, origin.from, created, version, template, user,
# image, role, rig.* — is left exactly as it arrived.
#
# Birth pair + latest pair, the shape heavy-duty/rig#61 settled on for the
# same repeated-event question. It transfers: a box can make the trip more
# than once, and these keys ride inside the artifact on every subsequent one.
# · imported / imported.by FIRST-wins. Pinned once, never rewritten.
# · imported.last / .last.by LAST-wins. When it landed HERE.
# · imported.count how many trips — first and last alone
# cannot tell 2 apart from 5, and nothing
# else on the box can reconstruct it.
# Last-wins alone would repeat, one level down, the very mistake this key
# exists to avoid: the second import would erase the evidence of the first.
# First-wins alone cannot answer "when did this land on THIS host", which is
# the question an operator actually asks.
local first_at prev_n n now
first_at="$(incus config get "$target" user.box.imported 2>/dev/null || true)"
prev_n="$(incus config get "$target" user.box.imported.count 2>/dev/null || true)"
# A count that is absent (the first trip) or not an integer (a hand-edited
# config, a foreign user.box.imported.count) must never fail an import that
# has already happened — arithmetic on it under 'set -e' would. Same side the
# schema check in box_provenance() falls on: degrade, never die.
fix: the import count guard closes the octal hole, not just the garbage one All three reviewers landed on the same line. Reproduced on this head under `set -euo pipefail`: prev_n=08 [ -n "$prev_n" ] && [ "$prev_n" -eq "$prev_n" ] 2>/dev/null || prev_n=0 # -> guard PASSES: test parses 08 as decimal n=$((prev_n + 1)) # -> bash: 08: value too great for base (error token is "08") Two holes that had to close together. `test -eq` reads decimal, arithmetic reads a leading zero as octal, so a value can pass the guard and still abort the arithmetic it was guarding. The abort lands after the physical `incus import` and before the stamp, the placement correction and the start — the exact window the degrade-never-die contract exists to protect, and the same side box_provenance()'s schema check falls on. case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac n=$((10#$prev_n + 1)) Digits-only closes sign and garbage; 10# forces base ten. Checked across 08 -> 9, 007 -> 8, 5 -> 6, and '' / not-a-number / -3 / 3x / 00 -> 1. The existing not-a-number fixture could never have caught this: that value fails the guard and degrades, so it exercises the path that already worked. Added a count=08 fixture beside it, asserting both halves — the import does not fail, AND the count advances to 9 rather than degrading to 1, because 08 is a real previous total and reading it as anything else would be its own small lie. A zero-padded count is not exotic; it is what any external tool that formats numbers writes. Verified by mutation: with the old guard restored both new assertions fail. Also drops user.box.mode.asked from the IMPCLONE fixture (grok's nit). Since #129 the clone path clears that key, so a fixture built from the mint shape that kept it described a box the clone path cannot produce. Nothing asserts it today — which is precisely why it would have rotted unnoticed.
2026-07-21 11:49:32 +00:00
# Two holes, and they have to close together. `[ 08 -eq 08 ]` PASSES — test
# parses decimal — and then `$((08 + 1))` aborts with "value too great for
# base", because arithmetic reads a leading zero as octal. That abort lands
# after the physical 'incus import' and before the stamp, the placement fix
# and the start: precisely the window this guard exists to protect. So the
# digits-only case closes sign and garbage, and 10# forces base ten.
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
n=$((10#$prev_n + 1)); now="$(mint_time)"
feat: box import records the trip, without rewriting who the box was An imported box kept the artifact's mint stamp verbatim (#103) — correct, the mint time, box version, image and origin belong to the originating host and should survive the trip. But nothing recorded the import, so an imported box was indistinguishable from one minted here at the artifact's mint time. Not origin=import. 'origin' answers how the instance came into BEING — mint or clone — and overwriting it would make an exported clone come back claiming to be an import, with nothing left saying it was ever a clone and an origin.from naming a lineage no key explains. The import is a third fact, orthogonal to the first two, so it takes its own keys and leaves every other one alone. Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the same repeated-event question: imported/imported.by pinned once and never rewritten, imported.last/.last.by refreshed on every arrival, imported.count for the trips in between. Last-wins alone would erase the evidence of the earlier trips, which is the same mistake origin=import makes one level up. box info prints IMPORTED directly under MINTED, because that adjacency is what stops the artifact's mint time being misread as this host's. It states only the ordering and never claims another host: box has no record of which host minted a box, and a re-import onto the same host is the documented upgrade flow. user.box.schema does not move — adding a key is not breaking — and is not written by the import at all, so a legacy artifact still reads as MINTED (not recorded) rather than acquiring a shape it does not have. Closes #131. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
local istamp=(
user.box.imported.last="$now"
user.box.imported.last.by="$(box_version)"
user.box.imported.count="$n"
)
# The birth of this box's import history: pinned on the first trip and
# carried by the artifact forever after, so a box on its third host still
# names the trip that started it.
[ -n "$first_at" ] || istamp+=(user.box.imported="$now" user.box.imported.by="$(box_version)")
# No 'user.box.schema' is written here, on either path. "Absent schema means
# pre-stamp" is load-bearing for the MINTED line, and stamping schema=1 onto
# a legacy artifact that carries no mint stamp would claim a shape it does
# not have. These keys need no schema to be read — every reader tolerates
# absence by construction. Nor does the schema BUMP: #103's contract is that
# it moves on removal or repurposing, never on addition, and this only adds.
#
# Before the start, like the clone re-stamp and for the same reason: an
# imported box is never observable — not for a second, not by a concurrent
# 'box info' — without the record of how it got here.
incus config set "$target" "${istamp[@]}"
feat: box export / import — state that survives the box and the host (#70) 'box rm' deletes a box and every snapshot it has; 'box new --from' clones, but the clone still lives on the same host. Nothing a box held could outlive a teardown — which made #66's upgrade refusal honest but lossy. This adds the way out and the way back: - box export <box> [<file>] [--instance-only]: wraps 'incus export' into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default. Requires the box stopped (require_stopped grew an honest reason parameter: export is down by OUR decision, not incus's). Credentials are SHOUTED, not scrubbed — the artifact carries the box's whole disk, and scrubbing a disk image is a promise tarball surgery cannot keep. - box import <file> [--name <box>]: reads the artifact's name from backup/index.yaml up front, refuses any name an existing instance holds (the resolve_box boundary from the other side), pre-flights the stack (require_stack, factored out of cmd_new), imports, then re-stamps the HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the box-net placement (profile assign, the migrate-host move), fresh volatile MACs (imports restore volatile.* verbatim — a re-import beside its sibling collided at start with 'MAC address already defined on another NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a clone. - restricted tier: box grant now converges restricted.backups allow — export rides the backup API, which incus-user's restricted projects block by default exactly like snapshots (incus 6.0 permissions.go, AllowBackupCreation). Import is plain instance creation and needs no key. - tests: driven usage errors + fail-closed grep/line-order guards for every daemon-gated invariant; CI's rehearsal job now runs a live round-trip (mint, write, snapshot, down, export, rm, import, assert the file, the snapshot, the tag, the agent, and the collision refusal). The whole flow was verified against a live Incus 6.0.4 daemon: running-box refusal, export, overwrite guard, rm, import with and without --name, re-home onto box-net, sibling re-import with distinct MACs and machine-ids, pre-export file and snapshot present in both. Closes #70 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
# Placement: the artifact carries its profile list, but the isolation
# contract is THIS host's box-net profile. A box export already says
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
# re-assigned — the same move migrate-host makes re-homing a legacy box.
# (An artifact naming a profile this host lacks fails inside 'incus import'
# above, with incus's own error naming the profile — honest enough.)
# Two traps in reading the list: a bare name filter PREFIX-matches (asking
# for 'work' also returns 'work2'), so anchor it; and the P column joins
# multiple profiles with newlines, so join the whole (quoted, multi-line)
# cell back into one comparable token instead of trusting the first line.
local profs
profs="$(incus list "^${target}\$" --format csv --columns P 2>/dev/null | tr -d '" ' | paste -sd, -)"
if [ "$profs" != box-net ]; then
incus profile assign "$target" box-net
echo "box: re-homed onto the box-net profile (the artifact said '${profs:-none}')"
fi
# The artifact's volatile.* config comes back verbatim too — including the
# NIC's MAC address. Importing an artifact twice, or beside the box it was
# exported from, then collides at start: "MAC address already defined on
# another NIC" (measured live), and the second box is left half-imported.
# 'incus copy' regenerates the MAC on clone; import does not — so unset
# every volatile hwaddr and let incus mint fresh ones at start. Host-side
# identity, the same reasoning as the in-guest machine-id reset below.
local k
for k in $(incus config show "$target" 2>/dev/null | awk -F: '/^ *volatile\..*\.hwaddr:/ { gsub(/ /, "", $1); print $1 }'); do
incus config unset "$target" "$k"
done
incus start "$target"
wait_agent "$target"
# The same trust boundary as a clone: the artifact's machine-id rides in
# its disk, and the box it was exported from may still exist somewhere. A
# fresh identity (machine-id → DHCP client-id → lease) before handover,
# always — reset_identity's comment has the collision this prevents.
reset_identity "$target"
echo "box: imported $target — auth state (agent logins, git creds) came back with it, by design."
echo "box: ready — 'box shell $target'."
}
# 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"
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
# The doctor's verdict depends on who is asking: a restricted user cannot
# see the nft tables or the kernel's bridge state, and telling them the
# host is broken because THEY cannot read it would be a wrong diagnosis.
export BOX_TIER; BOX_TIER="$(box_tier)"
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; }
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
cmd_grant() { host_script grant-user.sh; }
cmd_revoke() { host_script revoke-user.sh; }
# --- the versioned install (#66's stance, made livable in 0.7.0) ------------
# install.sh lands each version at <install-root>/versions/<v>, with a
# 'current' symlink naming the default and $BINDIR/box pointing through it.
# $root (readlink -f, line 8) already resolved the whole chain, so a versioned
# install always runs from .../versions/<v> — and a git checkout does not,
# which is how these verbs know to refuse instead of uninstalling somebody's
# working copy.
install_root() {
local vdir; vdir="$(dirname "$root")"
[ "$(basename "$vdir")" = versions ] || return 1
dirname "$vdir"
}
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version() {
case "$1" in
''|.*|-*) return 1 ;;
*[!A-Za-z0-9._+-]*) return 1 ;;
esac
return 0
}
# Which boxes exist on this host, at THIS caller's tier? Prints their names
# (both tag generations) and succeeds when at least one exists; fails when
# none are visible — including when incus is absent or not answering, because
# #66's stance protects BOXES from a version change, and a daemon that cannot
# answer has none to protect. install.sh carries a byte-identical copy (it
# runs before any install tree exists); test/cli.sh diffs the two so they
# cannot drift.
existing_boxes() {
command -v incus >/dev/null 2>&1 || return 1
{ timeout 10 incus list user.box=1 --format csv --columns n </dev/null
timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
}
# #66, kept at flip time: never change (or remove) the version under existing
# boxes. Names every box and the remedy, then dies — a refusal that does not
# say which boxes block it sends the operator off to rediscover 'box list'.
die_under_boxes() { # $1 = the act being refused, $2 = the retry command
local names n
names="$(existing_boxes)" || return 0
{
echo "box: this host has existing boxes:"
while IFS= read -r n; do echo "box: · $n"; done <<<"$names"
echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)."
echo "box: preserve what you care about — 'box down <box>', 'box export <box>' (one"
echo "box: portable file per box, #70) — then 'box rm <box>' each, and re-run: $2"
} >&2
exit 1
}
# The PATH symlinks that could ride this install: the one this invocation came
# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer
# checks where a link actually points before touching it, so a symlink that is
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
bin_links() {
local c=()
[ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
[ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box")
if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi
printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
}
converge_bin_links() { # $1 = install root: point our PATH symlinks through current
local ir="$1" p t
while IFS= read -r p; do
[ -L "$p" ] || continue
t="$(readlink -f "$p" 2>/dev/null || true)"
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
case "$t" in
"$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;;
esac
done < <(bin_links)
}
cmd_versions() {
local ir cur d v mark
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list"
cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
echo "VERSIONS ($ir)"
for d in "$ir/versions"/*/; do
[ -d "$d" ] || continue
v="$(basename "$d")"
mark=""
[ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
[ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)"
printf ' %s%s\n' "$v" "$mark"
done
echo
echo "switch the default: box use <version>"
echo "install another: re-run install.sh (versions land side by side)"
}
cmd_use() {
local v="${args[0]:-}" ir eff expect out
[ -n "$v" ] || usage_error "usage: $(synopsis_of use)"
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch"
valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'box versions')"
[ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')"
die_under_boxes "switch the default box version" "box use $v"
# An atomic flip, not unlink+create: ln -sfn leaves a window where current
# is missing; a rename over it does not.
ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
converge_bin_links "$ir"
# Assert the EFFECTIVE result, not the intent: current must resolve to the
# version asked for, and the chain's own binary must answer that version —
# a flip that "worked" while the operator's box still runs the old tree is
# exactly the flakiness this verb exists to end.
eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
[ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)"
if [ -n "$expect" ]; then
out="$("$ir/current/bin/box" --version 2>&1 || true)"
case "$out" in
*"$expect"*) : ;;
*) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;;
esac
fi
echo "box: switched to $v (current -> versions/$v)"
}
# The uninstall's own confirmation. NOT confirm() above: that one is for box
# lifecycle verbs and must never auto-accept from the environment (the drill
# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse
# without --force). Uninstalling is installer-family, and BOX_YES is the
# installer-family consent contract — same as install.sh and revoke --purge.
uninstall_confirm() { # $1 = question. --force, or BOX_YES=1, or a TTY.
[ "$force" -eq 1 ] && return 0
[ -n "${BOX_YES:-}" ] && return 0
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
local reply
printf 'box: %s? [y/N] ' "$1"
# Same EOF cure as confirm() above — Ctrl-D must abort out loud.
read -r reply || die "aborted."
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}
# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose.
# The full removal runs in the documented order: boxes first (refuse while
# they exist, or --purge-host tears the stack down with them), then trees and
# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the
# last word is a re-check, not a hope.
cmd_uninstall() {
local ir a ver="" all=0 purge_host=0 cur p t granted leftover=""
local targets=()
for a in ${args[@]+"${args[@]}"}; do
case "$a" in
--all) all=1 ;;
--purge-host) purge_host=1 ;;
-*) usage_error "unknown option: $a (see 'box help uninstall')" ;;
*) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;;
esac
done
ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
[ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall"
# -- one version -----------------------------------------------------------
if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
[ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version"
valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'box versions')"
[ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')"
cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
# A broken current makes the CURRENT guard below unfireable (cur empty
# when the link is missing; cur naming a non-directory when it dangles —
# readlink -f resolves a link whose last component does not exist). Heal
# first, then decide; never delete around a broken default.
{ [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
|| die "current is dangling — 'box use <version>' to repoint the default first (refusing to remove versions while it is broken)"
[ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use <other>' first, or 'box uninstall --all' for everything"
uninstall_confirm "remove box version $ver from $ir"
# rm's exit code is not the verdict — the absence re-check below is (a
# half-removed tree must be reported as INCOMPLETE, not as a crash).
rm -rf "$ir/versions/$ver" || true
if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
exit 1
fi
echo "box: removed version $ver (the default stays $cur)"
return 0
fi
[ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous"
# -- everything ------------------------------------------------------------
if [ "$purge_host" -eq 1 ]; then
# Granted users' worlds are not ours to erase silently — name them first;
# 'box revoke <user> --purge' is the clean path (and asserts its absence).
granted="$(timeout 10 incus project list --format csv 2>/dev/null </dev/null | cut -d, -f1 | grep '^user-' | tr '\n' ' ' || true)"
[ -n "${granted% }" ] && echo "box: NOTE — granted users still have projects (${granted% }) — 'box revoke <user> --purge' removes each world cleanly first" >&2
# Consent forwards: --force and BOX_YES are this verb's installer-family
# yes, and teardown-host must hear it too — otherwise a non-interactive
# 'uninstall --all --purge-host --force' dies at teardown's own prompt
# (EOF on read) with the tree untouched but the promise broken.
if [ "$force" -eq 1 ] || [ -n "${BOX_YES:-}" ]; then
bash "$root/host/teardown-host.sh" --yes \
|| die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
else
bash "$root/host/teardown-host.sh" \
|| die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
fi
else
die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)"
fi
uninstall_confirm "remove the ENTIRE box install at $ir (every version)"
# The removal set, gathered BEFORE anything is deleted, so the absence
# assert below re-checks exactly what was promised gone. PATH symlinks are
# removed only when they resolve into (or dangle at) THIS install root.
targets+=("$ir")
while IFS= read -r p; do
[ -L "$p" ] || continue
t="$(readlink -f "$p" 2>/dev/null || true)"
[ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
case "$t" in "$ir"/*) targets+=("$p") ;; esac
done < <(bin_links)
# Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real
# uninstall leaves neither generation behind.
while IFS= read -r p; do
p="$(dirname "$p")/claudebox"
[ -L "$p" ] && targets+=("$p")
done < <(bin_links)
[ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox")
mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')
# rm's exit code is not the verdict — the absence assert below is (a
# half-removed tree must be reported as INCOMPLETE by name, not as a crash).
for p in "${targets[@]}"; do rm -rf "$p" || true; done
# END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
# A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
# get verified (the revoke --purge discipline).
for p in "${targets[@]}"; do
if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
done
if [ -n "$leftover" ]; then
echo "box: uninstall INCOMPLETE — still present:$leftover" >&2
echo "box: remove them by hand, and re-check each path is really gone." >&2
exit 1
fi
echo "box: uninstalled — removed:"
for p in "${targets[@]}"; do echo "box: · $p"; done
if [ "$purge_host" -eq 0 ]; then
echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:"
echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time."
fi
}
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 "*)
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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[@]}"
feat!: claudebox becomes box — the Claude box is one template among several The tool underneath was already generic: a thin, honest wrapper over Incus. What was Claude-specific was welded on — one image, one profile, one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a template. The mechanic: 'box new' stamps the template's identity onto the instance (user.box=1, user.box.template, user.box.user); shell/exec/ tmux read the user back off the instance, and 'incus copy' carries user.* keys (audit B2), so a clone knows what it is without consulting the template. Templates are box.env (parsed against a strict allowlist, never sourced — no key for a network exists, on purpose) plus a verbatim cloud-init. Every template launches with the shared box-net profile: the isolated NIC and root disk, nothing template-controlled — resources land per-instance from box.env, overridable via BOX_CPU/ BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a small host now that profile edits can't). The three open calls, taken as recommended: clean cut at 0.4.0 (no claudebox shim; the installer retires the old symlink); default template = claude (muscle memory survives); repo stays heavy-duty/ claudebox, binary is box. Compat is the tag, not the name: resolve_box and list honor the legacy user.claudebox=1 forever, and the legacy tag maps to the claude user — a pre-rename box lists, shells, clones, unchanged. Deliberate divergence from #17's table: the host-stack resource names (claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT renamed — they are host-internal, invisible to users, and renaming them breaks every provisioned host for zero user-visible gain. claude-dev is no longer created; setup-host creates box-net, teardown removes both. Closes #17
2026-07-14 14:22:50 +00:00
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 in NAT mode DNATs host
# 127.0.0.1:<hostport> to the box's <ip>:<port>. The traffic rides the
# network into the guest, so 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 door can reach it. Three
# pieces beside the device itself, each one a drill-found absence:
# · a SCOPED ACL allow (this box's ip + this port only) — the ingress drop
# that makes A7 true would eat the DNAT'd packet;
# · route_localnet + a loopback masquerade on the host (box-firewall.sh) —
# Incus installs only the DNAT, and a loopback-sourced packet can neither
# leave the host nor be answered without them;
# · a static ipv4.address pin on the NIC — NAT mode refuses to start
# without one (see below).
exposure_dev() { echo "expose-$1"; } # device name for a port
cmd_expose() {
local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"
feat: the restricted tier — box grant/revoke converge users onto the hardened boxnet (#74) incus-user confines an incus-group user to their own project, but its defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS isolation), snapshots blocked, and the box-net profile invisible to their project. So the tier is an admin-run idempotent convergence: box grant <user> # incus group; touch incus-user (the project is lazy); # drop the private-bridge eth0 from their default # profile; restricted.networks.access=boxnet — and ONLY # boxnet, or the unhardened bridge stays one --network # flag away; restricted.snapshots=allow; install the # shipped box-net profile into their project box revoke <user> # group removal closes the socket, boxes keep running --purge # ...or delete their world, and assert the absence box_tier() (live credentials, argless id -nG; byte-identical copy in setup-host.sh) drives the tier-aware surface: new pre-flights the profile and names the right fix per tier, expose refuses before any daemon call (without the guard the failure is a lie — restricted certs cannot read boxnet's redacted config, so box_net_ip claims a running box has no address), setup-host exits 0 with the honest note, doctor judges only what the caller can see. Also fixed while the rehearsal exercised the lifecycle: box restore dispatched 'incus restore', which does not exist in Incus 6 (it is 'incus snapshot restore') — the verb had never worked. Fixed for every tier. Convergence survives incus-user restarts by that tool's own design (it configures a project only at creation) — read in its source, then measured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:13 +00:00
# Before ANY incus call: the door's plumbing (the box-isolate ACL, the
# host firewall's route_localnet + masquerade) is daemon-global state a
# restricted certificate cannot touch. Without this guard the failure is
# a lie — box_net_ip cannot read boxnet's (redacted) config, so the
# restricted user is told their running box "has no boxnet address yet".
if [ "$(box_tier)" = restricted ]; then
die "box expose edits the daemon-global ACL and firewall, which the restricted tier cannot modify — ask an incus-admin. (see #74)"
fi
# --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_net_ip "$inst" || true)"
[ -n "$ip" ] && incus network acl rule remove box-isolate ingress \
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
# If that was the last door, unpin the static address it required. Only
# then — other exposures still lean on the pin. Best-effort, like the ACL.
if ! incus config device list "$inst" 2>/dev/null | grep -q '^expose-'; then
incus config device unset "$inst" eth0 ipv4.address >/dev/null 2>&1
fi
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_net_ip "$inst")" \
|| die "$box has no boxnet address 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
# A VM's proxy device must be NAT mode — Incus supports proxy on containers in
# both modes, but on VMs "NAT mode only" (the userspace forkproxy is a
# container thing). NAT mode DNATs host:port → instance:port in netfilter, and
# it needs the host to be the instance's gateway, which boxnet makes true.
#
# And NAT mode needs a STATIC address. Incus resolves connect=0.0.0.0 to the
# NIC's ipv4.address — the device config, not the lease — and refuses when it
# is unset: `Instance has no static IPv4 address assigned to be used as the
# connect IP` (the 0.5.0 drill). The first cut pinned an address but the
# WRONG one (box_ipv4's docker0 decoy); the second cut removed the pin
# instead of correcting it. Third cut: pin the box's current BOXNET lease.
# Same address the box already holds, so nothing about its networking moves —
# the lease just becomes official. Left in place across exposures; unpinned
# when the last door closes.
fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH The first drill run where every failure was the RELEASE CODE, not the environment. 71 passed, 5 failed; all five traced to four bugs: 1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS user.box=1 but never removed user.claudebox=1, and legacy_boxes() counted the old tag — so retire saw its own freshly-migrated box as un-migrated and refused forever ('legacy boxes still exist: legacybox'), leaving claudenet + claude-dev behind. Now: a verified re-home drops the legacy tag LAST (after the move is proven, so a failure anywhere above still leaves the box valid under one tag or the other), and legacy_boxes() ignores boxes already carrying user.box=1. 2. box expose died with a bare 'could not add the proxy device' — it swallowed incus's reason, exactly the sin this repo keeps punishing. Now it prints incus's error. And the mechanism is corrected: a VM's proxy needs NAT mode, which requires a static NIC address, so expose pins the box's current lease first (which also fixes the restart caveat — the exposure no longer points at a lease the box may lose). 3. wait_box's 2-minute window was too short: the legacy box was declared dead and then every migration check against it passed. 4 minutes. 4. The grok template hunted for a regular file named exactly 'grok-build' under /home/grok and found nothing — an installer's drop may be a SYMLINK, and its binary name is upstream's to choose. Now it tries the plausible names and paths, falls back to any executable grok*, links both names, and SAYS what it found — or dumps what the installer actually left when it finds nothing. The drill likewise dumps the on-disk evidence and the cloud-init log on a --version failure instead of discarding the box.
2026-07-14 19:56:17 +00:00
local err; err="$(mktemp)"
if [ -z "$(incus config device get "$inst" eth0 ipv4.address 2>/dev/null)" ]; then
# override copies the profile NIC into the instance with the key set; if a
# local eth0 already exists, override refuses and set is the right verb.
if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err" \
&& ! incus config device set "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
echo "box: could not pin $box's boxnet address ($ip) as static — the NAT proxy requires one:" >&2
sed 's/^/ /' "$err" >&2; rm -f "$err"
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "expose failed"
fi
fi
# connect=0.0.0.0 is deliberate: Incus resolves it to the instance's static
# IPv4 (the pin above). Naming an address here would work too, but 0.0.0.0
# cannot repeat the docker0 mistake — there is nothing to get wrong.
if incus config device add "$inst" "$dev" proxy \
"listen=tcp:127.0.0.1:$hport" "connect=tcp:0.0.0.0:$port" \
fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH The first drill run where every failure was the RELEASE CODE, not the environment. 71 passed, 5 failed; all five traced to four bugs: 1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS user.box=1 but never removed user.claudebox=1, and legacy_boxes() counted the old tag — so retire saw its own freshly-migrated box as un-migrated and refused forever ('legacy boxes still exist: legacybox'), leaving claudenet + claude-dev behind. Now: a verified re-home drops the legacy tag LAST (after the move is proven, so a failure anywhere above still leaves the box valid under one tag or the other), and legacy_boxes() ignores boxes already carrying user.box=1. 2. box expose died with a bare 'could not add the proxy device' — it swallowed incus's reason, exactly the sin this repo keeps punishing. Now it prints incus's error. And the mechanism is corrected: a VM's proxy needs NAT mode, which requires a static NIC address, so expose pins the box's current lease first (which also fixes the restart caveat — the exposure no longer points at a lease the box may lose). 3. wait_box's 2-minute window was too short: the legacy box was declared dead and then every migration check against it passed. 4 minutes. 4. The grok template hunted for a regular file named exactly 'grok-build' under /home/grok and found nothing — an installer's drop may be a SYMLINK, and its binary name is upstream's to choose. Now it tries the plausible names and paths, falls back to any executable grok*, links both names, and SAYS what it found — or dumps what the installer actually left when it finds nothing. The drill likewise dumps the on-disk evidence and the cloud-init log on a --version failure instead of discarding the box.
2026-07-14 19:56:17 +00:00
bind=host nat=true >/dev/null 2>"$err"; then
rm -f "$err"
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)"
# The device alone is not the door: the DNAT'd loopback packet also needs
# route_localnet on the bridge (box-firewall.sh installs it, with the
# masquerade). Readable without root — warn instead of handing over a
# door that silently does not answer.
if [ "$(cat /proc/sys/net/ipv4/conf/boxnet/route_localnet 2>/dev/null)" != 1 ]; then
echo "box: WARNING — route_localnet is off on boxnet, so this door will NOT answer." >&2
echo "box: the host firewall predates expose — apply it: sudo /usr/local/sbin/box-firewall" >&2
fi
else
fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH The first drill run where every failure was the RELEASE CODE, not the environment. 71 passed, 5 failed; all five traced to four bugs: 1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS user.box=1 but never removed user.claudebox=1, and legacy_boxes() counted the old tag — so retire saw its own freshly-migrated box as un-migrated and refused forever ('legacy boxes still exist: legacybox'), leaving claudenet + claude-dev behind. Now: a verified re-home drops the legacy tag LAST (after the move is proven, so a failure anywhere above still leaves the box valid under one tag or the other), and legacy_boxes() ignores boxes already carrying user.box=1. 2. box expose died with a bare 'could not add the proxy device' — it swallowed incus's reason, exactly the sin this repo keeps punishing. Now it prints incus's error. And the mechanism is corrected: a VM's proxy needs NAT mode, which requires a static NIC address, so expose pins the box's current lease first (which also fixes the restart caveat — the exposure no longer points at a lease the box may lose). 3. wait_box's 2-minute window was too short: the legacy box was declared dead and then every migration check against it passed. 4 minutes. 4. The grok template hunted for a regular file named exactly 'grok-build' under /home/grok and found nothing — an installer's drop may be a SYMLINK, and its binary name is upstream's to choose. Now it tries the plausible names and paths, falls back to any executable grok*, links both names, and SAYS what it found — or dumps what the installer actually left when it finds nothing. The drill likewise dumps the on-disk evidence and the cloud-init log on a --version failure instead of discarding the box.
2026-07-14 19:56:17 +00:00
# NEVER swallow incus's reason — the first cut of this verb died with a bare
# "could not add the proxy device" and told the drill nothing.
echo "box: incus refused the proxy device:" >&2
sed 's/^/ /' "$err" >&2; rm -f "$err"
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH The first drill run where every failure was the RELEASE CODE, not the environment. 71 passed, 5 failed; all five traced to four bugs: 1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS user.box=1 but never removed user.claudebox=1, and legacy_boxes() counted the old tag — so retire saw its own freshly-migrated box as un-migrated and refused forever ('legacy boxes still exist: legacybox'), leaving claudenet + claude-dev behind. Now: a verified re-home drops the legacy tag LAST (after the move is proven, so a failure anywhere above still leaves the box valid under one tag or the other), and legacy_boxes() ignores boxes already carrying user.box=1. 2. box expose died with a bare 'could not add the proxy device' — it swallowed incus's reason, exactly the sin this repo keeps punishing. Now it prints incus's error. And the mechanism is corrected: a VM's proxy needs NAT mode, which requires a static NIC address, so expose pins the box's current lease first (which also fixes the restart caveat — the exposure no longer points at a lease the box may lose). 3. wait_box's 2-minute window was too short: the legacy box was declared dead and then every migration check against it passed. 4 minutes. 4. The grok template hunted for a regular file named exactly 'grok-build' under /home/grok and found nothing — an installer's drop may be a SYMLINK, and its binary name is upstream's to choose. Now it tries the plausible names and paths, falls back to any executable grok*, links both names, and SAYS what it found — or dumps what the installer actually left when it finds nothing. The drill likewise dumps the on-disk evidence and the cloud-init log on a --version failure instead of discarding the box.
2026-07-14 19:56:17 +00:00
die "expose failed"
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 cnf <<<"$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
# The prompt comes from the ROW, never from here. A shared string can only be
# right for one verb, and it was rm's — which is why 'restore' could not be
# gated by adding the token alone (#105). A row with 'confirm' and no words is
# a table bug, and it dies as one rather than asking a blank question.
case ",$pre," in *,confirm,*)
[ -n "$cnf" ] || die "internal: '$cmd' is marked confirm but its row carries no prompt"
confirm "$(fill "$cnf" "$inst")" ;;
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
echo "box: $(fill "$ok" "${args[0]}")"
fi
;;
esac