Uh oh!
There was an error while loading. Please reload this page.
fix(installer): a stopped Docker forced the admin path, and the copy said we were starting it - #741
Conversation
…said we were starting it
The assess branch for a down runtime printed
Docker isn't running yet — starting it, then checking your environment.
and then `return 0`. Nothing started anything. The probe found no usable
runtime, macOS classified Tier 2 — the admin-password path — and the only
`open -a Docker` in the tree sat inside install_docker_desktop, BEHIND
preflight_sudo. So the installer could only start Docker after taking an
administrator password it needed solely because Docker wasn't started.
A Mac with Docker installed but stopped therefore had no path at all for a
user who couldn't give that password, and had been told Docker was being
started. That is the state a machine is in after every reboot.
With Docker running, PROBE_RUNTIME_USABLE=1 wins first and unconditionally
in _classify_from_probes → Tier 0, where the admin gate and preflight_sudo
never run. Tier 0 (client#704) was unreachable for exactly the machines it
was written for.
_assess_handle_runtime_down now does the thing or doesn't claim it: on
macOS `open -a Docker` (a GUI launch, no privileges whatsoever), wait
bounded, and let the probe reclassify. If it doesn't come up, say so and
name the cost — the fallback needs a password. Off macOS, starting the
daemon needs root, so don't claim to be starting it.
_wait_for_docker is extracted so the nudge and install_docker_desktop share
one loop rather than growing a second one that drifts.
── the report named a healthy check ──
Stopped at .../common.sh:527 (exit 1) — command: sudo -n true
Line 527 is _real_sudo, and `sudo -n true` is the passwordless-sudo probe:
it is SUPPOSED to fail on a normal Mac, and the host check prints its
failure as a normal row ("sudo needs a password"). The real cause — "Could
not obtain administrator privileges (sudo authentication failed)" — was
never recorded, because error() calls `exit` and `exit` fires no ERR trap,
so TB_ERR_* still held the last benign probe.
A failure report that confidently names a healthy check is worse than one
that names nothing: it sends the reader to a line that is working. error()
now records its own CALLER location and message first (BASH_SOURCE[1], not
[0] — [0] would name common.sh for every refusal in the tree), with an
explicit exit code so it can't latch whatever $? happened to be.
Verified against the failing scenario end to end: the last recorded cause
goes from `cmd=sudo -n true` to `cmd=error: Could not obtain administrator
privileges (sudo authentication failed)`.
── the coverage gap that let it ship ──
preflight_sudo has four branches. Three were tested: root, no sudo binary,
passwordless. The ordinary Mac — an admin user whose sudo wants a password
— was exercised by NOTHING, because the tier-2 lifecycle tests stub
preflight_sudo out via _tier0_mocks and common.bats never covered it. Both
branches are now tested, each asserting its specific message rather than a
bare non-zero.
Mutations, each asserted to have applied:
error() stops recording -> 3 tests fail
error() records BASH_SOURCE[0] -> 1 fails
the old "starting it" copy back -> 7 fail
Three things this cost, worth recording:
- One assertion passed for the wrong reason first: assess.bats wasn't
sourcing setup-macos.sh, so `run _try_start_docker_desktop` exited 127
and a bare `-ne 0` accepted it. Now `run -1` asserts the one code.
- _try_start_docker_desktop's "not installed" branch was unreachable on any
macOS dev box, so _docker_app_installed is its own function and the test
stubs it — otherwise only CI would ever run that branch.
- A test assertion failed for an hour against a log that plainly contained
the string: bats runs a preprocessed copy (`N-common.bats.src`) and
rewrites those paths back in its OWN output, so `cat` prints something
the file does not contain. The needle is now what is really there.
Closes#740LukasWodka
commented
Aug 17, 2026
Correction — my "1102 passed, 0 failed" line was wrongI read that off a background suite run that had finished before the last round of test edits. A second, later run against the final tree exited 1, with two failures, both mine. Fixed in the follow-up commit; the PR body is updated. 1. |
Two failures from the full suite, both introduced by the previous commit and both missed because I reported a green run that had finished BEFORE the last round of test edits. scripts/manifest.sha256 carries the checksums of the installer libs this branch edits, and it is the installer's own integrity check over the files it downloads. Merging it stale would have broken verification at install time on every platform — a worse bug than the one this branch fixes. Regenerated; gen-manifest.sh --check passes. bats-hygiene flagged two `! grep -qF "LAUNCHED" <<<"$output"` lines with no `|| return 1`. Both happened to be the last statement in their test, so they did decide the result — but the rule is unconditional for a reason, and an advisory assertion is precisely the defect class this branch's tests exist to argue against. The process error is the one worth recording: I read a tail showing the last test passing and called the suite green. The suite's exit code says whether the suite passed; the last line says what ran last. Those are not the same claim, which is why the exit code exists.
…down The caller-location test passed on macOS bats 1.13 and failed on CI. The needle was the literal "common.bats", but bats executes a PREPROCESSED copy of the file whose name varies by version — and rewrites those paths back in its OWN output, so the failure message shows a path the log does not contain. That display cost an hour locally before CI cost another round. The expectation now comes from the same mechanism under test: whatever bash calls this file (BASH_SOURCE[0] at test scope) is exactly what error() must record as BASH_SOURCE[1] when called from a function defined here. True on every platform and every bats version, because both sides read the same thing rather than agreeing with a string I typed. Still mutation-proven: pointing error() back at BASH_SOURCE[0] reddens it. Manifest regenerated for the test-only change — gen-manifest --check passes.
client#736 (SS3 escape stripping) landed on develop and also touches common.sh. The code merged cleanly — both its escape handling and this branch's error() recording are present. scripts/manifest.sha256 conflicted, as it must: both branches regenerate the checksums of the libs they edit. It is a generated file, so it was regenerated over the merged tree rather than hand-resolved — hand-picking lines there would produce a manifest that matches neither branch's actual bytes, and the installer verifies itself against it.
Uh oh!
There was an error while loading. Please reload this page.
…exists for Bugbot, High, #741. _wait_for_docker and _try_start_docker_desktop called a bare `docker info`, which does not return against a WEDGED daemon — only a stopped one. Wedged is precisely the state that reaches them. _assess_runtime_down classifies runtime-down from _bounded's 124, i.e. from the bounded probe having ALREADY timed out; the new assess-time path then re-entered an unbounded probe. So it hung on the one input that routes to it, and the operator saw "starting it" followed by nothing, forever. The shape of that is worth naming: this branch exists because a message promised an action the code never took. Unbounded, it promised the action and then froze — a worse version of the same defect. _docker_answers (common.sh, beside _bounded) is now the single probe every "is the runtime up?" check routes through, defaulting to the same 10s as TB_ASSESS_DOCKER_TIMEOUT: both answer the same question about the same daemon and must not disagree. Wired into all four probes on this path, including install_docker_desktop's two, which bracket the loop extracted here and hang identically. _wait_for_docker also moves from a poll COUNT to a wall-clock deadline. Now that each probe is bounded, counting iterations would make "20 polls" mean 60s against a live daemon and ~260s against the wedged one this exists to survive. $SECONDS keeps the caller's polls*3s budget true either way. Three tests, four mutations, each anchor confirmed to apply (7 passing -> 6, 6, 4, 6). The fourth sets the bound to 0: still routed through _bounded, bound disabled — covered-looking and not covered. Two things the fix surfaced, both mine: - The first test asserted on a stub's stdout, but _docker_answers redirects its whole call to /dev/null, so the assertion was invisible either way. It passed. Now a marker file. - Bounding the probe runs it through timeout(1) as an EXTERNAL process, so a `docker() { … }` shell-function stub no longer intercepts — that broke a passing test. Worse, _bounded runs bare when neither timeout nor gtimeout exists, so such a stub works on some hosts and not others and the test's meaning depends on the machine. Stubs now sit at _docker_answers, the seam present everywhere. 16 more unbounded probes remain across preflight.sh, setup-linux.sh, diagnose.sh and setup-macos.sh's other branches, plus probe.sh hand-rolling its own copy of _bounded. Filed as #744 with a proposed check-style guard, rather than widened into this PR. Full suite green; manifest regenerated.
LukasWodka
commented
Aug 18, 2026
Fixed in 616e967 — correct finding, and a pointed one. Why it mattered more than it looks: Fix: one
Three tests, four mutations, each anchor confirmed to apply (7 passing → 6, 6, 4, 6). The fourth sets the bound to Two flaws the fix exposed in my own tests, worth recording:
Scope: 16 more unbounded probes remain ( Full suite green, manifest regenerated. |
Uh oh!
There was an error while loading. Please reload this page.
No code change. GitHub's 2026-08-17 incident (Actions in major outage from 13:40 UTC) dropped the push event for 616e967: the remote is at that sha and `actions/runs?head_sha=616e967…` reports total_count 0, while unrelated workflows on other branches ran normally half an hour ago. So it is that one event, not the platform. An empty commit rather than the alternatives, deliberately: * `--amend` + force-push would work but force-pushing needs an explicit human instruction, so it is not mine to do. * closing and reopening the PR would fire `pull_request`, but closing a PR is also not mine to do on a nudge. * `workflow_dispatch` exists on standard-checks and installer-tests but NOT on version-bump-gate-caller — and `version-bump-gate / version-check` is one of develop's eight required contexts, so dispatching would leave the PR permanently short of a required check while looking busy. A commit is the only nudge that reaches all eight.
Uh oh!
There was an error while loading. Please reload this page.
#738 and #739 landed; #738 also touches common.sh. Code merged cleanly and all three of this branch's changes are intact (_docker_answers, _assess_handle_runtime_down, error()'s BASH_SOURCE[1] record). manifest.sha256 conflicted again, as it must when two branches edit libs. Regenerated over the merged tree rather than hand-resolved. This merge is also the fix for the missing CI, and my earlier diagnosis of that was wrong. I blamed a dropped push event from the 08-17 outage. The evidence says otherwise: another PR on this repo got a full run set at 08:03 UTC, well after my 07:40 push. What my branch had that it did not was mergeable_state DIRTY — and `pull_request` workflows run against the merge ref, which GitHub cannot create for a conflicted PR, so no runs fire at all. Not a dropped event, and not something a force-push or a PR reopen would have fixed. The empty commit 72c529e was therefore a no-op against the real cause; it stays as history rather than being amended away, since amending means force-pushing.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e872350. Configure here.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
… level down
── the regression this branch introduced ──
_wait_for_docker returns non-zero on timeout, and install_docker_desktop
called it as a BARE statement under `set -e`. So the script exited there —
before the whale-icon guidance and the deliberate error() that exist
precisely for "Docker didn't come up". A silent death replacing a helpful
message, in a branch about messages that lie.
The old inline loop ended on printf/tput and so always fell through;
extracting it moved the timeout's status somewhere errexit could see. `||
true` now guards it, and the `if ! _docker_answers` below remains the
verdict. Guarded by a source-level assertion: driving install_docker_desktop
end to end would need brew, hdiutil and a DMG.
── the same defect, one layer down ──
On a Colima-only or headless Mac, _assess_handle_runtime_down announced
"starting it" and only then found there was no Docker Desktop to start —
and the failure copy went on to tell someone WITHOUT Docker Desktop to open
Docker Desktop. _docker_app_installed is now part of the condition, not just
of the nudge.
The off-macOS branch said "start it, then re-run this installer" and then
returned 0 and carried on into the privileged flow: guidance contradicting
the next thing on screen. It now names its continue, as the macOS failure
branch already did.
Both are this branch's own bug reproduced inside the fix for it. That is
worth stating plainly rather than filing under "review feedback": extracting
and re-routing a path is exactly where message-and-behaviour drift gets
reintroduced.
── the host-dependent test ──
CI failed on "won't launch an app that isn't installed": it stubbed
`docker()`, but a bounded probe runs through timeout(1) as an external
process, so on the ubuntu runner — which has a live daemon — the real probe
answered and the function returned 0 at "already up" without reaching the
branch under test. It passed here only because macOS ships no timeout(1), so
_bounded fell through to the bare call and the stub worked.
The fix is this file's existing convention, `_bounded() { shift; "$@"; }`,
which the _assess_runtime_down tests have used all along. I documented this
exact trap in #744 and in the previous commit message, then shipped it.
Verified the other way round too: local Docker is running here, so the test
now passes under the same condition that failed on CI.
Mutations, each anchor confirmed to apply (13 passing -> 12 each):
bare _wait_for_docker under set -e -> 1 fail
announce without checking the app exists -> 1 fail
drop the off-macOS "Continuing" line -> 1 fail
Full suite 1141 passed, 0 failed. Manifest regenerated, style guard clean.LukasWodka
commented
Aug 18, 2026
All four addressed in 8096970. Three were real, and worth naming rather than filing under "review feedback". The regression this branch introduced
The old inline loop ended on The same defect, one layer downBoth of the assess findings are this branch's own bug reproduced inside the fix for it:
Extracting and re-routing a path is exactly where message-and-behaviour drift gets reintroduced. Good catches. The host-dependent testCorrect, and CI had just failed on it independently. It stubbed Fixed with this file's existing convention, I had documented this exact trap in #744 and in the preceding commit message, and then shipped it anyway. VerificationMutations, each anchor confirmed to apply (13 passing → 12 each):
Full suite 1141 passed, 0 failed. Manifest regenerated, style guard clean. |
saadqbal
left a comment
There was a problem hiding this comment.
Clean — I tried to break it and could not. Verified independently on the branch rather than taking the report: shasum -a 256 -c scripts/manifest.sha256 passes, assess.bats + common.bats are 157/157 both with and without a timeout(1) on PATH (the host-dependence that bit you twice — it is genuinely gone now that the stubs sit at _docker_answers/_bounded), shellcheck --severity=error and check-style are clean, and every new path behaves identically under bash 3.2. The error() fix does exactly what the table claims: seeding TB_ERR_* with a benign sudo -n true and then refusing gives FAILED at <caller>:8 exit 1 cmd=error: Could not obtain administrator privileges — caller frame, right code, benign probe displaced.
I also checked the two places the extraction could plausibly still bite and they are fine: cmd && return 0 in _try_start_docker_desktop does not trip errexit (the left operand of an AND list is exempt), and _wait_for_docker is ERR-trap-safe in both callers because one is behind || and the other is inside an if.
Two stale-comment notes inline. Neither blocks.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Both are the defect this branch exists to remove — a statement that no longer describes the behaviour — so they belong in the same PR rather than a follow-up. ── assess.sh says STRICTLY NON-MUTATING, and this branch mutates ── `open -a Docker` plus a 60s wait is a mutation by the header's own definition, and the header still forbade it. The risk is not the stale sentence, it is which way the next reader resolves the contradiction: revert the nudge as an obvious breach, or read the ban as lifted and start the k3d cluster from `cluster-stopped` — the one mutation the header exists to forbid, and the one _assess_cluster_servers_running explains is off-limits. Restated as "non-mutating, with exactly ONE narrow exception", with the four properties that make this one admissible written down so the boundary is checkable rather than a matter of taste: unprivileged (a GUI launch, no sudo), idempotent (launching a running Docker is a no-op), bounded (a wall-clock deadline, every probe through _docker_answers), and load-bearing (without it a stopped-but-installed Docker takes Tier 2 and demands a password to start a runtime that is already installed). Plus the explicit non-consequence: this does not lift the ban, cluster-stopped still only prints. ── TB_ERR_CMD promises UNEXPANDED text, and error() stores interpolated ── The declaration said BASH_COMMAND yields `cmd "$VAR"` and never the value, "so this cannot leak a credential into the log". error() now stores "error: $*" — interpolated — and it reaches LOG_FILE twice, via _record_err and via install_cleanup's `FAILED at … command:` line. Nothing leaks today; every interpolating error() call carries paths, sizes, versions and arch names. But that comment is exactly what a future author would rely on when writing an error message, and it was false for the error() path. It now documents both producers and their different guarantees, and carries the rule forward: never interpolate a credential into an error() message — name the path, not the value. Considered making that rule a machine check, since a load-bearing claim should be one. Rejected, deliberately: the only name-shaped hit today is `error "…($cred_file)"`, a PATH — the safe pattern the comment recommends. A name-based guard would fire solely on that false positive, and silencing it needs a hand-maintained allowlist, which is the restate-the-rule defect. All noise, no signal. Comments only; no behaviour change. Full suite 1141 passed, 0 failed; manifest regenerated, style guard clean.
LukasWodka
commented
Aug 19, 2026
Thanks for going at it independently rather than reading the report back — the bash 3.2 pass and the with/without- Both notes fixed in e8cfbcb. Neither is a nit as far as I am concerned — they are the exact defect this branch exists to remove, a statement that no longer describes the behaviour, so they belong in this PR rather than a follow-up. assess.sh's non-mutating headerYou are right that the danger is not the stale sentence but which way the next reader resolves the contradiction — and "reads the ban as lifted and starts the k3d cluster from Restated as non-mutating with exactly ONE narrow exception, and I wrote down the four properties that make this one admissible so the boundary is checkable rather than a matter of taste:
Plus the explicit non-consequence you asked for: this does not lift the ban, TB_ERR_CMDAlso right, and sharper than a nit — the comment does not merely go stale, it becomes a false safety guarantee at precisely the moment someone is writing a new error message. It now documents both producers and their different guarantees (ERR trap → I considered making that a machine check, since the standard says a load-bearing claim should be one. Rejected, and worth saying why rather than leaving it silent: the only name-shaped hit across all 58 interpolating Comments only, no behaviour change. Full suite 1141 passed, 0 failed; manifest regenerated, style guard clean. |
saadqbal
left a comment
There was a problem hiding this comment.
Both notes addressed, and better than I asked for. Carving the exception out by its four properties — unprivileged, idempotent, bounded, load-bearing — and then explicitly re-stating that cluster-stopped is still banned is the version that survives the next reader; “if you are adding a second exception, question whether this file is still the right place” is the right guard rail. Same for TB_ERR_CMD: naming the two producers and turning the lost guarantee into a rule (never interpolate a credential, say “the credential file at $path”) is more useful than the blanket claim it replaces.
Re-verified at e8cfbcb: the diff is comment-only, manifest -c passes, assess.bats + common.bats 157/157, shellcheck --severity=error and check-style clean.
One cosmetic slip from the edit itself, inline. Not blocking.
Uh oh!
There was an error while loading. Please reload this page.
saadqbal
commented
Aug 19, 2026
Agreed on both, and your reasoning on the guard is right as far as it goes — I checked it rather than taking it: 59 interpolating One shape does answer the allowlist objection though, if you want it: exempt the safe pattern in the rule rather than in a list — match credential-ish names but drop anything ending Zero hits on the tree at e8cfbcb, and it fires on |
LukasWodka
commented
Aug 19, 2026
/fr-pass Advanced at @LukasWodka's explicit direction. Held in the automated pass for a verification gap (interactive/TTY, GPU-only, or journey-dependent while e2e journey is red — backend#2206); Lukas is accepting that gap for this card. |
Resolves the manifest.sha256 + BUGBOT.md conflicts from develop (#832/#842), and folds in both CHANGES_REQUESTED findings: - HIGH: `_bounded … sudo docker info` execs the real `sudo` binary via `timeout`, bypassing common.sh's root-aware `sudo()` shadow — on the root-run --prepare-host path (RFC 0001, often no sudo binary) a live daemon read as dead. New `_bounded_root` (id-0 → bare, else real sudo) at the two prepare-host probes; unit-tested in common.bats. - MEDIUM: rule 5 missed `docker info # comment` (no redirect) — added `#` to the follow-set; new bare-comment fixture reddens it. Discriminator kept (whole-line comments still filtered; no exempt mention has `#` as its next char). - Exempt _docker_answers_bounded's background-PID probe (common.sh) — it is bounded by spin's deadline, not lexically by _bounded, so rule 5 can't see it (style-guard: allow). - Harmonize BUGBOT.md: keep develop's macOS-coreutils-trap rule, add the rule-5 gate, the #741 test trap, and the _bounded_root/sudo-shadow lesson. - Clean setup-linux.bats id() mocks to answer `id -u` numerically (else _bounded_root's root check errored into the else-branch — passing for the wrong reason). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the next (#744) (#844) * fix(installer): bound every remaining 'docker info' probe, and guard the next (#744) A bare `docker info` never returns against a WEDGED daemon — the exact state that lands a machine in the probes that check for it. #741 bounded the four on the assess-time path; this bounds the 16 remaining in scripts/lib/ and stops the class recurring. - Route every `docker info` through the common.sh seam: `_docker_answers` (bounded yes/no) for the liveness checks (setup-macos ×4, setup-linux ×1, preflight ×3), `_bounded` for the probes that read a value (preflight ×3, setup-linux ×2 incl. the two `sudo docker info`, diagnose ×1). - probe.sh: `_probe_runtime_usable` calls `_bounded` instead of hand-rolling the same timeout/gtimeout/bare fallback a second time (keeps its 5s cap). - check-style.sh rule 5: fail CI on any unbounded `docker info` under scripts/lib/, encoding Bugbot's learned rule as a gate (mutation-proven both ways in check-style.bats). .cursor/BUGBOT.md records it. - Tests: preflight/diagnose setups shadow timeout/gtimeout so the bounded probe's external `timeout` can't bypass the docker() mock (the #741 trap). - Regenerate scripts/manifest.sha256 (bootstrap integrity surface over the five edited libs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(installer): bound the macOS-reachable docker probes coreutils-free (#744) Bugbot High on abef442: setup-macos's 4 probes and preflight's runtime readers called `_docker_answers`, which bounds through `_bounded` — a no-op on a stock Mac (no timeout/gtimeout), so a wedged Docker Desktop still hung there, defeating the fix on the platform it targets. Route them through `_docker_answers_bounded` (background-PID + spin deadline, coreutils-free, per backend#2521): - setup-macos.sh ×4 (_kill_lingering_docker, the two Colima liveness checks, the headless-Mac branch) — visible spinner, matching the colima-stop recovery site. - preflight.sh ×3 (_pf_runtime_mem_kb / _pf_runtime_ncpu / _pf_docker_root) — the liveness guard is silenced (>/dev/null): these readers' stdout is captured, and a passing bounded guard proves the daemon responsive so the --format read can't hang. Left as-is (not flagged, and both need output-capture a yes/no probe can't give): diagnose's bundle read and probe.sh's Tier-0 check — the latter unchanged from its pre-existing timeout/gtimeout form. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(installer): bound the --diagnose docker-info read coreutils-free (#744) Bugbot High on 7aee2fe: the support-bundle `docker info` still went through `_bounded`, a no-op bound on a stock Mac — so `--diagnose` (a Darwin-reachable path) hung forever on a wedged daemon, from a machine already broken. Gate it behind the same coreutils-free `_docker_answers_bounded` liveness probe preflight uses (silenced with >/dev/null so its spinner stays out of the bundle file); once the daemon is proven responsive the piped read can't hang. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(installer): bound the host-audit runtime probe coreutils-free (#744) Bugbot High on afeb9ec: `_probe_runtime_usable` routed `docker info` through `_bounded`, a no-op bound on a stock Mac — and host_audit runs run_host_probes on EVERY install (macOS included), so this is the FIRST thing to freeze at "Checking your machine" on a wedged Docker Desktop, before _kill_lingering_docker can run. The preflight/diagnose gates added earlier make that hang the reachable one. Route it through `_docker_answers_bounded` (background-PID + spin deadline, coreutils-free) with the probe's own 5s TB_PROBE_TIMEOUT as the deadline arg — Bugbot's own note: it takes a seconds argument, so the 5s cap needs no `_bounded`. Silenced (>/dev/null): the rc is the verdict, the host-audit panel says the rest. probe.bats: the three `_bounded`/timeout-mechanics tests are rewritten to the new mechanism — one asserts the 5s cap reaches `_docker_answers_bounded`, one proves the probe still works with no timeout/gtimeout on PATH, one keeps the never-fatal contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

Closes#740.
The trap
A Mac with Docker Desktop installed but not running — the state every machine is in after a reboot — could not be set up by a user who couldn't give an administrator password. And the installer told them it was starting Docker.
assess.shprinted "Docker isn't running yet — starting it" and fell through toreturn 0. Nothing started anything.preflight_sudoasked for the password. Dead end.The circularity is the bug: the only
open -a Dockerin the tree lives ininstall_docker_desktop, behindpreflight_sudo. The installer could only start Docker after taking a password it needed solely because Docker wasn't started.With Docker running,
PROBE_RUNTIME_USABLE=1wins first and unconditionally in_classify_from_probes→ Tier 0, where the admin gate andpreflight_sudonever run. Tier 0 (client#704) was unreachable for exactly the machines it was written for.The fix
_assess_handle_runtime_downeither does the thing or doesn't claim it:open -a Docker(a GUI launch, no privileges whatsoever), wait bounded, let the probe reclassify to Tier 0. If it doesn't come up: say so, and name the cost — the fallback needs a password. That sentence is the one the old copy hid._wait_for_dockeris extracted so the nudge andinstall_docker_desktopshare one loop instead of growing a second that drifts.The report named a healthy check
Line 527 is
_real_sudo;sudo -n trueis the passwordless-sudo probe, which is supposed to fail on a normal Mac — the host check prints its failure as a normal row ("sudo needs a password"). The real cause was never recorded, becauseerror()callsexit, andexitfires no ERR trap, soTB_ERR_*still held the last benign probe.A report that confidently names a working line is worse than one that names nothing.
error()now records its own caller location and message first —BASH_SOURCE[1], not[0], which would namecommon.shfor every refusal in the tree — with an explicit exit code so it can't latch whatever$?happened to be.Verified end-to-end against the failing scenario:
cmd=sudo -n truecmd=error: Could not obtain administrator privileges (sudo authentication failed)The coverage gap that let it ship
preflight_sudohas four branches. Three were tested — root, no sudo binary, passwordless. The ordinary Mac — an admin user whose sudo wants a password — was exercised by nothing: the tier-2 lifecycle tests stubpreflight_sudoout via_tier0_mocks, andcommon.batsnever covered it. Both branches are now tested, each asserting its specific message rather than a bare non-zero.Tests
Full suite: 1102 passed, 0 failed (see the correction comment — my first report of this was read off a stale background run that exited 1; two failures, both mine, are fixed in the follow-up commit).
shellcheck --severity=errorandcheck-style.shclean.scripts/manifest.sha256is regenerated in this PR: it carries the checksums of the installer libs edited here and is the installer's own integrity check over what it downloads, so a stale one breaks verification at install time.Mutations, each asserted to have applied:
error()stops recordingerror()recordsBASH_SOURCE[0]Three things this cost, worth recording
assess.batswasn't sourcingsetup-macos.sh, sorun _try_start_docker_desktopexited 127 (command not found) and a bare-ne 0accepted it — the bare-assertRaisestrap. Nowrun -1asserts the one code._try_start_docker_desktop's "not installed" path can't be hit on a machine that has Docker — i.e. the machine of whoever changes this code._docker_app_installedis now its own function and the test stubs it, so it isn't a CI-only branch.N-common.bats.src) and rewrites those paths back in its own output — socatprinted something the file does not contain, and every assertion written against what I saw on screen failed. The needle is now what is actually there, with a comment saying why.Workaround until this ships
Start Docker Desktop, wait for "running", re-run. The installer then takes Tier 0 and never asks for a password.
🤖 Generated with Claude Code
Note
Medium Risk
Changes first-run classification and macOS Docker startup before the admin gate, plus fatal-error logging; scope is installer shell only with heavy test coverage, but misclassification or a wedged-daemon edge case could still affect real installs.
Overview
Fixes the case where Docker Desktop is installed but stopped on macOS: the assess gate used to print that it was “starting” Docker while doing nothing, then the probe still saw no runtime and the install fell into Tier 2 (admin password)—even though the only
open -a Dockerlived behind that same prompt.Runtime-down handling adds
_assess_handle_runtime_down: on Darwin, when Docker.app is present, it calls_try_start_docker_desktop(GUIopen -a Docker, up to ~60s, bounded probes). Messaging either matches success or admits failure and that continuing may need an administrator password; non-macOS and Macs without Docker Desktop no longer claim to start anything.Shared Docker liveness centralizes checks in
_docker_answers(boundeddocker info) and_wait_for_docker(wall-clock deadline); install-time Docker wait is guarded with|| trueso a timeout does not abort underset -ebefore user-facing guidance.Failure reporting:
error()records the caller site and message via_record_errbeforeexit, so install logs no longer blame the last benignsudo -n trueprobe when sudo auth actually failed.Bats coverage for runtime-down copy, bounded routing,
error()/preflight_sudo, and updatedmanifest.sha256checksums for touched installer libs.Reviewed by Cursor Bugbot for commit e8cfbcb. Bugbot is set up for automated code reviews on this repo. Configure here.