fix(npm): let npm run its 2FA prompt when reading trust state - #53
Conversation
`trust_state` captures npm's output with `$(...)`, but npm's trust commands need an INTERACTIVE 2FA challenge: they print "Authenticate your account at: <url>" and wait on the terminal. Captured stdout swallows that, npm fails with EOTP, and the script correctly reports `unknown` and stops. It did exactly that against the real registry on a freshly logged-in session, twice; the same command uncaptured succeeded immediately. The classification was right and the plumbing was wrong. On `unknown` the state is now re-read once through an UNCAPTURED call, so the handshake happens with stdio attached. A warm session prompts for nothing. Extracted as `trust_state_interactive` so the harness can drive it. Inline in the caller it was untestable, and that mattered: a mutation deleting the retry passed all 28 fixtures, because they stub npm out entirely and the interaction between command substitution and npm's prompt is invisible to them. Two cases now cover it — a fake npm that fails its captured reads and answers afterwards, and a warm one that answers immediately — asserting both the classification AND whether the retry ran, since "granted either way" cannot tell a working retry from one that always prompts. Both mutants are caught. The call counter lives in a file, because `$(...)` is a subshell and the first version of the test saw the retry happen and still read `unknown`. Also pins the first fixture captured from a REAL registry rather than written to match the parser: verbatim `npm trust list` output for @bugsee/cli-win32-arm64. Every other case proves the parser self-consistent; this one proves it agrees with npm. And renames a local `status`, which is read-only in zsh — harmless under the bash shebang, free to avoid. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
|
||
| # 2. Attach the trusted publisher, so npm-publish.yml's OIDC can publish it. | ||
| case "$(trust_state "$pkg")" in | ||
| state="$(trust_state_interactive "$pkg")" |
There was a problem hiding this comment.
This wraps trust_state_interactive in $(...), which recaptures the exact stdout stream the whole fix depends on leaving attached to the terminal.
Inside trust_state_interactive (line 103), on the unknown path it runs echo " auth: ..." (line 107) and then the "uncaptured" $TRUST_NPM trust list "$pkg" || true — but neither is actually uncaptured here, since the entire function's fd1 is the pipe backing this outer $(...). So on a cold session (the exact scenario this PR fixes): the "auth:" message never reaches the user, npm's own prompt/output gets swallowed into $state instead of the terminal (recreating the original EOTP bug one level up), and the final $state ends up multi-line (" auth: ...\n<npm output>\ngranted"), which no longer matches any case arm — including granted) — and falls into the *) branch, exiting with "could not classify $pkg's trust state (unexpected output)."
This is masked in the new test because ti() in test-bootstrap-names.sh explicitly does printf '%s' "$out" | tail -1 before comparing, which the real call site here doesn't do. Consider having trust_state_interactive write its "auth:" message (and let npm's own retry output go) to stderr, or otherwise avoid nesting the interactive call inside a command substitution at the top level.
Code reviewThis changes Findings: 1 inline (1 blocking)
Recommendation: fix the blocking finding before merging — as written, the retry regresses to (a variant of) the exact bug it's meant to fix on the scenario described in the PR body itself (a freshly logged-in session). |
Review of #53 caught this, and it was a regression I introduced in the same PR. Extracting `trust_state_interactive` so the harness could drive it left the caller doing state="$(trust_state_interactive "$pkg")" which recaptures the exact stdout the fix exists to leave attached to the terminal. On the cold session this PR is about, npm's prompt would be swallowed again AND `$state` would arrive as several lines matching no arm of the `case` — so it would exit "could not classify (unexpected output)" instead of just failing to authenticate. The inline version it replaced was correct. The state now comes back in `TRUST_STATE` rather than on stdout, which is the only shape that keeps an uncaptured call uncaptured. My test hid it: `ti()` took the function's stdout and stripped the last line with `tail -1`, which quietly accommodated the state arriving with npm's output glued to it — a real caller has no `tail`. It now reads `TRUST_STATE` and compares exactly, and sends stdout to a file the way the real call site sends it to the terminal. And because no amount of driving the function can check its CALL SITE, that is pinned structurally: the suite greps the script (comments stripped — both files explain the hazard using the shape being searched for) and fails if the interactive read is wrapped in a command substitution again. Three mutants caught: the reviewer's finding reintroduced verbatim, the retry deleted, and the retry always taken. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Code reviewFixes a real bug in the npm trust-bootstrap tooling: This PR only touches Findings: 0 inline (0 blocking) — None. Merge as is. |
Pushed 2026-09-16 and never opened as a PR; rebased onto current
main.The bug
trust_statecaptures npm's output with$(...), but npm's trust commands need an interactive 2FA challenge — they printAuthenticate your account at: <url>and wait on the terminal. Captured stdout swallows the prompt, npm fails withEOTP, and the script correctly reportsunknownand stops.Found against the real registry on a freshly logged-in session: it stopped twice, and the same command run uncaptured succeeded immediately. The classification was right; the plumbing was wrong.
The fix
On
unknown, re-read once through an uncaptured call so the handshake happens with stdio attached. A warm session prompts for nothing.Why it is now a separate function
Inline in the caller it was untestable, and that mattered: a mutation deleting the retry passed all 28 fixtures, because they stub npm out entirely, so the interaction between command substitution and npm's prompt is invisible to them.
trust_state_interactiveis driven by two new cases — a fake npm that fails its captured reads and answers afterwards, and a warm one that answers immediately — asserting both the classification and whether the retry ran, since "granted either way" cannot tell a working retry from one that always prompts. Both mutants (retry removed, retry always taken) are caught.The call counter lives in a file:
$(...)is a subshell, and the first version of this test watched the retry happen and still readunknown.Also
npm trust listoutput for@bugsee/cli-win32-arm64. Every other case proves the parser self-consistent; this one proves it agrees with npm.status, which is read-only in zsh. Harmless under the bash shebang, free to avoid.Release tooling only — no Rust, no wire shape.
bash npm/test-bootstrap-names.shandshellcheckboth clean.🤖 Generated with Claude Code