Skip to content

fix(npm): let npm run its 2FA prompt when reading trust state - #53

Merged
krassx merged 2 commits into
mainfrom
fix/trust-state-interactive-auth
Sep 19, 2026
Merged

krassx merged 2 commits into
mainfrom
fix/trust-state-interactive-auth

Conversation

@krassx

@krassx krassx commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Pushed 2026-09-16 and never opened as a PR; rebased onto current main.

The bug

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 the prompt, npm fails with EOTP, and the script correctly reports unknown and 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_interactive is 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 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.
  • Renames a local 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.sh and shellcheck both clean.

🤖 Generated with Claude Code

`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)
Comment thread npm/bootstrap-names.sh Outdated

# 2. Attach the trusted publisher, so npm-publish.yml's OIDC can publish it.
case "$(trust_state "$pkg")" in
state="$(trust_state_interactive "$pkg")"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code review

This changes npm/bootstrap-names.sh to retry trust_state with an "uncaptured" npm call when the classification comes back unknown, so npm's interactive 2FA prompt can reach the terminal instead of being swallowed by $(...). Good instinct and well-documented reasoning, but the fix doesn't actually reach the terminal in the real call path — see the inline comment.

Findings: 1 inline (1 blocking)

  • npm/bootstrap-names.sh:268 — state="$(trust_state_interactive "$pkg")" wraps the whole retry function in a command substitution, which recaptures the exact stdout the fix is trying to leave attached to the terminal. On the cold-session path this PR targets, npm's prompt/output (and the new "auth:" diagnostic) get swallowed into $state instead of shown to the user, and the resulting multi-line value no longer matches the case statement's granted) arm — it falls into the *) branch and exits with "could not classify... (unexpected output)". The new unit test doesn't catch this because ti() strips the extra line with tail -1 before comparing, a workaround the real call site doesn't have.

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)
@claude

claude Bot commented Sep 19, 2026

Copy link
Copy Markdown

Code review

Fixes a real bug in the npm trust-bootstrap tooling: trust_state captured npm's stdout, which swallowed the interactive 2FA prompt npm's trust commands need, causing a warm-but-uncaptured session to misreport unknown. The fix adds trust_state_interactive, which retries once with an uncaptured call so the handshake can complete, then re-reads via trust_state. The new tests correctly drive both the "cold session, retry rescues it" and "warm session, no retry" paths, verify the retry actually ran (not just the final classification), and pin a real-registry fixture. The call site correctly reads the result from the TRUST_STATE global rather than via $(...), and a structural grep test in test-bootstrap-names.sh guards against a future call site recapturing that output. Docs in npm/README.md were updated in the same change to describe the new retry behavior.

This PR only touches npm/ shell tooling — no Rust source, CLI surface, exit codes, wire shapes, or Cargo.toml — so none of the CLAUDE.md contract categories (--help drift, stdout purity, exit-code stability, wire shapes, daemonizing, MSRV, cross-platform) apply here. I traced through the retry logic, the flaky_npm test fixture's call-count arithmetic, and the structural guard regex, and didn't find a correctness issue.

Findings: 0 inline (0 blocking) — None.

Merge as is.

@krassx
krassx merged commit 83ef224 into main Sep 19, 2026
20 checks passed
@krassx
krassx deleted the fix/trust-state-interactive-auth branch September 19, 2026 13:16
@krassx krassx mentioned this pull request Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant