Merge pull request #79 from dan-claude-bot/feat/versioned-install

feat: versioned installs, and a real uninstall
This commit is contained in:
Daniel Marin 2026-07-18 19:52:48 +01:00 committed by GitHub
commit d879530a7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1197 additions and 129 deletions

View file

@ -40,20 +40,60 @@ jobs:
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus
- name: global install, from this checkout (the #71 layout)
# Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove
# the code under review. Same tree, same layout, same a+rX.
- name: global install, via install.sh itself (the #71 layout, versioned)
# install.sh, not a cp -r mimic: BOX_INSTALL_SOURCE points it at this
# 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: |
sudo cp -r . /opt/box
sudo rm -rf /opt/box/.git
sudo chmod -R a+rX /opt/box
sudo ln -sf /opt/box/bin/box /usr/local/bin/box
sudo BOX_YES=1 BOX_SKIP_SETUP_HOST=1 BOX_INSTALL_SOURCE="$GITHUB_WORKSPACE" bash install.sh
# assert what landed: the layout, the chain, and that it answers
readlink -f /usr/local/bin/box | grep '^/opt/box/versions/'
/usr/local/bin/box --version
/usr/local/bin/box versions
- 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
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)
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
# 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
### 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
### Added

View file

@ -42,29 +42,48 @@ design rationale.
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,
downloads the tree to `~/.local/share/box`, links `box` onto your `PATH`, and
asks a second question: **"Set up this machine as a box host now?"** Say yes and
it builds the whole isolation stack for you (it may ask for `sudo`); say no and
you can run `box setup-host` later. (No `git clone` needed.)
It asks first — **"Install box?"** — then downloads the tree into a
**versioned** install (the way plenty of CLIs manage theirs), links `box` onto
your `PATH`, and on a fresh host asks a second question: **"Set up this
machine as a box host now?"** Say yes and it builds the whole isolation stack
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
you so and changes nothing — a stray re-run can never clobber your install or
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
The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
root install):
```
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>`
(which deletes the box _and_ its snapshots) — then:
```sh
rm -rf ~/.local/share/box ~/.local/bin/box # uninstall
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
box versions # what is installed, which is current, which is running
box use <version> # flip the default (same refusal while boxes exist)
```
A version-aware upgrade that migrates boxes instead of asking you to is
[#67](https://github.com/heavy-duty/box/issues/67). For unattended installs
(CI, images), `BOX_YES=1` answers every prompt yes and `BOX_SKIP_SETUP_HOST=1`
declines the host-setup step.
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
next installer run — the tree is moved, not re-downloaded, and your boxes are
untouched. A version-aware upgrade that migrates boxes instead of asking you
to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
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
@ -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
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)
@ -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
knows, because `incus copy` carries the metadata.
## Log in once, reuse via snapshotsrm -rf ~/.local/share/box ~/.local/bin/box # per-user install
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install
## Log in once, reuse via snapshots
Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead:
@ -388,13 +408,29 @@ documentation, not a host-executed script. See
## 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
box teardown-host # boxes, network, ACL, profile, firewall
box teardown-host --purge-incus # ...and Incus itself
rm -rf ~/.local/share/box ~/.local/bin/box # per-user install
sudo rm -rf /opt/box /usr/local/bin/box # global (root) install
box uninstall <version> # one non-current version (side-by-side cleanup)
box uninstall --all --purge-host # everything: teardown-host (all boxes, the
# boxnet stack, the firewall), then every
# 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
- **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^"
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
"versions^^^List the installed box versions — the current default and the running one^fn:cmd_versions^"
"use^<version>^^Switch the default box version (refuses while boxes exist)^fn:cmd_use^"
"uninstall^[<version>] [--all] [--purge-host]^^Remove one installed version, or the whole install — asks first, asserts the absence^fn:cmd_uninstall^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
)
@ -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 --retire-legacy # remove the old stack (once no legacy box remains)
EOF
;;
versions) cat <<'EOF'
List the versions installed under this install root — install.sh lands each
one side by side at <root>/versions/<v>, and a 'current' symlink tracks the
default (what the box on your PATH runs). The default is marked (current);
the tree answering THIS command is marked (running) — they differ when your
PATH resolves a different install (say, a global /opt/box shadowing yours).
box versions
box use <version> # switch the default
# install another version side by side: re-run install.sh
EOF
;;
use) cat <<'EOF'
Switch the default box version — repoint the 'current' symlink (and the PATH
symlink riding it) at an installed version. Refuses while ANY box exists:
never change versions under a user's boxes (#66) — 'box down' what you keep,
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'
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
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# the host verbs delegate their flags to the scripts they wrap
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke) args+=("$1"); shift; continue ;; esac
# the host verbs delegate their flags to the scripts they wrap;
# uninstall parses its own (--all, --purge-host) the same way
case "$cmd" in setup-host|teardown-host|migrate-host|grant|revoke|uninstall) args+=("$1"); shift; continue ;; esac
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
@ -1045,6 +1099,258 @@ cmd_migrate_host() { host_script migrate-host.sh; }
cmd_grant() { host_script grant-user.sh; }
cmd_revoke() { host_script revoke-user.sh; }
# --- the versioned install (#66's stance, made livable in 0.7.0) ------------
# install.sh lands each version at <install-root>/versions/<v>, with a
# 'current' symlink naming the default and $BINDIR/box pointing through it.
# $root (readlink -f, line 8) already resolved the whole chain, so a versioned
# install always runs from .../versions/<v> — and a git checkout does not,
# which is how these verbs know to refuse instead of uninstalling somebody's
# working copy.
install_root() {
local vdir; vdir="$(dirname "$root")"
[ "$(basename "$vdir")" = versions ] || return 1
dirname "$vdir"
}
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/box's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. install.sh carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version() {
case "$1" in
''|.*|-*) return 1 ;;
*[!A-Za-z0-9._+-]*) return 1 ;;
esac
return 0
}
# Which boxes exist on this host, at THIS caller's tier? Prints their names
# (both tag generations) and succeeds when at least one exists; fails when
# none are visible — including when incus is absent or not answering, because
# #66's stance protects BOXES from a version change, and a daemon that cannot
# answer has none to protect. install.sh carries a byte-identical copy (it
# runs before any install tree exists); test/cli.sh diffs the two so they
# cannot drift.
existing_boxes() {
command -v incus >/dev/null 2>&1 || return 1
{ timeout 10 incus list user.box=1 --format csv --columns n </dev/null
timeout 10 incus list user.claudebox=1 --format csv --columns n </dev/null
} 2>/dev/null | awk -F, 'NF && !seen[$1]++ { print $1 }' | grep .
}
# #66, kept at flip time: never change (or remove) the version under existing
# boxes. Names every box and the remedy, then dies — a refusal that does not
# say which boxes block it sends the operator off to rediscover 'box list'.
die_under_boxes() { # $1 = the act being refused, $2 = the retry command
local names n
names="$(existing_boxes)" || return 0
{
echo "box: this host has existing boxes:"
while IFS= read -r n; do echo "box: · $n"; done <<<"$names"
echo "box: refusing to $1 under them (#66: never change versions under a user's boxes)."
echo "box: preserve what you care about — 'box down <box>', 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]:-}"; }
# 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
fi
# The installer is a no-op when box is already installed — upgrading is
# uninstall-then-install, by design. The drill re-proves a tree from scratch
# every run, so it does the uninstall itself: clear any prior tree and symlink
# before installing, or install.sh would correctly refuse to touch them.
# The installer converges when a version is already installed (0.7.0's
# versioned layout: re-running the same version is a no-op, and an upgrade
# lands side-by-side without flipping under boxes). The drill re-proves a
# 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"
# 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
# the wrong tree while reporting success. A drill that silently drills the
# wrong code is worse than one that fails.
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
got="$(cat "$HOME/.local/share/box/current/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
if [ "$got" != "$REPO@$REF" ]; then
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
@ -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 " is supposed to converge in one run (#63). One of those did not happen." >&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
exit 1
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
# restores the stack rather than a fresh build.
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 " 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
@ -469,7 +470,7 @@ phase "B. The box surface"
# ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number —
# 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)"
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
# bad template in the installed tree (the drill owns this host), expect the
# 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"
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" \
@ -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
# migrate-host.sh moves it onto the new stack with its identity intact, and
# retires the legacy stack only once it is empty.
MIG="$HOME/.local/share/box/host/migrate-host.sh"
MIG="$HOME/.local/share/box/current/host/migrate-host.sh"
if [ ! -f "$MIG" ]; then
no "migrate-host.sh not installed — cannot drill the transition"
else
@ -985,5 +986,5 @@ fi
echo
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 "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 ]

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
# 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"
echo "purge: incus-user state for uid $uid removed"
fi
# Assert absence rather than trusting exit codes — the wipe.sh discipline.
# 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=""
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 config trust list --format csv --columns nf 2>/dev/null | grep -q "^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
echo "box revoke: purge INCOMPLETE — still present:$leftover" >&2
exit 1

View file

@ -3,20 +3,34 @@
# 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
# 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
# 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
purge=false
[ "${1:-}" = "--purge-incus" ] && purge=true
purge=false; yes=0
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 "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
echo "(both current and pre-0.4.0 names)."
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
if [ "$yes" -eq 1 ]; then
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
# 'incus delete' aborts at the first missing name).
@ -72,4 +86,4 @@ if $purge; then
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
#
# Downloads the box source tarball from its GitHub repo (heavy-duty/box),
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install
# script keeps working; BOX_REPO overrides.)
# Downloads the box source tarball from its GitHub repo (heavy-duty/box) and
# installs it into the VERSIONED layout under $DEST:
#
# $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}"
REF="${BOX_REF:-main}"
@ -47,30 +62,96 @@ confirm() { # $1 = question
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 ---------------------------------------------------------
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."
# --- confirm, then no-op if already installed ------------------------------
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
# ask whether you meant to. Then, if box is already installed, this run changes
# nothing and says so — a re-run is a safe no-op, which dissolves the whole
# "curl clobbered my working install / rebuilt the stack under my boxes" class
# 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 [ -n "${BOX_INSTALL_SOURCE:-}" ]; then
SRCDESC="local source $BOX_INSTALL_SOURCE"
else
SRCDESC="$REPO@$REF"
fi
if [ -e "$BINDIR/box" ] || [ -x "$DEST/bin/box" ]; then
cur="$(cat "$DEST/INSTALLED_FROM" 2>/dev/null || echo '<unknown source>')"
cur_ver="$(cat "$DEST/VERSION" 2>/dev/null || echo '?')"
log "box is already installed ($cur, version $cur_ver) — nothing to do."
log "To install a different version, remove the current one first:"
log " · preserve any boxes you care about — 'box down <box>', then keep them"
log " (a portable 'box export' is #70; for now copy what you need OUT via"
log " 'box shell'/'box exec'), and 'box rm <box>' when you are done"
log " · uninstall: rm -rf \"$DEST\" \"$BINDIR/box\""
log " · then re-run this installer"
exit 0
# --- confirm first ---------------------------------------------------------
# Prompt BEFORE downloading anything: the first thing a curl|bash should do is
# ask whether you meant to. Everything after this converges: re-running with a
# version that is already installed changes nothing and says so, which
# dissolves the whole "curl clobbered my working install / rebuilt the stack
# under my boxes" class of failures (#66).
confirm "Install box from $SRCDESC?" || die "cancelled — nothing was changed."
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
# rename over. Plain ln -sfn is unlink+create — a window where current names
# 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
# --- temp workspace --------------------------------------------------------
@ -78,50 +159,131 @@ TMPDIR="$(mktemp -d)"
cleanup() { rm -rf "$TMPDIR"; }
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 "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download $URL"
log "extracting archive"
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
log "extracting archive"
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# GitHub names the archive's top dir <repo>-<ref> (slashes in a ref become
# dashes) — deriving that name is guesswork, and it broke for real at the
# 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
# dashes) — deriving that name is guesswork, and it broke for real at the
# 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)"
[ -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?"
# The tree's own VERSION file names the directory it lands in — the version IS
# the identity of what is being installed, and 'box versions' lists these names.
new_ver="$(cat "$EXTRACTED/VERSION" 2>/dev/null || true)"
[ -n "$new_ver" ] || die "source has no VERSION file — cannot install it as a version"
valid_version "$new_ver" || die "the source's VERSION is not a sane directory name: '$new_ver'"
# --- install into $DEST ----------------------------------------------------
# Reached only on a host with no existing install (the no-op check above
# exits otherwise), so this is always a fresh tree, never an overwrite.
log "installing into $DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
# --- install into $DEST/versions/<version> ---------------------------------
VDIR="$DEST/versions/$new_ver"
newly_installed=0
if [ -d "$VDIR" ]; then
if [ -n "${BOX_REINSTALL:-}" ]; then
# 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"
# 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"
# --- which version is the default? -----------------------------------------
# 'current' is the tracked default; flipping it is the ONLY step that changes
# what an operator's `box` runs. #66's stance, kept exactly here: never change
# versions under existing boxes. A fresh host (or a dangling current) is
# claimed outright; an upgrade flips only when no box exists — otherwise the
# new version sits installed side-by-side and 'box use' is the deliberate act.
cur="$(readlink -f "$DEST/current" 2>/dev/null || true)"
want="$(readlink -f "$VDIR")"
if [ -z "$cur" ] || [ ! -d "$cur" ]; then
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
# --- 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"
ln -sf "$DEST/bin/box" "$BINDIR/box"
log "linked $BINDIR/box -> $DEST/bin/box"
ln -sfn "$DEST/current/bin/box" "$BINDIR/box"
log "linked $BINDIR/box -> $DEST/current/bin/box"
# 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
# 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)"
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 ------------------------------------------------------------
case ":$PATH:" in
*":$BINDIR:"*) : ;;
@ -147,29 +337,27 @@ case ":$PATH:" in
;;
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) --------------------------------------------
# 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
# 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.
# 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
# up by hand); BOX_YES answers "yes".
setup_ok=""
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)."
setup_declined=1
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 " 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
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
@ -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.
# 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.
if bash "$DEST/host/setup-host.sh" </dev/null; then
if bash "$DEST/current/host/setup-host.sh" </dev/null; then
setup_ok=1
else
warn "host setup did not complete — box is installed, the host is not ready."
@ -189,9 +377,9 @@ else
fi
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
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
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

View file

@ -227,7 +227,7 @@ check "revoke: purge refuses under unkillable sessions" 0 "" \
check "revoke: bare revoke warns about held sessions" 0 "" \
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
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
# 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 "" \
@ -276,8 +276,272 @@ check "multiuser.sh refuses without the env gate" 2 "opt in" \
bash "$ROOT/drill/multiuser.sh" --yes
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 "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 "$PASS passed, $FAIL failed"
rm -rf "$SHIMDIR"
rm -rf "$SHIMDIR" "$WORK"
[ "$FAIL" -eq 0 ]