diff --git a/docs/deployment/deploy-to-headless-server.mdx b/docs/deployment/deploy-to-headless-server.mdx index 8ab30001168..1d553a376ba 100644 --- a/docs/deployment/deploy-to-headless-server.mdx +++ b/docs/deployment/deploy-to-headless-server.mdx @@ -367,6 +367,10 @@ Snapshot restore and rebuild replay that recorded preset even when the original Keep the source YAML in your configuration repository so operators can review and change it. For the preset schema and removal workflow, refer to [Network Policies](../reference/network-policies). +An SSH command without `-t`, a service unit, and a CI job have no terminal on stdin, so the preset picker cannot run there. +Pass the preset name, `--from-file`, or `--from-dir` in such a session. +`policy add` and `policy remove` skip their confirmation prompts without a terminal on stdin, so neither needs `--yes` or `NEMOCLAW_NON_INTERACTIVE=1`. + ## Plan for Updates and Rebuilds Create a named snapshot before host maintenance or a manual update: diff --git a/docs/manage-sandboxes/add-channels-after-onboarding.mdx b/docs/manage-sandboxes/add-channels-after-onboarding.mdx index f496fed2393..85da3e6fc61 100644 --- a/docs/manage-sandboxes/add-channels-after-onboarding.mdx +++ b/docs/manage-sandboxes/add-channels-after-onboarding.mdx @@ -80,6 +80,8 @@ $$nemoclaw my-assistant rebuild ## Add a Channel Non-Interactively Set required environment variables before running `channels add`. +A run without a terminal on stdin is non-interactive even when `NEMOCLAW_NON_INTERACTIVE=1` is unset. +An SSH command without `-t`, a service unit, and a CI job all run without a terminal. Missing credentials fail fast, and the command queues the change for a manual rebuild: ```bash @@ -122,7 +124,7 @@ The login has an eight-minute deadline and refreshes the QR up to three times on Keep the terminal in the foreground until you see `✓ WeChat login confirmed`. The command requires an interactive terminal. -`NEMOCLAW_NON_INTERACTIVE=1` fails fast because the QR handshake needs a paired phone. +`NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, fails fast because the QR handshake needs a paired phone. ```bash $$nemoclaw my-assistant channels add wechat diff --git a/docs/manage-sandboxes/runtime-controls.mdx b/docs/manage-sandboxes/runtime-controls.mdx index 43d3d6f9bfb..9f83f623c0f 100644 --- a/docs/manage-sandboxes/runtime-controls.mdx +++ b/docs/manage-sandboxes/runtime-controls.mdx @@ -160,6 +160,7 @@ When recovery cannot complete, an interactive command returns an error, or the d NemoClaw also records durable containment when an owner exits before it can prove that the owner's descendants stopped, or when ownership becomes ambiguous. Durable containment, retained exact gates, or the fail-closed state-directory error keeps new mutations blocked until you complete exact-generation operator recovery. +A `--dry-run` run of a `channels` or `policy` command takes no mutation lock, so you can still preview the change while mutations are blocked. Stop all NemoClaw processes for the sandbox, then follow the paths, identities, tokens, and removal order in the reported error. Verify each recorded generation is unchanged, remove only the exact stale generations first, and remove the exact containment generation last. diff --git a/docs/network-policy/apply-policy-presets.mdx b/docs/network-policy/apply-policy-presets.mdx index 3ce99ed506e..1ebc10e6c03 100644 --- a/docs/network-policy/apply-policy-presets.mdx +++ b/docs/network-policy/apply-policy-presets.mdx @@ -66,8 +66,12 @@ Omit the preset name to use the interactive picker: $$nemoclaw my-assistant policy add ``` +The picker requires a terminal on stdin. +A run without a terminal, such as an SSH command without `-t`, a service unit, or a CI job, exits non-zero and reports that no input is available on stdin. + Pass a preset name with `--yes` for scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` to use the same non-interactive flow through an environment variable. +With that variable set, a missing preset name instead reports that non-interactive mode requires a preset name. ## Reapply an Edited Preset diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 110a76a55fb..1f8c78b4caf 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -2230,6 +2230,9 @@ $$nemoclaw my-assistant policy add pypi --yes The positional form is required in scripted workflows. Set `NEMOCLAW_NON_INTERACTIVE=1` instead of `--yes` if you want the same behavior from an environment variable. +Without a preset name, a run with `NEMOCLAW_NON_INTERACTIVE=1` reports that non-interactive mode requires a preset name. +A run without a terminal on stdin instead reports that no input is available on stdin. +Both exit non-zero rather than open the picker. If the preset name is unknown, the command exits non-zero with a clear error. If a named preset is already applied, the command compares the preset content with the live policy. When the content matches, the command reports no changes and exits zero. @@ -2343,6 +2346,7 @@ $$nemoclaw my-assistant policy remove pypi --yes ``` Set `NEMOCLAW_NON_INTERACTIVE=1` as an alternative to `--yes`. +Without a preset name, `policy remove` reports the same two picker errors as `policy add` and exits non-zero. If the preset is unknown or not currently applied, the command exits non-zero with a clear error. | Flag | Description | @@ -2362,6 +2366,7 @@ If the active agent or entry changes, rebuild fails closed until you clear or re The command refuses to exclude a key that an applied preset already owns, because removing that live key would also remove the preset's access. The critical `managed_inference` entry cannot currently be excluded pending product direction. Use `--force` or `--yes` for explicit non-interactive acknowledgement, or `--dry-run` to preview without changing the sandbox. +A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, does not prompt and requires one of those acknowledgement flags. ```bash $$nemoclaw my-assistant policy exclude nous_research --dry-run @@ -2382,6 +2387,7 @@ After you review the output, `--force` allows that egress again and clears the e When the baseline no longer defines the entry, `--dry-run` states that restoration will clear only the stale exclusion record. After you review the output, `--force` clears that record without changing live egress. Both paths require explicit acknowledgement unless you use `--dry-run`; use `--force` or `--yes` for non-interactive acknowledgement. +As with `policy exclude`, a run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, does not prompt. If a restore is interrupted, NemoClaw finalizes it only when the durable exclusion still exactly matches the staged exclusion and the current release baseline still exactly matches the journaled live target. If either value changed or the current baseline is unreadable, the journal remains in `repair required` state so you can inspect and re-review the current scope instead of silently accepting a different entry. @@ -2536,8 +2542,9 @@ Run only one active Slack sandbox on each OpenShell gateway, and use distinct Sl Optional Slack allowlists come from `SLACK_ALLOWED_USERS` and `SLACK_ALLOWED_CHANNELS` at rebuild time. Telegram and Discord mention mode default to `1` when no environment, session, or saved state value exists for that setting. Discord applies that default only when a server ID is configured. -When `NEMOCLAW_NON_INTERACTIVE=1` is set, any missing token fails fast and no rebuild prompt is shown. +A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, fails fast on any missing token and shows no rebuild prompt. Instead, the change is queued and you are told to run `$$nemoclaw rebuild` manually. +An SSH command without `-t`, a service unit, or a CI job has no terminal on stdin, so it does not need `NEMOCLAW_NON_INTERACTIVE=1` to reach this path. If you omit the required `` argument, the CLI prints the `channels add ` usage with the supported channel list instead of falling back to top-level help. ### `$$nemoclaw channels remove ` @@ -2564,7 +2571,8 @@ $$nemoclaw my-assistant channels remove telegram |------|-------------| | `--dry-run` | Report the channel that would be removed without clearing credentials or rebuilding | -As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` skips the rebuild prompt and queues the change for a manual `$$nemoclaw rebuild`. +As with `channels add`, `NEMOCLAW_NON_INTERACTIVE=1` or a run without a terminal on stdin skips the rebuild prompt and queues the change for a manual `$$nemoclaw rebuild`. +`channels start` and `channels stop` follow the same rule. If you omit the required `` argument, the CLI prints the `channels remove ` usage with the supported channel list. Host-side removal is the supported path because agent channel config is baked into the container image at build time (`/sandbox/.openclaw/openclaw.json` for OpenClaw and `/sandbox/.hermes/.env` for Hermes); agent-specific channel removals inside the sandbox would modify the running config but not persist changes across rebuilds. diff --git a/docs/reference/network-policies.mdx b/docs/reference/network-policies.mdx index 7c12b12dd95..bc3ec7a56c1 100644 --- a/docs/reference/network-policies.mdx +++ b/docs/reference/network-policies.mdx @@ -368,7 +368,7 @@ $$nemoclaw policy restore --force ``` Both restore paths require acknowledgement unless you use `--dry-run`. -Interactive runs prompt for confirmation when no acknowledgement flag is present. -Non-interactive runs require `--force`, `--yes`, or `-y`. +A run with a terminal on stdin prompts for confirmation when no acknowledgement flag is present. +A run with `NEMOCLAW_NON_INTERACTIVE=1`, or a run without a terminal on stdin, requires `--force`, `--yes`, or `-y`. Excluding a baseline entry leaves agent features that depend on it unsupported for that sandbox. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 464ca5bfb81..545dece7ca2 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -2215,7 +2215,7 @@ $$nemoclaw channels remove `channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. Both offer to rebuild the sandbox so the image reflects the new channel set. -In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`), the commands stage the change and leave the rebuild to a follow-up `$$nemoclaw rebuild`. +In non-interactive mode (`NEMOCLAW_NON_INTERACTIVE=1`, or any run without a terminal on stdin), the commands stage the change and leave the rebuild to a follow-up `$$nemoclaw rebuild`. WeChat and WhatsApp are experimental. Review [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them. diff --git a/docs/security/credential-rotation.mdx b/docs/security/credential-rotation.mdx index 8ca94a2f525..9628f986403 100644 --- a/docs/security/credential-rotation.mdx +++ b/docs/security/credential-rotation.mdx @@ -128,6 +128,8 @@ The same onboarding flow evaluates active `WECHAT_BOT_TOKEN` and `MSTEAMS_APP_PA Supply the replacement credential together with any required channel configuration. Rerun onboarding interactively when the channel uses a guided credential flow such as WeChat pairing. +For other `channels add` management, run the command from a terminal and omit `NEMOCLAW_NON_INTERACTIVE=1` and token variables to be prompted for values and offered an interactive rebuild. A session without a terminal on stdin fails fast on a missing token instead of prompting for it. When token variables are set, that session queues the change for a manual rebuild. + ## Rotate a Web Search Key Web search provider configuration and credential attachment are baked into the sandbox image. diff --git a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts index 0cff319aa4b..4fae336ecff 100644 --- a/src/lib/actions/sandbox/policy-channel-add-drift.test.ts +++ b/src/lib/actions/sandbox/policy-channel-add-drift.test.ts @@ -49,6 +49,7 @@ let applyPresetMock: MockInstance; let gatewayStateMock: MockInstance; let npmCompatibilityStateMock: MockInstance; let refreshSpy: MockInstance; +let stdinIsTty: PropertyDescriptor | undefined; async function captureExit(action: () => Promise): Promise { const outcome: unknown = await action().then( @@ -61,6 +62,8 @@ async function captureExit(action: () => Promise): Promise { delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -104,6 +107,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); }); describe("addSandboxPolicy drift-aware named re-add", () => { diff --git a/src/lib/actions/sandbox/policy-channel-baseline.test.ts b/src/lib/actions/sandbox/policy-channel-baseline.test.ts index a4d08a53ebb..b1d54c89a8e 100644 --- a/src/lib/actions/sandbox/policy-channel-baseline.test.ts +++ b/src/lib/actions/sandbox/policy-channel-baseline.test.ts @@ -60,8 +60,19 @@ async function captureExit(action: () => Promise): Promise { delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + arrangeTerminal(true); vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { @@ -89,6 +100,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); }); describe("excludeSandboxBaseline (#7178)", () => { @@ -128,6 +142,16 @@ describe("excludeSandboxBaseline (#7178)", () => { expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); }); + it("requires explicit acknowledgement when standard input has no terminal (#8877)", async () => { + arrangeTerminal(false); + + const code = await captureExit(() => excludeSandboxBaseline("alpha", { key: "nous_research" })); + + expect(code).toBe(1); + expect(promptMock).not.toHaveBeenCalled(); + expect(excludeBaselineEntryMock).not.toHaveBeenCalled(); + }); + it("excludes with a bound digest when acknowledged via --force", async () => { await excludeSandboxBaseline("alpha", { key: "nous_research", force: true }); expect(console.log).toHaveBeenCalledWith( @@ -216,6 +240,17 @@ describe("restoreSandboxBaseline (#7178)", () => { expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); }); + it("requires explicit restore acknowledgement when standard input has no terminal (#8877)", async () => { + getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); + arrangeTerminal(false); + + const code = await captureExit(() => restoreSandboxBaseline("alpha", { key: "nous_research" })); + + expect(code).toBe(1); + expect(promptMock).not.toHaveBeenCalled(); + expect(restoreBaselineEntryMock).not.toHaveBeenCalled(); + }); + it("does not restore when standard input closes before acknowledgement (#8114)", async () => { getBaselineExclusionsMock.mockReturnValue([{ key: "nous_research", digest: "digest-1" }]); promptMock.mockRejectedValue( diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 8690d5a2529..7cd4746273c 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -260,8 +260,12 @@ function conflictPromptShown(): boolean { ); } +let stdinIsTty: PropertyDescriptor | undefined; + beforeEach(() => { spies = []; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); delete process.env.NEMOCLAW_NON_INTERACTIVE; delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_ALLOWED_IDS; @@ -368,6 +372,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); for (const s of spies) s.mockRestore(); + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); delete process.env.NEMOCLAW_NON_INTERACTIVE; delete process.env.NEMOCLAW_SKIP_TELEGRAM_REACHABILITY; delete process.env.NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION; diff --git a/src/lib/actions/sandbox/policy-channel-lock.test.ts b/src/lib/actions/sandbox/policy-channel-lock.test.ts index 0a6ce4be6d9..9e61690e2ca 100644 --- a/src/lib/actions/sandbox/policy-channel-lock.test.ts +++ b/src/lib/actions/sandbox/policy-channel-lock.test.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as defs from "../../agent/defs"; +import * as policies from "../../policy"; +import * as registry from "../../state/registry"; const lockMocks = vi.hoisted(() => ({ withMcpLifecycleLock: vi.fn(async (_sandboxName: string, operation: () => unknown) => @@ -15,8 +19,10 @@ vi.mock("../../state/mcp-lifecycle-lock", () => lockMocks); import { addSandboxChannel, addSandboxPolicy, + excludeSandboxBaseline, removeSandboxChannel, removeSandboxPolicy, + restoreSandboxBaseline, startSandboxChannel, stopSandboxChannel, } from "./policy-channel"; @@ -24,6 +30,48 @@ import { describe("policy and channel sandbox mutation locking", () => { beforeEach(() => { lockMocks.withSandboxMutationLock.mockClear(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(defs, "loadAgent").mockReturnValue({ name: "hermes" } as defs.AgentDefinition); + vi.spyOn(registry, "getSandbox").mockReturnValue({ + name: "alpha", + agent: "hermes", + policies: ["pypi"], + }); + vi.spyOn(registry, "getCustomPolicies").mockReturnValue([]); + vi.spyOn(registry, "getBaselineExclusions").mockReturnValue([ + { version: 1, agent: "hermes", key: "nous_research", digest: "reviewed-digest" }, + ]); + vi.spyOn(registry, "getBaselineExclusionTransition").mockReturnValue(null); + vi.spyOn(registry, "getConfiguredMessagingChannelsFromEntry").mockReturnValue(["telegram"]); + vi.spyOn(registry, "getDisabledChannels").mockReturnValue(["telegram"]); + + vi.spyOn(policies, "listPresets").mockReturnValue([ + { file: "pypi.yaml", name: "pypi", description: "Python Package Index access" }, + ]); + vi.spyOn(policies, "listCustomPresets").mockReturnValue([]); + vi.spyOn(policies, "getAppliedPresets").mockReturnValue(["pypi"]); + vi.spyOn(policies, "loadPresetForSandbox").mockImplementation( + (_sandboxName, presetName) => + `network_policies:\n ${presetName}:\n name: ${presetName}\n endpoints:\n - host: example.com\n port: 443\n`, + ); + vi.spyOn(policies, "parsePresetPolicyKeys").mockReturnValue(["telegram"]); + vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("absent"); + vi.spyOn(policies, "getPresetValidationWarning").mockReturnValue(null); + vi.spyOn(policies, "getPresetEndpoints").mockReturnValue(["example.com"]); + vi.spyOn(policies, "resolveSandboxBaselinePolicy").mockReturnValue({ + agent: "hermes", + policyPath: "/repo/policy-additions.yaml", + content: "version: 1\nnetwork_policies: {}\n", + }); + vi.spyOn(policies, "getSandboxBaselineEntry").mockReturnValue({ + name: "nous_research", + endpoints: [{ host: "nousresearch.com", port: 443 }], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it.each([ @@ -39,4 +87,31 @@ describe("policy and channel sandbox mutation locking", () => { expect(lockMocks.withSandboxMutationLock).toHaveBeenCalledOnce(); expect(lockMocks.withSandboxMutationLock).toHaveBeenCalledWith("alpha", expect.any(Function)); }); + + it.each([ + ["policy add", () => addSandboxPolicy("alpha", { preset: "pypi", dryRun: true })], + ["policy remove", () => removeSandboxPolicy("alpha", { preset: "pypi", dryRun: true })], + [ + "policy exclude", + () => excludeSandboxBaseline("alpha", { key: "nous_research", dryRun: true }), + ], + [ + "policy restore", + () => restoreSandboxBaseline("alpha", { key: "nous_research", dryRun: true }), + ], + ["channel add", () => addSandboxChannel("alpha", { channel: "telegram", dryRun: true })], + ["channel remove", () => removeSandboxChannel("alpha", { channel: "telegram", dryRun: true })], + ["channel start", () => startSandboxChannel("alpha", { channel: "telegram", dryRun: true })], + [ + "channel stop", + async () => { + vi.mocked(registry.getDisabledChannels).mockReturnValue([]); + await stopSandboxChannel("alpha", { channel: "telegram", dryRun: true }); + }, + ], + ])("previews %s without taking the mutation lock (#8877)", async (_label, action) => { + await action(); + + expect(lockMocks.withSandboxMutationLock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/actions/sandbox/policy-channel-policy.test.ts b/src/lib/actions/sandbox/policy-channel-policy.test.ts index 4c42a58c3e5..29df1779853 100644 --- a/src/lib/actions/sandbox/policy-channel-policy.test.ts +++ b/src/lib/actions/sandbox/policy-channel-policy.test.ts @@ -72,8 +72,19 @@ function arrangeSandbox(agent: string | null = null): void { getSandboxMock.mockReturnValue({ name: "test-sandbox", agent, policies: ["pypi"] }); } +let stdinIsTty: PropertyDescriptor | undefined; + +function arrangeTerminal(present: boolean): void { + Object.defineProperty(process.stdin, "isTTY", { + configurable: true, + value: present ? true : undefined, + }); +} + beforeEach(() => { delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + arrangeTerminal(true); logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -116,6 +127,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); }); describe("addSandboxPolicy", () => { @@ -176,6 +190,16 @@ describe("addSandboxPolicy", () => { expect(applyPresetMock).not.toHaveBeenCalled(); }); + it("never reaches the picker in a session without a terminal (#8877)", async () => { + arrangeTerminal(false); + + await expect(captureExit(() => addSandboxPolicy("test-sandbox"))).resolves.toBe(1); + + expect(selectFromListMock).not.toHaveBeenCalled(); + expect(printedText()).toContain("No input available on stdin"); + expect(applyPresetMock).not.toHaveBeenCalled(); + }); + it("exits non-zero when the add picker reaches stdin EOF (#7418)", async () => { selectFromListMock.mockRejectedValueOnce( Object.assign(new Error("Prompt closed before input"), { code: "EOF" }), diff --git a/src/lib/actions/sandbox/policy-channel-refresh.test.ts b/src/lib/actions/sandbox/policy-channel-refresh.test.ts index 082b4b96ed1..9610caf1b76 100644 --- a/src/lib/actions/sandbox/policy-channel-refresh.test.ts +++ b/src/lib/actions/sandbox/policy-channel-refresh.test.ts @@ -63,8 +63,12 @@ async function captureExit(action: () => Promise): Promise { delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -115,6 +119,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); delete process.env.NEMOCLAW_NON_INTERACTIVE; + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); }); describe("addSandboxPolicy refresh contract", () => { diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 85fd5a70b91..8194e1b42c4 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { runOpenshell } from "../../adapters/openshell/runtime"; import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; -import { isNonInteractiveEnv } from "../../core/non-interactive"; +import { isNonInteractiveEnv, isNonInteractiveSession } from "../../core/non-interactive"; import { prompt as askPrompt, getCredential, @@ -81,7 +81,7 @@ import { policyChannelDependencies } from "./policy-channel-dependencies"; import { refreshSandboxPolicyContextFile } from "./policy-context-refresh"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; -const isNonInteractive = isNonInteractiveEnv; +const isNonInteractive = () => isNonInteractiveSession(); /** * Report that `NEMOCLAW_NON_INTERACTIVE=1` leaves no interactive picker, and @@ -94,12 +94,12 @@ function exitPresetNameRequired(usage: string): never { } /** - * Report that the picker prompt reached stdin EOF, and exit non-zero. + * Report that no picker can run in this session, and exit non-zero. * * Separate from `exitPresetNameRequired` because the conditions differ. That * one means the operator set `NEMOCLAW_NON_INTERACTIVE=1`. This one means - * stdin closed while that variable was unset, so naming the variable would - * misdirect whoever reads the boot-unit log. + * stdin is not a terminal, or closed, while that variable was unset, so naming + * the variable would misdirect whoever reads the boot-unit log. */ function exitPromptStdinClosed(usage: string): never { console.error(" No input available on stdin, so the preset picker cannot prompt."); @@ -135,6 +135,15 @@ type ChannelMutationOptions = { force?: boolean; }; +function withSandboxMutationLockUnlessPreview( + sandboxName: string, + dryRun: boolean | undefined, + operation: () => Promise, +): Promise { + if (dryRun) return operation(); + return withSandboxMutationLock(sandboxName, operation); +} + /** * Internal composition dependencies for channel mutation. * @@ -174,7 +183,9 @@ export async function addSandboxPolicy( sandboxName: string, options: PolicyAddOptions = {}, ): Promise { - return withSandboxMutationLock(sandboxName, () => addSandboxPolicyUnlocked(sandboxName, options)); + return withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => + addSandboxPolicyUnlocked(sandboxName, options), + ); } async function addSandboxPolicyUnlocked( @@ -367,9 +378,12 @@ async function addSandboxPolicyUnlocked( answer = preset.name; } else { const usage = `${CLI_NAME} policy add [--yes] [--dry-run]`; - if (isNonInteractive()) { + if (isNonInteractiveEnv()) { exitPresetNameRequired(usage); } + if (isNonInteractive()) { + exitPromptStdinClosed(usage); + } answer = await pickPresetOrExit(() => policies.selectFromList(allPresets, { applied }), usage); } if (!answer) return; @@ -1307,7 +1321,7 @@ export async function addSandboxChannel( options: ChannelMutationOptions = {}, dependencies: AddSandboxChannelDependencies = {}, ): Promise { - return withSandboxMutationLock(sandboxName, () => + return withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => addSandboxChannelUnlocked(sandboxName, options, dependencies), ); } @@ -1717,7 +1731,7 @@ export async function removeSandboxChannel( sandboxName: string, options: ChannelMutationOptions = {}, ): Promise { - return withSandboxMutationLock(sandboxName, () => + return withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => removeSandboxChannelUnlocked(sandboxName, options), ); } @@ -1933,7 +1947,7 @@ export async function stopSandboxChannel( sandboxName: string, options: ChannelMutationOptions = {}, ): Promise { - await withSandboxMutationLock(sandboxName, () => + await withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => sandboxChannelsSetEnabled(sandboxName, options, true), ); } @@ -1942,7 +1956,7 @@ export async function startSandboxChannel( sandboxName: string, options: ChannelMutationOptions = {}, ): Promise { - await withSandboxMutationLock(sandboxName, () => + await withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => sandboxChannelsSetEnabled(sandboxName, options, false), ); } @@ -1951,7 +1965,7 @@ export async function removeSandboxPolicy( sandboxName: string, options: PolicyRemoveOptions = {}, ): Promise { - return withSandboxMutationLock(sandboxName, () => + return withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => removeSandboxPolicyUnlocked(sandboxName, options), ); } @@ -1989,9 +2003,12 @@ async function removeSandboxPolicyUnlocked( answer = preset.name; } else { const usage = `${CLI_NAME} policy remove [--yes] [--dry-run]`; - if (isNonInteractive()) { + if (isNonInteractiveEnv()) { exitPresetNameRequired(usage); } + if (isNonInteractive()) { + exitPromptStdinClosed(usage); + } answer = await pickPresetOrExit( () => policies.selectForRemoval(allPresets, { applied }), usage, @@ -2047,7 +2064,7 @@ export async function excludeSandboxBaseline( sandboxName: string, options: PolicyBaselineOptions = {}, ): Promise { - return withSandboxMutationLock(sandboxName, () => + return withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => excludeSandboxBaselineUnlocked(sandboxName, options), ); } @@ -2133,7 +2150,7 @@ export async function restoreSandboxBaseline( sandboxName: string, options: PolicyBaselineOptions = {}, ): Promise { - return withSandboxMutationLock(sandboxName, () => + return withSandboxMutationLockUnlessPreview(sandboxName, options.dryRun, () => restoreSandboxBaselineUnlocked(sandboxName, options), ); } diff --git a/src/lib/core/non-interactive.test.ts b/src/lib/core/non-interactive.test.ts index a2cd7d7680b..e4451c58e76 100644 --- a/src/lib/core/non-interactive.test.ts +++ b/src/lib/core/non-interactive.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { isNonInteractiveEnv } from "./non-interactive"; +import { isNonInteractiveEnv, isNonInteractiveSession } from "./non-interactive"; afterEach(() => { vi.unstubAllEnvs(); @@ -28,3 +28,32 @@ describe("non-interactive environment detection", () => { expect(isNonInteractiveEnv()).toBe(false); }); }); + +describe("non-interactive session detection", () => { + it("treats a session without a stdin terminal as non-interactive (#8877)", () => { + expect(isNonInteractiveSession({} as NodeJS.ProcessEnv, false)).toBe(true); + expect(isNonInteractiveSession({} as NodeJS.ProcessEnv, true)).toBe(false); + }); + + it("keeps the explicit environment value authoritative on a terminal", () => { + expect( + isNonInteractiveSession({ NEMOCLAW_NON_INTERACTIVE: "1" } as NodeJS.ProcessEnv, true), + ).toBe(true); + }); + + it("reads process.stdin when called without a terminal argument", () => { + vi.stubEnv("NEMOCLAW_NON_INTERACTIVE", ""); + const originalDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + try { + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + expect(isNonInteractiveSession()).toBe(false); + + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: undefined }); + expect(isNonInteractiveSession()).toBe(true); + } finally { + originalDescriptor + ? Object.defineProperty(process.stdin, "isTTY", originalDescriptor) + : Reflect.deleteProperty(process.stdin, "isTTY"); + } + }); +}); diff --git a/src/lib/core/non-interactive.ts b/src/lib/core/non-interactive.ts index 72f71c4a876..d24cf0a393f 100644 --- a/src/lib/core/non-interactive.ts +++ b/src/lib/core/non-interactive.ts @@ -4,3 +4,10 @@ export function isNonInteractiveEnv(env: NodeJS.ProcessEnv = process.env): boolean { return env.NEMOCLAW_NON_INTERACTIVE === "1"; } + +export function isNonInteractiveSession( + env: NodeJS.ProcessEnv = process.env, + stdinIsTty: boolean = Boolean(process.stdin.isTTY), +): boolean { + return isNonInteractiveEnv(env) || !stdinIsTty; +} diff --git a/src/lib/domain/policy-channel.test.ts b/src/lib/domain/policy-channel.test.ts index a85f39da56e..17c53c566bc 100644 --- a/src/lib/domain/policy-channel.test.ts +++ b/src/lib/domain/policy-channel.test.ts @@ -23,7 +23,7 @@ describe("policy channel helpers", () => { }); it("parses policy add option errors", () => { - expect(parsePolicyAddOptions({ fromFile: "a.yaml", fromDir: "dir" }, {})).toEqual({ + expect(parsePolicyAddOptions({ fromFile: "a.yaml", fromDir: "dir" }, {}, true)).toEqual({ dryRun: false, skipConfirm: false, source: { kind: "error", message: "--from-file and --from-dir are mutually exclusive." }, @@ -31,7 +31,7 @@ describe("policy channel helpers", () => { trustedPrivateHosts: [], commandTrustedPrivateHosts: [], }); - expect(parsePolicyAddOptions({ fromFile: "" }, {})).toEqual({ + expect(parsePolicyAddOptions({ fromFile: "" }, {}, true)).toEqual({ dryRun: false, skipConfirm: false, source: { kind: "error", message: "--from-file requires a path argument." }, @@ -42,7 +42,7 @@ describe("policy channel helpers", () => { }); it("limits trusted private hosts to custom policy input (#8176)", () => { - expect(parsePolicyAddOptions({ trustedPrivateHosts: ["api.corp.example"] }, {})).toEqual({ + expect(parsePolicyAddOptions({ trustedPrivateHosts: ["api.corp.example"] }, {}, true)).toEqual({ dryRun: false, skipConfirm: false, source: { @@ -74,9 +74,15 @@ describe("policy channel helpers", () => { }); it("detects policy confirmation bypass options", () => { - expect(parsePolicyAddOptions({ yes: true }, {}).skipConfirm).toBe(true); - expect(parsePolicyAddOptions({ force: true }, {}).skipConfirm).toBe(true); - expect(parsePolicyAddOptions({}, { NEMOCLAW_NON_INTERACTIVE: "1" }).skipConfirm).toBe(true); - expect(parsePolicyAddOptions({}, {}).skipConfirm).toBe(false); + expect(parsePolicyAddOptions({ yes: true }, {}, true).skipConfirm).toBe(true); + expect(parsePolicyAddOptions({ force: true }, {}, true).skipConfirm).toBe(true); + expect(parsePolicyAddOptions({}, { NEMOCLAW_NON_INTERACTIVE: "1" }, true).skipConfirm).toBe( + true, + ); + expect(parsePolicyAddOptions({}, {}, true).skipConfirm).toBe(false); + }); + + it("skips the confirmation prompt in a session without a terminal (#8877)", () => { + expect(parsePolicyAddOptions({}, {}, false).skipConfirm).toBe(true); }); }); diff --git a/src/lib/domain/policy-channel.ts b/src/lib/domain/policy-channel.ts index 6cf7fcc8356..7e4eeb5e1be 100644 --- a/src/lib/domain/policy-channel.ts +++ b/src/lib/domain/policy-channel.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { isNonInteractiveSession } from "../core/non-interactive"; import { parseTrustedPrivateHosts } from "../security/trusted-private-endpoint"; export type CustomPolicySource = @@ -67,6 +68,7 @@ function customPolicySourceFromOptions(options: PolicyAddOptions): CustomPolicyS export function parsePolicyAddOptions( options: PolicyAddOptions = {}, env: Record = process.env, + stdinIsTty: boolean = Boolean(process.stdin.isTTY), ): ParsedPolicyAddOptions { const source = customPolicySourceFromOptions(options); const commandTrustedPrivateHosts = options.trustedPrivateHosts ?? []; @@ -83,7 +85,9 @@ export function parsePolicyAddOptions( : source; return { dryRun: Boolean(options.dryRun), - skipConfirm: Boolean(options.yes || options.force || env.NEMOCLAW_NON_INTERACTIVE === "1"), + skipConfirm: Boolean( + options.yes || options.force || isNonInteractiveSession(env as NodeJS.ProcessEnv, stdinIsTty), + ), source: effectiveSource, presetArg: options.preset ?? null, trustedPrivateHosts, diff --git a/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts index 8b5d93b66d9..af4428b1f8d 100644 --- a/test/channels-add-bridge-lifecycle.test.ts +++ b/test/channels-add-bridge-lifecycle.test.ts @@ -82,6 +82,7 @@ let testHome: string; let registryEntry: SandboxEntry; let appliedPresets: string[]; let session: onboardSession.Session; +let stdinIsTty: PropertyDescriptor | undefined; function printedText(): string { return [...logSpy.mock.calls, ...errorSpy.mock.calls] @@ -94,6 +95,8 @@ function openshellCalls(): string[][] { } beforeEach(() => { + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); testHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-add-bridge-")); process.env.HOME = testHome; process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -188,6 +191,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); fs.rmSync(testHome, { recursive: true, force: true }); for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, originalProcessEnv); @@ -228,6 +234,18 @@ describe("channels add owns the bridge-provider lifecycle (#6120)", () => { expect(printedText()).toContain("Registered googlechat bridge"); }); + it("queues the rebuild instead of prompting when the session has no terminal (#8877)", async () => { + delete process.env.NEMOCLAW_NON_INTERACTIVE; + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: undefined }); + const promptSpy = vi.spyOn(store, "prompt"); + + await addSandboxChannel("test-sb", { channel: "googlechat" }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(policyChannelDependencies.rebuildSandbox).not.toHaveBeenCalled(); + expect(printedText()).toContain("Change queued."); + }); + it("fails loudly at add time when the bridge secret is not resolvable", async () => { delete process.env.GOOGLECHAT_SERVICE_ACCOUNT; diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 9792fe7b3e5..021e8cd1cbe 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -166,8 +166,12 @@ function setSession( sessionState = { sandboxName, policyPresets } as onboardSession.Session; } +let stdinIsTty: PropertyDescriptor | undefined; + beforeEach(() => { for (const key of TEST_ENV_KEYS) delete process.env[key]; + stdinIsTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); testHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-channels-add-preset-")); process.env.HOME = testHome; process.env.NEMOCLAW_NON_INTERACTIVE = "1"; @@ -323,6 +327,9 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + stdinIsTty + ? Object.defineProperty(process.stdin, "isTTY", stdinIsTty) + : Reflect.deleteProperty(process.stdin, "isTTY"); fs.rmSync(testHome, { recursive: true, force: true }); for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, originalProcessEnv); diff --git a/test/package-contract/cli/policy-dispatch.test.ts b/test/package-contract/cli/policy-dispatch.test.ts index 3dab297fc93..f34baf4b514 100644 --- a/test/package-contract/cli/policy-dispatch.test.ts +++ b/test/package-contract/cli/policy-dispatch.test.ts @@ -248,15 +248,27 @@ network_policies: }); }); - it("does not apply an external preset when the confirmation prompt is declined", () => { + it("applies an explicitly named file without prompting when stdin has no terminal", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-from-file-no-")); const file = path.join(tmp, "custom-rule.yaml"); fs.writeFileSync(file, "preset:\n name: custom-rule\nnetwork_policies: {}\n"); - const result = runPolicyAddExternal(["--from-file", file], {}, "no"); + const result = runPolicyAddExternal( + ["--from-file", file], + { NEMOCLAW_NON_INTERACTIVE: undefined }, + "no", + ); expect(result.status).toBe(0); const calls = JSON.parse(result.stdout.split("__CALLS__")[1].trim()) as PolicyCall[]; - expect(calls.some((c) => c.type === "prompt")).toBeTruthy(); - expect(calls.some((c) => c.type === "apply")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "load", + path: file, + }); + expect(calls.some((c) => c.type === "prompt")).toBeFalsy(); + expect(calls).toContainEqual({ + type: "apply", + sandboxName: "test-sandbox", + presetName: "custom-rule", + }); }); it("errors when --from-file and --from-dir are combined", () => { diff --git a/test/package-contract/cli/policy-prompt-eof.test.ts b/test/package-contract/cli/policy-prompt-eof.test.ts index d56e24e38f5..c1601ddef59 100644 --- a/test/package-contract/cli/policy-prompt-eof.test.ts +++ b/test/package-contract/cli/policy-prompt-eof.test.ts @@ -2,17 +2,16 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Prompt-cancellation package contract for the policy preset pickers (#7418). + * Non-interactive package contract for policy preset selection (#7418). * * A boot unit runs `nemoclaw policy-add` with no preset name and a - * closed stdin. That reaches the interactive picker, and the prompt hits EOF. - * The `question` callback never fired, the picker promise never settled, and - * the CLI exited 0 having applied nothing. Automation could not distinguish - * an applied preset from a no-op. + * pipe-backed stdin. The CLI must reject the missing preset before it starts + * the interactive picker. Automation can then distinguish refusal from a + * successful mutation. * - * These tests drive the compiled CLI (`dist/nemoclaw.js`) on real stdin at - * EOF, so readline decides when the prompt closes. Only the registry and - * preset lookups are stubbed, which replaces on-disk sandbox state. + * 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. */ import { spawnSync } from "node:child_process"; @@ -28,8 +27,8 @@ const POLICIES_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "policy const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "state", "registry.js")); /** - * Run a policy command with no preset name against a closed stdin. `input: ""` - * gives the child an already-ended pipe, which is the EOF a boot unit + * 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") { @@ -76,7 +75,7 @@ describe("policy preset prompt cancellation", () => { menu: "Applied presets:", usage: "policy remove ", }, - ])("$command exits non-zero when the picker prompt hits EOF (#7418)", ({ + ])("$command exits non-zero before opening a picker without a terminal (#7418)", ({ command, menu, usage, @@ -88,9 +87,7 @@ describe("policy preset prompt cancellation", () => { // pre-#7418 regression exited 0 and is caught by the final assertion. expect(result.error).toBeUndefined(); expect(result.signal).toBeNull(); - // The picker was reached, so this is prompt EOF rather than the - // NEMOCLAW_NON_INTERACTIVE=1 guard exiting earlier. - expect(result.stderr).toContain(menu); + expect(result.stderr).not.toContain(menu); expect(result.stderr).toContain("No input available on stdin"); expect(result.stderr).toContain(usage); expect(result.status).toBe(1); diff --git a/test/package-contract/cli/policy-restore-acknowledgement.test.ts b/test/package-contract/cli/policy-restore-acknowledgement.test.ts index 05a8a060c6a..7426ef72be6 100644 --- a/test/package-contract/cli/policy-restore-acknowledgement.test.ts +++ b/test/package-contract/cli/policy-restore-acknowledgement.test.ts @@ -4,9 +4,8 @@ /** * Acknowledgement package contract for `policy restore`. * - * Declining confirmation must report cancellation so the operator knows no - * mutation occurred. A non-interactive refusal must print the same usage line - * as the prompt EOF path. + * A session without terminal input must require explicit acknowledgement and + * must not interpret pipe input as an interactive confirmation. * * These tests drive the compiled CLI (`dist/nemoclaw.js`) over a real stdin * pipe. The helper stubs registry and baseline lookups. It replaces @@ -75,15 +74,18 @@ require(${CLI_PATH}); } describe("policy restore acknowledgement", () => { - it("reports the cancellation when the operator declines the confirmation", () => { + it("requires explicit acknowledgement when pipe input contains a decline", () => { const result = runPolicyRestore({ input: "n\n", nonInteractive: false }); expect(result.error).toBeUndefined(); expect(result.signal).toBeNull(); expect(result.stdout).toContain("re-allows:"); - expect(result.stdout).toContain("Cancelled."); + expect(result.stderr).toContain( + "Non-interactive restore requires explicit acknowledgement: pass --force (or --yes).", + ); + expect(result.stderr).toContain(USAGE); expect(result.stdout).not.toContain(RESTORED_MARKER); - expect(result.status).toBe(0); + expect(result.status).toBe(1); }, 45_000); it("prints the usage line when non-interactive mode has no acknowledgement", () => { @@ -99,13 +101,13 @@ describe("policy restore acknowledgement", () => { expect(result.status).toBe(1); }, 45_000); - it("prints the same usage line when the confirmation prompt hits stdin EOF", () => { + it("prints the non-interactive usage line when stdin is an ended pipe", () => { const result = runPolicyRestore({ input: "", nonInteractive: false }); expect(result.error).toBeUndefined(); expect(result.signal).toBeNull(); expect(result.stderr).toContain( - "No input available on stdin, so policy restore cannot prompt.", + "Non-interactive restore requires explicit acknowledgement: pass --force (or --yes).", ); expect(result.stderr).toContain(USAGE); expect(result.stdout).not.toContain(RESTORED_MARKER);