release: box 0.5.0 — codex+grok templates, migrate-host, box expose #56

Merged
dan-claude-bot merged 23 commits from integration/0.5.0 into main 2026-07-15 00:04:54 +00:00
dan-claude-bot commented 2026-07-14 16:13:37 +00:00 (Migrated from github.com)

The 0.5.0 integration branch: three features, each already reviewed as its own PR into integration/0.5.0 on the fork (#2 templates · #3 migrate · #4 expose), merged and version-bumped here. Do not merge to main until the drill is green on this branch (commands below).

What's in it

  • codex + grok templates (#54) — the Claude box is now one of four. templates/codex/ (npm i -g @openai/codex, Node 22) and templates/grok/ (official x.ai/cli/install.sh), each mirroring the claude template's shape and creds-free contract. Install commands verified upstream at authoring time.
  • host/migrate-host.sh (#53) — the pre-0.4.0 → box transition: --box/--all-boxes re-home legacy boxes onto the new stack preserving authed state (no re-login), --retire-legacy removes the old stack once empty. Order is load-bearing (tag first, profile last, verify the effect).
  • box expose <box> <port> (#55) — a deliberate, loopback-only door to a box's dev server. Per-port, reversible, visible in box info; the network-facing "no inbound path" contract stays true. ⚠️ The highest-risk change — the Incus proxy-on-VM + ACL mechanism is validated by the drill, not by reasoning; expect it may need one iteration if phase E is red.

VERSION → 0.5.0. Legacy user.claudebox=1 boxes keep working under every verb, as since 0.4.0.

Verify before merging — drill this branch

The drill self-installs from the ref you pass, so point it at this branch. On a throwaway host you can format:

# fresh checkout of the drill (or reuse an existing ~/claudebox)
git clone https://github.com/heavy-duty/claudebox && cd claudebox
git fetch origin pull/56/head:rel-0.5.0 && git checkout rel-0.5.0 

# scorched earth first (strips both stack generations; --purge-storage also
# rebuilds the pool so #51's btrfs bootstrap is exercised):
bash drill/wipe.sh --purge-storage

# the full drill against THIS branch:
bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0

Expect the ~59 base checks plus the new phases:

  • Templates — listing shows four; a per-template smoke mints codex and grok and confirms <cli> --version answers via box exec (the #15 PATH bug is the risk).
  • Phase M (migration) — builds a faithful legacy stack (claudenet/10.87 + claude-dev + a user.claudebox=1 box), then proves: retire refuses while a legacy box exists, re-home flips the tag + reassigns box-net + lands a 10.88 address + resolves, retire then succeeds and leaves nothing.
  • Phase E (expose) — starts a detached listener in a box, exposes it, proves the host loopback reaches it, proves a non-exposed port is still dropped (A7 survives), proves --remove shuts the door.

Budget ~40 min (four extra cold mints: codex, grok, the legacy box, plus the existing claude/blank/clone/peer). Heartbeat dots mark every mint; tail -f /tmp/mint-<box>.log watches one live.

If phase E (expose) is the only red, that's the known-uncertain mechanism — comment the phase-E output and it gets one iteration on the expose commit before this merges. Templates and migration I'd expect green first try.

After green

Merge to main (autodeploys nothing — it's a CLI repo), tag v0.5.0. Existing hosts upgrade with the installer one-liner; box expose and the new templates are immediately available, and migrate-host.sh is there for anyone still on a pre-0.4.0 stack.

🤖 Generated with Claude Code

The **0.5.0** integration branch: three features, each already reviewed as its own PR into `integration/0.5.0` on the fork ([#2](https://github.com/claude-hdb/claudebox/pull/2) templates · [#3](https://github.com/claude-hdb/claudebox/pull/3) migrate · [#4](https://github.com/claude-hdb/claudebox/pull/4) expose), merged and version-bumped here. **Do not merge to `main` until the drill is green on this branch** (commands below). ## What's in it - **codex + grok templates** (#54) — the Claude box is now one of four. `templates/codex/` (`npm i -g @openai/codex`, Node 22) and `templates/grok/` (official `x.ai/cli/install.sh`), each mirroring the claude template's shape and creds-free contract. Install commands verified upstream at authoring time. - **`host/migrate-host.sh`** (#53) — the pre-0.4.0 → box transition: `--box`/`--all-boxes` re-home legacy boxes onto the new stack **preserving authed state** (no re-login), `--retire-legacy` removes the old stack once empty. Order is load-bearing (tag first, profile last, verify the effect). - **`box expose <box> <port>`** (#55) — a deliberate, **loopback-only** door to a box's dev server. Per-port, reversible, visible in `box info`; the network-facing "no inbound path" contract stays true. ⚠️ **The highest-risk change** — the Incus proxy-on-VM + ACL mechanism is validated by the drill, not by reasoning; expect it may need one iteration if phase E is red. VERSION → 0.5.0. Legacy `user.claudebox=1` boxes keep working under every verb, as since 0.4.0. ## Verify before merging — drill this branch The drill self-installs from the ref you pass, so point it at this branch. On a **throwaway host you can format**: ```sh # fresh checkout of the drill (or reuse an existing ~/claudebox) git clone https://github.com/heavy-duty/claudebox && cd claudebox git fetch origin pull/56/head:rel-0.5.0 && git checkout rel-0.5.0 # scorched earth first (strips both stack generations; --purge-storage also # rebuilds the pool so #51's btrfs bootstrap is exercised): bash drill/wipe.sh --purge-storage # the full drill against THIS branch: bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0 ``` Expect the ~59 base checks **plus** the new phases: - **Templates** — listing shows four; a per-template smoke mints codex and grok and confirms `<cli> --version` answers via `box exec` (the #15 PATH bug is the risk). - **Phase M (migration)** — builds a faithful legacy stack (claudenet/10.87 + claude-dev + a `user.claudebox=1` box), then proves: retire refuses while a legacy box exists, re-home flips the tag + reassigns `box-net` + lands a 10.88 address + resolves, retire then succeeds and leaves nothing. - **Phase E (expose)** — starts a detached listener in a box, exposes it, proves the **host loopback reaches it**, proves a **non-exposed port is still dropped** (A7 survives), proves `--remove` shuts the door. Budget ~40 min (four extra cold mints: codex, grok, the legacy box, plus the existing claude/blank/clone/peer). Heartbeat dots mark every mint; `tail -f /tmp/mint-<box>.log` watches one live. **If phase E (expose) is the only red**, that's the known-uncertain mechanism — comment the phase-E output and it gets one iteration on the expose commit before this merges. Templates and migration I'd expect green first try. ## After green Merge to `main` (autodeploys nothing — it's a CLI repo), tag `v0.5.0`. Existing hosts upgrade with the installer one-liner; `box expose` and the new templates are immediately available, and `migrate-host.sh` is there for anyone still on a pre-0.4.0 stack. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-14 17:35:58 +00:00 (Migrated from github.com)

Drill status — boot-path issues found (not in the 0.5.0 code)

Drilling this branch on the staging host surfaced a chain of VM boot failures. Every one reproduced with a bare incus launch (no box, no template), so none of them are the templates/migrate/expose features — they're the VM boot path, and the fixes belong to the release only because it's the thing being drilled. Recording them here so the next run (and the next host) has the map.

Symptom

Every mint hung at the GRUB/firmware menu and timed out at wait_agent (5 min). The VM console log (once sanitized — see below) told the real story in two layers:

Wall 1 — Secure Boot rejects the kernel:

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; the re-downloaded images:debian/13/cloud ships a shim this host's OVMF keys don't trust. Fixed in this branchsecurity.secureboot=false on box VMs (cmd_new + the drill's legacy box). Safe for a disposable box: the VM boundary is the trust boundary, not the guest's bootloader; there's no persistence surface to protect.

Wall 2 — kernel won't decompress (behind Wall 1):

EFI stub: ERROR: Failed to decompress kernel
error: image not loaded.

Seen only after Secure Boot was off (confirmed security.secureboot: "false"). Two candidate causes, both environmental: too little RAM for EFI-stub decompression (the bare test used the ~1 GiB default profile; real boxes get 3 GiB), or a corrupted image from the --purge-storage re-download.

Two things that also came out of it (both fixed in this branch)

  • wait_agent's console dump was raw — it scrambled the operator's terminal, doubly so through tail -f. Now sanitized to a file (/tmp/box-console-<box>.log), full escape sequences stripped, and it names a GRUB hang in plain English.
  • A failed mint left its stuck VM running, starving the next box's boot into a cascade of false 5-min timeouts. Every failure branch now tears the box down first.
  • A heartbeat dot every 5s per mint, and logs named /tmp/mint-<box>.log, so a slow mint is never mistaken for a wedge again.

Verification commands (run on the drill host)

Confirm the installed box actually carries the fix (a stale/CDN-lagged install was one red herring — the drill installs from the ref you pass, and install.sh defaults CLAUDEBOX_REPO to upstream, so the fork needs it explicit):

CLAUDEBOX_REPO=claude-hdb/claudebox CLAUDEBOX_REF=integration/0.5.0 \
  bash -c "$(curl -fsSL https://raw.githubusercontent.com/claude-hdb/claudebox/integration/0.5.0/install.sh)"
grep secureboot ~/.local/share/claudebox/bin/box    # must print the security.secureboot line

Isolate boot from box entirely — a bare VM reproduces (or clears) the walls with no box involved:

# Wall 1 (secureboot): default launch reproduces "bad shim signature"; with the flag it clears
incus launch images:debian/13/cloud bare --vm --config security.secureboot=false --config limits.memory=3GiB
sleep 90
incus exec bare -- true && echo ">>> BOOTED" || \
  incus console bare --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -15
incus config get bare security.secureboot          # confirm the flag applied → false
incus delete -f bare 2>/dev/null

If it still fails at 3 GiB with "Failed to decompress kernel", re-pull the image (rules out a corrupt --purge-storage download):

fp="$(incus image list --format csv -c f | head -1)"
incus image delete "$fp"      # then relaunch bare — it re-downloads

Read a stuck box's console safely at any time (raw --show-log scrambles the terminal):

incus console <box> --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -25

Re-drill

bash drill/wipe.sh                                   # plain (keeps pool+images; images are fine)
bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0

Avoid --purge-storage unless re-validating the pool bootstrap — it forces the image re-download that started this. The btrfs pool + resolver pin were already validated in run 14.

Open: confirming Wall 2 is RAM-vs-corrupt-image. If it turns out the current images:debian/13/cloud build is broken in-environment, the durable fix is pinning BOX_IMAGE (known-good build, or images:debian/12/cloud) per template — a one-line-per-template change that would land here before this merges.

## Drill status — boot-path issues found (not in the 0.5.0 code) Drilling this branch on the staging host surfaced a chain of **VM boot failures**. Every one reproduced with a bare `incus launch` (no box, no template), so **none of them are the templates/migrate/expose features** — they're the VM boot path, and the fixes belong to the release only because it's the thing being drilled. Recording them here so the next run (and the next host) has the map. ### Symptom Every mint hung at the GRUB/firmware menu and timed out at `wait_agent` (5 min). The VM console log (once sanitized — see below) told the real story in two layers: **Wall 1 — Secure Boot rejects the kernel:** ``` 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`; the re-downloaded `images:debian/13/cloud` ships a shim this host's OVMF keys don't trust. **Fixed in this branch** — `security.secureboot=false` on box VMs (`cmd_new` + the drill's legacy box). Safe for a disposable box: the VM boundary is the trust boundary, not the guest's bootloader; there's no persistence surface to protect. **Wall 2 — kernel won't decompress (behind Wall 1):** ``` EFI stub: ERROR: Failed to decompress kernel error: image not loaded. ``` Seen only after Secure Boot was off (confirmed `security.secureboot: "false"`). Two candidate causes, both environmental: too little RAM for EFI-stub decompression (the bare test used the ~1 GiB default profile; real boxes get 3 GiB), or a **corrupted image** from the `--purge-storage` re-download. ### Two things that also came out of it (both fixed in this branch) - `wait_agent`'s console dump was raw — it scrambled the operator's terminal, doubly so through `tail -f`. Now sanitized to a file (`/tmp/box-console-<box>.log`), full escape sequences stripped, and it names a GRUB hang in plain English. - A failed mint left its stuck VM running, starving the next box's boot into a **cascade** of false 5-min timeouts. Every failure branch now tears the box down first. - A heartbeat dot every 5s per mint, and logs named `/tmp/mint-<box>.log`, so a slow mint is never mistaken for a wedge again. ### Verification commands (run on the drill host) Confirm the installed box actually carries the fix (a stale/CDN-lagged install was one red herring — the drill installs from the ref you pass, and `install.sh` defaults `CLAUDEBOX_REPO` to upstream, so the fork needs it explicit): ```sh CLAUDEBOX_REPO=claude-hdb/claudebox CLAUDEBOX_REF=integration/0.5.0 \ bash -c "$(curl -fsSL https://raw.githubusercontent.com/claude-hdb/claudebox/integration/0.5.0/install.sh)" grep secureboot ~/.local/share/claudebox/bin/box # must print the security.secureboot line ``` Isolate boot from box entirely — a bare VM reproduces (or clears) the walls with no box involved: ```sh # Wall 1 (secureboot): default launch reproduces "bad shim signature"; with the flag it clears incus launch images:debian/13/cloud bare --vm --config security.secureboot=false --config limits.memory=3GiB sleep 90 incus exec bare -- true && echo ">>> BOOTED" || \ incus console bare --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -15 incus config get bare security.secureboot # confirm the flag applied → false incus delete -f bare 2>/dev/null ``` If it still fails at 3 GiB with "Failed to decompress kernel", re-pull the image (rules out a corrupt `--purge-storage` download): ```sh fp="$(incus image list --format csv -c f | head -1)" incus image delete "$fp" # then relaunch bare — it re-downloads ``` Read a stuck box's console safely at any time (raw `--show-log` scrambles the terminal): ```sh incus console <box> --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -25 ``` ### Re-drill ```sh bash drill/wipe.sh # plain (keeps pool+images; images are fine) bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0 ``` Avoid `--purge-storage` unless re-validating the pool bootstrap — it forces the image re-download that started this. The btrfs pool + resolver pin were already validated in run 14. **Open:** confirming Wall 2 is RAM-vs-corrupt-image. If it turns out the current `images:debian/13/cloud` build is broken in-environment, the durable fix is pinning `BOX_IMAGE` (known-good build, or `images:debian/12/cloud`) per template — a one-line-per-template change that would land here before this merges.
dan-claude-bot commented 2026-07-14 18:27:35 +00:00 (Migrated from github.com)

⚠️ Corrected commands — the debrand moved the paths and env vars

The verification block in my earlier comment is out of date: 0.5.0 finished the rename, so ~/.local/share/claudebox~/.local/share/box, CLAUDEBOX_*BOX_*, and the host scripts are verbs now. Use these instead.

Confirm the installed box carries the fixes

install.sh defaults BOX_REPO to upstream, so the fork needs it explicit. (The curl URL still says claude-hdb/claudebox — that's the repo name, which hasn't been renamed yet.)

BOX_REPO=claude-hdb/claudebox BOX_REF=integration/0.5.0 \
  bash -c "$(curl -fsSL https://raw.githubusercontent.com/claude-hdb/claudebox/integration/0.5.0/install.sh)"
grep secureboot ~/.local/share/box/bin/box    # NEW path — must print the security.secureboot line

The installer also sweeps the old ~/.local/share/claudebox tree on upgrade, so after this only ~/.local/share/box exists.

Isolate boot from box (bare VM — no box, no template)

incus launch images:debian/13/cloud bare --vm --config security.secureboot=false --config limits.memory=3GiB
sleep 90
incus exec bare -- true && echo ">>> BOOTED" || \
  incus console bare --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -15
incus config get bare security.secureboot     # confirm the flag applied → false
incus delete -f bare 2>/dev/null

If it still fails at 3 GiB with "Failed to decompress kernel", re-pull the image (rules out a corrupt --purge-storage download):

fp="$(incus image list --format csv -c f | head -1)"; incus image delete "$fp"   # then relaunch bare

Host setup / teardown are verbs now (not script paths)

box setup-host                 # was ~/.local/share/claudebox/host/setup-host.sh
box teardown-host              # was …/host/teardown-host.sh
box doctor                     # host health

Re-drill (unchanged flags; the drill self-installs from the ref)

bash drill/wipe.sh                                   # plain — keeps pool+images
bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0

Avoid --purge-storage unless re-validating the pool bootstrap — it forces the image re-download that started the boot saga.

Read the safe console idiom (raw --show-log scrambles the terminal):

incus console <box> --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -25

Still open: whether "Failed to decompress kernel" is low RAM (bare test used the ~1 GiB default profile; real boxes get 3 GiB) or a corrupt image download. The bare-at-3-GiB test above decides it.

## ⚠️ Corrected commands — the debrand moved the paths and env vars The verification block in my earlier comment is **out of date**: 0.5.0 finished the rename, so `~/.local/share/claudebox` → `~/.local/share/box`, `CLAUDEBOX_*` → `BOX_*`, and the host scripts are verbs now. Use these instead. ### Confirm the installed box carries the fixes `install.sh` defaults `BOX_REPO` to upstream, so the fork needs it explicit. (The curl URL still says `claude-hdb/claudebox` — that's the **repo** name, which hasn't been renamed yet.) ```sh BOX_REPO=claude-hdb/claudebox BOX_REF=integration/0.5.0 \ bash -c "$(curl -fsSL https://raw.githubusercontent.com/claude-hdb/claudebox/integration/0.5.0/install.sh)" grep secureboot ~/.local/share/box/bin/box # NEW path — must print the security.secureboot line ``` The installer also sweeps the old `~/.local/share/claudebox` tree on upgrade, so after this only `~/.local/share/box` exists. ### Isolate boot from box (bare VM — no box, no template) ```sh incus launch images:debian/13/cloud bare --vm --config security.secureboot=false --config limits.memory=3GiB sleep 90 incus exec bare -- true && echo ">>> BOOTED" || \ incus console bare --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -15 incus config get bare security.secureboot # confirm the flag applied → false incus delete -f bare 2>/dev/null ``` If it still fails at 3 GiB with "Failed to decompress kernel", re-pull the image (rules out a corrupt `--purge-storage` download): ```sh fp="$(incus image list --format csv -c f | head -1)"; incus image delete "$fp" # then relaunch bare ``` ### Host setup / teardown are verbs now (not script paths) ```sh box setup-host # was ~/.local/share/claudebox/host/setup-host.sh box teardown-host # was …/host/teardown-host.sh box doctor # host health ``` ### Re-drill (unchanged flags; the drill self-installs from the ref) ```sh bash drill/wipe.sh # plain — keeps pool+images bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0 ``` Avoid `--purge-storage` unless re-validating the pool bootstrap — it forces the image re-download that started the boot saga. **Read the safe console idiom** (raw `--show-log` scrambles the terminal): ```sh incus console <box> --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -25 ``` **Still open:** whether "Failed to decompress kernel" is low RAM (bare test used the ~1 GiB default profile; real boxes get 3 GiB) or a corrupt image download. The bare-at-3-GiB test above decides it.
dan-claude-bot commented 2026-07-14 19:11:10 +00:00 (Migrated from github.com)

🛑 Correction: the drill command was incomplete — and the gap was dangerous

The drill snippet I posted above was missing the mandatory first step: update the local checkout. That omission is not cosmetic, because of the 0.5.0 env-var rename:

  • A stale local drill.sh (pre-0.5.0) passes CLAUDEBOX_REPO / CLAUDEBOX_REF to install.sh.
  • Today's install.sh reads BOX_REPO / BOX_REF and ignores the old names.
  • So it falls back to its defaults and installs heavy-duty/claudebox@main — while the run reports "Installing box (claude-hdb/claudebox@integration/0.5.0)" and proceeds to drill the wrong tree to a green summary.

That's the same silent-wrong-install class that already burned an hour via a lagged CDN tarball. So:

The accurate sequence

cd ~/claudebox
git fetch origin pull/56/head && git reset --hard FETCH_HEAD   # ← REQUIRED: gets the new drill/wipe
bash drill/wipe.sh                                             # plain (keeps pool + images)
bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0

And the drill now refuses to drill a lie

install.sh records what it installed ($DEST/INSTALLED_FROM), and the drill asserts it matches the requested repo@ref before touching the host. If they disagree it fails immediately and names the cause:

drill: FATAL — asked to install claude-hdb/claudebox@integration/0.5.0, but the tree says 'heavy-duty/claudebox@main'.
  Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*, which today's
  install.sh ignores, so it fell back to main). Fix:
    git fetch origin && git checkout <the branch you mean> && git pull

A green run can no longer mean "drilled the wrong code."

(Everything else in the corrected-commands comment above stands: BOX_* env vars, ~/.local/share/box paths, box setup-host / box doctor verbs.)

## 🛑 Correction: the drill command was incomplete — and the gap was dangerous The drill snippet I posted above was missing the **mandatory first step: update the local checkout**. That omission is not cosmetic, because of the 0.5.0 env-var rename: - A **stale** local `drill.sh` (pre-0.5.0) passes `CLAUDEBOX_REPO` / `CLAUDEBOX_REF` to `install.sh`. - Today's `install.sh` reads **`BOX_REPO` / `BOX_REF`** and ignores the old names. - So it falls back to its defaults and installs **`heavy-duty/claudebox@main`** — while the run reports "Installing box (claude-hdb/claudebox@integration/0.5.0)" and proceeds to drill **the wrong tree to a green summary**. That's the same silent-wrong-install class that already burned an hour via a lagged CDN tarball. So: ### The accurate sequence ```sh cd ~/claudebox git fetch origin pull/56/head && git reset --hard FETCH_HEAD # ← REQUIRED: gets the new drill/wipe bash drill/wipe.sh # plain (keeps pool + images) bash drill/drill.sh --repo claude-hdb/claudebox --ref integration/0.5.0 ``` ### And the drill now refuses to drill a lie `install.sh` records what it installed (`$DEST/INSTALLED_FROM`), and the drill **asserts it matches the requested `repo@ref`** before touching the host. If they disagree it fails immediately and names the cause: ``` drill: FATAL — asked to install claude-hdb/claudebox@integration/0.5.0, but the tree says 'heavy-duty/claudebox@main'. Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*, which today's install.sh ignores, so it fell back to main). Fix: git fetch origin && git checkout <the branch you mean> && git pull ``` A green run can no longer mean "drilled the wrong code." *(Everything else in the corrected-commands comment above stands: `BOX_*` env vars, `~/.local/share/box` paths, `box setup-host` / `box doctor` verbs.)*
dan-claude-bot commented 2026-07-14 19:25:59 +00:00 (Migrated from github.com)

Boot Wall 2 — it is NOT low RAM. Next diagnostic (copy-pasteable)

Confirmed on the drill host: with security.secureboot=false AND limits.memory=3GiB, a bare VM (no box, no template) still dies at:

EFI stub: ERROR: Failed to decompress kernel
EFI stub: ERROR: efi_stub_entry() failed!
error: image not loaded.

Secure Boot (Wall 1) is genuinely fixed — we're past bad shim signature. And the install is confirmed good (box-install: done (claude-hdb/claudebox@integration/0.5.0), and grep secureboot ~/.local/share/box/bin/box prints the flag). So the kernel blob itself is bad, which leaves two suspects:

  1. A corrupt image--purge-storage forced a re-download of images:debian/13/cloud.
  2. A bad storage pool — that same flag rebuilt the btrfs-on-loop pool from scratch. A full or undersized pool corrupts blobs in exactly this way, and it would explain why this only started after --purge-storage.

Run this — checks storage, re-pulls the image, then isolates image-vs-host with a Debian 12 control

incus delete -f bare 2>/dev/null

# 0. IS THE POOL FULL / TINY? A starved btrfs loop pool corrupts blobs exactly like this.
incus storage info default
df -h /

# 1. Re-pull debian/13 — rules out a corrupt download
for fp in $(incus image list --format csv -c f); do incus image delete "$fp" 2>/dev/null; done
incus launch images:debian/13/cloud bare --vm --config security.secureboot=false --config limits.memory=3GiB
sleep 90
if incus exec bare -- true 2>/dev/null; then
  echo ">>> BOOTED after re-pull — the download was corrupt. Re-drill."
else
  echo ">>> debian/13 STILL fails on a fresh pull. Control test with debian/12:"
  incus delete -f bare 2>/dev/null
  incus launch images:debian/12/cloud bare12 --vm --config security.secureboot=false --config limits.memory=3GiB
  sleep 90
  if incus exec bare12 -- true 2>/dev/null; then
    echo ">>> debian/12 BOOTS — so the debian/13 build is broken. Fix = pin BOX_IMAGE."
  else
    echo ">>> debian/12 ALSO fails — the HOST/POOL is the problem, not the image:"
    incus console bare12 --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -12
  fi
  incus delete -f bare12 2>/dev/null
fi
incus delete -f bare 2>/dev/null

What each outcome means

Outcome Diagnosis Fix
BOOTED after re-pull The --purge-storage download was corrupt Nothing to change — re-drill
debian/12 BOOTS The current images:debian/13/cloud build is broken in-environment Pin BOX_IMAGE per template (debian/12, or a known-good 13 build) — one line each, lands in this branch
debian/12 ALSO fails The host/pool is at fault, most likely the fresh btrfs loop pool Rebuild the pool with a proper size; templates untouched

Watch the incus storage info default / df -h output regardless — if that pool is near-full or tiny, we have the answer before the tests even finish.

Reminder that none of this is the 0.5.0 feature code: every failure reproduces with a plain incus launch, no box involved. The release content (templates, migrate-host, expose) still has not been exercised, because nothing boots yet.

## Boot Wall 2 — it is NOT low RAM. Next diagnostic (copy-pasteable) Confirmed on the drill host: with **`security.secureboot=false` AND `limits.memory=3GiB`**, a bare VM (no box, no template) still dies at: ``` EFI stub: ERROR: Failed to decompress kernel EFI stub: ERROR: efi_stub_entry() failed! error: image not loaded. ``` Secure Boot (Wall 1) is genuinely fixed — we're past `bad shim signature`. And the install is confirmed good (`box-install: done (claude-hdb/claudebox@integration/0.5.0)`, and `grep secureboot ~/.local/share/box/bin/box` prints the flag). So the **kernel blob itself is bad**, which leaves two suspects: 1. **A corrupt image** — `--purge-storage` forced a re-download of `images:debian/13/cloud`. 2. **A bad storage pool** — that same flag rebuilt the btrfs-on-loop pool from scratch. A full or undersized pool corrupts blobs in exactly this way, and it would explain why this only started after `--purge-storage`. ### Run this — checks storage, re-pulls the image, then isolates image-vs-host with a Debian 12 control ```sh incus delete -f bare 2>/dev/null # 0. IS THE POOL FULL / TINY? A starved btrfs loop pool corrupts blobs exactly like this. incus storage info default df -h / # 1. Re-pull debian/13 — rules out a corrupt download for fp in $(incus image list --format csv -c f); do incus image delete "$fp" 2>/dev/null; done incus launch images:debian/13/cloud bare --vm --config security.secureboot=false --config limits.memory=3GiB sleep 90 if incus exec bare -- true 2>/dev/null; then echo ">>> BOOTED after re-pull — the download was corrupt. Re-drill." else echo ">>> debian/13 STILL fails on a fresh pull. Control test with debian/12:" incus delete -f bare 2>/dev/null incus launch images:debian/12/cloud bare12 --vm --config security.secureboot=false --config limits.memory=3GiB sleep 90 if incus exec bare12 -- true 2>/dev/null; then echo ">>> debian/12 BOOTS — so the debian/13 build is broken. Fix = pin BOX_IMAGE." else echo ">>> debian/12 ALSO fails — the HOST/POOL is the problem, not the image:" incus console bare12 --show-log 2>/dev/null | tr -cd '\11\12\40-\176' | tail -12 fi incus delete -f bare12 2>/dev/null fi incus delete -f bare 2>/dev/null ``` ### What each outcome means | Outcome | Diagnosis | Fix | | --- | --- | --- | | `BOOTED after re-pull` | The `--purge-storage` download was corrupt | Nothing to change — re-drill | | `debian/12 BOOTS` | The current `images:debian/13/cloud` build is broken in-environment | Pin `BOX_IMAGE` per template (debian/12, or a known-good 13 build) — one line each, lands in this branch | | `debian/12 ALSO fails` | The **host/pool** is at fault, most likely the fresh btrfs loop pool | Rebuild the pool with a proper size; templates untouched | Watch the `incus storage info default` / `df -h` output regardless — if that pool is near-full or tiny, we have the answer before the tests even finish. **Reminder that none of this is the 0.5.0 feature code**: every failure reproduces with a plain `incus launch`, no box involved. The release content (templates, migrate-host, expose) still has not been exercised, because nothing boots yet.
dan-claude-bot commented 2026-07-14 23:13:50 +00:00 (Migrated from github.com)

E-phase failure: found, reproduced live, fixed (edf8309)

I stood up the full stack (setup-host + box-firewall + a box on boxnet) on a scratch Incus host and reproduced the exact drill failure. tcpdump on boxnet during the failing curl:

IP 10.88.0.1.55102 > 10.88.0.30.8091: Flags [S]     ← SYN leaves, masqueraded as the gateway
IP 10.88.0.30.8091 > 10.88.0.1.55102: Flags [S.]    ← the box answers instantly
(SYN retransmits forever — the SYN/ACK never reaches curl)

So the route_localnet + masquerade plumbing from 32bb203 works — the packet that dies is the reply, and it dies at our own hand: the inet box input chain drops ALL boxnet input except DNS/DHCP, stateless. The box's SYN/ACK arrives at the host as input on boxnet and is eaten by the very firewall that guards A7. UFW hosts never had this hole (ufw's before.rules accept RELATED,ESTABLISHED); the nft fallback — what the drill host runs — did.

Fix: ct state established,related accept ahead of the drop, plus the chains are now rebuilt (add+flush+re-add) on every run instead of skip-if-present, so upgraded rules actually land on hosts that already have the table. Boxes still cannot initiate toward the host — a box-originated SYN is a NEW flow, which is what the drop is for.

Verified end-to-end on the repro stack: curl 127.0.0.1:18091HTTP 200 in 2 ms; box→host initiation still times out; DNS carve-out intact; a non-exposed port is still dropped; --remove shuts the door; re-expose answers again.

Copy-pastable probe (fast — container box, no VM mint; ~1 min)

From the repo checkout on the drill host, on this branch:

set -x
sudo bash host/box-firewall.sh                      # apply the new rules (idempotent)
sudo nft list chain inet box input                  # expect: ct state established,related accept ABOVE the drop
incus launch images:debian/13 probe -p default -p box-net
incus config set probe user.box=1 user.box.user=root
sleep 5
incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &'
sleep 2
./bin/box expose probe 8091 18091
curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/   # expect HTTP 200
curl -sS -m 3 -o /dev/null http://"$(incus list probe -c 4 -f csv | sed 's/ .*//')":9099/ ; echo "non-exposed port rc=$? (28 = still dropped, good)"
./bin/box expose probe --remove 8091
curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)"
./bin/box rm probe --force
set +x

Or just re-run the drill — its preamble re-runs setup-host, which restarts box-firewall with the new chain.

🤖 Generated with Claude Code

## E-phase failure: found, reproduced live, fixed (`edf8309`) I stood up the full stack (setup-host + box-firewall + a box on boxnet) on a scratch Incus host and reproduced the exact drill failure. tcpdump on `boxnet` during the failing curl: ``` IP 10.88.0.1.55102 > 10.88.0.30.8091: Flags [S] ← SYN leaves, masqueraded as the gateway IP 10.88.0.30.8091 > 10.88.0.1.55102: Flags [S.] ← the box answers instantly (SYN retransmits forever — the SYN/ACK never reaches curl) ``` So the route_localnet + masquerade plumbing from `32bb203` works — the packet that dies is the **reply**, and it dies at our own hand: the `inet box input` chain drops ALL boxnet input except DNS/DHCP, **stateless**. The box's SYN/ACK arrives at the host as input on boxnet and is eaten by the very firewall that guards A7. UFW hosts never had this hole (ufw's before.rules accept RELATED,ESTABLISHED); the nft fallback — what the drill host runs — did. Fix: `ct state established,related accept` ahead of the drop, plus the chains are now rebuilt (add+flush+re-add) on every run instead of skip-if-present, so upgraded rules actually land on hosts that already have the table. Boxes still cannot *initiate* toward the host — a box-originated SYN is a NEW flow, which is what the drop is for. Verified end-to-end on the repro stack: `curl 127.0.0.1:18091` → **HTTP 200 in 2 ms**; box→host initiation still times out; DNS carve-out intact; a non-exposed port is still dropped; `--remove` shuts the door; re-expose answers again. ### Copy-pastable probe (fast — container box, no VM mint; ~1 min) From the repo checkout on the drill host, on this branch: ```bash set -x sudo bash host/box-firewall.sh # apply the new rules (idempotent) sudo nft list chain inet box input # expect: ct state established,related accept ABOVE the drop incus launch images:debian/13 probe -p default -p box-net incus config set probe user.box=1 user.box.user=root sleep 5 incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' sleep 2 ./bin/box expose probe 8091 18091 curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/ # expect HTTP 200 curl -sS -m 3 -o /dev/null http://"$(incus list probe -c 4 -f csv | sed 's/ .*//')":9099/ ; echo "non-exposed port rc=$? (28 = still dropped, good)" ./bin/box expose probe --remove 8091 curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)" ./bin/box rm probe --force set +x ``` Or just re-run the drill — its preamble re-runs setup-host, which restarts box-firewall with the new chain. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-14 23:15:46 +00:00 (Migrated from github.com)

The probe ran from ~, not a repo checkout — host/box-firewall.sh and ./bin/box were not found, so the new rules were never applied and the door was never opened (the nft output shows the old chain, no ct state rule; the "good" negative results are just the old behavior). It also left a probe container running.

Here's a self-contained version — clones the branch itself, fails fast, cleans up after (including the leftover from the last attempt):

incus delete -f probe 2>/dev/null   # leftover from the previous attempt
rm -rf /tmp/boxprobe && git clone -q -b integration/0.5.0 https://github.com/claude-hdb/claudebox /tmp/boxprobe && cd /tmp/boxprobe || exit 1
set -ex
sudo bash host/box-firewall.sh
sudo nft list chain inet box input        # expect: ct state established,related accept ABOVE the drop
incus launch images:debian/13 probe -p default -p box-net
incus config set probe user.box=1 user.box.user=root
sleep 5
incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &'
sleep 2
./bin/box expose probe 8091 18091
curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/   # expect HTTP 200
set +e
curl -sS -m 3 -o /dev/null http://"$(incus list probe -c 4 -f csv | sed 's/ .*//')":9099/ ; echo "non-exposed port rc=$? (28 = still dropped, good)"
./bin/box expose probe --remove 8091
curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)"
./bin/box rm probe --force
set +x

🤖 Generated with Claude Code

The probe ran from `~`, not a repo checkout — `host/box-firewall.sh` and `./bin/box` were not found, so the new rules were never applied and the door was never opened (the nft output shows the old chain, no `ct state` rule; the "good" negative results are just the old behavior). It also left a `probe` container running. Here's a self-contained version — clones the branch itself, fails fast, cleans up after (including the leftover from the last attempt): ```bash incus delete -f probe 2>/dev/null # leftover from the previous attempt rm -rf /tmp/boxprobe && git clone -q -b integration/0.5.0 https://github.com/claude-hdb/claudebox /tmp/boxprobe && cd /tmp/boxprobe || exit 1 set -ex sudo bash host/box-firewall.sh sudo nft list chain inet box input # expect: ct state established,related accept ABOVE the drop incus launch images:debian/13 probe -p default -p box-net incus config set probe user.box=1 user.box.user=root sleep 5 incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' sleep 2 ./bin/box expose probe 8091 18091 curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/ # expect HTTP 200 set +e curl -sS -m 3 -o /dev/null http://"$(incus list probe -c 4 -f csv | sed 's/ .*//')":9099/ ; echo "non-exposed port rc=$? (28 = still dropped, good)" ./bin/box expose probe --remove 8091 curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)" ./bin/box rm probe --force set +x ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-14 23:18:34 +00:00 (Migrated from github.com)

Previous run was from a stale checkout — the device added (it had the static-pin fix) but the nft output shows no ct state rule, so box-firewall.sh was the old version and the box's SYN/ACK was still eaten by the stateless input drop. The fix is in edf8309; pull first. From ~/claudebox:

git pull                                  # git log --oneline -1 must show edf8309
sudo bash host/box-firewall.sh
sudo nft list chain inet box input        # ct state established,related accept must now be the FIRST rule
incus launch images:debian/13 probe -p default -p box-net
incus config set probe user.box=1 user.box.user=root
sleep 5
incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &'
sleep 2
./bin/box expose probe 8091 18091
curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/   # expect HTTP 200
./bin/box expose probe --remove 8091
curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)"
./bin/box rm probe --force

🤖 Generated with Claude Code

Previous run was from a stale checkout — the device added (it had the static-pin fix) but the `nft` output shows no `ct state` rule, so `box-firewall.sh` was the old version and the box's SYN/ACK was still eaten by the stateless input drop. The fix is in `edf8309`; pull first. From `~/claudebox`: ```bash git pull # git log --oneline -1 must show edf8309 sudo bash host/box-firewall.sh sudo nft list chain inet box input # ct state established,related accept must now be the FIRST rule incus launch images:debian/13 probe -p default -p box-net incus config set probe user.box=1 user.box.user=root sleep 5 incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' sleep 2 ./bin/box expose probe 8091 18091 curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/ # expect HTTP 200 ./bin/box expose probe --remove 8091 curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)" ./bin/box rm probe --force ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-14 23:19:31 +00:00 (Migrated from github.com)

git pull was a no-op — local branch rel-0.5.0 has no upstream, so that run used the same stale tree (the nft output still shows no ct state rule). Fetch the PR branch explicitly instead; this works regardless of remote setup. From ~/claudebox:

git fetch https://github.com/claude-hdb/claudebox.git integration/0.5.0
git checkout -B rel-0.5.0 FETCH_HEAD
git log --oneline -1                      # MUST print: edf8309 fix(expose): accept established flows back from boxnet …
sudo bash host/box-firewall.sh
sudo nft list chain inet box input        # ct state established,related accept must now be the FIRST rule
incus launch images:debian/13 probe -p default -p box-net
incus config set probe user.box=1 user.box.user=root
sleep 5
incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &'
sleep 2
./bin/box expose probe 8091 18091
curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/   # expect HTTP 200
./bin/box expose probe --remove 8091
curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)"
./bin/box rm probe --force

If the git log line doesn't show edf8309, stop there — nothing after it is testing the fix.

🤖 Generated with Claude Code

`git pull` was a no-op — local branch `rel-0.5.0` has no upstream, so that run used the same stale tree (the nft output still shows no `ct state` rule). Fetch the PR branch explicitly instead; this works regardless of remote setup. From `~/claudebox`: ```bash git fetch https://github.com/claude-hdb/claudebox.git integration/0.5.0 git checkout -B rel-0.5.0 FETCH_HEAD git log --oneline -1 # MUST print: edf8309 fix(expose): accept established flows back from boxnet … sudo bash host/box-firewall.sh sudo nft list chain inet box input # ct state established,related accept must now be the FIRST rule incus launch images:debian/13 probe -p default -p box-net incus config set probe user.box=1 user.box.user=root sleep 5 incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' sleep 2 ./bin/box expose probe 8091 18091 curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/ # expect HTTP 200 ./bin/box expose probe --remove 8091 curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)" ./bin/box rm probe --force ``` If the `git log` line doesn't show `edf8309`, stop there — nothing after it is testing the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-14 23:21:32 +00:00 (Migrated from github.com)

That run is the fix working — look at how the failure changed shape. Before: curl hung 6 s (the box's reply was dropped by the stateless input chain). Now: instant Connection refused in 0.3 ms — that's a TCP RST from inside the box, DNAT'd out and un-NAT'd back through the door. Two-way traffic works.

The RST is there because nothing was listening: stock images:debian/13 has no python3, and the backgrounded server line swallows the error (my earlier snippet's miss — my repro container had python3 installed beforehand). Same probe with the listener actually started and verified:

# still on rel-0.5.0 @ edf8309, from ~/claudebox
incus launch images:debian/13 probe -p default -p box-net
incus config set probe user.box=1 user.box.user=root
sleep 5
incus exec probe -- sh -c 'command -v python3 >/dev/null || { apt-get update -qq && apt-get install -y -qq python3; } >/dev/null 2>&1'
incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &'
sleep 2
incus exec probe -- python3 -c 'import urllib.request; print("in-box listener:", urllib.request.urlopen("http://127.0.0.1:8091/").status)'   # must print 200 BEFORE we test the door
./bin/box expose probe 8091 18091
curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/   # expect HTTP 200
./bin/box expose probe --remove 8091
curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)"
./bin/box rm probe --force

The full drill doesn't have this problem — its E phase uses node inside a claude box, which is always present. Expect it to pass as-is.

🤖 Generated with Claude Code

That run is the fix **working** — look at how the failure changed shape. Before: `curl` hung 6 s (the box's reply was dropped by the stateless input chain). Now: instant `Connection refused` in 0.3 ms — that's a TCP **RST from inside the box**, DNAT'd out and un-NAT'd back through the door. Two-way traffic works. The RST is there because nothing was listening: stock `images:debian/13` has no `python3`, and the backgrounded server line swallows the error (my earlier snippet's miss — my repro container had python3 installed beforehand). Same probe with the listener actually started and verified: ```bash # still on rel-0.5.0 @ edf8309, from ~/claudebox incus launch images:debian/13 probe -p default -p box-net incus config set probe user.box=1 user.box.user=root sleep 5 incus exec probe -- sh -c 'command -v python3 >/dev/null || { apt-get update -qq && apt-get install -y -qq python3; } >/dev/null 2>&1' incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' sleep 2 incus exec probe -- python3 -c 'import urllib.request; print("in-box listener:", urllib.request.urlopen("http://127.0.0.1:8091/").status)' # must print 200 BEFORE we test the door ./bin/box expose probe 8091 18091 curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/ # expect HTTP 200 ./bin/box expose probe --remove 8091 curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)" ./bin/box rm probe --force ``` The full drill doesn't have this problem — its E phase uses node inside a claude box, which is always present. Expect it to pass as-is. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-14 23:36:29 +00:00 (Migrated from github.com)

Found it — and it's the probe, not the door. Reproduced on my stack: when incus exec is run from an interactive terminal it allocates a PTY, and Incus kills the exec session's processes on return — including the setsid'd server (verified: with incus exec -t the listener dies instantly; with stdin redirected it survives). My snippet was missing </dev/null on the server-start line. The drill's own E phase always had it (… sh -c 'setsid node … &' </dev/null), which is why the drill is immune — its comment even says so.

Meanwhile your previous run already showed the door itself working: the old 6 s timeout became an instant RST relayed from inside the box. This run just needs a listener that survives:

# still on rel-0.5.0 @ edf8309, from ~/claudebox
incus launch images:debian/13 probe -p default -p box-net
incus config set probe user.box=1 user.box.user=root
sleep 5
incus exec probe -- sh -c 'command -v python3 >/dev/null || { apt-get update -qq && apt-get install -y -qq python3; } >/dev/null 2>&1' </dev/null
incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' </dev/null
sleep 2
incus exec probe -- python3 -c 'import urllib.request; print("in-box listener:", urllib.request.urlopen("http://127.0.0.1:8091/").status)' </dev/null   # must print 200
./bin/box expose probe 8091 18091
curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/   # expect HTTP 200
./bin/box expose probe --remove 8091
curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)"
./bin/box rm probe --force

(The trailing </dev/null on each exec is the load-bearing part.)

🤖 Generated with Claude Code

Found it — and it's the probe, not the door. Reproduced on my stack: when `incus exec` is run from an **interactive terminal** it allocates a PTY, and Incus kills the exec session's processes on return — *including* the `setsid`'d server (verified: with `incus exec -t` the listener dies instantly; with stdin redirected it survives). My snippet was missing `</dev/null` on the server-start line. The drill's own E phase always had it (`… sh -c 'setsid node … &' </dev/null`), which is why the drill is immune — its comment even says so. Meanwhile your previous run already showed the door itself working: the old 6 s timeout became an instant RST relayed from inside the box. This run just needs a listener that survives: ```bash # still on rel-0.5.0 @ edf8309, from ~/claudebox incus launch images:debian/13 probe -p default -p box-net incus config set probe user.box=1 user.box.user=root sleep 5 incus exec probe -- sh -c 'command -v python3 >/dev/null || { apt-get update -qq && apt-get install -y -qq python3; } >/dev/null 2>&1' </dev/null incus exec probe -- sh -c 'setsid python3 -m http.server 8091 --bind 0.0.0.0 >/tmp/srv.log 2>&1 </dev/null &' </dev/null sleep 2 incus exec probe -- python3 -c 'import urllib.request; print("in-box listener:", urllib.request.urlopen("http://127.0.0.1:8091/").status)' </dev/null # must print 200 ./bin/box expose probe 8091 18091 curl -sS -m 6 -o /dev/null -w 'door: HTTP %{http_code} in %{time_total}s\n' http://127.0.0.1:18091/ # expect HTTP 200 ./bin/box expose probe --remove 8091 curl -sS -m 3 -o /dev/null http://127.0.0.1:18091/ ; echo "after remove rc=$? (7 = door shut, good)" ./bin/box rm probe --force ``` (The trailing `</dev/null` on each exec is the load-bearing part.) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#56
No description provided.