diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 06c1a775d56..2e5f0bf1078 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -1058,6 +1058,44 @@ $$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. +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. + + + + +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. @@ -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. | -| `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 +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 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. | 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..3c2d8692e35 --- /dev/null +++ b/test/config-set-prompt-error.test.ts @@ -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 = 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) { + 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(); + }); +}); 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); +});