diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 9aba8dacd84..cac425897dc 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": 18, "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": 17, "scripts/checks/run-managed-image-openshell-e2e.ts": 18 } diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index f74a9596d0d..ea3a8eba72a 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1015,6 +1015,9 @@ 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. 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..74191852e66 --- /dev/null +++ b/src/lib/core/pending-stdin-eof.test.ts @@ -0,0 +1,75 @@ +// 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("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); + + 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..b9d8d4cbdb6 --- /dev/null +++ b/src/lib/core/pending-stdin-eof.ts @@ -0,0 +1,110 @@ +// 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, 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. + * + * 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); + // 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 { + 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" }); +} + +/** + * 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 61c831b7524..1d1604cc4a6 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -14,6 +14,11 @@ import path from "node:path"; import readline from "node:readline"; import { isErrnoException } from "../core/errno"; +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"; @@ -626,6 +631,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) cancelPromptWithPendingEof(question); + return readSecretAnswer(question, maskCap); + }); +} + +function readSecretAnswer(question: string, maskCap?: number): Promise { return new Promise((resolve, reject) => { const input = process.stdin; const output = process.stderr; @@ -677,7 +693,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) cancelPromptWithPendingEof(question); + 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 @@ -817,7 +867,7 @@ export function prompt( } const silent = opts.secret === true && isSecureCredentialPromptAvailable(); if (silent) { - promptSecret(question, opts.maskCap) + readSecretAnswer(question, opts.maskCap) .then(resolve) .catch((error: NodeJS.ErrnoException) => { if (error && error.code === "SIGINT") { @@ -874,7 +924,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/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.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); + }); +}); 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 fb2270dbb49..e0b538b45ff 100644 --- a/src/lib/onboard/messaging-selector.ts +++ b/src/lib/onboard/messaging-selector.ts @@ -3,6 +3,11 @@ import readline from "node:readline"; +import { + cancelPromptWithPendingEof, + pendingStdinEofError, + takePendingStdinEof, +} from "../core/pending-stdin-eof"; import { markPromptActive } from "../core/prompt-activity"; export interface MessagingChannelSelectorEntry { @@ -20,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; @@ -68,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); @@ -151,6 +159,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 +235,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) cancelPromptWithPendingEof(question); + 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 +354,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/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/src/lib/policy/index.ts b/src/lib/policy/index.ts index 865355f3a53..63456dce164 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -25,6 +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 { + cancelPromptWithPendingEof, + pendingStdinEofError, + takePendingStdinEof, +} from "../core/pending-stdin-eof"; import { getMessagingPolicyKeyAliases, getMessagingPolicyPresetValidationWarnings, @@ -2195,10 +2200,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) cancelPromptWithPendingEof(question); + 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) => { @@ -2217,9 +2233,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 8b97b02dffb..43964c1063b 100644 --- a/test/credentials/credentials.test.ts +++ b/test/credentials/credentials.test.ts @@ -960,6 +960,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'); }; @@ -1266,12 +1270,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); }); @@ -1304,4 +1311,58 @@ process.stderr.isTTY = true; 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"); + }); }); 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); + }, +); 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"); + }); +}); diff --git a/test/package-contract/onboard/onboard-stdin-eof.test.ts b/test/package-contract/onboard/onboard-stdin-eof.test.ts index 4308dae55f4..3691db4e7d6 100644 --- a/test/package-contract/onboard/onboard-stdin-eof.test.ts +++ b/test/package-contract/onboard/onboard-stdin-eof.test.ts @@ -11,6 +11,9 @@ * then leaked the rejected prompt error as a raw Node.js stack trace. * - #11039: Ctrl+C at agent selection drained the process before signal * delivery and returned success instead of the interrupt status. + * - #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 @@ -100,6 +103,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 () => {