feat: versioned installs, and a real uninstall #79

Merged
dan-claude-bot merged 6 commits from feat/versioned-install into main 2026-07-18 18:52:48 +00:00
10 changed files with 1197 additions and 129 deletions

View file

@ -40,20 +40,60 @@ jobs:
run: | run: |
sudo apt-get update sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
- name: global install, from this checkout (the #71 layout) - name: global install, via install.sh itself (the #71 layout, versioned)
# Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove # install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this
# the code under review. Same tree, same layout, same a+rX. # checkout, so CI proves the INSTALLER under review — the versioned
# layout, the current symlink, the PATH chain — not a hand-built
# imitation of it. Setup is run explicitly in the next step, so its
# output is its own CI section.
run: | run: |
sudo cp -r . /opt/box sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
sudo rm -rf /opt/box/.git # assert what landed: the layout, the chain, and that it answers
sudo chmod -R a+rX /opt/box readlink -f /usr/local/bin/box | grep '^/opt/box/versions/'
sudo ln -sf /opt/box/bin/box /usr/local/bin/box /usr/local/bin/box --version
/usr/local/bin/box versions
- name: setup-host - name: setup-host
run: sudo bash /opt/box/host/setup-host.sh run: sudo bash /opt/box/current/host/setup-host.sh
- name: doctor — the baseline is provable before anything is judged - name: doctor — the baseline is provable before anything is judged
run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh run: sudo BOX_TIER=admin bash /opt/box/current/drill/doctor.sh
- name: multi-user rehearsal (criteria a-l, container mode) - name: multi-user rehearsal (criteria a-l, container mode)
run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/current/drill/multiuser.sh --yes --container
- name: uninstall drill — revoke clean, teardown, uninstall, ZERO residue
# The full-removal order, end to end on the real daemon: revoke a
# granted user (--purge asserts its own absence, incl. the incus-user
# state dir), tear the stack down, uninstall the tree — then assert
# NOTHING survived: no networks, profiles, ACLs, nft tables, systemd
# units, files or symlinks. The uninstall was flaky exactly because
# nobody measured this.
run: |
set -x
sudo useradd -m -s /bin/bash uninstdrill
sudo BOX_YES=1 /usr/local/bin/box grant uninstdrill
uid="$(id -u uninstdrill)"
sudo BOX_YES=1 /usr/local/bin/box revoke uninstdrill --purge
sudo test ! -e "/var/lib/incus/users/$uid"
! sudo incus project show "user-$uid"
! sudo incus config trust list --format csv | grep -q "incus-user-$uid"
# The COMBINED verb, --force only, deliberately no BOX_YES and no
# TTY: this is the exact invocation that used to die at teardown's
# own prompt when consent was not forwarded (--purge-host now
# passes --yes through under --force/BOX_YES).
sudo /usr/local/bin/box uninstall --all --purge-host --force
# zero residue: the daemon's state...
! sudo incus network show boxnet
! sudo incus profile show box-net
! sudo incus network acl show box-isolate
# ...the firewall and its boot persistence...
! sudo nft list table inet box
! sudo nft list table bridge box
sudo test ! -e /etc/systemd/system/box-firewall.service
sudo test ! -e /usr/local/sbin/box-firewall
# ...and the install itself: files AND symlinks, both name generations
sudo test ! -e /opt/box
sudo test ! -e /usr/local/bin/box
sudo test ! -L /usr/local/bin/box
sudo test ! -e /usr/local/bin/claudebox
sudo test ! -L /usr/local/bin/claudebox
# NOT run here: the full drill (drill/drill.sh). It rehearses the whole # NOT run here: the full drill (drill/drill.sh). It rehearses the whole
# surface — cold template mints, expose, migration — and wants a real host # surface — cold template mints, expose, migration — and wants a real host

View file

@ -5,6 +5,64 @@ which records not just what changed but what each drill run proved.
## Unreleased ## Unreleased
### Added
- **Versioned installs** (#66's stance, made livable) — install.sh now lands
each version side by side at `<root>/versions/<v>` (its own `VERSION` +
`INSTALLED_FROM`), with a `current` symlink tracking the default and
`$BINDIR/box` riding the chain, the way plenty of CLIs manage theirs. New
verbs: `box versions` (lists installs, marks the current default and the
running tree), `box use <version>` (flips the default, converges the PATH
symlinks, and *asserts the effective result*`current` must resolve to
the asked-for version and the chain's `box --version` must answer it).
Re-running the installer with an installed version is a converging no-op
(`BOX_REINSTALL=1` replaces that version's tree); a **new** version installs
side-by-side and flips `current` only when no boxes exist — under existing
boxes the flip is refused loudly, naming the boxes (#66: never change
versions under a user's boxes; `box use` keeps the same refusal). A
pre-0.7.0 **flat tree is migrated in place** (two renames, the operator's
tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale
or dangling `$BINDIR/box` is healed instead of wedging the install; and the
installer warns when the *other* tier's install (/opt/box vs ~/.local)
coexists, since PATH order decides which wins.
- **A real uninstall**`box uninstall [<version>] [--all] [--purge-host]`
replaces the "rm -rf two paths" prose. One version: refuses the current one
(`box use` off it first). Everything: runs in the safe order — refuses
while boxes exist (naming them) unless `--purge-host` runs teardown-host
first — then removes every version, the `current` and PATH symlinks, and
the legacy claudebox crumbs (both name generations), and **ends with an
absence assert**: every removed path is re-checked, and any survivor makes
it exit 1 as `uninstall INCOMPLETE` naming the leftovers (the
`revoke --purge` discipline). `teardown-host.sh` gains `--yes`/`BOX_YES=1`
for automation and now points at `box uninstall` when done.
- **`BOX_INSTALL_SOURCE=<dir-or-tarball>`** — installs from a local tree,
bypassing the download. CI's rehearsal job now installs via install.sh
itself (proving the installer under review, not a `cp -r` mimic of it), and
ends with an **uninstall drill**: grant + `revoke --purge` a throwaway
user, `teardown-host`, `box uninstall --all`, then assert **zero residue**
— no networks, profiles, ACLs, nft tables, systemd units, files or
symlinks.
- **test/cli.sh drives real installs** — still dependency-free, non-root, no
daemon: `BOX_INSTALL_SOURCE` + throwaway `BOX_HOME`/`BOX_BIN` roots and a
fake `incus` on PATH (`$FAKE_BOXES`) turn layout, chain, no-op/converge,
reinstall, side-by-side upgrade, the three #66 refusals (install flip,
`use`, `uninstall` — boxes named), flat-tree migration, symlink healing,
single-version and zero-residue uninstalls, and the `INCOMPLETE` scream
into *driven* tests instead of greps (154 checks).
### Fixed
- **`revoke --purge` re-checks the incus-user state** — the purge removed
`/var/lib/incus/users/<uid>` without ever asserting its absence, the one
path its own absence block did not cover; and the stat now rides
`$SUDO test -d` (`/var/lib/incus` is not traversable by a non-root admin,
so a bare `[ -d ]` answered "absent" for a directory that was there).
- **A wedged `$BINDIR/box` no longer blocks installing** — the old
no-op-if-installed check keyed off the symlink's existence OR the tree's,
so a stale symlink (or a half-removed tree) could fake "already installed"
forever. Installed-ness is now judged from `versions/<v>` itself; symlinks
are converged with `ln -sfn`, never trusted as the signal.
## 0.6.0 — 2026-07-18 ## 0.6.0 — 2026-07-18
### Added ### Added

View file

@ -42,29 +42,48 @@ design rationale.
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
``` ```
It asks first — **"Install box?"** — then, if box is not already installed, It asks first — **"Install box?"** — then downloads the tree into a
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and **versioned** install (the way plenty of CLIs manage theirs), links `box` onto
asks a second question: **"Set up this machine as a box host now?"** Say yes and your `PATH`, and on a fresh host asks a second question: **"Set up this
it builds the whole isolation stack for you (it may ask for `sudo`); say no and machine as a box host now?"** Say yes and it builds the whole isolation stack
you can run `box setup-host` later. (No `git clone` needed.) for you (it may ask for `sudo`); say no and you can run `box setup-host`
later. (No `git clone` needed.)
**Re-running is a safe no-op.** If box is already installed, the installer tells The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
you so and changes nothing — a stray re-run can never clobber your install or root install):
rebuild the stack under your boxes. Upgrading is therefore explicit: uninstall
what you have and install fresh. Preserve any boxes first — `box down <box>`, ```
copy out anything you need (a portable `box export` is versions/<version>/ one full tree per installed version
current -> versions/<v> the tracked default
$BINDIR/box -> current/bin/box the PATH entry, riding the chain
```
**Re-running is a safe converge.** Installing a version you already have
changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree);
a stray re-run can never clobber your install or rebuild the stack under your
boxes. Installing a **new** version lands it side by side and flips `current`
only when you have **no boxes** — under existing boxes the flip is refused
(never change versions under a user's boxes,
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
deliberate act: preserve what you care about — `box down <box>`, copy out
anything you need (a portable `box export` is
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>` [#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
(which deletes the box _and_ its snapshots) — then: (which deletes the box _and_ its snapshots) — then:
```sh ```sh
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall box versions # what is installed, which is current, which is running
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash box use <version> # flip the default (same refusal while boxes exist)
``` ```
A version-aware upgrade that migrates boxes instead of asking you to is A pre-0.7.0 flat install is migrated into `versions/` automatically on the
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs next installer run — the tree is moved, not re-downloaded, and your boxes are
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1` untouched. A version-aware upgrade that migrates boxes instead of asking you
declines the host-setup step. to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
installs (CI, images), `BOX_YES=1` answers every prompt yes,
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
downloading (how CI proves the installer under review, and how the drill can
install an unpushed branch).
### Global vs per-user install ### Global vs per-user install
@ -83,7 +102,9 @@ box's tree is _executed by other users_ — so it cannot hide in one user's home
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user `BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
install under `/root` would be `0700` and unreadable to everyone else — which install under `/root` would be `0700` and unreadable to everyone else — which
is exactly the bug the root branch fixes. is exactly the bug the root branch fixes. When both tiers are installed, PATH
order decides which `box` wins — the installer warns when it sees the other
tier's tree.
## One-time host setup (Ubuntu 24.04 / Debian 13) ## One-time host setup (Ubuntu 24.04 / Debian 13)
@ -206,8 +227,7 @@ form; flags win). The template's identity (name, user) is stamped onto the insta
so `shell`, `exec` and `tmux` land in the right user — and a clone still so `shell`, `exec` and `tmux` land in the right user — and a clone still
knows, because `incus copy` carries the metadata. knows, because `incus copy` carries the metadata.
## Log in once, reuse via snapshotsrm -rf ~/.local/share/box ~/.local/bin/box # per-user install ## Log in once, reuse via snapshots
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install
Because every fresh box is creds-free, re-authenticating each time would be Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead: toil. Snapshot an authenticated box and clone from it instead:
@ -388,13 +408,29 @@ documentation, not a host-executed script. See
## Uninstall ## Uninstall
`box uninstall` is the real uninstall, and it runs in the safe order — boxes
first, then the stack, then the tree — and **ends with an absence assert**:
every path it removed is re-checked, and any survivor makes it exit 1 naming
the leftovers instead of reporting a clean uninstall that wasn't (the same
discipline as `box revoke --purge`).
```sh ```sh
box teardown-host # boxes, network, ACL, profile, firewall box uninstall <version> # one non-current version (side-by-side cleanup)
box teardown-host --purge-incus # ...and Incus itself box uninstall --all --purge-host # everything: teardown-host (all boxes, the
rm -rf ~/.local/share/box ~/.local/bin/box # per-user install # boxnet stack, the firewall), then every
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install # version, the symlinks, legacy claudebox crumbs
box uninstall # just the install — refuses while boxes exist
# (and names them); run teardown-host first,
# or use --purge-host
``` ```
The full-removal order on a multi-user host: `box revoke <user> --purge` each
granted user (it asserts its own zero-residue, including the incus-user state
under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus`
to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then
`box uninstall`. CI drills exactly this sequence and asserts zero residue —
no networks, profiles, nft tables, systemd units, files or symlinks.
## Non-goals ## Non-goals
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask - **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask

310
bin/box
View file

@ -77,6 +77,9 @@ CMDS=(
"revoke^<user> [--purge]^^Admin: take the restricted tier back (--purge also deletes their boxes)^fn:cmd_revoke^" "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^" "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^" "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^" "status^^^Deprecated alias for 'list'^fn:cmd_status^"
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^" "help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
) )
@ -480,6 +483,56 @@ the profile, then verifies the box works on its new network leg.
box migrate-host --all-boxes # re-home every 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) box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
EOF 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,
copy out what you need via 'box shell'/'box exec' (a portable 'box export'
is #70), 'box rm' each box, then switch. The flip is asserted afterwards:
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' status) cat <<'EOF'
Deprecated alias for 'box list'. It ignored the <box> argument it Deprecated alias for 'box list'. It ignored the <box> argument it
@ -536,8 +589,9 @@ while [ $# -gt 0 ]; do
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
# expose's own flags (--list, --remove) are positional to it, not box's # expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# the host verbs delegate their flags to the scripts they wrap # the host verbs delegate their flags to the scripts they wrap;
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke) args+=("$1"); shift; continue ;; esac # 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 if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi fi
@ -1045,6 +1099,258 @@ cmd_migrate_host() { host_script migrate-host.sh; }
cmd_grant() { host_script grant-user.sh; } cmd_grant() { host_script grant-user.sh; }
cmd_revoke() { host_script revoke-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>', copy out via 'box shell'/'box exec'"
echo "box: (a portable 'box export' is #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"
read -r reply
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]:-}"; } cmd_help() { show_help "${args[0]:-}"; }
# The escape hatch. The box is resolved and tag-checked; everything else is # The escape hatch. The box is resolved and tag-checked; everything else is

View file

@ -0,0 +1,154 @@
# Versioned installs, and a real uninstall (0.7.0 core)
**Status: implemented and tested.** 154/154 in `test/cli.sh` (which now
*drives* real installer runs, not greps of them), shellcheck clean, and CI's
rehearsal job installs via `install.sh` itself and ends with a zero-residue
uninstall drill on a live Incus. This doc records the design and why each
decision fell where it did.
## What was asked
Two maintainer requests, one PR:
1. **Install should be versioned** — each box version goes to its own folder
and a tracked default names the one you run, like plenty of CLIs manage
theirs. Before this, `install.sh` refused to touch an existing install at
all: changing versions meant uninstalling by hand (`rm -rf` two paths from
the README) and re-running the installer.
2. **Uninstall is flaky** — there was no uninstall verb at all, only prose;
`teardown-host.sh` deliberately leaves the install tree; and nothing
encoded the safe full-removal order (revoke users → teardown-host → remove
trees/symlinks). Add thorough tests for uninstall, and for grant/revoke
that they are clean.
## The layout
```
<root>/ /opt/box (root) or ~/.local/share/box (user);
BOX_HOME overrides — both unchanged from #71
versions/<version>/ one full tree per version, each with its own
VERSION + INSTALLED_FROM
current -> versions/<v> the tracked default (a relative symlink, so the
root can move as a unit)
$BINDIR/box -> <root>/current/bin/box
```
The version key is the tree's own `VERSION` file — the identity of what was
installed, and the name `box versions` lists. `bin/box` needed **no change**
to run from here: line 8 already derives `$root` via `readlink -f`, which
resolves the whole `$BINDIR/box → current → versions/<v>` chain, so
`VERSION`, `templates/`, `host/` and `drill/` all resolve inside the version
tree that is actually running. That same fact is how the new verbs detect
their world: a versioned install always runs from `.../versions/<v>`; a git
checkout does not, and the verbs refuse instead of uninstalling somebody's
working copy.
## Install semantics (#66's stance, kept — at the flip)
#66 established: a stray installer re-run must never clobber a working
install or rebuild the stack under existing boxes. The old enforcement was a
blanket "refuse if anything is installed", which also blocked upgrades. The
versioned layout splits the two concerns:
- **Same version present** → converging no-op ("already installed", exit 0);
`BOX_REINSTALL=1` replaces that version's tree via two renames (never a
partial overlay). A converge/reinstall of a non-current version never
moves the default — switching is `box use`, a deliberate act.
- **Different version** → installs side-by-side, then flips `current` **only
when no boxes exist**. With boxes present (both tag generations, checked at
the caller's tier via a shared `existing_boxes()` — byte-identical in
`install.sh` and `bin/box`, diffed by the tests so the two #66 stances
cannot drift), the flip is refused loudly, the boxes are *named*, and the
operator is pointed at the remedy: down/copy-out/rm, then `box use <v>`.
A daemon that is absent or not answering has no boxes to protect — the
stance guards boxes, not daemons.
- **Pre-0.7.0 flat tree** → migrated before anything else: `mv` the root
aside, `mkdir versions/`, `mv` it to `versions/<its-VERSION>`, link
`current` and `$BINDIR/box`. Two renames inside one parent directory — no
copy, no window with no install, the operator's tree preserved bit for bit
(the tests assert the migrated tree's own `INSTALLED_FROM` survives).
- **Wedged symlinks** → healed, never trusted. The old no-op check keyed off
`$BINDIR/box` *or* `$DEST/bin/box` existing, so a stale symlink (or a
half-removed tree) faked "already installed" forever. Installed-ness is now
judged from `versions/<v>` itself; `ln -sfn` converges the links.
- **Tier coexistence** → a root and a per-user install shadow each other by
PATH order alone; the installer warns when it sees the other tier's tree.
- Host setup is offered on **fresh** hosts only — an upgraded host has made
that decision (and may have live boxes the stack must not be rebuilt
under); `box setup-host` re-applies stack changes deliberately.
`BOX_INSTALL_SOURCE=<dir-or-tarball>` bypasses the download (a directory is
tar-copied with `--exclude=.git`). This exists for CI and the drill — the
code under review is what lands — and it is what turned the test suite's
install coverage from greps into real runs.
## The new verbs
Table rows like every other verb (the CMDS table stays the single source of
truth); `uninstall` joins the host-verb flag passthrough so `--all` /
`--purge-host` reach it.
- `box versions` — lists `versions/*`, marking the current default and the
tree answering the command (they differ when another install shadows yours
on PATH).
- `box use <version>` — same existing-boxes refusal as the installer's flip
(shared helper, boxes named), then repoints `current`, converges every PATH
symlink that resolves into this install root (never one that is somebody
else's), and **asserts the effective result**: `current` must resolve to
the asked-for version and `current/bin/box --version` must answer it. A
flip that "worked" while the operator still runs the old tree is exactly
the flakiness this verb exists to end.
- `box uninstall [<version>] [--all] [--purge-host]`
- one version: refuses the current one; removes the dir; re-checks it.
- full: the safe order — refuse while boxes exist (naming them) unless
`--purge-host` runs `teardown-host.sh` first (its own confirmation; a
note names granted users' surviving projects and `revoke --purge` as the
clean path); confirm (`--force` / `BOX_YES=1` — installer-family consent,
deliberately *not* the lifecycle `confirm()`, which must never
auto-accept from the environment); gather the removal set (root, every
PATH symlink pointing into it, claudebox crumbs of both name
generations); remove; then **the absence assert**: every path re-checked
for file/dir/symlink existence, any survivor → exit 1
`uninstall INCOMPLETE` naming the leftovers. `rm`'s exit code is not the
verdict — the re-check is (a half-removed tree is INCOMPLETE, not a
crash).
## Grant/revoke cleanliness
Reading `revoke-user.sh` against its own absence assert found the gap: the
purge removes `/var/lib/incus/users/<uid>` but never re-checks it — and the
stat was a bare `[ -d ]`, which lies for a non-root admin (`/var/lib/incus`
is not traversable, so the directory reads as absent while it is there).
Both fixed: the check rides `$SUDO test -d`, and the absence block now covers
the state dir. Grepped-and-guarded in `test/cli.sh`; drilled live in CI.
## Tests (the heart of this PR)
`test/cli.sh` stays dependency-free, non-root, daemon-free. New machinery: a
fake `incus` on PATH whose `list` prints `$FAKE_BOXES`, throwaway
`BOX_HOME`/`BOX_BIN` roots, and fabricated second/third sources with
different `VERSION`s. Driven end to end: fresh layout + chain
(`box --version` through both symlinks), no-op/canary, `BOX_REINSTALL`,
side-by-side + no-boxes flip, all three #66 refusals (install flip, `use`,
`uninstall` — boxes named, remedies named), `versions` markers, `use`
flip-and-assert, flat-tree migration (alone, and combined with an upgrade),
dangling- and stale-symlink healing, single-version uninstall (current
refused), full uninstall with planted legacy crumbs and a zero-residue
assert (files *and* symlinks *and* legacy names), the INCOMPLETE scream
(a chmod-pinned survivor), and refusals from a working tree. The existing
DEST/BINDIR-branch tests are kept unchanged (the branch itself is unchanged).
CI's rehearsal job now installs via `install.sh`
(`BOX_INSTALL_SOURCE=$GITHUB_WORKSPACE`), asserts the layout it left, runs
the stack from `/opt/box/current/...`, and appends the uninstall drill:
grant + `revoke --purge` a throwaway user (asserting the incus-user state
dir is gone), `teardown-host` (new `--yes`/`BOX_YES` support), `box
uninstall --all`, then zero residue — networks, profiles, ACLs, nft tables,
systemd units, files, symlinks, both name generations.
## What this is not
- Not #67: boxes still do not migrate across versions — this PR delivers the
version-agnostic upgrade *path* (side-by-side installs, an explicit flip);
data migration remains #67.
- Not a release: `VERSION` is untouched (a release PR bumps it).

View file

@ -202,10 +202,11 @@ EOF
export BOX_SKIP_SETUP_HOST=1 export BOX_SKIP_SETUP_HOST=1
fi fi
# The installer is a no-op when box is already installed — upgrading is # The installer converges when a version is already installed (0.7.0's
# uninstall-then-install, by design. The drill re-proves a tree from scratch # versioned layout: re-running the same version is a no-op, and an upgrade
# every run, so it does the uninstall itself: clear any prior tree and symlink # lands side-by-side without flipping under boxes). The drill re-proves a
# before installing, or install.sh would correctly refuse to touch them. # tree from SCRATCH every run — a fresh host, not a converged one — so it
# removes the whole install root and symlink first.
rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box" rm -rf "$HOME/.local/share/box" "$HOME/.local/bin/box"
# The installer prompts (install? set up host?) and reads /dev/tty. The drill # The installer prompts (install? set up host?) and reads /dev/tty. The drill
@ -224,7 +225,7 @@ EOF
# reads BOX_* — the vars were ignored, main was installed, and the run drilled # reads BOX_* — the vars were ignored, main was installed, and the run drilled
# the wrong tree while reporting success. A drill that silently drills the # the wrong tree while reporting success. A drill that silently drills the
# wrong code is worse than one that fails. # wrong code is worse than one that fails.
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')" got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
if [ "$got" != "$REPO@$REF" ]; then if [ "$got" != "$REPO@$REF" ]; then
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2 echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2 echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
@ -302,7 +303,7 @@ if [ "${DRILL_OWNS_SETUP:-0}" != 1 ]; then
echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2 echo " install.sh is supposed to run the host setup itself (#64), and setup-host" >&2
echo " is supposed to converge in one run (#63). One of those did not happen." >&2 echo " is supposed to converge in one run (#63). One of those did not happen." >&2
echo " reproduce with the output visible:" >&2 echo " reproduce with the output visible:" >&2
echo " ~/.local/share/box/host/setup-host.sh" >&2 echo " ~/.local/share/box/current/host/setup-host.sh" >&2
echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2 echo " or hand setup back to the drill: DRILL_OWNS_SETUP=1 $SELF" >&2
exit 1 exit 1
fi fi
@ -361,7 +362,7 @@ left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')"
# proves is idempotency: a second run over a cleaned host is a no-op that # proves is idempotency: a second run over a cleaned host is a no-op that
# restores the stack rather than a fresh build. # restores the stack rather than a fresh build.
inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…" inf "running setup-host.sh (post-clean convergence: restores dns.mode and any reverted mutations)…"
if ! timeout -k 10 300 ~/.local/share/box/host/setup-host.sh; then if ! timeout -k 10 300 ~/.local/share/box/current/host/setup-host.sh; then
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2 echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
echo " it should take seconds on a host that already has incus. usual causes:" >&2 echo " it should take seconds on a host that already has incus. usual causes:" >&2
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2 echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
@ -469,7 +470,7 @@ phase "B. The box surface"
# =========================================================================== # ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number — # Compare against the installed tree's VERSION file, not a hardcoded number —
# a pinned literal here would fail the drill on every release. # a pinned literal here would fail the drill on every release.
expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')" expected="$(cat "$HOME/.local/share/box/current/VERSION" 2>/dev/null || echo '?')"
v="$(box --version 2>&1)" v="$(box --version 2>&1)"
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
@ -498,7 +499,7 @@ box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
# The one rule that keeps templates honest: no key can name a network. Plant a # The one rule that keeps templates honest: no key can name a network. Plant a
# bad template in the installed tree (the drill owns this host), expect the # bad template in the installed tree (the drill owns this host), expect the
# parser to reject it BY NAME, remove it. # parser to reject it BY NAME, remove it.
badt="$HOME/.local/share/box/templates/cbdrill-bad" badt="$HOME/.local/share/box/current/templates/cbdrill-bad"
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml" mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \ box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \ && ok "a template cannot name a network — BOX_NETWORK rejected by name" \
@ -889,7 +890,7 @@ phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)"
# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove # tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove
# migrate-host.sh moves it onto the new stack with its identity intact, and # migrate-host.sh moves it onto the new stack with its identity intact, and
# retires the legacy stack only once it is empty. # retires the legacy stack only once it is empty.
MIG="$HOME/.local/share/box/host/migrate-host.sh" MIG="$HOME/.local/share/box/current/host/migrate-host.sh"
if [ ! -f "$MIG" ]; then if [ ! -f "$MIG" ]; then
no "migrate-host.sh not installed — cannot drill the transition" no "migrate-host.sh not installed — cannot drill the transition"
else else
@ -985,5 +986,5 @@ fi
echo echo
inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules" inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)." inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]" inf "to undo: box uninstall --purge-host (or ~/.local/share/box/current/host/teardown-host.sh)"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

View file

@ -135,19 +135,26 @@ done < <(incus config trust list --format csv --columns nf 2>/dev/null)
# incus-user's per-user client state (their key pair). Removed so a future # incus-user's per-user client state (their key pair). Removed so a future
# re-grant starts clean instead of trusting a key the purge revoked. # re-grant starts clean instead of trusting a key the purge revoked.
if [ -d "/var/lib/incus/users/$uid" ]; then # $SUDO test, not a bare [ -d ]: /var/lib/incus is not traversable by a
# non-root admin, so an unprivileged stat answers "absent" for a directory
# that is very much there — the same lie the absence assert below must dodge.
if $SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null; then
$SUDO rm -rf "/var/lib/incus/users/$uid" $SUDO rm -rf "/var/lib/incus/users/$uid"
echo "purge: incus-user state for uid $uid removed" echo "purge: incus-user state for uid $uid removed"
fi fi
# Assert absence rather than trusting exit codes — the wipe.sh discipline. # Assert absence rather than trusting exit codes — the wipe.sh discipline.
# The certificate included: its removal above is set -e-exempt (left of &&), # The certificate included: its removal above is set -e-exempt (left of &&),
# and a promise the header makes is a promise this block checks. # and a promise the header makes is a promise this block checks. The
# incus-user state directory too — it was purged for releases without being
# re-checked, which is exactly the gap this block exists to close.
leftover="" leftover=""
incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project" incus project show "$project" >/dev/null 2>&1 </dev/null && leftover="$leftover $project"
incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge" incus network show "$bridge" >/dev/null 2>&1 </dev/null && leftover="$leftover $bridge"
incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \ incus config trust list --format csv --columns nf 2>/dev/null | grep -q "^incus-user-$uid," \
&& leftover="$leftover cert:incus-user-$uid" && leftover="$leftover cert:incus-user-$uid"
$SUDO test -d "/var/lib/incus/users/$uid" 2>/dev/null \
&& leftover="$leftover /var/lib/incus/users/$uid"
if [ -n "$leftover" ]; then if [ -n "$leftover" ]; then
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2 echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
exit 1 exit 1

View file

@ -3,20 +3,34 @@
# ancestor created, so one teardown cleans a host of any generation: all boxes # ancestor created, so one teardown cleans a host of any generation: all boxes
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev # (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
# profiles, and both generations of firewall units and nft tables. # profiles, and both generations of firewall units and nft tables.
# Usage: ./host/teardown-host.sh [--purge-incus] # Usage: ./host/teardown-host.sh [--purge-incus] [--yes]
# --purge-incus also apt-purge Incus itself (skipped if non-box # --purge-incus also apt-purge Incus itself (skipped if non-box
# instances still exist on this host) # instances still exist on this host)
# --yes skip the confirmation (BOX_YES=1 does the same) — for
# automation: CI's uninstall drill and 'box uninstall
# --purge-host' run this unattended
set -euo pipefail set -euo pipefail
purge=false purge=false; yes=0
[ "${1:-}" = "--purge-incus" ] && purge=true for arg in "$@"; do
case "$arg" in
--purge-incus) purge=true ;;
--yes|-y) yes=1 ;;
*) echo "teardown-host: unknown option: $arg" >&2; exit 2 ;;
esac
done
[ -n "${BOX_YES:-}" ] && yes=1
echo "This removes ALL boxes (uncommitted work in them is lost), the" echo "This removes ALL boxes (uncommitted work in them is lost), the"
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules" echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
echo "(both current and pre-0.4.0 names)." echo "(both current and pre-0.4.0 names)."
$purge && echo "Incus itself will also be uninstalled (--purge-incus)." $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
read -rp "Continue? [y/N] " a if [ "$yes" -eq 1 ]; then
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac echo "(confirmed non-interactively: --yes/BOX_YES)"
else
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
fi
# Instances — both tag generations, one delete at a time (a multi-name # Instances — both tag generations, one delete at a time (a multi-name
# 'incus delete' aborts at the first missing name). # 'incus delete' aborts at the first missing name).
@ -72,4 +86,4 @@ if $purge; then
fi fi
fi fi
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)" echo "Teardown complete. (The box install tree itself remains — 'box uninstall' removes it, with a zero-residue check.)"

View file

@ -3,10 +3,25 @@ set -euo pipefail
# box installer — intended for: curl -fsSL .../install.sh | bash # box installer — intended for: curl -fsSL .../install.sh | bash
# #
# Downloads the box source tarball from its GitHub repo (heavy-duty/box), # Downloads the box source tarball from its GitHub repo (heavy-duty/box) and
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via # installs it into the VERSIONED layout under $DEST:
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install #
# script keeps working; BOX_REPO overrides.) # $DEST/versions/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
# $BINDIR/box -> $DEST/current/bin/box the PATH entry
#
# Versions install side by side, the way plenty of CLIs manage theirs: `box
# versions` lists them, `box use <v>` switches the default, `box uninstall`
# removes them. Re-running with an already-installed version is a converging
# no-op (BOX_REINSTALL=1 replaces that version's tree); a NEW version installs
# beside the old one and becomes the default only when NO boxes exist — #66's
# stance (never change versions under a user's boxes) now guards the FLIP, not
# the whole install. A pre-0.7.0 flat tree is migrated in place, so upgrading
# from 0.6.0 is seamless. (GitHub redirects the repo's pre-rename URLs, so an
# old install script keeps working; BOX_REPO overrides.)
#
# BOX_INSTALL_SOURCE=<dir-or-tarball> installs from a local tree instead of
# downloading — for CI and the drill, so what lands is the code under review.
REPO="${BOX_REPO:-heavy-duty/box}" REPO="${BOX_REPO:-heavy-duty/box}"
REF="${BOX_REF:-main}" REF="${BOX_REF:-main}"
@ -47,30 +62,96 @@ confirm() { # $1 = question
case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac case "$reply" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac
} }
# 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. bin/box 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. bin/box carries a byte-identical copy (the CLI
# needs the same gate for 'box use' / 'box uninstall'); 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 .
}
# --- prerequisites --------------------------------------------------------- # --- prerequisites ---------------------------------------------------------
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run." # curl only when something must be downloaded — a local BOX_INSTALL_SOURCE
# needs none, which is what lets test/cli.sh drive REAL installs offline.
if [ -z "${BOX_INSTALL_SOURCE:-}" ]; then
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
fi
command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run." command -v tar >/dev/null 2>&1 || die "tar is required but was not found. Please install tar and re-run."
# --- confirm, then no-op if already installed ------------------------------ if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is SRCDESC="local source $BOX_INSTALL_SOURCE"
# ask whether you meant to. Then, if box is already installed, this run changes else
# nothing and says so — a re-run is a safe no-op, which dissolves the whole SRCDESC="$REPO@$REF"
# "curl clobbered my working install / rebuilt the stack under my boxes" class fi
# of failures. Upgrading is deliberately NOT an in-place overwrite: you uninstall
# what you have (dealing with your boxes as you do) and install fresh.
confirm "Install box from $REPO@$REF?" || die "cancelled — nothing was changed."
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then # --- confirm first ---------------------------------------------------------
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')" # Prompt BEFORE downloading anything: the first thing a curl|bash should do is
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')" # ask whether you meant to. Everything after this converges: re-running with a
log "box is already installed ($cur, version $cur_ver) — nothing to do." # version that is already installed changes nothing and says so, which
log "To install a different version, remove the current one first:" # dissolves the whole "curl clobbered my working install / rebuilt the stack
log " · preserve any boxes you care about — 'box down <box>', then keep them" # under my boxes" class of failures (#66).
log " (a portable 'box export' is #70; for now copy what you need OUT via" confirm "Install box from $SRCDESC?" || die "cancelled — nothing was changed."
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\"" # Flip $DEST/current to versions/<v> atomically: build the new link beside it,
log " · then re-run this installer" # rename over. Plain ln -sfn is unlink+create — a window where current names
exit 0 # nothing and a concurrent 'box' invocation dies mid-chain. bin/box's cmd_use
# flips with the same pattern.
flip_current() {
ln -sfn "versions/$1" "$DEST/current.new.$$"
mv -Tf "$DEST/current.new.$$" "$DEST/current"
}
# --- migrate a pre-0.7.0 flat install --------------------------------------
# 0.6.0 and earlier installed the tree FLAT at $DEST (bin/box directly under
# it). Move such a tree to versions/<its-VERSION> BEFORE anything else, so an
# upgrade from 0.6.0 is seamless and the version comparison below sees the
# truth. The move is two renames inside one parent directory — no copying, no
# window with no install — and the operator's tree is preserved bit for bit.
if [ -e "$DEST/bin/box" ] && [ ! -d "$DEST/versions" ]; then
flat_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo 0.0.0-unknown)"
# The flat tree's VERSION is data from disk, not from this installer — the
# same trust boundary as the new_ver check, so the same gate: a corrupted
# (or hostile) VERSION must not steer the mv/ln below out of versions/.
valid_version "$flat_ver" || die "the flat install's VERSION is not a sane directory name: '$flat_ver' — fix $DEST/VERSION (one line, e.g. 0.6.0), then re-run"
log "found a pre-0.7.0 flat install at $DEST (version $flat_ver) — migrating it into the versioned layout"
staging="$DEST.migrating.$$"
mv "$DEST" "$staging"
mkdir -p "$DEST/versions"
mv "$staging" "$DEST/versions/$flat_ver"
flip_current "$flat_ver"
mkdir -p "$BINDIR"
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
log "migrated: it now lives at $DEST/versions/$flat_ver (still current; your boxes are untouched)"
fi
# Whether ANY version was installed before this run — read before we add one.
# It gates the host-setup offer below: a host that already ran box has made
# that decision (and may have live boxes the stack must not be rebuilt under,
# #66); after an upgrade, 'box setup-host' re-applies stack changes on purpose.
had_install=0
if [ -d "$DEST/versions" ] && [ -n "$(ls -A "$DEST/versions" 2>/dev/null)" ]; then
had_install=1
fi fi
# --- temp workspace -------------------------------------------------------- # --- temp workspace --------------------------------------------------------
@ -78,50 +159,131 @@ TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; } cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXIT trap cleanup EXIT
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" # --- acquire the tree ------------------------------------------------------
if [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
SRC="$BOX_INSTALL_SOURCE"
INSTALLED_FROM="local:$SRC"
if [ -d "$SRC" ]; then
log "copying local tree $SRC"
mkdir -p "$TMPDIR/tree"
# tar, not cp -a: --exclude=.git, so a working checkout never carries its
# VCS state (or its size) into the install tree.
tar -C "$SRC" --exclude=.git -cf - . | tar -xf - -C "$TMPDIR/tree"
EXTRACTED="$TMPDIR/tree"
elif [ -f "$SRC" ]; then
log "extracting local tarball $SRC"
tar -xzf "$SRC" -C "$TMPDIR" || die "failed to extract $SRC"
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
else
die "BOX_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
fi
else
INSTALLED_FROM="$REPO@$REF"
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing box from $REPO@$REF"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download $URL"
log "installing box from $REPO@$REF" log "extracting archive"
log "downloading $URL" tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \ || die "failed to extract archive"
|| die "failed to download $URL"
log "extracting archive" # GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \ # dashes) — deriving that name is guesswork, and it broke for real at the
|| die "failed to extract archive" # claudebox → box rename, when this glob kept looking for claudebox-* and the
# installer died on every host. The tarball has exactly ONE top-level
# directory: take the directory, whatever it is called, and let the bin/box
# check below judge whether it is the right tree.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
fi
[ -n "${EXTRACTED:-}" ] || die "could not find the source tree in $SRCDESC"
[ -f "$EXTRACTED/bin/box" ] || die "source does not contain bin/box — is $SRCDESC correct?"
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become # The tree's own VERSION file names the directory it lands in — the version IS
# dashes) — deriving that name is guesswork, and it broke for real at the # the identity of what is being installed, and 'box versions' lists these names.
# claudebox → box rename, when this glob kept looking for claudebox-* and the new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)"
# installer died on every host. The tarball has exactly ONE top-level [ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version"
# directory: take the directory, whatever it is called, and let the bin/box valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'"
# check below judge whether it is the right tree.
EXTRACTED="$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -type d | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- install into $DEST ---------------------------------------------------- # --- install into $DEST/versions/<version> ---------------------------------
# Reached only on a host with no existing install (the no-op check above VDIR="$DEST/versions/$new_ver"
# exits otherwise), so this is always a fresh tree, never an overwrite. newly_installed=0
log "installing into $DEST" if [ -d "$VDIR" ]; then
mkdir -p "$(dirname "$DEST")" if [ -n "${BOX_REINSTALL:-}" ]; then
mv "$EXTRACTED" "$DEST" # Replace THIS version's tree, as atomically as two renames allow — never
# a partial overlay of new files onto an old tree.
log "BOX_REINSTALL=1 — replacing the installed $new_ver tree"
stage="$VDIR.new.$$"; old="$VDIR.old.$$"
rm -rf "$stage" "$old"
chmod +x "$EXTRACTED/bin/box"
mv "$EXTRACTED" "$stage"
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
# length of the delete where current -> this version resolves to nothing.
mv "$VDIR" "$old"
mv "$stage" "$VDIR"
rm -rf "$old"
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
log "reinstalled $new_ver"
else
cur_from="$(cat "$VDIR/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
log "box $new_ver is already installed ($cur_from) — nothing to do."
log "(BOX_REINSTALL=1 replaces this version's tree; 'box versions' lists what is installed.)"
fi
else
log "installing $new_ver into $VDIR"
mkdir -p "$DEST/versions"
chmod +x "$EXTRACTED/bin/box"
mv "$EXTRACTED" "$VDIR"
newly_installed=1
# Record WHAT was installed, so a caller can assert it got what it asked for.
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
# retired in 0.5.0) silently falls back to the defaults and installs main —
# and the caller drills the wrong tree, believing it drilled its branch.
printf '%s\n' "$INSTALLED_FROM" > "$VDIR/INSTALLED_FROM"
fi
chmod +x "$DEST/bin/box" # --- which version is the default? -----------------------------------------
# 'current' is the tracked default; flipping it is the ONLY step that changes
# A global (root) install is run by OTHER users, but mv preserves the tarball's # what an operator's `box` runs. #66's stance, kept exactly here: never change
# root:root ownership and GitHub's archives carry no world bits on some paths — so # versions under existing boxes. A fresh host (or a dangling current) is
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box. # claimed outright; an upgrade flips only when no box exists — otherwise the
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on # new version sits installed side-by-side and 'box use' is the deliberate act.
# files, +search (x) on directories only. Guarded on root so the per-user install cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
# stays byte-identical to before. want="$(readlink -f "$VDIR")"
if [ "$(id -u)" -eq 0 ]; then if [ -z "$cur" ] || [ ! -d "$cur" ]; then
chmod -R a+rX "$DEST" flip_current "$new_ver"
log "default version: $new_ver"
elif [ "$cur" = "$want" ]; then
: # already the default — nothing to flip
elif [ "$newly_installed" -eq 0 ]; then
# A converge/no-op (or BOX_REINSTALL) of a version that is NOT the default
# never moves the default — a re-run must change nothing (#66); switching is
# 'box use', a deliberate act.
log "the default stays $(basename "$cur") — 'box use $new_ver' switches."
else
old_ver="$(basename "$cur")"
if names="$(existing_boxes)"; then
warn "this host has existing boxes:"
while IFS= read -r n; do warn " · $n"; done <<<"$names"
warn "refusing to change the default box version under them (#66) — the default stays at $old_ver."
log "box $new_ver is installed side-by-side. To switch:"
log " · preserve what you care about — 'box down <box>', copy out via 'box shell'/'box exec'"
log " (a portable 'box export' is #70), then 'box rm <box>' when you are done"
log " · then flip the default: box use $new_ver"
else
flip_current "$new_ver"
log "default version switched: $old_ver -> $new_ver ('box use $old_ver' switches back)"
fi
fi fi
# --- put box on PATH ------------------------------------------------------- # --- put box on PATH -------------------------------------------------------
# ln -sfn converges, and that includes HEALING: a stale or dangling
# $BINDIR/box (say, its tree half-removed by hand) must never block or wedge
# an install — it gets repointed at the current chain, whatever it said before.
mkdir -p "$BINDIR" mkdir -p "$BINDIR"
ln -sf "$DEST/bin/box" "$BINDIR/box" ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
log "linked $BINDIR/box -> $DEST/bin/box" log "linked $BINDIR/box -> $DEST/current/bin/box"
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it # 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI # cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
# honors their legacy tag — it is only the old command name that retires. # honors their legacy tag — it is only the old command name that retires.
@ -137,6 +299,34 @@ if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)" log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
fi fi
# A global (root) install is run by OTHER users, but mv preserves the tarball's
# root:root ownership and GitHub's archives carry no world bits on some paths — so
# without this, a non-root caller cannot even traverse into $DEST to reach bin/box.
# Root owns the tree, nobody else writes it, everybody reads it. a+rX: read on
# files, +search (x) on directories only. Guarded on root so the per-user install
# stays byte-identical to before.
if [ "$(id -u)" -eq 0 ]; then
chmod -R a+rX "$DEST"
fi
# --- the OTHER tier's install, if any --------------------------------------
# A root (/opt/box) and a per-user (~/.local/share/box) install coexist by
# PATH order alone, which is easy to be surprised by — say so out loud rather
# than let two versions silently shadow each other (#71's layout, both sides).
if [ "$(id -u)" -ne 0 ]; then
if [ -e /opt/box/current/bin/box ] || [ -e /opt/box/bin/box ]; then
warn "a GLOBAL install also exists at /opt/box — PATH order decides which 'box' you run (check: command -v box)"
fi
else
sudo_home=""
if [ -n "${SUDO_USER:-}" ]; then
sudo_home="$(getent passwd "$SUDO_USER" | cut -d: -f6)" || sudo_home=""
fi
if [ -n "$sudo_home" ] && { [ -e "$sudo_home/.local/share/box/current/bin/box" ] || [ -e "$sudo_home/.local/share/box/bin/box" ]; }; then
warn "a PER-USER install also exists at $sudo_home/.local/share/box — PATH order decides which 'box' $SUDO_USER runs"
fi
fi
# --- PATH check ------------------------------------------------------------ # --- PATH check ------------------------------------------------------------
case ":$PATH:" in case ":$PATH:" in
*":$BINDIR:"*) : ;; *":$BINDIR:"*) : ;;
@ -147,29 +337,27 @@ case ":$PATH:" in
;; ;;
esac esac
# Record WHAT was installed, so a caller can assert it got what it asked for.
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
# retired in 0.5.0) silently falls back to the defaults and installs main —
# and the caller drills the wrong tree, believing it drilled its branch.
# Written BEFORE host setup: this records the install, which has now happened,
# and it must not hinge on whether the host stack came up.
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
# --- host setup (second prompt) -------------------------------------------- # --- host setup (second prompt) --------------------------------------------
# The tool is installed; the machine is not yet a box host. Offer to finish the # The tool is installed; the machine is not yet a box host. Offer to finish the
# job — build Incus and the isolation stack — rather than leave 'box new' to die # job — build Incus and the isolation stack — rather than leave 'box new' to die
# later on a host with no boxnet and no profile (#64). This is its own decision: # later on a host with no boxnet and no profile (#64). This is its own decision:
# you might be installing the CLI on a workstation and hosting boxes elsewhere. # you might be installing the CLI on a workstation and hosting boxes elsewhere.
# Offered on a FRESH host only: a host that already had a box install has made
# this decision (and may have live boxes the stack must not be rebuilt under);
# 'box setup-host' re-applies stack changes deliberately, after an upgrade.
# BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set # BOX_SKIP_SETUP_HOST=1 answers "no" without prompting (image builds, a host set
# up by hand); BOX_YES answers "yes". # up by hand); BOX_YES answers "yes".
setup_ok="" setup_ok=""
setup_declined="" setup_declined=""
if [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then if [ "$had_install" -eq 1 ]; then
log "this host already had a box install — skipping host setup (re-apply stack changes any time: box setup-host)"
setup_declined=1
elif [ -n "${BOX_SKIP_SETUP_HOST:-}" ]; then
log "skipping host setup (BOX_SKIP_SETUP_HOST is set)." log "skipping host setup (BOX_SKIP_SETUP_HOST is set)."
setup_declined=1 setup_declined=1
elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then elif [ "$(id -u)" -ne 0 ] && ! command -v sudo >/dev/null 2>&1; then
warn "cannot set up the host: it needs root and sudo was not found." warn "cannot set up the host: it needs root and sudo was not found."
warn " run this as root to finish: $DEST/host/setup-host.sh" warn " run this as root to finish: $DEST/current/host/setup-host.sh"
setup_declined=1 setup_declined=1
elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then elif confirm "Set up this machine as a box host now? (installs Incus + the isolation stack; needs sudo)"; then
# </dev/null because under 'curl … | bash' this script IS stdin: a child that # </dev/null because under 'curl … | bash' this script IS stdin: a child that
@ -177,7 +365,7 @@ elif confirm "Set up this machine as a box host now? (installs Incus + the isola
# it prompts on /dev/tty, so an interactive host can still authenticate. # it prompts on /dev/tty, so an interactive host can still authenticate.
# setup-host re-execs itself under sg incus-admin if it must add you to the # setup-host re-execs itself under sg incus-admin if it must add you to the
# group; that re-exec is a child here and completes the whole setup in one go. # group; that re-exec is a child here and completes the whole setup in one go.
if bash "$DEST/host/setup-host.sh" </dev/null; then if bash "$DEST/current/host/setup-host.sh" </dev/null; then
setup_ok=1 setup_ok=1
else else
warn "host setup did not complete — box is installed, the host is not ready." warn "host setup did not complete — box is installed, the host is not ready."
@ -189,9 +377,9 @@ else
fi fi
if [ -n "$setup_ok" ]; then if [ -n "$setup_ok" ]; then
log "done ($REPO@$REF) — try: box new --name test" log "done ($SRCDESC, version $new_ver) — try: box new --name test"
elif [ -n "$setup_declined" ]; then elif [ -n "$setup_declined" ]; then
log "done ($REPO@$REF) — when you want this machine to host boxes: box setup-host" log "done ($SRCDESC, version $new_ver) — when you want this machine to host boxes: box setup-host"
else else
log "done ($REPO@$REF) — finish with 'box setup-host', then: box new --name test" log "done ($SRCDESC, version $new_ver) — finish with 'box setup-host', then: box new --name test"
fi fi

View file

@ -227,7 +227,7 @@ check "revoke: purge refuses under unkillable sessions" 0 "" \
check "revoke: bare revoke warns about held sessions" 0 "" \ check "revoke: bare revoke warns about held sessions" 0 "" \
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh" grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
check "revoke: the purge asserts the certificate's absence too" 0 "" \ check "revoke: the purge asserts the certificate's absence too" 0 "" \
bash -c 'grep -A6 "Assert absence" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"' bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
# A failed grant must not leave a half-granted user: if THIS run added the # A failed grant must not leave a half-granted user: if THIS run added the
# group, the exit path takes it back (and the trap disarms only on success). # group, the exit path takes it back (and the trap disarms only on success).
check "grant: backs out its own group-add on failure" 0 "" \ check "grant: backs out its own group-add on failure" 0 "" \
@ -276,8 +276,272 @@ check "multiuser.sh refuses without the env gate" 2 "opt in" \
bash "$ROOT/drill/multiuser.sh" --yes bash "$ROOT/drill/multiuser.sh" --yes
check "grant-user.sh is valid bash" 0 "" bash -n "$ROOT/host/grant-user.sh" check "grant-user.sh is valid bash" 0 "" bash -n "$ROOT/host/grant-user.sh"
check "revoke-user.sh is valid bash" 0 "" bash -n "$ROOT/host/revoke-user.sh" check "revoke-user.sh is valid bash" 0 "" bash -n "$ROOT/host/revoke-user.sh"
check "teardown-host.sh is valid bash" 0 "" bash -n "$ROOT/host/teardown-host.sh"
# ---------------------------------------------------------------------------
# Revoke leaves NOTHING (the grant/revoke cleanliness pass). The gap this
# closes: --purge removed /var/lib/incus/users/<uid> but never RE-CHECKED it —
# the one path its own absence assert did not cover. And the stat must ride
# $SUDO: /var/lib/incus is not traversable by a non-root admin, so a bare
# [ -d ] answers "absent" for a directory that is very much there.
# ---------------------------------------------------------------------------
check "revoke: purge removes the incus-user state directory" 0 "" \
grep -qF '/var/lib/incus/users/' "$ROOT/host/revoke-user.sh"
check "revoke: the absence assert covers the incus-user state too" 0 "" \
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "/var/lib/incus/users/"'
# shellcheck disable=SC2016 # the $-strings are literals in the target file
check "revoke: the state checks go through \$SUDO test (an unprivileged stat lies)" 0 "" \
grep -qF '$SUDO test -d "/var/lib/incus/users/$uid"' "$ROOT/host/revoke-user.sh"
# ---------------------------------------------------------------------------
# The versioned install (#66 → 0.7.0). BOX_INSTALL_SOURCE bypasses the network,
# so these are REAL runs of install.sh against throwaway BOX_HOME/BOX_BIN
# roots — layout, symlink chain, flat-tree migration, symlink healing, use and
# uninstall are all DRIVEN, not grepped. A fake `incus` on PATH answers the
# existing-boxes gate ($FAKE_BOXES names them), so the #66 refusals — refuse
# to flip, refuse to switch, refuse to uninstall under boxes — run for real
# too, with no daemon anywhere near this suite.
# ---------------------------------------------------------------------------
VER="$(cat "$ROOT/VERSION")"
WORK="$(mktemp -d)"
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
ISHIM="$WORK/ishim"; mkdir -p "$ISHIM"
cat > "$ISHIM/incus" <<'SHIM'
#!/usr/bin/env bash
# Fake incus: 'list' prints $FAKE_BOXES (whitespace-separated names, one per
# line); everything else succeeds silently. Just enough for the existing-boxes
# gate that guards version flips.
case " $* " in
*" list "*) for b in ${FAKE_BOXES:-}; do printf '%s\n' "$b"; done ;;
esac
exit 0
SHIM
chmod +x "$ISHIM/incus"
# A fabricated "newer release": the same CLI, a different VERSION — what an
# upgrade actually is, from the installer's point of view.
SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin"
cp "$ROOT/bin/box" "$SRC9/bin/box"; chmod +x "$SRC9/bin/box"
echo "9.9.9-drill" > "$SRC9/VERSION"
SRC8="$WORK/src-8.8.8"; mkdir -p "$SRC8/bin"
cp "$ROOT/bin/box" "$SRC8/bin/box"; chmod +x "$SRC8/bin/box"
echo "8.8.8-drill" > "$SRC8/VERSION"
inst() { # inst <box_home> <box_bin> [VAR=val ...] — run install.sh for real
local h="$1" b="$2"; shift 2
env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= \
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
BOX_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh"
}
ibox() { # ibox [VAR=val ...] <cmd...> — run an installed box under the shim
env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= "$@"
}
# --- fresh install: the layout and the chain --------------------------------
H1="$WORK/h1"; B1="$WORK/b1"
check "install: a fresh install runs clean" 0 "done" inst "$H1" "$B1"
check "install: the tree lands in versions/<v>" 0 "" test -x "$H1/versions/$VER/bin/box"
check "install: 'current' points at versions/<v>" 0 "versions/$VER" readlink "$H1/current"
check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/box" readlink "$B1/box"
check "install: box --version answers through the whole chain" 0 "box $VER" ibox "$B1/box" --version
check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM"
# --- converge, don't clobber ------------------------------------------------
touch "$H1/versions/$VER/CANARY"
check "install: a same-version re-run is a no-op that says so (#66)" 0 "already installed" inst "$H1" "$B1"
check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY"
check "install: BOX_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" BOX_REINSTALL=1
check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY"
# --- a second version: side-by-side, and the no-boxes flip ------------------
check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9"
check "install: ...into its own versions dir" 0 "" test -x "$H1/versions/9.9.9-drill/bin/box"
check "install: ...and the old version stays" 0 "" test -d "$H1/versions/$VER"
check "install: with no boxes, the default flips to the new version" 0 "box 9.9.9-drill" ibox "$B1/box" --version
# --- box versions -----------------------------------------------------------
check "versions: lists the installed versions" 0 "$VER" ibox "$B1/box" versions
check "versions: marks the current default" 0 "(current)" ibox "$B1/box" versions
check "versions: marks the running one" 0 "(running)" ibox "$B1/box" versions
# --- box use ----------------------------------------------------------------
check "use: no argument is a usage error" 2 "usage: box use" ibox "$B1/box" use
check "use: an unknown version is refused by name" 1 "no such version" ibox "$B1/box" use 1.2.3
# A version is a directory NAME — a crafted one must die at the gate, never
# reach the ln (current pointing outside the root) or an rm -rf.
check "use: a path-traversal version dies at the gate" 1 "not a sane version name" \
ibox "$B1/box" use '../../tmp/evil'
check "use: refuses under existing boxes, naming them (#66)" 1 "wedged" \
ibox FAKE_BOXES="wedged stuck" "$B1/box" use "$VER"
check "use: the refusal points at the remedy (box rm, then re-run)" 1 "box rm" \
ibox FAKE_BOXES=wedged "$B1/box" use "$VER"
check "use: with no boxes, flips the default" 0 "switched to $VER" ibox "$B1/box" use "$VER"
check "use: the flip is effective through the PATH chain" 0 "box $VER" ibox "$B1/box" --version
check "install: an installed-but-not-current version is a no-op too" 0 "already installed" \
inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9"
check "install: ...and does not move the default" 0 "box $VER" ibox "$B1/box" --version
# --- the upgrade-under-boxes refusal, driven end to end ---------------------
H2="$WORK/h2"; B2="$WORK/b2"
check "refusal drill: baseline install" 0 "done" inst "$H2" "$B2"
check "upgrade under boxes: REFUSES the default flip (#66)" 0 "refusing to change the default box version" \
inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC9" FAKE_BOXES=work
check "upgrade under boxes: the new version IS installed side-by-side" 0 "" \
test -d "$H2/versions/9.9.9-drill"
check "upgrade under boxes: the default stayed put" 0 "box $VER" ibox "$B2/box" --version
check "upgrade under boxes: the blocking boxes are NAMED" 0 "· work" \
inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC8" FAKE_BOXES=work
check "upgrade under boxes: the refusal names the deliberate flip" 0 "" \
bash -c 'grep -q "then flip the default: box use" "'"$ROOT"'/install.sh"'
# --- migration: a 0.6.0 flat tree becomes a versioned one -------------------
H3="$WORK/h3"; B3="$WORK/b3"; mkdir -p "$H3/bin" "$B3"
cp "$ROOT/bin/box" "$H3/bin/box"; chmod +x "$H3/bin/box"
cp "$ROOT/VERSION" "$H3/VERSION"
echo "test@flat" > "$H3/INSTALLED_FROM"
ln -s "$H3/bin/box" "$B3/box"
check "migrate: a pre-0.7.0 flat tree is moved into versions/" 0 "migrating" inst "$H3" "$B3"
check "migrate: the OPERATOR'S tree moved (not a fresh copy)" 0 "test@flat" \
cat "$H3/versions/$VER/INSTALLED_FROM"
check "migrate: nothing flat remains at the root" 1 "" test -e "$H3/bin"
check "migrate: current points at the migrated version" 0 "versions/$VER" readlink "$H3/current"
check "migrate: the PATH symlink was re-pointed through current" 0 "$H3/current/bin/box" readlink "$B3/box"
check "migrate: the migrated install answers --version" 0 "box $VER" ibox "$B3/box" --version
# ...and the seamless 0.6.0 → 0.7.0 upgrade: flat tree in, new version beside it.
H4="$WORK/h4"; B4="$WORK/b4"; mkdir -p "$H4/bin" "$B4"
cp "$ROOT/bin/box" "$H4/bin/box"; chmod +x "$H4/bin/box"
cp "$ROOT/VERSION" "$H4/VERSION"
ln -s "$H4/bin/box" "$B4/box"
check "migrate+upgrade: flat 0.6.0 in, new version installed beside it" 0 "" \
inst "$H4" "$B4" BOX_INSTALL_SOURCE="$SRC9"
check "migrate+upgrade: both versions present" 0 "" \
bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]"
check "migrate+upgrade: no boxes → the new version is the default" 0 "box 9.9.9-drill" \
ibox "$B4/box" --version
# A broken current must halt the single-version path BEFORE any decision: the
# CURRENT guard keys off what current resolves to, and a dangling link makes
# that answer a lie. Drive the version tree's own binary — the current chain
# is exactly what is broken. H4 has two versions; heal current afterwards.
ln -sfn "versions/gone" "$H4/current"
check "uninstall: refuses while current is dangling (heal before delete)" 1 "dangling" \
ibox "$H4/versions/$VER/bin/box" uninstall 9.9.9-drill --force
check "uninstall: ...and both version trees survived the refusal" 0 "" \
bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]"
ln -sfn "versions/9.9.9-drill" "$H4/current"
# The migration reads VERSION off the old tree — disk data, not installer
# data. A hostile value must refuse BEFORE the tree moves anywhere.
H9="$WORK/h9"; B9="$WORK/b9"; mkdir -p "$H9/bin" "$B9"
cp "$ROOT/bin/box" "$H9/bin/box"; chmod +x "$H9/bin/box"
printf '%s\n' '../pwn' > "$H9/VERSION"
check "migrate: a hostile flat VERSION refuses to migrate" 1 "not a sane directory name" \
inst "$H9" "$B9"
check "migrate: ...with the flat tree untouched where it was" 0 "" test -x "$H9/bin/box"
# --- healing: a wedged \$BINDIR/box must never block an install -------------
H5="$WORK/h5"; B5="$WORK/b5"; mkdir -p "$B5"
ln -s "$WORK/nowhere/box" "$B5/box" # dangling
check "heal: a DANGLING \$BINDIR/box does not wedge the install" 0 "done" inst "$H5" "$B5"
check "heal: ...and got repointed" 0 "box $VER" ibox "$B5/box" --version
H6="$WORK/h6"; B6="$WORK/b6"; mkdir -p "$B6"
ln -s /bin/true "$B6/box" # stale, but resolvable
check "heal: a STALE \$BINDIR/box with no tree does not fake 'installed'" 0 "installing $VER" \
inst "$H6" "$B6"
check "heal: ...the install is real and answers" 0 "box $VER" ibox "$B6/box" --version
# --- box uninstall: one version ---------------------------------------------
check "uninstall: refuses to remove the CURRENT version" 1 "CURRENT" \
ibox "$B1/box" uninstall "$VER" --force
check "uninstall: an unknown version is refused by name" 1 "no such version" \
ibox "$B1/box" uninstall 5.5.5 --force
check "uninstall: a path-traversal version dies at the gate (never an rm -rf)" 1 "not a sane version name" \
ibox "$B1/box" uninstall '../../../../etc' --force
check "uninstall: a version plus --all is ambiguous (usage error)" 2 "" \
ibox "$B1/box" uninstall 9.9.9-drill --all --force
check "uninstall: removes a non-current version" 0 "removed version" \
ibox "$B1/box" uninstall 9.9.9-drill --force
check "uninstall: that version dir is gone" 1 "" test -e "$H1/versions/9.9.9-drill"
check "uninstall: the current version still answers" 0 "box $VER" ibox "$B1/box" --version
# --- box uninstall: everything, in the safe order ---------------------------
check "uninstall: refuses while boxes exist, naming them" 1 "wedged" \
ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force
check "uninstall: the refusal offers --purge-host" 1 "purge-host" \
ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force
check "uninstall: refuses without --force when no terminal" 2 "refusing" \
ibox bash -c "'$B1/box' uninstall --all </dev/null"
# Plant legacy crumbs: a real uninstall leaves neither name generation behind.
mkdir -p "$FAKEHOME/.local/share/claudebox"
ln -s "$WORK/gone" "$B1/claudebox"
check "uninstall --all: removes the whole install" 0 "uninstalled" \
ibox "$B1/box" uninstall --all --force
check "uninstall --all: ZERO residue — root, symlinks, legacy names" 0 "" bash -c "
[ ! -e '$H1' ] && [ ! -L '$H1' ] &&
[ ! -e '$B1/box' ] && [ ! -L '$B1/box' ] &&
[ ! -e '$B1/claudebox' ] && [ ! -L '$B1/claudebox' ] &&
[ ! -e '$FAKEHOME/.local/share/claudebox' ]"
# The last word is a re-check: a survivor must turn into a loud INCOMPLETE,
# never a cheerful "uninstalled". (Root ignores file modes, so this drill is
# meaningful — and runnable — for a non-root runner only.)
if [ "$(id -u)" -ne 0 ]; then
H7="$WORK/h7"; B7="$WORK/b7"
inst "$H7" "$B7" >/dev/null 2>&1
mkdir -p "$H7/versions/$VER/stuck"; touch "$H7/versions/$VER/stuck/pin"
chmod 555 "$H7/versions/$VER/stuck"
check "uninstall: a survivor makes it scream INCOMPLETE (exit 1)" 1 "INCOMPLETE" \
ibox "$B7/box" uninstall --all --force
chmod -R u+w "$H7" 2>/dev/null
fi
# --- the versioned verbs from a working tree: refuse, don't guess -----------
check "uninstall: refuses from a working tree" 1 "not a versioned install" "$BOX" uninstall --all --force
check "versions: refuses from a working tree" 1 "not a versioned install" "$BOX" versions
check "use: refuses from a working tree" 1 "not a versioned install" "$BOX" use 1.0.0
# The existing-boxes gate must be ONE decision: install.sh and bin/box carry
# byte-identical copies (the installer runs before any tree exists), and a
# drifted copy is two #66 stances pretending to be one.
EBBIN="$(mktemp)"; EBINST="$(mktemp)"
awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/bin/box" > "$EBBIN"
awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/install.sh" > "$EBINST"
check "existing_boxes: extracted from bin/box (guards the awk)" 0 "user.box=1" cat "$EBBIN"
check "existing_boxes: bin/box and install.sh copies are byte-identical" 0 "" diff "$EBBIN" "$EBINST"
rm -f "$EBBIN" "$EBINST"
# Same discipline for the version-name gate: one policy, two copies, no drift
# — a version that install.sh would refuse must not be one 'box use' accepts.
VVBIN="$(mktemp)"; VVINST="$(mktemp)"
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/bin/box" > "$VVBIN"
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/install.sh" > "$VVINST"
check "valid_version: extracted from bin/box (guards the awk)" 0 "A-Za-z0-9" cat "$VVBIN"
check "valid_version: bin/box and install.sh copies are byte-identical" 0 "" diff "$VVBIN" "$VVINST"
rm -f "$VVBIN" "$VVINST"
# --purge-host must FORWARD installer-family consent: under --force/BOX_YES
# the teardown call carries --yes, or a non-interactive combined uninstall
# dies at teardown's own prompt with the flag's promise broken.
# shellcheck disable=SC2016 # the $-string is a literal in the target file
check "uninstall: --purge-host forwards consent to teardown-host (--yes)" 0 "" \
grep -qF -- 'bash "$root/host/teardown-host.sh" --yes' "$ROOT/bin/box"
# --- the help keeps its promises --------------------------------------------
check "help: the table lists 'versions'" 0 "versions" "$BOX" help
check "help use: names the #66 stance" 0 "boxes" "$BOX" help use
check "help uninstall: names --purge-host" 0 "purge-host" "$BOX" help uninstall
check "help uninstall: promises the absence re-check" 0 "absence" "$BOX" help uninstall
# --- automation hooks the CI uninstall drill rides ---------------------------
check "teardown-host: honors --yes/BOX_YES (CI runs it unattended)" 0 "" \
grep -qF 'BOX_YES' "$ROOT/host/teardown-host.sh"
check "teardown-host: points at box uninstall when done" 0 "" \
grep -qF "box uninstall" "$ROOT/host/teardown-host.sh"
check "drill: reads the installed tree through current/" 0 "" \
grep -qF '.local/share/box/current/VERSION' "$ROOT/drill/drill.sh"
echo "---" echo "---"
echo "$PASS passed, $FAIL failed" echo "$PASS passed, $FAIL failed"
rm -rf "$SHIMDIR" rm -rf "$SHIMDIR" "$WORK"
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]