feat: box export / import — state that survives the box and the host (#70) #78

Merged
dan-claude-bot merged 3 commits from feat/box-export into main 2026-07-18 19:01:57 +00:00
10 changed files with 567 additions and 44 deletions

View file

@ -33,7 +33,7 @@ jobs:
# release still proves the boundary.
rehearsal:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- name: install incus
@ -58,6 +58,33 @@ jobs:
run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
- name: multi-user rehearsal (criteria a-l, container mode)
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container
# The #70 round-trip, on the SAME live daemon: a box's state must
# survive 'box rm' via export → import. Container mode for the same
# reason the rehearsal uses it — export/import are backup mechanics
# (tarball out, tarball in, re-stamp), identical across instance types;
# the VM trust boundary stays a real-hardware ritual. Every assertion
# is state observed AFTER the original box was deleted: the file
# written pre-export, the snapshot, the boundary tag, a live agent.
- name: export/import round-trip — state survives 'box rm' (#70)
run: |
set -eux
sudo box new --name keeper --container
sudo box exec keeper -- sh -c 'echo survives > /home/dev/proof'
sudo box snapshot keeper pre-export
sudo box down keeper
sudo box export keeper /tmp/keeper.tar.gz
sudo test -s /tmp/keeper.tar.gz
sudo box rm keeper --force
sudo box import /tmp/keeper.tar.gz --name keeper2
test "$(sudo incus config get keeper2 user.box)" = 1
sudo incus exec keeper2 -- true
sudo box exec keeper2 -- cat /home/dev/proof | grep -qx survives
sudo incus snapshot list keeper2 --format csv | grep -q '^pre-export'
# the collision boundary, live: the name is taken, import must refuse
if sudo box import /tmp/keeper.tar.gz --name keeper2; then
echo 'collision was not refused'; exit 1
fi
sudo box rm keeper2 --force
- name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue
# The full-removal order, end to end on the real daemon: revoke a
# granted user (--purge asserts its own absence, incl. the incus-user

View file

@ -7,6 +7,34 @@ which records not just what changed but what each drill run proved.
### Added
- **`box export` / `box import`** (#70) — a box's state that survives the box
_and_ the host, unblocking #66's humane upgrade flow (down, export, rm,
upgrade, re-import). `box export <box> [<file>]` wraps `incus export` into
one portable backup tarball (default `<box>-<UTC stamp>.tar.gz`), snapshots
included by default (`--instance-only` opts out); the box must be stopped
first (`box down`) so the artifact is a settled disk, not a moving one. The
file is **shouted about, not scrubbed** — it carries the box's whole disk
(agent logins, git credentials, SSH keys), and scrubbing a disk image is a
promise tarball surgery cannot keep, so box says what is inside instead,
every time. `box import <file> [--name <box>]` mints the box back and
re-stamps what is the _current host's_ truth, not the artifact's: the
`user.box=1` boundary tag (legacy `user.claudebox=1` honored), the
`box-net` placement (re-assigned if the artifact's differs — the
migrate-host move), and a fresh machine identity: the NIC's MAC (imports
restore `volatile.*` verbatim, and a re-import beside its sibling collided
at start with "MAC address already defined on another NIC" — measured
live; `incus copy` regenerates it, `incus import` does not) plus
`reset_identity` (the clone trust boundary: no DHCP collision with the box
it was exported from).
Import refuses any name an existing instance holds — the `resolve_box`
boundary, seen from the other side. Works on both tiers: `box grant` now
also converges `restricted.backups allow` (incus-user blocks backups by
default exactly like snapshots, and an export _is_ a backup
create+download — measured against incus 6.0's `permissions.go`); re-run
`box grant <user>` after upgrading, as documented. CI's `rehearsal` job now
proves the round-trip on a live Incus: mint → write a file → snapshot →
down → export → `rm` → import → the file and the snapshot survived, the
agent answers, the tag is present, and a colliding re-import is refused.
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
`INSTALLED_FROM`), with a `current` symlink tracking the default and

View file

@ -65,8 +65,8 @@ boxes. Installing a **new** version lands it side by side and flips `current`
only when you have **no boxes** — under existing boxes the flip is refused
(never change versions under a user's boxes,
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
deliberate act: preserve what you care about — `box down <box>`, copy out
anything you need (a portable `box export` is
deliberate act: preserve what you care about — `box down <box>`, then
`box export <box>` (one portable file per box, snapshots included —
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
(which deletes the box _and_ its snapshots) — then:
@ -77,8 +77,10 @@ box use <version> # flip the default (same refusal while boxes exist)
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
next installer run — the tree is moved, not re-downloaded, and your boxes are
untouched. A version-aware upgrade that migrates boxes instead of asking you
to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
untouched. After switching versions (and `box setup-host`, if the stack was
torn down), `box import <file>` brings each exported box back — snapshots,
logins and all. A version-aware upgrade that migrates boxes instead of asking
you to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
installs (CI, images), `BOX_YES=1` answers every prompt yes,
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
@ -159,7 +161,8 @@ user to a private _unhardened_ NAT bridge, it blocks snapshots, and it cannot
see the `box-net` profile. Granting rewires all three: the user's project is
restricted to `boxnet` **and only boxnet** — the hardened network is not their
default placement but the only one their certificate can express — snapshots
are allowed, and the shipped profile is installed into their project. Re-run
and backups are allowed (the clone and `box export` workflows), and the
shipped profile is installed into their project. Re-run
`box grant <user>` after upgrading box to refresh the profile, like
`setup-host` for the stack.
@ -244,6 +247,38 @@ 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
snapshot labels and the `--from` line to clone one.
## Survive the host: `box export` / `box import`
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
snapshots. `box new --from` clones — but the clone still lives on the same
host, under the same stack. `box export` is the way out
([#70](https://github.com/heavy-duty/box/issues/70)): one portable file that
outlives the box, the host stack, and the machine.
```sh
box down work # export wants a settled disk
box export work # → work-<UTC stamp>.tar.gz, snapshots included
box rm work # nothing is lost anymore
# ...upgrade box / rebuild the host / carry the file to another machine...
box import work-<stamp>.tar.gz # the box is back — snapshots, logins and all
box import work-<stamp>.tar.gz --name work2 # or under a new name
```
This is what makes the upgrade flow humane
([#66](https://github.com/heavy-duty/box/issues/66)): stop, export, remove
every box, upgrade, re-import. Everything `incus import` restores is the
artifact's truth (disk, config, snapshots); what box re-stamps on import is
_this_ host's truth — the `user.box=1` boundary tag, the `box-net` placement
(re-assigned if the artifact's differs), and a fresh machine identity, the
same move a clone gets, so an imported box can never collide with the box it
was exported from. Import refuses a name any existing instance already holds.
`--instance-only` exports the live state without the snapshots.
**The file is a credential.** A box's disk carries everything inside it —
agent logins, git PATs, SSH keys, shell history. Export scrubs nothing (a
"scrubbed" disk image would be a lie) and shouts instead, every time. Store
and move the file like the secret it is.
## See a dev server: `box expose`
The isolation contract says no inbound path exists — which is one "no" too
@ -276,6 +311,10 @@ box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap> # roll back to a snapshot
box export <box> [<file>] [--instance-only]
# one portable file (snapshots incl.) — survives rm & host
box import <file> [--name <box>]
# mint a box back from an exported file, re-stamped
box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box

250
bin/box
View file

@ -7,7 +7,7 @@ set -euo pipefail
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
cpu=""; memory=""; disk=""
cpu=""; memory=""; disk=""; instance_only=0
inst="" # the resolved Incus instance, set by the 'box' precondition
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
@ -65,6 +65,8 @@ CMDS=(
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
"restore^<box> <snapshot>^box,arg2^Roll a box back to one of its snapshots^incus:snapshot restore^restored {} to {1}"
"export^<box> [<file>] [--instance-only]^box^Export a stopped box to one portable file — it survives 'box rm' and this host^fn:cmd_export^"
"import^<file> [--name <box>]^^Mint a box from an exported file, re-stamped onto this host's stack^fn:cmd_import^"
"rename^<box> <new-name>^box,arg2,stopped^Rename a box (it must be stopped first)^incus:rename^renamed {} to {1}"
"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 {}"
@ -151,14 +153,15 @@ EOF
cat <<'EOF'
OPTIONS
--name <box> Name for the new box (new)
--name <box> Name for the new box (new, import)
--template <t> Template to mint from (default: blank) (new)
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
--vm Force VM mode: the trust-less target (new)
--container Force container mode: weaker isolation, (new)
dev/test only. Default where /dev/kvm is absent.
--json Emit Incus JSON instead of a table (list, info)
--force, -f Delete without the confirmation prompt (rm)
--instance-only Export the live state only, no snapshots (export)
--force, -f Delete without asking (rm); overwrite the file (export)
--remote <r> Act on Incus remote <r> (any)
--help, -h Help; after a command, help for that command
--version, -V Print the box version
@ -182,6 +185,11 @@ EXAMPLES
box list
box info work
# a box that outlives this host: one portable file, credentials inside
box down work
box export work # → work-<UTC stamp>.tar.gz — guard it like a credential
box import work-<stamp>.tar.gz --name work2
# run something without opening a shell
box exec work -- git -C project pull
@ -329,6 +337,57 @@ that snapshot is lost. 'box info <box>' lists the labels.
box restore work authed
EOF
;;
export) cat <<'EOF'
One portable file that outlives the box AND the host. 'box rm' deletes a box
and every snapshot it has; 'box new --from' clones, but the clone still lives
on the same host. Export is the way out (#70): it wraps 'incus export' into a
backup tarball of the whole instance — snapshots included by default, because
the reuse workflow (log in once, snapshot, clone forever) lives in them.
box export <box> [<file>] # default file: <box>-<UTC timestamp>.tar.gz
--instance-only # live state only, leave the snapshots behind
--force # overwrite an existing <file> (refused otherwise)
The box must be stopped first ('box down <box>'). Incus can back up a running
instance, but a live root disk is a moving target — and this artifact's whole
job is to be trusted later, on a host that no longer has the box.
THE FILE IS A CREDENTIAL. A box's disk carries everything inside it — agent
logins, git PATs, SSH keys, shell history. Export scrubs nothing (a
"scrubbed" disk image is a promise tarball surgery cannot keep) and says so
loudly instead. Store and move the file like the secret it is.
The upgrade flow this unblocks (#66):
box down work && box export work # one file per box
box rm work # nothing is lost anymore
# ...upgrade box / rebuild the host / move machines...
box import work-<stamp>.tar.gz # the box is back, snapshots and all
EOF
;;
import) cat <<'EOF'
Mint a box from a 'box export' file — on this host or any other that has the
box stack ('box setup-host' builds it). The name inside the tarball is used
unless --name picks another; either way the name must be free: import will
not occupy a name ANY existing instance holds, box or not.
Everything 'incus import' restores is the artifact's truth (disk, config,
snapshots). What box then re-stamps is THIS host's truth:
· the user.box=1 boundary tag (a legacy user.claudebox=1 stays honored)
· the box-net placement — re-assigned if the artifact's profile list
differs, the same move migrate-host makes re-homing a legacy box
· a fresh machine identity (reset_identity, exactly like a clone), so its
DHCP lease can never collide with the box it was exported from
Auth state rides along by design — the artifact carries the box's whole disk,
logins included. That is the point (log in once, keep the file), and the same
trust boundary as cloning an authed snapshot.
box import work-20260718T120000Z.tar.gz
box import work-20260718T120000Z.tar.gz --name work2
EOF
;;
rename) cat <<'EOF'
Rename a box. Incus cannot rename a running instance, so stop it first:
@ -439,12 +498,13 @@ What it converges, idempotently (safe to re-run, and re-run after upgrades):
· points the project at boxnet and ONLY boxnet — the private incusbr-<uid>
bridge incus-user auto-creates carries none of box's hardening, so it is
unreferenced and unreachable, not just unused
· allows snapshots (incus-user blocks them; box's clone workflow needs them)
· allows snapshots and backups (incus-user blocks both; the clone workflow
rides snapshots, 'box export' rides backups — #70)
· installs the box-net profile into their project
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/rm on
their own boxes. Not theirs: expose (edits daemon-global state), setup-host,
grant. Admin boxes and other users' boxes are invisible to them, and the
The user's surface: new/list/info/shell/exec/tmux/snapshot/restore/export/
import/rm on their own boxes. Not theirs: expose (edits daemon-global
state), setup-host, grant. Admin boxes and other users' boxes are invisible to them, and the
existing box-to-box drop means even their instances cannot reach a sibling.
box grant dev1
@ -500,8 +560,8 @@ EOF
Switch the default box version — repoint the 'current' symlink (and the PATH
symlink riding it) at an installed version. Refuses while ANY box exists:
never change versions under a user's boxes (#66) — 'box down' what you keep,
copy out what you need via 'box shell'/'box exec' (a portable 'box export'
is #70), 'box rm' each box, then switch. The flip is asserted afterwards:
'box export' what you keep (one
portable file per box, #70), 'box rm' each box, then switch. The flip is asserted afterwards:
current must resolve to the version you asked for, and the chain's
'box --version' must answer it.
@ -577,6 +637,7 @@ while [ $# -gt 0 ]; do
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
--vm) mode=vm; shift ;;
--container) mode=container; shift ;;
--instance-only) instance_only=1; shift ;;
--force|-f) force=1; shift ;;
--json) json=1; shift ;;
--help|-h) want_help=1; shift ;;
@ -622,14 +683,35 @@ resolve_box() {
box_state() { incus list "$1" --format csv --columns s 2>/dev/null | head -n1; }
# $3, when given, replaces the default "why" — the table's 'stopped' rows are
# stopped because INCUS insists (rename), but export is stopped by OUR
# decision (a consistent artifact), and the refusal should say the true reason.
require_stopped() {
local i="$1" box="$2" st; st="$(box_state "$i")"
local i="$1" box="$2" why="${3:-Incus needs it stopped for this}" st; st="$(box_state "$i")"
case "$st" in
STOPPED|Stopped|stopped) return 0 ;;
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
*) die "box '$box' is ${st:-not stopped} — $why. Stop it: box down $box" ;;
esac
}
# The placement contract must exist before a mint OR an import lands — in the
# DEFAULT project for an admin (setup-host builds it), in YOUR project for a
# restricted user (box grant converges it). Its absence has a different fix
# per tier, and incus's own "Profile not found" at launch time names neither.
require_stack() {
if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
# A missing profile and a daemon that is not answering are different
# faults with different fixes — "run setup-host" at a wedged daemon
# (the #26 shape) is wrong advice. Separate them before diagnosing.
timeout 10 incus list >/dev/null 2>&1 </dev/null \
|| die "the incus daemon is not answering — diagnose it: box doctor"
if [ "$(box_tier)" = restricted ]; then
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
fi
die "no box-net profile — the host stack is missing. Build it: box setup-host"
fi
}
need_name() {
if [ "${#args[@]}" -lt 1 ] || [ -z "${args[0]}" ]; then
usage_error "usage: $(synopsis_of "$cmd")"
@ -723,7 +805,7 @@ wait_agent() {
# the reset costs one reboot. Do it before handing the box over, never after.
reset_identity() {
local i="$1"
echo "box: giving the clone its own identity (machine-id, DHCP lease)..."
echo "box: giving the instance its own identity (machine-id, DHCP lease)..."
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
# down cleanly, so the graceful stop hangs and the reboot never happens —
# leaving the clone on its source's identity, which is the bug we are here to
@ -808,21 +890,7 @@ cmd_templates() {
cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
# The placement contract must exist before any mint — in the DEFAULT project
# for an admin (setup-host builds it), in YOUR project for a restricted user
# (box grant converges it). Its absence has a different fix per tier, and
# incus's own "Profile not found" at launch time names neither.
if [ -z "$remote" ] && ! timeout 10 incus profile show box-net >/dev/null 2>&1 </dev/null; then
# A missing profile and a daemon that is not answering are different
# faults with different fixes — "run setup-host" at a wedged daemon
# (the #26 shape) is wrong advice. Separate them before diagnosing.
timeout 10 incus list >/dev/null 2>&1 </dev/null \
|| die "the incus daemon is not answering — diagnose it: box doctor"
if [ "$(box_tier)" = restricted ]; then
die "your project has no box-net profile — the restricted tier is granted per user, by an admin: box grant $(id -un)"
fi
die "no box-net profile — the host stack is missing. Build it: box setup-host"
fi
require_stack
local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
@ -1068,6 +1136,130 @@ cmd_status() {
list_all
}
# --- export / import (#70): state that survives the box and the host --------
#
# 'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
# but the clone still lives on the same host under the same stack. So until
# now, nothing a box held could outlive a host teardown — and #66's installer
# refusal ("stop, export, remove every box before you upgrade") was only
# honest advice once a real export existed. 'incus export' is the primitive:
# a backup tarball of the instance and (by default) its snapshots.
cmd_export() {
local box="${args[0]}" file="${args[1]:-}"
# Down first, by OUR decision, not incus's necessity: incus can back up a
# running instance, but a live root disk is a moving target, and this
# artifact's whole job is to be trusted later, on a host that no longer
# has the box (#70 settled on require-down over snapshot-then-export).
require_stopped "$inst" "$box" "export wants a settled disk, so the artifact can be trusted later"
# Default filename: the box's name plus a UTC stamp. Sortable, never
# colliding across repeated exports, and it answers the question you will
# actually ask the file six months from now — WHEN is this state from?
[ -n "$file" ] || file="$box-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
if [ -e "$file" ] && [ "$force" -ne 1 ]; then
die "$file already exists — pick another name, or --force to overwrite"
fi
local extra=()
# Snapshots ride along by DEFAULT (#70's call): the reuse workflow lives in
# them (log in once, snapshot, clone forever), and an artifact that quietly
# dropped the authed checkpoint would defeat its own purpose. --instance-only
# is the explicit opt-out, passed through to incus verbatim.
[ "$instance_only" -eq 1 ] && extra+=(--instance-only)
echo "box: exporting $box → $file ..."
incus export "$inst" "$file" "${extra[@]}"
echo "box: exported $box → $file"
[ "$instance_only" -eq 1 ] || echo "box: (snapshots included — 'box import' brings them back too)"
# #70's credential decision: scrub or SHOUT — and box shouts. Scrubbing a
# disk image is a promise no tarball surgery can keep (dotfiles, keychains,
# tokens in shell history, deleted-but-unwiped blocks); handing someone a
# "sanitized" file that is not sanitized would be worse than the risk it
# hides. So the artifact carries everything, and box says so every time.
{
echo "box: ============================== WARNING =============================="
echo "box: this file contains the box's ENTIRE disk: agent logins (Claude,"
echo "box: Codex, Grok), git credentials, SSH keys, shell history — everything"
echo "box: that was inside the box. Nothing in it was scrubbed."
echo "box: treat the file itself as a credential: private storage, trusted"
echo "box: channels only."
echo "box: ====================================================================="
} >&2
}
# The way back in. Everything 'incus import' restores is the ARTIFACT's truth
# (disk, config, devices, snapshots); everything box then re-stamps is THIS
# host's truth (the boundary tag, the placement contract, a fresh machine
# identity). That split is the design (#70): state is portable, the trust
# boundary is not — it is re-established on the current stack, every time.
cmd_import() {
local file="${args[0]:-}"
[ -n "$file" ] || usage_error "usage: $(synopsis_of import)"
[ -f "$file" ] || die "no such file: $file"
# The artifact names its instance in backup/index.yaml — read it up front:
# the collision check and the re-stamping both need the final name BEFORE
# incus acts. GNU tar auto-detects the compression on read. '|| true'
# because pipefail would otherwise kill the script on a non-tarball with
# tar's status instead of reaching the die below that names the problem.
local embedded
embedded="$(tar -xOf "$file" backup/index.yaml 2>/dev/null | awk '$1 == "name:" { print $2; exit }' || true)"
[ -n "$embedded" ] || die "$file is not an incus/box export (no backup/index.yaml inside)"
local target="${name:-$embedded}"
# The boundary resolve_box enforces, seen from the other side: box will not
# occupy a name ANY existing instance holds — not a box's (import is not
# restore), and not an unmanaged VM's (not ours to shadow or clobber).
if incus config show "$target" >/dev/null 2>&1 </dev/null; then
die "an instance named '$target' already exists — import under another name: box import $file --name <new>"
fi
# The stack this lands on must exist first (a fresh host runs setup-host
# before it re-imports) — same pre-flight as a mint, same tier-aware fix.
require_stack
echo "box: importing $file as $target..."
if [ -n "$name" ]; then incus import "$file" "$name"; else incus import "$file"; fi
# Re-stamp the boundary tag. user.* keys ride inside the artifact, so a box
# export brings its template/user stamps back on its own, and a legacy
# user.claudebox=1 stays honored as it is everywhere else. Only an instance
# carrying NEITHER tag is stamped user.box=1 now — importing is minting,
# and a minted box is ours to manage.
local tag
tag="$(incus config get "$target" user.box 2>/dev/null || true)"
[ "$tag" = 1 ] || tag="$(incus config get "$target" user.claudebox 2>/dev/null || true)"
[ "$tag" = 1 ] || incus config set "$target" user.box=1
# Placement: the artifact carries its profile list, but the isolation
# contract is THIS host's box-net profile. A box export already says
# box-net; anything else (a pre-0.4.0 artifact, a hand-rolled export) gets
# re-assigned — the same move migrate-host makes re-homing a legacy box.
# (An artifact naming a profile this host lacks fails inside 'incus import'
# above, with incus's own error naming the profile — honest enough.)
# Two traps in reading the list: a bare name filter PREFIX-matches (asking
# for 'work' also returns 'work2'), so anchor it; and the P column joins
# multiple profiles with newlines, so join the whole (quoted, multi-line)
# cell back into one comparable token instead of trusting the first line.
local profs
profs="$(incus list "^${target}\$" --format csv --columns P 2>/dev/null | tr -d '" ' | paste -sd, -)"
if [ "$profs" != box-net ]; then
incus profile assign "$target" box-net
echo "box: re-homed onto the box-net profile (the artifact said '${profs:-none}')"
fi
# The artifact's volatile.* config comes back verbatim too — including the
# NIC's MAC address. Importing an artifact twice, or beside the box it was
# exported from, then collides at start: "MAC address already defined on
# another NIC" (measured live), and the second box is left half-imported.
# 'incus copy' regenerates the MAC on clone; import does not — so unset
# every volatile hwaddr and let incus mint fresh ones at start. Host-side
# identity, the same reasoning as the in-guest machine-id reset below.
local k
for k in $(incus config show "$target" 2>/dev/null | awk -F: '/^ *volatile\..*\.hwaddr:/ { gsub(/ /, "", $1); print $1 }'); do
incus config unset "$target" "$k"
done
incus start "$target"
wait_agent "$target"
# The same trust boundary as a clone: the artifact's machine-id rides in
# its disk, and the box it was exported from may still exist somewhere. A
# fresh identity (machine-id → DHCP client-id → lease) before handover,
# always — reset_identity's comment has the collision this prevents.
reset_identity "$target"
echo "box: imported $target — auth state (agent logins, git creds) came back with it, by design."
echo "box: ready — 'box shell $target'."
}
# The host-health checks live in drill/doctor.sh — grown by the drill, but
# every fault they diagnose (a wedged daemon, a dnsmasq that isn't serving,
# a VPN resolver boxes inherit, isolation off in the kernel) is a USER's
@ -1151,8 +1343,8 @@ die_under_boxes() { # $1 = the act being refused, $2 = the retry command
echo "box: this host has existing boxes:"
while IFS= read -r n; do echo "box: · $n"; done <<<"$names"
echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)."
echo "box: preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
echo "box: (a portable 'box export' is #70) — then 'box rm <box>' each, and re-run: $2"
echo "box: preserve what you care about — 'box down <box>', 'box export <box>' (one"
echo "box: portable file per box, #70) — then 'box rm <box>' each, and re-run: $2"
} >&2
exit 1
}

View file

@ -43,6 +43,18 @@ snapshots, not a secrets store:
Log in once → snapshot → spin up authed boxes from it.
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
clone still lives on the same host. The off-host mechanism is `box export` /
`box import` (#70) — one portable backup tarball, snapshots included by
default, that survives `rm`, a host teardown, an upgrade, a move. The split
of truths is the design: everything `incus import` restores is the artifact's
(disk, config, snapshots); everything box re-stamps on import is the current
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
machine identity via the same `reset_identity` a clone gets). Auth state
rides along deliberately — and because scrubbing a disk image is a promise
tarball surgery cannot keep, export shouts that the file is a credential
instead of pretending to sanitize it.
## The box announces itself to the agent
cloud-init installs a global agent-context file in every coding-agent box

View file

@ -0,0 +1,134 @@
# box export / import — a box's state that survives the box and the host (#70)
**Status: implemented.** Asked for by @danmt on #66, as the prerequisite for
the upgrade flow the installer wants to enforce: _stop, export, remove every
box before you upgrade — then re-import_. This doc records the design
decisions, the facts they rest on, and what holds them.
## The gap #70 named
Nothing a box held could outlive a host teardown:
- `box snapshot` is an **in-box** checkpoint, and `box rm` deletes the box
and every snapshot it has — a snapshot cannot outlive its box.
- `box new --from` clones to an independent box, but the clone still lives
**on the same host**, under the same stack. It is not an artifact you can
carry off the machine or keep across a teardown.
So #66's refusal-to-upgrade-over-live-boxes could only say "copy things out
by hand" — honest, but lossy. `box export` upgrades that instruction to
"export, keep the file, re-import after".
## The shape
`incus export` / `incus import` are the primitives: a backup tarball of an
instance and (by default) its snapshots, and instance creation from that
tarball. box wraps them where it must enforce what incus cannot see.
- **`box export <box> [<file>]`** — the box must be tagged `user.box=1` (the
boundary, as everywhere) and **stopped**. Default filename
`<box>-<UTC stamp>.tar.gz` (sortable, collision-free, and it answers the
question you will ask the file later: _when is this state from?_).
Snapshots ride along by default; `--instance-only` opts out, passed to
incus verbatim. Refuses to overwrite an existing file without `--force`.
- **`box import <file> [--name <box>]`** — reads the artifact's own instance
name from `backup/index.yaml` up front, refuses any name an existing
instance already holds (box or not — `resolve_box`'s boundary from the
other side), pre-flights the stack (`require_stack`, factored out of
`cmd_new` now that it has two callers), imports, then re-stamps, starts,
and hands over.
## The three decisions, and why they fell where they did
**1. Require `box down` first — no live export, no snapshot-then-export.**
Incus _can_ back up a running instance, but a live root disk is a moving
target, and this artifact's entire job is to be trusted later, on a host
that no longer has the box to compare against. The refusal reuses
`require_stopped` with an honest reason parameter: rename is stopped because
_incus_ insists; export is stopped because _we_ decided — the message should
not claim otherwise.
**2. Snapshots included by default.** box's reuse workflow (log in once,
snapshot, clone forever) lives in snapshots; an artifact that quietly
dropped the authed checkpoint would defeat the verb's purpose. The opt-out
is explicit and named for what it does (`--instance-only`).
**3. Credentials: shout, don't scrub.** A box's disk carries agent logins,
git PATs, SSH keys, shell history, deleted-but-unwiped blocks. "Scrubbing" a
disk-image tarball is a promise no tarball surgery can keep, and handing
someone a file labeled sanitized that is not would be worse than the risk it
hides. So export prints a loud, unconditional stderr warning: the file _is_
a credential; store and move it as one. Import repeats the point — auth
state came back by design, the same trust boundary as cloning an authed
snapshot.
## Import re-stamps the host's truth, not the artifact's
The split is the design. Everything `incus import` restores is the
artifact's truth: disk, config, devices, snapshots. Everything box then
re-stamps is the current host's:
- **The boundary tag.** `user.*` keys ride inside the artifact, so a box
export brings `user.box.template` / `user.box.user` back on its own, and a
legacy `user.claudebox=1` stays honored as it is everywhere else. An
instance carrying neither tag is stamped `user.box=1` — importing is
minting, and a minted box is ours to manage.
- **The placement.** The artifact carries its profile list, but the
isolation contract is _this_ host's `box-net` profile. A box export
already says `box-net`; anything else is re-assigned (`incus profile
assign` — the same move `migrate-host` makes re-homing a legacy box). An
artifact naming a profile the host lacks fails inside `incus import` with
incus's own error naming it. A fresh host without the stack at all is
refused before the import, tier-aware (`require_stack`: admins are sent to
`setup-host`, restricted users to `box grant`).
- **The identity — host side and guest side.** The artifact's `volatile.*`
config comes back verbatim, _including the NIC's MAC_: importing an
artifact twice, or beside the box it was exported from, collided at start
with `MAC address already defined on another NIC` (measured live on Incus
6.0.4 — `incus copy` regenerates the MAC on clone; `incus import` does
not). So import unsets every volatile hwaddr before the start and lets
incus mint fresh ones. Then, in-guest: the artifact's machine-id rides in
its disk, and `reset_identity` runs before handover, exactly like a clone
— machine-id → DHCP client-id → lease, the collision that function's
comment documents. Verified live: two imports of one artifact running side
by side with distinct MACs, distinct machine-ids, both holding the
pre-export file and snapshot.
## The restricted tier: measured, then converged
`incus export` rides the backup API (an export _is_ "create a backup,
download it, delete it"), and a restricted project blocks it by default:
`restricted.backups=block` the moment `restricted=true` — read from incus
6.0's `internal/server/project/permissions.go` (the default table, and
`AllowBackupCreation` enforcing it). Import needs no key of its own:
restoring a backup file is plain instance creation.
So the honest answer was not an `expose`-style refusal — the limitation is a
project key, not daemon-global state — but the same convergence grant
already performs for snapshots: `box grant` now also sets
`restricted.backups allow`. Re-run `box grant <user>` after upgrading, as
the grant contract already says.
## What holds it
- **`test/cli.sh`** (dependency-free, no incus): driven usage errors
(missing box/file/name-value, unknown box, missing file, a non-artifact
file refused by the pure tar+awk parse), and grep/line-order guards for
every daemon-gated invariant — `require_stopped` before `incus export`,
snapshots-by-default, the credential shout, `user.box=1` re-stamping, the
collision guard before `incus import`, `require_stack` in both `cmd_new`
and `cmd_import`, `reset_identity` after the start, and grant's
`restricted.backups allow`. All fail-closed: a deleted guard cannot ship
green.
- **CI's `rehearsal` job**, on the runner's live Incus (container mode — the
round-trip is backup mechanics, identical across instance types; the VM
boundary stays a real-hardware ritual): mint → write a file → snapshot →
down → export → `rm` → import under a new name → assert the tag, the
agent, the file, the snapshot survived, and that a colliding re-import is
refused.
## Related
- #66 — the installer refusal this makes humane (its message is reconciled
when both land).
- #67 — the version-aware upgrade that would migrate instead of asking.

View file

@ -151,6 +151,13 @@ snaps="$(incus project get "$p1" restricted.snapshots 2>/dev/null)"
[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \
|| no "restricted.snapshots = '$snaps' — box snapshot will refuse"
# The same shape for backups (#70): export rides the backup API, which
# restricted projects block by default exactly like snapshots. A grant that
# missed this key strands every post-upgrade 'box export' at the tier.
bkups="$(incus project get "$p1" restricted.backups 2>/dev/null)"
[ "$bkups" = allow ] && ok "backups allowed in $p1 (box export works at this tier)" \
|| no "restricted.backups = '$bkups' — box export will refuse"
incus --project "$p1" profile device get default eth0 type >/dev/null 2>&1 \
&& no "(h) $p1's default profile still carries the private-bridge eth0" \
|| ok "(h) $p1's default profile places no network — box-net is the only door"

View file

@ -17,7 +17,8 @@
# '--network' flag away from any box they mint
# 5. allow snapshots (incus-user blocks them; box's clone workflow is built
# on them)
# 6. install the shipped box-net profile into their project
# 6. allow backups (blocked too; 'box export' rides the backup API — #70)
# 7. install the shipped box-net profile into their project
#
# Idempotent: every step converges, so re-running (including after a box
# upgrade, to refresh the profile) is safe. incus-user never rewrites a
@ -183,7 +184,18 @@ echo "network: $project restricted to boxnet (the private $bridge is unreference
incus project set "$project" restricted.snapshots allow </dev/null
echo "snapshots: allowed"
# 6. The placement contract itself, installed into their project. Created if
# 6. Backups. 'box export' rides incus's backup API — an export IS "create a
# backup, download it, delete it" — and a restricted project blocks that by
# default: restricted.backups=block the moment restricted=true (incus 6.0,
# internal/server/project/permissions.go, enforced by AllowBackupCreation).
# 'box import' needs no key of its own — restoring a backup file is plain
# instance creation. Same convergence as snapshots, for the same reason: the
# tier is the same workflows on your own boxes, and export/import are
# workflows (#70).
incus project set "$project" restricted.backups allow </dev/null
echo "backups: allowed ('box export' rides them, #70)"
# 7. The placement contract itself, installed into their project. Created if
# missing, refreshed unconditionally — same convergence discipline as
# setup-host's own profile handling, so a box upgrade propagates by re-run.
incus --project "$project" profile show box-net >/dev/null 2>&1 </dev/null \

View file

@ -268,8 +268,8 @@ else
while IFS= read -r n; do warn " · $n"; done <<<"$names"
warn "refusing to change the default box version under them (#66) — the default stays at $old_ver."
log "box $new_ver is installed side-by-side. To switch:"
log " · preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
log " (a portable 'box export' is #70), then 'box rm <box>' when you are done"
log " · preserve what you care about — 'box down <box>', 'box export <box>'"
log " (one portable file per box, #70), then 'box rm <box>' when you are done"
log " · then flip the default: box use $new_ver"
else
flip_current "$new_ver"

View file

@ -195,11 +195,15 @@ check "expose: the restricted guard precedes the first incus call" 0 "" bash -c
[ -n "$guard" ] && [ -n "$first" ] && [ "$guard" -lt "$first" ]'
# cmd_new refuses before minting when the placement contract is absent, and
# the message is tier-aware (a restricted user is sent to 'box grant', not
# to setup-host they cannot run).
check "new: pre-flights the box-net profile" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
check "new: the restricted fix names box grant" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
# to setup-host they cannot run). The pre-flight lives in require_stack()
# since #70 gave it a second caller (import lands on the same contract), so
# assert both halves: the helper holds the probe, and cmd_new calls it.
check "require_stack: probes the box-net profile" 0 "" bash -c '
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
check "require_stack: the restricted fix names box grant" 0 "" bash -c '
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
check "new: pre-flights the stack (require_stack)" 0 "" bash -c '
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
# grant converges to boxnet and ONLY boxnet — "boxnet,incusbr" would keep the
# unhardened private bridge one --network flag away (the #74 measured hole).
check "grant: narrows access to boxnet alone" 0 "" \
@ -270,6 +274,74 @@ check "box exports BOX_TIER to the doctor" 0 "" \
# does not exist and the verb was broken for everyone until #74's rehearsal hit it.
check "restore: dispatches 'incus snapshot restore'" 0 "" \
grep -qF '^incus:snapshot restore^' "$ROOT/bin/box"
# ---------------------------------------------------------------------------
# export / import (#70) — a box's state that survives the box and the host.
# Usage errors and the pure pre-incus refusals are DRIVEN; every daemon-gated
# invariant is grep-guarded or line-order-asserted (fail-closed: an empty
# grep is a FAIL, so a deleted guard cannot ship green).
# ---------------------------------------------------------------------------
check "export without a box exits 2" 2 "usage: box export" "$BOX" export
check "export of an unknown box exits 1" 1 "no such box" "$BOX" export nosuchbox
check "import without a file exits 2" 2 "usage: box import" "$BOX" import
check "import of a missing file exits 1" 1 "no such file" "$BOX" import /nope/nothing.tar.gz
check "import --name with no value exits 2" 2 "--name needs a value" "$BOX" import x.tar.gz --name
# A file that is not an export artifact is named as such, before any incus
# call — pure (tar + awk), so it is driven, not grepped.
NOTATARBALL="$(mktemp)"; echo "not a tarball" > "$NOTATARBALL"
check "import: a non-artifact file is refused" 1 "not an incus/box export" "$BOX" import "$NOTATARBALL"
rm -f "$NOTATARBALL"
check "help export names the credential risk" 0 "CREDENTIAL" "$BOX" help export
check "help import names the re-stamping" 0 "user.box=1" "$BOX" help import
# Export refuses a running box — require_stopped fires BEFORE incus export
# (line order inside cmd_export, fail-closed on either grep missing).
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
check "export: requires the box stopped, before exporting" 0 "" bash -c '
fn="$(awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
guard="$(printf "%s\n" "$fn" | grep -n "require_stopped" | head -1 | cut -d: -f1)"
run="$(printf "%s\n" "$fn" | grep -n "incus export" | head -1 | cut -d: -f1)"
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
# Snapshots ride along by default; --instance-only is the explicit opt-out.
check "export: snapshots included unless --instance-only" 0 "" bash -c '
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q -- "--instance-only"'
# The credential SHOUT (#70's scrub-or-shout decision: box shouts).
check "export: shouts that the file is a credential" 0 "" bash -c '
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "treat the file itself as a credential"'
# Import re-stamps the boundary tag onto the current stack.
check "import: re-stamps user.box=1" 0 "" bash -c '
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "user.box=1"'
# The name-collision guard fires BEFORE incus import — the resolve_box
# boundary from the other side: never occupy an existing instance's name.
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
check "import: the collision guard precedes the import" 0 "" bash -c '
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
guard="$(printf "%s\n" "$fn" | grep -n "already exists" | head -1 | cut -d: -f1)"
run="$(printf "%s\n" "$fn" | grep -n "incus import" | head -1 | cut -d: -f1)"
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
# Import lands on the placement contract: same pre-flight as a mint.
check "import: pre-flights the stack (require_stack)" 0 "" bash -c '
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
# The artifact's MAC comes back verbatim, and a re-import beside a sibling
# collides at start (measured live: "MAC address already defined on another
# NIC") — the hwaddr unset must precede the start. Line order, fail-closed.
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
check "import: regenerates the NIC MAC before the start" 0 "" bash -c '
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
mac="$(printf "%s\n" "$fn" | grep -n "hwaddr" | head -1 | cut -d: -f1)"
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
[ -n "$mac" ] && [ -n "$start" ] && [ "$mac" -lt "$start" ]'
# reset_identity runs AFTER the imported box is started — the clone trust
# boundary (machine-id → DHCP lease), line-order-asserted, fail-closed.
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
check "import: reset_identity follows the start" 0 "" bash -c '
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
reset="$(printf "%s\n" "$fn" | grep -n "reset_identity" | head -1 | cut -d: -f1)"
[ -n "$start" ] && [ -n "$reset" ] && [ "$start" -lt "$reset" ]'
# The restricted tier can export: grant converges restricted.backups (the
# backup API is what 'incus export' rides; blocked by default — #70).
check "grant: allows backups (the export workflow)" 0 "" \
grep -qF 'restricted.backups allow' "$ROOT/host/grant-user.sh"
# The rehearsal itself stays runnable: syntax-checked here, run on real hosts.
check "multiuser.sh is valid bash" 0 "" bash -n "$ROOT/drill/multiuser.sh"
check "multiuser.sh refuses without the env gate" 2 "opt in" \