feat: versioned installs, and a real uninstall #79
No reviewers
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#79
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/versioned-install"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
Two maintainer requests, one PR:
versions/<v>tree; a relativecurrentsymlink tracks the default;$BINDIR/boxpoints through it.bin/boxneeded no change to run from here: its existingreadlink -fresolves the whole chain.box uninstall [<version>] [--all] [--purge-host]encodes the safe removal order (revoke → teardown-host → trees/symlinks/crumbs) and ends with an absence assert: every removed path is re-checked, any survivor exits 1uninstall INCOMPLETEnaming the leftovers.rm's exit code is not the verdict — the re-check is.New verbs (CMDS-table rows like every other verb):
box versions— lists installed versions, marking the current default and the tree actually answering the command.box use <version>— repointscurrentwith the same existing-boxes refusal as the installer's flip (sharedexisting_boxes()helper, byte-identical ininstall.shandbin/box, diffed by the tests so the two #66 stances cannot drift), then asserts the effective result —current/bin/box --versionmust answer the asked-for version.box uninstall— single-version (current refused) or full (--all), with--purge-hostfor the whole stack.Install semantics (#66's stance, kept — at the flip)
BOX_REINSTALL=1replaces via two renames.currentflips only when no boxes exist — with boxes present the flip is refused loudly, the boxes are named, and the remedy is spelled out.versions/<v>itself.Also
[ -d ]lies for a non-root admin. Now rides$SUDO test -dand joins the absence block.teardown-host.shgains--yes/BOX_YESfor the CI drill.Tests
test/cli.sh: 154/154, still dependency-free, non-root, daemon-free — but installs are now driven (realinstall.shruns viaBOX_INSTALL_SOURCE), not grepped. Covers the fresh chain, no-op/canary, reinstall, side-by-side + flip, all three #66 refusals, flat-tree migration, symlink healing, both uninstall shapes, the INCOMPLETE scream, and working-tree refusals.CI's rehearsal job now installs via
install.shitself, runs the stack from/opt/box/current/..., and ends with a zero-residue uninstall drill on live Incus (grant + revoke --purge, teardown, uninstall --all, then nothing left — networks, profiles, ACLs, nft, units, files, symlinks, both name generations).Design doc:
docs/plans/2026-07-18-versioned-install.md.Not #67 (boxes still don't migrate across versions — this delivers the upgrade path), and not a release (
VERSIONuntouched).🤖 Generated with Claude Code
Merge order (cross-repo)
Analyzed all open heavy-duty PRs (box #79/#78/#69, rig #30/#29/#21/#19) for file overlap, semantic dependencies, and mergeability. The recommended order:
test/cli.sh→ trivial rebase after #29.install.shrewrite,bin/box, CI). Lands first so the other box PRs rebase onto the new layout exactly once.install.shuntouched ("owned by a concurrent PR, reconciles at rebase time" — that PR is #79). Rebasing after #79 lets the installer's upgrade-flow message finally namedown → export → rm → upgrade → re-import.bin/box/test/cli.sh/CHANGELOG/docs/box-design.md→ mechanical rebase.Closed as superseded (both predate the traits redesign that landed via rig#27/#28, and their content is re-delivered on the new design by their successors' own account):
Notes:
This PR is step 3 (first on the box side) — merge before #78/#69 so the install-layout churn lands once and the others rebase onto it.
Merge-order update: box#69 was closed in favor of the thin-template layering (#81, heavy-duty/rig#31); its surviving mint-time pieces were re-cut as #82. The box track is now #79 → #78 → #82.
🔧 Reviewed — I agree with most; feedback below.
Strong PR — I drove through install.sh, the three new verbs, the uninstall absence-assert, and the revoke fix. The versioned-layout logic is sound:
install_root()keying off.../versions/<v>, the flip gated onexisting_boxes()(byte-identical + diff-guarded across both files), migration-in-place via two renames,ln -sfnhealing, and the gather-then-remove-then-re-check uninstall are all coherent and genuinely tested (not grepped). Therevoke --purge$SUDO test -dfix and its now-covered absence assert are correct — a bare[ -d ]really does lie for a non-root admin under/var/lib/incus.One concern:
bin/boxcmd_uninstall— the--purge-hostbranch callsbash "$root/host/teardown-host.sh"with no args and relies onBOX_YESinheriting from the env. But--forcesets onlyforce=1, notBOX_YES, sobox uninstall --all --purge-host --forcein a non-interactive context (no TTY, noBOX_YES) hits teardown-host's ownread -rp, gets EOF → "aborted" → yourdie "teardown-host did not complete". So--forcealone is not sufficient for the purge-host path the way it is for the rest of uninstall. Either forward consent (yes=1/BOX_YES) into the teardown call whenforce -eq 1, or document that--purge-hostnon-interactively requiresBOX_YES=1. The CI drill sidesteps this by runningteardown-hostseparately withBOX_YES=1, so the gap isn't currently exercised.Everything else looks good to merge.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Request changes
Head:
34bf39be863118b57803670daaf0f69034739cdf· Checks:check+rehearsalgreen · Prior reviews: none · Priorgrok-bot-andresmgslcomments: noneSolid design overall: versioned
versions/<v>+ relativecurrent, #66 gates at the flip (not the whole install), realBOX_INSTALL_SOURCE-driven tests, revoke$SUDO testfix, and the uninstall absence assert (re-check is the verdict, notrm's exit) are the right shape. A few safety holes need closing before merge.Blockers
1. Version args are not sanitized — path traversal on
uninstall/useinstall.shcorrectly rejects bad VERSION directory names:cmd_useandcmd_uninstalltake the version from argv and interpolate it unsafely:With a crafted argument,
..segments escapeversions/:Confirmed resolve:
$ir/versions/../../victim→ sibling of the install root. Under a global install as root this is arbitrary delete; under a per-user install it is still a footgun and breaks the "version is a directory name" invariant the installer already enforces.Fix: share one validator (same
caseasinstall.sh, or stricter: only[A-Za-z0-9._+-]/ reject*../leading.) and apply it incmd_use, single-versioncmd_uninstall, and on migrate'sflat_ver(below).2. Migrate path:
flat_veris unsanitized the same waynew_veris validated;flat_veris not. A corrupted/hostileVERSIONin a pre-0.7.0 tree can steer themv/lnoutsideversions/. Apply the same sanity check (and refuse / fallback to a safe name if invalid).Non-blocking (should fix or explicitly document)
3.
--purge-host+--forcedoes not make teardown unattendedNo
--yesis passed.teardown-hostonly auto-confirms via its own--yesorBOX_YES. So:BOX_YES=1 box uninstall --all --purge-hostbox uninstall --all --purge-host --force(TTY)BOX_YESuninstall_confirmtreats--forceandBOX_YESas equivalent consent; teardown does not. Help text ("--forceorBOX_YES=1skips the prompt") reads like the whole flow is covered.teardown-host.sh's own comment says'box uninstall --purge-host' run this unattended— only true underBOX_YES.Prefer: if
force=1orBOX_YESis set, invoketeardown-host.sh --yes. CI currently sidesteps this by callingteardown-hostanduninstall --allas separate steps — the combined--purge-hostpath is untested end-to-end.4. Symlink / reinstall races (polish)
ln -sfnforcurrent: GNUln -sfnis unlink+create, not an atomic replace. Brief window wherecurrentis missing duringbox use/ install flip. Common pattern:ln -sfn versions/$v "$ir/current.new.$$" && mv -Tf "$ir/current.new.$$" "$ir/current".BOX_REINSTALL=1:rm -rf "$VDIR"thenmv "$stage" "$VDIR"leaves a hole wherecurrent→ that version is broken mid-reinstall. Two-rename swap (VDIR→VDIR.old.$$, stage →VDIR, then rm old) matches the comment's "as atomically as two renames allow" better than delete-then-move.Neither is catastrophic for a single-operator tool; both are small and in scope of "version symlink races."
5. Nits
current+ single-version uninstall: ifcurrentis broken,curis empty and the "refuse removing CURRENT" guard does not fire — you can delete the treecurrentstill names. Heal or refuse whencurrentis dangling.box uninstall --all --purge-hostas one verb (only the split path). Worth one rehearsal step once consent wiring is fixed.snapshotsrm -rf…) fixed here — good catch.What looks good (no action)
uninstall INCOMPLETEwith names;rm || trueso the re-check is the verdict. Matches revoke --purge discipline.use+ full uninstall); side-by-side install still allowed; boxes named;existing_boxesbyte-identical acrossinstall.sh/bin/boxand diff'd in tests.[ -w "$ir" ]before uninstall;bin_linksonly removes PATH symlinks that resolve into this install root; rootchmod -R a+rXpreserved; revoke state dir via$SUDO test -d+ absence membership — correct fix for the non-root admin lie.install_root(…/versions/<v>only) — won'trm -rfa checkout.Requested changes before approve
cmd_use,cmd_uninstall(single-version), and migrationflat_ver— same policy as install'snew_vercheck (blocker).--force/BOX_YESintoteardown-host.sh --yesfor--purge-host, or document that onlyBOX_YESunattended-covers teardown and add a test for the combined path.Happy to re-review quickly once (1) is in.
Verdict: I have feedback.
The unsanitized version arguments are a blocking path-integrity issue. The single-version uninstall operation interpolates the requested version below the versions directory, and use builds a symlink from it, so values containing slashes and parent-directory segments can escape that directory. The flat-tree migration VERSION value has the same trust boundary. Apply one strict version-name validator to installer versions, migrated versions, use, and single-version uninstall, with traversal regression tests. Also forward unattended consent from --force to the combined --purge-host teardown path.
Round 1 response — all three of you converged on both real findings. Fixed in
68a4996.Blocker 1: version sanitization (@grok-bot-andresmgsl, @codex-bot-andresmgsl). Confirmed and taken with your stricter policy: one
valid_version()— only[A-Za-z0-9._+-], no leading.or-, which forbids/,..-escapes, spaces and option-lookalikes by construction — byte-identical ininstall.shandbin/boxlikeexisting_boxes, with the same diff-guard in the tests so the two gates cannot drift. Applied at all four callers: the installer'snew_ver(replacing the old loosercase), migration'sflat_ver(which now refuses before the tree moves anywhere),box use, and single-versionbox uninstall. Traversal regressions drive all three CLI/installer paths (use '../../tmp/evil',uninstall '../../../../etc' --force, and a flat tree withVERSION='../pwn'asserting the refusal leaves it untouched in place).Blocker 2 / @claude-bot-andresmgsl's concern: consent forwarding.
--purge-hostnow forwards installer-family consent — under--forceorBOX_YES, teardown-host is invoked with--yes, so the combined non-interactive uninstall no longer dies at teardown's own prompt. And per @grok-bot-andresmgsl's nit about the untested combined path: CI's uninstall drill now runsbox uninstall --all --purge-host --forceas one verb, deliberately with noBOX_YESand no TTY — the exact invocation that used to abort — before the zero-residue asserts.Polish, taken (@grok-bot-andresmgsl #4/#5):
currentnow flips by rename everywhere (lnto a side name,mv -Tfover) — sharedflip_current()ininstall.sh(fresh claim, upgrade flip, migration) and the same pattern inbox use. No window with nocurrent.BOX_REINSTALLswaps by two renames and deletes last — no hole while the old tree is being removed.currentis dangling — and since GNUreadlink -fresolves a link whose last component is missing, the guard checks thatversions/<cur>is a real directory, not just that the string is non-empty. Regression drives a danglingcurrentagainst the version tree's own binary (thecurrentchain being exactly what's broken).Gate:
bash test/cli.sh— 163 passed, 0 failed (154 before);shellcheck -xclean. Ready for another look.✅ Reviewed — I agree with all of this, no concerns.
Re-reviewed
68a4996against my prior point and the whole convergence round.My concern is resolved.
cmd_uninstall's--purge-hostbranch now forwards installer-family consent —bin/box:555-557: underforce -eq 1orBOX_YES,teardown-host.shis invoked with--yes, so the non-interactivebox uninstall --all --purge-host --forcepath no longer dies at teardown's ownread. The CI drill now exercises exactly that combined verb (--forcealone, noBOX_YES, no TTY) before the zero-residue asserts, so the gap I flagged is now covered end-to-end.Also verified the other reviewers' blockers landed cleanly, since they touch the same paths:
valid_version()gate ([A-Za-z0-9._+-], no leading./-) applied at every caller — installernew_ver(install.sh:1160), migrationflat_verbefore the tree moves (install.sh:1065),cmd_use(bin/box:461), and single-versioncmd_uninstall(bin/box:522) — byte-identical across both files and diff-guarded in the tests (test/cli.sh:1604-1607). Path traversal onuse/uninstall/migration is closed.flip_current()now flips byln -sfnto a side name +mv -Tf(install.sh:1049-1051, mirrored incmd_use) — no window with a missingcurrent;BOX_REINSTALLswaps by two renames and deletes last.currentby checking the resolved directory, not the string (bin/box:529-530).163/163 tests, shellcheck clean. Good to merge.
— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
What closed prior REQUEST_CHANGES (
34bf39be→68a4996)valid_version()([A-Za-z0-9._+-], no leading./-) is byte-identical ininstall.shandbin/box, diff-guarded in tests, and applied at installernew_ver, migrationflat_ver(before any move),box use, and single-versionuninstall. Traversal regressions (use '../../tmp/evil',uninstall '../../../../etc' --force, flatVERSION='../pwn') cover the gate.--purge-hostconsent — forwards--yesunder--force/BOX_YES; CI drill runs the combined non-interactive path.currentflip, reinstall delete-last, refuse uninstall whilecurrentdangles.Versioned layout + absence-assert uninstall still look sound. CI green (
check,rehearsal). No new blockers.Verdict: I agree with everything and have no additional feedback.