Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,9 @@ If you enable Telegram during onboarding, the wizard can also prompt for whether

</AgentOnly>

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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
Expand Down
75 changes: 75 additions & 0 deletions src/lib/core/pending-stdin-eof.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
110 changes: 110 additions & 0 deletions src/lib/core/pending-stdin-eof.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> | false {
if (input.isTTY !== true) return false;
return new Promise((resolve) => {
const chunks: Array<string | Uint8Array> = [];
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();
}
56 changes: 53 additions & 3 deletions src/lib/credentials/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -626,6 +631,17 @@ export function removeLegacyCredentialsFileIfEmpty(): boolean {
* rejects with `code: "SIGINT"` on Ctrl-C.
*/
export function promptSecret(question: string, maskCap?: number): Promise<string> {
// 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<string> {
return new Promise((resolve, reject) => {
const input = process.stdin;
const output = process.stderr;
Expand Down Expand Up @@ -677,7 +693,7 @@ export function promptSecret(question: string, maskCap?: number): Promise<string
}

function onInputClosed() {
rejectPrompt(Object.assign(new Error("Prompt closed before input"), { code: "EOF" }));
rejectPrompt(pendingStdinEofError());
}

// With maskCap set, cap the asterisks and add an "(and N more characters)"
Expand Down Expand Up @@ -730,6 +746,20 @@ export function promptSecret(question: string, maskCap?: number): Promise<string
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;
Expand Down Expand Up @@ -805,6 +835,26 @@ export function isSecureCredentialPromptAvailable(
export function prompt(
question: string,
opts: { secret?: boolean; maskCap?: number } = {},
): Promise<string> {
// 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<string> {
return new Promise((resolve, reject) => {
// Re-attach stdin to the event loop before any prompt path. unref() in
Expand All @@ -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") {
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 2 additions & 2 deletions src/lib/onboard/cancel-rollback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 53 additions & 2 deletions src/lib/onboard/hermes-managed-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
});
Loading
Loading