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
4 changes: 4 additions & 0 deletions docs/deployment/deploy-to-headless-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion docs/manage-sandboxes/add-channels-after-onboarding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/manage-sandboxes/runtime-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/network-policy/apply-policy-presets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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 <name> 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 `<channel>` argument, the CLI prints the `channels add <channel>` usage with the supported channel list instead of falling back to top-level help.

### `$$nemoclaw <name> channels remove <channel>`
Expand All @@ -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 <name> 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 <name> rebuild`.
`channels start` and `channels stop` follow the same rule.
If you omit the required `<channel>` argument, the CLI prints the `channels remove <channel>` 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.
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/network-policies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ $$nemoclaw <sandbox-name> policy restore <key> --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.
2 changes: 1 addition & 1 deletion docs/reference/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2215,7 +2215,7 @@ $$nemoclaw <sandbox> channels remove <telegram|discord|slack|wechat|whatsapp>

`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 <sandbox> 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 <sandbox> rebuild`.
WeChat and WhatsApp are experimental.
Review [Choose Messaging Channels](../manage-sandboxes/messaging-channels/choose-messaging-channels) before enabling them.

Expand Down
2 changes: 2 additions & 0 deletions docs/security/credential-rotation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/lib/actions/sandbox/policy-channel-add-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<number | undefined> {
const outcome: unknown = await action().then(
Expand All @@ -61,6 +62,8 @@ async function captureExit(action: () => Promise<void>): Promise<number | undefi

beforeEach(() => {
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);
Expand Down Expand Up @@ -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", () => {
Expand Down
35 changes: 35 additions & 0 deletions src/lib/actions/sandbox/policy-channel-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,19 @@ async function captureExit(action: () => Promise<void>): Promise<number | undefi
throw new Error("Expected process.exit to be called");
}

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);
vi.spyOn(console, "log").mockImplementation(() => undefined);
vi.spyOn(console, "error").mockImplementation(() => undefined);
exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
Expand Down Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions src/lib/actions/sandbox/policy-channel-conflict.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
77 changes: 76 additions & 1 deletion src/lib/actions/sandbox/policy-channel-lock.test.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand All @@ -15,15 +19,59 @@ vi.mock("../../state/mcp-lifecycle-lock", () => lockMocks);
import {
addSandboxChannel,
addSandboxPolicy,
excludeSandboxBaseline,
removeSandboxChannel,
removeSandboxPolicy,
restoreSandboxBaseline,
startSandboxChannel,
stopSandboxChannel,
} from "./policy-channel";

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([
Expand All @@ -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();
});
});
Loading
Loading