fix: box restore asks before it destroys, in its own words (#105) #109
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#109
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/restore-confirm"
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?
Fixes #105.
What
box restoredestroyed user state with no prompt and no--force. Thetable gave it
box,arg2— the instance is ours, a snapshot name ispresent, go — so
box restore work stale-labelsilently discardedeverything done in the box since that snapshot.
rm, on the very nextrow, has always asked.
The one-token fix the issue proposes does not work, and that is the real
finding here.
confirmwas already a precondition token, but thedispatch line hardcoded the words:
Adding
confirmtorestorewould have gated it behind a promptoffering to delete the box the operator was trying to rescue. That is
worse than no gate: it is how people learn to answer
ywithout reading.So the prompt had to become row-driven first.
How
The prompt is a seventh table field. Same
^-separated idiom, same{}/{1}substitution as the ok message, documented in the table headernext to the other fields.
{}is the resolved instance rather than thename as typed — the ok message reports on what you asked for, a prompt
about to destroy something should say which machine (so
--remoteprints
lab:work, notwork).The two rows:
confirm()wraps that intobox: <prompt>? this cannot be undone. [y/N]and into the no-TTY refusal, so
--force, the TTY check and refusingrather than assuming consent all come free — that function already had
them.
Three small consequences, kept minimal:
{}/{1}substitution was inline in the dispatch's ok branch andnow has two callers, so it is a three-line
fill(). No behaviourchange to the ok messages.
field()gainedcnf, and the two otherIFS='^' readsites gained atrailing placeholder so a six-var read cannot silently swallow the new
field into
ok.confirmwith no words dies as an internal error ratherthan asking a blank question.
rm's wording is byte-identical. It was the string that lived in thedispatch; it moved, it did not change. A test pins it verbatim, because
rewording the one verb that already worked would be a regression shipped
as a refactor.
Help.
box help restorenow documents the prompt and--force, andthe general
--forceline namesrestorealongsiderm.Not in this PR, deliberately
stoppedprecondition. #105 raises it and explicitly wants itdecided on its own rather than smuggled in with the confirm. Agreed —
it is a UX cost on the common case.
--helpnow states the situationinstead: snapshots here are stateless (nothing in
bin/orhost/passes
--stateful, nothing mentionsmigration.stateful), so arollback of a running box is crash-consistent, like a machine coming
back from losing power. No user-facing text claims otherwise.
reset_identity. #105 asks that nobody add it. Nobody did.Tests
Coverage for
restorewas two argument-validation checks(
test/cli.sh:58,:777); neither ever reached dispatch, which is whyfour releases of an ungated destructive verb shipped green. The
destructive path is now driven against a fake
incusthat logs everycall — the same shim discipline the grant/revoke blocks use:
--forceon a closed stdin exits 2, and the call logis empty — the assertion that proves the gate actually held, not
merely that it printed
and does not contain "delete work"
--forceexits 0, printsrestored work to authed, and producesexactly one
incus snapshot restore work authedrmunchanged in both directions: refusal text pinned verbatim,nothing deleted;
--forcestill reachesincus delete -f workCMDS): every row whose preconditionscontain
confirmhas a seventh field and it is non-empty — and itfails closed if it matches no rows at all
$cnf, not a constantStdin is closed on every driven run on purpose —
confirm()branches on[ -t 0 ], and a suite run from a terminal would otherwise block waitingfor a human.
The rehearsal found the one caller. CI's multi-user rehearsal drives
box restoreunattended on real Incus (drill/multiuser.sh:206) andtook the new no-TTY refusal:
56 passed, 1 failed,(b) restore failed. That is the gate working, so the fix is in the caller — it nowpasses
--force, which is the drill proving the gate is real ratherthan working around it. Deliberately not
BOX_YES=1: that variableis installer-family only and
confirm()does not honor it, by design(
bin/box:1646-1651), so the installer's ownBOX_YES=1cannot makebox rmskip its prompt.--forceis the only correct answer.The suite should have caught this and did not, so it now pins the exact
no-TTY wording too. Audited the rest of
drill/,test/,host/,install.shand.github/:multiuser.sh:206was the onlynon-interactive
box restore.drill/drill.shalready drivesbox rmwith
--forceand already asserts its no-TTY refusal.Docs swept for anything implying
rmis the only gated verb: the--forceoption line, therestorerow summary,box help restore,and README's command table and prose.
Verified on this branch:
bash test/cli.sh— 468 passed, 0 failed (was 454)bash test/release.sh— 70 passed, 0 failedshellcheck -x bin/box test/cli.sh— clean🤖 Generated with Claude Code
Verdict: I have feedback.
Blocking: update the required multi-user rehearsal for the new non-interactive contract.
drill/multiuser.sh:209still runsbox restore mine s1without--force; under the rehearsal's non-TTY execution the new confirmation gate correctly refuses it, and the currentrehearsalcheck fails withFAIL: (b) restore failed. Pass--forcethere (and keep the lifecycle assertion) so the end-to-end required check exercises the intended explicit-consent path and returns green.Verdict: I agree with everything and have no additional feedback.
Reviewed at
531803a. Approving — I verified each of the claims that matter for a destructive-action gate, and they hold.What restore destroys vs. what the prompt says.
incus snapshot restorediscards all instance state newer than the snapshot, and these snapshots are stateless (no--statefulanywhere in the tree, confirmed). The row's prompt — "roll {} back to snapshot '{1}' and discard everything in the box since it was taken" (bin/box:81) — states exactly that, names the label (the actual risk is picking the wrong one), and uses the resolved instance so--remoteprintslab:work. The issue's one-token fix would indeed have asked the operator to confirm deleting the box (confirm "delete $inst and all its snapshots"was hardcoded in the old dispatch); making the prompt a row field is the right cure, not gold-plating.Prompt fires before any destructive step. Dispatch order at
bin/box:1948-1957is resolve (a read-onlyincus config getprobe), arg check, thenconfirm, then the singleincus snapshot restorecall. No destructive work happens before or between — one gate, one action.Headless behavior. With no TTY and no
--force,confirm()(bin/box:821-828) refuses loudly viausage_error(exit 2, names both the act and the way out).--force/-fis the documented bypass — global parser atbin/box:726, documented in the--forceoption line,box help restore, and README.BOX_YESdeliberately does not bypass it (installer-family only,bin/box:1643-1651) — the right call, and the drill fix atdrill/multiuser.sh:215correctly uses--forcerather than reaching for it.Consistency with
rm. Both destructive verbs now go through the identical row-driven mechanism;rm's wording moved without changing a byte, pinned verbatim in both directions (refusal text +--forcereachingincus delete -f). A confirm row with no words dies as an internal error (bin/box:1955), and the awk table invariant fails closed if it matches no confirm rows at all.Tests are driven, not grepped. The load-bearing assertion is present: refusal leaves the fake-incus call log empty (
test/cli.sh:837),--forceproduces exactly oneincus snapshot restore work authed(test/cli.sh:850), the prompt text is pinned positively ("discard everything...") and negatively (no "delete work"), the no-TTY refusal wording is pinned (the regression test for this PR's own CI failure), and the rehearsal's explicit consent is pinned. Stdin is closed on every driven run so a terminal-run suite cannot hang — correct.Verified locally on the PR head:
bash test/cli.sh— 468 passed, 0 failed;bash test/release.sh— 70 passed, 0 failed;shellcheck -x bin/box test/cli.shclean. Matches the PR's stated numbers.One non-blocking note, for a follow-up.
confirm()'sread -r replyatbin/box:826is unguarded: Ctrl-D at a real interactive prompt makesreadreturn non-zero andset -euo pipefailends the run silently with exit 1 — no "aborted.", nothing after the question. This is exactly the pattern heavy-duty/rig#43 cured withread ... || die. It fails closed (nothing is destroyed) and it predates this PR —rmhas always had it — so it is not this change's bug and I am not blocking on it. But this PR doubled the number of verbs that reach that line; a one-tokenread -r reply || die "aborted."(and the same inuninstall_confirmatbin/box:1654) would close the family's known silent-EOF gap. The interactive abort (n) and accept (y) paths are untested for the same structural reason — they need a pty — which is acceptable here, but worth ascript-based check if one ever grows.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is.
Why this is the right shape
The one-token fix would have been worse than no gate:
confirmwas already a precondition, but dispatch hardcoded rm's "delete …" wording. Row-driven prompts (7th^field, same{}/{1}as ok) are the real fix — restore names the snapshot and the loss, not a deletion.Load-bearing pieces checked at tip
531803arestorerow:box,arg2,confirm+ promptroll {} back to snapshot '{1}' and discard everything in the box since it was takenrmwording byte-identical to the old dispatch constant (pinned in suite)fill()+confirm "$(fill "$cnf" "$inst")"—{}is resolved instance (so--remotenames the machine)cnfon a confirm row dies as internal error--forceproduces exactly onesnapshot restore; prompt must not containdelete workdrill/multiuser.shpasses--force(codex R1 closed); suite pins that lineDeliberate non-goals (
stoppedprecondition, noreset_identity) are correctly left out.Checks: unit/scope green; rehearsal was pending at review time (the contract the suite now pins).
@danmt — handoff summary. Three head-current approvals, all checks green including the real-Incus rehearsal.
What it fixes
box restoreirreversibly discarded everything since the snapshot with no prompt and no--force(#105).rmandrestoreare the two verbs that destroy user state, and only one of them asked.The reason it shipped ungated for four releases is the interesting part, and it is why this diff is larger than the issue implies.
confirmalready existed as a precondition token, but the dispatch hardcoded rm's words:So the one-token fix in the issue would have gated restore behind a prompt offering to delete the box being rescued. The prompt had to become a per-row field first. It is now a seventh
^field using the same{}/{1}idiom as the ok message, with{}bound to the resolved instance — a destruction prompt should name the machine, so--remoteprintslab:work.rm's wording moved byte-identical and is pinned verbatim in both directions. Aconfirmrow with no words is now a hard internal error, so this class cannot recur silently.Review rounds
R1 — codex, CHANGES_REQUESTED. The rehearsal was red:
drill/multiuser.shdrovebox restoreunattended, the new gate correctly refused a non-TTY invocation, and the required check failedFAIL: (b) restore failed(56 passed, 1 failed).Fixed in the caller with
--force, not by weakening the gate. Worth recording why that distinction mattered:BOX_YES=1was the tempting shortcut and would have been wrong — it is installer-family only (bin/box:1643-1651), deliberately not honored byconfirm(), because the installer exports it and must still havebox rmrefuse. Auditeddrill/,test/,host/,install.sh,.github/: that was the only non-interactivebox restorein the tree.The failure is also the strongest evidence the gate works — it proved caller-visible reach on live Incus, which the unit suite had not.
test/cli.shnow pins the exact no-TTY refusal wording as the regression test for this PR's own CI failure.R2 — codex APPROVED, claude APPROVED, grok APPROVED, all at
531803a. Rehearsal re-ran green: 57 passed, 0 failed.Verification
bash test/cli.sh— 468 passed, 0 failed (454 before, 14 added)bash test/release.sh— 70 passed, 0 failedshellcheck -x bin/box test/cli.sh— cleanTests are driven, not grepped: refusal leaves the fake-incus call log empty,
--forceproduces exactly oneincus snapshot restore work authed, and the prompt is pinned positively ("discard everything…") and negatively (must not contain "delete work"). Stdin is closed on every driven run so a terminal-run suite cannot hang.Flagged for your judgment
A pre-existing silent-EOF gap that this PR doubles the exposure of. Raised by claude-bot as explicitly non-blocking; I agree it should not block, but it should not be lost either.
confirm()'sread -r reply(bin/box:826) is unguarded. Ctrl-D at a real interactive prompt makesreadreturn non-zero, and underset -euo pipefailthe run ends silently with exit 1 — no "aborted.", nothing after the question. This is the same defect heavy-duty/rig#43 already cured withread ... || die.It fails closed (nothing is destroyed) and predates this PR —
rmhas always had it — so it is not this change's bug. But this PR takes the number of verbs reaching that line from one to two. The cure is one token in two places (confirm()anduninstall_confirm()atbin/box:1654).Your call whether that rides a follow-up issue or gets folded in here. I lean follow-up: it is a different defect with a different blast radius, and rig#43 gives it a precedent to point at.
Related: the interactive accept/abort paths (
y/n) are untested, structurally — they need a pty. Acceptable as-is, but ascript-based check would close it if one ever grows.Merge note
#109 and #110 conflict on one anchor — both insert at the top of
## Unreleased→### Fixed. Pure adjacency, no semantic overlap; keep both entries. Whichever merges second needs a rebase, and that push retires its approvals. Suggest merging #109 first, since re-validating it costs a rehearsal run and #110's diff (script + prose + tests) is cheaper to re-check.🤖 Generated with Claude Code