#!/usr/bin/env bash
# box — trust-less, isolated Incus VMs with Claude Code, creds-free.
# The command surface is the CMDS table below: it is the single source of truth
# for what exists, what it looks like, what the help says, and what runs. The
# help cannot drift from the code, because it is rendered from the same rows.
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=""; instance_only=0
inst=""   # the resolved Incus instance, set by the 'box' precondition

die() { echo "box: $*" >&2; exit 1; }         # 1 = it went wrong
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; }  # 2 = you asked wrong
# The tree's own version, read in ONE place. 'box --version' says it out loud;
# the mint stamp (#103) writes it onto every instance box creates, so a box can
# still name the release that made it long after that release is history.
box_version() { cat "$root/VERSION" 2>/dev/null || echo unknown; }
version() { echo "box $(box_version) ($root)"; }

# The SHAPE of the mint stamp, not the box version — an integer that changes
# only when a key is removed or repurposed, never when one is added (a reader
# that does not know a key simply does not print it). Absent means pre-stamp:
# every box minted before #103 has no schema key at all, and must keep working
# under every verb, which is the same promise 'user.claudebox' carries.
BOX_STAMP_SCHEMA=1

# Which tier is THIS PROCESS? Decided from live credentials (argless 'id -nG':
# what the kernel will present when incus opens the socket), never from the
# group database — the two disagree for exactly as long as a re-login is
# pending, and that window is where every wrong answer lives.
#   UID 0 / incus-admin  -> admin       (the full daemon socket)
#   incus (only)         -> restricted  (incus-user: your own project, nothing else)
#   neither              -> none        (no socket at all)
# host/setup-host.sh carries a byte-identical copy (it runs before any install
# tree exists); test/cli.sh diffs the two so they cannot drift.
box_tier() {
  [ "$(id -u)" -eq 0 ] && { printf 'admin\n'; return; }
  local groups; groups="$(id -nG 2>/dev/null | tr ' ' '\n')"
  if   printf '%s\n' "$groups" | grep -qx incus-admin; then printf 'admin\n'
  elif printf '%s\n' "$groups" | grep -qx incus;       then printf 'restricted\n'
  else printf 'none\n'
  fi
}

# ---------------------------------------------------------------------------
# The command table.
#
#   verb ^ synopsis args ^ preconditions ^ one-line summary ^ action ^ ok message ^ confirm prompt
#
# Fields are ^-separated because a synopsis may contain '|' ([--vm|--container]).
#
# preconditions (comma-separated):
#   box      first positional is a box: resolve it, and REFUSE if the instance
#            isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
#   arg2     a second positional is required
#   stopped  the box must not be running
#   confirm  destructive: prompt unless --force. The row supplies the words
#            (last field) — see 'confirm prompt' below.
#
# action:
#   incus:<subcommand>   run `incus <subcommand> <instance> [rest...]`
#   fn:<function>        call a shell function (it has real work to do)
#
# ok message: printed on success; {} = the box, {1} = the second positional.
#
# confirm prompt: the words the 'confirm' precondition asks with, phrased as
# the act ("delete X", "roll X back to Y") — confirm() wraps it into
# "box: <prompt>? this cannot be undone." and into the no-TTY refusal
# ("refusing to <prompt> without --force"). It is a per-row field and not a
# shared string on purpose: the prompt was hardcoded to rm's wording once, and
# the whole reason 'restore' shipped ungated for four releases is that adding
# the token to its row would have asked the operator to confirm DELETING the
# box they were trying to rescue (#105). A gate that names the wrong act is
# worse than no gate — it teaches people to answer 'y' without reading. Same
# substitutions as the ok message, except {} is the RESOLVED instance: the
# prompt names the thing about to be destroyed, so under --remote it should
# say 'lab:work', not 'work'.
#
# Adding a thin verb is one row. If a request can't be expressed as a row and
# doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for.
CMDS=(
  "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^"
  "list^[--json]^^List your boxes^fn:cmd_list^"
  "info^<box> [--json]^box^One box: state, type, IP, what minted it, snapshot labels^fn:cmd_info^"
  "shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
  "exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
  "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> [--force]^box,arg2,confirm^Roll a box back to one of its snapshots — irreversible, and it asks first^incus:snapshot restore^restored {} to {1}^roll {} back to snapshot '{1}' and discard everything in the box since it was taken"
  "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 {}"
  "rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}^delete {} and all its snapshots"
  "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^"
  "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^"
  "grant^<user>^^Admin: give a host user the restricted tier — their own boxes, on the hardened boxnet^fn:cmd_grant^"
  "revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^"
  "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^"
  "versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^"
  "use^<version>^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^"
  "uninstall^[<version>] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^"
  "status^^^Deprecated alias for 'list'^fn:cmd_status^"
  "help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
)

cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
verbs()   { local r; for r in "${CMDS[@]}"; do echo "${r%%^*}"; done; }
is_command() { cmd_row "$1" >/dev/null 2>&1; }
# locals matter here: dispatch holds $pre/$action/$ok, and field() is called from
# error paths inside it — a global read would clobber the row being dispatched.
field() {
  local r f_syn f_pre f_sum f_act f_ok f_cnf
  r="$(cmd_row "$1")" || return 1
  IFS='^' read -r _ f_syn f_pre f_sum f_act f_ok f_cnf <<<"$r"
  case "$2" in
    syn) echo "$f_syn" ;; pre) echo "$f_pre" ;; sum) echo "$f_sum" ;;
    act) echo "$f_act" ;; ok) echo "$f_ok" ;; cnf) echo "$f_cnf" ;;
  esac
}
synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }

# Nearest command by edit distance — a typo should point somewhere, not just fail.
suggest() {
  verbs | awk -v w="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" '
    function dist(a, b,   la, lb, i, j, c, prev, cur) {
      la = length(a); lb = length(b)
      for (j = 0; j <= lb; j++) prev[j] = j
      for (i = 1; i <= la; i++) {
        cur[0] = i
        for (j = 1; j <= lb; j++) {
          c = (substr(a, i, 1) == substr(b, j, 1)) ? 0 : 1
          cur[j] = prev[j] + 1
          if (cur[j - 1] + 1 < cur[j]) cur[j] = cur[j - 1] + 1
          if (prev[j - 1] + c < cur[j]) cur[j] = prev[j - 1] + c
        }
        for (j = 0; j <= lb; j++) prev[j] = cur[j]
      }
      return prev[lb]
    }
    BEGIN { best = 99 }
    { d = dist(w, $0); if (d < best) { best = d; hit = $0 } }
    END { if (best <= 2) print hit }'
}

unknown_command() {
  local hint; hint="$(suggest "$1")"
  if [ -n "$hint" ]; then
    echo "box: unknown command: $1 — did you mean '$hint'?" >&2
  else
    echo "box: unknown command: $1" >&2
  fi
  echo "try 'box help' for the command list." >&2
  exit 2
}

usage() {
  cat <<'EOF'
box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.

USAGE
  box <command> [<args>] [options]

COMMANDS
EOF
  local r v sum
  for r in "${CMDS[@]}"; do
    IFS='^' read -r v _ _ sum _ _ _ <<<"$r"
    printf '  %-13s %s\n' "$v" "$sum"
  done
  cat <<'EOF'

OPTIONS
  --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)
  --instance-only       Export the live state only, no snapshots   (export)
  --force, -f           Destroy without asking (rm, restore); 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

  Options come after the command: 'box list --json', not 'box --json list'.

EXAMPLES
  # mint a claude box and log in inside it — the tool never handles your token
  box new --name work --template claude-box
  box shell work                    # then: run 'claude', then /login

  # log in once, reuse forever: checkpoint the authed box, clone from it
  box snapshot work authed
  box new --name feature --from work/authed

  # the default: a blank box — same isolation, nobody home
  box new --name scratch
  box templates

  # what have I got, and what can I clone?
  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

  # anything box doesn't wrap: boxes are plain Incus instances
  box incus work -- config show

EXIT STATUS
  0  ok
  1  it went wrong (Incus failed, no such box, aborted at a prompt)
  2  you asked wrong (unknown command, bad flag, destructive act without --force)

THE MODEL
  A box carries NO credentials. You authenticate interactively inside it
  ('claude' then /login; 'gh auth login'); box never stores or injects a
  secret. A box reaches the public internet and nothing else — there is no
  inbound path. Destroying a box loses nothing you didn't push.

  box owns a command when it must enforce something Incus cannot see: the
  user.box=1 boundary, the isolation stack, or the creds-free snapshot
  workflow. Everything else is Incus's job — and 'box incus' is the door.

  On a shared host, an admin hands out the restricted tier per user
  ('box grant <user>'): their own boxes, the same hardened network, and
  no view of anyone else's. 'box help grant' has the contract.

  Docs: https://github.com/heavy-duty/box
EOF
}

# The synopsis and the summary come from the table; only prose lives here, and
# only where a command has something to say beyond its summary.
help_cmd() {
  echo "usage: $(synopsis_of "$1")"
  echo
  case "$1" in
    new) cat <<'EOF'
Mint a box. Without --from, launches a fresh box from a template (default:
blank — bare Debian 13, nobody home; --template claude-box gets Claude Code
installed, creds-free, ~10 min cold). With --from, clones an existing box or
one of its snapshots — login state, git creds and clones carry over,
isolation is preserved, and the clone knows its template's user without
being told.

A tenant template (claude-box, codex-box, grok-box, staging-box) is a THIN seed — the user,
tmux, rig (#81) — and after cloud-init box auto-runs the creds-free tenant
role inside it ('rig bootstrap <role>', rig#31): that role installs the
agent CLI / server posture and the agent-context file. rig is preinstalled
from RIG_REPO/RIG_REF in the mint environment (default heavy-duty/rig@main,
unpinned — an honest edge until rig has releases). Anything that joins a
tailnet or holds a key stays operator-run, never auto-run.

  --name <box>           Required. The box's name.
  --template <t>         Template to mint from; 'box templates' lists them.
                         A template sets image, user, resources, boot
                         demands (independently: BOX_REQUIRE_VM insists on
                         VM mode, BOX_AUTOSTART survives host reboots) and
                         a creds-free tenant role (BOX_BOOTSTRAP_ROLE) —
                         never the network: every template gets the same
                         isolation.
  --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
                         default wherever /dev/kvm exists; container mode
                         (security.nesting=true) is the fallback for hosts
                         without nested virt — weaker isolation, dev/test only.
                         A template that requires VM mode refuses both the
                         fallback and --container.

Resources resolve most-specific-first: these flags, then BOX_CPU /
BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the
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_LAUNCH_TIMEOUT=<seconds> (default 600) bounds the 'incus launch' call —
a launch that overruns it fails loudly instead of hanging forever (#93).

  box new --name scratch                     # blank, the default
  box new --name work --template claude-box
  box new --name lean --template claude-box --cpu 2 --memory 3GiB
  box new --name feature --from work/authed
EOF
;;
    templates) cat <<'EOF'
List the templates this install can mint, with their descriptions. A template
is a directory under templates/: a box.env (image, user, resources, boot
demands, tenant role — parsed against an allowlist, never sourced) and a
user-data.yaml (cloud-init, passed to Incus verbatim except the rig pin
tokens @RIG_REPO@/@RIG_REF@, resolved at mint from the environment).
Templates cannot touch the network or security flags — the shared box-net
profile is the placement contract, so every template gets the same isolation.

Templates are thin, creds-free seeds (#81): the user, tmux and rig — what a
box BECOMES lives in rig's bootstrap roles (rig#31), auto-run at mint via
BOX_BOOTSTRAP_ROLE. box mints; rig converges.

  box templates
  box new --name scratch --template blank
EOF
;;
    list) cat <<'EOF'
List the boxes box minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'box info <box>'.

  --json   Incus's JSON, straight through, for scripting.

  box list
EOF
;;
    info) cat <<'EOF'
Show one box: state, type, IP address, and — the reason this exists — the
labels of its snapshots, with the --from line to clone one.

  --json   Incus's JSON, straight through, for scripting.

  box info work
EOF
;;
    shell) cat <<'EOF'
Open an interactive shell in a running box as the 'claude' user. This is the
only entry path — there is no SSH and no inbound route to a box.

  box shell work
EOF
;;
    exec) cat <<'EOF'
Run a command inside a box as the 'claude' user. Everything after -- is passed
through untouched; the -- is required, or box will read your command's
flags as its own.

  box exec work -- git -C project pull
  box exec work -- claude --version
EOF
;;
    tmux) cat <<'EOF'
A shell that survives you. 'shell' is a child of the exec connection — if your
terminal or SSH session drops, everything running in it is SIGHUP'd, and a
long Claude run dies with it. This attaches a tmux session instead
(new-session -A): created if new, reattached if it exists — so starting work
and resuming after a disconnect are the same command, with no state to
remember.

The session name (default: main) buys parallel streams in one box:

  box tmux work            # attach or create 'main'
  box tmux work run-1      # a second, independent stream, same box

Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
tmux's semantics, 'box shell' is unchanged.
EOF
;;
    snapshot) cat <<'EOF'
Checkpoint a box. Snapshots are how an authenticated box is reused: log in
once, snapshot, then 'new --from <box>/<label>' as often as you like. The
label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have.

  box snapshot work authed
EOF
;;
    restore) cat <<'EOF'
Roll a box back to one of its snapshots, in place. Anything in the box since
that snapshot is lost. 'box info <box>' lists the labels.

  box restore work authed
  box restore work authed --force

Destructive, so it asks first — naming the snapshot it is rolling back to,
because the whole risk is picking the wrong label. --force (-f) skips the
prompt; with no TTY to ask on it refuses rather than assuming yes.

box does not require the box to be stopped for this. Snapshots here are
stateless — no live memory is captured — so a rollback is crash-consistent:
the box comes back the way a machine comes back from losing power. 'box down
<box>' first if that matters.
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:

  box down work
  box rename work archive
  box start archive

Snapshots and Claude auth follow the box; anything referring to the old name by
hand (a --from line, a script) does not.
EOF
;;
    rm) cat <<'EOF'
Delete a box and every snapshot it has. This cannot be undone, so it asks for
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
(a script, a pipe), it refuses unless --force is given.

  box rm work
  box rm work --force
EOF
;;
    expose) cat <<'EOF'
Open a deliberate, loopback-only door to a port inside a box — for when you
are coding in a box and want to see its dev server in your browser.

  box expose <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
  box shell web        # inside: run a dev server on 0.0.0.0:3000
  box expose web 3000  # then open http://127.0.0.1:3000 in your browser
EOF
;;
    incus) cat <<'EOF'
The door out. box wraps the box lifecycle and the isolation model, not
all of Incus — so when you need something it doesn't wrap, run Incus through
here and keep the safety rail that matters: the box name is resolved and
checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
box didn't mint.

Everything after -- is passed to incus verbatim. A literal {} is replaced with
the resolved instance name; with no {}, the instance is appended at the end.
The command that will run is echoed before it runs.

  box incus work -- config show
  box incus work -- config device add {} extra disk source=/data path=/data

Changing the profile, the network, a device or a security.* key can take a box
outside the isolation stack. box warns and then does as you asked — from
there, the trust boundary is yours to keep.
EOF
;;
    doctor) cat <<'EOF'
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
the kernel's bridge port say 'isolated on', is the resolver pinned or is a
host VPN's DNS leaking into boxes, can a box actually resolve names — and
the #80 nested-stack signature: a default gateway held as a LOCAL address,
or duplicate connected routes for the uplink subnet, judged on this machine
AND inside every box it probes (a box stack installed inside a box squats on
the guest's gateway and blackholes its egress, intermittently). Every check
exists because its fault has happened — most kill a cold mint with a
cloud-init error that names none of them.

  --fix      also revert what a drill run may have left behind
  --pin-dns  pin boxnet's resolver to public upstreams and re-test
             (setup-host.sh now pins by default; this is the quick test)

  box doctor
  box doctor --fix

Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given.
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;
install.sh runs it for you, so this is for re-applying by hand.

One run is enough. If it has to add you to the incus-admin group it re-runs
itself under that group — no re-login, no second invocation.

The stack's subnet: 10.88.0.0/24 when free; with an existing boxnet it
converges on the bridge's own subnet; and when the default is claimed by
something else — most tellingly this machine's own default gateway, i.e.
setup-host running INSIDE a box — it auto-picks the first free /24 from
10.89.0.0/24 through 10.127.0.0/24 and says so. A nested stack on the
guest's own uplink subnet would capture its gateway address and blackhole
its egress, intermittently (issue #80); the auto-pick is why a drill or
rehearsal inside a box now works with zero flags. BOX_SUBNET pins the
subnet explicitly (the bridge, the gateway carve-out and the firewall all
derive from it) — a pin is never overridden: setup-host REFUSES, before
touching anything, when the pinned subnet is claimed by a foreigner or
disagrees with an existing bridge.

  box setup-host                           # picks/converges by itself
  BOX_SUBNET=10.90.0.0/24 box setup-host   # scripted hosts: pin it

Multi-user hosts: setup-host builds the stack once, for everyone. An admin
then hands individual users the restricted tier with 'box grant <user>' —
their own boxes, on this same hardened network, seeing nobody else's.
EOF
;;
    grant) cat <<'EOF'
Give a host user the restricted tier. They get their own Incus project (via
incus-user), and every box they mint lands on the SAME hardened boxnet as an
admin's — the full isolation contract (ACL, DNS isolation, resolver pin,
port isolation, the box-to-box drop), with no view of anyone else's boxes.

What it converges, idempotently (safe to re-run, and re-run after upgrades):
  · puts the user in the 'incus' group (not incus-admin — that is the point)
  · creates their user-<uid> project by touching incus-user for them
  · 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 and backups (incus-user blocks both; the clone workflow
    rides snapshots, 'box export' rides backups — #70)
  · installs the box-net profile into their project

An incus-admin member is provisioned too, not refused (#99): they are added
to 'incus' like anyone else — not a new privilege, since incus-admin already
opens the daemon, but the key to a FILE, because incus-user's socket is group
'incus' mode 0660 and nothing below can provision them without it. Everything
else converges, so they finally have a project of their own. What it is not
is a confinement:
incus-admin wins at the socket, so the restrictions are a default placement
they can step outside at will, and their own client keeps resolving to the
admin socket (and the default project) until incus-admin is taken away. The
grant says all of that out loud when it lands.

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
EOF
;;
    revoke) cat <<'EOF'
Take the restricted tier back from a user. Without --purge, this removes
them from the 'incus' group: their project and boxes stay (still running!)
and 'box grant' restores access untouched. Group membership is read at
LOGIN, so a session they already hold keeps the socket until it ends —
revoke says so and names the loginctl command when it happens. With
--purge, their sessions are terminated first (a stale session could quietly
recreate the project, unhardened, afterwards — measured, not theoretical),
then their boxes, images, project, private bridge and trust-store
certificate are removed — irreversible, so it asks first.

On an incus-admin member a bare revoke takes back the 'incus' membership that
grant added — reported as 'partial:', because it ends no access: incus-admin
still opens every project on this host. (One who was never granted is a named
no-op instead.) Mind what that leaves behind: with 'incus' gone, a later
'gpasswd -d <user> incus-admin' drops them into NEITHER group and their ready
project becomes unreachable — grant's "no re-grant needed" holds only while
they still hold 'incus'. --purge unmakes the provisioning the same way. Only
'gpasswd -d <user> incus-admin' ends their access, and revoke says so.

  box revoke dev1            # take the tier; their boxes keep running
  box revoke dev1 --purge    # ...or end their sessions and delete everything
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
;;
    versions) cat <<'EOF'
List the versions installed under this install root — install.sh lands each
one side by side at <root>/versions/<v>, and a 'current' symlink tracks the
default (what the box on your PATH runs). The default is marked (current);
the tree answering THIS command is marked (running) — they differ when your
PATH resolves a different install (say, a global /opt/box shadowing yours).

  box versions
  box use <version>        # switch the default
  # install another version side by side: re-run install.sh
EOF
;;
    use) cat <<'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,
'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.

  box versions             # what is installed
  box use 0.6.0
EOF
;;
    uninstall) cat <<'EOF'
Remove one installed version, or the whole install — the real uninstall,
replacing the old "rm -rf two paths by hand" instructions.

  box uninstall <version>     one NON-current version ('box use' another
                              first if you are on it)
  box uninstall               everything: every version, the current and
  box uninstall --all         PATH symlinks, and any legacy claudebox crumbs
  box uninstall --purge-host  run teardown-host first (all boxes, the boxnet
                              stack, the firewall — its own confirmation),
                              then remove the install

The full uninstall runs in the safe order: boxes first — it refuses while
any exist (and names them) unless --purge-host tears them down; then the
trees and symlinks; and it ENDS with an absence assert — every removed path
is re-checked, and anything still present makes it exit 1 naming the
leftovers instead of reporting a clean uninstall that wasn't (the same
discipline as 'box revoke --purge'). Asks before removing; --force or
BOX_YES=1 skips the prompt. On a multi-user host, revoke granted users
first: 'box revoke <user> --purge'.

  box uninstall 0.5.0
  box uninstall --all --purge-host
EOF
;;
    status) cat <<'EOF'
Deprecated alias for 'box list'. It ignored the <box> argument it
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
still works, and forwards to 'list'.
EOF
;;
    help) cat <<'EOF'
Print the general help, or the help for one command.

  box help
  box help rename
EOF
;;
    *) field "$1" sum ;;   # no prose: the table's summary is the help
  esac
}

show_help() {   # "" → general help
  case "${1:-}" in
    ""|help) usage ;;
    *) is_command "$1" || unknown_command "$1"; help_cmd "$1" ;;
  esac
}

cmd="${1:-help}"; shift || true
case "$cmd" in
  -h|--help)    usage; exit 0 ;;
  -V|--version) version; exit 0 ;;
  -*)           usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
esac

args=()
while [ $# -gt 0 ]; do
  case "$1" in
    --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 ;;
    --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 ;;
    --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 ;;
    --version|-V) version; exit 0 ;;
    --) shift; args+=("$@"); break ;;
    # An unrecognized flag used to be swallowed as a positional — so a typo'd
    # --labl silently became a snapshot's label. Say so instead.
    -*)
      # doctor's flags belong to the doctor script, not to box
      if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
      # expose's own flags (--list, --remove) are positional to it, not box's
      if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
      # the host verbs delegate their flags to the scripts they wrap;
      # uninstall parses its own (--all, --purge-host) the same way
      case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac
      if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
        usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
      fi
      usage_error "unknown option: $1 (see 'box help $cmd')" ;;
    *) args+=("$1"); shift ;;
  esac
done

if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi

# --- preconditions ---------------------------------------------------------

iname_of() { echo "$remote$1"; }   # instance name = box name

# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
# somebody's VM, and box will not stop, rename or delete it by accident.
resolve_box() {
  local box="$1" i tag
  i="$(iname_of "$box")"
  tag="$(incus config get "$i" user.box 2>/dev/null || true)"
  # A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
  # old boxes outlive the release that minted them — the legacy tag is honored
  # forever, or an old box stops being a box at all.
  [ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
  [ "$tag" = "1" ] || die "no such box: $box (see 'box list')"
  echo "$i"
}

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" 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} — $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")"
  fi
}

need_arg2() {
  if [ "${#args[@]}" -lt 2 ] || [ -z "${args[1]}" ]; then
    usage_error "usage: $(synopsis_of "$cmd")"
  fi
}

# Row templates: {} -> $2, {1} -> the second positional. The caller picks what
# {} means because the two uses differ on purpose — the ok message reports on
# the box the operator NAMED, the confirm prompt names the RESOLVED instance,
# because a prompt about to destroy something should say which machine.
fill() {   # $1 = template, $2 = what {} stands for
  local t="${1//\{\}/$2}"
  printf '%s\n' "${t//\{1\}/${args[1]:-}}"
}

confirm() {   # $1 = prompt. --force, or a TTY to ask on, or we refuse.
  if [ "$force" -eq 1 ]; then return 0; fi
  [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
  local reply
  printf 'box: %s? this cannot be undone. [y/N] ' "$1"
  # EOF (Ctrl-D) is an answer, and it means no. Unguarded, 'read' returns
  # non-zero and 'set -e' ends the run in silence — heavy-duty/rig#43.
  read -r reply || die "aborted."
  case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}

# --- commands with real work -----------------------------------------------

pick_mode() {
  if [ "$mode" != auto ]; then echo "$mode"; return; fi
  if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
    echo "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
    echo container
  fi
}

# Five minutes, not three: the first VM launch on a fresh pool unpacks the
# image into a pool volume and takes the coldest possible boot — measured
# live, an agent can need past the 3-minute mark exactly once per pool while
# every later boot answers in seconds. And when it still fails, ship the
# forensics: the VM's console says why, and the box is torn down by whoever
# called us before anyone can read it.
wait_agent() {
  local n="$1" i clog
  echo "box: waiting for instance agent..."
  for i in $(seq 1 150); do
    if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
    if [ "$i" -eq 150 ]; then
      # The console log is FULL of terminal escape sequences (boot messages,
      # a firmware menu). Dumping it raw scrambles the operator's terminal —
      # and doubly so when it lands in a log someone is tail -f'ing. Capture
      # it to a file, STRIP everything but printable ASCII + tab/newline, and
      # print only a short sanitized tail. Nothing raw ever reaches a terminal.
      clog="/tmp/box-console-$n.log"
      timeout -k 5 15 incus console "$n" --show-log </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
    sleep 2
  done
}

# A clone must not BE its source. Incus regenerates the MAC, but /etc/machine-id
# rides along inside the disk — and systemd derives its DHCP client identifier
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
# to the second on the lease timer. Every box cloned from one snapshot collided
# on the network, which is exactly the workflow box exists for (log in
# once, snapshot, clone forever).
#
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
# the reset costs one reboot. Do it before handing the box over, never after.
reset_identity() {
  local i="$1"
  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
  # fix. 'systemd-machine-id-setup' writes a fresh VALID id instead; in a VM it
  # derives from the DMI product UUID, which Incus makes unique per instance.
  incus exec "$i" -- sh -c '
    rm -f /etc/machine-id /var/lib/dbus/machine-id
    systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
    ln -sf /etc/machine-id /var/lib/dbus/machine-id
    test -s /etc/machine-id
  ' </dev/null || die "could not reset the clone's machine-id"
  # The new id only takes effect at boot. Ask nicely, then insist — a clone that
  # keeps its source's DHCP lease is worse than an unclean stop of a box that
  # booted 30 seconds ago.
  incus restart --timeout 60 "$i" >/dev/null 2>&1 || incus restart -f "$i"
  wait_agent "$i"
}

# Templates set image, user, resources, boot demands, a tenant role and
# cloud-init — NOTHING else. The box.env file is parsed against this allowlist,
# never sourced: sourcing would hand every template arbitrary bash execution on
# the HOST at mint time. And there is deliberately no key for a network or a
# security flag — the shared box-net profile is the placement contract, so no
# template can weaken isolation. 'blank' is a box with nobody home, not a box
# with the safety off. The two boot demands are for server-class templates
# (#68): BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust
# boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps
# boot.autostart so the box survives a host reboot without an operator.
# BOX_BOOTSTRAP_ROLE (#81) names the rig role box auto-runs after mint — the
# thin-template split: the seed is user + tmux + rig, and what the box BECOMES
# is 'rig bootstrap <role>'. Only creds-free roles belong here, by contract.
load_template() {
  local t="$1" dir line key val
  dir="$root/templates/$t"
  [ -d "$dir" ] || die "no such template: $t (see 'box templates')"
  [ -f "$dir/box.env" ] || die "template '$t' has no box.env"
  T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
  T_REQUIRE_VM=""; T_AUTOSTART=""; T_BOOTSTRAP_ROLE=""
  while IFS= read -r line || [ -n "$line" ]; do
    case "$line" in ''|\#*) continue ;; esac
    case "$line" in
      *=*) key="${line%%=*}"; val="${line#*=}" ;;
      *) die "template '$t': not a KEY=\"value\" line: $line" ;;
    esac
    val="${val#\"}"; val="${val%\"}"
    # T_DESC is parsed for symmetry with the other BOX_* keys, but cmd_templates
    # re-reads BOX_DESCRIPTION straight from the file (a box is listed without
    # ever loading its template), so the parsed value here is never read. Keep the
    # row — deleting it would turn box.env's own key into an "unknown key" error at
    # mint time. (SC2034 disabled for the branch below; the directive must sit on
    # the whole case, not an individual arm.)
    # shellcheck disable=SC2034
    case "$key" in
      BOX_DESCRIPTION) T_DESC="$val" ;;
      BOX_IMAGE)       T_IMAGE="$val" ;;
      BOX_USER)        T_USER="$val" ;;
      BOX_CPU)         T_CPU="$val" ;;
      BOX_MEMORY)      T_MEMORY="$val" ;;
      BOX_DISK)        T_DISK="$val" ;;
      BOX_REQUIRE_VM)  T_REQUIRE_VM="$val" ;;
      BOX_AUTOSTART)   T_AUTOSTART="$val" ;;
      BOX_BOOTSTRAP_ROLE) T_BOOTSTRAP_ROLE="$val" ;;
      *) die "template '$t': unknown key '$key' — a template sets image, user, resources, boot demands and a tenant role, nothing else (there is no key for a network, on purpose)" ;;
    esac
  done <"$dir/box.env"
  # Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still
  # dies (which is what we want) — but it reads as an if-then-else it is not, so
  # spell the guard out (SC2015).
  if [ -z "$T_IMAGE" ] || [ -z "$T_USER" ]; then
    die "template '$t': BOX_IMAGE and BOX_USER are required"
  fi
  # A bootstrap role is a rig role NAME and nothing more — it is handed to
  # 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
  # the value must die here, on the host, before a guest ever sees it.
  if [ -n "$T_BOOTSTRAP_ROLE" ] && ! [[ "$T_BOOTSTRAP_ROLE" =~ ^[a-z][a-z0-9-]*$ ]]; then
    die "template '$t': BOX_BOOTSTRAP_ROLE is not a sane role name: $T_BOOTSTRAP_ROLE"
  fi
  # Resolution, most specific wins: inline flag (--cpu/--memory/--disk, #57)
  # > BOX_* environment (how a small host or the drill shrinks every box it
  # mints) > the template's file > defaults. Values pass to Incus verbatim —
  # its units, its validation; box adds no parser of its own. Resources only:
  # 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}}}"
}

# The ONE substitution a template gets — user-data.yaml is otherwise passed to
# Incus verbatim. The tenant seeds preinstall rig, which inverts the rig→box
# install edge (rig#28: rig installs box on hosts; now box guests install rig),
# and that edge needs a pin point (#81): the seed carries @RIG_REPO@ /
# @RIG_REF@ tokens, resolved here from the mint environment — RIG_REPO
# (default heavy-duty/rig) and RIG_REF (default main). Both directions track
# main unpinned today, said honestly (the same treatment rig#29 gave box's own
# unpinned install) until a release flow exists (rig#32 / #83). The values are
# allowlist-validated BEFORE touching the YAML: they land inside a runcmd
# shell line, so a quote, a space or a newline smuggled through the
# environment must die on the host, never execute in the guest. bash's =~
# anchors to the whole string — a multi-line value cannot sneak one clean
# line past it the way a line-oriented grep would.
# The rig pin, resolved from the mint environment, in ONE place: render_userdata
# substitutes it into the seed, and the mint stamp (#103) records it onto the
# instance. Two spellings of the same default would eventually disagree, and a
# stamp that disagrees with the seed is worse than no stamp at all.
rig_repo() { printf '%s\n' "${RIG_REPO:-heavy-duty/rig}"; }
rig_ref()  { printf '%s\n' "${RIG_REF:-main}"; }

render_userdata() {
  local f="$1" repo data ref
  repo="$(rig_repo)"; ref="$(rig_ref)"
  [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
    || die "RIG_REPO must look like owner/repo: $repo"
  [[ "$ref" =~ ^[A-Za-z0-9._/-]+$ ]] \
    || die "RIG_REF must be a plain ref name (letters, digits, . _ / -): $ref"
  data="$(cat "$f")"
  data="${data//@RIG_REPO@/$repo}"
  data="${data//@RIG_REF@/$ref}"
  printf '%s\n' "$data"
}

cmd_templates() {
  local d t desc
  echo "TEMPLATES"
  for d in "$root/templates"/*/; do
    t="$(basename "$d")"
    desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
    printf '  %-10s %s\n' "$t" "$desc"
  done
  echo
  echo "mint one:  box new --name <box> --template <template>"
}

# When this instance came into being. A timestamp in a CONVERGENT file would be
# churn — the same run writing a different byte every time — but a mint is not
# convergent: it happens exactly once, to exactly one instance, and is never
# re-run against it. UTC and ISO 8601 so it sorts as a string and means the same
# thing on every host that reads it back.
mint_time() { date -u +%Y-%m-%dT%H:%M:%SZ; }

cmd_new() {
  [ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
  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)"
    [ -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
    case "$from" in */*) snap="${from#*/}" ;; esac
    srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
    incus copy "$srcref" "$instance"
    # 'incus copy' carries every user.* key forward (audit B2) — which is what
    # makes a clone know its template and user for free, and is also why the
    # mint stamp (#103) cannot simply ride along. A clone that inherited the
    # stamp verbatim would claim to have been minted at the source's mint time,
    # by the box version that minted the SOURCE, in a mint that never touched
    # this instance. That is not a stale field, it is a false one.
    #
    # So re-stamp exactly the keys that describe THIS instance's coming into
    # being, and leave the rest alone:
    #   · version / created / schema — the clone was made HERE, NOW, by THIS box
    #   · origin=clone, origin.from=<srcref> — how, and from what
    # Deliberately NOT re-stamped, because they are lineage and stay true: the
    # clone's disk really did come from that image, that template, that user and
    # that rig role — reading them off the source is the whole point of a clone.
    # ('incus copy' preserves the instance type too, so mode stays true as well.)
    #
    # 'mode.asked' is the one key that sits in NEITHER column, and so it is
    # CLEARED rather than re-stamped or inherited. It is a mint-event fact —
    # only the mint knew whether a container was asked for or fallen back into
    # for want of /dev/kvm — and the asker was the SOURCE's operator. A clone
    # refuses --vm/--container outright (nobody was asked anything here), so an
    # inherited 'asked' makes 'box info' print a demand that was never made of
    # this instance. There is no true value to re-stamp it with: the honest
    # answer is absence, and absence is already how the whole block renders
    # what it does not know — the MODE line simply does not print, while TYPE
    # above still says VM or CT off the preserved instance type.
    #
    # origin.from records ONE hop. A clone of a clone names its parent and
    # forgets its grandparent: the alternative is an unbounded chain in a config
    # value, and the parent is the box an operator can actually go look at.
    incus config set "$instance" \
      user.box.schema="$BOX_STAMP_SCHEMA" \
      user.box.version="$(box_version)" \
      user.box.created="$(mint_time)" \
      user.box.origin=clone \
      user.box.origin.from="$srcref"
    # Cleared, not set-to-empty: an empty value is still a key, and a reader
    # that greps the config would find it. Tolerated failure because the source
    # may predate the stamp and never have carried the key at all — a clone
    # must not die over a key that was already absent.
    incus config unset "$instance" user.box.mode.asked >/dev/null 2>&1 || true
    incus start "$instance"
    wait_agent "$instance"
    reset_identity "$instance"
    echo "box: cloned $srcref — isolation and auth state carry over from the source."
  else
    local t="${template:-blank}" m extra=()
    load_template "$t"
    m="$(pick_mode)"
    # A server-class template (BOX_REQUIRE_VM, #68) has no weaker mode: the VM
    # is its trust boundary, and its guest runs docker. Refuse the container
    # fallback AND an explicit --container — never silently mint something
    # lesser than what the template promises. The message holds for both
    # tiers: /dev/kvm is a fact about the HOST, and an admin's default-project
    # mint and a restricted user's incus-user mint go through the same daemon,
    # so the fix is the same for both — a KVM-capable host, not a grant.
    if [ "$T_REQUIRE_VM" = 1 ] && [ "$m" != vm ]; then
      [ "$mode" != container ] || usage_error "template '$t' requires VM mode — it will not mint as a container (drop --container)"
      die "template '$t' requires VM mode and this host has no /dev/kvm — mint it on a KVM-capable host (or via --remote)"
    fi
    # shellcheck disable=SC2054 # "root,size=..." is a single incus argument
    # 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
    # BOX_AUTOSTART (#68): a server-class box must come back after a host
    # reboot without an operator. Stamped per-instance like limits.*; a --from clone
    # needs no code — 'incus copy' keeps every non-volatile config key, the
    # same ride the user.* stamps take (audit B2).
    [ "$T_AUTOSTART" != 1 ] || extra+=(--config boot.autostart=true)
    # 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,
    # which user. 'incus copy' preserves user.* keys (audit B2), so a clone
    # knows what it is without ever consulting the template again.
    #
    # And the rest of what this line knows and used to drop on the floor (#103).
    # There is no host-side per-box store — the Incus instance config IS the
    # database — so a fact not written here is simply gone the moment the mint
    # returns. The stamp describes the MINT, not the outcome: it lands before
    # cloud-init and before rig, and nothing later edits it.
    #
    #   schema   the stamp's shape, so a future reader knows what it is holding
    #   version  the box that minted it — 'box --version' is a fact about the
    #            binary in front of you, never about the box you are looking at
    #   image    the alias asked for. It is an UNPINNED alias on a moving
    #            remote: two boxes minted a month apart from "the same
    #            template" are not the same box, and the alias alone cannot
    #            say so. What it resolved to is pinned after the launch below.
    #   mode     what it minted as, and what was ASKED — a container that fell
    #            back for want of /dev/kvm and one the operator asked for read
    #            identically afterwards, and only the mint knew which
    #   role      the rig role box auto-runs at the hook below
    #   rig.*     WHICH rig converged it, stamped only for a seed that actually
    #            installs rig from the pin ('blank' seeds none, so it gets none)
    #   created   when. See mint_time() for why a timestamp belongs here.
    #   origin    mint. A clone re-stamps it (see the --from branch above).
    #
    # NOT stamped, on purpose: cpu/memory (limits.* already hold them, and a
    # duplicate drifts the first time someone edits the limit by hand); disk
    # (a VM's is the root device size, and a container's does not exist — its
    # root rides the pool, so a stamped value would be fiction); and tier,
    # which box_tier() derives from whoever is ASKING, not from the box.
    local stamp=(
      --config user.box.schema="$BOX_STAMP_SCHEMA"
      --config user.box.version="$(box_version)"
      --config user.box.image="$T_IMAGE"
      --config user.box.mode="$m"
      --config user.box.mode.asked="$mode"
      --config user.box.created="$(mint_time)"
      --config user.box.origin=mint
    )
    [ -z "$T_BOOTSTRAP_ROLE" ] || stamp+=(--config user.box.role="$T_BOOTSTRAP_ROLE")
    if grep -q '@RIG_REPO@' "$root/templates/$t/user-data.yaml" 2>/dev/null; then
      stamp+=(--config user.box.rig.repo="$(rig_repo)" --config user.box.rig.ref="$(rig_ref)")
    fi
    #
    # The launch is narrated and TIME-BOXED (#93). Twice in the 2026-07-19
    # release drill the child 'incus launch' wedged before the create was
    # even accepted — 'incus operation list' empty, the instance never
    # existed, the daemon journal quiet — once for 56 minutes until killed
    # by hand. Without a line here that wedge reads exactly like a cold mint
    # working; without a budget it lasts forever. Ten minutes is generous —
    # the coldest measured mint (first VM on a fresh pool, see wait_agent)
    # is minutes, never an hour — and BOX_LAUNCH_TIMEOUT (seconds) overrides
    # it, the same scripting knob shape as BOX_CPU / BOX_MEMORY. The drill's
    # lore applies verbatim (RUNS.md trap 13): 'timeout -k' so a launch that
    # shrugs off TERM still dies, and stdin pinned like every other
    # non-interactive incus call — only shell/exec/tmux may own the terminal.
    local budget="${BOX_LAUNCH_TIMEOUT:-600}" rc=0
    echo "box: launching instance $instance (incus launch, $m mode)..."
    timeout -k 5 "$budget" incus launch "$T_IMAGE" "$instance" --profile box-net \
      --config user.box=1 \
      --config user.box.template="$t" \
      --config user.box.user="$T_USER" \
      "${stamp[@]}" \
      --config limits.cpu="$T_CPU" \
      --config limits.memory="$T_MEMORY" \
      --config cloud-init.user-data="$(render_userdata "$root/templates/$t/user-data.yaml")" \
      "${extra[@]}" </dev/null || rc=$?
    # 124 = the budget fired (TERM landed); 137 = the -k KILL was needed —
    # or, on 137, something external (an OOM kill) beat the budget to it.
    if [ "$rc" -eq 124 ] || [ "$rc" -eq 137 ]; then
      echo >&2
      # timeout only proves the CLIENT overran the budget. 'incus launch' is
      # create-then-start, so a slow-but-progressing launch (first mint
      # pulling an uncached image, say) may already have REGISTERED the
      # instance — in which case "never created, retry" would be exactly
      # wrong: the retry collides with 'Instance already exists'. Probe, say
      # which case this is, and best-effort delete either way (a no-op on
      # the true #93 wedge, the cleanup on an overrun; also covers a create
      # that lands in the race between the probe and the delete) so the
      # retry advice below is safe in BOTH worlds. Review consensus on the
      # first round of #94: all three reviewers converged on this hole.
      if timeout -k 5 15 incus info "$instance" </dev/null >/dev/null 2>&1; then
        echo "box: 'incus launch' OVERRAN its ${budget}s budget (killed) — but the instance WAS" >&2
        echo "box: registered: this looks like a slow launch, not the #93 client wedge. Removing" >&2
        echo "box: the partial instance so a retry starts clean..." >&2
      else
        echo "box: 'incus launch' WEDGED — killed after ${budget}s (or killed from outside), and" >&2
        echo "box: the instance was never created. This is the #93 failure: the incus client" >&2
        echo "box: hangs with NO server-side operation ('incus operation list' is empty, the" >&2
        echo "box: daemon journal is quiet). An immediate retry of the exact same 'box new' has" >&2
        echo "box: been observed to succeed, both times it was measured." >&2
      fi
      timeout -k 5 30 incus delete --force "$instance" </dev/null >/dev/null 2>&1 || true
      echo "box: if it persists, diagnose the host:  box doctor" >&2
      echo "box: (a genuinely slower mint can raise the budget: BOX_LAUNCH_TIMEOUT=<seconds>)" >&2
      die "incus launch did not finish inside ${budget}s — retry the same command (#93)"
    elif [ "$rc" -ne 0 ]; then
      # Not a wedge: incus refused and said why on stderr, right above.
      die "incus launch failed (exit $rc)"
    fi
    # The one field the launch line could not know: 'user.box.image' above is
    # the ALIAS, and an alias on a moving remote is not a reproducible fact.
    # Incus resolves it during the launch and records what it landed on in
    # volatile.base_image — read it back and pin it into the stamp, so an
    # incident six months from now can ask "was this box built on the image
    # that broke?" and get an answer instead of a template name.
    #
    # Best-effort BY CONSTRUCTION, and that is the whole design of this line:
    # it runs only after a launch that already succeeded, and a box that exists
    # and boots must never be failed over a provenance field. Every failure
    # here is silent and leaves the alias standing as the honest partial answer
    # — a stamp with no fingerprint, which is exactly how cmd_info renders it.
    local fp; fp="$(incus config get "$instance" volatile.base_image 2>/dev/null || true)"
    [ -z "$fp" ] || incus config set "$instance" user.box.image.fingerprint="$fp" >/dev/null 2>&1 || true
    wait_agent "$instance"
    echo "box: waiting for phase-1 (cloud-init)..."
    echo "box: (its full narration, live:  incus exec $name -- tail -f /var/log/cloud-init-output.log)"
    # A failed cloud-init used to print a screen of dots and the word "error",
    # with nothing to act on — the box's own log holds the reason, and nobody
    # was told it existed. Show it, and leave the box up to inspect.
    # Every non-interactive exec pins stdin. With a TTY on stdin, 'incus exec'
    # goes interactive — and when box's own output is redirected (a script, the
    # drill), the session can wedge open after the remote command has exited,
    # blocking forever on a websocket that will never close. Caught live: a
    # mint stuck at 'status: done'. Only shell/exec/tmux may own the terminal.
    # PYTHONUNBUFFERED: cloud-init's progress dots are block-buffered the
    # moment stdout is not a tty — a redirected mint (a script, the drill)
    # shows NOTHING for the whole install and then one burst at the end,
    # which reads exactly like a hang. Unbuffered, the dots arrive as dots.
    if ! incus exec "$instance" -- env PYTHONUNBUFFERED=1 cloud-init status --wait </dev/null; then
      echo >&2
      echo "box: cloud-init FAILED in $name. What it says:" >&2
      incus exec "$instance" -- cloud-init status --long </dev/null 2>&1 | sed 's/^/  /' >&2
      echo >&2
      echo "box: the errors, from the box's log:" >&2
      incus exec "$instance" -- sh -c \
        "grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
        </dev/null 2>/dev/null | sed 's/^/  /' >&2
      echo >&2
      echo "box: '$name' is still up — inspect it, then delete it:" >&2
      echo "  box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
      echo "  box rm $name" >&2
      echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
      echo "  not serving, a VPN resolver the box inherits). Diagnose it:  box doctor" >&2
      die "cloud-init failed — the box is incomplete, so refusing to hand it over"
    fi
    # The tenant convergence (#81): the seed above is thin — the user, tmux,
    # rig — and what the box BECOMES is rig's job. A template that names a
    # bootstrap role gets it auto-run here, as root inside the guest, because
    # the tenant roles are creds-free and non-interactive BY CONTRACT
    # (rig#31): no prompts, no tailnet, no keys — nothing that joins or
    # admits may ever ride this hook. The creds-holding steps (staging's
    # workload join) stay operator-run through 'box shell', and the role is
    # idempotent, so a failed or interrupted run is re-runnable as-is.
    if [ -n "$T_BOOTSTRAP_ROLE" ]; then
      echo "box: converging the tenant — rig bootstrap $T_BOOTSTRAP_ROLE (rig's own narration follows)..."
      if ! incus exec "$instance" -- rig bootstrap "$T_BOOTSTRAP_ROLE" </dev/null; then
        echo >&2
        echo "box: rig bootstrap $T_BOOTSTRAP_ROLE FAILED in $name." >&2
        echo "box: the box is up and the seed is intact — the role converges, so re-run it:" >&2
        echo "  box shell $name    # then: sudo rig bootstrap $T_BOOTSTRAP_ROLE" >&2
        die "the tenant role did not converge — the box is incomplete, so refusing to call it ready"
      fi
    fi
  fi
  # The login hint belongs to the claude-box template — read the EFFECTIVE
  # template off the instance, so a clone of a claude-box box gets it too and
  # a blank box is not told to run a binary it doesn't have.
  #
  # Both spellings match, and that is not an alias for the ROLE. The role
  # names are a hard cut (rig#76) — 'rig bootstrap claude' is gone, so the
  # seeds ask for 'claude-box' and nothing here softens that. What these arms
  # read is user.box.template, a stamp left on an INSTANCE at its own mint
  # time: every box minted before the rename carries the bare name forever,
  # and a clone carries it forward. Refusing the old spelling here would not
  # cut anything over, it would only drop the hint on boxes that predate the
  # rename — the same reason user.claudebox is honored everywhere else.
  local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
  [ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude-box
  if [ "$eff" = claude-box ] || [ "$eff" = claude ]; then
    echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
  elif [ "$eff" = staging-box ] || [ "$eff" = staging ]; then
    echo "box: ready — 'box shell $name'. The tailnet join stays operator-run (it holds a key box must never see):"
    echo "  box shell $name    # then: sudo rig bootstrap workload-server --hostname $name"
  else
    echo "box: ready — 'box shell $name'."
  fi
}

# Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
# thing that makes them ours, so every read below is filtered by it and we
# never report on (or touch) an instance box didn't mint. Pre-rename boxes
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
# tags via any path we mint, but the dedupe costs nothing.
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
# several addresses and Incus quotes them across lines. It's fetched separately.)
boxes_csv() {
  {
    incus list ${remote:+"$remote"} "user.box=1" --format csv --columns nstS
    incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
  } 2>/dev/null | awk -F, '!seen[$1]++'
}

box_ipv4() {   # first address only; strips Incus's " (iface)" suffix. "-" if none.
  incus list "$1" --format csv --columns 4 2>/dev/null \
    | tr -d '"' | sed 's/ (.*//' | grep -v '^[[:space:]]*$' | head -n1 \
    | 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' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
}

# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
short_type() {
  case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
    virtual-machine|virtualmachine) echo VM ;;
    container) echo CT ;;
    *) echo "$1" ;;
  esac
}

list_all() {
  local rows; rows="$(boxes_csv)"
  if [ -z "$rows" ]; then
    echo "box: no boxes yet — create one with: box new --name work" >&2
    return 0
  fi
  {
    echo "NAME,STATE,TYPE,SNAPSHOTS"
    while IFS=, read -r n s t snaps; do
      [ -n "$n" ] || continue
      echo "$n,${s:--},$(short_type "$t"),${snaps:-0}"
    done <<<"$rows"
  } | awk -F, '
    { for (i = 1; i <= NF; i++) { cell[NR, i] = $i; if (length($i) > w[i]) w[i] = length($i) } n = NR }
    END { for (r = 1; r <= n; r++) { line = ""
            for (i = 1; i <= 4; i++) line = line sprintf("%-*s  ", w[i], cell[r, i])
            sub(/ +$/, "", line); print line } }'
}

# 'list' lists them all; 'info' shows one. A box name handed to 'list' is a wrong
# guess we can answer, not a surprise: point at the command that does want one.
cmd_list() {
  if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
    die "list takes no box — for one box, use: box info ${args[0]}"
  fi
  if [ "$json" -eq 1 ]; then
    incus list ${remote:+"$remote"} "user.box=1" --format json
  else
    list_all
  fi
}

# One instance config key. 'incus config get' on an UNSET key prints empty and
# exits 0 (audit B4) — so the '|| true' here covers the daemon refusing, not the
# key being absent, and every caller below reads absence as an empty string.
box_cfg() { incus config get "$1" "$2" 2>/dev/null || true; }

# The mint stamp (#103), read back for 'box info'. Every key is optional and so
# is the whole block: a box minted before the stamp existed carries none of it
# and must render as a box with blanks — never as an error, and never as a box
# wearing a mint time it does not have. Legacy boxes are not a transitional
# case: a box outlives the release that minted it, which is exactly what the
# legacy 'user.claudebox' tag already says out loud at resolve_box.
box_provenance() {
  local i="$1" schema created ver img fp m asked tpl u role rrepo rref origin from
  schema="$(box_cfg "$i" user.box.schema)"
  created="$(box_cfg "$i" user.box.created)"; ver="$(box_cfg "$i" user.box.version)"
  img="$(box_cfg "$i" user.box.image)";       fp="$(box_cfg "$i" user.box.image.fingerprint)"
  m="$(box_cfg "$i" user.box.mode)";          asked="$(box_cfg "$i" user.box.mode.asked)"
  tpl="$(box_cfg "$i" user.box.template)";    u="$(box_cfg "$i" user.box.user)"
  role="$(box_cfg "$i" user.box.role)"
  rrepo="$(box_cfg "$i" user.box.rig.repo)";  rref="$(box_cfg "$i" user.box.rig.ref)"
  origin="$(box_cfg "$i" user.box.origin)";   from="$(box_cfg "$i" user.box.origin.from)"
  # A pre-rename box has no metadata at all but is always a Claude box — the
  # same mapping box_user() makes, for the same reason.
  [ -n "$tpl" ] || [ "$(box_cfg "$i" user.claudebox)" != 1 ] || { tpl=claude; u="${u:-claude}"; }

  echo
  if [ -n "$created" ] || [ -n "$ver" ]; then
    printf '%-11s%s\n' MINTED "${created:-(time not recorded)} by box ${ver:-unknown}"
  else
    printf '%-11s%s\n' MINTED "(not recorded — this box predates the mint stamp)"
  fi
  if [ -n "$tpl" ]; then
    local paren=""
    [ -z "$u" ] || paren="user $u"
    [ -z "$role" ] || paren="${paren:+$paren, }role $role"
    printf '%-11s%s\n' TEMPLATE "$tpl${paren:+ ($paren)}"
  fi
  [ -z "$img" ] || printf '%-11s%s\n' IMAGE "$img${fp:+ @ ${fp:0:12}…}"
  # The mode is only worth a line alongside what was ASKED: TYPE above already
  # says VM or CT, but only the mint knew whether a container was chosen or
  # fallen back into for want of /dev/kvm.
  [ -z "$asked" ] || printf '%-11s%s\n' MODE "${m:-?} (asked: $asked)"
  [ -z "$rrepo" ] || printf '%-11s%s\n' RIG "$rrepo@${rref:-?}"
  [ -z "$origin" ] || printf '%-11s%s\n' ORIGIN "$origin${from:+ of $from}"
  # A schema box does not recognise is NEWER than box, not broken: show what is
  # understood and say so, rather than refusing to describe a box that a later
  # release minted perfectly well. (A non-integer lands here too, which is the
  # right side to fail on.)
  if [ -n "$schema" ] && { ! [ "$schema" -eq "$schema" ] 2>/dev/null || [ "$schema" -gt "$BOX_STAMP_SCHEMA" ]; }; then
    printf '%-11s%s\n' NOTE "stamp schema '$schema' is newer than this box ($(box_version)) reads ($BOX_STAMP_SCHEMA) — showing what it understands"
  fi
}

cmd_info() {
  local box="${args[0]}" row
  if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi

  row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
  [ -n "$row" ] || die "no such box: $box (see 'box list')"

  local state type snaps
  IFS=, read -r _ state type snaps <<<"$row"
  printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
    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)

  # What built this box, from what, when, with which box (#103). Nothing else
  # on the host records it — the instance config IS the store — so a stamp
  # nothing surfaces is a stamp nobody has. 'box info --json' carries the keys
  # for free: 'incus list --format json' includes config verbatim.
  box_provenance "$inst"

  echo
  case "${snaps:-0}" in
    ''|0)
      echo "SNAPSHOTS  (none)"
      echo
      echo "Take one:   box snapshot $box authed"
      return 0 ;;
  esac
  echo "SNAPSHOTS"
  local first="" sname taken
  while IFS=, read -r sname taken _; do
    [ -n "$sname" ] || continue
    [ -n "$first" ] || first="$sname"
    printf '  %-14s%s\n' "$sname" "$taken"
  done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
  echo
  echo "Clone one:  box new --name <new> --from $box/${first:-<snapshot>}"
}

# Which user does a shell land in? The template stamped it on the instance at
# mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
# knows without consulting the template. Two subtleties, both from the audit:
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
# so the legacy tag maps to 'claude'. The root fallback is effectively
# unreachable (every template sets a user) — anything that truly needs root
# goes through the 'box incus' escape hatch.
box_user() {
  local u
  u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
  if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
    u=claude
  fi
  echo "${u:-root}"
}

cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
cmd_exec()  { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }

# A shell is a child of the exec connection: drop the terminal and everything
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
# attaches when the session exists and creates it when it doesn't, so starting
# work and reattaching after a disconnect are the same command. 'shell' stays
# bare on purpose — two verbs, two contracts.
cmd_tmux() {
  local session="${args[1]:-main}"
  case "$session" in
    *[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
  esac
  incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
}

cmd_snapshot() {
  local label="${args[1]:-manual-$(date +%s)}"
  incus snapshot create "$inst" "$label"
  echo "$label"
}

cmd_status() {
  echo "box: 'status' is deprecated — use 'box list'." >&2
  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
# fault first: each one has killed a cold mint or silently weakened a
# boundary. The install tree ships the whole repo, so delegate — one
# hardened script, two audiences.
cmd_doctor() {
  local script="$root/drill/doctor.sh"
  [ -f "$script" ] || die "doctor script not found at $script — re-run install.sh"
  # The doctor's verdict depends on who is asking: a restricted user cannot
  # see the nft tables or the kernel's bridge state, and telling them the
  # host is broken because THEY cannot read it would be a wrong diagnosis.
  export BOX_TIER; BOX_TIER="$(box_tier)"
  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_grant()         { host_script grant-user.sh; }
cmd_revoke()        { host_script revoke-user.sh; }

# --- the versioned install (#66's stance, made livable in 0.7.0) ------------
# install.sh lands each version at <install-root>/versions/<v>, with a
# 'current' symlink naming the default and $BINDIR/box pointing through it.
# $root (readlink -f, line 8) already resolved the whole chain, so a versioned
# install always runs from .../versions/<v> — and a git checkout does not,
# which is how these verbs know to refuse instead of uninstalling somebody's
# working copy.
install_root() {
  local vdir; vdir="$(dirname "$root")"
  [ "$(basename "$vdir")" = versions ] || return 1
  dirname "$vdir"
}

# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version() {
  case "$1" in
    ''|.*|-*) return 1 ;;
    *[!A-Za-z0-9._+-]*) return 1 ;;
  esac
  return 0
}

# Which boxes exist on this host, at THIS caller's tier? Prints their names
# (both tag generations) and succeeds when at least one exists; fails when
# none are visible — including when incus is absent or not answering, because
# #66's stance protects BOXES from a version change, and a daemon that cannot
# answer has none to protect. install.sh carries a byte-identical copy (it
# runs before any install tree exists); test/cli.sh diffs the two so they
# cannot drift.
existing_boxes() {
  command -v incus >/dev/null 2>&1 || return 1
  { timeout 10 incus list user.box=1 --format csv --columns n </dev/null
    timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
  } 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
}

# #66, kept at flip time: never change (or remove) the version under existing
# boxes. Names every box and the remedy, then dies — a refusal that does not
# say which boxes block it sends the operator off to rediscover 'box list'.
die_under_boxes() {   # $1 = the act being refused, $2 = the retry command
  local names n
  names="$(existing_boxes)" || return 0
  {
    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>', 'box export <box>' (one"
    echo "box: portable file per box, #70) — then 'box rm <box>' each, and re-run: $2"
  } >&2
  exit 1
}

# The PATH symlinks that could ride this install: the one this invocation came
# in on, BOX_BIN's, and the tier default's. Candidates only — every consumer
# checks where a link actually points before touching it, so a symlink that is
# somebody else's (another install root, a hand-rolled wrapper) is never moved.
bin_links() {
  local c=()
  [ -L "${BASH_SOURCE[0]}" ] && c+=("${BASH_SOURCE[0]}")
  [ -n "${BOX_BIN:-}" ] && c+=("$BOX_BIN/box")
  if [ "$(id -u)" -eq 0 ]; then c+=(/usr/local/bin/box); else c+=("$HOME/.local/bin/box"); fi
  printf '%s\n' "${c[@]}" | awk '!seen[$0]++'
}

converge_bin_links() {   # $1 = install root: point our PATH symlinks through current
  local ir="$1" p t
  while IFS= read -r p; do
    [ -L "$p" ] || continue
    t="$(readlink -f "$p" 2>/dev/null || true)"
    [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
    case "$t" in
      "$ir"/*) ln -sfn "$ir/current/bin/box" "$p" ;;
    esac
  done < <(bin_links)
}

cmd_versions() {
  local ir cur d v mark
  ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to list"
  cur="$(readlink -f "$ir/current" 2>/dev/null || true)"
  echo "VERSIONS  ($ir)"
  for d in "$ir/versions"/*/; do
    [ -d "$d" ] || continue
    v="$(basename "$d")"
    mark=""
    [ "$(readlink -f "$d")" = "$cur" ] && mark=" (current)"
    [ "$(readlink -f "$d")" = "$root" ] && mark="$mark (running)"
    printf '  %s%s\n' "$v" "$mark"
  done
  echo
  echo "switch the default:  box use <version>"
  echo "install another:     re-run install.sh (versions land side by side)"
}

cmd_use() {
  local v="${args[0]:-}" ir eff expect out
  [ -n "$v" ] || usage_error "usage: $(synopsis_of use)"
  ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to switch"
  valid_version "$v" || die "not a sane version name: '$v' (a version is a directory name under versions/ — see 'box versions')"
  [ -d "$ir/versions/$v" ] || die "no such version: $v (see 'box versions')"
  die_under_boxes "switch the default box version" "box use $v"
  # An atomic flip, not unlink+create: ln -sfn leaves a window where current
  # is missing; a rename over it does not.
  ln -sfn "versions/$v" "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current"
  converge_bin_links "$ir"
  # Assert the EFFECTIVE result, not the intent: current must resolve to the
  # version asked for, and the chain's own binary must answer that version —
  # a flip that "worked" while the operator's box still runs the old tree is
  # exactly the flakiness this verb exists to end.
  eff="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
  [ "$eff" = "$v" ] || die "the flip did not take — current resolves to '${eff:-nothing}', not $v"
  expect="$(cat "$ir/versions/$v/VERSION" 2>/dev/null || true)"
  if [ -n "$expect" ]; then
    out="$("$ir/current/bin/box" --version 2>&1 || true)"
    case "$out" in
      *"$expect"*) : ;;
      *) die "current/bin/box answers '$out', not version $expect — the symlink chain is broken" ;;
    esac
  fi
  echo "box: switched to $v (current -> versions/$v)"
}

# The uninstall's own confirmation. NOT confirm() above: that one is for box
# lifecycle verbs and must never auto-accept from the environment (the drill
# exports BOX_YES=1 for the installer and still expects 'box rm' to refuse
# without --force). Uninstalling is installer-family, and BOX_YES is the
# installer-family consent contract — same as install.sh and revoke --purge.
uninstall_confirm() {   # $1 = question. --force, or BOX_YES=1, or a TTY.
  [ "$force" -eq 1 ] && return 0
  [ -n "${BOX_YES:-}" ] && return 0
  [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on; BOX_YES=1 also means yes)"
  local reply
  printf 'box: %s? [y/N] ' "$1"
  # Same EOF cure as confirm() above — Ctrl-D must abort out loud.
  read -r reply || die "aborted."
  case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
}

# 'box uninstall' — the real uninstall #66 left as two rm -rf lines of prose.
# The full removal runs in the documented order: boxes first (refuse while
# they exist, or --purge-host tears the stack down with them), then trees and
# symlinks, and it ENDS by PROVING the absence — like revoke --purge, the
# last word is a re-check, not a hope.
cmd_uninstall() {
  local ir a ver="" all=0 purge_host=0 cur p t granted leftover=""
  local targets=()
  for a in ${args[@]+"${args[@]}"}; do
    case "$a" in
      --all) all=1 ;;
      --purge-host) purge_host=1 ;;
      -*) usage_error "unknown option: $a (see 'box help uninstall')" ;;
      *) [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall)"; ver="$a" ;;
    esac
  done
  ir="$(install_root)" || die "this box runs from a working tree ($root), not a versioned install — nothing to uninstall (a checkout is removed with plain rm)"
  [ -w "$ir" ] || die "cannot write $ir — a global install is uninstalled as root: sudo box uninstall"

  # -- one version -----------------------------------------------------------
  if [ -n "$ver" ] && [ "$all" -eq 0 ]; then
    [ "$purge_host" -eq 0 ] || usage_error "--purge-host goes with the full uninstall, not a single version"
    valid_version "$ver" || die "not a sane version name: '$ver' (a version is a directory name under versions/ — see 'box versions')"
    [ -d "$ir/versions/$ver" ] || die "no such version: $ver (see 'box versions')"
    cur="$(basename "$(readlink -f "$ir/current" 2>/dev/null || true)")"
    # A broken current makes the CURRENT guard below unfireable (cur empty
    # when the link is missing; cur naming a non-directory when it dangles —
    # readlink -f resolves a link whose last component does not exist). Heal
    # first, then decide; never delete around a broken default.
    { [ -n "$cur" ] && [ -d "$ir/versions/$cur" ]; } \
      || die "current is dangling — 'box use <version>' to repoint the default first (refusing to remove versions while it is broken)"
    [ "$ver" != "$cur" ] || die "$ver is the CURRENT version — 'box use <other>' first, or 'box uninstall --all' for everything"
    uninstall_confirm "remove box version $ver from $ir"
    # rm's exit code is not the verdict — the absence re-check below is (a
    # half-removed tree must be reported as INCOMPLETE, not as a crash).
    rm -rf "$ir/versions/$ver" || true
    if [ -e "$ir/versions/$ver" ] || [ -L "$ir/versions/$ver" ]; then
      echo "box: uninstall INCOMPLETE — still present: $ir/versions/$ver" >&2
      exit 1
    fi
    echo "box: removed version $ver (the default stays $cur)"
    return 0
  fi
  [ -z "$ver" ] || usage_error "usage: $(synopsis_of uninstall) — a version and --all together is ambiguous"

  # -- everything ------------------------------------------------------------
  if [ "$purge_host" -eq 1 ]; then
    # Granted users' worlds are not ours to erase silently — name them first;
    # 'box revoke <user> --purge' is the clean path (and asserts its absence).
    granted="$(timeout 10 incus project list --format csv 2>/dev/null </dev/null | cut -d, -f1 | grep '^user-' | tr '\n' ' ' || true)"
    [ -n "${granted% }" ] && echo "box: NOTE — granted users still have projects (${granted% }) — 'box revoke <user> --purge' removes each world cleanly first" >&2
    # Consent forwards: --force and BOX_YES are this verb's installer-family
    # yes, and teardown-host must hear it too — otherwise a non-interactive
    # 'uninstall --all --purge-host --force' dies at teardown's own prompt
    # (EOF on read) with the tree untouched but the promise broken.
    if [ "$force" -eq 1 ] || [ -n "${BOX_YES:-}" ]; then
      bash "$root/host/teardown-host.sh" --yes \
        || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
    else
      bash "$root/host/teardown-host.sh" \
        || die "teardown-host did not complete — stopping BEFORE removing the install (the tree is untouched; fix the error and re-run)"
    fi
  else
    die_under_boxes "uninstall box" "box uninstall (or 'box uninstall --purge-host' to tear the host stack down with them)"
  fi
  uninstall_confirm "remove the ENTIRE box install at $ir (every version)"

  # The removal set, gathered BEFORE anything is deleted, so the absence
  # assert below re-checks exactly what was promised gone. PATH symlinks are
  # removed only when they resolve into (or dangle at) THIS install root.
  targets+=("$ir")
  while IFS= read -r p; do
    [ -L "$p" ] || continue
    t="$(readlink -f "$p" 2>/dev/null || true)"
    [ -n "$t" ] || t="$(readlink "$p" 2>/dev/null || true)"
    case "$t" in "$ir"/*) targets+=("$p") ;; esac
  done < <(bin_links)
  # Legacy crumbs: the pre-0.4.0 command name and the pre-0.5.0 tree. A real
  # uninstall leaves neither generation behind.
  while IFS= read -r p; do
    p="$(dirname "$p")/claudebox"
    [ -L "$p" ] && targets+=("$p")
  done < <(bin_links)
  [ -d "$HOME/.local/share/claudebox" ] && targets+=("$HOME/.local/share/claudebox")
  mapfile -t targets < <(printf '%s\n' "${targets[@]}" | awk '!seen[$0]++')

  # rm's exit code is not the verdict — the absence assert below is (a
  # half-removed tree must be reported as INCOMPLETE by name, not as a crash).
  for p in "${targets[@]}"; do rm -rf "$p" || true; done

  # END WITH THE ABSENCE ASSERT: every path re-checked — file, dir OR symlink.
  # A leftover makes this exit 1 by name; "uninstalled" is a claim, and claims
  # get verified (the revoke --purge discipline).
  for p in "${targets[@]}"; do
    if [ -e "$p" ] || [ -L "$p" ]; then leftover="$leftover $p"; fi
  done
  if [ -n "$leftover" ]; then
    echo "box: uninstall INCOMPLETE — still present:$leftover" >&2
    echo "box: remove them by hand, and re-check each path is really gone." >&2
    exit 1
  fi
  echo "box: uninstalled — removed:"
  for p in "${targets[@]}"; do echo "box:   · $p"; done
  if [ "$purge_host" -eq 0 ]; then
    echo "box: note — the host stack (boxnet, firewall), if this host has one, was NOT touched:"
    echo "box: run teardown-host from a checkout (host/teardown-host.sh), or use --purge-host next time."
  fi
}

cmd_help() { show_help "${args[0]:-}"; }

# The escape hatch. The box is resolved and tag-checked; everything else is
# yours. {} is the instance name; without it, the instance goes last.
warn_isolation() {
  case " $* " in
    *" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
      echo "box: warning: this can move the box off the isolation stack" >&2
      echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
  esac
}

cmd_incus() {
  local rest=("${args[@]:1}") out=() a replaced=0
  [ "${#rest[@]}" -gt 0 ] || usage_error "usage: $(synopsis_of incus)"
  for a in "${rest[@]}"; do
    case "$a" in
      *"{}"*) out+=("${a//\{\}/$inst}"); replaced=1 ;;
      *) out+=("$a") ;;
    esac
  done
  [ "$replaced" -eq 1 ] || out+=("$inst")
  warn_isolation "${out[@]}"
  echo "box: incus ${out[*]}" >&2   # no magic: show what runs
  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]:-}"

  # Before ANY incus call: the door's plumbing (the box-isolate ACL, the
  # host firewall's route_localnet + masquerade) is daemon-global state a
  # restricted certificate cannot touch. Without this guard the failure is
  # a lie — box_net_ip cannot read boxnet's (redacted) config, so the
  # restricted user is told their running box "has no boxnet address yet".
  if [ "$(box_tier)" = restricted ]; then
    die "box expose edits the daemon-global ACL and firewall, which the restricted tier cannot modify — ask an incus-admin. (see #74)"
  fi

  # --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 --------------

row="$(cmd_row "$cmd")" || unknown_command "$cmd"
IFS='^' read -r _ _ pre _ action ok cnf <<<"$row"

case ",$pre," in *,box,*)     need_name; inst="$(resolve_box "${args[0]}")" ;; esac
case ",$pre," in *,arg2,*)    need_arg2 ;; esac
case ",$pre," in *,stopped,*) require_stopped "$inst" "${args[0]}" ;; esac
# The prompt comes from the ROW, never from here. A shared string can only be
# right for one verb, and it was rm's — which is why 'restore' could not be
# gated by adding the token alone (#105). A row with 'confirm' and no words is
# a table bug, and it dies as one rather than asking a blank question.
case ",$pre," in *,confirm,*)
  [ -n "$cnf" ] || die "internal: '$cmd' is marked confirm but its row carries no prompt"
  confirm "$(fill "$cnf" "$inst")" ;;
esac

case "$action" in
  fn:*)
    "${action#fn:}"
    ;;
  incus:*)
    sub="${action#incus:}"
    # word-split intentionally: a subcommand may carry a flag ("delete -f")
    # shellcheck disable=SC2086
    incus $sub "$inst" "${args[@]:1}"
    if [ -n "$ok" ]; then
      echo "box: $(fill "$ok" "${args[0]}")"
    fi
    ;;
esac
