From b3fa94a7d4e098c7cd6059856d90656f907c8449 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 23:36:32 +0000 Subject: [PATCH 1/2] fix: teardown-host refuses a terminal-less run instead of aborting mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit host/teardown-host.sh had no `[ -t 0 ]` check before its confirmation prompt. Without --yes/BOX_YES and without a terminal — CI, a pipe, a nohup — it fell into `read`, took the instant EOF and exited 1 saying only "aborted": a refusal naming neither the cause nor the override, in the most destructive script in the tree. It now refuses with the override named, exit 2 — "you invoked this wrong", matching host/revoke-user.sh --purge and install.sh's confirm(), versus 1 for "you were asked and you said no". The gate sits below the --yes/BOX_YES arm, so consent given non-interactively still runs headless, and above the first incus call, so the refusal needs no daemon — which is what lets test/cli.sh drive it for real instead of grepping for it. Refs #113 --- CHANGELOG.md | 15 +++++++++++++++ host/teardown-host.sh | 12 ++++++++++++ test/cli.sh | 5 +++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5cc1dc..4ec42f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,21 @@ which records not just what changed but what each drill run proved. wrong default: it is the only thing to roll back *to*, at exactly the moment that matters. No behaviour change. +- **`teardown-host.sh` refuses a terminal-less run instead of aborting mute** + (#113) — the most destructive script in the tree was the one that would not + tell a non-interactive caller how to proceed. With no `--yes`/`BOX_YES` and + no TTY it fell straight into `read`, took the instant EOF and exited 1 + saying only `aborted` — a refusal that names neither the cause nor the + override. It now checks `[ -t 0 ]` and refuses with + `--yes (or BOX_YES=1) means yes.`, exit **2** — "you invoked this wrong", + the same contract and the same code as `host/revoke-user.sh --purge` and + `install.sh`'s `confirm()`, versus 1 for "you were asked and you said no". + The gate sits *below* the `--yes`/`BOX_YES` arm, so consent given + non-interactively still runs headless — CI's uninstall drill and + `box uninstall --purge-host --force` forward `--yes` and are unaffected — + and *above* the first `incus` call, so the refusal costs no daemon and + `test/cli.sh` drives it for real rather than grepping for it. + ## 0.8.0 — 2026-07-19 ### Added diff --git a/host/teardown-host.sh b/host/teardown-host.sh index a3fdce0..3d6c978 100755 --- a/host/teardown-host.sh +++ b/host/teardown-host.sh @@ -28,6 +28,18 @@ $purge && echo "Incus itself will also be uninstalled (--purge-incus)." if [ "$yes" -eq 1 ]; then echo "(confirmed non-interactively: --yes/BOX_YES)" else + # No terminal to ask on, and no consent given: refuse and say how to proceed, + # rather than fall into 'read', hit instant EOF and abort with nothing but + # "aborted" (#113). This must stay BELOW the --yes/BOX_YES arm above — the + # order is the contract: consent given non-interactively still runs headless + # (CI's uninstall drill and 'box uninstall --purge-host --force' depend on + # it), consent NOT given without a terminal is a usage error, exit 2, the + # same shape as host/revoke-user.sh and install.sh. It also lands before the + # first 'incus' call below, so the refusal needs no daemon. + if [ ! -t 0 ]; then + echo "teardown-host: refusing to run without a terminal to confirm on. --yes (or BOX_YES=1) means yes." >&2 + exit 2 + fi # EOF (Ctrl-D) refuses, out loud: unguarded, errexit would end the run on # this line and the 'aborted' below would never print (#111). read -rp "Continue? [y/N] " a || { echo "aborted"; exit 1; } diff --git a/test/cli.sh b/test/cli.sh index c10154b..a11e150 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -1993,6 +1993,11 @@ 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" +# ...and the other side of that contract (#113): consent NOT given and no +# terminal to ask on is a usage error, not a mute 'aborted'. Driven for real — +# the gate sits above the first 'incus' call, so a daemon-free run reaches it. +check "teardown-host: refuses without a TTY and names the override (#113)" 2 \ + "--yes (or BOX_YES=1) means yes" bash "$ROOT/host/teardown-host.sh" Date: Sun, 19 Jul 2026 23:57:19 +0000 Subject: [PATCH 2/2] test: the teardown check must not inherit BOX_YES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new check drives host/teardown-host.sh for real — the first check in the suite to execute a destructive script rather than grep it or run it against shims. teardown-host honours an inherited BOX_YES, and test/cli.sh never sanitised it, so under `BOX_YES=1 bash test/cli.sh` the invocation took the consent arm instead of the gate: a real teardown of the host the suite was running on, reported red only afterwards. BOX_YES is the documented automation switch for this family, so an operator's CI wrapper exporting it is the expected case, not a contrived one. The file header promises the suite is runnable anywhere; before this the promise held under any environment. Fixed at the call site with `env -u BOX_YES`, plus a file-level `unset` so the promise cannot be broken again by the next check that drives something destructive. Refs #113 --- test/cli.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/cli.sh b/test/cli.sh index a11e150..7983e43 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -7,6 +7,13 @@ # a fixture, or grep the load-bearing line so a deleted guard cannot ship green. # Deliberately no `set -e` — the harness asserts on failing commands. set -u +# BOX_YES is this family's documented automation switch, so an operator's CI +# wrapper may well export it. Checks that drive a destructive script for real +# would then take the CONSENT arm instead of the refusal they are asserting — +# turning this suite into `box uninstall --purge-host` on the host it runs on. +# Individual call sites use `env -u BOX_YES`; this is the belt to that braces, +# so the header's "runnable anywhere" promise cannot be broken by one export. +unset BOX_YES ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PASS=0 FAIL=0 @@ -1997,7 +2004,7 @@ check "teardown-host: points at box uninstall when done" 0 "" \ # terminal to ask on is a usage error, not a mute 'aborted'. Driven for real — # the gate sits above the first 'incus' call, so a daemon-free run reaches it. check "teardown-host: refuses without a TTY and names the override (#113)" 2 \ - "--yes (or BOX_YES=1) means yes" bash "$ROOT/host/teardown-host.sh"