From b3f50b975419fa00ced6f6289bb06ca1c75fce11 Mon Sep 17 00:00:00 2001 From: harjoth Date: Sat, 25 Jul 2026 20:02:31 -0700 Subject: [PATCH 1/6] fix(config): fail when new-key prompt reaches EOF Signed-off-by: harjoth --- src/lib/sandbox/config.ts | 35 ++-- test/config-set-prompt-error.test.ts | 179 +++++++++++++++++ .../cli/config-set-prompt-eof.test.ts | 180 ++++++++++++++++++ 3 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 test/config-set-prompt-error.test.ts create mode 100644 test/package-contract/cli/config-set-prompt-eof.test.ts diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index 7e30d3ebd4f..26252222558 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -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"); @@ -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."); } @@ -1417,21 +1429,10 @@ function readStdin(): Promise { * 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 { - 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 { + const { prompt: askPrompt } = + require("../credentials/store") as typeof import("../credentials/store"); + return askPrompt(question).then((answer) => /^y(es)?$/i.test(answer)); } // --------------------------------------------------------------------------- diff --git a/test/config-set-prompt-error.test.ts b/test/config-set-prompt-error.test.ts new file mode 100644 index 00000000000..e1249ffe080 --- /dev/null +++ b/test/config-set-prompt-error.test.ts @@ -0,0 +1,179 @@ +// 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 = require.cache as any; + +function installMock(modulePath: string, exports: unknown): void { + requireCache[modulePath] = { + id: modulePath, + filename: modulePath, + loaded: true, + exports, + } as any; +} + +function restoreCachedModule(modulePath: string, previous: unknown): void { + Reflect.deleteProperty(requireCache, modulePath); + if (previous !== undefined) requireCache[modulePath] = previous; +} + +async function runConfigSetWithPrompt(prompt: () => Promise) { + 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, require.cache[modulePath]]), + ); + const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const priorAcceptNewPath = process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH; + const priorNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; + 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 }); + delete process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH; + delete process.env.NEMOCLAW_NON_INTERACTIVE; + + 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 { + if (stdinTtyDescriptor) { + Object.defineProperty(process.stdin, "isTTY", stdinTtyDescriptor); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + if (priorAcceptNewPath === undefined) delete process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH; + else process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH = priorAcceptNewPath; + if (priorNonInteractive === undefined) delete process.env.NEMOCLAW_NON_INTERACTIVE; + else process.env.NEMOCLAW_NON_INTERACTIVE = priorNonInteractive; + for (const [modulePath, previous] of cachedModules) restoreCachedModule(modulePath, previous); + } +} + +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(); + }); +}); diff --git a/test/package-contract/cli/config-set-prompt-eof.test.ts b/test/package-contract/cli/config-set-prompt-eof.test.ts new file mode 100644 index 00000000000..541b3bd7da2 --- /dev/null +++ b/test/package-contract/cli/config-set-prompt-eof.test.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** Verify line answers and EOF through the compiled CLI over a real stdin pipe. */ +const REPO_ROOT = path.join(import.meta.dirname, "../../.."); +const CLI_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "nemoclaw.js")); +const OPENSHELL_PATH = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "adapters", "openshell", "client.js"), +); +const REGISTRY_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "state", "registry.js")); +const SHIELDS_PATH = JSON.stringify(path.join(REPO_ROOT, "dist", "lib", "shields", "index.js")); +const SHIELDS_AUDIT_PATH = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "shields", "audit.js"), +); +const TIMER_BOUND_LOCK_PATH = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "shields", "timer-bound-lock.js"), +); +const LIFECYCLE_LOCK_PATH = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "state", "mcp-lifecycle-lock.js"), +); +const CONFIG_LOCK_PATH = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "shields", "openclaw-config-lock.js"), +); +const PRIVILEGED_EXEC_PATH = JSON.stringify( + path.join(REPO_ROOT, "dist", "lib", "sandbox", "privileged-exec.js"), +); + +function runConfigSetWithInput(input: string) { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-config-prompt-eof-")); + const scriptPath = path.join(tmpDir, "config-prompt-eof-check.js"); + const script = [ + "function install(modulePath, exports) {", + " require.cache[modulePath] = {", + " id: modulePath,", + " filename: modulePath,", + " loaded: true,", + " exports,", + " };", + "}", + "", + "install(" + REGISTRY_PATH + ", {", + ' getSandbox: (name) => (name === "prompt-eof" ? { name } : null),', + ' listSandboxes: () => ({ sandboxes: [{ name: "prompt-eof" }] }),', + "});", + "install(" + OPENSHELL_PATH + ", {", + " captureOpenshellCommand: () => ({", + " status: 0,", + " signal: null,", + ' output: "{}",', + ' stdout: "{}\\n",', + ' stderr: "",', + " }),", + " runOpenshellCommand: () => ({ status: 0 }),", + "});", + "install(" + SHIELDS_PATH + ", {", + " isShieldsDown: () => true,", + "});", + "install(" + SHIELDS_AUDIT_PATH + ", {", + " appendAuditEntry: () => undefined,", + "});", + "install(" + TIMER_BOUND_LOCK_PATH + ", {", + " withTimerBoundShieldsMutationLock: (_sandboxName, _command, callback) => callback(),", + "});", + "install(" + LIFECYCLE_LOCK_PATH + ", {", + " withSandboxMutationLock: (_sandboxName, callback) => callback(),", + "});", + "install(" + CONFIG_LOCK_PATH + ", {", + " validateOpenClawConfigCandidate: () => [],", + " runOpenClawConfigGuard: (_privileged, _action, options) => ({", + " issues: [],", + " chattrApplied: true,", + ' configSha256: require("node:crypto")', + ' .createHash("sha256")', + ' .update(options.input || "")', + ' .digest("hex"),', + " }),", + "});", + "install(" + PRIVILEGED_EXEC_PATH + ", {", + ' privilegedSandboxExecArgv: () => ["docker", "exec", "container-id"],', + ' resolveDirectSandboxContainer: () => "container-id",', + "});", + "", + 'Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });', + "process.argv = [", + ' "node",', + ' "nemoclaw.js",', + ' "prompt-eof",', + ' "config",', + ' "set",', + ' "--key",', + ' "new.path",', + ' "--value",', + ' "1",', + "];", + "require(" + CLI_PATH + ");", + ].join("\n"); + try { + fs.writeFileSync(scriptPath, script); + return spawnSync(process.execPath, [scriptPath], { + cwd: REPO_ROOT, + encoding: "utf-8", + input, + timeout: 30_000, + killSignal: "SIGKILL", + env: { + ...process.env, + HOME: tmpDir, + NEMOCLAW_CONFIG_ACCEPT_NEW_PATH: undefined, + NEMOCLAW_NON_INTERACTIVE: undefined, + }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +describe("config set new-key prompt", () => { + it("exits non-zero when the new-key prompt reaches EOF", () => { + // An empty input closes the pipe before readline asks the question. + const result = runConfigSetWithInput(""); + + // A timeout would produce SIGKILL and a null status. Before this fix, the + // unresolved question let Node exit 0 after stdin closed. + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.stdout).toContain("Old value: (not set)"); + expect(result.stderr).toContain("Write this new key? [y/N]"); + expect(result.stderr).toContain("No input available on stdin"); + expect(result.stderr).toContain("--config-accept-new-path"); + expect(result.stderr).toContain("NEMOCLAW_CONFIG_ACCEPT_NEW_PATH=1"); + expect(result.stdout).not.toContain("Writing config to sandbox"); + expect(result.stdout).not.toContain("config updated"); + expect(result.status).toBe(1); + }, 45_000); + + it("treats an empty answer as an abort instead of EOF", () => { + const result = runConfigSetWithInput("\n"); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.stderr).toContain("Write this new key? [y/N]"); + expect(result.stderr).toContain("Aborted."); + expect(result.stderr).not.toContain("No input available on stdin"); + expect(result.stdout).not.toContain("Writing config to sandbox"); + expect(result.stdout).not.toContain("config updated"); + expect(result.status).toBe(1); + }, 45_000); + + it("accepts a whitespace-padded affirmative answer", () => { + const result = runConfigSetWithInput(" yes \n"); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.stderr).toContain("Write this new key? [y/N]"); + expect(result.stderr).not.toContain("Aborted."); + expect(result.stderr).not.toContain("No input available on stdin"); + expect(result.stdout).toContain("Writing config to sandbox"); + expect(result.stdout).toContain("config updated"); + expect(result.status).toBe(0); + }, 45_000); + + it("treats an unterminated answer as EOF", () => { + const result = runConfigSetWithInput("yes"); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.stderr).toContain("Write this new key? [y/N]"); + expect(result.stderr).toContain("No input available on stdin"); + expect(result.stdout).not.toContain("Writing config to sandbox"); + expect(result.stdout).not.toContain("config updated"); + expect(result.status).toBe(1); + }, 45_000); +}); From ca459e70bc416bbb24e308f8258044a9c3176e7a Mon Sep 17 00:00:00 2001 From: harjoth Date: Sat, 25 Jul 2026 21:54:15 -0700 Subject: [PATCH 2/6] test(config): keep prompt EOF test free of conditionals The codebase-growth-guardrails check rejects newly added `if` statements in changed test files. Restore the captured stdin and require-cache property descriptors through one named helper, and use `vi.stubEnv` with `vi.unstubAllEnvs` for the two environment variables, so setup and teardown stay linear. Test behavior is unchanged. Signed-off-by: harjoth --- test/config-set-prompt-error.test.ts | 35 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/test/config-set-prompt-error.test.ts b/test/config-set-prompt-error.test.ts index e1249ffe080..3c2d8692e35 100644 --- a/test/config-set-prompt-error.test.ts +++ b/test/config-set-prompt-error.test.ts @@ -18,9 +18,14 @@ function installMock(modulePath: string, exports: unknown): void { } as any; } -function restoreCachedModule(modulePath: string, previous: unknown): void { - Reflect.deleteProperty(requireCache, modulePath); - if (previous !== undefined) requireCache[modulePath] = previous; +/** 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) { @@ -47,11 +52,12 @@ async function runConfigSetWithPrompt(prompt: () => Promise) { credentialStorePath, ]; const cachedModules = new Map( - modulePaths.map((modulePath) => [modulePath, require.cache[modulePath]]), + modulePaths.map((modulePath) => [ + modulePath, + Object.getOwnPropertyDescriptor(requireCache, modulePath), + ]), ); const stdinTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); - const priorAcceptNewPath = process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH; - const priorNonInteractive = process.env.NEMOCLAW_NON_INTERACTIVE; const configWrite = vi.fn( (_privileged: unknown, _action: string, options: { input?: string }) => ({ issues: [], @@ -99,8 +105,8 @@ async function runConfigSetWithPrompt(prompt: () => Promise) { }); installMock(credentialStorePath, { prompt }); Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); - delete process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH; - delete process.env.NEMOCLAW_NON_INTERACTIVE; + vi.stubEnv("NEMOCLAW_CONFIG_ACCEPT_NEW_PATH", undefined); + vi.stubEnv("NEMOCLAW_NON_INTERACTIVE", undefined); const { configSet } = require("../src/lib/sandbox/config"); try { @@ -110,16 +116,11 @@ async function runConfigSetWithPrompt(prompt: () => Promise) { } return { auditWrite, configWrite, error }; } finally { - if (stdinTtyDescriptor) { - Object.defineProperty(process.stdin, "isTTY", stdinTtyDescriptor); - } else { - Reflect.deleteProperty(process.stdin, "isTTY"); + restoreOwnProperty(process.stdin, "isTTY", stdinTtyDescriptor); + vi.unstubAllEnvs(); + for (const [modulePath, descriptor] of cachedModules) { + restoreOwnProperty(requireCache, modulePath, descriptor); } - if (priorAcceptNewPath === undefined) delete process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH; - else process.env.NEMOCLAW_CONFIG_ACCEPT_NEW_PATH = priorAcceptNewPath; - if (priorNonInteractive === undefined) delete process.env.NEMOCLAW_NON_INTERACTIVE; - else process.env.NEMOCLAW_NON_INTERACTIVE = priorNonInteractive; - for (const [modulePath, previous] of cachedModules) restoreCachedModule(modulePath, previous); } } From 6b7b0427b86d739646fb81311ffcb6bcf53209d7 Mon Sep 17 00:00:00 2001 From: harjoth Date: Sat, 25 Jul 2026 23:39:31 -0700 Subject: [PATCH 3/6] docs(reference): correct NEMOCLAW_CONFIG_ACCEPT_NEW_PATH and document config set The environment variable row described accepting a new sandbox config file path during onboarding. The variable actually lets `config set` write a dotpath that is absent from the sandbox config. Move the row to the existing-sandbox table, describe the real behavior, and add a `config set` reference section so `--config-accept-new-path` is documented alongside it. Signed-off-by: harjoth --- docs/reference/commands.mdx | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 06c1a775d56..75307134e01 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1058,6 +1058,33 @@ $$nemoclaw my-assistant config get --key model --format yaml | `--key ` | Print one value from the sanitized config | | `--format json\|yaml` | Output format. Defaults to JSON | +#### `$$nemoclaw config set` + +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 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 to update in the config. Required | +| `--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. + +The command refuses to write `gateway` or any dotpath under `gateway.`, which holds credentials. +Use `$$nemoclaw config rotate-token` for those changes. + #### `$$nemoclaw shields` Manage the sandbox config lockdown posture from the host. @@ -3668,7 +3695,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. | -| `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. | @@ -3827,6 +3853,7 @@ 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 destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. | +| `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | Exactly `"1"` to opt in (`true`, `yes`, `on` are not accepted) | Allows `$$nemoclaw 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. | | `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 connect` and `$$nemoclaw 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 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. | From a8ee43727b9747b36469e4e28831f2bf69781d64 Mon Sep 17 00:00:00 2001 From: harjoth Date: Sat, 25 Jul 2026 23:46:03 -0700 Subject: [PATCH 4/6] docs(reference): gate config set by agent variant and cover EOF The new config set section rendered into the Deep Agents guide, where the command always fails because dcode bakes its config into the sandbox image. Gate the OpenClaw and Hermes body, point Deep Agents at the re-onboard path, and state the end-of-input outcome alongside the interactive and no-TTY ones. Signed-off-by: harjoth --- docs/reference/commands.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 75307134e01..898877c0e12 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1060,6 +1060,8 @@ $$nemoclaw my-assistant config get --key model --format yaml #### `$$nemoclaw config set` + + 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. @@ -1081,10 +1083,20 @@ The command treats a dotpath that does not already exist in the config as a poss 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. Use `$$nemoclaw config rotate-token` for those changes. + + + +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 --fresh` when you need to change it. +Use `$$nemoclaw config get` to read the current values. + + + #### `$$nemoclaw shields` Manage the sandbox config lockdown posture from the host. From 067e3903841ecc908e871085af6b61028481705f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 26 Jul 2026 06:30:23 -0700 Subject: [PATCH 5/6] docs(reference): scope config path override Signed-off-by: Carlos Villela --- docs/reference/commands.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 898877c0e12..5f9af6ce8ad 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -3865,7 +3865,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 destroy` removes the shared gateway when destroying the last sandbox. Command-line `--cleanup-gateway` and `--no-cleanup-gateway` still take precedence. | + | `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH` | Exactly `"1"` to opt in (`true`, `yes`, `on` are not accepted) | Allows `$$nemoclaw 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. | + | `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 connect` and `$$nemoclaw 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 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. | From 7e5a4946c801464b5ec6acf54655d6801dc45a6e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sun, 26 Jul 2026 06:52:12 -0700 Subject: [PATCH 6/6] docs(reference): hide experimental config command Signed-off-by: Carlos Villela --- docs/reference/commands.mdx | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 5f9af6ce8ad..2e5f0bf1078 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1086,7 +1086,6 @@ Pass `--config-accept-new-path`, or set `NEMOCLAW_CONFIG_ACCEPT_NEW_PATH=1`, to 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. -Use `$$nemoclaw config rotate-token` for those changes.