Skip to content

fix: prompt on stderr and gate OTP on stderr TTY - #10192

Open
ump45nose wants to merge 7 commits into
npm:latestfrom
ump45nose:fix/10190-otp-prompt-stderr
Open

ump45nose wants to merge 7 commits into
npm:latestfrom
ump45nose:fix/10190-otp-prompt-stderr

Conversation

@ump45nose

Copy link
Copy Markdown
Contributor

Fixes

Fixes #10190

What

otplease only prompted for OTP when both stdin and stdout were TTYs, and read-user-info used read's default output (process.stdout). Spawning npm with piped stdout (e.g. stdio: ['inherit', 'pipe', 'inherit']) therefore failed with bare EOTP instead of showing an interactive OTP prompt, even though the user could still see stderr and type on stdin.

Changes

  • lib/utils/auth.js: gate interactive OTP on stdin + stderr TTY (not stdout)
  • lib/utils/read-user-info.js: send prompts to process.stderr
  • test/lib/utils/auth.js: update TTY mocks; add regression for piped stdout + TTY stderr

Testing

  • tap test/lib/utils/auth.js --no-coverage — pass (incl. new piped-stdout case)
  • tap test/lib/utils/read-user-info.js --no-coverage — pass
  • Full npm test not run locally (large; relying on CI)
  • No live npm trust / OTP session in this environment; covered by unit mocks matching the reporter's TTY layout

AI disclosure

This change was prepared with AI assistance (Grok Bot / Cursor agent). I reviewed the diff, updated/ran the tests above, and believe the fix matches the issue.

Interactive auth prompts (OTP/login) wrote to stdout via `read`'s
default, and otplease refused to prompt unless stdout was a TTY. When
callers pipe stdout (stdio inherit/pipe/inherit), OTP failed with EOTP
instead of prompting even though stdin and stderr were TTYs.

- Gate otplease on stdin+stderr isTTY
- Send read-user-info prompts to process.stderr
- Cover piped-stdout + TTY stderr in auth tests

Fixes npm#10190
@ump45nose
ump45nose requested a review from a team as a code owner October 29, 2026 19:26
@ump45nose

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up: test/lib/commands/profile.js mockProfile now also mocks process.stderr.isTTY to match the OTP gate in auth.js (prompts go to stderr). Locally tap test/lib/commands/profile.js test/lib/utils/auth.js --no-coverage is green after this.

auth.js now gates OTP prompts on stdin+stderr TTYs (prompts write to
stderr). mockProfile only mocked stdin/stdout, so enable/disable-2fa
tests failed in CI with EOTP instead of prompting.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The web-auth opener still requires a stdout TTY and writes instructions there, so piping stdout can hide authentication instructions and skip browser opening while polling continues.

1 open issue
What changed in this PR

This PR addresses #10190 by allowing interactive OTP entry when npm’s stdout is piped but stdin and stderr remain terminals.

Changes:

  • Gate interactive OTP handling on stdin and stderr TTY status.
  • Default OTP and credential prompts to stderr.
  • Update TTY mocks and add a piped-stdout OTP regression test.
File Description
test/​lib/​utils/​auth.js Updates TTY mocks and tests OTP entry with piped stdout.
test/​lib/​commands/​profile.js Adds stderr TTY mocking for profile authentication tests.
lib/​utils/​read-user-info.js Defaults prompt output to stderr.
lib/​utils/​auth.js Checks stderr rather than stdout for interactive OTP support.

Review effort: Balanced

Comment thread lib/utils/auth.js
} catch (err) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
// Prompts go to stderr; allow OTP when stdout is piped but stdin+stderr are TTYs.
if (!process.stdin.isTTY || !process.stderr.isTTY) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks. Fixed in ba2f4b5: openUrlPrompt no longer requires stdout to be a TTY. It now prompts whenever stdin and stderr are TTYs, and it uses stderr as the readline output. If stdout is piped, the auth URL is also printed on stderr, so the user can still see it next to the prompt. If stdout is a TTY, nothing is repeated.

I added tests in test/lib/utils/open-url.js for three cases: stdout piped with the prompt on stderr, stdout as a TTY with no duplicate URL, and stderr not a TTY with no prompt.

(AI-assisted reply.)

The browser prompt used to require stdout to be a TTY, so piping stdout (e.g. `npm publish | tee log`) skipped it. Gate the prompt on stdin/stderr instead and repeat the auth URL on stderr when stdout is not a TTY so it stays visible.
@jeffkl
jeffkl requested a balanced review from Copilot November 4, 2026 16:10
jeffkl
jeffkl previously approved these changes Nov 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Redirecting stderr can disable terminal mode and expose typed passwords, creating a critical credential-disclosure risk.

3 open issues
1 previously missed issue

In code that hasn't changed since last review

Medium severity Write prompt completion newline to the prompt stream

lib/​utils/​read-user-info.js:17

Silent password prompts also move to stderr here, but Display.#inputHandler still terminates them with output.standard() (lib/utils/display.js:331–334). With stdout piped, the completion newline goes into the pipe while stderr remains on the password-prompt line, so subsequent stderr messages run into that prompt. Route the completion/cancellation newline to the prompt's stream as well, and cover a silent prompt with piped stdout.

Review effort: Balanced

Comment thread lib/utils/read-user-info.js Resolved
Comment thread lib/utils/open-url.js Resolved
Derive readline terminal mode from stdin so silent password prompts
stay muted when stderr is redirected. Always repeat the web-auth URL
on stderr when stdout is not a TTY, including JSON --no-browser mode.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Silent-prompt completion and cancellation still send newlines to stdout instead of stderr, leaving prompts unterminated and polluting piped output; the stream routing needs correction and a split-stream regression test.

2 open issues
2 resolved since last review

Review effort: Balanced

// Keep prompts on stderr so piped stdout still works, but derive
// terminal/echo mode from stdin so redirected stderr does not reveal
// silent password input.
output: process.stderr,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in the latest commit: silent-prompt completion and cancel newlines now go through output.error() (stderr), matching where read-user-info writes prompts. Added split-stream Display regressions that assert stdout stays empty while stderr gets the terminating newline.

Locally: tap test/lib/utils/display.js read-user-info.js open-url.js auth.js (all green).

(AI-assisted reply.)

Display was terminating silent password prompts and cancelled reads with
output.standard(), which writes to stdout. When stdout is piped that left
the stderr prompt unterminated and polluted captured command output.

Route those newlines through output.error() (stderr) and add split-stream
regressions for silent completion and cancel.
@ump45nose

Copy link
Copy Markdown
Contributor Author

Addressed the latest Copilot note on Display newlines:

  • Silent-prompt completion and cancel now write their terminating newline via output.error() (stderr), so piped stdout is not polluted and the stderr prompt line is closed.
  • Added split-stream regressions in test/lib/utils/display.js.

Locally: tap test/lib/utils/display.js read-user-info.js open-url.js auth.js.

(AI-assisted.)

@ump45nose

Copy link
Copy Markdown
Contributor Author

Pushed e0e866b to clear the Lint failure: removed the extra blank line in test/lib/utils/display.js (no-multiple-empty-lines).

(AI-assisted.)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The shared rejection handler sends cancellation newlines to stderr even for stdout prompts, leaving the visible prompt unterminated when stderr is redirected.

3 open issues

Review effort: Balanced

Comment thread lib/utils/display.js Outdated
output.standard()
// If user hits ctrl+c, terminate the prompt line on stderr
// (where read-user-info writes prompts).
output.error()
Display always wrote cancel newlines with output.error(), so stdout
prompts such as npm init and trust confirm lost their terminating
newline when stderr was redirected. Record the prompt stream on input
metadata, keep stdout for existing callers, and use stderr for auth
and OTP prompts. Completion and cancel newlines follow that stream.
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.

[BUG] interactive terminal check is flawed, and interactive prompts shouldn't go to stdout

3 participants