Merge pull request #59 from claude-hdb/feat/new-resource-flags

feat(new): inline resource overrides — --cpu, --memory, --disk
This commit is contained in:
Daniel Marin 2026-07-15 01:22:57 +01:00 committed by GitHub
commit 1cbdfb05dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 117 additions and 15 deletions

72
CHANGELOG.md Normal file
View file

@ -0,0 +1,72 @@
# Changelog
History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md),
which records not just what changed but what each drill run proved.
## 0.5.0 — 2026-07-15
The release the project was renamed in: the repo is `heavy-duty/box`, matching
the CLI it ships. Everything legacy-facing is honored forever — the
`user.claudebox=1` tag, the `.claudebox/` runbook folder, the old symlink the
installer retires — but nothing current carries the old name.
### Added
- **`codex` and `grok` templates** — OpenAI Codex CLI and xAI Grok CLI boxes,
creds-free like every template. The template mechanic (image + user +
resources, never a network or a `security.*` key) now has three tenants
beside `blank`, and the drill mints all of them cold.
- **`box expose <box> <port> [<host-port>]`** — a deliberate, loopback-only
door to a port inside a box, for seeing a dev server in your browser. The
listen side is always the host's `127.0.0.1` (no flag to widen it), the door
is per-port, `--list`/`--remove` manage it, and `box info` shows open
exposures — a box with a hole says so.
- **Inline resource overrides on `new`** — `--cpu <n> --memory <size>
--disk <size>` (#57). Resolution most-specific-first: flag > `BOX_CPU` /
`BOX_MEMORY` / `BOX_DISK` environment (the scripting form) > template
`box.env` > defaults. Values pass to Incus verbatim; resources are all a
flag can touch. `--from` refuses them — a clone carries its source's
resources.
- **Host lifecycle as verbs**`box setup-host`, `box teardown-host`, and
`box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
(`--box <n>` / `--all-boxes`, authed state preserved) and retires the legacy
bridge once empty (`--retire-legacy`).
- **The `.box/` recipe convention** — the agent-facing runbook folder a repo
can ship, renamed from `.claudebox/` (both spellings read).
### Fixed
- **VM mints no longer hang at GRUB** — Incus defaults VMs to Secure Boot on,
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
shim signature" forever. Boxes now launch with `security.secureboot=false`;
the VM boundary, not boot attestation, is the box threat model.
- **`box expose` actually delivers packets** — a trilogy of drill-found
absences: the NAT proxy needs the box's boxnet lease pinned as a static
`ipv4.address` (Incus resolves `connect=0.0.0.0` against device config, not
the lease); a loopback-sourced packet needs `route_localnet` plus a
masquerade on the bridge to leave the host and be answerable; and the box's
replies need a `ct state established,related` accept ahead of the host
firewall's input drop, which was eating them statelessly. Boxes still
cannot initiate toward the host — a box-originated SYN is a NEW flow.
- **Firewall rules now converge on upgrade**`box-firewall.sh` rebuilds its
chains every run (add + flush + re-add) instead of skipping when they
exist, which had pinned every host to the rule set of the release that
first ran there.
- **Failed mints tell you why** — cloud-init failures print the box's own log
excerpts and leave the box up to inspect; a mint that never boots names the
likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized
console dump; the installer asserts it landed the ref it was asked for.
- **`grok` installs the binary it actually ships** — the installer was read,
not guessed at, and the CLI lands on the non-interactive PATH (same fix
class as codex).
### Changed
- **Debrand complete** — env vars, install dir, docs, template descriptions
and the README all say `box`; the install URL is
`heavy-duty/box` (GitHub redirects the old one, `BOX_REPO` overrides).
- **The drill grew from 47 to 83 checks** — the expose door opened, exercised
and shut (with the contract re-probed around it), every template minted
cold, a faithful pre-0.4.0 box re-homed through `migrate-host`, and the
inline resource flags asserted (including their precedence over the
environment).

View file

@ -106,9 +106,10 @@ box new --name scratch # the DEFAULT template is blank: bare Debian,
A template **cannot** name a network, a profile, or a `security.*` flag — A template **cannot** name a network, a profile, or a `security.*` flag —
there is no key for them. Every box launches with the shared `box-net` there is no key for them. Every box launches with the shared `box-net`
profile (the isolated NIC + root disk), so every template gets the identical profile (the isolated NIC + root disk), so every template gets the identical
trust boundary. Resources come from the template's `box.env`; trust boundary. Resources come from the template's `box.env`, overridable at
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables override them at mint time — inline (`--cpu 2 --memory 3GiB --disk 20GiB`) or via
mint time. The template's identity (name, user) is stamped onto the instance, `BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables (the scripting
form; flags win). The template's identity (name, user) is stamped onto the instance,
so `shell`, `exec` and `tmux` land in the right user — and a clone still so `shell`, `exec` and `tmux` land in the right user — and a clone still
knows, because `incus copy` carries the metadata. knows, because `incus copy` carries the metadata.
@ -152,7 +153,7 @@ the door is per-port, punched and removable at runtime.
## Commands ## Commands
``` ```
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--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, exposures, snapshot labels box info <box> # one box: state, IP, exposures, snapshot labels
@ -246,7 +247,7 @@ drill installs the whole stack, mints every template cold, snapshots and
clones, probes every boundary from inside the boxes, opens and shuts the clones, probes every boundary from inside the boxes, opens and shuts the
`expose` door (and checks the contract survives it), re-homes a faithful `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 — 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 currently **83 checks**. [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

36
bin/box
View file

@ -7,6 +7,7 @@ set -euo pipefail
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0 remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
cpu=""; memory=""; disk=""
inst="" # the resolved Incus instance, set by the 'box' precondition inst="" # the resolved Incus instance, set by the 'box' precondition
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
@ -37,7 +38,7 @@ version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)
# doesn't enforce a box invariant, it is incus's job, not ours — that is # doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for. # what `box incus` is for.
CMDS=( CMDS=(
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^" "new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
"templates^^^List the templates this install can mint^fn:cmd_templates^" "templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^" "list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^" "info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
@ -202,17 +203,24 @@ being told.
A template sets image, user and resources — never A template sets image, user and resources — never
the network: every template gets the same isolation. the network: every template gets the same isolation.
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>. --from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
--cpu <n> CPUs for this mint (limits.cpu, verbatim to Incus).
--memory <size> RAM for this mint, e.g. 3GiB (limits.memory).
--disk <size> Root disk size, e.g. 20GiB. VM mode only — a
container's root rides the storage pool.
--vm | --container Force the mode. VM is the trust boundary and the --vm | --container Force the mode. VM is the trust boundary and the
default wherever /dev/kvm exists; container mode default wherever /dev/kvm exists; container mode
(security.nesting=true) is the fallback for hosts (security.nesting=true) is the fallback for hosts
without nested virt — weaker isolation, dev/test only. without nested virt — weaker isolation, dev/test only.
Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK Resources resolve most-specific-first: these flags, then BOX_CPU /
environment variables override them at mint time (a small host shrinks a box BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
without editing a template it doesn't own). template's box.env, then defaults. Flags shape a fresh mint only — a --from
clone carries its source's resources. Resources are all a flag can touch:
there is no flag for a network or a security key, on purpose.
box new --name scratch # blank, the default box new --name scratch # blank, the default
box new --name work --template claude box new --name work --template claude
box new --name lean --template claude --cpu 2 --memory 3GiB
box new --name feature --from work/authed box new --name feature --from work/authed
EOF EOF
;; ;;
@ -443,6 +451,9 @@ while [ $# -gt 0 ]; do
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;; --name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;; --from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;; --template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
--cpu) [ $# -ge 2 ] || usage_error "--cpu needs a value"; cpu="$2"; shift 2 ;;
--memory) [ $# -ge 2 ] || usage_error "--memory needs a value"; memory="$2"; shift 2 ;;
--disk) [ $# -ge 2 ] || usage_error "--disk needs a value"; disk="$2"; shift 2 ;;
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;; --remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
--vm) mode=vm; shift ;; --vm) mode=vm; shift ;;
--container) mode=container; shift ;; --container) mode=container; shift ;;
@ -640,11 +651,14 @@ load_template() {
esac esac
done <"$dir/box.env" done <"$dir/box.env"
[ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required" [ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
# Environment overrides beat the file — this is how a small host (or the # Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
# drill) shrinks a box without editing a template it doesn't own. # > BOX_* environment (how a small host or the drill shrinks every box it
T_CPU="${BOX_CPU:-${T_CPU:-4}}" # mints) > the template's file > defaults. Values pass to Incus verbatim —
T_MEMORY="${BOX_MEMORY:-${T_MEMORY:-8GiB}}" # its units, its validation; box adds no parser of its own. Resources only:
T_DISK="${BOX_DISK:-${T_DISK:-60GiB}}" # there is still no flag for a network or a security.* key, on purpose.
T_CPU="${cpu:-${BOX_CPU:-${T_CPU:-4}}}"
T_MEMORY="${memory:-${BOX_MEMORY:-${T_MEMORY:-8GiB}}}"
T_DISK="${disk:-${BOX_DISK:-${T_DISK:-60GiB}}}"
} }
cmd_templates() { cmd_templates() {
@ -664,6 +678,7 @@ cmd_new() {
local instance; instance="$(iname_of "$name")" local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then if [ -n "$from" ]; then
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)" [ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
[ -z "$cpu$memory$disk" ] || usage_error "--cpu/--memory/--disk shape a fresh mint; a clone carries its source's resources ('box incus' can change them afterwards)"
local src="${from%%/*}" snap="" srcref local src="${from%%/*}" snap="" srcref
case "$from" in */*) snap="${from#*/}" ;; esac case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap" srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
@ -685,6 +700,9 @@ cmd_new() {
# turning it off boots reliably across image rebuilds. Container mode has # turning it off boots reliably across image rebuilds. Container mode has
# no firmware, so it does not apply there. # 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 if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); else extra+=(--config security.nesting=true); fi
# Root size is a VM launch concern; a container's root rides the pool. Say
# so instead of silently dropping an explicit --disk.
[ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2
# 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.

View file

@ -422,13 +422,24 @@ box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWO
|| no "a box.env key outside the allowlist was ACCEPTED — a template could weaken isolation" || no "a box.env key outside the allowlist was ACCEPTED — a template could weaken isolation"
rm -rf "$badt" rm -rf "$badt"
# Inline resource flags (#57): refused on a clone, honored on a mint. The
# mint proof rides the blank box below — and because this drill exports
# BOX_CPU/BOX_MEMORY on small hosts, it is also the precedence proof
# (flag > env > template > default).
box new --name tpl --from nowhere --cpu 2 2>&1 | grep -q 'carries its source' \
&& ok "resource flags refused on --from — a clone carries its source's resources" \
|| no "--from accepted a resource flag (should refuse: clone resources come from the source)"
printf '\n minting a blank box (the DEFAULT template — no tooling, fast)…\n' printf '\n minting a blank box (the DEFAULT template — no tooling, fast)…\n'
t0=$SECONDS t0=$SECONDS
if mint_box /tmp/mint-tpl.log --name tpl; then if mint_box /tmp/mint-tpl.log --name tpl --cpu 1 --memory 1GiB; then
ok "box new --name tpl, no --template ($((SECONDS - t0))s)" ok "box new --name tpl, no --template ($((SECONDS - t0))s)"
tt="$(incus config get tpl user.box.template 2>/dev/null)" tt="$(incus config get tpl user.box.template 2>/dev/null)"
[ "$tt" = blank ] && ok "the default template is blank (user.box.template=blank)" \ [ "$tt" = blank ] && ok "the default template is blank (user.box.template=blank)" \
|| no "default template is '${tt:-<unset>}' — expected blank" || no "default template is '${tt:-<unset>}' — expected blank"
rc="$(incus config get tpl limits.cpu 2>/dev/null)/$(incus config get tpl limits.memory 2>/dev/null)"
[ "$rc" = "1/1GiB" ] && ok "inline --cpu/--memory landed (limits = $rc, beating BOX_* env)" \
|| no "inline resource flags did not land — limits are $rc, expected 1/1GiB"
[ "$(incus config get tpl user.box.user 2>/dev/null)" = dev ] \ [ "$(incus config get tpl user.box.user 2>/dev/null)" = dev ] \
&& ok "template user stamped on the instance (user.box.user=dev)" || no "user.box.user not stamped" && ok "template user stamped on the instance (user.box.user=dev)" || no "user.box.user not stamped"
incus config show tpl 2>/dev/null | grep -q '^- box-net' \ incus config show tpl 2>/dev/null | grep -q '^- box-net' \