Commit graph

284 commits

Author SHA1 Message Date
dan-claude-bot
437a3a8e35 test+ci: add CI workflow and a dependency-free test suite
box had no CI and no unit tests — only the live-host drill. Mirror rig's CI:
one `check` job = globstar `shellcheck -x` over bin/* and **/*.sh, then
`bash test/cli.sh`. The suite is dependency-free and runs non-root with no
Incus: the full CLI contract; install.sh's DEST/BINDIR branch driven
functionally against a shim `id` (both tiers + the BOX_HOME/BOX_BIN overrides);
the root-only a+rX and #66's confirm/no-op flow grep-guarded; tmux asserted in
every template. Pre-existing repo shellcheck findings (bin/box SC2034/SC2015/
SC2020, and file-level SC2015 idioms in doctor.sh/wipe.sh/migrate-host.sh) were
resolved — real fixes where behaviour allows, reasoned disables otherwise — so
the new CI is green over the whole repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:01:15 +00:00
dan-claude-bot
9849021b6a feat: global install (#71) and tmux in templates (#65)
#71 — run as root, box installs once to /opt/box (world-readable) with a
/usr/local/bin shim, so every operator on a shared host has `box` on PATH
instead of `command not found`. Per-user ($HOME/.local) install is
byte-identical to before; BOX_HOME/BOX_BIN still override. A per-user tree
under /root is 0700 and unreadable to everyone else — the whole fleet got
`command not found` — so the root branch lands in a system location and
chmod -R a+rX's it, guarded on root. This is the path rig installs box through
(rig#24's `box` role).

#65 — `box tmux` runs `tmux new-session` inside the box, but the templates did
not install tmux, so it failed with `tmux: command not found`. tmux is now in
each template's cloud-init package list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:01:15 +00:00
claude-bot-andresmgsl
d0a14a18a5 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
claude-bot-andresmgsl
043e556027 Give box-firewall.service RemainAfterExit so its state means something
Found by running the drill on a real host, which I could not do before.

The unit is Type=oneshot with no RemainAfterExit, so systemd marks it
'inactive (dead)' the moment ExecStart succeeds. The rules are applied and the
box-to-box drop is live, and the unit still reads as though it died. That is
precisely the question people ask this unit: drill.sh's own failure hint sends
you to 'systemctl status box-firewall.service' to find out whether the firewall
came up, and today the honest answer and the alarming one look identical.

setup-host.sh already believed this was set — 'The unit is RemainAfterExit, so
once it has run it stays "active" forever' — and reasoned from it to explain why
it uses restart instead of 'enable --now'. The reasoning is right and the
restart is right; only the unit was missing the line the comment assumed.

Verified live: before, 'nft list table bridge box' showed the drop present while
is-active said inactive. After, is-active says active (exited) with the drop
still present, and restart still re-applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:05:23 +00:00
claude-bot-andresmgsl
3e1143f1d3 Refuse to change versions under existing boxes; file the migration as #67
Per @danmt on #66: hatch first, the version-aware migration as its own issue.

Building the host stack from the installer means an upgrade is no longer a tree
swap — it reaches under every box attached to that stack. So the installer now
declines to guess. Same version and ref: it says so and changes nothing.
Version or ref change with boxes on the host: it refuses, lists them, and does
so BEFORE $DEST is touched, so a refusal leaves the working install intact. No
boxes: nothing to lose, proceed. BOX_FORCE_UPGRADE=1 overrides, and the drill
sets it, because arriving on a dirty host and wiping it is the drill's job.

Ref, not just VERSION: a branch and main carry the same VERSION string, so
VERSION alone would call an install of this very branch "unchanged" and skip the
hatch. Both tag generations count as boxes — a pre-rename user.claudebox=1 box
is just as much someone's work as a current one.

The box query runs unprivileged first and escalates only if the socket refuses:
anyone who owns boxes is already in incus-admin, and an installer should not
demand a sudo password merely to look.

The error deliberately does not suggest snapshot -> rm -> restore --from: 'box
rm' deletes a box AND every snapshot it has, so that path loses the data at the
rm. It says to copy anything needed out of the box first. Raised on #67.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:34:53 +00:00
claude-bot-andresmgsl
ce3a0c5076 Make setup-host privilege-aware; make the drill prove the new contract
Review found two real problems, both confirmed by reproducing them.

setup-host hardcoded 'sudo' for every privileged call, so install.sh's
deliberate root branch — the one that proceeds when id -u is 0 even with no
sudo installed — handed off to a script that died on 'sudo: command not found'
before doing anything (exit 127, reproduced with env -i and a minimal PATH).
The root path was nominal, not real. Privilege is now resolved once: nothing at
UID 0, sudo otherwise, a clear error if neither is possible.

Two things fell out of that. Root does not need incus-admin at all (UID 0 opens
the socket regardless), so adding root to the group was a no-op that also missed
the human — under 'sudo install.sh' that is SUDO_USER, who is now the one
granted the group. And apt must not hang: install.sh runs setup-host with nobody
watching, while a fresh cloud image holds the dpkg lock in apt-daily for its
first minutes, so the calls are now bounded and non-interactive.

The drill did not exercise any of this. It ran setup-host immediately after
install.sh, so the stack existed by the drill's own hand and a run passed
identically whether or not install.sh had done a thing — a fresh run converged
three times while its messages still described the pre-#63 "first pass may only
add you to the group" behaviour. It now asserts the post-install stack in-group,
before the clean or anything else mutates the host, which is the assertion that
actually proves #64. setup-host then runs exactly once more, after the clean —
that one is load-bearing, since the clean deliberately unsets dns.mode and
something has to converge it back. DRILL_OWNS_SETUP=1 hands sequencing back to
the drill. Pre-setup tripwires now read before install.sh, because install.sh is
what triggers setup now; read afterwards they said nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:16:06 +00:00
claude-bot-andresmgsl
aad576a86a 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
Daniel Marin
84da78a2c1
Merge pull request #62 from claude-hdb/fix/doctor-fresh-host
fix(doctor): a fresh host is not a dirty one
2026-07-15 01:57:47 +01:00
42c1f63c95 fix(doctor): a fresh host is not a dirty one
A bare host got two DIRTYs (the box-to-box drop 'MISSING', the host's
Tailscale resolver) and a 'NOT fit to mint (or to drill)' verdict — and
the very next drill run went 84/84 green from that exact state. Missing
from a stack and never set up are different findings: the network,
profile and ACL sections already knew this; the firewall and resolver
sections now do too. FRESH (no boxnet) downgrades both to information —
the VPN resolver is still named, as a fact about the host that setup-host
pins around, not a fault in a stack that does not exist. The clean
verdict on a fresh host now says what to actually do: run setup-host, or
the drill, which sets the host up itself.

Verified both paths live: standing stack → 'clean', post-teardown →
'fresh' with no DIRTYs.

Also: the measured drill count is 84 (README said 83 — the box-info
exposure check was a NOTE when last counted and is a PASS now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:55:10 +00:00
Daniel Marin
6c3ff72a3a
Merge pull request #61 from claude-hdb/fix/install-extract-dir
fix(install): the rename broke every install — stop guessing the tarball's top dir
2026-07-15 01:39:39 +01:00
ca2be71f88 fix(install): find THE extracted directory instead of guessing its name
The tarball's top dir is <repo>-<ref>, and the extractor globbed
claudebox-* — dead the moment the repo became heavy-duty/box: every
'curl install.sh | bash' and every drill died with 'could not find the
extracted source directory'. The archive has exactly one top-level
directory; take it whatever it is called, and let the existing bin/box
check judge whether it is the right tree. Survives the next rename too.

Verified against the live tarball: BOX_HOME/BOX_BIN scratch install from
heavy-duty/box@main lands and 'box --version' answers 0.5.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:38:51 +00:00
Daniel Marin
71ba6e8782
Merge pull request #60 from claude-hdb/docs/readme-run-the-drill
docs(readme): how to run the drill against the latest version
2026-07-15 01:29:13 +01:00
342b533f4f docs(readme): how to run the drill against the latest version
The drill section showed the commands but not the step that actually
bites: making sure the checkout you run is the code you mean to judge.
Two versions are in play — the drill script itself, and the (repo, ref)
the drill installs from GitHub and asserts before any verdict. Spell both
out, plus --repo/--ref for drilling a release or a PR branch.

Also catch drill.sh's REPO default up with the rename — it still said
heavy-duty/claudebox (GitHub redirects it, but the default should name
the repo that exists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:28:15 +00:00
Daniel Marin
018e16ec40
Merge pull request #58 from claude-hdb/docs/agnostic-readme
docs: make the box narrative agent-agnostic, not Claude-specific
2026-07-15 01:24:44 +01:00
claude-hdb
aff68f5492 docs(box-design): update isolation-stack names to the 0.4.0 rename
The Snapshots, ownership-rule, and Isolation sections still named the
pre-0.4.0 stack (claudenet/claude-dev/claude-isolate) while the rest of the
repo — README, host/setup-host.sh, host/box-firewall.sh — uses boxnet/box-net/
box-isolate. Rename the doc references to match ground truth: claudenet→boxnet,
claude-dev→box-net (profile), claude-isolate→box-isolate (ACL). Stale wording
only; the mechanism described is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:23:58 +00:00
Daniel Marin
1cbdfb05dd
Merge pull request #59 from claude-hdb/feat/new-resource-flags
feat(new): inline resource overrides — --cpu, --memory, --disk
2026-07-15 01:22:57 +01:00
2c567f0549 docs(readme): drill count follows the two checks this branch adds
83 checks once the resource-flag assertions land; the '81 passing' claim
belongs to the last green run and the next one re-earns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:21:44 +00:00
claude-hdb
9fb2b8147c docs: extend the agent-agnostic reframe to design, recipe, and drill docs
Same treatment as the README, applied to the prose that stood in "Claude" for
"the coding agent": box-recipe.md and box-design.md now describe the `.box/`
runbook and creds-free flow around whichever agent the box was minted with, and
name the per-template context file (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`,
`~/.grok/AGENTS.md`) instead of hardcoding Claude's. drill/README.md's
"does not check" note says the drill confirms each template's CLI, not just
Claude Code. `claude` stays as the concrete login example throughout.

Left untouched (out of scope, literal identifiers): the legacy isolation-stack
names (claudenet/claude-dev/claude-isolate, user.claudebox), the claude template
files themselves, and drill/RUNS.md history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:21:22 +00:00
85e70a326a docs: add CHANGELOG, starting at 0.5.0
One entry for now — 0.5.0 as merged plus the inline resource flags that
fold into it. Pre-0.5.0 history stays in git and drill/RUNS.md, which
this points at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:21:18 +00:00
b90372da62 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
claude-hdb
244a8f69a2 docs(readme): make the narrative agent-agnostic, not Claude-specific
The README told the story around Claude Code even though the tool ships
`codex` and `grok` templates and treats every coding agent the same. Reframe
the generic prose — the intro, creds-free line, `.box/` runbook, quick start,
snapshot copy, isolation boundary, recipes, and non-goals — to speak of "the
coding agent" while keeping `claude` as a named, concrete example (it's still
where the project started). No behavior or command changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 00:15:45 +00:00
Daniel Marin
4890c9a3d9
Merge pull request #56 from claude-hdb/integration/0.5.0
release: box 0.5.0 — codex+grok templates, migrate-host, box expose
2026-07-15 01:04:53 +01:00
b1c430c448 chore(templates): debrand the claude template's description too
Same straggler phrase the README carried — the instance description is
operator-visible in 'incus list', so it follows the rename as well.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:00:15 +00:00
794c81c520 docs: debrand the last project-name references — the repo becomes heavy-duty/box
README title, install URL, and the claude-template blurb now use the
post-rename name; install.sh's REPO default follows (GitHub redirects the
pre-rename URLs, and BOX_REPO still overrides). The two survivors are
literal legacy artifact names that must keep their spelling: the pre-0.4.0
user.claudebox=1 tag and the old claudebox symlink the installer retires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:59:34 +00:00
75acd5c84d 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
edf8309f99 fix(expose): accept established flows back from boxnet — the input drop was eating the door's replies
Reproduced the drill's E failure on a live stack (Incus 6.0.4, container
box, same setup-host/box-firewall): tcpdump on boxnet shows the SYN
leaving masqueraded as the gateway and the box answering SYN/ACK
instantly — which then dies at the host's input hook. The inet-box input
chain dropped ALL boxnet input except DNS/DHCP, stateless: the reply to
the very connection the door opened. UFW hosts never had this hole
(before.rules accepts RELATED,ESTABLISHED); the nft fallback now matches
that semantics with a ct state established,related accept ahead of the
drop. Boxes still cannot INITIATE toward the host — a box-originated SYN
is a NEW flow, which is what the drop is for.

Also rebuild the chains on every run (add chain + flush + re-add) instead
of skip-if-present: the existence guard pinned every host to the rule set
of the release that first ran there, so an upgraded rule never landed.

Verified end-to-end on the repro stack: curl 127.0.0.1:18091 → HTTP 200;
box→host initiation still times out; DNS carve-out intact; non-exposed
port still dropped; --remove kills the door; re-expose returns 200.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:13:25 +00:00
32bb203ddb fix(expose): install the loopback door's missing half — route_localnet + masquerade on boxnet
The drill's E phase moved one layer down: the device now adds, but
127.0.0.1:<hport> never reaches the box. Incus's NAT-mode proxy installs
only the DNAT (prerouting + output); a loopback-sourced packet then dies
twice — the kernel refuses to route it out a non-loopback interface
without route_localnet on the bridge, and the box would reply to its OWN
127.0.0.1 without a masquerade. This is the exact plumbing Docker
installs on docker0 for '-p 127.0.0.1y'.

box-firewall.sh now sets route_localnet=1 on boxnet and masquerades
loopback-sourced traffic leaving it (chain expose-snat, table inet box).
route_localnet's known risk — 127/8 becomes a routable destination on
the bridge — is covered by the existing iifname-boxnet input drop, which
fires regardless of destination address. The no-UFW guard now checks the
input CHAIN, not the table, since expose-snat shares the table.

expose warns (root-free, via /proc) when the host firewall predates this
plumbing instead of handing over a door that silently does not answer,
and 'box info' now lists open exposures — the drill's nice-to-have: a
box with a hole says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:55:10 +00:00
44b9d512db fix(expose): pin the boxnet lease as static — NAT proxy resolves connect=0.0.0.0 against ipv4.address, not the lease
The drill's E phase failed with `Instance has no static IPv4 address
assigned to be used as the connect IP`: Incus NAT-mode proxy devices
read the NIC's static ipv4.address device config, never the neighbour
table — the previous comment claimed otherwise. First cut pinned the
wrong address (docker0's), second cut removed the pin instead of
correcting it; this pins the box's current boxnet lease (same address
it already holds) before adding the device, and unpins when the last
exposure is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:31:40 +00:00
claude-hdb
a5d54e4b70 fix(expose): it was pointing the proxy at docker0 — the drill's own oldest trap
Incus finally said it, once the drill stopped swallowing the error:

  Connect IP "172.17.0.1" must be one of the instance's static IPv4 addresses

172.17.0.1 is DOCKER0. box_ipv4() returns whatever Incus lists first, and
a box running docker lists docker0 first — so expose has been aiming the
proxy at the wrong interface all along. This is drill trap 4, verbatim
('docker0 (172.17.x) is the decoy'), which the drill has known since run
4 and the CLI never learned. Now bin/box has its own box_net_ip(): the
address ON boxnet, with the prefix derived from the network rather than
hardcoded.

And the connect address is now the wildcard 0.0.0.0: in NAT mode Incus
resolves the instance's own current address off the bridge's neighbour
table. Naming an address makes it demand a *static* one — the very
demand that produced the error, for an address that was wrong anyway.
Ask Incus for less and it finds the box itself.
2026-07-14 20:45:09 +00:00
claude-hdb
e4b546cd29 fix: expose asks for too much, and the fake legacy box had no claude user
Run 18: 74/2. grok passes (reading the installer worked), migrate's
retire passes. Two left, both mine:

1. box expose — read the Incus proxy docs instead of guessing again.
   Proxy IS supported on VMs, but NAT mode only (correct already), and
   crucially it does NOT need a static address: with no static IP, Incus
   reads the box's address off the bridge's neighbour table and keeps
   the NAT rules in step. My first cut pinned the lease with a device
   override anyway — unnecessary, and almost certainly the step that was
   failing before the proxy was ever added. Dropped it. Ask for less.

   The drill also stopped throwing incus's reason away: it swallowed
   stderr, then RE-RAN the command and printed only the last line ('box:
   expose failed') — box's own words, never incus's. It now captures the
   first attempt and prints all of it.

2. 'legacy box never came up' was unfixable by any timeout. wait_box
   uses 'box exec', which for a legacy-tagged box resolves the user to
   'claude' — and the drill's synthetic legacy box was a BARE image with
   no claude user, so sudo -u claude could never answer, on a box that
   was perfectly healthy (every migration check against it passed). The
   fake legacy box now creates a claude user, like a real pre-0.4.0 box
   had.
2026-07-14 20:30:01 +00:00
claude-hdb
8729c3e522 fix(grok): read the actual installer instead of guessing — the binary is 'grok'
Fetched https://x.ai/cli/install.sh and read it, rather than inferring
the layout from docs. Three facts, every one of which the template had
wrong:

  · the CLI installs as 'grok' (with an 'agent' alias) — NOT 'grok-build'.
    So the drill was checking a command that never existed.
  · BIN_DIR defaults to $HOME/.grok/bin, and what lands there is a
    SYMLINK into the versioned download dir — which is exactly why the
    template's 'find -type f' found nothing.
  · GROK_BIN_DIR can override the directory.

The install was almost certainly succeeding the whole time; the template
was hunting for the wrong name, as the wrong file type, in the wrong
place. Now it links the known path onto the system PATH, asserts
'grok --version' answers, and dumps what the installer actually left if
the upstream layout ever moves.

Also corrected: the drill's version check (grok, not grok-build), the
agent briefing ('grok login'), the template description, and the README
row. The lesson is the repo's oldest one — read the thing, don't reason
about it.
2026-07-14 20:04:10 +00:00
claude-hdb
6f7c3bfd60 fix: run 17's four real findings — migrate retire, expose proxy, wait_box, grok PATH
The first drill run where every failure was the RELEASE CODE, not the
environment. 71 passed, 5 failed; all five traced to four bugs:

1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS
   user.box=1 but never removed user.claudebox=1, and legacy_boxes()
   counted the old tag — so retire saw its own freshly-migrated box as
   un-migrated and refused forever ('legacy boxes still exist:
   legacybox'), leaving claudenet + claude-dev behind. Now: a verified
   re-home drops the legacy tag LAST (after the move is proven, so a
   failure anywhere above still leaves the box valid under one tag or
   the other), and legacy_boxes() ignores boxes already carrying
   user.box=1.

2. box expose died with a bare 'could not add the proxy device' — it
   swallowed incus's reason, exactly the sin this repo keeps punishing.
   Now it prints incus's error. And the mechanism is corrected: a VM's
   proxy needs NAT mode, which requires a static NIC address, so expose
   pins the box's current lease first (which also fixes the restart
   caveat — the exposure no longer points at a lease the box may lose).

3. wait_box's 2-minute window was too short: the legacy box was declared
   dead and then every migration check against it passed. 4 minutes.

4. The grok template hunted for a regular file named exactly
   'grok-build' under /home/grok and found nothing — an installer's drop
   may be a SYMLINK, and its binary name is upstream's to choose. Now it
   tries the plausible names and paths, falls back to any executable
   grok*, links both names, and SAYS what it found — or dumps what the
   installer actually left when it finds nothing. The drill likewise
   dumps the on-disk evidence and the cloud-init log on a --version
   failure instead of discarding the box.
2026-07-14 19:56:17 +00:00
claude-hdb
c31fbc63f7 fix: name the boot failure — a corrupt image, Secure Boot, or a GRUB hang
Wall 2 is solved: 'EFI stub: Failed to decompress kernel' was a CORRUPT
IMAGE — the --purge-storage re-download produced a bad blob. Deleting
the cached image and re-pulling booted the box immediately. The storage
pool was innocent (1.29GiB used of 30GiB).

Both walls cost hours to diagnose by hand. The next box to hit them
should be told the answer, not the symptom — so wait_agent now reads
the console log and names the failure:

  · 'Failed to decompress kernel' -> the cached image is corrupt; here
    is the incus image delete command to re-pull it
  · 'bad shim signature' -> Secure Boot rejected the kernel (shouldn't
    happen now; box mints with security.secureboot=false)
  · GRUB/firmware menu -> never booted; re-pull or pin BOX_IMAGE
2026-07-14 19:31:41 +00:00
claude-hdb
e09e4b62ee fix: assert the install landed the ref we asked for — a silent wrong-install is worse than a failure
The 0.5.0 env-var rename (CLAUDEBOX_* -> BOX_*) created a silent trap:
a STALE local drill.sh passes CLAUDEBOX_REPO/REF, today's install.sh
reads BOX_REPO/REF, the vars are ignored, main gets installed — and the
drill runs to a green summary having drilled the wrong tree entirely.
The same class already cost an hour once via a lagged CDN tarball.

install.sh now records what it installed (/INSTALLED_FROM), and the
drill ASSERTS it matches the requested repo@ref before touching the
host — failing loudly, and naming the stale-checkout cause, instead of
drilling a lie.
2026-07-14 19:10:48 +00:00
claude-hdb
455fbc656e feat: host lifecycle as verbs (setup-host/teardown-host/migrate-host) + .box/ convention
Two things:

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

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

Also widened the help command column for the longer verb names, and
fixed one sed-casualty where a broad '.claudebox'→'.box' pass had
turned the README's legacy user.claudebox tag into user.box.
2026-07-14 18:01:34 +00:00
claude-hdb
4eb6b35a7b 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
claude-hdb
912e0621ca fix: disable Secure Boot on box VMs — 'bad shim signature' hung every mint at GRUB
The console log finally showed the real error behind the GRUB-menu hang:

  error: prohibited by secure boot policy.
  error: bad shim signature.
  Failed to boot both default and fallback entries.

Incus defaults VMs to security.secureboot=true. A Debian cloud image
whose shim is signed with a key this host's OVMF does not trust then
fails signature verification, the kernel never loads, and the VM sits
at the GRUB menu forever — which is exactly the 5-min agent timeout on
every box. It worked in runs 11–15 on the old cached image and broke
the moment --purge-storage re-downloaded a build with a different shim.

security.secureboot=false on VM launch (cmd_new, and the drill's legacy
box). Secure Boot inside a throwaway box is not part of its threat
model — the VM boundary is — and off, it boots reliably across image
rebuilds. Container mode has no firmware and is unaffected.

Bare repro that isolated it: 'incus launch images:debian/13/cloud x --vm'
alone reproduced the hang, proving it was never the 0.5.0 code.
2026-07-14 17:05:39 +00:00
claude-hdb
b9480fd9ce fix: strip whole escape sequences from the console dump, and name the GRUB hang
The first sanitize stripped only the ESC byte, leaving visible '[1m[37m'
halves as noise. Strip full CSI/escape sequences first (while ESC is
present), then residual control bytes — clean text.

And read the log: a box sitting at 'GNU GRUB / Press enter to boot /
UEFI Firmware Settings' never booted — that is the IMAGE, not box. Say
so, and point at re-pulling the image or pinning BOX_IMAGE. Surfaced
running the 0.5.0 drill after --purge-storage re-downloaded a
debian/13/cloud build that hangs at the GRUB menu on the serial console.
2026-07-14 16:56:52 +00:00
claude-hdb
c4cc9f43d1 fix: sanitize the console dump (no more scrambled terminal) + tear down failed mints
Two bugs surfaced running the 0.5.0 drill with --purge-storage on a
cold btrfs pool:

1. wait_agent dumped the VM's RAW console log on timeout — full of
   terminal escape sequences and a firmware menu — which scrambled the
   operator's terminal, and doubly so when it landed in a log they were
   tail -f'ing ('*Debian GNU/Linux', 'ESC to return previous menu',
   ^[^[^[). Now: capture to /tmp/box-console-<n>.log, strip everything
   but printable ASCII + tab/newline, print only a short sanitized tail.
   Nothing raw reaches a terminal.

2. A failed mint left its stuck VM running, starving the NEXT box's boot
   and cascading more 5-min timeouts (tpl failed → codex failed). Every
   mint-failure branch now tears the box down before continuing.
2026-07-14 16:38:54 +00:00
claude-hdb
5deef69621 chore: VERSION → 0.5.0 (templates, migrate-host, expose) 2026-07-14 16:13:08 +00:00
claude-hdb
449d750eb5 Merge remote-tracking branch 'fork/feat/expose' into integration/0.5.0 2026-07-14 16:12:53 +00:00
claude-hdb
05287c96d7 Merge remote-tracking branch 'fork/feat/migrate-host' into integration/0.5.0
# Conflicts:
#	drill/drill.sh
2026-07-14 16:12:53 +00:00
claude-hdb
de6467a728 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
claude-hdb
c9712834f2 feat: host/migrate-host.sh — re-home legacy boxes onto the new stack, then retire it
The 0.4.0 transition is zero-ceremony (install + setup-host = a
dual-stack host). This script is the two things that path does not do:

- --box <name> / --all-boxes: re-home a pre-rename box onto the new
  stack, PRESERVING its authed state (no re-login). Order is
  load-bearing — tag first (additive, reversible), profile-assign last
  (the network move), then verify the box actually resolves + reaches
  the internet on its 10.88 leg before declaring it migrated. A box
  never ends up tagless or profileless.
- --retire-legacy: remove claudenet/claude-dev/claude-isolate and the
  old firewall unit + nft tables, but REFUSE while any legacy box still
  references them; assert their absence rather than trust exit codes.

Drill phase M builds a faithful legacy stack (claudenet on 10.87, a
claude-dev profile pinned to it, a box on the old tag), then proves:
retire refuses with a legacy box present, re-home flips the tag +
reassigns box-net + lands a 10.88 address + resolves, and retire then
succeeds and leaves nothing. The transition is measured, not asserted.

Closes #53
2026-07-14 16:02:27 +00:00
claude-hdb
c6bb6cb0e0 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
Daniel Marin
9b3522e4ee
Merge pull request #52 from claude-hdb/feat/box-rename
feat!: claudebox becomes box — the Claude box is one template among several
2026-07-14 16:44:10 +01:00
claude-hdb
c033a26979 fix: a watched mint must move — unbuffer the dots, name the log after the box
Operator watched /tmp/new.log through a claude mint and saw not one
message: cloud-init's progress dots are block-buffered the moment
stdout is not a tty, so a redirected mint shows nothing for the whole
install and then one burst — which reads exactly like a hang, on the
very night three real hangs happened.

PYTHONUNBUFFERED=1 on the cloud-init wait makes the dots arrive as
dots; box new also prints how to watch the box's own full narration
(incus exec <box> -- tail -f /var/log/cloud-init-output.log); and the
drill's logs are named for the box being minted (/tmp/mint-drill.log),
not for the verb that mints it.
2026-07-14 15:34:08 +00:00
claude-hdb
0c87911cb1 fix: give the agent five minutes, and ship the console log when it never comes
Run 14: the blank mint — the FIRST VM launch on the fresh btrfs pool,
which unpacks the image into a pool volume and takes the coldest boot —
died at wait_agent's 3-minute window ('Processes: -1' well past it),
while the identical claude mint sixty seconds later booted in the warm
path and passed in 96s. The window was tuned on a dir pool with a
cached, unpacked image.

150×2s now, and on failure box new prints the VM's console log tail
before dying — this run's evidence was torn down with the box before
anyone could read it.
2026-07-14 15:28:25 +00:00
claude-hdb
06d4589b6a feat(drill): a heartbeat during every mint — silence must never mean 'maybe wedged'
All four mints (blank, claude, clone, peer) now run through mint_box:
box new's narration lands in the log as before, the drill prints where
to tail it, and a dot every 5s on the drill's own terminal proves the
run is alive. A silent multi-minute mint is indistinguishable from a
wedge, and that ambiguity has cost whole evenings — the operator said
so, verbatim.
2026-07-14 15:21:28 +00:00
claude-hdb
ce4d2b6006 fix: pin stdin on every non-interactive exec in the CLI — a mint wedged at 'status: done'
Run 14's second catch: the blank mint's cloud-init finished, printed
'status: done' — and 'box new' hung for 15+ minutes on an exec session
that never closed ('incus operation list' showed it still RUNNING).
With a TTY on stdin (the drill redirects only stdout/stderr), incus
exec goes interactive, and the session can wedge open after the remote
command has exited. Same disease as drill trap 2 and doctor trap 13;
the CLI's own execs never got the cure.

Every non-interactive exec now pins stdin: wait_agent's probe, the
cloud-init wait, both failure-path reads, and the clone identity
reset. shell/exec/tmux keep the terminal — owning it is their job.

Also: wipe.sh keeps cached images on a plain wipe. An image is
upstream's artifact, content-addressed by fingerprint — deleting it
buys zero cleanliness and costs the next mint a full re-download. It
goes only with --purge-storage, where the pool it lives in goes too
(and it must go first: images block pool deletion).
2026-07-14 15:07:01 +00:00