Skip to content
42 changes: 41 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,44 @@ $$nemoclaw my-assistant config get --key model --format yaml
| `--key <dotpath>` | Print one value from the sanitized config |
| `--format json\|yaml` | Output format. Defaults to JSON |

#### `$$nemoclaw <name> config set`

<AgentOnly variant="openclaw,hermes">

Write one value into the agent configuration in a sandbox.
The command validates every HTTP and HTTPS URL in the value, including URLs nested inside JSON objects or arrays.
It pins an HTTP host to the validated IP address.
Config changes are unavailable while shields are up, so lower shields with `$$nemoclaw <name> shields down` first.

```bash
$$nemoclaw my-assistant config set --key agents.defaults.model.primary --value nvidia/nemotron
$$nemoclaw my-assistant config set --key agents.defaults.timeoutSeconds --value 600 --restart
```

| Flag | Description |
|------|-------------|
| `--key <dotpath>` | Dotpath to update in the config. Required |
| `--value <value>` | Value to write. The command parses a JSON value when it can, and otherwise writes the text as a string. Required |
| `--restart` | Restart a supported OpenClaw or Hermes gateway after writing |
| `--config-accept-new-path` | Write a dotpath that does not already exist in the config |

The command treats a dotpath that does not already exist in the config as a possible typo.
An interactive run asks for confirmation before writing the new dotpath.
A run without a TTY, or a run with `NEMOCLAW_NON_INTERACTIVE=1`, refuses the write.
Pass `--config-accept-new-path`, or set `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH=1`, to write the new dotpath without the confirmation.
If the confirmation reaches the end of input, for example when you press `Ctrl-D` or run the command from a harness that closes stdin, the command exits non-zero without writing and repeats the same guidance.

The command refuses to write `gateway` or any dotpath under `gateway.`, which holds credentials.

</AgentOnly>
<AgentOnly variant="deepagents">

For Deep Agents sandboxes, `config set` is unavailable because the `dcode` configuration is baked into the sandbox image at build time.
Run `$$nemoclaw onboard --agent dcode --name <sandbox-name> --fresh` when you need to change it.
Use `$$nemoclaw <name> config get` to read the current values.

</AgentOnly>

#### `$$nemoclaw <name> shields`

Manage the sandbox config lockdown posture from the host.
Expand Down Expand Up @@ -3668,7 +3706,6 @@ Set them before running `$$nemoclaw onboard`.
| `NEMOCLAW_SKIP_TELEGRAM_REACHABILITY` | `1` to enable | Skips the Telegram bot reachability probe during onboard (useful in restricted networks). |
| `NEMOCLAW_SKIP_SLACK_AUTH_VALIDATION` | `1`, `true`, `yes`, or `on` to enable | Skips the live Slack `auth.test` and `apps.connections.open` credential probes during onboard and `channels add slack`. Use only in restricted networks or hermetic test environments; Slack token format checks still apply. |
</AgentOnly>
| `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | `1` to enable | Accepts a new sandbox config path without an interactive prompt when the stored path differs from the discovered one. |
| `NEMOCLAW_RESOURCE_PROFILE` | profile name or `default` | Selects a sandbox CPU/RAM resource profile from the blueprint during onboarding. `default` means no resource preference, so NemoClaw passes no OpenShell CPU or memory flags. Unknown names fail fast. |
| `NEMOCLAW_CPU` | percentage or Kubernetes CPU quantity | Overrides the selected profile's CPU size passed to OpenShell `--cpu`. Percentages resolve against detected capacity. |
| `NEMOCLAW_RAM` | percentage or Kubernetes memory quantity | Overrides the selected profile's memory size passed to OpenShell `--memory`. Percentages resolve against detected capacity. |
Expand Down Expand Up @@ -3827,6 +3864,9 @@ The following flags change defaults for commands that manage existing sandboxes.
| Variable | Format | Effect |
|----------|--------|--------|
| `NEMOCLAW_CLEANUP_GATEWAY` | `1`, `true`, or `yes` to enable; `0`, `false`, or `no` to disable | Overrides the platform default (macOS unattended: cleanup; Linux/Windows: preserve) for whether `$$nemoclaw <name> destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. |
<AgentOnly variant="openclaw,hermes">
| `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | Exactly `"1"` to opt in (`true`, `yes`, `on` are not accepted) | Allows `$$nemoclaw <name> config set` to write a dotpath that does not already exist in the sandbox config, without the interactive confirmation. Equivalent to passing `--config-accept-new-path`, and it takes precedence over `NEMOCLAW_NON_INTERACTIVE=1`. Without it, a run without a TTY refuses the write instead. |
</AgentOnly>
| `NEMOCLAW_CONFIRM_LEGACY_MANAGED_RECREATE` | Exact JSON array of sandbox names | Confirms to the installer that the exact listed set of pre-fingerprint OpenClaw or Hermes sandboxes used NemoClaw-managed images, allowing recovery onto the current managed image. The normalized names must exactly match the installer's printed array. Set it only after verifying every named sandbox. Recorded custom-image evidence remains blocked. |
| `NEMOCLAW_DISABLE_INFERENCE_ROUTE_REPAIR` | `1` to enable | Skips the automatic DNS-proxy repair for stale `inference.local` routes during `$$nemoclaw <name> connect` and `$$nemoclaw <name> connect --probe-only`. Use only as a troubleshooting escape hatch. |
| `NEMOCLAW_DISABLE_SUPERVISOR_RELAUNCH` | `1` to enable | Skips the automatic trusted container recreation during `$$nemoclaw <name> recover` when two managed scans find no supervisor while PID 1 remains stable. Use only as a troubleshooting escape hatch; recovery then falls back to the rebuild or re-onboard guidance. |
Expand Down
35 changes: 18 additions & 17 deletions src/lib/sandbox/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@
// config set: Host-initiated config mutation with validation.
// config rotate-token: Credential rotation via stdin or env var.

const readline = require("readline");
const { createHash } = require("node:crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { promises: dnsPromises } = require("node:dns");
const { isIP } = require("node:net");
const { isErrnoException }: typeof import("../core/errno") = require("../core/errno");
const { validateName } = require("../runner");
const { shellQuote } = require("../core/shell-quote");
const { dockerExecFileSync, dockerSpawnSync } = require("../adapters/docker/exec");
Expand Down Expand Up @@ -1175,7 +1175,19 @@ async function configSet(sandboxName: string, opts: ConfigSetOpts = {}): Promise
]);
}
if (gate.mode === "prompt") {
const confirmed = await confirmYesNo(" Write this new key? [y/N] ");
let confirmed: boolean;
try {
confirmed = await confirmYesNo(" Write this new key? [y/N] ");
} catch (error) {
// The shared prompt re-raises SIGINT; only EOF needs config-specific remediation here.
if (isErrnoException(error) && error.code === "EOF") {
configFail([
" No input available on stdin, so config set cannot confirm the new key.",
" Re-run with --config-accept-new-path or set NEMOCLAW_CONFIG_ACCEPT_NEW_PATH=1.",
]);
}
throw error;
}
if (!confirmed) {
configFail(" Aborted.");
}
Expand Down Expand Up @@ -1417,21 +1429,10 @@ function readStdin(): Promise<string> {
* Ask a yes/no question on stderr. Returns true only when the answer matches
* /^y(es)?$/i — empty, "no", or unparseable input is treated as no.
*/
function confirmYesNo(prompt: string): Promise<boolean> {
return new Promise((resolve) => {
// 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 });
rl.question(prompt, (answer: string) => {
rl.close();
// pause+unref so the process exits naturally after the last prompt.
// The matching ref() above keeps subsequent prompts working.
if (typeof process.stdin.pause === "function") process.stdin.pause();
if (typeof process.stdin.unref === "function") process.stdin.unref();
resolve(/^y(es)?$/i.test(answer.trim()));
});
});
function confirmYesNo(question: string): Promise<boolean> {
const { prompt: askPrompt } =
require("../credentials/store") as typeof import("../credentials/store");
return askPrompt(question).then((answer) => /^y(es)?$/i.test(answer));
}

// ---------------------------------------------------------------------------
Expand Down
180 changes: 180 additions & 0 deletions test/config-set-prompt-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { describe, expect, it, vi } from "vitest";

/** Verify prompt branching and write effects directly against CLI source. */
const require = createRequire(import.meta.url);
const requireCache: Record<string, unknown> = require.cache as any;

function installMock(modulePath: string, exports: unknown): void {
requireCache[modulePath] = {
id: modulePath,
filename: modulePath,
loaded: true,
exports,
} as any;
}

/** Put an own property back exactly as captured, leaving it absent when it had none. */
function restoreOwnProperty(
target: object,
key: string,
descriptor: PropertyDescriptor | undefined,
): void {
Reflect.deleteProperty(target, key);
Object.defineProperties(target, descriptor ? { [key]: descriptor } : {});
}

async function runConfigSetWithPrompt(prompt: () => Promise<string>) {
const configPath = require.resolve("../src/lib/sandbox/config");
const openshellPath = require.resolve("../src/lib/adapters/openshell/client");
const registryPath = require.resolve("../src/lib/state/registry");
const shieldsPath = require.resolve("../src/lib/shields");
const shieldsAuditPath = require.resolve("../src/lib/shields/audit");
const timerBoundLockPath = require.resolve("../src/lib/shields/timer-bound-lock");
const lifecycleLockPath = require.resolve("../src/lib/state/mcp-lifecycle-lock");
const configLockPath = require.resolve("../src/lib/shields/openclaw-config-lock");
const privilegedExecPath = require.resolve("../src/lib/sandbox/privileged-exec");
const credentialStorePath = require.resolve("../src/lib/credentials/store");
const modulePaths = [
configPath,
openshellPath,
registryPath,
shieldsPath,
shieldsAuditPath,
timerBoundLockPath,
lifecycleLockPath,
configLockPath,
privilegedExecPath,
credentialStorePath,
];
const cachedModules = new Map(
modulePaths.map((modulePath) => [
modulePath,
Object.getOwnPropertyDescriptor(requireCache, modulePath),
]),
);
const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const configWrite = vi.fn(
(_privileged: unknown, _action: string, options: { input?: string }) => ({
issues: [],
chattrApplied: true,
configSha256: createHash("sha256")
.update(options.input ?? "")
.digest("hex"),
}),
);
const auditWrite = vi.fn();
let error: unknown;

try {
delete require.cache[configPath];
installMock(openshellPath, {
captureOpenshellCommand: () => ({
status: 0,
signal: null,
output: "{}",
stdout: "{}\n",
stderr: "",
}),
runOpenshellCommand: vi.fn(),
});
installMock(registryPath, { getSandbox: () => null });
installMock(shieldsPath, { isShieldsDown: () => true });
installMock(shieldsAuditPath, { appendAuditEntry: auditWrite });
installMock(timerBoundLockPath, {
withTimerBoundShieldsMutationLock: (
_sandboxName: string,
_command: string,
callback: () => unknown,
) => callback(),
});
installMock(lifecycleLockPath, {
withSandboxMutationLock: (_sandboxName: string, callback: () => unknown) => callback(),
});
installMock(configLockPath, {
runOpenClawConfigGuard: configWrite,
validateOpenClawConfigCandidate: () => [],
});
installMock(privilegedExecPath, {
privilegedSandboxExecArgv: () => ["docker", "exec", "container-id"],
resolveDirectSandboxContainer: () => "container-id",
});
installMock(credentialStorePath, { prompt });
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
vi.stubEnv("NEMOCLAW_CONFIG_ACCEPT_NEW_PATH", undefined);
vi.stubEnv("NEMOCLAW_NON_INTERACTIVE", undefined);

const { configSet } = require("../src/lib/sandbox/config");
try {
await configSet("prompt-test", { key: "new.path", value: "1" });
} catch (caught) {
error = caught;
}
return { auditWrite, configWrite, error };
} finally {
restoreOwnProperty(process.stdin, "isTTY", stdinTtyDescriptor);
vi.unstubAllEnvs();
for (const [modulePath, descriptor] of cachedModules) {
restoreOwnProperty(requireCache, modulePath, descriptor);
}
}
}

describe("config set prompt answers", () => {
it("reports EOF guidance without writing config", async () => {
const promptError = Object.assign(new Error("prompt closed"), { code: "EOF" });
const prompt = vi.fn(async () => {
throw promptError;
});
const result = await runConfigSetWithPrompt(prompt);

expect(result.error).toMatchObject({
message: expect.stringContaining("No input available on stdin"),
});
expect(result.error).toMatchObject({
message: expect.stringContaining("--config-accept-new-path"),
});
expect(prompt).toHaveBeenCalledWith(" Write this new key? [y/N] ");
expect(result.configWrite).not.toHaveBeenCalled();
expect(result.auditWrite).not.toHaveBeenCalled();
});

it("rethrows non-EOF prompt errors without writing config", async () => {
const promptError = Object.assign(new Error("prompt interrupted"), { code: "SIGINT" });
const prompt = vi.fn(async () => {
throw promptError;
});
const result = await runConfigSetWithPrompt(prompt);

expect(result.error).toBe(promptError);
expect(prompt).toHaveBeenCalledWith(" Write this new key? [y/N] ");
expect(result.configWrite).not.toHaveBeenCalled();
expect(result.auditWrite).not.toHaveBeenCalled();
});

it("writes a new key after an affirmative answer", async () => {
const prompt = vi.fn(async () => "yes");
const result = await runConfigSetWithPrompt(prompt);

expect(result.error).toBeUndefined();
expect(prompt).toHaveBeenCalledWith(" Write this new key? [y/N] ");
expect(result.configWrite).toHaveBeenCalledOnce();
expect(result.auditWrite).toHaveBeenCalledWith(
expect.objectContaining({ action: "config_set", sandbox: "prompt-test" }),
);
});

it("aborts without writing after a negative answer", async () => {
const prompt = vi.fn(async () => "no");
const result = await runConfigSetWithPrompt(prompt);

expect(result.error).toMatchObject({ message: " Aborted." });
expect(prompt).toHaveBeenCalledWith(" Write this new key? [y/N] ");
expect(result.configWrite).not.toHaveBeenCalled();
expect(result.auditWrite).not.toHaveBeenCalled();
});
});
Loading
Loading