Commit graph

165 commits

Author SHA1 Message Date
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
claude-hdb
cdd8b703eb fix: box-net's NIC still pointed at claudenet — the drill caught it in seconds
The profile rename changed the file's name, header and limits but not
the device's 'network:' field; the claudenet→boxnet sed covered
host/*.sh only. On a wiped host (no claudenet to silently latch onto)
'incus profile edit box-net' refused the YAML and setup died — run 14's
first catch, before a single box was minted. The sweep this fix rode in
on found exactly one other stale reference, in the same file's comment.
2026-07-14 14:43:43 +00:00
claude-hdb
5defd40bca 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
claude-hdb
c11f3d7552 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
Daniel Marin
ffdfb52512
Merge pull request #51 from claude-hdb/fix/storage-btrfs
fix: create the storage pool deliberately — btrfs, because cloning is the whole point
2026-07-14 14:24:32 +01:00
Daniel Marin
154f10e336
Merge pull request #50 from claude-hdb/feat/doctor-verb
feat: 'claudebox doctor' — the host-health checks as a first-class verb
2026-07-14 14:24:17 +01:00
Daniel Marin
7e03f88c6f
Merge pull request #49 from claude-hdb/feat/tmux-verb
feat: 'claudebox tmux <box> [session]' — a shell that survives you
2026-07-14 14:23:58 +01:00
claude-hdb
c0611e5f0b fix: create the storage pool deliberately — btrfs, because cloning is the whole point
'incus admin init --minimal' picks the 'dir' backend, which has no
copy-on-write: every snapshot and clone is a full copy of the box's
root — several GB and minutes apiece once Docker, Node and Claude Code
are installed. Measured live: one clone took minutes, and a single
drill run pays that three times (snapshot + two clones). The workflow
the tool exists for — log in once, snapshot, clone forever — stops
being attractive at exactly that price.

setup-host.sh now bootstraps via a preseed that mirrors what --minimal
creates (default pool, incusbr0, default profile with root + eth0) with
only the driver deliberate: btrfs on a loop device (CoW — clones share
blocks and diverge on write), installing btrfs-progs if absent, falling
back to dir with a warning if btrfs cannot be had. Existing hosts are
untouched: the block is skipped whenever a 'default' pool exists.

Closes #29
2026-07-14 13:14:26 +00:00
claude-hdb
16a4c129fc feat: 'claudebox doctor' — the host-health checks as a first-class verb
Every fault the drill's doctor diagnoses is a user's fault first: a
wedged Incus daemon, a dnsmasq that silently isn't serving, a VPN
resolver boxes inherit, isolation claimed by config but off in the
kernel — each has killed a cold mint or weakened a boundary, with a
cloud-init error that names none of them. The CLI half-admitted it:
cmd_new's failure path hand-pointed at issue #33, doing one special
case of a doctor's job inline.

The verb delegates to the installed drill/doctor.sh (the tree ships
whole) — one hardened script, two audiences. Its flags pass through;
the verdict now reads 'fit to mint boxes (and to drill)'; the mint-
failure hint ends with 'claudebox doctor' instead of the hand-rolled
diagnosis.

Closes #46
2026-07-14 13:13:18 +00:00
claude-hdb
eacb1a9e28 feat: 'claudebox tmux <box> [session]' — a shell that survives you
'shell' is a child of the exec connection: drop the terminal and a long
Claude run inside it is SIGHUP'd — which has already cost one run. tmux
is installed in every box but relying on remembering to start it is how
that run was lost.

New verb, not a changed default: 'tmux new-session -A -s <session>'
attaches when the session exists and creates it when it doesn't, so
starting work and reattaching after a disconnect are the same command.
The session name (default: main) gives parallel streams in one box.
'shell' stays bare on purpose — two verbs, two contracts — so tmux's
costs (nesting, scrollback semantics, exit-vs-detach) are only paid
when asked for.

Closes #6
2026-07-14 13:10:59 +00:00
Daniel Marin
f241b1f27a
Merge pull request #48 from claude-hdb/docs/runs-11-13
docs(drill): record runs 11–13 — the contract measured at zero, from a bare host
2026-07-14 14:01:41 +01:00
Daniel Marin
6525d7f3c4
Merge pull request #47 from claude-hdb/docs/readme-measured-isolation
docs(readme): the isolation contract as it ships — and measured, not claimed
2026-07-14 14:01:29 +01:00
claude-hdb
bcc641beff docs(drill): record runs 11–13 — the contract measured at zero, from a bare host
The log stopped at run 10's headline (boxes reach each other). Three
runs later the arc is closed and RUNS.md should say so:

- run 11 (45/2): A3 re-answered with the fixed probe — BLOCKED,
  security.port_isolation; both FAILs were the drill's own empty-host
  assumption
- run 12 (46/0): first zero-failure run
- run 13 (47/0): full teardown → rebuild → drill from a bare host,
  pinned resolver built from scratch on a Tailscale host

Answer table updated (A3/A4 show fixed + re-measured state), headline
flipped to match, traps 13–15 added (TTY stdin under timeout, the
gateway-ping false alarm, demanding an empty host), run history gains
the three rows and a closing note.
2026-07-14 12:52:02 +00:00
claude-hdb
15fe15b23f 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
Daniel Marin
7df9dafc40
Merge pull request #45 from claude-hdb/fix/pin-claudenet-resolver
fix: pin claudenet's resolver — a box's DNS is not a function of the host's VPN
2026-07-14 13:22:47 +01:00
claude-hdb
a2c0758ba6 fix: pin claudenet's resolver — a box's DNS is not a function of the host's VPN
The bridge's dnsmasq forwarded to whatever sat in the host's
/etc/resolv.conf at that moment. On a Tailscale host that is MagicDNS:
box DNS flapped with the tailnet (killing cold mints), and tailnet peer
names and split-DNS zones resolved from inside a box — name-level
reconnaissance of a private network, the same class as the sibling
enumeration dns.mode=none already closes.

setup-host.sh now sets raw.dnsmasq to no-resolv + pinned public
upstreams (BOX_DNS overrides the default 1.1.1.1 8.8.8.8), answering
the issue's three open questions from live measurement: raw.dnsmasq is
the lever (no first-class upstream key on the bridge; verified by
doctor --pin-dns followed by a box resolving), upstreams are a setting
with a sane default, and the pin is unconditional.

The doctor's unpinned-state messages now point at setup-host.sh as the
durable fix, keeping --pin-dns as the quick test.

Closes #33
2026-07-14 12:15:45 +00:00
Daniel Marin
7efc8a171d
Merge pull request #44 from claude-hdb/fix/drill-shared-host
fix(drill): stop demanding an empty host — assert OUR boxes, not NO boxes
2026-07-14 13:12:06 +01:00
claude-hdb
730d7da54e fix(drill): stop demanding an empty host — assert OUR boxes, not NO boxes
Run 11: 45 passed, 2 failed — and both failures were the same non-fact.
The host had two operator boxes (t1, t2) minted before the run, so
'claudebox list' could not say 'no boxes yet' at either end of the drill.

The empty-host message is only testable on an actually-empty host: skip
it (with a note) when tenants pre-exist, instead of failing it.

The teardown check contradicted its own rm loop: the loop deliberately
removes only the names the drill minted — the multi-tenant discipline —
and then the assertion demanded a globally empty host, flagging that
very restraint as a failure. Assert the drill's own names are gone;
name the survivor when one is.
2026-07-14 12:09:44 +00:00
Daniel Marin
c1542c2f34
Merge pull request #43 from claude-hdb/fix/doctor-gateway-ping
fix(doctor): the gateway does not answer ping — by design, so stop asking
2026-07-14 12:59:26 +01:00
claude-hdb
2c03624013 fix(doctor): the gateway does not answer ping — by design, so stop asking
claudebox-firewall.sh drops everything from a box to the host except
DNS (53) and DHCP (67); ICMP to 10.87.0.1 dies in that trailing drop on
every healthy host. The doctor used exactly that ping as its routing
probe, so it reported 'cannot even reach the gateway' — and a NOT-fit-
to-drill verdict — on a host whose very next line proved DNS working
through that same gateway.

Probe routing the way the contract states it: a box reaches the public
internet. curl to 1.1.1.1 by address, reusing the one probe for the
DNS-failure diagnosis instead of running it twice.
2026-07-14 11:58:36 +00:00