2026-07-14 23:59:34 +00:00
|
|
|
# box
|
2026-07-10 14:53:40 +00:00
|
|
|
|
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
|
|
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
2026-07-15 00:15:45 +00:00
|
|
|
network-isolated Incus box from a **template**; the coding-agent templates
|
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
|
|
|
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
|
|
|
|
|
(OpenAI Codex), `grok-box` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
converges**: the template is a thin seed, and the agent tooling lands via a
|
|
|
|
|
creds-free `rig bootstrap` role auto-run at mint
|
|
|
|
|
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
|
|
|
|
|
— you log in and work; destroying it loses nothing you didn't push.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
**Strictly creds-free.** A box ships with everything installed and **no**
|
2026-07-15 00:15:45 +00:00
|
|
|
credentials — no agent token, no git PAT, nothing. You authenticate
|
2026-07-18 13:39:45 +00:00
|
|
|
interactively _inside_ the box. The tool never stores or injects a secret. That
|
2026-07-10 15:00:36 +00:00
|
|
|
means there's nothing shared or committed, so it's safe for multiple operators
|
|
|
|
|
out of the 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
|
|
|
**Templates set what's in the box, never what it can reach.** A template is
|
|
|
|
|
image + user + resources + cloud-init; the network and every security flag
|
|
|
|
|
live in a shared profile no template can touch, so `blank` is a box with
|
|
|
|
|
nobody home — not a box with the safety off.
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
**The tool knows nothing about your projects.** You just `git clone` inside a
|
2026-07-14 18:01:34 +00:00
|
|
|
box. A repo can ship an optional [`.box/`](docs/box-recipe.md)
|
2026-07-15 00:15:45 +00:00
|
|
|
runbook that the box's coding agent reads and acts on — there is no `install`
|
|
|
|
|
step and no host-run setup. See [docs/box-design.md](docs/box-design.md) for the
|
2026-07-10 15:00:36 +00:00
|
|
|
design rationale.
|
|
|
|
|
|
2026-07-18 13:39:45 +00:00
|
|
|
> **0.6.0**: multi-user support.
|
|
|
|
|
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
> **0.5.0**: two new templates (`codex`, `grok`), `box expose` — a
|
|
|
|
|
> loopback-only door to a box port, for seeing a dev server — and the host
|
|
|
|
|
> lifecycle as first-class verbs: `box setup-host`, `box teardown-host`, and
|
|
|
|
|
> `box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
|
|
|
|
|
> and retires the legacy bridge.
|
|
|
|
|
>
|
2026-07-14 23:59:34 +00:00
|
|
|
> **0.4.0's clean cut stands**: the CLI is `box` (no legacy shim), the
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
> host stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and
|
|
|
|
|
> the default template is `blank`. Boxes minted by any earlier version keep
|
|
|
|
|
> working under every verb — their legacy tag is honored forever.
|
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
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
## Install
|
|
|
|
|
|
|
|
|
|
```sh
|
2026-07-14 23:59:34 +00:00
|
|
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
2026-07-18 20:54:50 +00:00
|
|
|
By default that installs the **latest release** — the installer resolves the
|
|
|
|
|
release tag off GitHub's `releases/latest` redirect (no API, no token) and
|
|
|
|
|
downloads exactly that tree, so two operators running it get the same box.
|
|
|
|
|
If the resolution fails it says so and stops — it never silently hands out
|
|
|
|
|
`main`. `BOX_REF` picks another channel (a set ref is tried as a tag first,
|
|
|
|
|
then as a branch — [#83](https://github.com/heavy-duty/box/issues/83)):
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
curl -fsSL .../install.sh | bash # the latest release (default)
|
|
|
|
|
curl -fsSL .../install.sh | BOX_REF=0.6.0 bash # pin a release
|
|
|
|
|
curl -fsSL .../install.sh | BOX_REF=main bash # the development tip
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
(A dev tree's `VERSION` carries a `-dev` suffix, so it lands beside your
|
|
|
|
|
releases under `versions/`, never on top of one.)
|
|
|
|
|
|
2026-07-18 16:02:37 +00:00
|
|
|
It asks first — **"Install box?"** — then downloads the tree into a
|
|
|
|
|
**versioned** install (the way plenty of CLIs manage theirs), links `box` onto
|
|
|
|
|
your `PATH`, and on a fresh host asks a second question: **"Set up this
|
|
|
|
|
machine as a box host now?"** Say yes and it builds the whole isolation stack
|
|
|
|
|
for you (it may ask for `sudo`); say no and you can run `box setup-host`
|
|
|
|
|
later. (No `git clone` needed.)
|
|
|
|
|
|
|
|
|
|
The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
|
|
|
|
|
root install):
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
versions/<version>/ one full tree per installed version
|
|
|
|
|
current -> versions/<v> the tracked default
|
|
|
|
|
$BINDIR/box -> current/bin/box the PATH entry, riding the chain
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
**Re-running is a safe converge.** Installing a version you already have
|
|
|
|
|
changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree);
|
|
|
|
|
a stray re-run can never clobber your install or rebuild the stack under your
|
|
|
|
|
boxes. Installing a **new** version lands it side by side and flips `current`
|
|
|
|
|
only when you have **no boxes** — under existing boxes the flip is refused
|
|
|
|
|
(never change versions under a user's boxes,
|
|
|
|
|
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
|
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
|
|
|
deliberate act: preserve what you care about — `box down <box>`, then
|
|
|
|
|
`box export <box>` (one portable file per box, snapshots included —
|
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
|
|
|
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
|
2026-07-18 13:39:45 +00:00
|
|
|
(which deletes the box _and_ its snapshots) — then:
|
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
|
|
|
|
|
|
|
|
```sh
|
2026-07-18 16:02:37 +00:00
|
|
|
box versions # what is installed, which is current, which is running
|
|
|
|
|
box use <version> # flip the default (same refusal while boxes exist)
|
Redesign install flow: confirm, no-op if installed, opt-in host setup
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:10:34 +00:00
|
|
|
```
|
|
|
|
|
|
2026-07-18 16:02:37 +00:00
|
|
|
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
|
|
|
|
|
next installer run — the tree is moved, not re-downloaded, and your boxes are
|
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
|
|
|
untouched. After switching versions (and `box setup-host`, if the stack was
|
|
|
|
|
torn down), `box import <file>` brings each exported box back — snapshots,
|
|
|
|
|
logins and all. A version-aware upgrade that migrates boxes instead of asking
|
|
|
|
|
you to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
|
2026-07-18 16:02:37 +00:00
|
|
|
installs (CI, images), `BOX_YES=1` answers every prompt yes,
|
|
|
|
|
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
|
|
|
|
|
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
|
|
|
|
|
downloading (how CI proves the installer under review, and how the drill can
|
|
|
|
|
install an unpushed branch).
|
2026-07-10 15:00:36 +00:00
|
|
|
|
2026-07-18 00:01:15 +00:00
|
|
|
### Global vs per-user install
|
|
|
|
|
|
|
|
|
|
Where box lands depends on **who runs the installer**, because on a shared host
|
2026-07-18 13:39:45 +00:00
|
|
|
box's tree is _executed by other users_ — so it cannot hide in one user's home:
|
2026-07-18 00:01:15 +00:00
|
|
|
|
|
|
|
|
- **As root → global.** The tree goes to `/opt/box` (world-readable) and the
|
|
|
|
|
`box` symlink to `/usr/local/bin` (already on every login `PATH`). One
|
|
|
|
|
install, every operator on the host runs the same `box`. This is the fleet
|
|
|
|
|
path: [rig](https://github.com/heavy-duty/rig)'s `box` role
|
|
|
|
|
([rig#24](https://github.com/heavy-duty/rig/issues/24)) installs box once at
|
|
|
|
|
host bootstrap ([#71](https://github.com/heavy-duty/box/issues/71)).
|
|
|
|
|
- **As a normal user → per-user.** The tree goes to `~/.local/share/box` and
|
|
|
|
|
the symlink to `~/.local/bin` — the solo path, unchanged. Nobody else needs
|
|
|
|
|
to run your box.
|
|
|
|
|
|
|
|
|
|
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
|
|
|
|
|
install under `/root` would be `0700` and unreadable to everyone else — which
|
2026-07-18 16:02:37 +00:00
|
|
|
is exactly the bug the root branch fixes. When both tiers are installed, PATH
|
|
|
|
|
order decides which `box` wins — the installer warns when it sees the other
|
|
|
|
|
tier's tree.
|
2026-07-18 00:01:15 +00:00
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
|
|
|
|
|
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
|
|
|
The installer already does this. Run it directly to set up a host you
|
|
|
|
|
installed with `BOX_SKIP_SETUP_HOST=1`, or to re-apply the stack by hand:
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
```sh
|
Make host setup complete in one run, and let the installer run it
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes #63
Fixes #64
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:52:50 +00:00
|
|
|
box setup-host # one run is enough
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
bridge (sibling-name resolution off, resolver pinned to public upstreams —
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
`BOX_DNS` overrides), the `box-isolate` ACL (drops all RFC1918/CGNAT/
|
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
|
|
|
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
reach each other), and firewall rules blocking instance → host. All rules
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
|
2026-07-10 15:00:36 +00:00
|
|
|
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
|
|
|
|
`sudo apt-get install -y dnsmasq-base`.
|
|
|
|
|
|
2026-07-18 21:20:51 +00:00
|
|
|
The stack's subnet is `10.88.0.0/24` when free. setup-host **never builds on
|
|
|
|
|
a subnet something else already claims** — most tellingly when this machine's
|
|
|
|
|
own default gateway sits inside it, which means it is being run *inside a
|
|
|
|
|
box*: a nested `boxnet` on the guest's own uplink subnet captures its gateway
|
|
|
|
|
address and blackholes the guest's egress in intermittent,
|
|
|
|
|
maddening-to-attribute blackouts
|
|
|
|
|
([#80](https://github.com/heavy-duty/box/issues/80)). Instead of refusing, a
|
|
|
|
|
bare `box setup-host` decides for itself: an existing `boxnet` bridge is
|
|
|
|
|
converged on as-is (the bridge is the pin — it is never re-addressed), and a
|
|
|
|
|
claimed default triggers an auto-pick of the first free `/24` from
|
|
|
|
|
`10.89.0.0/24` through `10.127.0.0/24`, announced loudly — so drills and
|
|
|
|
|
rehearsals *inside a box* work with zero flags. `BOX_SUBNET=<a.b.c.0/24>`
|
|
|
|
|
pins the subnet explicitly for scripted hosts (the bridge address, the ACL's
|
|
|
|
|
gateway carve-out and the firewall all derive from it); a pin is honored or
|
|
|
|
|
refused, never silently overridden. `box doctor` recognizes the poisoned
|
|
|
|
|
state (a gateway held as a local address, duplicate uplink routes) on the
|
|
|
|
|
machine it runs on and inside every box it probes.
|
2026-07-18 19:47:20 +00:00
|
|
|
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
A host still carrying the pre-0.4.0 stack: `box migrate-host --all-boxes`
|
|
|
|
|
re-homes each legacy box onto `boxnet` (authed state preserved), and
|
|
|
|
|
`box migrate-host --retire-legacy` removes the old bridge and profile once no
|
|
|
|
|
legacy box remains.
|
|
|
|
|
|
2026-07-18 04:09:48 +00:00
|
|
|
## Multi-user hosts: the restricted tier
|
|
|
|
|
|
|
|
|
|
One host, several people, and not everyone should hold the daemon. Incus's
|
|
|
|
|
socket is all-or-nothing — `incus-admin` group members own every instance on
|
|
|
|
|
the machine — so box layers a second tier on
|
|
|
|
|
[incus-user](https://linuxcontainers.org/incus/docs/main/projects/):
|
|
|
|
|
|
2026-07-18 13:39:45 +00:00
|
|
|
| tier | who | what they hold |
|
|
|
|
|
| -------------- | -------------------------------- | ----------------------------------------------------------------- |
|
|
|
|
|
| **admin** | root, or the `incus-admin` group | everything: all boxes, the stack, `setup-host`, `expose`, `grant` |
|
|
|
|
|
| **restricted** | the `incus` group | their **own** boxes only, on the same hardened network |
|
|
|
|
|
| none | everyone else | no socket, nothing |
|
2026-07-18 04:09:48 +00:00
|
|
|
|
|
|
|
|
An admin hands the tier out per user, and takes it back:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
box grant dev1 # dev1 can now: box new / list / shell / snapshot / rm — their boxes only
|
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 # tier removed; their boxes survive (grant again restores).
|
|
|
|
|
# a session they already hold keeps the socket until it
|
|
|
|
|
# ends — revoke warns and names the loginctl command
|
|
|
|
|
box revoke dev1 --purge # ...or end their sessions and delete everything they had
|
2026-07-18 04:09:48 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`grant` is an idempotent convergence, not a flag flip, because incus-user's
|
|
|
|
|
defaults miss box's contract three ways (measured on Debian 13 / Incus 6.0.4,
|
|
|
|
|
see [the plan doc](docs/plans/2026-07-18-restricted-tier.md)): it pins each
|
2026-07-18 13:39:45 +00:00
|
|
|
user to a private _unhardened_ NAT bridge, it blocks snapshots, and it cannot
|
2026-07-18 04:09:48 +00:00
|
|
|
see the `box-net` profile. Granting rewires all three: the user's project is
|
|
|
|
|
restricted to `boxnet` **and only boxnet** — the hardened network is not their
|
|
|
|
|
default placement but the only one their certificate can express — snapshots
|
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
|
|
|
and backups are allowed (the clone and `box export` workflows), and the
|
|
|
|
|
shipped profile is installed into their project. Re-run
|
2026-07-18 04:09:48 +00:00
|
|
|
`box grant <user>` after upgrading box to refresh the profile, like
|
|
|
|
|
`setup-host` for the stack.
|
|
|
|
|
|
|
|
|
|
What a restricted user gets is the full contract: same ACL, same DNS
|
|
|
|
|
isolation, same pinned resolver, same port isolation, same box↔box drop —
|
|
|
|
|
and their boxes cannot reach another user's box, which is the same
|
|
|
|
|
box↔box drop doing its one job. What they can't do stays honest: `box
|
|
|
|
|
expose` (daemon-global state) says to ask an admin, `box setup-host` and
|
|
|
|
|
`box doctor` answer at their tier instead of failing at it.
|
|
|
|
|
|
|
|
|
|
`drill/multiuser.sh` rehearses all of it live — two users, real grants, real
|
|
|
|
|
boxes, probes from inside — and CI runs it on every PR (container mode; the
|
|
|
|
|
VM boundary itself is proven on real hardware, like the rest of the drill).
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
## Quick start
|
|
|
|
|
|
|
|
|
|
```sh
|
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 # a creds-free coding-agent box (~10 min cold)
|
|
|
|
|
box shell work # enter as the template's user
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
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
|
|
|
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
|
|
|
|
|
`grok-box` — or `blank` for none. Inside the box, authenticate as needed. The
|
|
|
|
|
`claude-box` template looks like this; the others follow the same shape with
|
|
|
|
|
their own login step:
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
claude # then run /login — copy the URL (press c), open it
|
|
|
|
|
# in YOUR browser, paste the code back. No host CLI needed.
|
|
|
|
|
gh auth login # or drop a PAT in — your git credentials, your call
|
|
|
|
|
git clone https://github.com/you/project && cd project
|
2026-07-15 00:15:45 +00:00
|
|
|
claude # if the repo has .box/, the agent reads it and sets up
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-07-15 00:15:45 +00:00
|
|
|
No coding agent is special — each is one template among several, and adding
|
|
|
|
|
another is just another directory. What ships today:
|
feat: codex and grok templates — the mechanic's second and third tenants
Two coding-CLI templates mirroring claude's shape: a box.env + verbatim
cloud-init, inheriting the box-net placement contract structurally, no
new design.
- codex: OpenAI Codex CLI via 'npm i -g @openai/codex' (the SCOPED
package; needs Node 22), symlinked onto the non-interactive exec PATH
via 'npm prefix -g' — the same PATH fix the claude template needed.
- grok: xAI Grok Build via the official 'curl x.ai/cli/install.sh',
run AS the grok user (the installer drops into $HOME); the binary is
found and symlinked to /usr/local/bin.
Install commands verified upstream at implementation time, per the
issue's rule (npmjs.com/package/@openai/codex, x.ai/cli). Each gets an
AGENTS.md-style context file telling the agent it lives in a
disposable, isolated, creds-free box.
Drill: templates listing now expects four; a compact per-template smoke
(mint, '<cli> --version' via box exec, remove) validates each payload
installs and lands on the exec PATH — the generic mechanic is already
proven by blank+claude and not repeated.
Closes #54
2026-07-14 15:59:39 +00:00
|
|
|
|
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
|
|
|
| Template | What it becomes |
|
|
|
|
|
| ------------- | ------------------------------------------------------------------ |
|
|
|
|
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
|
|
|
|
| `claude-box` | Claude Code, creds-free — where this project started |
|
|
|
|
|
| `codex-box` | OpenAI Codex CLI, creds-free |
|
|
|
|
|
| `grok-box` | xAI Grok CLI, creds-free |
|
|
|
|
|
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
|
|
|
|
**Templates are thin seeds; rig does the becoming**
|
|
|
|
|
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
|
|
|
|
|
directory under `templates/`: a `box.env` (image, user, resources, boot
|
|
|
|
|
demands, tenant role — parsed against a strict allowlist, never sourced) and
|
|
|
|
|
a `user-data.yaml` (cloud-init, passed to Incus verbatim except the two rig
|
|
|
|
|
pin tokens below). The seed is deliberately small — the tenant user, tmux,
|
|
|
|
|
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
|
|
|
|
|
joins a tailnet or admits credentials — and after cloud-init settles, box
|
|
|
|
|
auto-runs the template's **creds-free** tenant role inside the guest
|
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
|
|
|
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `staging-box`,
|
|
|
|
|
[rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
|
|
|
|
|
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
|
|
|
|
|
template is named for the role it converges,
|
|
|
|
|
[rig#76](https://github.com/heavy-duty/rig/issues/76)). The agent CLI,
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
docker, the server posture and the agent-context file all come from that
|
|
|
|
|
role — convergent and idempotent, so the same command re-run later converges
|
|
|
|
|
an *existing* box to a newer spec (`box shell <box>` →
|
|
|
|
|
`sudo rig bootstrap <role>`). The agent-context file carries the
|
|
|
|
|
[#80](https://github.com/heavy-duty/box/issues/80) guard — never run
|
|
|
|
|
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
|
|
|
|
|
from rig's roles, instead of copy-pasted per template.
|
|
|
|
|
|
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
|
|
|
**Anything that joins or admits stays operator-run.** The `staging-box`
|
|
|
|
|
tenant's tailnet workload join holds a pre-auth key, so box only prints it as the
|
2026-07-20 00:07:00 +00:00
|
|
|
next step — `box shell <name>`, then `sudo rig bootstrap workload-server` — and
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
|
|
|
|
|
split, kept).
|
|
|
|
|
|
|
|
|
|
**The rig pin point** (`RIG_REPO` / `RIG_REF`). The seeds preinstall rig,
|
|
|
|
|
which inverts the rig→box install edge
|
|
|
|
|
([rig#28](https://github.com/heavy-duty/rig/issues/28): rig installs box on
|
|
|
|
|
host-class machines; box guests now install rig). The seed's install line
|
|
|
|
|
carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
|
|
|
|
|
environment:
|
feat: codex and grok templates — the mechanic's second and third tenants
Two coding-CLI templates mirroring claude's shape: a box.env + verbatim
cloud-init, inheriting the box-net placement contract structurally, no
new design.
- codex: OpenAI Codex CLI via 'npm i -g @openai/codex' (the SCOPED
package; needs Node 22), symlinked onto the non-interactive exec PATH
via 'npm prefix -g' — the same PATH fix the claude template needed.
- grok: xAI Grok Build via the official 'curl x.ai/cli/install.sh',
run AS the grok user (the installer drops into $HOME); the binary is
found and symlinked to /usr/local/bin.
Install commands verified upstream at implementation time, per the
issue's rule (npmjs.com/package/@openai/codex, x.ai/cli). Each gets an
AGENTS.md-style context file telling the agent it lives in a
disposable, isolated, creds-free box.
Drill: templates listing now expects four; a compact per-template smoke
(mint, '<cli> --version' via box exec, remove) validates each payload
installs and lands on the exec PATH — the generic mechanic is already
proven by blank+claude and not repeated.
Closes #54
2026-07-14 15:59:39 +00:00
|
|
|
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
```sh
|
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 # heavy-duty/rig @ main
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
RIG_REPO=you/rig RIG_REF=my-branch \
|
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 trial --template claude-box # a rig branch under review
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Both directions of that edge track `main` unpinned today — said honestly,
|
|
|
|
|
the same way rig documents box's unpinned install
|
|
|
|
|
([rig#29](https://github.com/heavy-duty/rig/issues/29)) — until the release
|
|
|
|
|
flow lands ([rig#32](https://github.com/heavy-duty/rig/issues/32),
|
|
|
|
|
[#83](https://github.com/heavy-duty/box/issues/83)). The pin covers both the
|
|
|
|
|
installer fetched and the tree it installs, and the values are
|
|
|
|
|
allowlist-validated on the host before they touch the YAML.
|
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
|
|
|
|
|
|
|
|
```sh
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
box templates # list what this install can mint
|
|
|
|
|
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
docs: the thin-template story — box mints, rig converges (#81)
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
# same isolation, nobody home — no rig, no 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
|
|
|
```
|
|
|
|
|
|
|
|
|
|
A template **cannot** name a network, a profile, or a `security.*` flag —
|
|
|
|
|
there is no key for them. Every box launches with the shared `box-net`
|
|
|
|
|
profile (the isolated NIC + root disk), so every template gets the identical
|
feat(new): inline resource overrides — --cpu, --memory, --disk (#57)
Resolution is most-specific-first: flag > BOX_* env (kept — it is the
scripting form and how the drill shrinks boxes on small hosts) > the
template's box.env > defaults. Values pass to Incus verbatim (limits.cpu,
limits.memory, root size=) — its units, its validation; box adds no
parser. Resources are all a flag can touch: there is still no flag for a
network or a security.* key, on purpose.
Flags shape a fresh mint only — --from refuses them, a clone carries its
source's resources. An explicit --disk on a container mint gets a note
instead of a silent drop (a container's root rides the pool).
The drill's blank mint now carries --cpu 1 --memory 1GiB and asserts the
limits landed — which is also the precedence proof, since the drill
exports BOX_CPU/BOX_MEMORY on small hosts — plus a negative check that
--from refuses resource flags.
Verified live (container mint, image cached): BOX_CPU=3 + --cpu 1
--memory 1GiB → limits.cpu=1, limits.memory=1GiB; --from + --cpu exits 2
before touching anything; container --disk prints the note.
Closes #57
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:17:33 +00:00
|
|
|
trust boundary. Resources come from the template's `box.env`, overridable at
|
|
|
|
|
mint time — inline (`--cpu 2 --memory 3GiB --disk 20GiB`) or via
|
|
|
|
|
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables (the scripting
|
|
|
|
|
form; flags win). The template's identity (name, user) is stamped onto 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
|
|
|
so `shell`, `exec` and `tmux` land in the right user — and a clone still
|
|
|
|
|
knows, because `incus copy` carries the metadata.
|
|
|
|
|
|
2026-07-18 16:02:37 +00:00
|
|
|
## Log in once, reuse via snapshots
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
Because every fresh box is creds-free, re-authenticating each time would be
|
|
|
|
|
toil. Snapshot an authenticated box and clone from it instead:
|
|
|
|
|
|
|
|
|
|
```sh
|
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 # checkpoint after you've logged in
|
|
|
|
|
box new --name feature --from work/authed # clone the authed state into a new box
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
2026-07-15 00:15:45 +00:00
|
|
|
`--from` copies the whole box (agent login, git creds, clones and all) while
|
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
|
|
|
preserving isolation. You can also `box new --name x --from work` to clone
|
2026-07-19 19:46:06 +00:00
|
|
|
a box's live state, or roll a box back with `box restore work authed` — which
|
|
|
|
|
asks first, since a rollback discards everything since the snapshot (`--force`
|
|
|
|
|
skips the prompt, and scripts must pass it: with no terminal to ask on, box
|
|
|
|
|
refuses rather than assuming yes).
|
2026-07-10 15:00:36 +00:00
|
|
|
|
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
|
|
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
2026-07-13 20:26:44 +00:00
|
|
|
snapshot labels and the `--from` line to clone one.
|
|
|
|
|
|
2026-07-20 10:04:49 +00:00
|
|
|
### `pristine` — the one checkpoint box takes for you
|
|
|
|
|
|
|
|
|
|
Every fresh mint marks a snapshot called `pristine`
|
|
|
|
|
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
|
|
|
|
|
is true: **after cloud-init, before `rig bootstrap` converges the tenant
|
|
|
|
|
role.** At that instant the guest is pristine Debian plus box's thin seed
|
|
|
|
|
(the user, tmux, rig) and nothing else — the state
|
|
|
|
|
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
|
|
|
|
|
to pristine Debian". It exists for a few seconds on every mint, so box
|
|
|
|
|
captures it rather than asking you to be quick.
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
box restore work pristine # undo the tenant role and everything since
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
That is a complete undo for every tenant role: everything `rig bootstrap
|
|
|
|
|
claude|codex|grok|staging` does — docker, node, the agent CLI, the
|
|
|
|
|
agent-context file, the role marker — is box-local and file-shaped, so a
|
|
|
|
|
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
|
|
|
|
|
|
|
|
|
|
Three things it deliberately does not do:
|
|
|
|
|
|
|
|
|
|
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
|
|
|
|
|
deletes a box _and_ every snapshot it has. `box export` is the only state
|
|
|
|
|
that outlives the box — see below.
|
|
|
|
|
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
|
|
|
|
|
registration, a pushed commit: those are records held somewhere else, and
|
|
|
|
|
no filesystem rollback undoes them (rig#62 covers those separately).
|
|
|
|
|
- **A `--from` clone gets no `pristine` of its own.** A clone skips
|
|
|
|
|
cloud-init and rig entirely, so it has no pristine moment to capture, and
|
|
|
|
|
box will not label a source's worked-in state as one. Cloning a _box_
|
|
|
|
|
inherits the source's snapshots (a real `pristine` among them, if the
|
|
|
|
|
source had one); cloning a _snapshot_ starts with none. `box new` says
|
|
|
|
|
which of the two you got.
|
|
|
|
|
|
|
|
|
|
On a host whose storage pool uses the `dir` driver, a snapshot is a full
|
|
|
|
|
multi-GB copy rather than a near-free copy-on-write mark, so the mint
|
|
|
|
|
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
|
|
|
|
|
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
|
|
|
|
|
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
|
|
|
|
|
skips the mark on any host.
|
|
|
|
|
|
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` — the same undo, one step later
|
|
|
|
|
|
|
|
|
|
A mint whose template names a bootstrap role marks a second snapshot,
|
|
|
|
|
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
|
|
|
|
|
`rig bootstrap` has run and box has **watched it succeed**. That is the box
|
|
|
|
|
converged and not yet touched — the state you actually wanted back most of
|
|
|
|
|
the time.
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
box restore work bootstrapped # keep the tenant role, undo what you did to it
|
|
|
|
|
box restore work pristine # throw the tenant role away too
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
|
|
|
|
|
pool (with two marks that disk cost is twice the size, so a CoW-less host is
|
|
|
|
|
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
|
|
|
|
|
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
|
|
|
|
|
(`box export` is the durable path), and it **cannot reach off-box state** —
|
|
|
|
|
a tailnet device record, a runner registration (rig#62).
|
|
|
|
|
|
|
|
|
|
**Read the label in one direction only.** Its _presence_ means the mint-time
|
|
|
|
|
hook converged and nothing has touched the box since. Its _absence_ means
|
|
|
|
|
nothing at all, because box only marks a hook it watched:
|
|
|
|
|
|
|
|
|
|
- A **blank** box runs no hook, so there is no convergence to mark. It gets
|
|
|
|
|
none — `pristine` and `bootstrapped` would be the same disk state at twice
|
|
|
|
|
the cost, and a label claiming a convergence that never happened is worse
|
|
|
|
|
than no label.
|
|
|
|
|
- A box whose hook **failed** gets none either. box tells you to re-run the
|
|
|
|
|
role by hand through `box shell`, and a by-hand run happens in a shell box
|
|
|
|
|
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
|
|
|
|
|
at the moment it is true, rather than inventing a fact.
|
|
|
|
|
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
|
|
|
|
|
|
|
|
|
|
`box info <box>` is what actually tells you which labels a box has.
|
|
|
|
|
|
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
|
|
|
## Survive the host: `box export` / `box import`
|
|
|
|
|
|
|
|
|
|
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
|
|
|
|
|
snapshots. `box new --from` clones — but the clone still lives on the same
|
|
|
|
|
host, under the same stack. `box export` is the way out
|
|
|
|
|
([#70](https://github.com/heavy-duty/box/issues/70)): one portable file that
|
|
|
|
|
outlives the box, the host stack, and the machine.
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
box down work # export wants a settled disk
|
|
|
|
|
box export work # → work-<UTC stamp>.tar.gz, snapshots included
|
|
|
|
|
box rm work # nothing is lost anymore
|
|
|
|
|
# ...upgrade box / rebuild the host / carry the file to another machine...
|
|
|
|
|
box import work-<stamp>.tar.gz # the box is back — snapshots, logins and all
|
|
|
|
|
box import work-<stamp>.tar.gz --name work2 # or under a new name
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
This is what makes the upgrade flow humane
|
|
|
|
|
([#66](https://github.com/heavy-duty/box/issues/66)): stop, export, remove
|
|
|
|
|
every box, upgrade, re-import. Everything `incus import` restores is the
|
|
|
|
|
artifact's truth (disk, config, snapshots); what box re-stamps on import is
|
|
|
|
|
_this_ host's truth — the `user.box=1` boundary tag, the `box-net` placement
|
|
|
|
|
(re-assigned if the artifact's differs), and a fresh machine identity, the
|
|
|
|
|
same move a clone gets, so an imported box can never collide with the box it
|
|
|
|
|
was exported from. Import refuses a name any existing instance already holds.
|
|
|
|
|
`--instance-only` exports the live state without the snapshots.
|
|
|
|
|
|
|
|
|
|
**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 would be a lie) and shouts instead, every time. Store
|
|
|
|
|
and move the file like the secret it is.
|
|
|
|
|
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
## See a dev server: `box expose`
|
|
|
|
|
|
|
|
|
|
The isolation contract says no inbound path exists — which is one "no" too
|
|
|
|
|
many when you're coding in a box and want its dev server in your browser.
|
|
|
|
|
`box expose` is the deliberate exception:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
box expose work 3000 # http://127.0.0.1:3000 → work:3000
|
|
|
|
|
box expose work 3000 8080 # or pick the host port: 127.0.0.1:8080 → work:3000
|
|
|
|
|
box expose work --list # what doors are open
|
|
|
|
|
box expose work --remove 3000 # close one
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The listen side is **always the host's own loopback** — never the network, no
|
|
|
|
|
flag to widen it — so no other machine gains a path to the box. The in-box
|
|
|
|
|
server must listen on `0.0.0.0`, not its own loopback (safe inside the
|
|
|
|
|
isolation stack: only this door can reach it). A box with a hole says so:
|
|
|
|
|
`box info` lists open exposures. Everything else on the box stays dropped —
|
|
|
|
|
the door is per-port, punched and removable at runtime.
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
## Commands
|
|
|
|
|
|
|
|
|
|
```
|
feat(new): inline resource overrides — --cpu, --memory, --disk (#57)
Resolution is most-specific-first: flag > BOX_* env (kept — it is the
scripting form and how the drill shrinks boxes on small hosts) > the
template's box.env > defaults. Values pass to Incus verbatim (limits.cpu,
limits.memory, root size=) — its units, its validation; box adds no
parser. Resources are all a flag can touch: there is still no flag for a
network or a security.* key, on purpose.
Flags shape a fresh mint only — --from refuses them, a clone carries its
source's resources. An explicit --disk on a container mint gets a note
instead of a silent drop (a container's root rides the pool).
The drill's blank mint now carries --cpu 1 --memory 1GiB and asserts the
limits landed — which is also the precedence proof, since the drill
exports BOX_CPU/BOX_MEMORY on small hosts — plus a negative check that
--from refuses resource flags.
Verified live (container mint, image cached): BOX_CPU=3 + --cpu 1
--memory 1GiB → limits.cpu=1, limits.memory=1GiB; --from + --cpu exits 2
before touching anything; container --disk prints the note.
Closes #57
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:17:33 +00:00
|
|
|
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
|
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 # list the templates this install can mint
|
|
|
|
|
box list # list your boxes
|
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
|
|
|
box info <box> # one box: state, IP, exposures, provenance, snapshots
|
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 <box> # enter as the template's user
|
|
|
|
|
box exec <box> -- <cmd...> # run a command in the box
|
|
|
|
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
|
|
|
|
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
2026-07-19 19:46:06 +00:00
|
|
|
box restore <box> <snap> [--force]
|
|
|
|
|
# roll back to a snapshot — destructive, asks first
|
2026-07-20 10:04:49 +00:00
|
|
|
# 'pristine' is auto-marked at mint: back to
|
|
|
|
|
# pristine Debian + box's seed, before rig ran
|
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' too, if a rig hook converged:
|
|
|
|
|
# the role kept, everything since undone
|
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
|
|
|
box export <box> [<file>] [--instance-only]
|
|
|
|
|
# one portable file (snapshots incl.) — survives rm & host
|
|
|
|
|
box import <file> [--name <box>]
|
|
|
|
|
# mint a box back from an exported file, re-stamped
|
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 rename <box> <new> # rename a box (stop it first)
|
|
|
|
|
box down <box> # stop (state kept; `start` resumes)
|
|
|
|
|
box start <box> # start a stopped box
|
|
|
|
|
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
box expose <box> <port> [<host-port>] | --list | --remove <port>
|
|
|
|
|
# forward a box port to host loopback — see a dev server
|
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 <box> -- <args...> # escape hatch: any incus command, box resolved
|
|
|
|
|
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
box setup-host # one-time host setup: Incus, the boxnet stack, the firewall
|
|
|
|
|
box teardown-host [--purge-incus] # remove the host stack (both name generations)
|
|
|
|
|
box migrate-host --box <n> | --all-boxes | --retire-legacy
|
|
|
|
|
# move a pre-0.4.0 host onto the box stack
|
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 status # deprecated alias for `list`
|
|
|
|
|
box help [<command>] # full help, or one command's page
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
Every command takes `--help`, and options come after the 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 list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
fix: standard help, honest flags, and an `rm` that asks first
The help was `sed -n '2,9p' "$0"` — the script scraping its own header comment
by line number — with no sections, no per-command help, no `-h`, and no
`--version`. Worse, parts of the interface it described weren't real: `--force`
was parsed and never read, so `claudebox rm work` force-deleted a running box
with no confirmation while advertising a flag that implied the bare form was
the careful one.
- usage()/help_cmd(): NAME/USAGE/COMMANDS/OPTIONS/EXAMPLES/EXIT STATUS, plus a
page per command. `claudebox help <cmd>` and `claudebox <cmd> --help`.
- `-h`/`--help` anywhere; `--version`/`-V` (VERSION file).
- Unknown command → stderr, exit 2, and a did-you-mean by edit distance.
- Unknown options are rejected, not swallowed as positionals: `snapshot work
--labl x` no longer names the snapshot "--labl". `--` still passes everything
through for exec.
- `--force` is real: `rm` confirms on a TTY, refuses without one unless forced.
BEHAVIOR CHANGE — a scripted `claudebox rm` now needs `--force`.
- Exit 2 for usage errors, 1 for runtime failures; accurate per-command usage
strings (`exec` no longer omits `-- <cmd...>`).
- shellcheck is clean: SC2034 (dead `force`) and SC2015 both gone.
Closes #8
2026-07-13 20:31:01 +00:00
|
|
|
wrong.
|
|
|
|
|
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
`new` fresh-launches from a template (default: `blank`), or with `--from`
|
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
|
|
|
clones an existing box or snapshot. VM mode (`--vm`, the default where
|
|
|
|
|
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
|
|
|
|
|
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
|
|
|
|
dev/test only.
|
2026-07-10 15:00:36 +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
|
|
|
## What minted this box: `box info`
|
|
|
|
|
|
|
|
|
|
A box outlives the release that minted it, the template that shaped it and the
|
|
|
|
|
image build it came from — and until
|
|
|
|
|
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
|
|
|
|
|
There is no host-side per-box store; the Incus instance config _is_ the
|
|
|
|
|
database, so a fact not written at mint time is simply gone. `box new` now
|
|
|
|
|
stamps what it knew, and `box info` reads it back:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
NAME work
|
|
|
|
|
STATE RUNNING
|
|
|
|
|
TYPE VM
|
|
|
|
|
IPV4 10.x.x.x
|
|
|
|
|
|
|
|
|
|
MINTED 2026-07-19T14:22:07Z by box 0.8.1
|
|
|
|
|
TEMPLATE claude (user claude, role claude)
|
|
|
|
|
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
|
|
|
|
|
MODE vm (asked: auto)
|
|
|
|
|
RIG heavy-duty/rig@main
|
|
|
|
|
ORIGIN mint
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The image line carries both halves on purpose: the template names an
|
|
|
|
|
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
|
|
|
|
|
only reproducible fact. `box info --json` carries every key verbatim — they
|
|
|
|
|
ride `incus list --format json` in `config`.
|
|
|
|
|
|
|
|
|
|
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
|
|
|
|
|
its source's template and user for free — but inheriting the mint stamp would
|
|
|
|
|
not make it stale, it would make it **false**: the clone was not present at that
|
|
|
|
|
mint. `box new --from` therefore re-stamps the four keys that describe _this_
|
|
|
|
|
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
|
|
|
|
|
box version that cloned it) and leaves the lineage keys alone, because the
|
|
|
|
|
clone's disk genuinely did come from that image, template and role. `origin.from`
|
|
|
|
|
records one hop: a clone of a clone names its parent, not its grandparent.
|
|
|
|
|
|
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
|
|
|
**An import records the trip, and rewrites nothing**
|
|
|
|
|
([#131](https://github.com/heavy-duty/box/issues/131)). Everything `incus
|
|
|
|
|
import` restores is the _artifact's_ truth, so an imported box keeps its mint
|
|
|
|
|
stamp verbatim — the mint time, the box version, the image and the origin
|
|
|
|
|
belong to the originating host and survive the trip on purpose. What `box
|
|
|
|
|
import` adds is the one fact the artifact cannot carry: that the trip happened.
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
MINTED 2026-06-01T10:00:00Z by box 0.7.0
|
|
|
|
|
IMPORTED 2026-07-20T09:14:03Z by box 0.8.1 (the mint above predates it)
|
|
|
|
|
ORIGIN clone of work/authed
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
It is **not** `origin=import`, and the difference is the whole point. `origin`
|
|
|
|
|
answers how the instance came into _being_ — mint or clone — and overwriting it
|
|
|
|
|
would destroy that: the clone above 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. The import is a _third_ fact, orthogonal to the first
|
|
|
|
|
two, so it takes its own keys and leaves every other one alone.
|
|
|
|
|
|
|
|
|
|
The `IMPORTED` line sits directly under `MINTED` because that adjacency is what
|
|
|
|
|
stops the mint time being misread as this host's. Note what it does not claim:
|
|
|
|
|
box has no record of _which_ host minted the box, and a box can be exported and
|
|
|
|
|
re-imported onto the same host (that is the upgrade flow above), so the line
|
|
|
|
|
states only the ordering — the one thing box actually knows.
|
|
|
|
|
|
|
|
|
|
**A box can make the trip more than once**, and both ends are kept: the first
|
|
|
|
|
import is pinned forever, the latest is refreshed on every arrival, and a count
|
|
|
|
|
says how many. Last-wins alone would erase the evidence of the earlier trips,
|
|
|
|
|
which is the same mistake `origin=import` makes one level up. (The shape
|
|
|
|
|
follows [heavy-duty/rig#61](https://github.com/heavy-duty/rig/issues/61)'s
|
|
|
|
|
manifest: a birth pair plus a latest pair.)
|
|
|
|
|
|
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
|
|
|
**Boxes minted before this stamp existed keep working**, under this verb and
|
|
|
|
|
every other — they render as a box with blanks and say `MINTED (not recorded)`
|
|
|
|
|
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
|
|
|
|
|
not the box version) so a box minted by a later release reads back on an older
|
|
|
|
|
box as "here is what I understand, and there is more I don't".
|
|
|
|
|
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: claudebox owns a command
when it must enforce an invariant incus cannot see — the user.claudebox=1
boundary, the isolation stack, or the creds-free snapshot workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
## Boxes are just Incus instances
|
|
|
|
|
|
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
|
|
|
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
2026-07-14 18:01:34 +00:00
|
|
|
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and
|
|
|
|
|
the isolation model — not all of Incus. It owns a command
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: claudebox owns a command
when it must enforce an invariant incus cannot see — the user.claudebox=1
boundary, the isolation stack, or the creds-free snapshot workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
when it must enforce something Incus can't see: that tag (it will not stop,
|
|
|
|
|
rename or delete an instance it didn't mint), the isolation stack, or the
|
|
|
|
|
creds-free snapshot workflow. For everything else, there's the door:
|
|
|
|
|
|
|
|
|
|
```sh
|
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 # instance name appended
|
|
|
|
|
box incus work -- file push x.tar {}/tmp/ # or placed with {}
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: claudebox owns a command
when it must enforce an invariant incus cannot see — the user.claudebox=1
boundary, the isolation stack, or the creds-free snapshot workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
|
|
|
|
|
the command is echoed before it runs. If it can move the box off the isolation
|
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
|
|
|
stack (profile, network, device, `security.*`), box warns and proceeds —
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: claudebox owns a command
when it must enforce an invariant incus cannot see — the user.claudebox=1
boundary, the isolation stack, or the creds-free snapshot workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
the trust boundary is then yours to keep. See
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
2026-07-14 17:44:24 +00:00
|
|
|
[docs/box-design.md](docs/box-design.md) for the rule and why the
|
feat: make the command surface a table, add rename and an escape hatch
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: claudebox owns a command
when it must enforce an invariant incus cannot see — the user.claudebox=1
boundary, the isolation stack, or the creds-free snapshot workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes #11
2026-07-13 20:49:46 +00:00
|
|
|
command surface is a table.
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
## Isolation
|
|
|
|
|
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
The contract: **a box reaches the public internet and nothing else.** Not the
|
2026-07-18 13:39:45 +00:00
|
|
|
host, not your LAN, not another box, not even another box's _name_. What
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
enforces it, layer by layer:
|
|
|
|
|
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
- **Dedicated NAT bridge** `boxnet`, IPv6 off. Every rule below is
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
IPv4-only, so IPv6 would be an uncovered path — off is part of the
|
|
|
|
|
contract, not a default.
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
- **`box-isolate` ACL** — drops all egress to private space (RFC1918,
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
CGNAT, link-local), with a single carve-out to the gateway so DNS works.
|
2026-07-18 13:39:45 +00:00
|
|
|
- **Sibling isolation, at L2** — two boxes on one bridge are _switched_,
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
never routed, so no L3 rule can separate them (learned the hard way; see
|
|
|
|
|
below). `security.port_isolation` on every box NIC plus an nft
|
|
|
|
|
bridge-family drop mean box A cannot exchange frames with box B at all.
|
|
|
|
|
- **No name-level reconnaissance** — `dns.mode=none` stops the gateway
|
|
|
|
|
resolving sibling names, and the bridge's resolver is pinned to public
|
|
|
|
|
upstreams (`no-resolv`), so tailnet names and split-DNS zones from a
|
|
|
|
|
host-level VPN don't resolve inside a box either.
|
|
|
|
|
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
|
|
|
|
|
the host's public IPs. Entry is `incus exec` over the local socket only —
|
feat: 'box expose <box> <port>' — a deliberate, loopback-only door to a dev server
The 'no inbound path' contract is one notch too absolute for the tool's
own flagship workflow: coding in a box, a dev server on :3000, and no
way to open it in your browser. expose is the deliberate un-screwing.
- Loopback only, always: the host side listens on 127.0.0.1, never
0.0.0.0 — no other machine can reach the box; only this host gets a
door. No flag widens it (that is the escape hatch's job).
- A verb, per-port, reversible, visible: each exposure is a named proxy
device (expose-<port>); --list and box info show it, --remove undoes
it. A box with a hole says so.
- Mechanism (VMs): an Incus proxy device forwards host loopback to the
box's ip:port, plus a SCOPED ingress ACL allow (this box's ip + this
port only) so the forkproxy's connection survives the default drop —
the drill decides whether that allow is needed or redundant. The
in-box server must listen on 0.0.0.0 (a VM's forwarder reaches it over
the network); inside an isolated box that is safe.
Drill phase E: start a detached listener in a box, expose it, prove the
HOST loopback reaches it, prove a NON-exposed port is still dropped (A7
survives), prove --remove shuts the door.
Closes #55
2026-07-14 16:07:17 +00:00
|
|
|
**no inbound path exists** — unless you punch one with `box expose`, and
|
|
|
|
|
that door only ever opens onto the host's own loopback (`127.0.0.1`), never
|
|
|
|
|
the network.
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
|
2026-07-15 00:15:45 +00:00
|
|
|
The VM is the trust boundary: whatever runs inside — the coding agent, or
|
|
|
|
|
anything a template ships — can run arbitrary code and touch nothing you care
|
|
|
|
|
about.
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
|
|
|
|
|
### Measured, not claimed
|
|
|
|
|
|
|
|
|
|
Every clause above is probed live by an end-to-end drill, because the one time
|
|
|
|
|
this contract was reasoned about instead of measured, the reasoning was wrong:
|
|
|
|
|
box→box traffic was "covered" by an L3 drop that L2-switched frames never
|
|
|
|
|
meet — a hole found by probing, not by reading the rules. On a bare host the
|
docs(readme): 0.5.0 — expose, codex/grok, host verbs, drill at 81/81
The version callout now leads with what 0.5.0 adds (codex+grok templates,
box expose, setup-host/teardown-host/migrate-host as verbs) and keeps
0.4.0's clean-cut terms beneath it. New 'See a dev server' section
documents expose's contract: loopback-only listen, 0.0.0.0 in-box,
per-port, visible in box info. Commands block synced to the actual table
(expose synopsis, host verbs, --remote gone, default template is blank —
the text said claude). Drill paragraph now names the full sweep: every
template cold, the expose door, the legacy re-home.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:55:07 +00:00
|
|
|
drill installs the whole stack, mints every template cold, snapshots and
|
|
|
|
|
clones, probes every boundary from inside the boxes, opens and shuts the
|
|
|
|
|
`expose` door (and checks the contract survives it), re-homes a faithful
|
|
|
|
|
pre-0.4.0 box through `migrate-host`, and removes what it minted —
|
2026-07-15 00:55:10 +00:00
|
|
|
currently **84 checks, 84 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
history, including every trap that fooled a run into a wrong verdict.
|
|
|
|
|
|
2026-07-15 00:28:15 +00:00
|
|
|
### Run the drill yourself
|
|
|
|
|
|
|
|
|
|
The drill ships in the repo, not the installed tree — run it from a checkout.
|
|
|
|
|
Two versions are in play and both must be current: **the drill script you
|
|
|
|
|
run** (a stale checkout judges the past), and **the code under test** — the
|
|
|
|
|
drill does not test your working tree; it installs box from GitHub
|
|
|
|
|
(default: `heavy-duty/box@main`) and asserts the installed tree is exactly
|
|
|
|
|
the ref it asked for before issuing any verdict.
|
|
|
|
|
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
```sh
|
2026-07-15 00:28:15 +00:00
|
|
|
git clone https://github.com/heavy-duty/box && cd box # or refresh an existing
|
|
|
|
|
git log --oneline -1 # checkout — this commit is
|
|
|
|
|
# the drill that will judge
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
|
|
|
|
|
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
|
feat!: rename the host stack too, default to blank, drill the templates, add wipe
Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:
- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
claudenet on 10.87, two bridges must not claim one subnet),
box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
service}. teardown-host now strips BOTH name generations, so one
script uninstalls a host of any age.
- Default template is blank: 'box new --name x' mints bare Debian;
the claude box is '--template claude'. The login hint follows the
EFFECTIVE template read off the instance, so clones of claude boxes
still get it and blank boxes are not told to run a binary they lack.
- The drill validates templates: listing, unknown-template refusal,
the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
default resolves to blank, metadata stamped, box-net placement, exec
lands in 'dev', no claude binary, and isolation parity (egress +
pinned DNS) on the same contract as every template.
- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
every drill-named instance, networks/ACLs/profiles/firewall of both
generations, cached images, and (--purge-storage) the default pool.
Ends by asserting the ABSENCE of every artifact rather than trusting
the removals' exit codes.
2026-07-14 14:36:39 +00:00
|
|
|
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
|
|
|
|
|
# (--purge-storage) the pool, so a run starts from bare
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
```
|
|
|
|
|
|
2026-07-15 00:28:15 +00:00
|
|
|
To drill something other than latest `main` — a release ref, or a PR branch
|
|
|
|
|
on a fork:
|
|
|
|
|
|
|
|
|
|
```sh
|
|
|
|
|
bash drill/drill.sh --ref <branch-or-tag>
|
|
|
|
|
bash drill/drill.sh --repo <owner>/<repo> --ref <branch> # a PR under review
|
|
|
|
|
```
|
|
|
|
|
|
docs(readme): the isolation contract as it ships — and measured, not claimed
The Isolation section predated the #16 arc: it described the ACL and the
host firewall, and none of what that audit shipped — port-isolated NICs
(the actual sibling boundary), dns.mode=none, the pinned resolver. It
also never mentioned that the contract is tested at all.
State the contract layer by layer as it exists on main, and add a
'Measured, not claimed' section: the drill and doctor, what each does,
which one mutates the host, and the standing score — 47/47 from a bare
host (run 13, full teardown → rebuild → drill).
2026-07-14 12:47:09 +00:00
|
|
|
The doctor reads ground truth, not config claims — the kernel's `isolated on`
|
|
|
|
|
flag per bridge port, the process table, the resolver actually in use — and
|
|
|
|
|
diagnoses the host faults that have actually happened: a wedged Incus daemon,
|
|
|
|
|
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
|
|
|
|
|
inherit.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
2026-07-14 18:01:34 +00:00
|
|
|
## Recipes: the `.box/` convention
|
2026-07-10 15:00:36 +00:00
|
|
|
|
2026-07-14 18:01:34 +00:00
|
|
|
A repo that wants to be easy to stand up in a box ships an optional `.box/`
|
2026-07-15 00:15:45 +00:00
|
|
|
folder — a runbook the box's coding agent reads and follows (install deps,
|
|
|
|
|
start services, template env, seed data, smoke-test). It is agent-facing
|
|
|
|
|
documentation, not a host-executed script. See
|
|
|
|
|
[docs/box-recipe.md](docs/box-recipe.md).
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
## Uninstall
|
|
|
|
|
|
2026-07-18 16:02:37 +00:00
|
|
|
`box uninstall` is the real uninstall, and it runs in the safe order — boxes
|
|
|
|
|
first, then the stack, then the tree — and **ends with an absence assert**:
|
|
|
|
|
every path it removed is re-checked, and any survivor makes it exit 1 naming
|
|
|
|
|
the leftovers instead of reporting a clean uninstall that wasn't (the same
|
|
|
|
|
discipline as `box revoke --purge`).
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
```sh
|
2026-07-18 16:02:37 +00:00
|
|
|
box uninstall <version> # one non-current version (side-by-side cleanup)
|
|
|
|
|
box uninstall --all --purge-host # everything: teardown-host (all boxes, the
|
|
|
|
|
# boxnet stack, the firewall), then every
|
|
|
|
|
# version, the symlinks, legacy claudebox crumbs
|
|
|
|
|
box uninstall # just the install — refuses while boxes exist
|
|
|
|
|
# (and names them); run teardown-host first,
|
|
|
|
|
# or use --purge-host
|
2026-07-10 15:00:36 +00:00
|
|
|
```
|
|
|
|
|
|
2026-07-18 16:02:37 +00:00
|
|
|
The full-removal order on a multi-user host: `box revoke <user> --purge` each
|
|
|
|
|
granted user (it asserts its own zero-residue, including the incus-user state
|
|
|
|
|
under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus`
|
|
|
|
|
to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then
|
|
|
|
|
`box uninstall`. CI drills exactly this sequence and asserts zero residue —
|
|
|
|
|
no networks, profiles, nft tables, systemd units, files or symlinks.
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
## Non-goals
|
|
|
|
|
|
|
|
|
|
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
|
2026-07-15 00:15:45 +00:00
|
|
|
the agent). Reproducible-by-construction provisioning is out of scope.
|
2026-07-10 15:00:36 +00:00
|
|
|
- **No credential storage or injection by the tool.** Boxes are creds-free;
|
|
|
|
|
snapshots are the reuse mechanism, not a secrets store.
|