From c6bb6cb0e046f41026780554b7352ffcb3927f97 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 15:59:39 +0000 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20codex=20and=20grok=20templates=20?= =?UTF-8?q?=E2=80=94=20the=20mechanic's=20second=20and=20third=20tenants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, ' --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 --- README.md | 18 +++++++--- drill/drill.sh | 38 +++++++++++++++++---- templates/codex/box.env | 11 +++++++ templates/codex/user-data.yaml | 60 ++++++++++++++++++++++++++++++++++ templates/grok/box.env | 11 +++++++ templates/grok/user-data.yaml | 59 +++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 templates/codex/box.env create mode 100644 templates/codex/user-data.yaml create mode 100644 templates/grok/box.env create mode 100644 templates/grok/user-data.yaml diff --git a/README.md b/README.md index 821d798..5dbf81e 100644 --- a/README.md +++ b/README.md @@ -75,10 +75,20 @@ claude # if the repo has .claudebox/, Claude reads it ## Templates -The claude box is one template among several. A template is a directory under -`templates/`: a `box.env` (image, user, resources — parsed against a strict -allowlist, never sourced) and a `user-data.yaml` (cloud-init, passed to Incus -verbatim). +The claude box is one template among several. What ships today: + +| Template | What's in it | +| --- | --- | +| `blank` | Bare Debian 13 — same isolation, no tooling. The default. | +| `claude` | Claude Code, creds-free (the original claudebox) | +| `codex` | OpenAI Codex CLI, creds-free | +| `grok` | xAI Grok Build CLI, creds-free | + +A template is a directory under `templates/`: a `box.env` (image, user, +resources — parsed against a strict allowlist, never sourced) and a +`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI +templates are all the same shape — install the CLI, put it on PATH, drop an +agent-context file; none of them carry credentials. ```sh box templates # list what this install can mint diff --git a/drill/drill.sh b/drill/drill.sh index 7a8c13e..cf8961b 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -159,7 +159,7 @@ This will, ON THIS HOST ($(hostname)): · install Incus and a systemd unit · create a network (boxnet), an ACL, and a profile · rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain) - · create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl + · create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl, codex, grok · mutate the network and profile mid-run to rehearse the #16 hardening Only do this on a machine you can format. EOF @@ -243,7 +243,7 @@ fi inf "clearing anything a previous run left behind…" # One name at a time — 'incus delete -f a b c' aborts at the first MISSING name, # which is how run 2 inherited run 1's boxes and cascaded five false FAILs. -for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do +for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl codex grok; do timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 done if incus network show boxnet >/dev/null 2>&1; then @@ -385,8 +385,12 @@ else fi # --- templates: the mint surface is itself a surface to test ---------------- -box templates 2>/dev/null | grep -q '^ blank' && box templates 2>/dev/null | grep -q '^ claude' \ - && ok "templates: lists blank and claude" || no "templates listing is missing a shipped template" +tpl_missing="" +for t in blank claude codex grok; do + box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t" +done +[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \ + || no "templates listing is missing:$tpl_missing" box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \ && ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused" # The one rule that keeps templates honest: no key can name a network. Plant a @@ -427,6 +431,28 @@ else no "blank mint FAILED — tail: $(tail -3 /tmp/mint-tpl.log | tr '\n' ' ')" fi +# The generic mechanic (metadata, placement, user, isolation parity) is proven +# once by blank+claude and needs no per-template repeat. What a NEW template +# still has to prove is its own payload: the CLI installs, lands on the +# non-interactive exec PATH, and answers --version. One mint each. +for t in codex grok; do + case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok-build; user=grok ;; esac + printf '\n minting a %s box (cold — validates the template install)…\n' "$t" + if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then + [ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \ + && ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user" + if timeout -k 5 30 box exec "$t" -- "$bin" --version /dev/null 2>&1; then + ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH" + else + no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)" + inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH /dev/null)" + fi + box rm "$t" --force >/dev/null 2>&1 && ok "$t box removed" || no "$t: could not remove" + else + no "$t mint FAILED — tail: $(tail -3 "/tmp/mint-$t.log" | tr '\n' ' ')" + fi +done + printf '\n minting a claude box (cold, ~10 min)…\n' t0=$SECONDS if mint_box /tmp/mint-drill.log --name drill --template claude; then @@ -678,11 +704,11 @@ if [ "$KEEP" = 1 ]; then inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied" else # every name the drill can have left, whatever branch a partial run took - for n in drill clone archive peer tpl; do box rm "$n" --force >/dev/null 2>&1; done + for n in drill clone archive peer tpl codex grok; do box rm "$n" --force >/dev/null 2>&1; done # Assert OUR boxes are gone — not that the host is empty. The rm loop above # already embodies the discipline (only names the drill minted); demanding # 'no boxes yet' here would flag any pre-existing operator box as a failure. - leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer|tpl)([[:space:]]|$)' || true)" + leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer|tpl|codex|grok)([[:space:]]|$)' || true)" [ -z "$leftover" ] && ok "teardown: every box the drill minted is gone" \ || no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')" fi diff --git a/templates/codex/box.env b/templates/codex/box.env new file mode 100644 index 0000000..08d5e58 --- /dev/null +++ b/templates/codex/box.env @@ -0,0 +1,11 @@ +# The codex template — Debian 13 + OpenAI Codex CLI, creds-free. +# KEY="value" only. Parsed against an allowlist, never sourced; there is no +# key for a network or a security flag, on purpose — the shared box-net +# profile is the placement contract and no template can weaken it. +# BOX_USER must match the user user-data.yaml creates. +BOX_DESCRIPTION="OpenAI Codex CLI on Debian 13, creds-free" +BOX_IMAGE="images:debian/13/cloud" +BOX_USER="codex" +BOX_CPU="4" +BOX_MEMORY="8GiB" +BOX_DISK="60GiB" diff --git a/templates/codex/user-data.yaml b/templates/codex/user-data.yaml new file mode 100644 index 0000000..1aa115c --- /dev/null +++ b/templates/codex/user-data.yaml @@ -0,0 +1,60 @@ +#cloud-config +users: + - name: codex + shell: /bin/bash + sudo: "ALL=(ALL) NOPASSWD:ALL" + lock_passwd: true +write_files: + - path: /home/codex/.codex/AGENTS.md + owner: "codex:codex" + permissions: '0644' + defer: true + content: | + # You are running inside a box (template: codex) + + A box is a trust-less, network-isolated, ephemeral VM created by the + `box` CLI. Keep this context in mind: + + - **Creds-free by default.** The box starts with no OpenAI and no git + credentials. If you need to authenticate Codex, the operator runs the + login flow (`codex`) interactively. For git, the operator adds their own + credentials (a PAT or `gh auth login`). Never assume credentials are + present; never ask for or store secrets on disk beyond what the operator + sets up. + - **Isolated.** The box reaches the public internet but nothing on the host + or local network. There is no inbound path. + - **Disposable.** Nothing here is backed up. State is discarded when the box + is removed; the operator persists work via git push and via `box snapshot`. + - **Bootstrap runbook.** If the repository you are working in contains a + `.claudebox/` folder, read it as your setup runbook — how to install + dependencies, start services, template environment files, seed data, and + smoke-test — and follow it. It is documentation for you, not a script the + host runs. +package_update: true +packages: + - git + - gh + - curl + - ca-certificates + - gnupg + - ripgrep + - jq + - tmux + - age + - unzip + - build-essential +runcmd: + - curl -fsSL https://get.docker.com | sh + - usermod -aG docker codex + # Codex CLI is an npm global and needs Node 22+ (verified upstream: + # npmjs.com/package/@openai/codex — the SCOPED @openai/codex, not the + # unrelated 2012 'codex' package). + - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + - apt-get install -y nodejs + - npm install -g @openai/codex + # 'box exec -- codex …' runs a NON-interactive shell that reads no + # rc files — npm's global bin must be reachable from every shell. Symlink + # the installed binary into /usr/local/bin (the same fix the claude + # template needed for its own CLI). + - ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex + - echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> /home/codex/.bashrc diff --git a/templates/grok/box.env b/templates/grok/box.env new file mode 100644 index 0000000..be5b569 --- /dev/null +++ b/templates/grok/box.env @@ -0,0 +1,11 @@ +# The grok template — Debian 13 + xAI's Grok Build CLI, creds-free. +# KEY="value" only. Parsed against an allowlist, never sourced; there is no +# key for a network or a security flag, on purpose — the shared box-net +# profile is the placement contract and no template can weaken it. +# BOX_USER must match the user user-data.yaml creates. +BOX_DESCRIPTION="xAI Grok Build CLI on Debian 13, creds-free" +BOX_IMAGE="images:debian/13/cloud" +BOX_USER="grok" +BOX_CPU="4" +BOX_MEMORY="8GiB" +BOX_DISK="60GiB" diff --git a/templates/grok/user-data.yaml b/templates/grok/user-data.yaml new file mode 100644 index 0000000..1de6646 --- /dev/null +++ b/templates/grok/user-data.yaml @@ -0,0 +1,59 @@ +#cloud-config +users: + - name: grok + shell: /bin/bash + sudo: "ALL=(ALL) NOPASSWD:ALL" + lock_passwd: true +write_files: + - path: /home/grok/.grok/AGENTS.md + owner: "grok:grok" + permissions: '0644' + defer: true + content: | + # You are running inside a box (template: grok) + + A box is a trust-less, network-isolated, ephemeral VM created by the + `box` CLI. Keep this context in mind: + + - **Creds-free by default.** The box starts with no xAI and no git + credentials. If you need to authenticate, the operator runs + `grok-build login` interactively (SuperGrok / X Premium+). For git, the + operator adds their own credentials (a PAT or `gh auth login`). Never + assume credentials are present; never ask for or store secrets on disk + beyond what the operator sets up. + - **Isolated.** The box reaches the public internet but nothing on the host + or local network. There is no inbound path. + - **Disposable.** Nothing here is backed up. State is discarded when the box + is removed; the operator persists work via git push and via `box snapshot`. + - **Bootstrap runbook.** If the repository you are working in contains a + `.claudebox/` folder, read it as your setup runbook — how to install + dependencies, start services, template environment files, seed data, and + smoke-test — and follow it. It is documentation for you, not a script the + host runs. +package_update: true +packages: + - git + - gh + - curl + - ca-certificates + - gnupg + - ripgrep + - jq + - tmux + - age + - unzip + - build-essential +runcmd: + - curl -fsSL https://get.docker.com | sh + - usermod -aG docker grok + # Grok Build's official installer (verified upstream: x.ai/cli). It drops the + # binary under the invoking user's home, so run it AS grok, not root. + - sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash' + # 'box exec -- grok-build …' runs a NON-interactive shell that reads no + # rc files. Find whatever the installer dropped and symlink it onto the + # system PATH — the same fix the claude template needed. If the installer's + # binary name changes upstream, this glob is the one line to update. + - | + bin="$(find /home/grok -maxdepth 4 -type f -name 'grok-build' 2>/dev/null | head -1)" + [ -n "$bin" ] && ln -sf "$bin" /usr/local/bin/grok-build || echo "grok-build binary not found post-install — check x.ai/cli install layout" >&2 + - echo 'export PATH="$HOME/.local/bin:$PATH"' >> /home/grok/.bashrc -- 2.45.2 From c9712834f2ce2f74fccd01fc4410df5d381b4a00 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 16:02:27 +0000 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20host/migrate-host.sh=20=E2=80=94?= =?UTF-8?q?=20re-home=20legacy=20boxes=20onto=20the=20new=20stack,=20then?= =?UTF-8?q?=20retire=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 / --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 --- drill/drill.sh | 75 +++++++++++++++++++++-- host/migrate-host.sh | 137 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 host/migrate-host.sh diff --git a/drill/drill.sh b/drill/drill.sh index 7a8c13e..a724966 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -159,8 +159,8 @@ This will, ON THIS HOST ($(hostname)): · install Incus and a systemd unit · create a network (boxnet), an ACL, and a profile · rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain) - · create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl - · mutate the network and profile mid-run to rehearse the #16 hardening + · create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl, legacybox + · build a faithful legacy stack (claudenet/10.87, claude-dev) to drill migration Only do this on a machine you can format. EOF [ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; } @@ -243,7 +243,7 @@ fi inf "clearing anything a previous run left behind…" # One name at a time — 'incus delete -f a b c' aborts at the first MISSING name, # which is how run 2 inherited run 1's boxes and cascaded five false FAILs. -for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do +for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl legacybox; do timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 done if incus network show boxnet >/dev/null 2>&1; then @@ -671,6 +671,73 @@ if [ "$BASELINE_OK" -ne 1 ]; then inf "start with: bash drill/doctor.sh" fi +# =========================================================================== +phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)" +# =========================================================================== +# A fresh host has no legacy stack, so build a faithful one: claudenet on the +# OLD subnet, a claude-dev profile pinned to it, and a box tagged with the OLD +# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove +# migrate-host.sh moves it onto the new stack with its identity intact, and +# retires the legacy stack only once it is empty. +MIG="$HOME/.local/share/claudebox/host/migrate-host.sh" +if [ ! -f "$MIG" ]; then + no "migrate-host.sh not installed — cannot drill the transition" +else + inf "building a faithful legacy stack (claudenet/10.87 + claude-dev)…" + incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \ + ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none >/dev/null 2>&1 + if ! incus profile show claude-dev >/dev/null 2>&1; then + incus profile create claude-dev >/dev/null 2>&1 + incus profile device add claude-dev root disk pool=default path=/ >/dev/null 2>&1 + incus profile device add claude-dev eth0 nic network=claudenet name=eth0 \ + security.port_isolation=true >/dev/null 2>&1 + fi + # A minimal legacy box: no template payload, just boots and networks on the + # old stack, wearing the old tag. This is what migrate has to move. + printf '\n minting a faithful legacy box on the old stack…\n' + if mint_legacy=$(incus launch images:debian/13/cloud legacybox --profile claude-dev \ + --config user.claudebox=1 --vm --device root,size=20GiB 2>&1); then + wait_box legacybox && ok "legacy box up on the old stack (claudenet, user.claudebox=1)" \ + || no "legacy box never came up — cannot drill migration" + box list 2>/dev/null | grep -q '^legacybox' \ + && ok "box list shows the legacy box (dual-tag matching)" || no "legacy box invisible to 'box list'" + + # Retire must REFUSE while a legacy box exists. + bash "$MIG" --retire-legacy 2>&1 | grep -qi 'legacy boxes still exist' \ + && ok "retire-legacy refuses while a legacy box remains" \ + || no "retire-legacy did NOT refuse with a legacy box present — it would strip an in-use stack" + + # Re-home it. + printf ' re-homing the legacy box…\n' + bash "$MIG" --box legacybox 2>&1 | sed 's/^/ /' + [ "$(incus config get legacybox user.box 2>/dev/null)" = 1 ] \ + && ok "migrate: legacy box now tagged user.box=1" || no "migrate: user.box tag not set" + [ "$(incus config get legacybox user.box.user 2>/dev/null)" = claude ] \ + && ok "migrate: legacy box mapped to the claude user" || no "migrate: user.box.user not claude" + incus config show legacybox 2>/dev/null | grep -q '^- box-net' \ + && ok "migrate: legacy box reassigned to box-net (the new placement contract)" \ + || no "migrate: legacy box is NOT on box-net" + lip="$(boxnet_ip legacybox)" + [ -n "$lip" ] && ok "migrate: legacy box got a boxnet address ($lip) — network move landed" \ + || no "migrate: legacy box has no 10.88 address — the move did not take" + in_box legacybox getent hosts deb.debian.org >/dev/null 2>&1 \ + && ok "migrate: re-homed box resolves + reaches the internet on its new leg" \ + || no "migrate: re-homed box cannot resolve on boxnet" + + # No legacy boxes remain → retire must now SUCCEED and leave nothing. + printf ' retiring the (now empty) legacy stack…\n' + bash "$MIG" --retire-legacy 2>&1 | sed 's/^/ /' + incus network show claudenet >/dev/null 2>&1 \ + && no "retire-legacy left claudenet behind" || ok "retire-legacy removed claudenet" + incus profile show claude-dev >/dev/null 2>&1 \ + && no "retire-legacy left claude-dev behind" || ok "retire-legacy removed claude-dev" + + box rm legacybox --force >/dev/null 2>&1 + else + no "could not launch the legacy box: $(printf '%s' "$mint_legacy" | tail -1)" + fi +fi + # =========================================================================== if [ "$KEEP" = 1 ]; then phase "Boxes left up (--keep-boxes)" @@ -678,7 +745,7 @@ if [ "$KEEP" = 1 ]; then inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied" else # every name the drill can have left, whatever branch a partial run took - for n in drill clone archive peer tpl; do box rm "$n" --force >/dev/null 2>&1; done + for n in drill clone archive peer tpl legacybox; do box rm "$n" --force >/dev/null 2>&1; done # Assert OUR boxes are gone — not that the host is empty. The rm loop above # already embodies the discipline (only names the drill minted); demanding # 'no boxes yet' here would flag any pre-existing operator box as a failure. diff --git a/host/migrate-host.sh b/host/migrate-host.sh new file mode 100644 index 0000000..c4b2968 --- /dev/null +++ b/host/migrate-host.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# migrate-host.sh — move a host from the pre-0.4.0 'claudebox' stack to 'box'. +# +# The zero-ceremony transition is just install.sh + setup-host.sh: that leaves +# a DUAL-STACK host where legacy boxes (tag user.claudebox=1, claudenet/10.87, +# claude-dev) keep working while new mints land on boxnet/10.88. This script is +# the two things that path does not do: +# +# migrate-host.sh --box re-home ONE legacy box onto the new stack +# migrate-host.sh --all-boxes re-home every legacy box +# migrate-host.sh --retire-legacy remove the legacy stack (refuses while any +# legacy box still exists) +# +# One action per invocation, idempotent, loud about what it did. Re-homing +# PRESERVES the box's authed state (Claude login, git creds — the expensive +# thing); it does not re-mint. The order is load-bearing: tag first (additive, +# reversible), profile last, and verify the box works on its new leg BEFORE +# calling it migrated — a box must never end up tagless or profileless. +# +# NOT 'set -e' around the per-box work: a box that fails one step is reported +# and skipped, not a crash that abandons the rest mid-migration. +set -u + +GW_NEW=10.88.0.1 +say() { printf 'migrate: %s\n' "$*"; } +warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; } +die() { printf 'migrate: ERROR: %s\n' "$*" >&2; exit 1; } + +mode="" +target="" +while [ $# -gt 0 ]; do + case "$1" in + --box) mode=box; target="${2:-}"; shift 2 || die "--box needs a name" ;; + --all-boxes) mode=all; shift ;; + --retire-legacy) mode=retire; shift ;; + -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) die "unknown argument: $1 (see --help)" ;; + esac +done +[ -n "$mode" ] || die "pick one: --box | --all-boxes | --retire-legacy" +command -v incus >/dev/null || die "incus is not installed on this host" + +# The new stack must exist before any box can be re-homed onto it. setup-host +# creates it; refuse rather than move a box onto a network that isn't there. +require_new_stack() { + incus network show boxnet >/dev/null 2>&1 || die "boxnet does not exist — run host/setup-host.sh first" + incus profile show box-net >/dev/null 2>&1 || die "box-net profile does not exist — run host/setup-host.sh first" +} + +legacy_boxes() { incus list "user.claudebox=1" -f csv -c n 2>/dev/null; } + +# Re-home one box. Legacy boxes are all claude boxes (the only template the old +# tool minted), so the new metadata is the claude template's. +rehome_one() { + local b="$1" st + incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; } + if [ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ]; then + say "$b already carries user.box=1 — already re-homed, skipping"; return 0 + fi + say "re-homing $b …" + + # 1. TAG FIRST — additive and reversible. A box that stops here is still a + # valid legacy box (the old tag is untouched) AND now a new one. + incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \ + || { warn "$b: could not set new metadata — left untouched"; return 1; } + + # 2. Stop, reassign the profile (this is the network move), restart. Incus + # won't reassign a profile on a running instance's NIC cleanly, and the + # box needs a fresh DHCP lease on boxnet anyway. + st="$(incus list "$b" -f csv -c s 2>/dev/null | head -1)" + case "$st" in RUNNING|Running|running) incus stop "$b" >/dev/null 2>&1 || warn "$b: stop was not clean" ;; esac + incus profile assign "$b" box-net \ + || { warn "$b: profile assign failed — it still has user.box=1 but is on the OLD network; fix by hand"; return 1; } + incus start "$b" >/dev/null 2>&1 || { warn "$b: did not restart — start it by hand"; return 1; } + + # 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box + # must be on 10.88 and actually resolve+reach the internet on its new leg + # before we call it migrated. + local i ip + ip="" + for i in $(seq 1 30); do + ip="$(incus exec "$b" -- ip -4 -o addr show scope global /dev/null \ + | awk '{for(i=1;i/dev/null 2>&1; then + say "$b re-homed: on boxnet ($ip), resolves + reachable, authed state preserved" + return 0 + fi + warn "$b is on boxnet ($ip) but cannot resolve — check the new stack's resolver (box doctor)" + return 1 +} + +case "$mode" in + box) + [ -n "$target" ] || die "--box needs a name" + require_new_stack + rehome_one "$target" + ;; + all) + require_new_stack + boxes="$(legacy_boxes)" + [ -n "$boxes" ] || { say "no legacy boxes to re-home"; exit 0; } + rc=0 + for b in $boxes; do rehome_one "$b" || rc=1; done + [ "$rc" = 0 ] && say "all legacy boxes re-homed" || warn "some boxes need attention (above)" + exit "$rc" + ;; + retire) + # Refuse while any legacy box still references the old stack — removing an + # in-use profile/network fails anyway, and a half-removed stack is worse + # than an intact one. + remaining="$(legacy_boxes)" + if [ -n "$remaining" ]; then + die "legacy boxes still exist: $(echo "$remaining" | tr '\n' ' ') + re-home them first (--all-boxes), or delete them, then retire." + fi + say "no legacy boxes remain — removing the legacy stack" + incus profile delete claude-dev >/dev/null 2>&1 && say "deleted profile claude-dev" + incus network delete claudenet >/dev/null 2>&1 && say "deleted network claudenet" + incus network acl delete claude-isolate >/dev/null 2>&1 && say "deleted ACL claude-isolate" + sudo systemctl disable --now claudebox-firewall.service >/dev/null 2>&1 && say "disabled claudebox-firewall.service" + sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall + sudo systemctl daemon-reload + sudo nft delete table inet claudebox >/dev/null 2>&1 && say "deleted nft table inet claudebox" + sudo nft delete table bridge claudebox >/dev/null 2>&1 && say "deleted nft table bridge claudebox" + # Assert the absence — don't trust the removals' exit codes. + left="" + incus network show claudenet >/dev/null 2>&1 && left="$left claudenet" + incus profile show claude-dev >/dev/null 2>&1 && left="$left claude-dev" + sudo nft list table bridge claudebox >/dev/null 2>&1 && left="$left nft-bridge" + [ -z "$left" ] && say "legacy stack retired — this host is now single-stack (box only)" \ + || die "legacy stack NOT fully removed:$left" + ;; +esac -- 2.45.2 From de6467a7288ad2cb2045ade54fe971d984aa06e1 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 16:07:17 +0000 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20'box=20expose=20=20'?= =?UTF-8?q?=20=E2=80=94=20a=20deliberate,=20loopback-only=20door=20to=20a?= =?UTF-8?q?=20dev=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-); --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 --- README.md | 5 ++- bin/box | 111 +++++++++++++++++++++++++++++++++++++++++++++++++ drill/drill.sh | 53 +++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 821d798..6a8b7d0 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ box rename # rename a box (stop it first) box down # stop (state kept; `start` resumes) box start # start a stopped box box rm [--force] # delete the box + its snapshots (asks first) +box expose # forward a box port to host loopback — see a dev server box incus -- # escape hatch: any incus command, box resolved box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth box status # deprecated alias for `list` @@ -185,7 +186,9 @@ enforces it, layer by layer: host-level VPN don't resolve inside a box either. - **Host firewall** — instance → host is dropped except DNS/DHCP, including the host's public IPs. Entry is `incus exec` over the local socket only — - **no inbound path exists.** + **no inbound path exists** — unless you punch one with `box expose`, and + that door only ever opens onto the host's own loopback (`127.0.0.1`), never + the network. The VM is the trust boundary: whatever runs inside — Claude, or anything a template ships — can run arbitrary code and touch nothing you care about. diff --git a/bin/box b/bin/box index 5ce6ce7..caac6ea 100755 --- a/bin/box +++ b/bin/box @@ -50,6 +50,7 @@ CMDS=( "down^^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}" "start^^box^Start a stopped box^incus:start^started {}" "rm^ [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}" + "expose^ [] | --list | --remove ^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^" "incus^ -- ^box^Escape hatch: run any incus command against a box^fn:cmd_incus^" "doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^" "status^^^Deprecated alias for 'list'^fn:cmd_status^" @@ -310,6 +311,28 @@ confirmation first; --force (-f) skips the prompt. With no TTY to confirm on box rm work box rm work --force EOF +;; + expose) cat <<'EOF' +Open a deliberate, loopback-only door to a port inside a box — for when you +are coding in a box and want to see its dev server in your browser. + + box expose [] # forward 127.0.0.1: → box: + box expose --list # what doors are open + box expose --remove # close one + +The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the +box, only this host's loopback. There is no flag to widen that; if you need +LAN exposure you are leaving the tool's threat model, and 'box incus' is the +door (with its warning). + +The in-box server must listen on 0.0.0.0:, not only its own loopback — +a VM's forwarder connects to the box over the network. Inside an isolated box +that is safe: nothing but this proxy can reach the port. + + box new --name web --template claude + box shell web # inside: run a dev server on 0.0.0.0:3000 + box expose web 3000 # then open http://127.0.0.1:3000 in your browser +EOF ;; incus) cat <<'EOF' The door out. box wraps the box lifecycle and the isolation model, not @@ -399,6 +422,8 @@ while [ $# -gt 0 ]; do -*) # doctor's flags belong to the doctor script, not to box if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi + # expose's own flags (--list, --remove) are positional to it, not box's + if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" fi @@ -818,6 +843,92 @@ cmd_incus() { incus "${out[@]}" } +# A deliberate, loopback-only door to a box's port — for the one workflow the +# "no inbound path" contract is too absolute for: you are coding in a box and +# want to open its dev server in your browser. +# +# Two decisions, both load-bearing: +# · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays +# true — no other machine can reach the box; only THIS host's loopback gets +# a door. There is no flag to widen it; that is the escape hatch's job, with +# its warning. +# · Each exposure is a named proxy device (expose-), so 'box info' and +# --list can see it and --remove can undo it. A box with a hole says so. +# +# Mechanism (VMs): an Incus 'proxy' device forwards host 127.0.0.1: +# to the box's :. Because a VM's forkproxy connects over the network +# to the guest, the in-box server must listen on 0.0.0.0 (not just its own +# loopback) — inside an isolated box that is safe: boxnet + port-isolation + +# the ingress drop mean only this proxy can reach it. And that ingress drop is +# exactly what would eat the forkproxy's connection, so expose adds a SCOPED +# ACL allow (this box's ip + this port only) beside the device. Whether the +# proxy needs that allow, or bypasses the ACL, is settled by the drill, not +# here — the allow is scoped and harmless if redundant. +exposure_dev() { echo "expose-$1"; } # device name for a port + +cmd_expose() { + local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}" + + # --list + if [ "$a2" = "--list" ]; then + local found=0 d listen connect + while IFS= read -r d; do + case "$d" in expose-*) : ;; *) continue ;; esac + listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)" + connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)" + [ "$found" = 0 ] && echo "EXPOSURES for $box" + found=1 + printf ' %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect" + done < <(incus config device list "$inst" 2>/dev/null) + [ "$found" = 0 ] && echo "box: $box has no exposed ports" + return 0 + fi + + # --remove + if [ "$a2" = "--remove" ]; then + local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove " + local dev; dev="$(exposure_dev "$port")" + incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \ + || die "$box has no exposure on port $port (see 'box expose $box --list')" + incus config device remove "$inst" "$dev" >/dev/null \ + && echo "box: closed the door on port $port" + # Remove the scoped ACL allow, if one was added. Best-effort: its absence + # is not an error (the drill may show the allow was never needed). + local ip; ip="$(box_ipv4 "$inst")" + [ "$ip" != "-" ] && incus network acl rule remove box-isolate ingress \ + action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 + return 0 + fi + + # expose [] + local port="$a2" hport="${a3:-$a2}" + [ -n "$port" ] || usage_error "usage: $(synopsis_of expose)" + case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac + + local ip; ip="$(box_ipv4 "$inst")" + [ "$ip" != "-" ] || die "$box has no IP yet — is it running? (box info $box)" + + local dev; dev="$(exposure_dev "$port")" + if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then + die "$box already exposes port $port (change or remove it: box expose $box --remove $port)" + fi + + # The scoped ACL allow FIRST, so the door is open by the time the proxy uses + # it. Scoped to this box's IP and this port — it does not widen any other box. + incus network acl rule add box-isolate ingress action=allow \ + "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true + + if incus config device add "$inst" "$dev" proxy \ + "listen=tcp:127.0.0.1:$hport" "connect=tcp:$ip:$port" bind=host >/dev/null; then + echo "box: 127.0.0.1:$hport → $box:$port" + echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)" + else + incus network acl rule remove box-isolate ingress action=allow \ + "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 + die "could not add the proxy device" + fi +} + # --- dispatch: driven by the table, not by a hand-written case -------------- row="$(cmd_row "$cmd")" || unknown_command "$cmd" diff --git a/drill/drill.sh b/drill/drill.sh index 7a8c13e..1a8a9ab 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -638,6 +638,59 @@ else aud "A7 inbound host→box: NOT PROBED" fi +# =========================================================================== +phase "E. box expose — a deliberate loopback door (#55)" +# =========================================================================== +# archive is a running claude box (node is installed). Start a DETACHED +# listener on 0.0.0.0 inside it, expose the port, and prove the door works +# from the HOST's loopback. Then prove removing it closes the door, and that a +# NON-exposed port still obeys the ingress drop — the feature must not +# globally weaken A7. +EP=8091; EHP=18091 +srv="$(mktemp)" +printf 'require("http").createServer((q,r)=>r.end("box-expose-ok")).listen(%s,"0.0.0.0")\n' "$EP" >"$srv" +if incus file push "$srv" archive/tmp/srv.js >/dev/null 2>&1; then + rm -f "$srv" + # Detached: setsid + all fds redirected so 'incus exec' returns at once and + # nothing holds its stdout (trap 2/3). The listener outlives the exec. + timeout -k 5 20 incus exec archive -- sh -c 'setsid node /tmp/srv.js >/tmp/srv.log 2>&1 /dev/null 2>&1; then + ok "box expose archive $EP $EHP — the device was added" + box expose archive --list 2>/dev/null | grep -q "$EP" \ + && ok "expose --list shows the open door" || no "expose --list does not show the exposure" + box info archive 2>/dev/null | grep -qi "$EP" \ + && ok "box info surfaces the exposure (a box with a hole says so)" || note "box info does not mention the exposure (nice-to-have)" + # THE test: does the host's loopback reach the box's server? + sleep 2 + if curl -sS -m 6 "http://127.0.0.1:$EHP" 2>/dev/null | grep -q box-expose-ok; then + ok "127.0.0.1:$EHP reaches the box's server — the door WORKS" + else + no "127.0.0.1:$EHP does NOT reach the box — the proxy/ACL mechanism needs work (#55)" + inf "srv.log inside the box: $(in_box archive cat /tmp/srv.log 2>/dev/null | tail -2 | tr '\n' ' ')" + fi + # A NON-exposed port must still be dropped — the feature is per-port, not a + # global ingress opening. + nemsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:9099" 2>&1)" + printf '%s' "$nemsg" | grep -q 'Connection refused' \ + && no "a non-exposed port answered on the box — expose opened ingress too wide" \ + || ok "a non-exposed port is still dropped — expose is per-port, A7 survives" + # Close it, and confirm the door shuts. + box expose archive --remove "$EP" >/dev/null 2>&1 && ok "box expose --remove closed the device" || no "expose --remove failed" + sleep 2 + curl -sS -m 5 -o /dev/null "http://127.0.0.1:$EHP" 2>/dev/null \ + && no "the host still reaches the box after --remove — the door did not shut" \ + || ok "after --remove, 127.0.0.1:$EHP is dead — the door shut" + else + no "box expose failed to add the device — tail: $(box expose archive "$EP" "$EHP" 2>&1 | tail -1)" + rm -f "$srv" 2>/dev/null + fi + timeout -k 5 15 incus exec archive -- pkill -f srv.js /dev/null 2>&1 +else + rm -f "$srv" + no "could not push the test server into archive — expose phase did not run" +fi + # =========================================================================== phase "D. The isolation contract, stated" # =========================================================================== -- 2.45.2 From 5deef69621fdc82a99d3dbb059a6d68f87bee2c2 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 16:13:08 +0000 Subject: [PATCH 04/21] =?UTF-8?q?chore:=20VERSION=20=E2=86=92=200.5.0=20(t?= =?UTF-8?q?emplates,=20migrate-host,=20expose)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 -- 2.45.2 From c4cc9f43d1382d12810e025e55fabc28f004df49 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 16:38:54 +0000 Subject: [PATCH 05/21] fix: sanitize the console dump (no more scrambled terminal) + tear down failed mints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-.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. --- bin/box | 17 +++++++++++++---- drill/drill.sh | 4 ++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bin/box b/bin/box index caac6ea..2757033 100755 --- a/bin/box +++ b/bin/box @@ -500,14 +500,23 @@ pick_mode() { # forensics: the VM's console says why, and the box is torn down by whoever # called us before anyone can read it. wait_agent() { - local n="$1" i + local n="$1" i clog echo "box: waiting for instance agent..." for i in $(seq 1 150); do if incus exec "$n" -- true /dev/null 2>&1; then return; fi if [ "$i" -eq 150 ]; then - echo "box: instance agent never came up. The VM's console log:" >&2 - timeout -k 5 15 incus console "$n" --show-log 2>/dev/null | tail -15 | sed 's/^/ /' >&2 - die "agent unreachable after 5 minutes (incus console $n to inspect live)" + # The console log is FULL of terminal escape sequences (boot messages, + # a firmware menu). Dumping it raw scrambles the operator's terminal — + # and doubly so when it lands in a log someone is tail -f'ing. Capture + # it to a file, STRIP everything but printable ASCII + tab/newline, and + # print only a short sanitized tail. Nothing raw ever reaches a terminal. + clog="/tmp/box-console-$n.log" + timeout -k 5 15 incus console "$n" --show-log "$clog.raw" 2>/dev/null || true + tr -cd '\11\12\40-\176' <"$clog.raw" >"$clog" 2>/dev/null; rm -f "$clog.raw" + echo "box: instance agent never came up after 5 minutes." >&2 + echo "box: sanitized console log → $clog (last lines:)" >&2 + tail -8 "$clog" 2>/dev/null | sed 's/^/ /' >&2 + die "agent unreachable (inspect live: incus console $n)" fi sleep 2 done diff --git a/drill/drill.sh b/drill/drill.sh index 4c6b1ab..a82093c 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -429,6 +429,8 @@ if mint_box /tmp/mint-tpl.log --name tpl; then box rm tpl --force >/dev/null 2>&1 && ok "blank box removed" || no "could not remove the blank box" else no "blank mint FAILED — tail: $(tail -3 /tmp/mint-tpl.log | tr '\n' ' ')" + # Tear the stuck box down — a failed mint that lingers starves the next one. + timeout -k 5 60 incus delete -f tpl >/dev/null 2>&1 fi # The generic mechanic (metadata, placement, user, isolation parity) is proven @@ -450,6 +452,7 @@ for t in codex grok; do box rm "$t" --force >/dev/null 2>&1 && ok "$t box removed" || no "$t: could not remove" else no "$t mint FAILED — tail: $(tail -3 "/tmp/mint-$t.log" | tr '\n' ' ')" + timeout -k 5 60 incus delete -f "$t" >/dev/null 2>&1 fi done @@ -459,6 +462,7 @@ if mint_box /tmp/mint-drill.log --name drill --template claude; then ok "box new --name drill --template claude ($((SECONDS - t0))s)" else no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')" + timeout -k 5 60 incus delete -f drill >/dev/null 2>&1 echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1 fi -- 2.45.2 From b9480fd9ce22bf23573fc361516653ea6bc6679e Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 16:56:52 +0000 Subject: [PATCH 06/21] fix: strip whole escape sequences from the console dump, and name the GRUB hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/box | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bin/box b/bin/box index 2757033..f076c98 100755 --- a/bin/box +++ b/bin/box @@ -512,10 +512,21 @@ wait_agent() { # print only a short sanitized tail. Nothing raw ever reaches a terminal. clog="/tmp/box-console-$n.log" timeout -k 5 15 incus console "$n" --show-log "$clog.raw" 2>/dev/null || true - tr -cd '\11\12\40-\176' <"$clog.raw" >"$clog" 2>/dev/null; rm -f "$clog.raw" + # Strip whole escape sequences FIRST (while the ESC byte is present), then + # drop any residual control bytes — otherwise 'tr' alone leaves the visible + # '[1m[37m' halves behind. Result is clean, readable text. + sed -E $'s/\x1b\\[[0-9;:?]*[ -/]*[@-~]//g; s/\x1b[()#][0-9A-Za-z]//g; s/\x1b[=>PX^_].*?(\x1b\\\\|\x07)//g; s/\x1b.//g' \ + "$clog.raw" 2>/dev/null | tr -cd '\11\12\40-\176' >"$clog" + rm -f "$clog.raw" echo "box: instance agent never came up after 5 minutes." >&2 - echo "box: sanitized console log → $clog (last lines:)" >&2 - tail -8 "$clog" 2>/dev/null | sed 's/^/ /' >&2 + echo "box: sanitized console log → $clog (last non-blank lines:)" >&2 + grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/ /' >&2 + # A box sitting at the GRUB/firmware menu is not slow — it never booted. + if grep -qiE 'GNU GRUB|Press enter to boot|UEFI Firmware Settings' "$clog" 2>/dev/null; then + echo "box: the VM is stuck at the GRUB/firmware menu — it is not booting." >&2 + echo "box: this is the IMAGE, not box. Try re-pulling it (incus image delete …)" >&2 + echo "box: or pin a known-good build/alias in the template's BOX_IMAGE." >&2 + fi die "agent unreachable (inspect live: incus console $n)" fi sleep 2 -- 2.45.2 From 912e0621cab6b00c41e97a66f9c64f1ec1436d4f Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 17:05:39 +0000 Subject: [PATCH 07/21] =?UTF-8?q?fix:=20disable=20Secure=20Boot=20on=20box?= =?UTF-8?q?=20VMs=20=E2=80=94=20'bad=20shim=20signature'=20hung=20every=20?= =?UTF-8?q?mint=20at=20GRUB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- bin/box | 9 ++++++++- drill/drill.sh | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/bin/box b/bin/box index f076c98..0e840ec 100755 --- a/bin/box +++ b/bin/box @@ -630,7 +630,14 @@ cmd_new() { load_template "$t" m="$(pick_mode)" # shellcheck disable=SC2054 # "root,size=..." is a single incus argument - if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK"); else extra+=(--config security.nesting=true); fi + # security.secureboot=false: Incus defaults VMs to secureboot ON, and a + # Debian cloud image whose shim is signed with a key the host's OVMF does + # not trust dies with "bad shim signature / prohibited by secure boot + # policy" and drops to the GRUB menu forever — the kernel never loads. It + # is not part of a throwaway box's threat model (the VM boundary is), and + # turning it off boots reliably across image rebuilds. Container mode has + # no firmware, so it does not apply there. + if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi # The template's identity is stamped ONTO the instance: which template, # which user. 'incus copy' preserves user.* keys (audit B2), so a clone # knows what it is without ever consulting the template again. diff --git a/drill/drill.sh b/drill/drill.sh index a82093c..d8edd7a 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -779,7 +779,8 @@ else # old stack, wearing the old tag. This is what migrate has to move. printf '\n minting a faithful legacy box on the old stack…\n' if mint_legacy=$(incus launch images:debian/13/cloud legacybox --profile claude-dev \ - --config user.claudebox=1 --vm --device root,size=20GiB 2>&1); then + --config user.claudebox=1 --vm --device root,size=20GiB \ + --config security.secureboot=false 2>&1); then wait_box legacybox && ok "legacy box up on the old stack (claudenet, user.claudebox=1)" \ || no "legacy box never came up — cannot drill migration" box list 2>/dev/null | grep -q '^legacybox' \ -- 2.45.2 From 4eb6b35a7b2838feb9e290da33a17ab036739ce3 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 17:44:24 +0000 Subject: [PATCH 08/21] =?UTF-8?q?chore:=20finish=20the=20debrand=20?= =?UTF-8?q?=E2=80=94=20env=20vars,=20install=20dir,=20docs=20are=20'box',?= =?UTF-8?q?=20not=20'claudebox'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- 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. --- README.md | 18 +++++----- docs/{claudebox-design.md => box-design.md} | 26 +++++++------- docs/{claudebox-recipe.md => box-recipe.md} | 10 +++--- drill/README.md | 10 +++--- drill/doctor.sh | 6 ++-- drill/drill.sh | 22 ++++++------ host/teardown-host.sh | 2 +- install.sh | 39 +++++++++++++-------- templates/claude/user-data.yaml | 8 ++--- 9 files changed, 75 insertions(+), 66 deletions(-) rename docs/{claudebox-design.md => box-design.md} (85%) rename docs/{claudebox-recipe.md => box-recipe.md} (90%) diff --git a/README.md b/README.md index f6379ac..0edc42f 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ live in a shared profile no template can touch, so `blank` is a box with nobody home — not a box with the safety off. **The tool knows nothing about your projects.** You just `git clone` inside a -box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md) +box. A repo can ship an optional [`.claudebox/`](docs/box-recipe.md) runbook that Claude Code reads and acts on — there is no `install` step and no -host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the +host-run setup. See [docs/box-design.md](docs/box-design.md) for the design rationale. > **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host @@ -37,14 +37,14 @@ design rationale. curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash ``` -Installs the tree to `~/.local/share/claudebox` and links `box` onto your +Installs the tree to `~/.local/share/box` and links `box` onto your `PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also retires the old `claudebox` symlink. (No `git clone` needed.) ## One-time host setup (Ubuntu 24.04 / Debian 13) ```sh -~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between) +~/.local/share/box/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between) ``` Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT @@ -172,7 +172,7 @@ The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and the command is echoed before it runs. If it can move the box off the isolation stack (profile, network, device, `security.*`), box warns and proceeds — the trust boundary is then yours to keep. See -[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the +[docs/box-design.md](docs/box-design.md) for the rule and why the command surface is a table. ## Isolation @@ -232,14 +232,14 @@ inherit. A repo that wants to be easy to stand up in a box ships an optional `.claudebox/` folder — a runbook Claude reads and follows (install deps, start services, template env, seed data, smoke-test). It is agent-facing documentation, not a -host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md). +host-executed script. See [docs/box-recipe.md](docs/box-recipe.md). ## Uninstall ```sh -~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall -~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself -rm -rf ~/.local/share/claudebox ~/.local/bin/box # the CLI +~/.local/share/box/host/teardown-host.sh # boxes, network, ACL, profile, firewall +~/.local/share/box/host/teardown-host.sh --purge-incus # ...and Incus itself +rm -rf ~/.local/share/box ~/.local/bin/box # the CLI ``` ## Non-goals diff --git a/docs/claudebox-design.md b/docs/box-design.md similarity index 85% rename from docs/claudebox-design.md rename to docs/box-design.md index 0b0e925..416d308 100644 --- a/docs/claudebox-design.md +++ b/docs/box-design.md @@ -1,6 +1,6 @@ -# claudebox design +# box design -`claudebox` is a CLI that mints and manages **trust-less, network-isolated VMs +`box` is a CLI that mints and manages **trust-less, network-isolated VMs with Claude Code installed**. It is infrastructure, not a project provisioner. See issue #3 for the full reframe and rationale. This doc captures the durable @@ -16,7 +16,7 @@ design decisions. ## Boxes are strictly creds-free -`claudebox new --name ` launches a blank box: everything installed, **no** +`box new --name ` launches a blank box: everything installed, **no** git credentials and **no** Claude credentials. The operator authenticates interactively *inside* the box: @@ -33,18 +33,18 @@ multi-user problem: nothing shared, nothing committed. Re-authing every fresh box would be toil, so authenticated state is reused via snapshots, not a secrets store: -- `claudebox snapshot [label]` — checkpoint after login + clone. -- `claudebox new --name --from [/]` — clone an existing box +- `box snapshot [label]` — checkpoint after login + clone. +- `box new --name --from [/]` — clone an existing box or snapshot (authed state and all). Isolation is preserved: the clone keeps the `claude-dev` profile + `claudenet` + ACL. -- `claudebox restore ` — roll a box back to a checkpoint. +- `box restore ` — roll a box back to a checkpoint. Log in once → snapshot → spin up authed boxes from it. ## The box announces itself to the agent cloud-init installs a global `~/.claude/CLAUDE.md` in every box telling Claude it -is running in a claudebox (trust-less, ephemeral, creds-free) and to treat a +is running in a box (trust-less, ephemeral, creds-free) and to treat a repo's `.claudebox/` folder as its bootstrap runbook. No "tell it" step, no host execution. @@ -54,13 +54,13 @@ Not host-executed shell. A repo that wants to be easy to stand up in a sandbox ships a runbook (prose + optional scripts the agent may run). A repo that does not, you set up by hand. The tool enforces no contract; there is no `install`. -## What claudebox owns, and what it doesn't +## What box owns, and what it doesn't Boxes are ordinary Incus instances, tagged `user.claudebox=1`. That makes every Incus verb a candidate feature request — `rename`, `info`, `file push`, on forever — and wrapping them one at a time grows a worse `incus`. The rule: -> **claudebox owns a command when it must enforce an invariant Incus cannot see:** +> **box owns a command when it must enforce an invariant Incus cannot see:** > the `user.claudebox=1` boundary (never touch an instance we didn't mint), the > isolation stack (`claude-dev` profile + `claudenet` + ACL), or the creds-free > snapshot→clone workflow. Everything else is Incus's job. @@ -76,17 +76,17 @@ The rule cuts both ways, and that's the point: Two mechanisms keep this honest. -**The command table** (`CMDS` in `bin/claudebox`) is the single source of truth +**The command table** (`CMDS` in `bin/box`) is the single source of truth for what exists, its synopsis, its help line, its preconditions and what runs. Dispatch and help are both rendered from it, so the help cannot describe a command that doesn't exist — the failure that produced #8. A thin verb is one row; a verb that can't be expressed as a row and enforces no invariant of ours doesn't belong in the tool. -**The escape hatch** — `claudebox incus -- ` — resolves and +**The escape hatch** — `box incus -- ` — resolves and tag-checks the box, then hands the rest to Incus verbatim. It means "no" to a proxy request is not "you can't do that", and it keeps the one rail that matters: -you cannot aim it at an instance claudebox didn't mint. If the command can move +you cannot aim it at an instance box didn't mint. If the command can move the box off the isolation stack (profile, network, device, `security.*`), it warns and proceeds — from there the trust boundary is yours to keep. @@ -102,7 +102,7 @@ That last clause is the one that was assumed and turned out to be false, so it is spelled out here with the mechanism, and `drill/` tests it on every run. - **Box → host, LAN, RFC1918, CGNAT, link-local:** the `claude-isolate` ACL. -- **Box → box: an nftables *bridge-family* rule** (`host/claudebox-firewall.sh`). +- **Box → box: an nftables *bridge-family* rule** (`host/box-firewall.sh`). It cannot be an ACL rule. Two boxes on one bridge share an L2 segment, so their frames are *switched* between bridge ports and never traverse the netfilter path an L3 ACL lives on — the ACL looked airtight (it drops diff --git a/docs/claudebox-recipe.md b/docs/box-recipe.md similarity index 90% rename from docs/claudebox-recipe.md rename to docs/box-recipe.md index e79ee6a..cf65476 100644 --- a/docs/claudebox-recipe.md +++ b/docs/box-recipe.md @@ -1,7 +1,7 @@ # The `.claudebox/` convention -`claudebox` mints trust-less, creds-free, isolated VMs with Claude Code already -installed (`claudebox new/shell/snapshot/restore/exec/down/start/rm/status`). The +`box` mints trust-less, creds-free, isolated VMs with Claude Code already +installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`). The tool knows **nothing** about your project. There is no `install` step and no host-run setup script. @@ -23,12 +23,12 @@ shell that the host executes. ## How it's consumed Every box ships a global `~/.claude/CLAUDE.md` telling Claude it is inside a -claudebox and to treat a repo's `.claudebox/` folder as its bootstrap runbook. +box and to treat a repo's `.claudebox/` folder as its bootstrap runbook. So the whole flow is: ``` -claudebox new # get a box -claudebox shell # get in +box new # get a box +box shell # get in git clone && cd claude # Claude reads .claudebox/ and brings the project up ``` diff --git a/drill/README.md b/drill/README.md index f5e06c6..cdb41e9 100644 --- a/drill/README.md +++ b/drill/README.md @@ -1,6 +1,6 @@ # The drill -An end-to-end rehearsal of claudebox against a **real** Incus: install the CLI, +An end-to-end rehearsal of box against a **real** Incus: install the CLI, set up the host, mint boxes, drive the whole surface, check that the isolation actually holds — and run the full [#15 audit](https://github.com/heavy-duty/claudebox/issues/15), including a live @@ -32,7 +32,7 @@ The drill mutates the host in phase D; an aborted run can leave a network that mints boxes with **no DNS**. **Iterating on the drill?** Read [RUNS.md](RUNS.md) first — it is the run log: -what the audit has answered so far, the bugs the drill has found in claudebox, +what the audit has answered so far, the bugs the drill has found in box, the traps this script has already fallen into (every one cost a run), how to diagnose a stall, and how to run a single probe by hand instead of paying for a whole run. @@ -46,7 +46,7 @@ wrong. The drill runs the real thing. ## What it checks -**A. Incus semantics.** The assumptions claudebox is built on, probed directly: +**A. Incus semantics.** The assumptions box is built on, probed directly: that `incus config get user.claudebox` returns `1` (this is on the path of *every* box command — if it lies, everything fails closed); that the `user.claudebox=1` list filter selects our instances and excludes an untagged @@ -62,7 +62,7 @@ and its isolation warning, the `rm` confirmation guard, and the CLI contract (typo'd command, typo'd flag, `list `). **The boundary** gets its own treatment: the drill launches an instance -claudebox did *not* mint, aims `down`, `rm` and the escape hatch at it, and +box did *not* mint, aims `down`, `rm` and the escape hatch at it, and requires all three to refuse — and the instance to still be standing afterwards. **C. Isolation baseline (#15 section A).** From inside a real box: public egress @@ -88,6 +88,6 @@ a **design veto** for #16, caught before the code is written. design. The drill confirms Claude Code is installed and runnable; authenticating is yours. -If the host has no `/dev/kvm`, claudebox falls back to container mode. The drill +If the host has no `/dev/kvm`, box falls back to container mode. The drill still runs, but it says loudly that **the VM trust boundary was not validated** rather than passing quietly on a weaker one. diff --git a/drill/doctor.sh b/drill/doctor.sh index 7a1974d..1b25fda 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -49,7 +49,7 @@ if incus network show boxnet >/dev/null 2>&1; then ok "dns.mode = none — a box cannot enumerate its siblings by name" else no "dns.mode = ${dns:-} — a box can RESOLVE its siblings' names and addresses" - inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh" + inf "fix: re-run ~/.local/share/box/host/setup-host.sh" [ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; } fi inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)" @@ -110,7 +110,7 @@ if [ -n "$PROFILES" ]; then no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER" inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2" inf "segment, so their frames are switched, never routed past the ACL." - inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh" + inf "fix: re-run ~/.local/share/box/host/setup-host.sh" fi for k in security.mac_filtering security.ipv4_filtering; do v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)" @@ -192,7 +192,7 @@ else no "the host resolves via a CGNAT/Tailscale resolver ($hostns), and boxes INHERIT it — see issue #33" inf "· box DNS breaks whenever the tailnet's resolver does (this is what kills cold mints)" inf "· and tailnet names RESOLVE from inside a box, though its ACL blocks connecting to them" - inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh (it pins the resolver)" + inf "fix: re-run ~/.local/share/box/host/setup-host.sh (it pins the resolver)" inf " or quick-test the pin alone: bash drill/doctor.sh --pin-dns" else inf "boxes inherit the host's resolver (unpinned — setup-host.sh pins this now; re-run it)" diff --git a/drill/drill.sh b/drill/drill.sh index d8edd7a..7e67661 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# drill.sh — end-to-end drill for box (the claudebox repo), against a real Incus. +# drill.sh — end-to-end drill for box, against a real Incus. # # ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY host you can format. # It installs Incus, rewrites the host's firewall rules, installs a systemd @@ -32,8 +32,8 @@ # false FAILs on the first live run. The pipeline verdict must be grep's alone. set -u -REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}" -REF="${CLAUDEBOX_REF:-main}" +REPO="${BOX_REPO:-heavy-duty/claudebox}" +REF="${BOX_REF:-main}" YES=0; KEEP=0 SELF="$(readlink -f "$0")" @@ -170,7 +170,7 @@ EOF fi phase "Installing box ($REPO@$REF)" - CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \ + BOX_REPO="$REPO" BOX_REF="$REF" \ bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \ || { echo "install failed"; exit 1; } export PATH="$HOME/.local/bin:$PATH" @@ -206,10 +206,10 @@ EOF fi inf "running setup-host.sh (first pass: may only add you to incus-admin)…" - ~/.local/share/claudebox/host/setup-host.sh || true + ~/.local/share/box/host/setup-host.sh || true # The group we were just added to isn't in this shell's credentials yet. inf "re-entering inside the incus-admin group…" - exec sg incus-admin -c "IN_GROUP=1 CLAUDEBOX_REPO='$REPO' CLAUDEBOX_REF='$REF' KEEP=$KEEP bash '$SELF' --in-group" + exec sg incus-admin -c "IN_GROUP=1 BOX_REPO='$REPO' BOX_REF='$REF' KEEP=$KEEP bash '$SELF' --in-group" fi export PATH="$HOME/.local/bin:$PATH" @@ -259,7 +259,7 @@ left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')" [ -n "$left" ] && inf "instances still on this host (not ours, left alone): $left" inf "running setup-host.sh (in-group pass: network, ACL, profile, firewall)…" -if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then +if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then echo "drill: setup-host.sh failed or timed out (>5 min)." >&2 echo " it should take seconds on a host that already has incus. usual causes:" >&2 echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2 @@ -367,7 +367,7 @@ phase "B. The box surface" # =========================================================================== # Compare against the installed tree's VERSION file, not a hardcoded number — # a pinned literal here would fail the drill on every release. -expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')" +expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')" v="$(box --version 2>&1)" case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac @@ -396,7 +396,7 @@ box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \ # The one rule that keeps templates honest: no key can name a network. Plant a # bad template in the installed tree (the drill owns this host), expect the # parser to reject it BY NAME, remove it. -badt="$HOME/.local/share/claudebox/templates/cbdrill-bad" +badt="$HOME/.local/share/box/templates/cbdrill-bad" mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml" box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \ && ok "a template cannot name a network — BOX_NETWORK rejected by name" \ @@ -762,7 +762,7 @@ phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)" # tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove # migrate-host.sh moves it onto the new stack with its identity intact, and # retires the legacy stack only once it is empty. -MIG="$HOME/.local/share/claudebox/host/migrate-host.sh" +MIG="$HOME/.local/share/box/host/migrate-host.sh" if [ ! -f "$MIG" ]; then no "migrate-host.sh not installed — cannot drill the transition" else @@ -853,5 +853,5 @@ fi echo inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules" inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)." -inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]" +inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]" [ "$fail" -eq 0 ] diff --git a/host/teardown-host.sh b/host/teardown-host.sh index 091b94e..af6266f 100755 --- a/host/teardown-host.sh +++ b/host/teardown-host.sh @@ -72,4 +72,4 @@ if $purge; then fi fi -echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)" +echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)" diff --git a/install.sh b/install.sh index 9bf307c..c4c770c 100755 --- a/install.sh +++ b/install.sh @@ -1,19 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -# claudebox installer — intended for: curl -fsSL .../install.sh | bash +# box installer — intended for: curl -fsSL .../install.sh | bash # -# Downloads the claudebox repo tarball, installs the whole tree under -# $DEST, and puts a `box` symlink on PATH via $BINDIR. +# Downloads the box source tarball from its GitHub repo (heavy-duty/claudebox — +# the repo keeps the old name; only the tool is 'box'), installs the whole tree +# under $DEST, and puts a `box` symlink on PATH via $BINDIR. -REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}" -REF="${CLAUDEBOX_REF:-main}" -DEST="${CLAUDEBOX_HOME:-$HOME/.local/share/claudebox}" -BINDIR="${CLAUDEBOX_BIN:-$HOME/.local/bin}" +REPO="${BOX_REPO:-heavy-duty/claudebox}" +REF="${BOX_REF:-main}" +DEST="${BOX_HOME:-$HOME/.local/share/box}" +BINDIR="${BOX_BIN:-$HOME/.local/bin}" -log() { printf 'claudebox-install: %s\n' "$*"; } -warn() { printf 'claudebox-install: WARNING: %s\n' "$*" >&2; } -die() { printf 'claudebox-install: ERROR: %s\n' "$*" >&2; exit 1; } +log() { printf 'box-install: %s\n' "$*"; } +warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; } +die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; } # --- prerequisites --------------------------------------------------------- command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." @@ -26,18 +27,19 @@ trap cleanup EXIT URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" -log "installing box (the claudebox repo) ($REPO@$REF)" +log "installing box from $REPO@$REF" log "downloading $URL" -curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \ +curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \ || die "failed to download $URL" log "extracting archive" -tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \ +tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \ || die "failed to extract archive" -# GitHub archives extract to a single top-level dir like claudebox-/ +# GitHub names the archive's top dir after the REPO, which is still 'claudebox': +# it extracts to claudebox-/. That is repo-derived, not a stray brand. EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)" -[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive" +[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive" [ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?" # --- atomically replace $DEST --------------------------------------------- @@ -59,6 +61,13 @@ if [ -L "$BINDIR/claudebox" ]; then rm -f "$BINDIR/claudebox" log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)" fi +# 0.5.0 moved the install tree from ~/.local/share/claudebox to ~/.local/share/box. +# Sweep the old tree so an upgrade does not leave a stale copy behind. +OLD_DEST="$HOME/.local/share/claudebox" +if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then + rm -rf "$OLD_DEST" + log "removed the old install tree at $OLD_DEST (it now lives at $DEST)" +fi # --- PATH check ------------------------------------------------------------ case ":$PATH:" in diff --git a/templates/claude/user-data.yaml b/templates/claude/user-data.yaml index 737294a..25eb51a 100644 --- a/templates/claude/user-data.yaml +++ b/templates/claude/user-data.yaml @@ -10,10 +10,10 @@ write_files: permissions: '0644' defer: true content: | - # You are running inside a claudebox + # You are running inside a box (template: claude) - A claudebox is a trust-less, network-isolated, ephemeral VM created by the - `box` CLI (template: claude). Keep this context in mind: + A box is a trust-less, network-isolated, ephemeral VM created by the + `box` CLI. Keep this context in mind: - **Creds-free by default.** The box starts with no Claude and no git credentials. If you need to authenticate Claude, the operator runs `/login` @@ -49,7 +49,7 @@ runcmd: - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y nodejs - sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash' - # 'claudebox exec box -- claude …' runs through a NON-interactive shell, which + # 'box exec -- claude …' runs through a NON-interactive shell, which # reads no .bashrc/.zshrc — a PATH export there is invisible to it. The # symlink makes claude reachable from every shell, interactive or not. - ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude -- 2.45.2 From 455fbc656e27ac40afca04cbb8a691b4a01908c8 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 18:01:34 +0000 Subject: [PATCH 09/21] feat: host lifecycle as verbs (setup-host/teardown-host/migrate-host) + .box/ convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things: 1. The host scripts are first-class verbs now — 'box setup-host', 'box teardown-host [--purge-incus]', 'box migrate-host --box '. Nobody should have to run ~/.local/share/box/host/