From b6dba04d992e88785e1cad5061bf2d5a0276f2b2 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 22 Sep 2026 14:08:08 +0800 Subject: [PATCH 1/6] fix(onboard): cancel on stdin EOF typed between prompts Interactive prompts own stdin only while a question is outstanding, so a Ctrl-D that reaches the terminal between two questions is still queued when the next prompt starts. readline switches the TTY to raw mode before its first read and the queued end-of-file indication does not survive that mode change, so the configuration-review prompt waited for an answer that could never arrive: onboarding hung at `Choose [1]:` without printing `Installation cancelled` or exiting non-zero. Prompts that carry the EOF-cancellation contract now read stdin for one queued-read window before handing it to readline or a raw-mode reader. A pending EOF surfaces as a normal stream end and becomes the documented cancellation; type-ahead is pushed back so the prompt still answers with it. Non-TTY stdin keeps today's exact path and timing. Fixes #12169 Co-Authored-By: Claude Opus 5 Signed-off-by: Yanyun Liao --- ci/source-architecture-budget.json | 2 +- docs/reference/commands.mdx | 2 + src/lib/core/pending-stdin-eof.test.ts | 65 +++++++++++++ src/lib/core/pending-stdin-eof.ts | 91 +++++++++++++++++++ src/lib/credentials/store.ts | 38 +++++++- src/lib/onboard/messaging-selector.ts | 38 ++++++-- src/lib/policy/index.ts | 22 +++-- test/credentials/credentials.test.ts | 11 ++- .../onboard/onboard-stdin-eof.test.ts | 82 +++++++++++++++++ 9 files changed, 333 insertions(+), 18 deletions(-) create mode 100644 src/lib/core/pending-stdin-eof.test.ts create mode 100644 src/lib/core/pending-stdin-eof.ts diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 858e6ae34fc..b9f68104fa4 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -56,7 +56,7 @@ "src/lib/onboard.ts": 192, "src/lib/onboard/sandbox-gpu-create-run-attempt.ts": 20, "src/lib/onboard/machine/handlers/sandbox.ts": 21, - "src/lib/policy/index.ts": 22, + "src/lib/policy/index.ts": 23, "src/lib/sandbox/config.ts": 19, "src/lib/state/sandbox.ts": 21, "scripts/checks/run-managed-image-openshell-e2e.ts": 18 diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 1655c9d9a01..37b56b77484 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -870,6 +870,8 @@ If you enable Telegram during onboarding, the wizard can also prompt for whether +Closing stdin during onboarding cancels the run: NemoClaw prints `Installation cancelled`, exits non-zero, and keeps the resumable onboarding session so `nemoclaw onboard --resume` can continue. This holds whether you press `Ctrl-D` while a question is on screen or while the wizard is working between questions, and whether stdin is a terminal or a harness pipe that closes. + If you cancel a brand-new onboarding run at the policy-tier selector or either policy-preset selector after sandbox creation, NemoClaw preserves the incomplete sandbox, registry entry, and onboarding session for retained recovery. NemoClaw reports the durable sandbox identity fingerprint when it is available. It does not run OpenShell's mutable-name deletion command because the name may now identify a replacement sandbox. diff --git a/src/lib/core/pending-stdin-eof.test.ts b/src/lib/core/pending-stdin-eof.test.ts new file mode 100644 index 00000000000..de063e0d49c --- /dev/null +++ b/src/lib/core/pending-stdin-eof.test.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; + +import { takePendingStdinEof } from "./pending-stdin-eof"; + +type PromptInput = PassThrough & { isTTY?: boolean }; + +function makeInput(isTTY: boolean | undefined): PromptInput { + const input = new PassThrough() as PromptInput; + input.isTTY = isTTY; + input.pause(); + return input; +} + +describe("takePendingStdinEof", () => { + it("returns false synchronously for piped stdin so prompt timing is unchanged", () => { + expect(takePendingStdinEof(makeInput(false), 10)).toBe(false); + expect(takePendingStdinEof(makeInput(undefined), 10)).toBe(false); + }); + + it("reports the cancellation when stdin ends during the window (#12169)", async () => { + const input = makeInput(true); + const pending = takePendingStdinEof(input, 50); + input.end(); + + await expect(pending).resolves.toBe(true); + }); + + it("reports the cancellation when stdin closes without ending", async () => { + const input = makeInput(true); + const pending = takePendingStdinEof(input, 50); + input.emit("close"); + + await expect(pending).resolves.toBe(true); + }); + + it("reports no cancellation for a quiet terminal and leaves stdin paused", async () => { + const input = makeInput(true); + + await expect(takePendingStdinEof(input, 5)).resolves.toBe(false); + expect(input.isPaused()).toBe(true); + }); + + it("hands type-ahead back so the prompt still answers with it", async () => { + const input = makeInput(true); + const pending = takePendingStdinEof(input, 50); + input.write("2\n"); + + await expect(pending).resolves.toBe(false); + expect(input.read()?.toString()).toBe("2\n"); + }); + + it("stops listening once it settles so the prompt owns stdin alone", async () => { + const input = makeInput(true); + + await takePendingStdinEof(input, 5); + + expect(input.listenerCount("data")).toBe(0); + expect(input.listenerCount("end")).toBe(0); + expect(input.listenerCount("close")).toBe(0); + }); +}); diff --git a/src/lib/core/pending-stdin-eof.ts b/src/lib/core/pending-stdin-eof.ts new file mode 100644 index 00000000000..b523c01886a --- /dev/null +++ b/src/lib/core/pending-stdin-eof.ts @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** How long to let a queued terminal read complete before a prompt starts. */ +export const PENDING_STDIN_WINDOW_MS = 10; + +interface PendingStdinInput extends NodeJS.ReadableStream { + isTTY?: boolean; +} + +/** + * Recover a Ctrl-D that reached the terminal while no prompt was reading. + * + * Interactive prompts own stdin only while a question is outstanding: each + * prompt pauses stdin when it settles so onboarding steps between questions + * (and any child process sharing the terminal) are not competing for input. + * A Ctrl-D typed inside one of those gaps — the reporter's "close stdin" + * while the Ollama model loads and the configuration summary prints — stays + * queued in the terminal, because nothing is reading it yet. + * + * The next prompt never sees it. `readline.createInterface()` switches a TTY + * to raw mode before its first read, and the queued end-of-file indication + * does not survive that mode change; in raw mode the byte would no longer + * mean EOF either. The prompt therefore waits for an answer that can never + * arrive: `nemoclaw onboard` hangs at `Choose [1]:` instead of reporting the + * cancellation and exiting non-zero (#12169). A Ctrl-D typed while the + * question is on screen is unaffected — that one reaches readline directly. + * + * Reading stdin for one queued-read window before the prompt takes over + * surfaces the pending EOF as a normal stream `end`. Anything that is real + * input instead (type-ahead) is pushed back so the prompt still answers with + * it. Only a TTY needs this: a pipe or `< /dev/null` reports its EOF through + * the same stream end whenever the prompt gets around to reading, so those + * callers are left completely untouched. + * + * Returns `false` — synchronously, not a promise — when there is nothing to + * recover, so every non-TTY caller keeps today's exact prompt timing and only + * a real terminal pays the window. Otherwise resolves `true` when stdin ended + * during the window, meaning the caller must cancel instead of asking its + * question. + */ +export function takePendingStdinEof( + input: PendingStdinInput = process.stdin, + windowMs: number = PENDING_STDIN_WINDOW_MS, +): Promise | false { + if (input.isTTY !== true) return false; + return new Promise((resolve) => { + const chunks: Array = []; + let settled = false; + + function onData(chunk: string | Uint8Array): void { + chunks.push(chunk); + } + + function finish(ended: boolean): void { + if (settled) return; + settled = true; + clearTimeout(timer); + input.removeListener("data", onData); + input.removeListener("end", onEnd); + input.removeListener("close", onEnd); + if (typeof input.pause === "function") input.pause(); + // Restore type-ahead in arrival order. After `end` the stream rejects + // unshift(), and the buffered text is moot anyway: the caller cancels. + if (!ended) { + for (let index = chunks.length - 1; index >= 0; index -= 1) { + input.unshift(chunks[index]); + } + } + resolve(ended); + } + + function onEnd(): void { + finish(true); + } + + // Deliberately ref'd: an unref'd window would let the process exit + // mid-prompt, which is the silent exit-0 shape of this very bug. + const timer = setTimeout(() => finish(false), windowMs); + input.on("data", onData); + // Both settle the prompt readers, so both count as a closed stdin here. + input.once("end", onEnd); + input.once("close", onEnd); + if (typeof input.resume === "function") input.resume(); + }); +} + +/** The rejection every prompt uses for a stdin EOF that arrived before an answer. */ +export function pendingStdinEofError(): Error { + return Object.assign(new Error("Prompt closed before input"), { code: "EOF" }); +} diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index d2010c75661..841bc8d73f5 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -14,6 +14,7 @@ import path from "node:path"; import readline from "node:readline"; import { isErrnoException } from "../core/errno"; +import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; import { GATEWAY_PORT } from "../core/ports"; import { createPromptActivityCleanup } from "../core/prompt-activity"; import { listMessagingCredentialMetadata } from "../messaging/channels"; @@ -530,6 +531,17 @@ export function removeLegacyCredentialsFileIfEmpty(): boolean { * rejects with `code: "SIGINT"` on Ctrl-C. */ export function promptSecret(question: string, maskCap?: number): Promise { + // A Ctrl-D typed before this prompt started would otherwise be dropped by + // the raw-mode switch below and the read would never settle (#12169). + const pending = takePendingStdinEof(); + if (pending === false) return readSecretAnswer(question, maskCap); + return pending.then((ended) => { + if (ended) throw pendingStdinEofError(); + return readSecretAnswer(question, maskCap); + }); +} + +function readSecretAnswer(question: string, maskCap?: number): Promise { return new Promise((resolve, reject) => { const input = process.stdin; const output = process.stderr; @@ -581,7 +593,7 @@ export function promptSecret(question: string, maskCap?: number): Promise { + // Re-attach stdin before the pending-input window: takePendingStdinEof() + // reads the terminal, and a detached handle would not deliver that read. + if (typeof process.stdin.ref === "function") { + process.stdin.ref(); + } + // Cancellation that reached the terminal between questions belongs to this + // prompt: readline would drop it when it switches the TTY to raw mode and + // then wait forever for an answer (#12169). + const pending = takePendingStdinEof(); + if (pending === false) return askQuestion(question, opts); + return pending.then((ended) => { + if (ended) throw pendingStdinEofError(); + return askQuestion(question, opts); + }); +} + +function askQuestion( + question: string, + opts: { secret?: boolean; maskCap?: number }, ): Promise { return new Promise((resolve, reject) => { // Re-attach stdin to the event loop before any prompt path. unref() in @@ -714,7 +746,7 @@ export function prompt( } const silent = opts.secret === true && process.stdin.isTTY && process.stderr.isTTY; if (silent) { - promptSecret(question, opts.maskCap) + readSecretAnswer(question, opts.maskCap) .then(resolve) .catch((error: NodeJS.ErrnoException) => { if (error && error.code === "SIGINT") { @@ -771,7 +803,7 @@ export function prompt( // post-answer close is ignored and only a premature EOF rejects here. rl.on("close", () => { if (finished) return; - rejectPrompt(Object.assign(new Error("Prompt closed before input"), { code: "EOF" })); + rejectPrompt(pendingStdinEofError()); }); rl.question(question, (answer) => { resolvePrompt(answer.trim()); diff --git a/src/lib/onboard/messaging-selector.ts b/src/lib/onboard/messaging-selector.ts index fb2270dbb49..2e28ff754e6 100644 --- a/src/lib/onboard/messaging-selector.ts +++ b/src/lib/onboard/messaging-selector.ts @@ -3,6 +3,7 @@ import readline from "node:readline"; +import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; import { markPromptActive } from "../core/prompt-activity"; export interface MessagingChannelSelectorEntry { @@ -151,6 +152,21 @@ export function readMessagingChannelSelection, showList: () => void, +): Promise { + // This menu switches the TTY to raw mode too, so a Ctrl-D typed before it + // opened would be dropped and the menu would never settle (#12169). + const pending = takePendingStdinEof(); + if (pending === false) return readChannelKeys(availableChannels, enabled, showList); + return pending.then((ended) => { + if (ended) throw pendingStdinEofError(); + return readChannelKeys(availableChannels, enabled, showList); + }); +} + +function readChannelKeys( + availableChannels: readonly T[], + enabled: Set, + showList: () => void, ): Promise { return new Promise((resolve, reject) => { const input = process.stdin as MessagingSelectorInput; @@ -212,7 +228,7 @@ export function readMessagingChannelSelection { - return new Promise((resolve, reject) => { - if (typeof process.stdin.ref === "function") { - process.stdin.ref(); - } + if (typeof process.stdin.ref === "function") { + process.stdin.ref(); + } + // Cancellation typed between questions would be lost to readline's + // raw-mode switch and the prompt would never settle (#12169). + const pending = takePendingStdinEof(); + if (pending === false) return readMessagingSelectorLine(question); + return pending.then((ended) => { + if (ended) throw pendingStdinEofError(); + return readMessagingSelectorLine(question); + }); +} +function readMessagingSelectorLine(question: string): Promise { + return new Promise((resolve, reject) => { // Hold background heartbeat output while this prompt owns the terminal; // released in cleanup() on every settle path. (#6651) const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); @@ -317,7 +343,7 @@ function promptMessagingSelectorLine(question: string): Promise { }); rl.on("close", () => { if (finished) return; - rejectPrompt(Object.assign(new Error("Prompt closed before input"), { code: "EOF" })); + rejectPrompt(pendingStdinEofError()); }); rl.question(question, resolvePrompt); } catch (error) { diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 0ffa894e487..9889c1933a3 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -25,6 +25,7 @@ export { isPolicyObservationError } from "../adapters/openshell/policy-state"; import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; +import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, @@ -2134,10 +2135,21 @@ type LiveBaselineEntryState = * This matches the `prompt()` contract in `credentials/store.ts` (#5976). */ function askPreset(question: string): Promise { + // Re-attach stdin to the event loop — unref() on exit is sticky and + // would otherwise leave a follow-up prompt waiting on a detached handle. + if (typeof process.stdin.ref === "function") process.stdin.ref(); + // A Ctrl-D that arrived between questions is this prompt's cancellation; + // readline drops it when it switches the TTY to raw mode (#12169). + const pending = takePendingStdinEof(); + if (pending === false) return readPresetAnswer(question); + return pending.then((ended) => { + if (ended) throw pendingStdinEofError(); + return readPresetAnswer(question); + }); +} + +function readPresetAnswer(question: string): Promise { return new Promise((resolve, reject) => { - // Re-attach stdin to the event loop — unref() on exit is sticky and - // would otherwise leave a follow-up prompt waiting on a detached handle. - if (typeof process.stdin.ref === "function") process.stdin.ref(); const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); let finished = false; const finish = (settle: () => void) => { @@ -2156,9 +2168,7 @@ function askPreset(question: string): Promise { finish(() => reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" }))); process.kill(process.pid, "SIGINT"); }); - rl.on("close", () => - finish(() => reject(Object.assign(new Error("Prompt closed before input"), { code: "EOF" }))), - ); + rl.on("close", () => finish(() => reject(pendingStdinEofError()))); rl.question(question, (answer: string) => finish(() => resolve(answer))); }); } diff --git a/test/credentials/credentials.test.ts b/test/credentials/credentials.test.ts index 561ac9d8b44..d93e47abf61 100644 --- a/test/credentials/credentials.test.ts +++ b/test/credentials/credentials.test.ts @@ -768,6 +768,10 @@ const { isAnyPromptActive } = require(${JSON.stringify(path.join(import.meta.dir process.stdin.isTTY = true; process.stderr.isTTY = true; process.stdin.ref = () => process.stdin; +// The fake TTY flag above would otherwise make the pending-input window +// (#12169) read this process's real, already-closed stdin and report +// cancellation before the raw-mode failure under test can happen. +process.stdin.resume = () => process.stdin; process.stdin.pause = () => process.stdin; process.stdin.unref = () => process.stdin; process.stdin.setRawMode = () => { throw new Error('raw mode unavailable'); }; @@ -1074,12 +1078,15 @@ process.stdin.unref = () => { counts.unref += 1; return process.stdin; }; process.stdin.setRawMode = (value) => { counts.raw.push(value); return process.stdin; }; process.stdin.isTTY = true; process.stderr.isTTY = true; +// The fake TTY flag makes each prompt open its pending-input window +// (#12169) first, so answer once the window has handed stdin to the reader. +const answerAfterPendingWindow = (text) => setTimeout(() => process.stdin.emit('data', text), 40); (async () => { const normalPrompt = prompt('normal: '); - setImmediate(() => process.stdin.emit('data', 'alpha\\n')); + answerAfterPendingWindow('alpha\\n'); const normal = await normalPrompt; const secretPrompt = prompt('secret: ', { secret: true }); - setImmediate(() => process.stdin.emit('data', 'bravo\\n')); + answerAfterPendingWindow('bravo\\n'); const secret = await secretPrompt; console.log(JSON.stringify({ normal, secret, counts })); })().catch((err) => { console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); diff --git a/test/package-contract/onboard/onboard-stdin-eof.test.ts b/test/package-contract/onboard/onboard-stdin-eof.test.ts index 1a2731067ad..7249fcb2991 100644 --- a/test/package-contract/onboard/onboard-stdin-eof.test.ts +++ b/test/package-contract/onboard/onboard-stdin-eof.test.ts @@ -9,6 +9,9 @@ * cancellation. * - #7439: Ctrl+C at a hidden credential prompt printed resume guidance and * then leaked the rejected prompt error as a raw Node.js stack trace. + * - #12169: Ctrl+D typed on a real terminal between two onboarding questions + * was dropped by readline's raw-mode switch, so the configuration-review + * prompt never settled and onboarding never reported the cancellation. * * The tests use compiled artifacts (`dist/lib/...js`) to exercise the shipped * CLI path on the minimum supported Node.js runtime. They drive EOF through @@ -98,6 +101,85 @@ runOnboardCommand({ expect(`${result.stdout}${result.stderr}`).not.toContain("UNEXPECTED_"); }); + it.skipIf(process.platform === "win32")( + "cancels when stdin closes between two prompts on a terminal (#12169)", + () => { + const driver = ` +const { runOnboardCommand } = require(${JSON.stringify(COMMAND_PATH)}); +const { prompt } = require(${JSON.stringify(STORE_PATH)}); +runOnboardCommand({ + flags: {}, + env: process.env, + // Stand in for the reporter's flow: the model answer lands, onboarding works + // for a moment with nobody reading stdin, then the review prompt opens. + runOnboard: async () => { + await prompt(" Choose model [1]: "); + await new Promise((resolve) => setTimeout(resolve, 500)); + await prompt(" Choose [1]: "); + }, + error: (message) => console.error(message), + exit: (code) => process.exit(code), +}); +`; + const ptyRunner = ` +import os, pty, re, select, sys, time + +pid, fd = pty.fork() +if pid == 0: + os.execvpe(os.environ["NODE_UNDER_TEST"], [os.environ["NODE_UNDER_TEST"], "-e", os.environ["DRIVER_SOURCE"]], os.environ) + +out = bytearray() +answered = False +closed = False +exit_code = 124 +deadline = time.time() + 20 +os.set_blocking(fd, False) +while time.time() < deadline: + ready, _, _ = select.select([fd], [], [], 0.1) + if ready: + try: + chunk = os.read(fd, 4096) + except (BlockingIOError, OSError): + chunk = b"" + out.extend(chunk) + text = re.sub(rb"\\x1b\\[[0-9;?]*[a-zA-Z]", b"", bytes(out)) + if not answered and b"Choose model [1]:" in text: + time.sleep(0.2) + os.write(fd, b"1\\n") + answered = True + continue + # Close stdin while onboarding is between questions: nothing is reading. + if answered and not closed: + time.sleep(0.3) + os.write(fd, b"\\x04") + closed = True + waited = os.waitpid(pid, os.WNOHANG) + if waited[0] == pid: + exit_code = os.waitstatus_to_exitcode(waited[1]) + break +else: + os.kill(pid, 9) + os.waitpid(pid, 0) + +sys.stdout.buffer.write(bytes(out)) +sys.exit(exit_code if exit_code >= 0 else 128 - exit_code) +`; + const python = + spawnSync("bash", ["--noprofile", "--norc", "-c", "command -v python3"], { + encoding: "utf-8", + }).stdout.trim() || "python3"; + const result = spawnSync(python, ["-c", ptyRunner], { + encoding: "utf-8", + timeout: 30000, + env: { ...process.env, DRIVER_SOURCE: driver, NODE_UNDER_TEST: process.execPath }, + }); + const output = `${result.stdout}${result.stderr}`; + + expect(result.status, output).toBe(1); + expect(output).toContain("Installation cancelled"); + }, + ); + it.skipIf(process.platform === "win32")( "preserves SIGINT cancellation without a prompt stack trace (#7439)", async () => { From 0a6d42d3f608acfadb216713c6b0f417021fa1bd Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 22 Sep 2026 14:37:39 +0800 Subject: [PATCH 2/6] fix(cli): keep prompt transcript and piped answers on EOF recovery The pending-input window reported cancellation for any stdin end it saw, including the end of a pipe that had just delivered the answer, and it rejected before the question was printed. `nemoclaw config set` then skipped its confirmation line and refused a supplied answer. Input now ends the window immediately and is handed back, so the prompt answers with it and observes the EOF behind it as before; a window that only sees the EOF echoes the question before reporting the cancellation, keeping the transcript identical to an EOF typed at the prompt. Co-Authored-By: Claude Opus 5 Signed-off-by: Yanyun Liao --- src/lib/core/pending-stdin-eof.test.ts | 10 ++++++++++ src/lib/core/pending-stdin-eof.ts | 23 +++++++++++++++++++++-- src/lib/credentials/store.ts | 10 +++++++--- src/lib/onboard/messaging-selector.ts | 8 ++++++-- src/lib/policy/index.ts | 8 ++++++-- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/lib/core/pending-stdin-eof.test.ts b/src/lib/core/pending-stdin-eof.test.ts index de063e0d49c..74191852e66 100644 --- a/src/lib/core/pending-stdin-eof.test.ts +++ b/src/lib/core/pending-stdin-eof.test.ts @@ -53,6 +53,16 @@ describe("takePendingStdinEof", () => { expect(input.read()?.toString()).toBe("2\n"); }); + it("answers with input that arrives ahead of a closing stdin", async () => { + const input = makeInput(true); + const pending = takePendingStdinEof(input, 50); + input.write("yes\n"); + input.end(); + + await expect(pending).resolves.toBe(false); + expect(input.read()?.toString()).toBe("yes\n"); + }); + it("stops listening once it settles so the prompt owns stdin alone", async () => { const input = makeInput(true); diff --git a/src/lib/core/pending-stdin-eof.ts b/src/lib/core/pending-stdin-eof.ts index b523c01886a..b9d8d4cbdb6 100644 --- a/src/lib/core/pending-stdin-eof.ts +++ b/src/lib/core/pending-stdin-eof.ts @@ -28,8 +28,9 @@ interface PendingStdinInput extends NodeJS.ReadableStream { * * Reading stdin for one queued-read window before the prompt takes over * surfaces the pending EOF as a normal stream `end`. Anything that is real - * input instead (type-ahead) is pushed back so the prompt still answers with - * it. Only a TTY needs this: a pipe or `< /dev/null` reports its EOF through + * input instead (type-ahead, or piped answers that close the pipe behind + * them) ends the window immediately and is pushed back, so the prompt still + * answers with it and observes any EOF that follows exactly as before. Only a TTY needs this: a pipe or `< /dev/null` reports its EOF through * the same stream end whenever the prompt gets around to reading, so those * callers are left completely untouched. * @@ -50,6 +51,10 @@ export function takePendingStdinEof( function onData(chunk: string | Uint8Array): void { chunks.push(chunk); + // Input beats a queued EOF: an answer that is already typed (or piped) + // must still answer the question, and the EOF behind it is then the + // prompt's own to observe. + finish(false); } function finish(ended: boolean): void { @@ -89,3 +94,17 @@ export function takePendingStdinEof( export function pendingStdinEofError(): Error { return Object.assign(new Error("Prompt closed before input"), { code: "EOF" }); } + +/** + * Reject a prompt whose cancellation was already queued, after echoing the + * question. Callers that reach EOF while the question is on screen leave it + * there, so echoing keeps the transcript identical either way: operators and + * the CLI contract tests both see the question that was cancelled. + */ +export function cancelPromptWithPendingEof( + question: string, + output: NodeJS.WritableStream = process.stderr, +): never { + output.write(question); + throw pendingStdinEofError(); +} diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 841bc8d73f5..f750137b488 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -14,7 +14,11 @@ import path from "node:path"; import readline from "node:readline"; import { isErrnoException } from "../core/errno"; -import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; +import { + cancelPromptWithPendingEof, + pendingStdinEofError, + takePendingStdinEof, +} from "../core/pending-stdin-eof"; import { GATEWAY_PORT } from "../core/ports"; import { createPromptActivityCleanup } from "../core/prompt-activity"; import { listMessagingCredentialMetadata } from "../messaging/channels"; @@ -536,7 +540,7 @@ export function promptSecret(question: string, maskCap?: number): Promise { - if (ended) throw pendingStdinEofError(); + if (ended) cancelPromptWithPendingEof(question); return readSecretAnswer(question, maskCap); }); } @@ -726,7 +730,7 @@ export function prompt( const pending = takePendingStdinEof(); if (pending === false) return askQuestion(question, opts); return pending.then((ended) => { - if (ended) throw pendingStdinEofError(); + if (ended) cancelPromptWithPendingEof(question); return askQuestion(question, opts); }); } diff --git a/src/lib/onboard/messaging-selector.ts b/src/lib/onboard/messaging-selector.ts index 2e28ff754e6..c8c5b865436 100644 --- a/src/lib/onboard/messaging-selector.ts +++ b/src/lib/onboard/messaging-selector.ts @@ -3,7 +3,11 @@ import readline from "node:readline"; -import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; +import { + cancelPromptWithPendingEof, + pendingStdinEofError, + takePendingStdinEof, +} from "../core/pending-stdin-eof"; import { markPromptActive } from "../core/prompt-activity"; export interface MessagingChannelSelectorEntry { @@ -298,7 +302,7 @@ function promptMessagingSelectorLine(question: string): Promise { const pending = takePendingStdinEof(); if (pending === false) return readMessagingSelectorLine(question); return pending.then((ended) => { - if (ended) throw pendingStdinEofError(); + if (ended) cancelPromptWithPendingEof(question); return readMessagingSelectorLine(question); }); } diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 9889c1933a3..e8aaa21acec 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -25,7 +25,11 @@ export { isPolicyObservationError } from "../adapters/openshell/policy-state"; import type { OpenShellRuntimeSelection } from "../adapters/openshell/runtime-selection"; import { loadAgent, requireAgentPolicyAdditionsPath } from "../agent/defs"; import { CLI_NAME } from "../cli/branding"; -import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; +import { + cancelPromptWithPendingEof, + pendingStdinEofError, + takePendingStdinEof, +} from "../core/pending-stdin-eof"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, @@ -2143,7 +2147,7 @@ function askPreset(question: string): Promise { const pending = takePendingStdinEof(); if (pending === false) return readPresetAnswer(question); return pending.then((ended) => { - if (ended) throw pendingStdinEofError(); + if (ended) cancelPromptWithPendingEof(question); return readPresetAnswer(question); }); } From fd4cdf57bc305e243def8865c75909a7555c4bac Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Fri, 9 Oct 2026 10:42:39 +0800 Subject: [PATCH 3/6] fix(credentials): cancel masked prompts on Ctrl-D ## Outcome A `Ctrl-D` typed at a masked prompt now cancels onboarding the same way it does at every other prompt: the reader rejects with the pending-stdin EOF error, so the run prints `Installation cancelled`, exits non-zero, and keeps the resumable session. A `Ctrl-D` typed after the first character is ignored and the masked answer survives, matching readline, which ends only on an empty line. The documentation now states that rule. ## Reason `readSecretAnswer()` enables raw mode, which suppresses the terminal's own end-of-file handling, and its `onData` handler had no branch for `\u0004`. The byte is below the printable range, so it fell through without changing the answer or settling the prompt, and neither `end` nor `close` fires in raw mode. The API key prompt therefore waited for input that could never arrive, while the surrounding documentation promised cancellation at any question. ### Related issues Refs #12169 Co-Authored-By: Claude Opus 5 --- docs/reference/commands.mdx | 1 + src/lib/credentials/store.ts | 14 ++++++++ test/credentials/credentials.test.ts | 54 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index e406dccb756..49536f6da0d 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -933,6 +933,7 @@ If you enable Telegram during onboarding, the wizard can also prompt for whether Closing stdin during onboarding cancels the run: NemoClaw prints `Installation cancelled`, exits non-zero, and keeps the resumable onboarding session so `nemoclaw onboard --resume` can continue. This holds whether you press `Ctrl-D` while a question is on screen or while the wizard is working between questions, and whether stdin is a terminal or a harness pipe that closes. +On a terminal, `Ctrl-D` cancels while the answer is still empty, including at a masked prompt such as an API key; after you type characters it is ignored until you clear them, the same rule a shell applies. If you cancel a brand-new onboarding run at the policy-tier selector or either policy-preset selector after sandbox creation, NemoClaw preserves the incomplete sandbox, registry entry, and onboarding session for retained recovery. NemoClaw reports the durable sandbox identity fingerprint when it is available. diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 495be9b2dce..1d1604cc4a6 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -746,6 +746,20 @@ function readSecretAnswer(question: string, maskCap?: number): Promise { return; } + // Raw mode suppresses the terminal's own end-of-file handling, so the + // masked reader has to recognise Ctrl-D itself or the key does + // nothing here while it cancels every other prompt (#12169). readline + // ends only on an empty line and ignores the key once an answer is + // typed; a half-entered secret keeps that behaviour rather than + // discarding the characters the user already masked. + if (ch === "\u0004") { + if (answer.length === 0) { + onInputClosed(); + return; + } + continue; + } + if (ch.charCodeAt(0) === 0x17) { wordDeleteBackward(); continue; diff --git a/test/credentials/credentials.test.ts b/test/credentials/credentials.test.ts index b0bb36c0fb8..43964c1063b 100644 --- a/test/credentials/credentials.test.ts +++ b/test/credentials/credentials.test.ts @@ -1311,4 +1311,58 @@ const answerAfterPendingWindow = (text) => setTimeout(() => process.stdin.emit(' expect(result.stderr).toContain("*****"); expect(result.stderr).not.toContain("bravo"); }); + + it("cancels a masked prompt on Ctrl-D and keeps a half-typed secret", () => { + const script = ` +const { prompt } = require(${JSON.stringify(path.join(import.meta.dirname, "../..", "src", "lib", "credentials", "store.ts"))}); +process.stdin.ref = () => process.stdin; +process.stdin.resume = () => process.stdin; +process.stdin.pause = () => process.stdin; +process.stdin.unref = () => process.stdin; +process.stdin.setRawMode = () => process.stdin; +process.stdin.isTTY = true; +process.stderr.isTTY = true; +// Each prompt opens the pending-input window (#12169) before it reads, so +// every keystroke below waits for stdin to reach the masked reader. +const typeAfterPendingWindow = (text, delay) => + setTimeout(() => process.stdin.emit('data', text), delay); +(async () => { + const typed = prompt('secret: ', { secret: true }); + typeAfterPendingWindow('alpha', 40); + typeAfterPendingWindow('\\u0004', 80); + typeAfterPendingWindow('\\n', 120); + const kept = await typed; + const empty = prompt('secret: ', { secret: true }); + typeAfterPendingWindow('\\u0004', 40); + const cancelled = await empty.then( + (value) => ({ resolved: value }), + (err) => ({ code: err && err.code, message: err && err.message }), + ); + console.log(JSON.stringify({ kept, cancelled })); +})().catch((err) => { console.error(err && err.stack ? err.stack : String(err)); process.exit(1); }); +`; + const scriptFile = path.join(os.tmpdir(), `nemoclaw-credential-eof-${process.pid}.js`); + fs.writeFileSync(scriptFile, script, { mode: 0o700 }); + let result: ReturnType; + try { + result = spawnSync(process.execPath, [scriptFile], { + encoding: "utf-8", + timeout: 5000, + }); + } finally { + try { + fs.unlinkSync(scriptFile); + } catch { + /* ignore */ + } + } + + expect(result.status).toBe(0); + const parsed = JSON.parse(String(result.stdout).trim()); + // Ctrl-D after characters is ignored, exactly as readline ignores it on a + // non-empty line, so the masked characters still answer the question. + expect(parsed.kept).toBe("alpha"); + expect(parsed.cancelled).toEqual({ code: "EOF", message: "Prompt closed before input" }); + expect(result.stderr).not.toContain("alpha"); + }); }); From e073b28e749b752ddfb3ac83c256d7f480ed4943 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Tue, 20 Oct 2026 15:29:26 +0800 Subject: [PATCH 4/6] fix(onboard): cancel every raw-mode menu on Ctrl-D The pending-EOF handoff only covered readline prompts, the credential reader and the messaging menu. The policy-tier menu, both policy-preset menus and the Hermes managed-tools menu switched the terminal to raw mode without first consuming a Ctrl-D queued between questions, so the switch dropped it and the menu waited forever. Inside a raw-mode menu Ctrl-D also arrives as an ordinary byte instead of an end-of-file, and none of those menus, including the messaging menu, treated it as one. Each raw-mode menu now runs the shared pending-EOF window before raw mode starts and treats a 0x04 byte as a closed stdin. None of the menus has a typed answer to protect, so the key always cancels. The three policy menus cancel through the same path as Ctrl-C there, which marks the sandbox cancel rollback and exits 1; the messaging and Hermes menus reject with the prompt EOF error, which onboarding reports as "Installation cancelled" with exit 1. The policy menus take the window as an injectable dependency. It stays synchronous when nothing is pending, so the menu attaches its key listener on the same tick as before, and unit tests with an injected stdin keep their existing timing. Add PTY contract tests that drive all three menu families through a real pseudo-terminal, press Ctrl-D both between questions and inside the menu, and assert exit status 1, the cancellation report, and that the terminal is no longer in raw mode at exit. Without this change five of the six cases hang until the deadline; the sixth, messaging between questions, already passed because that menu had the handoff. Co-Authored-By: Claude Opus 5.5 --- src/lib/onboard/cancel-rollback.ts | 4 +- src/lib/onboard/hermes-managed-tools.ts | 14 ++ src/lib/onboard/messaging-selector.test.ts | 31 +++ src/lib/onboard/messaging-selector.ts | 9 +- .../onboard/policy-selection-prompts.test.ts | 62 +++++ src/lib/onboard/policy-selection-prompts.ts | 48 +++- .../onboard/onboard-raw-selector-eof.test.ts | 215 ++++++++++++++++++ 7 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 test/package-contract/onboard/onboard-raw-selector-eof.test.ts diff --git a/src/lib/onboard/cancel-rollback.ts b/src/lib/onboard/cancel-rollback.ts index 27db2046bdb..326c69bee27 100644 --- a/src/lib/onboard/cancel-rollback.ts +++ b/src/lib/onboard/cancel-rollback.ts @@ -19,8 +19,8 @@ export { restoreDefaultAfterRecreate, wasSandboxDefault } from "./default-preser * * The guard activates only when both conditions are true: * - `arm()` records a newly created sandbox after `createSandbox` succeeds. - * - `markCancelled()` records Ctrl+C or SIGTERM at the policy-tier or either - * policy-preset selector. + * - `markCancelled()` records Ctrl+C, Ctrl+D, or SIGTERM at the policy-tier + * or either policy-preset selector. * * Other `process.exit(1)` failure paths do not call `markCancelled()`. Their * existing preservation behavior remains unchanged. diff --git a/src/lib/onboard/hermes-managed-tools.ts b/src/lib/onboard/hermes-managed-tools.ts index 705dec3bec7..e95ac0a2212 100644 --- a/src/lib/onboard/hermes-managed-tools.ts +++ b/src/lib/onboard/hermes-managed-tools.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { pendingStdinEofError, takePendingStdinEof } from "../core/pending-stdin-eof"; import type { HermesAuthMethod } from "../hermes-provider-auth"; import * as hermesProviderAuth from "../hermes-provider-auth"; @@ -176,6 +177,11 @@ async function selectHermesToolGatewaysInteractive( showList(); + // Switching the terminal to raw mode drops a Ctrl-D that was queued before + // the menu opened, which would leave the menu waiting forever (#12169). + const pending = takePendingStdinEof(input); + if (pending !== false && (await pending)) throw pendingStdinEofError(); + await new Promise((resolve, reject) => { let rawModeEnabled = false; let finished = false; @@ -212,6 +218,14 @@ async function selectHermesToolGatewaysInteractive( process.kill(process.pid, "SIGINT"); return; } + // Raw mode delivers Ctrl-D as an ordinary byte instead of an + // end-of-file. The menu has no typed answer to protect, so it always + // closes stdin. + if (ch === "\u0004") { + cleanup(); + reject(pendingStdinEofError()); + return; + } if (ch === "\r" || ch === "\n") { finish(); return; diff --git a/src/lib/onboard/messaging-selector.test.ts b/src/lib/onboard/messaging-selector.test.ts index f5f0a0a6d38..8e645d7507d 100644 --- a/src/lib/onboard/messaging-selector.test.ts +++ b/src/lib/onboard/messaging-selector.test.ts @@ -78,6 +78,13 @@ describe("messaging selector key handling", () => { expect(applyMessagingSelectorKey("\r", enabled, channels)).toBe("finish"); }); + it("treats a raw Ctrl-D as a closed stdin (#12169)", () => { + const enabled = new Set(["telegram"]); + + expect(applyMessagingSelectorKey("\u0004", enabled, channels)).toBe("eof"); + expect([...enabled]).toEqual(["telegram"]); + }); + it("normalizes complete terminal keypad and extended numeric sequences", () => { expect(normalizeMessagingSelectorInput("\x1bOq")).toBe("1"); expect(normalizeMessagingSelectorInput("\x1b[49;5u")).toBe("1"); @@ -182,6 +189,30 @@ describe("messaging selector key handling", () => { expect(input.listenerCount("close")).toBe(0); }); + it("cancels and restores the terminal when Ctrl-D is pressed in the raw menu (#12169)", async () => { + const input = createMockSelectorInput(); + const output = { write: vi.fn() }; + Object.defineProperty(process, "stdin", { + configurable: true, + value: input, + }); + Object.defineProperty(process, "stderr", { + configurable: true, + value: output, + }); + + const selection = readMessagingChannelSelection(channels, new Set(), () => {}); + input.emit("data", "\u0004"); + + await expect(selection).rejects.toMatchObject({ code: "EOF" }); + expect(isAnyPromptActive()).toBe(false); + expect(input.setRawMode).toHaveBeenNthCalledWith(1, true); + expect(input.setRawMode).toHaveBeenLastCalledWith(false); + expect(input.listenerCount("data")).toBe(0); + expect(input.listenerCount("end")).toBe(0); + expect(input.listenerCount("close")).toBe(0); + }); + it("restores raw mode and removes listeners when SIGTERM interrupts", async () => { const input = createMockSelectorInput(); const output = { write: vi.fn() }; diff --git a/src/lib/onboard/messaging-selector.ts b/src/lib/onboard/messaging-selector.ts index c8c5b865436..e0b538b45ff 100644 --- a/src/lib/onboard/messaging-selector.ts +++ b/src/lib/onboard/messaging-selector.ts @@ -25,7 +25,7 @@ export type MessagingSelectorOutput = typeof process.stderr & { readonly isTTY?: boolean; }; -export type MessagingSelectorKeyAction = "continue" | "redraw" | "finish" | "interrupt"; +export type MessagingSelectorKeyAction = "continue" | "redraw" | "finish" | "interrupt" | "eof"; export interface MessagingSelectorNormalizerState { carry: string; @@ -73,6 +73,9 @@ export function applyMessagingSelectorKey( availableChannels: readonly MessagingChannelSelectorEntry[], ): MessagingSelectorKeyAction { if (key === "\u0003") return "interrupt"; + // Raw mode delivers Ctrl-D as an ordinary byte instead of an end-of-file. + // The menu has no typed answer to protect, so it always closes stdin. + if (key === "\u0004") return "eof"; if (key === "\r" || key === "\n") return "finish"; const num = Number.parseInt(key, 10); @@ -243,6 +246,10 @@ function readChannelKeys( interrupt("SIGINT"); return; } + if (action === "eof") { + fail(pendingStdinEofError()); + return; + } if (action === "finish") { finish(); return; diff --git a/src/lib/onboard/policy-selection-prompts.test.ts b/src/lib/onboard/policy-selection-prompts.test.ts index f07d2969460..bf93136c5d1 100644 --- a/src/lib/onboard/policy-selection-prompts.test.ts +++ b/src/lib/onboard/policy-selection-prompts.test.ts @@ -5,6 +5,8 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TierDefinition } from "../policy/tiers"; import { createPolicySelectionPromptHelpers } from "./policy-selection-prompts"; + +type PromptHelpers = ReturnType; import { selectFromNumberedMenuOrExit } from "./prompt-helpers"; class FakeInput extends EventEmitter { @@ -67,11 +69,13 @@ function createHarness({ stdinTTY = true, stdoutTTY = true, promptReplies = [], + takePendingStdinEof, }: { tiers?: TierDefinition[]; stdinTTY?: boolean; stdoutTTY?: boolean; promptReplies?: string[]; + takePendingStdinEof?: () => Promise | false; } = {}) { const stdin = new FakeInput(stdinTTY); const stdout = new FakeOutput(stdoutTTY); @@ -101,6 +105,7 @@ function createHarness({ stdin, stdout, processEvents, + takePendingStdinEof, }); return { helpers, markCancelled, processEvents, prompt, stdin, stdout }; @@ -289,6 +294,63 @@ describe("createPolicySelectionPromptHelpers", () => { expect(stdin.listenerCount("data")).toBe(0); }); + const RAW_MENUS = [ + ["selectPolicyTier", (helpers: PromptHelpers) => helpers.selectPolicyTier()], + [ + "selectTierPresetsAndAccess", + (helpers: PromptHelpers) => + helpers.selectTierPresetsAndAccess("balanced", [{ name: "npm" }, { name: "pypi" }]), + ], + [ + "presetsCheckboxSelector", + (helpers: PromptHelpers) => + helpers.presetsCheckboxSelector([{ name: "npm", description: "npm registry" }], []), + ], + ] as const; + + it.each(RAW_MENUS)( + "%s cancels through the prompt when Ctrl-D is pressed in the raw menu (#12169)", + async (_name, open) => { + const { helpers, markCancelled, stdin } = createHarness(); + + const selection = open(helpers); + stdin.emit("data", "\x04"); + + await expect(selection).rejects.toMatchObject({ code: 1 }); + expect(markCancelled).toHaveBeenCalledOnce(); + expect(stdin.setRawMode).toHaveBeenLastCalledWith(false); + expect(stdin.listenerCount("data")).toBe(0); + }, + ); + + it.each(RAW_MENUS)( + "%s cancels before raw mode when stdin closed between questions (#12169)", + async (_name, open) => { + const { helpers, markCancelled, stdin } = createHarness({ + takePendingStdinEof: () => Promise.resolve(true), + }); + + await expect(open(helpers)).rejects.toMatchObject({ code: 1 }); + expect(markCancelled).toHaveBeenCalledOnce(); + expect(stdin.setRawMode).not.toHaveBeenCalled(); + expect(stdin.listenerCount("data")).toBe(0); + }, + ); + + it("opens the raw menu when the pending-input window finds no closed stdin", async () => { + const { helpers, stdin } = createHarness({ + takePendingStdinEof: () => Promise.resolve(false), + }); + + const selection = helpers.selectPolicyTier(); + await vi.waitFor(() => expect(stdin.listenerCount("data")).toBe(1)); + stdin.emit("data", "\r"); + + await expect(selection).resolves.toBe("balanced"); + expect(stdin.setRawMode).toHaveBeenNthCalledWith(1, true); + expect(stdin.setRawMode).toHaveBeenLastCalledWith(false); + }); + it("selectPolicyTier rejects when no policy tiers are configured", async () => { const { helpers } = createHarness({ tiers: [] }); diff --git a/src/lib/onboard/policy-selection-prompts.ts b/src/lib/onboard/policy-selection-prompts.ts index bf9ff28d0be..589119e8d84 100644 --- a/src/lib/onboard/policy-selection-prompts.ts +++ b/src/lib/onboard/policy-selection-prompts.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { takePendingStdinEof } from "../core/pending-stdin-eof"; import type { TierDefinition } from "../policy/tiers"; import type { SandboxCancelRollback } from "./cancel-rollback"; import { OnboardDeferredExitError } from "./session-bootstrap"; @@ -49,6 +50,12 @@ export interface PolicySelectionPromptDeps { stdin?: PolicyPromptInput; stdout?: PolicyPromptOutput; processEvents?: PolicyPromptProcessEvents; + /** + * Report whether stdin closed before a raw-mode menu opened. Defaults to the + * shared terminal check for the real stdin and to "nothing pending" for an + * injected one. + */ + takePendingStdinEof?: () => Promise | false; } export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDeps): { @@ -77,6 +84,26 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe const stdin = deps.stdin ?? process.stdin; const stdout = deps.stdout ?? process.stdout; const processEvents = deps.processEvents ?? process; + const takePendingEof = + deps.takePendingStdinEof ?? (() => (stdin === process.stdin ? takePendingStdinEof() : false)); + + /** + * Cancel a raw-mode menu whose stdin closed before it opened. Switching the + * terminal to raw mode drops a Ctrl-D queued between questions, so the menu + * would otherwise wait for a key that never arrives (#12169). Callers check + * `takePendingEof()` first, which stays synchronous when nothing is pending + * so the menu attaches its key listener on the same tick as before. The + * cancellation takes the same path as Ctrl-C at these selectors. + */ + function cancelForClosedStdin(): Promise { + return new Promise((_resolve, reject) => { + makeOnboardCancelExit( + sandboxCancelRollback, + () => undefined, + (code) => reject(new OnboardDeferredExitError(code)), + )(); + }); + } function promptWithOnboardCancel(question: string): Promise { return new Promise((resolve, reject) => { @@ -200,6 +227,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe lineCount = lines.length; }; + const pending = takePendingEof(); + if (pending !== false && (await pending)) return cancelForClosedStdin(); + // Re-attach stdin to the event loop. A prior prompt cleanup may have // unref'd it (sticky), and resume() alone would leave the raw-mode read // detached from the loop. @@ -232,7 +262,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe } else if (key === " ") { selectedIdx = cursor; redraw(); - } else if (key === "\x03") { + } else if (key === "\x03" || key === "\x04") { + // Raw mode delivers Ctrl-D as an ordinary byte instead of an + // end-of-file; the menu has no typed answer to protect. onSigterm(); } else if (key === "\x1b[A" || key === "k") { cursor = (cursor - 1 + n) % n; @@ -382,6 +414,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe lineCount = lines.length; }; + const pending = takePendingEof(); + if (pending !== false && (await pending)) return cancelForClosedStdin(); + // Re-attach stdin to the event loop. A prior prompt cleanup may have // unref'd it (sticky), and resume() alone would leave the raw-mode read // detached from the loop. @@ -415,7 +450,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe .filter((preset) => included.has(preset.name)) .map((preset) => ({ name: preset.name, access: accessModes[preset.name] })), ); - } else if (key === "\x03") { + } else if (key === "\x03" || key === "\x04") { + // Raw mode delivers Ctrl-D as an ordinary byte instead of an + // end-of-file; the menu has no typed answer to protect. onSigterm(); } else if (key === "\x1b[A" || key === "k") { cursor = (cursor - 1 + n) % n; @@ -532,6 +569,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe lineCount = lines.length; }; + const pending = takePendingEof(); + if (pending !== false && (await pending)) return cancelForClosedStdin(); + // Re-attach stdin to the event loop. A prior prompt cleanup may have // unref'd it (sticky), and resume() alone would leave the raw-mode read // detached from the loop. @@ -561,7 +601,9 @@ export function createPolicySelectionPromptHelpers(deps: PolicySelectionPromptDe cleanup(); stdout.write("\n"); resolve([...selected]); - } else if (key === "\x03") { + } else if (key === "\x03" || key === "\x04") { + // Raw mode delivers Ctrl-D as an ordinary byte instead of an + // end-of-file; the menu has no typed answer to protect. onSigterm(); } else if (key === "\x1b[A" || key === "k") { cursor = (cursor - 1 + n) % n; diff --git a/test/package-contract/onboard/onboard-raw-selector-eof.test.ts b/test/package-contract/onboard/onboard-raw-selector-eof.test.ts new file mode 100644 index 00000000000..cfb01d3337b --- /dev/null +++ b/test/package-contract/onboard/onboard-raw-selector-eof.test.ts @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Ctrl-D cancellation at the raw-mode onboarding menus (#12169). + * + * The policy-tier and policy-preset menus, the messaging-channel menu and the + * Hermes managed-tools menu each switch the terminal to raw mode. That switch + * drops a Ctrl-D typed between two questions, and inside the menu the key + * arrives as an ordinary byte rather than an end-of-file. Either way the menu + * waited for a key that never came. These tests drive every menu through a + * real pseudo-terminal, press Ctrl-D both between questions and inside the + * menu, and assert that onboarding cancels with exit status 1 and leaves the + * terminal out of raw mode. + * + * The tests use compiled artifacts (`dist/lib/...js`) to exercise the shipped + * code path. + */ + +import { spawnSync } from "node:child_process"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const REPO_ROOT = path.join(import.meta.dirname, "..", "..", ".."); +const dist = (relative: string): string => path.join(REPO_ROOT, "dist", "lib", relative); + +const MENUS = { + policy: { + banner: "Policy tier", + cancellation: "MARK_CANCELLED", + }, + messaging: { + banner: "MESSAGING_MENU_OPEN", + cancellation: "Installation cancelled", + }, + hermes: { + banner: "Hermes managed Nous tools", + cancellation: "Installation cancelled", + }, +} as const; + +type MenuName = keyof typeof MENUS; +type Moment = "between" | "inside"; + +const DRIVER = ` +const { runOnboardCommand } = require(${JSON.stringify(dist("onboard/command.js"))}); +const { prompt } = require(${JSON.stringify(dist("credentials/store.js"))}); + +process.on("exit", () => { + process.stderr.write("\\nRAW_AT_EXIT=" + (process.stdin.isRaw === true) + "\\n"); +}); + +const menus = { + policy: () => { + const { createPolicySelectionPromptHelpers } = require(${JSON.stringify(dist("onboard/policy-selection-prompts.js"))}); + const { makeOnboardCancelExit } = require(${JSON.stringify(dist("onboard/cancel-rollback.js"))}); + const { selectFromNumberedMenuOrExit } = require(${JSON.stringify(dist("onboard/prompt-helpers.js"))}); + const tiers = require(${JSON.stringify(dist("policy/tiers.js"))}); + return createPolicySelectionPromptHelpers({ + tiers, + policyTierEnv: { resolvePolicyTierFromEnv: () => "balanced" }, + isNonInteractive: () => false, + note: () => undefined, + prompt, + selectFromNumberedMenuOrExit, + makeOnboardCancelExit, + sandboxCancelRollback: { + markCancelled: () => process.stderr.write("MARK_CANCELLED\\n"), + }, + useColor: false, + }).selectPolicyTier(); + }, + messaging: () => { + const { readMessagingChannelSelection } = require(${JSON.stringify(dist("onboard/messaging-selector.js"))}); + process.stderr.write("MESSAGING_MENU_OPEN\\n"); + return readMessagingChannelSelection( + [{ id: "telegram", displayName: "Telegram" }], + new Set(), + () => undefined, + ); + }, + hermes: () => { + const { setupHermesToolGateways } = require(${JSON.stringify(dist("onboard/hermes-managed-tools.js"))}); + const { HERMES_PROVIDER_NAME } = require(${JSON.stringify(dist("onboard/inference-providers/hermes-provider-identity.js"))}); + return setupHermesToolGateways(HERMES_PROVIDER_NAME, "oauth", null, { + prompt, + note: () => undefined, + isNonInteractive: () => false, + }); + }, +}; + +runOnboardCommand({ + flags: {}, + env: process.env, + // The model answer lands, onboarding works for a moment with nobody + // reading stdin, then the raw-mode menu opens. + runOnboard: async () => { + await prompt(" Choose model [1]: "); + await new Promise((resolve) => setTimeout(resolve, 500)); + await menus[process.env.MENU_UNDER_TEST](); + }, + error: (message) => console.error(message), + exit: (code) => process.exit(code), +}); +`; + +const PTY_RUNNER = ` +import os, pty, re, select, sys, termios, time + +moment = sys.argv[1] +pid, fd = pty.fork() +if pid == 0: + node = os.environ["NODE_UNDER_TEST"] + os.execvpe(node, [node, "-e", os.environ["DRIVER_SOURCE"]], os.environ) + +def raw_mode(): + try: + return not bool(termios.tcgetattr(fd)[3] & termios.ICANON) + except termios.error: + return False + +out = bytearray() +answered_at = None +sent = False +exit_code = 124 +deadline = time.time() + 20 +os.set_blocking(fd, False) +while time.time() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + out.extend(os.read(fd, 4096)) + except (BlockingIOError, OSError): + pass + text = re.sub(rb"\\x1b\\[[0-9;?]*[a-zA-Z]", b"", bytes(out)) + if answered_at is None and b"Choose model [1]:" in text: + time.sleep(0.2) + os.write(fd, b"1\\n") + answered_at = time.time() + continue + if answered_at is not None and not sent: + elapsed = time.time() - answered_at + # Between questions: nothing reads stdin and the terminal is cooked. + if moment == "between" and elapsed > 0.2: + os.write(fd, b"\\x04") + sent = True + # Inside the menu: wait out the pause between questions, then for the + # menu to own the terminal in raw mode. + if moment == "inside" and elapsed > 0.7 and raw_mode(): + time.sleep(0.1) + os.write(fd, b"\\x04") + sent = True + waited = os.waitpid(pid, os.WNOHANG) + if waited[0] == pid: + exit_code = os.waitstatus_to_exitcode(waited[1]) + break +else: + os.kill(pid, 9) + os.waitpid(pid, 0) + +try: + while True: + chunk = os.read(fd, 4096) + if not chunk: + break + out.extend(chunk) +except (BlockingIOError, OSError): + pass + +sys.stdout.buffer.write(bytes(out)) +print("\\nPTY_CTRL_D_SENT=" + str(sent)) +sys.exit(exit_code if exit_code >= 0 else 128 - exit_code) +`; + +function runMenu(menu: MenuName, moment: Moment): { status: number | null; output: string } { + const python = + spawnSync("bash", ["--noprofile", "--norc", "-c", "command -v python3"], { + encoding: "utf-8", + }).stdout.trim() || "python3"; + const env: NodeJS.ProcessEnv = { + ...process.env, + DRIVER_SOURCE: DRIVER, + MENU_UNDER_TEST: menu, + NODE_UNDER_TEST: process.execPath, + NODE_NO_WARNINGS: "1", + }; + // An inherited preset would bypass the Hermes menu entirely. + delete env.NEMOCLAW_HERMES_TOOL_GATEWAYS; + delete env.NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS; + const result = spawnSync(python, ["-c", PTY_RUNNER, moment], { + encoding: "utf-8", + timeout: 30000, + env, + }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; +} + +describe.skipIf(process.platform === "win32")("raw-mode menu Ctrl-D cancellation (#12169)", () => { + const cases = (Object.keys(MENUS) as MenuName[]).flatMap((menu) => + (["between", "inside"] as const).map((moment) => [menu, moment] as const), + ); + + it.each(cases)("%s menu cancels on Ctrl-D typed %s", { timeout: 35000 }, (menu, moment) => { + const { status, output } = runMenu(menu, moment); + + expect(output).toContain("PTY_CTRL_D_SENT=True"); + expect(status, output).toBe(1); + expect(output).toContain(MENUS[menu].banner); + expect(output).toContain(MENUS[menu].cancellation); + expect(output).toContain("RAW_AT_EXIT=false"); + expect(output).not.toContain("Error: Prompt closed before input"); + }); +}); From 1ad6cae0257d3cf155ad94137a8d63e49d30c55e Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Wed, 21 Oct 2026 09:26:12 +0800 Subject: [PATCH 5/6] test(policy): cover a terminal Ctrl-D queued before the preset picker Drive policy-add on a pseudo-terminal. Answer a prior prompt, then type Ctrl-D while the CLI is busy and the terminal is cooked, so the kernel queues it as an end-of-file for the picker. Assert exit status 1, the picker transcript, the stdin-closed guidance, and that no preset is applied. Removing the pending-EOF handoff from askPreset() makes the picker wait forever and fails the test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Yanyun Liao --- .../cli/policy-prompt-eof.test.ts | 159 ++++++++++++++++-- 1 file changed, 147 insertions(+), 12 deletions(-) diff --git a/test/package-contract/cli/policy-prompt-eof.test.ts b/test/package-contract/cli/policy-prompt-eof.test.ts index 3642712e0d8..ed2e32c5f5f 100644 --- a/test/package-contract/cli/policy-prompt-eof.test.ts +++ b/test/package-contract/cli/policy-prompt-eof.test.ts @@ -9,9 +9,14 @@ * the interactive picker. Automation can then distinguish refusal from a * successful mutation. * - * These tests drive the compiled CLI (`dist/nemoclaw.js`) with a pipe-backed - * stdin. Only the registry and preset lookups are stubbed, which replaces - * on-disk sandbox state. + * A terminal Ctrl-D typed between two prompts must also cancel the picker + * (#12169). The terminal is cooked while nothing reads stdin, so the kernel + * queues the Ctrl-D as an end-of-file. The picker prompt switches the terminal + * to raw mode, which would drop that end-of-file and leave the picker waiting. + * + * These tests drive the compiled CLI (`dist/nemoclaw.js`) through a pipe-backed + * stdin or a real pseudo-terminal. Only the registry and preset lookups are + * stubbed, which replaces on-disk sandbox state. */ import { spawnSync } from "node:child_process"; @@ -29,15 +34,7 @@ const CROSS_PORT_PATH = JSON.stringify( path.join(REPO_ROOT, "dist", "lib", "state", "registry", "cross-port.js"), ); -/** - * Run a policy command with no preset name and no terminal input. `input: ""` - * gives the child an already-ended pipe, which is the stdin shape a boot unit - * produces. - */ -function runPolicyCommandAtStdinEof(command: "policy-add" | "policy-remove") { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-prompt-eof-")); - const scriptPath = path.join(tmpDir, "policy-prompt-eof-check.js"); - const script = String.raw` +const PRESET_STUBS = String.raw` const registry = require(${REGISTRY_PATH}); const crossPort = require(${CROSS_PORT_PATH}); const policies = require(${POLICIES_PATH}); @@ -54,6 +51,18 @@ crossPort.findSandboxAcrossGatewayRoots = (name) => name === "test-sandbox" ? { entry: { name }, gatewayPort: null, registryFile: "test-registry" } : null; +`; + +/** + * Run a policy command with no preset name and no terminal input. `input: ""` + * gives the child an already-ended pipe, which is the stdin shape a boot unit + * produces. + */ +function runPolicyCommandAtStdinEof(command: "policy-add" | "policy-remove") { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-prompt-eof-")); + const scriptPath = path.join(tmpDir, "policy-prompt-eof-check.js"); + const script = String.raw` +${PRESET_STUBS} process.argv = ["node", "nemoclaw.js", "test-sandbox", ${JSON.stringify(command)}]; require(${CLI_PATH}); `; @@ -103,3 +112,129 @@ describe("policy preset prompt cancellation", () => { 45_000, ); }); + +const STORE_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "credentials", "store.js")); + +/** + * Drive the child on a pseudo-terminal. The runner answers a prior prompt, + * then types Ctrl-D while the child is busy and the terminal is cooked, so the + * kernel queues it as an end-of-file for the policy picker. + */ +const PTY_RUNNER = String.raw` +import os, pty, re, select, sys, termios, time + +pid, fd = pty.fork() +if pid == 0: + node = os.environ["NODE_UNDER_TEST"] + os.execvpe(node, [node, sys.argv[1]], os.environ) + +def cooked(): + try: + return bool(termios.tcgetattr(fd)[3] & termios.ICANON) + except termios.error: + return False + +out = bytearray() +answered = False +sent = False +exit_code = 124 +deadline = time.time() + 20 +os.set_blocking(fd, False) +while time.time() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + out.extend(os.read(fd, 4096)) + except (BlockingIOError, OSError): + pass + text = re.sub(rb"\x1b\[[0-9;?]*[a-zA-Z]", b"", bytes(out)) + if not answered and b"Prior question:" in text: + time.sleep(0.2) + os.write(fd, b"ok\n") + answered = True + if answered and not sent and b"BUSY_BEFORE_PICKER" in text and cooked(): + os.write(fd, b"\x04") + sent = True + waited = os.waitpid(pid, os.WNOHANG) + if waited[0] == pid: + exit_code = os.waitstatus_to_exitcode(waited[1]) + break +else: + os.kill(pid, 9) + os.waitpid(pid, 0) + +try: + while True: + chunk = os.read(fd, 4096) + if not chunk: + break + out.extend(chunk) +except (BlockingIOError, OSError): + pass + +sys.stdout.buffer.write(bytes(out)) +print("\nPTY_CTRL_D_SENT=" + str(sent)) +sys.exit(exit_code if exit_code >= 0 else 128 - exit_code) +`; + +function runPolicyAddWithQueuedCtrlD() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-policy-prompt-pty-eof-")); + const scriptPath = path.join(tmpDir, "policy-prompt-pty-eof-check.js"); + // The busy preset lookup blocks the event loop, so nothing reads stdin + // while the runner types Ctrl-D. + const script = String.raw` +${PRESET_STUBS} +const listPresets = policies.listPresets; +policies.listPresets = () => { + process.stderr.write("BUSY_BEFORE_PICKER\n"); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 700); + return listPresets(); +}; +policies.applyPreset = async () => { + process.stderr.write("POLICY_MUTATED\n"); + return true; +}; +require(${STORE_PATH}) + .prompt(" Prior question: ") + .then(() => { + process.argv = ["node", "nemoclaw.js", "test-sandbox", "policy-add"]; + require(${CLI_PATH}); + }); +`; + fs.writeFileSync(scriptPath, script); + try { + const result = spawnSync("python3", ["-c", PTY_RUNNER, scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + timeout: 30_000, + killSignal: "SIGKILL", + env: { + ...process.env, + HOME: tmpDir, + NODE_UNDER_TEST: process.execPath, + NODE_NO_WARNINGS: "1", + NEMOCLAW_NON_INTERACTIVE: undefined, + }, + }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe.skipIf(process.platform === "win32")( + "policy preset prompt terminal Ctrl-D (#12169)", + () => { + it("policy-add cancels the picker on a Ctrl-D queued after a prior prompt", () => { + const { status, output } = runPolicyAddWithQueuedCtrlD(); + + expect(output).toContain("PTY_CTRL_D_SENT=True"); + // 124 is the runner's hang verdict. + expect(status, output).toBe(1); + expect(output).toContain("Available presets:"); + expect(output).toContain("Choose preset [2]:"); + expect(output).toContain("No input available on stdin"); + expect(output).not.toContain("POLICY_MUTATED"); + }, 45_000); + }, +); From 65826aca1b5bd73e5b6ab9d0d707e487f0c773f6 Mon Sep 17 00:00:00 2001 From: Yanyun Liao Date: Wed, 21 Oct 2026 10:01:53 +0800 Subject: [PATCH 6/6] test(onboard): cover EOF cancellation in the Hermes managed-tools selector Drive setupHermesToolGateways through an injected TTY. Cover an EOF that is queued before the menu enters raw mode and a Ctrl-D typed inside the raw-mode menu. Assert the EOF rejection, that raw mode is never entered or is restored, and that the menu drops its key listener. Removing either the queued-EOF handoff or the in-menu Ctrl-D handling fails the matching test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Yanyun Liao --- src/lib/onboard/hermes-managed-tools.test.ts | 55 +++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/hermes-managed-tools.test.ts b/src/lib/onboard/hermes-managed-tools.test.ts index dc83393960e..ce4a31cee4a 100644 --- a/src/lib/onboard/hermes-managed-tools.test.ts +++ b/src/lib/onboard/hermes-managed-tools.test.ts @@ -1,9 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { PassThrough } from "node:stream"; -import { stringSetsEqual } from "./hermes-managed-tools"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { HERMES_PROVIDER_NAME } from "../hermes-provider-auth"; +import { setupHermesToolGateways, stringSetsEqual } from "./hermes-managed-tools"; describe("stringSetsEqual", () => { it("accepts equal selections regardless of order or duplicates", () => { @@ -16,3 +19,51 @@ describe("stringSetsEqual", () => { expect(stringSetsEqual(["nous-web", "nous-code"], ["nous-web"])).toBe(false); }); }); + +describe("setupHermesToolGateways interactive selector EOF (#12169)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + function openSelector() { + // An inherited preset would bypass the interactive menu. + vi.stubEnv("NEMOCLAW_HERMES_TOOL_GATEWAYS", ""); + vi.stubEnv("NEMOCLAW_HERMES_TOOL_GATEWAY_PRESETS", ""); + const input = Object.assign(new PassThrough(), { + isTTY: true, + setRawMode: vi.fn(), + ref: vi.fn(), + unref: vi.fn(), + }); + const output = Object.assign(new PassThrough(), { isTTY: true }); + const select = () => + setupHermesToolGateways(HERMES_PROVIDER_NAME, "oauth", null, { + prompt: vi.fn(), + note: vi.fn(), + isNonInteractive: () => false, + input: input as unknown as NodeJS.ReadStream, + output: output as unknown as NodeJS.WriteStream, + }); + return { input, select }; + } + + it("cancels on an EOF queued before the menu enters raw mode", async () => { + const { input, select } = openSelector(); + input.end(); + + await expect(select()).rejects.toMatchObject({ code: "EOF" }); + expect(input.setRawMode).not.toHaveBeenCalledWith(true); + expect(input.listenerCount("data")).toBe(0); + }); + + it("cancels on Ctrl-D typed inside the raw-mode menu and restores the terminal", async () => { + const { input, select } = openSelector(); + const pending = select(); + await vi.waitFor(() => expect(input.setRawMode).toHaveBeenCalledWith(true)); + input.write("\u0004"); + + await expect(pending).rejects.toMatchObject({ code: "EOF" }); + expect(input.setRawMode).toHaveBeenLastCalledWith(false); + expect(input.listenerCount("data")).toBe(0); + }); +});