release: box 0.5.0 — codex+grok templates, migrate-host, box expose #56
18 changed files with 1023 additions and 137 deletions
120
README.md
120
README.md
|
|
@ -1,10 +1,9 @@
|
||||||
# claudebox — ships the `box` CLI
|
# box
|
||||||
|
|
||||||
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
||||||
network-isolated Incus box from a **template**; the flagship template is
|
network-isolated Incus box from a **template**; the flagship template is
|
||||||
`claude` — Debian 13 with Claude Code installed, the box this repo is named
|
`claude` — Debian 13 with Claude Code installed. The box is the product —
|
||||||
for. The box is the product — you log in and work; destroying it loses
|
you log in and work; destroying it loses nothing you didn't push.
|
||||||
nothing you didn't push.
|
|
||||||
|
|
||||||
**Strictly creds-free.** A box ships with everything installed and **no**
|
**Strictly creds-free.** A box ships with everything installed and **no**
|
||||||
credentials — no Claude token, no git PAT, nothing. You authenticate
|
credentials — no Claude token, no git PAT, nothing. You authenticate
|
||||||
|
|
@ -18,33 +17,36 @@ live in a shared profile no template can touch, so `blank` is a box with
|
||||||
nobody home — not a box with the safety off.
|
nobody home — not a box with the safety off.
|
||||||
|
|
||||||
**The tool knows nothing about your projects.** You just `git clone` inside a
|
**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 [`.box/`](docs/box-recipe.md)
|
||||||
runbook that Claude Code reads and acts on — there is no `install` step and no
|
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.
|
design rationale.
|
||||||
|
|
||||||
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
|
> **0.5.0**: two new templates (`codex`, `grok`), `box expose` — a
|
||||||
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
|
> loopback-only door to a box port, for seeing a dev server — and the host
|
||||||
> default template is `blank`. Existing boxes minted by any earlier version
|
> lifecycle as first-class verbs: `box setup-host`, `box teardown-host`, and
|
||||||
> keep working under every verb — their legacy tag is honored forever, and
|
> `box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
|
||||||
> their old `claudenet` (10.87) is left standing beside the new bridge. To
|
> and retires the legacy bridge.
|
||||||
> strip a host of both generations at once: `host/teardown-host.sh`, or
|
>
|
||||||
> `drill/wipe.sh` for the scorched-earth version.
|
> **0.4.0's clean cut stands**: the CLI is `box` (no legacy shim), the
|
||||||
|
> host stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and
|
||||||
|
> the default template is `blank`. Boxes minted by any earlier version keep
|
||||||
|
> working under every verb — their legacy tag is honored forever.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/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
|
`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.)
|
retires the old `claudebox` symlink. (No `git clone` needed.)
|
||||||
|
|
||||||
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between)
|
box setup-host # run twice if it adds you to incus-admin (re-login between)
|
||||||
```
|
```
|
||||||
|
|
||||||
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
|
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
|
||||||
|
|
@ -56,6 +58,11 @@ re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
|
||||||
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
||||||
`sudo apt-get install -y dnsmasq-base`.
|
`sudo apt-get install -y dnsmasq-base`.
|
||||||
|
|
||||||
|
A host still carrying the pre-0.4.0 stack: `box migrate-host --all-boxes`
|
||||||
|
re-homes each legacy box onto `boxnet` (authed state preserved), and
|
||||||
|
`box migrate-host --retire-legacy` removes the old bridge and profile once no
|
||||||
|
legacy box remains.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -70,15 +77,25 @@ claude # then run /login — copy the URL (press c), o
|
||||||
# in YOUR browser, paste the code back. No host CLI needed.
|
# in YOUR browser, paste the code back. No host CLI needed.
|
||||||
gh auth login # or drop a PAT in — your git credentials, your call
|
gh auth login # or drop a PAT in — your git credentials, your call
|
||||||
git clone https://github.com/you/project && cd project
|
git clone https://github.com/you/project && cd project
|
||||||
claude # if the repo has .claudebox/, Claude reads it and sets up
|
claude # if the repo has .box/, Claude reads it and sets up
|
||||||
```
|
```
|
||||||
|
|
||||||
## Templates
|
## Templates
|
||||||
|
|
||||||
The claude box is one template among several. A template is a directory under
|
The claude box is one template among several. What ships today:
|
||||||
`templates/`: a `box.env` (image, user, resources — parsed against a strict
|
|
||||||
allowlist, never sourced) and a `user-data.yaml` (cloud-init, passed to Incus
|
| Template | What's in it |
|
||||||
verbatim).
|
| --- | --- |
|
||||||
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
||||||
|
| `claude` | Claude Code, creds-free — where this project started |
|
||||||
|
| `codex` | OpenAI Codex CLI, creds-free |
|
||||||
|
| `grok` | xAI Grok 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
|
```sh
|
||||||
box templates # list what this install can mint
|
box templates # list what this install can mint
|
||||||
|
|
@ -112,13 +129,33 @@ a box's live state, or roll a box back with `box restore work authed`.
|
||||||
Forgotten what you called a checkpoint? `box info work` prints the box's
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
||||||
snapshot labels and the `--from` line to clone one.
|
snapshot labels and the `--from` line to clone one.
|
||||||
|
|
||||||
|
## See a dev server: `box expose`
|
||||||
|
|
||||||
|
The isolation contract says no inbound path exists — which is one "no" too
|
||||||
|
many when you're coding in a box and want its dev server in your browser.
|
||||||
|
`box expose` is the deliberate exception:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
box expose work 3000 # http://127.0.0.1:3000 → work:3000
|
||||||
|
box expose work 3000 8080 # or pick the host port: 127.0.0.1:8080 → work:3000
|
||||||
|
box expose work --list # what doors are open
|
||||||
|
box expose work --remove 3000 # close one
|
||||||
|
```
|
||||||
|
|
||||||
|
The listen side is **always the host's own loopback** — never the network, no
|
||||||
|
flag to widen it — so no other machine gains a path to the box. The in-box
|
||||||
|
server must listen on `0.0.0.0`, not its own loopback (safe inside the
|
||||||
|
isolation stack: only this door can reach it). A box with a hole says so:
|
||||||
|
`box info` lists open exposures. Everything else on the box stays dropped —
|
||||||
|
the door is per-port, punched and removable at runtime.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```
|
```
|
||||||
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]
|
||||||
box templates # list the templates this install can mint
|
box templates # list the templates this install can mint
|
||||||
box list # list your boxes
|
box list # list your boxes
|
||||||
box info <box> # one box: state, IP, snapshot labels
|
box info <box> # one box: state, IP, exposures, snapshot labels
|
||||||
box shell <box> # enter as the template's user
|
box shell <box> # enter as the template's user
|
||||||
box exec <box> -- <cmd...> # run a command in the box
|
box exec <box> -- <cmd...> # run a command in the box
|
||||||
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
||||||
|
|
@ -128,8 +165,14 @@ box rename <box> <new> # rename a box (stop it first)
|
||||||
box down <box> # stop (state kept; `start` resumes)
|
box down <box> # stop (state kept; `start` resumes)
|
||||||
box start <box> # start a stopped box
|
box start <box> # start a stopped box
|
||||||
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
||||||
|
box expose <box> <port> [<host-port>] | --list | --remove <port>
|
||||||
|
# forward a box port to host loopback — see a dev server
|
||||||
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
||||||
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
||||||
|
box setup-host # one-time host setup: Incus, the boxnet stack, the firewall
|
||||||
|
box teardown-host [--purge-incus] # remove the host stack (both name generations)
|
||||||
|
box migrate-host --box <n> | --all-boxes | --retire-legacy
|
||||||
|
# move a pre-0.4.0 host onto the box stack
|
||||||
box status # deprecated alias for `list`
|
box status # deprecated alias for `list`
|
||||||
box help [<command>] # full help, or one command's page
|
box help [<command>] # full help, or one command's page
|
||||||
```
|
```
|
||||||
|
|
@ -138,7 +181,7 @@ Every command takes `--help`, and options come after the command
|
||||||
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
||||||
wrong.
|
wrong.
|
||||||
|
|
||||||
`new` fresh-launches from a template (default: `claude`), or with `--from`
|
`new` fresh-launches from a template (default: `blank`), or with `--from`
|
||||||
clones an existing box or snapshot. VM mode (`--vm`, the default where
|
clones an existing box or snapshot. VM mode (`--vm`, the default where
|
||||||
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
|
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
|
||||||
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
||||||
|
|
@ -147,7 +190,8 @@ dev/test only.
|
||||||
## Boxes are just Incus instances
|
## Boxes are just Incus instances
|
||||||
|
|
||||||
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
||||||
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command
|
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and
|
||||||
|
the isolation model — not all of Incus. It owns a command
|
||||||
when it must enforce something Incus can't see: that tag (it will not stop,
|
when it must enforce something Incus can't see: that tag (it will not stop,
|
||||||
rename or delete an instance it didn't mint), the isolation stack, or the
|
rename or delete an instance it didn't mint), the isolation stack, or the
|
||||||
creds-free snapshot workflow. For everything else, there's the door:
|
creds-free snapshot workflow. For everything else, there's the door:
|
||||||
|
|
@ -161,7 +205,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
|
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 —
|
stack (profile, network, device, `security.*`), box warns and proceeds —
|
||||||
the trust boundary is then yours to keep. See
|
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.
|
command surface is a table.
|
||||||
|
|
||||||
## Isolation
|
## Isolation
|
||||||
|
|
@ -185,7 +229,9 @@ enforces it, layer by layer:
|
||||||
host-level VPN don't resolve inside a box either.
|
host-level VPN don't resolve inside a box either.
|
||||||
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
|
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
|
||||||
the host's public IPs. Entry is `incus exec` over the local socket only —
|
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
|
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.
|
template ships — can run arbitrary code and touch nothing you care about.
|
||||||
|
|
@ -196,9 +242,11 @@ Every clause above is probed live by an end-to-end drill, because the one time
|
||||||
this contract was reasoned about instead of measured, the reasoning was wrong:
|
this contract was reasoned about instead of measured, the reasoning was wrong:
|
||||||
box→box traffic was "covered" by an L3 drop that L2-switched frames never
|
box→box traffic was "covered" by an L3 drop that L2-switched frames never
|
||||||
meet — a hole found by probing, not by reading the rules. On a bare host the
|
meet — a hole found by probing, not by reading the rules. On a bare host the
|
||||||
drill installs the whole stack, mints a box cold, snapshots and clones it,
|
drill installs the whole stack, mints every template cold, snapshots and
|
||||||
probes every boundary from inside the boxes, and removes what it minted —
|
clones, probes every boundary from inside the boxes, opens and shuts the
|
||||||
currently **47 checks, 47 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
|
`expose` door (and checks the contract survives it), re-homes a faithful
|
||||||
|
pre-0.4.0 box through `migrate-host`, and removes what it minted —
|
||||||
|
currently **81 checks, 81 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
|
||||||
history, including every trap that fooled a run into a wrong verdict.
|
history, including every trap that fooled a run into a wrong verdict.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -214,19 +262,19 @@ diagnoses the host faults that have actually happened: a wedged Incus daemon,
|
||||||
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
|
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
|
||||||
inherit.
|
inherit.
|
||||||
|
|
||||||
## Recipes: the `.claudebox/` convention
|
## Recipes: the `.box/` convention
|
||||||
|
|
||||||
A repo that wants to be easy to stand up in a box ships an optional `.claudebox/`
|
A repo that wants to be easy to stand up in a box ships an optional `.box/`
|
||||||
folder — a runbook Claude reads and follows (install deps, start services,
|
folder — a runbook Claude reads and follows (install deps, start services,
|
||||||
template env, seed data, smoke-test). It is agent-facing documentation, not a
|
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
|
## Uninstall
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall
|
box teardown-host # boxes, network, ACL, profile, firewall
|
||||||
~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself
|
box teardown-host --purge-incus # ...and Incus itself
|
||||||
rm -rf ~/.local/share/claudebox ~/.local/bin/box # the CLI
|
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI itself
|
||||||
```
|
```
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.4.0
|
0.5.0
|
||||||
|
|
|
||||||
287
bin/box
287
bin/box
|
|
@ -50,8 +50,12 @@ CMDS=(
|
||||||
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
|
||||||
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
"start^<box>^box^Start a stopped box^incus:start^started {}"
|
||||||
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
|
||||||
|
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
|
||||||
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
|
"incus^<box> -- <args...>^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^"
|
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
|
||||||
|
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
|
||||||
|
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
|
||||||
|
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
|
||||||
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
|
||||||
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
|
||||||
)
|
)
|
||||||
|
|
@ -118,7 +122,7 @@ EOF
|
||||||
local r v sum
|
local r v sum
|
||||||
for r in "${CMDS[@]}"; do
|
for r in "${CMDS[@]}"; do
|
||||||
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
IFS='^' read -r v _ _ sum _ _ <<<"$r"
|
||||||
printf ' %-9s %s\n' "$v" "$sum"
|
printf ' %-13s %s\n' "$v" "$sum"
|
||||||
done
|
done
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
|
|
||||||
|
|
@ -310,6 +314,28 @@ confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
|
||||||
box rm work
|
box rm work
|
||||||
box rm work --force
|
box rm work --force
|
||||||
EOF
|
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 <box> <port> [<host-port>] # forward 127.0.0.1:<host-port> → box:<port>
|
||||||
|
box expose <box> --list # what doors are open
|
||||||
|
box expose <box> --remove <port> # 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:<port>, 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'
|
incus) cat <<'EOF'
|
||||||
The door out. box wraps the box lifecycle and the isolation model, not
|
The door out. box wraps the box lifecycle and the isolation model, not
|
||||||
|
|
@ -348,6 +374,37 @@ cloud-init error that names none of them.
|
||||||
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
|
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
|
||||||
unless --fix or --pin-dns is given.
|
unless --fix or --pin-dns is given.
|
||||||
EOF
|
EOF
|
||||||
|
;;
|
||||||
|
setup-host) cat <<'EOF'
|
||||||
|
Prepare this host to mint boxes — one time. Installs Incus and builds the
|
||||||
|
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
|
||||||
|
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
|
||||||
|
Idempotent — safe to re-run after a box upgrade to pick up stack changes.
|
||||||
|
|
||||||
|
If it has to add you to the incus-admin group it will say so and exit; log
|
||||||
|
back in (or 'sg incus-admin') and run it again.
|
||||||
|
|
||||||
|
box setup-host
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
teardown-host) cat <<'EOF'
|
||||||
|
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
|
||||||
|
ACLs, the profiles, and the firewall rules of BOTH name generations (current
|
||||||
|
and pre-0.4.0). Asks first.
|
||||||
|
|
||||||
|
box teardown-host # the stack; leaves Incus installed
|
||||||
|
box teardown-host --purge-incus # ...and uninstall Incus too
|
||||||
|
EOF
|
||||||
|
;;
|
||||||
|
migrate-host) cat <<'EOF'
|
||||||
|
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
|
||||||
|
preserves a box's authed state (no re-login) — it only re-tags and reassigns
|
||||||
|
the profile, then verifies the box works on its new network leg.
|
||||||
|
|
||||||
|
box migrate-host --box <name> # re-home one legacy box
|
||||||
|
box migrate-host --all-boxes # re-home every legacy box
|
||||||
|
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
|
||||||
|
EOF
|
||||||
;;
|
;;
|
||||||
status) cat <<'EOF'
|
status) cat <<'EOF'
|
||||||
Deprecated alias for 'box list'. It ignored the <box> argument it
|
Deprecated alias for 'box list'. It ignored the <box> argument it
|
||||||
|
|
@ -399,6 +456,10 @@ while [ $# -gt 0 ]; do
|
||||||
-*)
|
-*)
|
||||||
# doctor's flags belong to the doctor script, not to box
|
# doctor's flags belong to the doctor script, not to box
|
||||||
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
|
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
|
||||||
|
# the host verbs delegate their flags to the scripts they wrap
|
||||||
|
case "$cmd" in setup-host|teardown-host|migrate-host) args+=("$1"); shift; continue ;; esac
|
||||||
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
|
||||||
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
|
||||||
fi
|
fi
|
||||||
|
|
@ -475,14 +536,45 @@ pick_mode() {
|
||||||
# forensics: the VM's console says why, and the box is torn down by whoever
|
# forensics: the VM's console says why, and the box is torn down by whoever
|
||||||
# called us before anyone can read it.
|
# called us before anyone can read it.
|
||||||
wait_agent() {
|
wait_agent() {
|
||||||
local n="$1" i
|
local n="$1" i clog
|
||||||
echo "box: waiting for instance agent..."
|
echo "box: waiting for instance agent..."
|
||||||
for i in $(seq 1 150); do
|
for i in $(seq 1 150); do
|
||||||
if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
|
if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
|
||||||
if [ "$i" -eq 150 ]; then
|
if [ "$i" -eq 150 ]; then
|
||||||
echo "box: instance agent never came up. The VM's console log:" >&2
|
# The console log is FULL of terminal escape sequences (boot messages,
|
||||||
timeout -k 5 15 incus console "$n" --show-log 2>/dev/null | tail -15 | sed 's/^/ /' >&2
|
# a firmware menu). Dumping it raw scrambles the operator's terminal —
|
||||||
die "agent unreachable after 5 minutes (incus console $n to inspect live)"
|
# 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 </dev/null >"$clog.raw" 2>/dev/null || true
|
||||||
|
# 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 non-blank lines:)" >&2
|
||||||
|
grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/ /' >&2
|
||||||
|
# A box that never boots is NOT a slow box, and the console says which
|
||||||
|
# failure it is. Each of these cost hours to diagnose by hand once; the
|
||||||
|
# box that hits them next should be told the answer, not the symptom.
|
||||||
|
if grep -qiE 'Failed to decompress kernel|efi_stub_entry\(\) failed' "$clog" 2>/dev/null; then
|
||||||
|
echo "box: THE KERNEL WOULD NOT DECOMPRESS — the cached image is corrupt." >&2
|
||||||
|
echo "box: (a truncated/bad image download does exactly this). Re-pull it:" >&2
|
||||||
|
echo "box: incus image list # find the fingerprint" >&2
|
||||||
|
echo "box: incus image delete <fingerprint> # the next mint re-downloads" >&2
|
||||||
|
elif grep -qiE 'bad shim signature|prohibited by secure boot' "$clog" 2>/dev/null; then
|
||||||
|
echo "box: SECURE BOOT rejected the kernel — but box mints VMs with" >&2
|
||||||
|
echo "box: security.secureboot=false, so this box predates that fix or was" >&2
|
||||||
|
echo "box: created by hand. Re-mint it with a current box." >&2
|
||||||
|
elif 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 never booted." >&2
|
||||||
|
echo "box: this is the IMAGE, not box. Re-pull it (incus image delete …)," >&2
|
||||||
|
echo "box: or pin a known-good build in the template's BOX_IMAGE." >&2
|
||||||
|
fi
|
||||||
|
die "agent unreachable (inspect live: incus console $n)"
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
@ -585,7 +677,14 @@ cmd_new() {
|
||||||
load_template "$t"
|
load_template "$t"
|
||||||
m="$(pick_mode)"
|
m="$(pick_mode)"
|
||||||
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
|
# 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,
|
# The template's identity is stamped ONTO the instance: which template,
|
||||||
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
|
||||||
# knows what it is without ever consulting the template again.
|
# knows what it is without ever consulting the template again.
|
||||||
|
|
@ -663,6 +762,21 @@ box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if no
|
||||||
| grep . || echo "-"
|
| grep . || echo "-"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The box's address ON BOXNET — which is NOT the same as "its first address".
|
||||||
|
# A box running docker also carries 172.17.0.1 (docker0), and Incus happily
|
||||||
|
# lists that FIRST. box_ipv4() hands you the decoy, and pointing anything at it
|
||||||
|
# is pointing at the wrong interface: 'box expose' did exactly that until Incus
|
||||||
|
# refused with `Connect IP "172.17.0.1" must be one of the instance's static
|
||||||
|
# IPv4 addresses`. The drill has known this trap since run 4; the CLI had not.
|
||||||
|
# Derive the prefix from the network rather than hardcoding it.
|
||||||
|
box_net_ip() {
|
||||||
|
local pfx
|
||||||
|
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
|
||||||
|
[ -n "$pfx" ] || return 1
|
||||||
|
incus list "$1" --format csv --columns 4 2>/dev/null \
|
||||||
|
| tr -d '"' | tr ' ,' '\n\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
|
||||||
|
}
|
||||||
|
|
||||||
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
|
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
|
||||||
short_type() {
|
short_type() {
|
||||||
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
|
@ -716,6 +830,15 @@ cmd_info() {
|
||||||
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
|
||||||
IPV4 "$(box_ipv4 "$inst")"
|
IPV4 "$(box_ipv4 "$inst")"
|
||||||
|
|
||||||
|
# A box with a hole says so — an exposure visible only to --list is a hole
|
||||||
|
# info would deny. One line per open door.
|
||||||
|
local d listen
|
||||||
|
while IFS= read -r d; do
|
||||||
|
case "$d" in expose-*) : ;; *) continue ;; esac
|
||||||
|
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
|
||||||
|
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
|
||||||
|
done < <(incus config device list "$inst" 2>/dev/null)
|
||||||
|
|
||||||
echo
|
echo
|
||||||
case "${snaps:-0}" in
|
case "${snaps:-0}" in
|
||||||
''|0)
|
''|0)
|
||||||
|
|
@ -791,6 +914,19 @@ cmd_doctor() {
|
||||||
exec bash "$script" "${args[@]}"
|
exec bash "$script" "${args[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The host lifecycle scripts, as first-class verbs — nobody should have to know
|
||||||
|
# where the install tree keeps its scripts. Each execs the installed script
|
||||||
|
# with its flags passed through; the script owns its own behavior (setup-host's
|
||||||
|
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
|
||||||
|
host_script() { # $1 = script basename under host/
|
||||||
|
local script="$root/host/$1"
|
||||||
|
[ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
|
||||||
|
exec bash "$script" "${args[@]}"
|
||||||
|
}
|
||||||
|
cmd_setup_host() { host_script setup-host.sh; }
|
||||||
|
cmd_teardown_host() { host_script teardown-host.sh; }
|
||||||
|
cmd_migrate_host() { host_script migrate-host.sh; }
|
||||||
|
|
||||||
cmd_help() { show_help "${args[0]:-}"; }
|
cmd_help() { show_help "${args[0]:-}"; }
|
||||||
|
|
||||||
# The escape hatch. The box is resolved and tag-checked; everything else is
|
# The escape hatch. The box is resolved and tag-checked; everything else is
|
||||||
|
|
@ -818,6 +954,145 @@ cmd_incus() {
|
||||||
incus "${out[@]}"
|
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-<port>), 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 in NAT mode DNATs host
|
||||||
|
# 127.0.0.1:<hostport> to the box's <ip>:<port>. The traffic rides the
|
||||||
|
# network into the guest, so 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 door can reach it. Three
|
||||||
|
# pieces beside the device itself, each one a drill-found absence:
|
||||||
|
# · a SCOPED ACL allow (this box's ip + this port only) — the ingress drop
|
||||||
|
# that makes A7 true would eat the DNAT'd packet;
|
||||||
|
# · route_localnet + a loopback masquerade on the host (box-firewall.sh) —
|
||||||
|
# Incus installs only the DNAT, and a loopback-sourced packet can neither
|
||||||
|
# leave the host nor be answered without them;
|
||||||
|
# · a static ipv4.address pin on the NIC — NAT mode refuses to start
|
||||||
|
# without one (see below).
|
||||||
|
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 <port>
|
||||||
|
if [ "$a2" = "--remove" ]; then
|
||||||
|
local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
|
||||||
|
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_net_ip "$inst" || true)"
|
||||||
|
[ -n "$ip" ] && incus network acl rule remove box-isolate ingress \
|
||||||
|
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
||||||
|
# If that was the last door, unpin the static address it required. Only
|
||||||
|
# then — other exposures still lean on the pin. Best-effort, like the ACL.
|
||||||
|
if ! incus config device list "$inst" 2>/dev/null | grep -q '^expose-'; then
|
||||||
|
incus config device unset "$inst" eth0 ipv4.address >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# expose <port> [<host-port>]
|
||||||
|
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_net_ip "$inst")" \
|
||||||
|
|| die "$box has no boxnet address 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
|
||||||
|
|
||||||
|
# A VM's proxy device must be NAT mode — Incus supports proxy on containers in
|
||||||
|
# both modes, but on VMs "NAT mode only" (the userspace forkproxy is a
|
||||||
|
# container thing). NAT mode DNATs host:port → instance:port in netfilter, and
|
||||||
|
# it needs the host to be the instance's gateway, which boxnet makes true.
|
||||||
|
#
|
||||||
|
# And NAT mode needs a STATIC address. Incus resolves connect=0.0.0.0 to the
|
||||||
|
# NIC's ipv4.address — the device config, not the lease — and refuses when it
|
||||||
|
# is unset: `Instance has no static IPv4 address assigned to be used as the
|
||||||
|
# connect IP` (the 0.5.0 drill). The first cut pinned an address but the
|
||||||
|
# WRONG one (box_ipv4's docker0 decoy); the second cut removed the pin
|
||||||
|
# instead of correcting it. Third cut: pin the box's current BOXNET lease.
|
||||||
|
# Same address the box already holds, so nothing about its networking moves —
|
||||||
|
# the lease just becomes official. Left in place across exposures; unpinned
|
||||||
|
# when the last door closes.
|
||||||
|
local err; err="$(mktemp)"
|
||||||
|
if [ -z "$(incus config device get "$inst" eth0 ipv4.address 2>/dev/null)" ]; then
|
||||||
|
# override copies the profile NIC into the instance with the key set; if a
|
||||||
|
# local eth0 already exists, override refuses and set is the right verb.
|
||||||
|
if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err" \
|
||||||
|
&& ! incus config device set "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
|
||||||
|
echo "box: could not pin $box's boxnet address ($ip) as static — the NAT proxy requires one:" >&2
|
||||||
|
sed 's/^/ /' "$err" >&2; rm -f "$err"
|
||||||
|
incus network acl rule remove box-isolate ingress action=allow \
|
||||||
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
||||||
|
die "expose failed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# connect=0.0.0.0 is deliberate: Incus resolves it to the instance's static
|
||||||
|
# IPv4 (the pin above). Naming an address here would work too, but 0.0.0.0
|
||||||
|
# cannot repeat the docker0 mistake — there is nothing to get wrong.
|
||||||
|
if incus config device add "$inst" "$dev" proxy \
|
||||||
|
"listen=tcp:127.0.0.1:$hport" "connect=tcp:0.0.0.0:$port" \
|
||||||
|
bind=host nat=true >/dev/null 2>"$err"; then
|
||||||
|
rm -f "$err"
|
||||||
|
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)"
|
||||||
|
# The device alone is not the door: the DNAT'd loopback packet also needs
|
||||||
|
# route_localnet on the bridge (box-firewall.sh installs it, with the
|
||||||
|
# masquerade). Readable without root — warn instead of handing over a
|
||||||
|
# door that silently does not answer.
|
||||||
|
if [ "$(cat /proc/sys/net/ipv4/conf/boxnet/route_localnet 2>/dev/null)" != 1 ]; then
|
||||||
|
echo "box: WARNING — route_localnet is off on boxnet, so this door will NOT answer." >&2
|
||||||
|
echo "box: the host firewall predates expose — apply it: sudo /usr/local/sbin/box-firewall" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# NEVER swallow incus's reason — the first cut of this verb died with a bare
|
||||||
|
# "could not add the proxy device" and told the drill nothing.
|
||||||
|
echo "box: incus refused the proxy device:" >&2
|
||||||
|
sed 's/^/ /' "$err" >&2; rm -f "$err"
|
||||||
|
incus network acl rule remove box-isolate ingress action=allow \
|
||||||
|
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
|
||||||
|
die "expose failed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --- dispatch: driven by the table, not by a hand-written case --------------
|
# --- dispatch: driven by the table, not by a hand-written case --------------
|
||||||
|
|
||||||
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
row="$(cmd_row "$cmd")" || unknown_command "$cmd"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
See issue #3 for the full reframe and rationale. This doc captures the durable
|
||||||
|
|
@ -10,13 +10,13 @@ design decisions.
|
||||||
|
|
||||||
- **The tool** mints isolated boxes with Claude installed but **unauthenticated**.
|
- **The tool** mints isolated boxes with Claude installed but **unauthenticated**.
|
||||||
It knows nothing about projects, secrets, recipes, or memory.
|
It knows nothing about projects, secrets, recipes, or memory.
|
||||||
- **The agent** (Claude Code, inside the box) reads an optional `.claudebox/`
|
- **The agent** (Claude Code, inside the box) reads an optional `.box/`
|
||||||
runbook in a cloned repo and acts on it. The recipe's consumer is the
|
runbook in a cloned repo and acts on it. The recipe's consumer is the
|
||||||
reasoning agent, not host machinery.
|
reasoning agent, not host machinery.
|
||||||
|
|
||||||
## Boxes are strictly creds-free
|
## Boxes are strictly creds-free
|
||||||
|
|
||||||
`claudebox new --name <n>` launches a blank box: everything installed, **no**
|
`box new --name <n>` launches a blank box: everything installed, **no**
|
||||||
git credentials and **no** Claude credentials. The operator authenticates
|
git credentials and **no** Claude credentials. The operator authenticates
|
||||||
interactively *inside* the box:
|
interactively *inside* the box:
|
||||||
|
|
||||||
|
|
@ -33,35 +33,35 @@ multi-user problem: nothing shared, nothing committed.
|
||||||
Re-authing every fresh box would be toil, so authenticated state is reused via
|
Re-authing every fresh box would be toil, so authenticated state is reused via
|
||||||
snapshots, not a secrets store:
|
snapshots, not a secrets store:
|
||||||
|
|
||||||
- `claudebox snapshot <n> [label]` — checkpoint after login + clone.
|
- `box snapshot <n> [label]` — checkpoint after login + clone.
|
||||||
- `claudebox new --name <n2> --from <src>[/<snapshot>]` — clone an existing box
|
- `box new --name <n2> --from <src>[/<snapshot>]` — clone an existing box
|
||||||
or snapshot (authed state and all). Isolation is preserved: the clone keeps
|
or snapshot (authed state and all). Isolation is preserved: the clone keeps
|
||||||
the `claude-dev` profile + `claudenet` + ACL.
|
the `claude-dev` profile + `claudenet` + ACL.
|
||||||
- `claudebox restore <n> <snapshot>` — roll a box back to a checkpoint.
|
- `box restore <n> <snapshot>` — roll a box back to a checkpoint.
|
||||||
|
|
||||||
Log in once → snapshot → spin up authed boxes from it.
|
Log in once → snapshot → spin up authed boxes from it.
|
||||||
|
|
||||||
## The box announces itself to the agent
|
## The box announces itself to the agent
|
||||||
|
|
||||||
cloud-init installs a global `~/.claude/CLAUDE.md` in every box telling Claude it
|
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
|
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
|
||||||
execution.
|
execution.
|
||||||
|
|
||||||
## `.claudebox/` is optional, agent-facing documentation
|
## `.box/` is optional, agent-facing documentation
|
||||||
|
|
||||||
Not host-executed shell. A repo that wants to be easy to stand up in a sandbox
|
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
|
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`.
|
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
|
Boxes are ordinary Incus instances, tagged `user.box=1`. That makes every
|
||||||
Incus verb a candidate feature request — `rename`, `info`, `file push`, on
|
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:
|
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
|
> the `user.box=1` boundary (never touch an instance we didn't mint), the
|
||||||
> isolation stack (`claude-dev` profile + `claudenet` + ACL), or the creds-free
|
> isolation stack (`claude-dev` profile + `claudenet` + ACL), or the creds-free
|
||||||
> snapshot→clone workflow. Everything else is Incus's job.
|
> 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.
|
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.
|
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
|
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
|
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
|
row; a verb that can't be expressed as a row and enforces no invariant of ours
|
||||||
doesn't belong in the tool.
|
doesn't belong in the tool.
|
||||||
|
|
||||||
**The escape hatch** — `claudebox incus <box> -- <args...>` — resolves and
|
**The escape hatch** — `box incus <box> -- <args...>` — resolves and
|
||||||
tag-checks the box, then hands the rest to Incus verbatim. It means "no" to a
|
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:
|
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
|
the box off the isolation stack (profile, network, device, `security.*`), it
|
||||||
warns and proceeds — from there the trust boundary is yours to keep.
|
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.
|
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 → 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
|
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
|
their frames are *switched* between bridge ports and never traverse the
|
||||||
netfilter path an L3 ACL lives on — the ACL looked airtight (it drops
|
netfilter path an L3 ACL lives on — the ACL looked airtight (it drops
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
# The `.claudebox/` convention
|
# The `.box/` convention
|
||||||
|
|
||||||
`claudebox` mints trust-less, creds-free, isolated VMs with Claude Code already
|
`box` mints trust-less, creds-free, isolated VMs with Claude Code already
|
||||||
installed (`claudebox new/shell/snapshot/restore/exec/down/start/rm/status`). The
|
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
|
tool knows **nothing** about your project. There is no `install` step and no
|
||||||
host-run setup script.
|
host-run setup script.
|
||||||
|
|
||||||
A project makes itself easy to stand up inside a box by shipping an optional
|
A project makes itself easy to stand up inside a box by shipping an optional
|
||||||
`.claudebox/` folder. This folder is **agent-facing documentation** — read and
|
`.box/` folder. This folder is **agent-facing documentation** — read and
|
||||||
acted on by Claude Code (the reasoning agent) running inside the box. It is not
|
acted on by Claude Code (the reasoning agent) running inside the box. It is not
|
||||||
shell that the host executes.
|
shell that the host executes.
|
||||||
|
|
||||||
|
> The folder was named `.claudebox/` before the 0.5.0 rename. Repos that still
|
||||||
|
> ship `.claudebox/` keep working — the agent is told to read either — but new
|
||||||
|
> projects should use `.box/`.
|
||||||
|
|
||||||
## What it is / what it is not
|
## What it is / what it is not
|
||||||
|
|
||||||
- **Optional.** No `.claudebox/` is a perfectly valid state.
|
- **Optional.** No `.box/` is a perfectly valid state.
|
||||||
- **Agent-facing.** You are writing instructions to a reasoning agent, not a
|
- **Agent-facing.** You are writing instructions to a reasoning agent, not a
|
||||||
machine. Prose is fine; the agent adapts.
|
machine. Prose is fine; the agent adapts.
|
||||||
- **Not a host contract.** The host never parses, sources, or runs anything in
|
- **Not a host contract.** The host never parses, sources, or runs anything in
|
||||||
|
|
@ -23,34 +27,34 @@ shell that the host executes.
|
||||||
## How it's consumed
|
## How it's consumed
|
||||||
|
|
||||||
Every box ships a global `~/.claude/CLAUDE.md` telling Claude it is inside a
|
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 `.box/` folder as its bootstrap runbook.
|
||||||
So the whole flow is:
|
So the whole flow is:
|
||||||
|
|
||||||
```
|
```
|
||||||
claudebox new # get a box
|
box new # get a box
|
||||||
claudebox shell # get in
|
box shell # get in
|
||||||
git clone <repo> && cd <repo>
|
git clone <repo> && cd <repo>
|
||||||
claude # Claude reads .claudebox/ and brings the project up
|
claude # Claude reads .box/ and brings the project up
|
||||||
```
|
```
|
||||||
|
|
||||||
The operator can also just say: *"set this project up per .claudebox"*.
|
The operator can also just say: *"set this project up per .box"*.
|
||||||
|
|
||||||
## Suggested contents (all optional)
|
## Suggested contents (all optional)
|
||||||
|
|
||||||
Author everything here for a reasoning agent.
|
Author everything here for a reasoning agent.
|
||||||
|
|
||||||
- **`.claudebox/SETUP.md`** — the prose runbook. Prerequisites, how to install
|
- **`.box/SETUP.md`** — the prose runbook. Prerequisites, how to install
|
||||||
deps, how to start services, how to template the env, how to seed data, and
|
deps, how to start services, how to template the env, how to seed data, and
|
||||||
how to smoke-test. Written as instructions to Claude.
|
how to smoke-test. Written as instructions to Claude.
|
||||||
- **Helper scripts** (e.g. `.claudebox/dev-up.sh`) that the runbook tells Claude
|
- **Helper scripts** (e.g. `.box/dev-up.sh`) that the runbook tells Claude
|
||||||
to run. Claude decides to run them; the host never does.
|
to run. Claude decides to run them; the host never does.
|
||||||
- **`.claudebox/env.template`** — example env the runbook explains how to fill.
|
- **`.box/env.template`** — example env the runbook explains how to fill.
|
||||||
Staging values the operator pastes in. **Never commit real secrets.**
|
Staging values the operator pastes in. **Never commit real secrets.**
|
||||||
- **`.claudebox/compose.yml`** — optional services the runbook starts.
|
- **`.box/compose.yml`** — optional services the runbook starts.
|
||||||
|
|
||||||
## Worked example
|
## Worked example
|
||||||
|
|
||||||
A minimal `.claudebox/SETUP.md` for a Node + Postgres app:
|
A minimal `.box/SETUP.md` for a Node + Postgres app:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
# Setup
|
# Setup
|
||||||
|
|
@ -58,8 +62,8 @@ A minimal `.claudebox/SETUP.md` for a Node + Postgres app:
|
||||||
This is a Node service backed by Postgres.
|
This is a Node service backed by Postgres.
|
||||||
|
|
||||||
1. Install deps: `npm ci`
|
1. Install deps: `npm ci`
|
||||||
2. Start Postgres: `docker compose -f .claudebox/compose.yml up -d`
|
2. Start Postgres: `docker compose -f .box/compose.yml up -d`
|
||||||
3. Create the env file: copy `.claudebox/env.template` to `.env` and ask the
|
3. Create the env file: copy `.box/env.template` to `.env` and ask the
|
||||||
operator to fill in `DATABASE_URL` and `API_KEY` (staging values).
|
operator to fill in `DATABASE_URL` and `API_KEY` (staging values).
|
||||||
4. Run migrations: `npm run migrate`
|
4. Run migrations: `npm run migrate`
|
||||||
5. Start the app: `npm run dev`
|
5. Start the app: `npm run dev`
|
||||||
|
|
@ -72,7 +76,7 @@ That's it — Claude reads it top to bottom and adapts if reality differs.
|
||||||
|
|
||||||
- **Keep it declarative and resilient.** State intent and steps; let the agent
|
- **Keep it declarative and resilient.** State intent and steps; let the agent
|
||||||
adapt when the repo has drifted. Don't hard-code brittle assumptions.
|
adapt when the repo has drifted. Don't hard-code brittle assumptions.
|
||||||
- **Never put real credentials in `.claudebox/`.** Templates and staging
|
- **Never put real credentials in `.box/`.** Templates and staging
|
||||||
placeholders only. The operator pastes real values at runtime.
|
placeholders only. The operator pastes real values at runtime.
|
||||||
- **No `.claudebox/` is fine.** The operator can stand the project up by hand,
|
- **No `.box/` is fine.** The operator can stand the project up by hand,
|
||||||
or let Claude infer the steps from the repo's `README` / `CLAUDE.md`.
|
or let Claude infer the steps from the repo's `README` / `CLAUDE.md`.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# The drill
|
# 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
|
set up the host, mint boxes, drive the whole surface, check that the isolation
|
||||||
actually holds — and run the full
|
actually holds — and run the full
|
||||||
[#15 audit](https://github.com/heavy-duty/claudebox/issues/15), including a live
|
[#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**.
|
mints boxes with **no DNS**.
|
||||||
|
|
||||||
**Iterating on the drill?** Read [RUNS.md](RUNS.md) first — it is the run log:
|
**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
|
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
|
diagnose a stall, and how to run a single probe by hand instead of paying for a
|
||||||
whole run.
|
whole run.
|
||||||
|
|
@ -46,7 +46,7 @@ wrong. The drill runs the real thing.
|
||||||
|
|
||||||
## What it checks
|
## 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 <inst> user.claudebox` returns `1` (this is on the path of
|
that `incus config get <inst> user.claudebox` returns `1` (this is on the path of
|
||||||
*every* box command — if it lies, everything fails closed); that the
|
*every* box command — if it lies, everything fails closed); that the
|
||||||
`user.claudebox=1` list filter selects our instances and excludes an untagged
|
`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 <box>`).
|
(typo'd command, typo'd flag, `list <box>`).
|
||||||
|
|
||||||
**The boundary** gets its own treatment: the drill launches an instance
|
**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.
|
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
|
**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
|
design. The drill confirms Claude Code is installed and runnable; authenticating
|
||||||
is yours.
|
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**
|
still runs, but it says loudly that **the VM trust boundary was not validated**
|
||||||
rather than passing quietly on a weaker one.
|
rather than passing quietly on a weaker one.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
ok "dns.mode = none — a box cannot enumerate its siblings by name"
|
||||||
else
|
else
|
||||||
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
|
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
|
||||||
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
|
inf "fix: re-run: box setup-host"
|
||||||
[ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
|
[ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
|
||||||
fi
|
fi
|
||||||
inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)"
|
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"
|
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 "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 "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: box setup-host"
|
||||||
fi
|
fi
|
||||||
for k in security.mac_filtering security.ipv4_filtering; do
|
for k in security.mac_filtering security.ipv4_filtering; do
|
||||||
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
|
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"
|
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 "· 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 "· 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: box setup-host (it pins the resolver)"
|
||||||
inf " or quick-test the pin alone: bash drill/doctor.sh --pin-dns"
|
inf " or quick-test the pin alone: bash drill/doctor.sh --pin-dns"
|
||||||
else
|
else
|
||||||
inf "boxes inherit the host's resolver (unpinned — setup-host.sh pins this now; re-run it)"
|
inf "boxes inherit the host's resolver (unpinned — setup-host.sh pins this now; re-run it)"
|
||||||
|
|
|
||||||
227
drill/drill.sh
227
drill/drill.sh
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env bash
|
#!/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.
|
# ⚠ 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
|
# 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.
|
# false FAILs on the first live run. The pipeline verdict must be grep's alone.
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
|
REPO="${BOX_REPO:-heavy-duty/claudebox}"
|
||||||
REF="${CLAUDEBOX_REF:-main}"
|
REF="${BOX_REF:-main}"
|
||||||
YES=0; KEEP=0
|
YES=0; KEEP=0
|
||||||
SELF="$(readlink -f "$0")"
|
SELF="$(readlink -f "$0")"
|
||||||
|
|
||||||
|
|
@ -57,9 +57,11 @@ inf() { printf ' %s\n' "$*"; }
|
||||||
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
|
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
|
||||||
aud() { audit+=("$*"); } # an answer for the #15 audit
|
aud() { audit+=("$*"); } # an answer for the #15 audit
|
||||||
|
|
||||||
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
|
wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
|
||||||
|
# 2 min was too short: run 17's legacy box came up AFTER the window closed —
|
||||||
|
# the drill called it dead and then every migration check on it passed.
|
||||||
local b="$1" _i
|
local b="$1" _i
|
||||||
for _i in $(seq 1 60); do
|
for _i in $(seq 1 120); do
|
||||||
box exec "$b" -- true >/dev/null 2>&1 && return 0
|
box exec "$b" -- true >/dev/null 2>&1 && return 0
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
@ -159,8 +161,8 @@ This will, ON THIS HOST ($(hostname)):
|
||||||
· install Incus and a systemd unit
|
· install Incus and a systemd unit
|
||||||
· create a network (boxnet), an ACL, and a profile
|
· create a network (boxnet), an ACL, and a profile
|
||||||
· rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain)
|
· 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, legacybox
|
||||||
· mutate the network and profile mid-run to rehearse the #16 hardening
|
· build a faithful legacy stack (claudenet/10.87, claude-dev) to drill migration
|
||||||
Only do this on a machine you can format.
|
Only do this on a machine you can format.
|
||||||
EOF
|
EOF
|
||||||
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
|
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
|
||||||
|
|
@ -170,11 +172,28 @@ EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
phase "Installing box ($REPO@$REF)"
|
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")" \
|
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|
||||||
|| { echo "install failed"; exit 1; }
|
|| { echo "install failed"; exit 1; }
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
||||||
|
# ASSERT WHAT LANDED — never trust that the install obeyed us.
|
||||||
|
# This has bitten twice: once on a lagged CDN tarball, once when a STALE local
|
||||||
|
# drill.sh passed the retired CLAUDEBOX_* env vars to a 0.5.0 install.sh that
|
||||||
|
# reads BOX_* — the vars were ignored, main was installed, and the run drilled
|
||||||
|
# the wrong tree while reporting success. A drill that silently drills the
|
||||||
|
# wrong code is worse than one that fails.
|
||||||
|
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
|
||||||
|
if [ "$got" != "$REPO@$REF" ]; then
|
||||||
|
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
|
||||||
|
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
|
||||||
|
echo " which today's install.sh ignores, so it fell back to main). Fix:" >&2
|
||||||
|
echo " git fetch origin && git checkout <the branch you mean> && git pull" >&2
|
||||||
|
echo " then re-run this drill." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
inf "installed tree confirms: $got"
|
||||||
|
|
||||||
phase "Host setup (Incus, boxnet, ACL, profile, firewall)"
|
phase "Host setup (Incus, boxnet, ACL, profile, firewall)"
|
||||||
# setup-host.sh installs nftables itself when neither nft nor UFW exists
|
# setup-host.sh installs nftables itself when neither nft nor UFW exists
|
||||||
# (a stock Debian 13 cloud image ships neither). This guard is a tripwire:
|
# (a stock Debian 13 cloud image ships neither). This guard is a tripwire:
|
||||||
|
|
@ -206,10 +225,10 @@ EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
inf "running setup-host.sh (first pass: may only add you to incus-admin)…"
|
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.
|
# The group we were just added to isn't in this shell's credentials yet.
|
||||||
inf "re-entering inside the incus-admin group…"
|
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
|
fi
|
||||||
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
|
|
@ -243,7 +262,7 @@ fi
|
||||||
inf "clearing anything a previous run left behind…"
|
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,
|
# 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.
|
# 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 legacybox; do
|
||||||
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
|
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
|
||||||
done
|
done
|
||||||
if incus network show boxnet >/dev/null 2>&1; then
|
if incus network show boxnet >/dev/null 2>&1; then
|
||||||
|
|
@ -259,7 +278,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"
|
[ -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)…"
|
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 "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 " 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
|
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
|
||||||
|
|
@ -367,7 +386,7 @@ phase "B. The box surface"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
# Compare against the installed tree's VERSION file, not a hardcoded number —
|
||||||
# a pinned literal here would fail the drill on every release.
|
# 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)"
|
v="$(box --version 2>&1)"
|
||||||
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
|
||||||
|
|
||||||
|
|
@ -385,14 +404,18 @@ else
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- templates: the mint surface is itself a surface to test ----------------
|
# --- 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' \
|
tpl_missing=""
|
||||||
&& ok "templates: lists blank and claude" || no "templates listing is missing a shipped template"
|
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' \
|
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"
|
&& 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
|
# 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
|
# bad template in the installed tree (the drill owns this host), expect the
|
||||||
# parser to reject it BY NAME, remove it.
|
# 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"
|
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'" \
|
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" \
|
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|
||||||
|
|
@ -425,14 +448,50 @@ 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"
|
box rm tpl --force >/dev/null 2>&1 && ok "blank box removed" || no "could not remove the blank box"
|
||||||
else
|
else
|
||||||
no "blank mint FAILED — tail: $(tail -3 /tmp/mint-tpl.log | tr '\n' ' ')"
|
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
|
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; 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 >/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 2>/dev/null)"
|
||||||
|
# Do not throw the evidence away — say WHAT the installer actually left.
|
||||||
|
# Do NOT throw the evidence away — say what the installer actually left
|
||||||
|
# behind, and what its own log said. Guessing at an upstream installer's
|
||||||
|
# layout is how this FAILed in the first place.
|
||||||
|
inf "anything named '$t' on disk:"
|
||||||
|
in_box "$t" sh -c "find /home /opt /usr/local /usr/bin -maxdepth 4 \\( -type f -o -type l \\) -iname '*$t*' 2>/dev/null | head -8" \
|
||||||
|
| sed 's/^/ /'
|
||||||
|
inf "what its cloud-init said:"
|
||||||
|
in_box "$t" sh -c "grep -iE '$t|install' /var/log/cloud-init-output.log 2>/dev/null | tail -8" \
|
||||||
|
| sed 's/^/ /'
|
||||||
|
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' ' ')"
|
||||||
|
timeout -k 5 60 incus delete -f "$t" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
printf '\n minting a claude box (cold, ~10 min)…\n'
|
printf '\n minting a claude box (cold, ~10 min)…\n'
|
||||||
t0=$SECONDS
|
t0=$SECONDS
|
||||||
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
if mint_box /tmp/mint-drill.log --name drill --template claude; then
|
||||||
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
|
||||||
else
|
else
|
||||||
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.log | tr '\n' ' ')"
|
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
|
echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -638,6 +697,63 @@ else
|
||||||
aud "A7 inbound host→box: NOT PROBED"
|
aud "A7 inbound host→box: NOT PROBED"
|
||||||
fi
|
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 &' </dev/null
|
||||||
|
sleep 3
|
||||||
|
xlog="$(mktemp)"
|
||||||
|
if box expose archive "$EP" "$EHP" >"$xlog" 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"
|
||||||
|
inf "what box and incus actually said:"
|
||||||
|
sed 's/^/ /' "$xlog" 2>/dev/null
|
||||||
|
rm -f "$srv" 2>/dev/null
|
||||||
|
fi
|
||||||
|
rm -f "$xlog" 2>/dev/null
|
||||||
|
timeout -k 5 15 incus exec archive -- pkill -f srv.js </dev/null >/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"
|
phase "D. The isolation contract, stated"
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -671,6 +787,79 @@ if [ "$BASELINE_OK" -ne 1 ]; then
|
||||||
inf "start with: bash drill/doctor.sh"
|
inf "start with: bash drill/doctor.sh"
|
||||||
fi
|
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/box/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'
|
||||||
|
# The legacy box must carry a 'claude' user, because that is what a real
|
||||||
|
# pre-0.4.0 box had — and box_user() maps the legacy tag to it. Without the
|
||||||
|
# user, 'box exec' (sudo -u claude) can never answer and wait_box fails
|
||||||
|
# forever on a box that is perfectly healthy. Run 17/18 lost a FAIL to this.
|
||||||
|
if mint_legacy=$(incus launch images:debian/13/cloud legacybox --profile claude-dev \
|
||||||
|
--config user.claudebox=1 --vm --device root,size=20GiB \
|
||||||
|
--config security.secureboot=false \
|
||||||
|
--config cloud-init.user-data="$(printf '#cloud-config\nusers:\n - name: claude\n shell: /bin/bash\n sudo: "ALL=(ALL) NOPASSWD:ALL"\n lock_passwd: true\n')" 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
|
if [ "$KEEP" = 1 ]; then
|
||||||
phase "Boxes left up (--keep-boxes)"
|
phase "Boxes left up (--keep-boxes)"
|
||||||
|
|
@ -678,11 +867,11 @@ if [ "$KEEP" = 1 ]; then
|
||||||
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
|
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
|
||||||
else
|
else
|
||||||
# every name the drill can have left, whatever branch a partial run took
|
# 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 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
|
# 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
|
# already embodies the discipline (only names the drill minted); demanding
|
||||||
# 'no boxes yet' here would flag any pre-existing operator box as a failure.
|
# '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" \
|
[ -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' ' ')"
|
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
|
||||||
fi
|
fi
|
||||||
|
|
@ -702,5 +891,5 @@ fi
|
||||||
echo
|
echo
|
||||||
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
|
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 "(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 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,25 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# No UFW: protect the host's own sockets with a dedicated nft table.
|
# No UFW: protect the host's own sockets with a dedicated nft table.
|
||||||
if ! nft list table inet box >/dev/null 2>&1; then
|
# Flush + rebuild rather than skip-if-present: a guard that only checks
|
||||||
nft add table inet box
|
# existence pins every host to the rule set of the release that FIRST ran
|
||||||
nft 'add chain inet box input { type filter hook input priority -5 ; }'
|
# here, and an upgraded rule never lands. 'add chain' with the same spec is
|
||||||
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
|
# a no-op, so this converges.
|
||||||
nft add rule inet box input iifname "$NET" tcp dport 53 accept
|
#
|
||||||
nft add rule inet box input iifname "$NET" drop
|
# The established,related accept is load-bearing for 'box expose' (#55): the
|
||||||
fi
|
# door's traffic reaches the box masqueraded as the gateway, so the box's
|
||||||
|
# REPLY arrives here as input on boxnet — a stateless drop eats it and the
|
||||||
|
# door times out (drill-found). Boxes still cannot INITIATE toward the host:
|
||||||
|
# a box-originated SYN is a NEW flow, and NEW is what the drop is for.
|
||||||
|
# (UFW hosts get the same semantics from ufw's built-in RELATED,ESTABLISHED
|
||||||
|
# accept in before.rules — this branch must match it.)
|
||||||
|
nft add table inet box
|
||||||
|
nft 'add chain inet box input { type filter hook input priority -5 ; }'
|
||||||
|
nft flush chain inet box input
|
||||||
|
nft add rule inet box input iifname "$NET" ct state established,related accept
|
||||||
|
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
|
||||||
|
nft add rule inet box input iifname "$NET" tcp dport 53 accept
|
||||||
|
nft add rule inet box input iifname "$NET" drop
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Sibling isolation: a box must not reach another box --------------------
|
# --- Sibling isolation: a box must not reach another box --------------------
|
||||||
|
|
@ -53,6 +65,38 @@ if ! nft list table bridge box >/dev/null 2>&1; then
|
||||||
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
|
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- The loopback door's missing half (box expose, #55) ----------------------
|
||||||
|
#
|
||||||
|
# 'box expose' publishes a box port on the host's 127.0.0.1 via an Incus
|
||||||
|
# NAT-mode proxy device. Incus installs the DNAT (prerouting + output hooks)
|
||||||
|
# and NOTHING else — its only SNAT is a hairpin rule for the box reaching its
|
||||||
|
# own exposure. A host-local `curl 127.0.0.1:<hport>` is therefore DNAT'd
|
||||||
|
# toward the box and then dies twice:
|
||||||
|
# · the kernel refuses to route a loopback-SOURCED packet out a
|
||||||
|
# non-loopback interface (a martian) unless route_localnet is set on the
|
||||||
|
# egress bridge;
|
||||||
|
# · even then, the box would reply to 127.0.0.1 — its OWN loopback —
|
||||||
|
# unless the source is rewritten to something it can answer.
|
||||||
|
# This is exactly the plumbing Docker installs on docker0 to make
|
||||||
|
# `-p 127.0.0.1:x:y` work: route_localnet=1 on the bridge, plus a masquerade
|
||||||
|
# of loopback-sourced traffic leaving it (the box then sees the gateway and
|
||||||
|
# replies through it). Scoped to boxnet only, never 'all'.
|
||||||
|
#
|
||||||
|
# route_localnet's known risk — it makes 127/8 a routable DESTINATION on the
|
||||||
|
# interface, so a box could aim frames at the host's loopback services — is
|
||||||
|
# covered by the ingress stance above: everything arriving on boxnet at the
|
||||||
|
# host is dropped except DNS/DHCP (UFW 'deny in' or the inet-box input chain),
|
||||||
|
# and that drop fires regardless of the destination address.
|
||||||
|
if [ -e "/proc/sys/net/ipv4/conf/$NET/route_localnet" ]; then
|
||||||
|
sysctl -qw "net.ipv4.conf.$NET.route_localnet=1"
|
||||||
|
else
|
||||||
|
echo "box-firewall: $NET does not exist yet — route_localnet not set; expose's loopback door stays dead until this script runs again" >&2
|
||||||
|
fi
|
||||||
|
nft add table inet box
|
||||||
|
nft "add chain inet box expose-snat { type nat hook postrouting priority 110 ; }"
|
||||||
|
nft flush chain inet box expose-snat
|
||||||
|
nft add rule inet box expose-snat oifname "$NET" ip saddr 127.0.0.0/8 masquerade
|
||||||
|
|
||||||
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
|
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
|
||||||
if command -v docker >/dev/null && iptables -L DOCKER-USER -n >/dev/null 2>&1; then
|
if command -v docker >/dev/null && iptables -L DOCKER-USER -n >/dev/null 2>&1; then
|
||||||
iptables -C DOCKER-USER -i "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i "$NET" -j ACCEPT
|
iptables -C DOCKER-USER -i "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i "$NET" -j ACCEPT
|
||||||
|
|
|
||||||
153
host/migrate-host.sh
Normal file
153
host/migrate-host.sh
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
#!/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 <name> 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 <name> | --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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# A box is LEGACY only if it still carries the old tag and has NOT been
|
||||||
|
# re-homed. Counting every user.claudebox=1 box was a bug: re-homing ADDS
|
||||||
|
# user.box=1 without removing the old tag, so --retire-legacy saw its own
|
||||||
|
# migrated boxes as un-migrated and refused forever.
|
||||||
|
legacy_boxes() {
|
||||||
|
local b
|
||||||
|
for b in $(incus list "user.claudebox=1" -f csv -c n 2>/dev/null); do
|
||||||
|
[ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ] && continue # already re-homed
|
||||||
|
echo "$b"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 2>/dev/null \
|
||||||
|
| awk '{for(i=1;i<NF;i++) if($i=="inet" && $(i+1)~/^10\.88\./){split($(i+1),a,"/"); print a[1]; exit}}')"
|
||||||
|
[ -n "$ip" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[ -n "$ip" ] || { warn "$b: never got a 10.88 address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
|
||||||
|
if incus exec "$b" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
|
||||||
|
# LAST, and only once the move is VERIFIED: drop the legacy tag. Until this
|
||||||
|
# point the box wears both tags, so a failure anywhere above leaves it a
|
||||||
|
# valid box under one name or the other — never orphaned. Now it is simply
|
||||||
|
# a box, and --retire-legacy can see the old stack is empty.
|
||||||
|
incus config unset "$b" user.claudebox >/dev/null 2>&1 \
|
||||||
|
|| warn "$b: migrated, but the legacy tag could not be removed — retire-legacy will still see it"
|
||||||
|
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
|
||||||
|
|
@ -72,4 +72,4 @@ if $purge; then
|
||||||
fi
|
fi
|
||||||
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.)"
|
||||||
|
|
|
||||||
48
install.sh
48
install.sh
|
|
@ -1,19 +1,21 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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
|
# Downloads the box source tarball from its GitHub repo (heavy-duty/box),
|
||||||
# $DEST, and puts a `box` symlink on PATH via $BINDIR.
|
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via
|
||||||
|
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install
|
||||||
|
# script keeps working; BOX_REPO overrides.)
|
||||||
|
|
||||||
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
|
REPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
REF="${CLAUDEBOX_REF:-main}"
|
REF="${BOX_REF:-main}"
|
||||||
DEST="${CLAUDEBOX_HOME:-$HOME/.local/share/claudebox}"
|
DEST="${BOX_HOME:-$HOME/.local/share/box}"
|
||||||
BINDIR="${CLAUDEBOX_BIN:-$HOME/.local/bin}"
|
BINDIR="${BOX_BIN:-$HOME/.local/bin}"
|
||||||
|
|
||||||
log() { printf 'claudebox-install: %s\n' "$*"; }
|
log() { printf 'box-install: %s\n' "$*"; }
|
||||||
warn() { printf 'claudebox-install: WARNING: %s\n' "$*" >&2; }
|
warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
|
||||||
die() { printf 'claudebox-install: ERROR: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
# --- prerequisites ---------------------------------------------------------
|
# --- prerequisites ---------------------------------------------------------
|
||||||
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
|
||||||
|
|
@ -26,18 +28,19 @@ trap cleanup EXIT
|
||||||
|
|
||||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
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"
|
log "downloading $URL"
|
||||||
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
|
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|
||||||
|| die "failed to download $URL"
|
|| die "failed to download $URL"
|
||||||
|
|
||||||
log "extracting archive"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|
||||||
|| die "failed to extract archive"
|
|| die "failed to extract archive"
|
||||||
|
|
||||||
# GitHub archives extract to a single top-level dir like claudebox-<ref>/
|
# GitHub names the archive's top dir after the REPO, which is still 'claudebox':
|
||||||
|
# it extracts to claudebox-<ref>/. That is repo-derived, not a stray brand.
|
||||||
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
|
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?"
|
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
|
||||||
|
|
||||||
# --- atomically replace $DEST ---------------------------------------------
|
# --- atomically replace $DEST ---------------------------------------------
|
||||||
|
|
@ -59,6 +62,13 @@ if [ -L "$BINDIR/claudebox" ]; then
|
||||||
rm -f "$BINDIR/claudebox"
|
rm -f "$BINDIR/claudebox"
|
||||||
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
|
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
|
||||||
fi
|
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 ------------------------------------------------------------
|
# --- PATH check ------------------------------------------------------------
|
||||||
case ":$PATH:" in
|
case ":$PATH:" in
|
||||||
|
|
@ -76,4 +86,10 @@ if ! command -v incus >/dev/null 2>&1; then
|
||||||
warn " run the one-time host setup: $DEST/host/setup-host.sh"
|
warn " run the one-time host setup: $DEST/host/setup-host.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "done — try: box new --name test"
|
# Record WHAT was installed, so a caller can assert it got what it asked for.
|
||||||
|
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
|
||||||
|
# retired in 0.5.0) silently falls back to the defaults and installs main —
|
||||||
|
# and the caller drills the wrong tree, believing it drilled its branch.
|
||||||
|
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
|
||||||
|
|
||||||
|
log "done ($REPO@$REF) — try: box new --name test"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
# profile is the placement contract and no template can weaken it.
|
# profile is the placement contract and no template can weaken it.
|
||||||
# BOX_USER must match the user user-data.yaml creates (the cloud-init file is
|
# BOX_USER must match the user user-data.yaml creates (the cloud-init file is
|
||||||
# passed to Incus verbatim, so the duplication is deliberate and by hand).
|
# passed to Incus verbatim, so the duplication is deliberate and by hand).
|
||||||
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free (the original claudebox)"
|
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free — where this project started"
|
||||||
BOX_IMAGE="images:debian/13/cloud"
|
BOX_IMAGE="images:debian/13/cloud"
|
||||||
BOX_USER="claude"
|
BOX_USER="claude"
|
||||||
BOX_CPU="4"
|
BOX_CPU="4"
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ write_files:
|
||||||
permissions: '0644'
|
permissions: '0644'
|
||||||
defer: true
|
defer: true
|
||||||
content: |
|
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
|
A box is a trust-less, network-isolated, ephemeral VM created by the
|
||||||
`box` CLI (template: claude). Keep this context in mind:
|
`box` CLI. Keep this context in mind:
|
||||||
|
|
||||||
- **Creds-free by default.** The box starts with no Claude and no git
|
- **Creds-free by default.** The box starts with no Claude and no git
|
||||||
credentials. If you need to authenticate Claude, the operator runs `/login`
|
credentials. If you need to authenticate Claude, the operator runs `/login`
|
||||||
|
|
@ -25,7 +25,7 @@ write_files:
|
||||||
- **Disposable.** Nothing here is backed up. State is discarded when the box is
|
- **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`.
|
removed; the operator persists work via git push and via `box snapshot`.
|
||||||
- **Bootstrap runbook.** If the repository you are working in contains a
|
- **Bootstrap runbook.** If the repository you are working in contains a
|
||||||
`.claudebox/` folder, read it as your setup runbook — how to install
|
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
|
||||||
dependencies, start services, template environment files, seed data, and
|
dependencies, start services, template environment files, seed data, and
|
||||||
smoke-test — and follow it. It is documentation for you, not a script the
|
smoke-test — and follow it. It is documentation for you, not a script the
|
||||||
host runs.
|
host runs.
|
||||||
|
|
@ -49,7 +49,7 @@ runcmd:
|
||||||
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
- apt-get install -y nodejs
|
- apt-get install -y nodejs
|
||||||
- sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash'
|
- 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 <box> -- claude …' runs through a NON-interactive shell, which
|
||||||
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The
|
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The
|
||||||
# symlink makes claude reachable from every shell, interactive or not.
|
# symlink makes claude reachable from every shell, interactive or not.
|
||||||
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude
|
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude
|
||||||
|
|
|
||||||
11
templates/codex/box.env
Normal file
11
templates/codex/box.env
Normal file
|
|
@ -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"
|
||||||
60
templates/codex/user-data.yaml
Normal file
60
templates/codex/user-data.yaml
Normal file
|
|
@ -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
|
||||||
|
`.box/` folder (older repos may use `.claudebox/`), 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 <b> -- 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
|
||||||
11
templates/grok/box.env
Normal file
11
templates/grok/box.env
Normal file
|
|
@ -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 CLI on Debian 13, creds-free"
|
||||||
|
BOX_IMAGE="images:debian/13/cloud"
|
||||||
|
BOX_USER="grok"
|
||||||
|
BOX_CPU="4"
|
||||||
|
BOX_MEMORY="8GiB"
|
||||||
|
BOX_DISK="60GiB"
|
||||||
75
templates/grok/user-data.yaml
Normal file
75
templates/grok/user-data.yaml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
#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 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
|
||||||
|
`.box/` folder (older repos may use `.claudebox/`), 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
|
||||||
|
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than
|
||||||
|
# guessed at. What it actually does:
|
||||||
|
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build`
|
||||||
|
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a
|
||||||
|
# SYMLINK into its versioned download dir (so `find -type f` misses it)
|
||||||
|
# · GROK_BIN_DIR overrides that directory
|
||||||
|
#
|
||||||
|
# Run it AS grok, not root: the binary symlink points into the invoking
|
||||||
|
# user's download dir, and root's home is 0700 — a symlink into it would be
|
||||||
|
# unreadable to the grok user, giving a CLI that exists and cannot run.
|
||||||
|
- sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash'
|
||||||
|
# 'box exec <b> -- grok …' is a NON-interactive shell: it reads no rc files,
|
||||||
|
# so ~/.grok/bin is never on its PATH. Symlink onto the system PATH — the
|
||||||
|
# same fix the claude template needed (#15). Assert the result: a CLI that
|
||||||
|
# silently is not on PATH is what cost the last drill run.
|
||||||
|
- |
|
||||||
|
if [ -e /home/grok/.grok/bin/grok ]; then
|
||||||
|
ln -sf /home/grok/.grok/bin/grok /usr/local/bin/grok
|
||||||
|
echo "grok: linked /usr/local/bin/grok -> /home/grok/.grok/bin/grok"
|
||||||
|
/usr/local/bin/grok --version >/dev/null 2>&1 \
|
||||||
|
&& echo "grok: 'grok --version' answers from the system PATH" \
|
||||||
|
|| echo "grok: WARNING - linked, but 'grok --version' does not answer" >&2
|
||||||
|
else
|
||||||
|
echo "grok: installer produced no ~/.grok/bin/grok - upstream layout changed?" >&2
|
||||||
|
find /home/grok -maxdepth 4 \( -type f -o -type l \) -perm -u+x 2>/dev/null | head -20 >&2
|
||||||
|
fi
|
||||||
|
- echo 'export PATH="$HOME/.grok/bin:$PATH"' >> /home/grok/.bashrc
|
||||||
Loading…
Reference in a new issue