diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 9bd60c2ee5..103bee4505 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -215,6 +215,15 @@ The command prints the sandbox name, NemoClaw harness, active `dcode` agent, con `dcode --help` lists the managed aliases before the upstream Deep Agents Code help. The sandbox name resolves when you run the command from a `nemoclaw connect` shell, which loads the NemoClaw runtime environment. +Inspect the generated Deep Agents configuration from the host with: + +```bash +nemo-deepagents config get +``` + +NemoClaw parses `config.toml`, removes gateway auth data, and redacts credential-shaped values before printing it. +`config set` is not supported for this image-baked configuration; re-onboard the named sandbox to change its managed provider or model selection. + diff --git a/package-lock.json b/package-lock.json index 04214a8085..24f0a65c94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", - "smol-toml": "^1.7.0", + "smol-toml": "1.7.0", "yaml": "2.8.3" }, "bin": { diff --git a/package.json b/package.json index ac1718f530..58aed3b22b 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", - "smol-toml": "^1.7.0", + "smol-toml": "1.7.0", "yaml": "2.8.3" }, "bundleDependencies": [ diff --git a/src/lib/sandbox/config-format.test.ts b/src/lib/sandbox/config-format.test.ts new file mode 100644 index 0000000000..11aa2ff1c9 --- /dev/null +++ b/src/lib/sandbox/config-format.test.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +const { parseConfig, serializeConfig } = + require("./config-format") as typeof import("./config-format"); + +describe("sandbox config formats", () => { + it("parses a comment-prefixed TOML config with nested tables (#6548)", () => { + const parsed = parseConfig( + [ + "# Generated by NemoClaw. Do not edit by hand.", + "[models]", + 'default = "openai:nvidia/meta/llama-3.1-8b-instruct"', + "[models.providers.openai]", + 'models = ["nvidia/meta/llama-3.1-8b-instruct"]', + "enabled = true", + ].join("\n"), + "toml", + ); + + expect(parsed).toEqual({ + models: { + default: "openai:nvidia/meta/llama-3.1-8b-instruct", + providers: { + openai: { models: ["nvidia/meta/llama-3.1-8b-instruct"], enabled: true }, + }, + }, + }); + }); + + it("keeps JSON and YAML parsing behavior", () => { + expect(parseConfig('{"model":{"id":"nemotron"}}', "json")).toEqual({ + model: { id: "nemotron" }, + }); + expect(parseConfig("model:\n id: nemotron\n", "yaml")).toEqual({ + model: { id: "nemotron" }, + }); + }); + + it("rejects excessive TOML nesting with a generic source-safe error", () => { + const secret = "credential-canary-must-not-escape"; + const acceptedPath = Array.from({ length: 63 }, (_, index) => `level${index}`).join("."); + const tablePath = Array.from({ length: 64 }, (_, index) => `level${index}`).join("."); + const source = `[${tablePath}]\npassword = "${secret}"`; + + expect(parseConfig(`[${acceptedPath}]\nvalue = 1`, "toml")).toBeDefined(); + expect(() => parseConfig(source, "toml")).toThrow("Config exceeds safe structural limits."); + try { + parseConfig(source, "toml"); + } catch (error) { + expect(String(error)).not.toContain(secret); + expect(String(error)).not.toContain(tablePath); + } + }); + + it("rejects oversized TOML containers", () => { + const accepted = `values = [${Array.from({ length: 10_000 }, () => "0").join(",")}]`; + const source = `values = [${Array.from({ length: 10_001 }, () => "0").join(",")}]`; + + expect((parseConfig(accepted, "toml").values as unknown[]).length).toBe(10_000); + expect(() => parseConfig(source, "toml")).toThrow("Config exceeds safe structural limits."); + }); + + it("rejects objects that exceed per-object and aggregate key budgets", () => { + const oversizedTomlObject = Array.from( + { length: 10_001 }, + (_, index) => `key${index} = 0`, + ).join("\n"); + const groups = Array.from({ length: 6 }, (_, group) => + Object.fromEntries(Array.from({ length: 9_000 }, (_, index) => [`key${group}_${index}`, 0])), + ); + + expect(() => parseConfig(oversizedTomlObject, "toml")).toThrow( + "Config exceeds safe structural limits.", + ); + expect(() => parseConfig(JSON.stringify({ groups }), "json")).toThrow( + "Config exceeds safe structural limits.", + ); + }); + + it("rejects TOML that exceeds the total parsed-node budget", () => { + const array = Array.from({ length: 100 }, () => "0").join(","); + const source = Array.from({ length: 1_000 }, (_, index) => `key${index} = [${array}]`).join( + "\n", + ); + + expect(() => parseConfig(source, "toml")).toThrow("Config exceeds safe structural limits."); + }); + + it("rejects cyclic YAML aliases without recursive validation", () => { + const source = "root: &root\n child: *root\n"; + + expect(() => parseConfig(source, "yaml")).toThrow("Config exceeds safe structural limits."); + }); + + it("refuses to serialize TOML as another format", () => { + expect(() => serializeConfig({ model: "nemotron" }, "toml")).toThrow( + /config set is not supported for TOML-format agents/, + ); + }); +}); diff --git a/src/lib/sandbox/config-format.ts b/src/lib/sandbox/config-format.ts new file mode 100644 index 0000000000..0e27f34a02 --- /dev/null +++ b/src/lib/sandbox/config-format.ts @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +type ConfigObject = import("../security/credential-filter").ConfigObject; + +const MAX_CONFIG_STRUCTURE_DEPTH = 64; +const MAX_CONFIG_STRUCTURE_NODES = 100_000; +const MAX_CONFIG_OBJECT_KEYS = 10_000; +const MAX_CONFIG_TOTAL_KEYS = 50_000; +const MAX_CONFIG_ARRAY_LENGTH = 10_000; + +type ConfigWalkEntry = + | { kind: "visit"; depth: number; value: unknown } + | { kind: "leave"; value: object }; + +function configStructureLimitError(): Error { + return new Error("Config exceeds safe structural limits."); +} + +function invalidConfigShapeError(): Error { + return new Error("Config is not a JSON-like object."); +} + +function readOwnDataValue(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) throw invalidConfigShapeError(); + return descriptor.value; +} + +/** + * Validate parsed, sandbox-controlled config iteratively before any recursive + * validation, redaction, or serialization can consume it. + */ +function assertSafeConfigStructure(value: unknown): asserts value is ConfigObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalidConfigShapeError(); + } + + const stack: ConfigWalkEntry[] = [{ kind: "visit", value, depth: 0 }]; + const activeAncestors = new WeakSet(); + let scheduledNodes = 1; + let totalObjectKeys = 0; + + while (stack.length > 0) { + const entry = stack.pop(); + if (!entry) break; + if (entry.kind === "leave") { + activeAncestors.delete(entry.value); + continue; + } + if (entry.depth > MAX_CONFIG_STRUCTURE_DEPTH) { + throw configStructureLimitError(); + } + + const entryType = typeof entry.value; + if ( + entry.value === null || + entry.value === undefined || + entryType === "boolean" || + entryType === "number" || + entryType === "string" + ) { + continue; + } + if (entryType !== "object") { + throw invalidConfigShapeError(); + } + + const objectValue = entry.value as object; + if (activeAncestors.has(objectValue)) throw configStructureLimitError(); + activeAncestors.add(objectValue); + stack.push({ kind: "leave", value: objectValue }); + + let children: unknown[]; + if (Array.isArray(entry.value)) { + if (entry.value.length > MAX_CONFIG_ARRAY_LENGTH) { + throw configStructureLimitError(); + } + children = Array.from({ length: entry.value.length }, (_, index) => { + const descriptor = Object.getOwnPropertyDescriptor(entry.value, String(index)); + if (!descriptor) return undefined; + if (!("value" in descriptor)) throw invalidConfigShapeError(); + return descriptor.value; + }); + } else { + const prototype = Object.getPrototypeOf(entry.value); + if (prototype !== Object.prototype && prototype !== null) { + throw invalidConfigShapeError(); + } + const keys = Object.keys(entry.value); + totalObjectKeys += keys.length; + if (keys.length > MAX_CONFIG_OBJECT_KEYS || totalObjectKeys > MAX_CONFIG_TOTAL_KEYS) { + throw configStructureLimitError(); + } + children = keys.map((key) => readOwnDataValue(entry.value as object, key)); + } + + scheduledNodes += children.length; + if (scheduledNodes > MAX_CONFIG_STRUCTURE_NODES) throw configStructureLimitError(); + for (let index = children.length - 1; index >= 0; index -= 1) { + stack.push({ kind: "visit", value: children[index], depth: entry.depth + 1 }); + } + } +} + +/** Parse raw agent configuration according to its manifest-declared format. */ +export function parseConfig(raw: string, format: string): ConfigObject { + let parsed: unknown; + if (format === "yaml") { + const YAML = require("yaml") as { + parse: (text: string) => unknown; + }; + try { + parsed = YAML.parse(raw); + } catch { + throw new Error("Invalid YAML configuration syntax."); + } + } else if (format === "toml") { + const TOML = require("smol-toml") as { + parse: (text: string) => unknown; + }; + try { + parsed = TOML.parse(raw); + } catch { + throw new Error("Invalid TOML configuration syntax."); + } + } else { + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Invalid JSON configuration syntax."); + } + } + assertSafeConfigStructure(parsed); + return parsed; +} + +/** Serialize mutable agent configuration without corrupting TOML inputs. */ +export function serializeConfig(config: ConfigObject, format: string): string { + if (format === "yaml") { + return require("yaml").stringify(config); + } + if (format === "toml") { + throw new Error("config set is not supported for TOML-format agents."); + } + return JSON.stringify(config, null, 2); +} diff --git a/src/lib/sandbox/config-get.test.ts b/src/lib/sandbox/config-get.test.ts index b8182c8dd5..b2dc587e07 100644 --- a/src/lib/sandbox/config-get.test.ts +++ b/src/lib/sandbox/config-get.test.ts @@ -70,7 +70,10 @@ function loadConfigGet(): (name: string, opts?: { key?: string; format?: string } function stubSandboxRead(rawConfig: unknown): void { - const raw = JSON.stringify(rawConfig); + stubSandboxRawRead(JSON.stringify(rawConfig)); +} + +function stubSandboxRawRead(raw: string): void { client.captureOpenshellCommand = () => ({ status: 0, signal: null, @@ -93,6 +96,15 @@ function captureStdout(run: () => void): string { return chunks.join("\n"); } +function captureError(run: () => void): Error { + try { + run(); + } catch (error) { + return error as Error; + } + throw new Error("Expected callback to throw."); +} + describe("configGet output redaction and gateway omission (#config-get)", () => { beforeEach(() => { stubSandboxRead(SANDBOX_CONFIG); @@ -156,9 +168,21 @@ describe("configGet output redaction and gateway omission (#config-get)", () => // the command fails rather than leaking regenerated auth material. expect(() => configGet("alpha", { key: "gateway.token" })).toThrow(/not found/i); }); + + it("does not echo credential-bearing source text from malformed JSON", () => { + const secret = "nvapi-jsonabcdefghijklmnopqrstuvwxyz0123456789"; + const sourceLine = `{"provider":{"apiKey":"${secret}"}} trailing-text`; + stubSandboxRawRead(sourceLine); + + const error = captureError(() => loadConfigGet()("alpha")); + + expect(error.message).toContain("Invalid JSON configuration syntax."); + expect(error.message).not.toContain(secret); + expect(error.message).not.toContain(sourceLine); + }); }); -describe("configGet TOML parsing for dcode agents (#6548)", () => { +describe("configGet parsing for manifest-declared formats (#6548)", () => { const registryPath = require.resolve("../state/registry"); const agentDefsPath = require.resolve("../agent/defs"); const registry = require(registryPath) as { getSandbox: (name: string) => unknown }; @@ -182,6 +206,13 @@ describe("configGet TOML parsing for dcode agents (#6548)", () => { "[update]", "check = false", "", + "[provider]", + 'api_key = "nvapi-abcdefghijklmnopqrstuvwxyz0123456789"', + "", + "[gateway]", + 'token = "nvapi-gateway000000000000000000000000000000"', + 'url = "http://127.0.0.1:8080"', + "", ].join("\n"); beforeEach(() => { @@ -221,4 +252,88 @@ describe("configGet TOML parsing for dcode agents (#6548)", () => { expect(parsed.models.providers.openai.models).toEqual(["nvidia/meta/llama-3.1-8b-instruct"]); expect(parsed.update.check).toBe(false); }); + + it("returns a TOML-origin leaf selected with --key", () => { + const configGet = loadConfigGet(); + const out = captureStdout(() => configGet("dcode-sb", { key: "models.default" })); + + expect(JSON.parse(out)).toBe("openai:nvidia/meta/llama-3.1-8b-instruct"); + }); + + it("renders sanitized TOML-origin config as requested YAML", () => { + const configGet = loadConfigGet(); + const out = captureStdout(() => configGet("dcode-sb", { format: "yaml" })); + const parsed = require("yaml").parse(out) as { + models: { default: string }; + update: { check: boolean }; + provider: { api_key: string }; + }; + + expect(parsed.models.default).toBe("openai:nvidia/meta/llama-3.1-8b-instruct"); + expect(parsed.update.check).toBe(false); + expect(parsed.provider.api_key).toBe("[STRIPPED_BY_MIGRATION]"); + expect(parsed).not.toHaveProperty("gateway"); + expect(out).not.toContain("nvapi-"); + }); + + it("redacts credentials and omits gateway auth after TOML parsing", () => { + const configGet = loadConfigGet(); + const out = captureStdout(() => configGet("dcode-sb")); + + expect(out).not.toContain("nvapi-abcdefghijklmnopqrstuvwxyz0123456789"); + expect(out).not.toContain("nvapi-gateway000000000000000000000000000000"); + expect(out).toContain("[STRIPPED_BY_MIGRATION]"); + expect(JSON.parse(out)).not.toHaveProperty("gateway"); + }); + + it("does not echo credential-bearing source lines from malformed TOML", () => { + const secret = "nvapi-tomlabcdefghijklmnopqrstuvwxyz0123456789"; + const sourceLine = `api_key = "${secret}" trailing-text`; + stubSandboxRawRead(["[provider]", sourceLine].join("\n")); + + const error = captureError(() => loadConfigGet()("dcode-sb")); + + expect(error.message).toContain("Invalid TOML configuration syntax."); + expect(error.message).not.toContain(secret); + expect(error.message).not.toContain(sourceLine); + }); + + it("rejects excessive TOML structure without echoing credential-bearing source", () => { + const secret = "credential-canary-must-not-escape"; + const tablePath = Array.from({ length: 64 }, (_, index) => `level${index}`).join("."); + stubSandboxRawRead(`[${tablePath}]\npassword = "${secret}"`); + + const error = captureError(() => loadConfigGet()("dcode-sb")); + + expect(error.message).toContain("Config exceeds safe structural limits."); + expect(error.message).not.toContain(secret); + expect(error.message).not.toContain(tablePath); + }); + + it("does not echo credential-bearing source lines from malformed YAML", () => { + registry.getSandbox = () => ({ agent: "hermes" }); + agentDefs.loadAgent = () => ({ + configPaths: { dir: "/sandbox/.hermes", configFile: "config.yaml", format: "yaml" }, + }); + const secret = "nvapi-yamlabcdefghijklmnopqrstuvwxyz0123456789"; + const sourceLine = `api_key: "${secret}" trailing-text`; + stubSandboxRawRead(sourceLine); + + const error = captureError(() => loadConfigGet()("hermes-sb")); + + expect(error.message).toContain("Invalid YAML configuration syntax."); + expect(error.message).not.toContain(secret); + expect(error.message).not.toContain(sourceLine); + }); + + it("refuses config set before attempting to rewrite an image-baked TOML file", async () => { + delete require.cache[configModulePath]; + const { configSet } = require(configModulePath) as { + configSet: (name: string, opts: { key: string; value: string }) => Promise; + }; + + await expect( + configSet("dcode-sb", { key: "models.default", value: "openai:nvidia/new-model" }), + ).rejects.toThrow(/config set is not available.*baked into the sandbox image.*re-onboard/i); + }); }); diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index 9307033994..0f96b1cdeb 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -41,6 +41,10 @@ const { const { buildHermesUpstreamHeader, }: typeof import("./hermes-upstream-header") = require("./hermes-upstream-header"); +const { + parseConfig, + serializeConfig, +}: typeof import("./config-format") = require("./config-format"); type ConfigObject = import("../security/credential-filter").ConfigObject; type ConfigValue = import("../security/credential-filter").ConfigValue; @@ -69,7 +73,7 @@ export interface AgentConfigTarget { configPath: string; /** Directory containing the config (for chown after cp) */ configDir: string; - /** Config file format: "json" or "yaml" */ + /** Config file format: "json", "yaml", or "toml" */ format: string; /** Config file basename */ configFile: string; @@ -400,47 +404,6 @@ function classifyNewKeyGate(inputs: NewKeyGateInputs): NewKeyGate { return { mode: "prompt" }; } -/** - * Parse a config file's raw text according to its format. - */ -function parseConfig(raw: string, format: string): ConfigObject { - // dcode declares `format: toml` (agents/langchain-deepagents-code/manifest.yaml); - // without a TOML branch a config.toml (which opens with a `# Generated ...` - // comment) fell through to JSON.parse() and threw. Parse it with smol-toml, - // mirroring how yaml is handled. #6548. - let parsed: ConfigValue; - if (format === "yaml") { - parsed = require("yaml").parse(raw); - } else if (format === "toml") { - parsed = require("smol-toml").parse(raw); - } else { - parsed = JSON.parse(raw); - } - if (!isConfigObject(parsed)) { - throw new Error("Config is not an object."); - } - return parsed; -} - -/** - * Serialize a config object according to its format. - */ -function serializeConfig(config: ConfigObject, format: string): string { - if (format === "yaml") { - const YAML = require("yaml"); - return YAML.stringify(config); - } - if (format === "toml") { - // Backstop: config set already refuses TOML agents up front (their config - // is image-baked; #6321/#6548), so this is unreachable in practice. Refuse - // rather than serialize JSON into a `.toml` file, which would corrupt it. - throw new Error( - "config set is not supported for TOML-format agents (e.g. langchain-deepagents-code).", - ); - } - return JSON.stringify(config, null, 2); -} - /** * Pure body composition for {@link writeSandboxConfig}: serialize the config * and prepend agent-specific headers. Extracted so unit tests can assert the diff --git a/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh b/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh index 3ddecd348f..ae31bc17ca 100755 --- a/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh +++ b/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh @@ -18,6 +18,7 @@ PREFIX="04-deepagents-code-fresh-reonboard" PRIMARY_TARGET_MODEL="openai/openai/gpt-5.5" FALLBACK_TARGET_MODEL="nvidia/nvidia/nemotron-3-ultra" HOSTED_ENDPOINT="${NEMOCLAW_ENDPOINT_URL:-https://inference-api.nvidia.com/v1}" +CREDENTIAL_CANARY="nemoclaw-dcode-config-get-canary" fail() { printf '%s: FAIL: %s\n' "$PREFIX" "$1" >&2 @@ -74,7 +75,7 @@ import tomllib import tomli_w path = Path("/sandbox/.deepagents/config.toml") -model = sys.argv[1] +model, credential_canary = sys.argv[1:] config = tomllib.loads(path.read_text(encoding="utf-8")) provider = config["models"]["providers"]["openai"] config["models"]["default"] = f"openai:{model}" @@ -88,7 +89,7 @@ config["threads"] = { "columns": {"initial_prompt": False}, } config["agents"] = {"startup_command": "discard"} -config["headers"] = {"authorization": "discard"} +config["headers"] = {"authorization": credential_canary} config["hooks"] = {"post_start": "discard"} config["mcp"] = {"autoload": True, "config": "/sandbox/discard-mcp.json"} config["servers"] = {"discard": {"api_key": "discard"}} @@ -173,11 +174,51 @@ fi pass "initial live identity reports model A" seed_source="$(seed_config_source | encode_source)" -seed_command="printf '%s' ${seed_source@Q} | base64 -d | /opt/venv/bin/python3 -I - ${model_a@Q}" +seed_command="printf '%s' ${seed_source@Q} | base64 -d | /opt/venv/bin/python3 -I - ${model_a@Q} ${CREDENTIAL_CANARY@Q}" seed_output="$(sandbox_exec "$seed_command")" || fail "could not seed stale DCode config" printf '%s\n' "$seed_output" | grep -Fq "NEMOCLAW_DCODE_STALE_CONFIG_SEEDED" || fail "stale config seed marker is missing" pass "seeded safe preferences and stale managed data" +config_json="$("$CLI" "$SANDBOX_NAME" config get 2>&1)" || fail "config get failed for live DCode TOML: $config_json" +CONFIG_JSON="$config_json" MODEL_A="$model_a" CREDENTIAL_CANARY="$CREDENTIAL_CANARY" node -e ' +const config = JSON.parse(process.env.CONFIG_JSON); +if (config.models?.default !== "openai:" + process.env.MODEL_A) process.exit(1); +if (Object.hasOwn(config, "gateway")) process.exit(1); +if (config.headers?.authorization !== "[STRIPPED_BY_MIGRATION]") process.exit(1); +if (JSON.stringify(config).includes(process.env.CREDENTIAL_CANARY)) process.exit(1); +' || fail "config get did not return sanitized, parseable JSON for model A" +pass "config get parses live DCode TOML and redacts credentials" + +config_model_json="$("$CLI" "$SANDBOX_NAME" config get --key models.default 2>&1)" || fail "keyed config get failed for live DCode TOML: $config_model_json" +CONFIG_MODEL_JSON="$config_model_json" MODEL_A="$model_a" node -e ' +if (JSON.parse(process.env.CONFIG_MODEL_JSON) !== "openai:" + process.env.MODEL_A) process.exit(1); +' || fail "keyed config get did not return model A" +pass "keyed config get returns the live model" + +config_yaml="$("$CLI" "$SANDBOX_NAME" config get --format yaml 2>&1)" || fail "YAML config get failed for live DCode TOML: $config_yaml" +CONFIG_YAML="$config_yaml" MODEL_A="$model_a" CREDENTIAL_CANARY="$CREDENTIAL_CANARY" node -e ' +const config = require("yaml").parse(process.env.CONFIG_YAML); +if (config.models?.default !== "openai:" + process.env.MODEL_A) process.exit(1); +if (Object.hasOwn(config, "gateway")) process.exit(1); +if (config.headers?.authorization !== "[STRIPPED_BY_MIGRATION]") process.exit(1); +if (JSON.stringify(config).includes(process.env.CREDENTIAL_CANARY)) process.exit(1); +' || fail "config get --format yaml did not return sanitized, parseable YAML for model A" +pass "YAML config get preserves the sanitized live DCode shape" + +config_sha_before="$(sandbox_exec "sha256sum /sandbox/.deepagents/config.toml | awk '{print \$1}'")" || fail "could not hash DCode config before rejected mutation" +[[ "$config_sha_before" =~ ^[0-9a-f]{64}$ ]] || fail "invalid pre-mutation DCode config hash" +set +e +config_set_output="$("$CLI" "$SANDBOX_NAME" config set --key models.default --value "openai:$model_b" 2>&1)" +config_set_status=$? +set -e +[ "$config_set_status" -ne 0 ] || fail "config set unexpectedly mutated image-baked DCode config" +printf '%s\n' "$config_set_output" | grep -Fq "config is baked into the sandbox image at build time" || fail "config set rejection did not explain the image-baked boundary" +printf '%s\n' "$config_set_output" | grep -Fq "re-onboard with the new selection" || fail "config set rejection did not provide re-onboard guidance" +printf '%s\n' "$config_set_output" | grep -Fq -- "--fresh" || fail "config set rejection did not provide the fresh re-onboard command" +config_sha_after="$(sandbox_exec "sha256sum /sandbox/.deepagents/config.toml | awk '{print \$1}'")" || fail "could not hash DCode config after rejected mutation" +[ "$config_sha_after" = "$config_sha_before" ] || fail "rejected config set changed image-baked DCode config" +pass "config set rejects image-baked DCode mutation without changing the file" + if ! reonboard_output="$( COMPATIBLE_API_KEY="$COMPATIBLE_API_KEY" \ NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ @@ -211,6 +252,12 @@ assert_identity "$identity_after" "$model_b" "fresh" printf '%s\n' "$identity_after" | grep -Fq "$model_a" && fail "fresh identity still contains model A" pass "live dcode identity reports model B" +config_model_after_json="$("$CLI" "$SANDBOX_NAME" config get --key models.default 2>&1)" || fail "keyed config get failed after re-onboard: $config_model_after_json" +CONFIG_MODEL_JSON="$config_model_after_json" MODEL_B="$model_b" node -e ' +if (JSON.parse(process.env.CONFIG_MODEL_JSON) !== "openai:" + process.env.MODEL_B) process.exit(1); +' || fail "keyed config get did not return model B after re-onboard" +pass "keyed config get reports model B after re-onboard" + status_json="$("$CLI" "$SANDBOX_NAME" status --json 2>&1)" || fail "nemoclaw status failed after re-onboard" STATUS_JSON="$status_json" SANDBOX_NAME="$SANDBOX_NAME" MODEL_B="$model_b" node -e ' const status = JSON.parse(process.env.STATUS_JSON); @@ -237,4 +284,4 @@ verify_output="$(sandbox_exec "$verify_command")" || fail "live DCode config doe printf '%s\n' "$verify_output" | grep -Fq "NEMOCLAW_DCODE_FRESH_CONFIG_VERIFIED" || fail "fresh config verification marker is missing" pass "config keeps model B and only the allowlisted preferences" -printf '%s: 6 passed, 0 failed\n' "$PREFIX" +printf '%s: 11 passed, 0 failed\n' "$PREFIX" diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 2fa5405e8e..a6d3eca3fb 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -27,6 +27,10 @@ const dcodeTavilyCheck = path.join( process.cwd(), "test/e2e/e2e-cloud-experimental/checks/09-deepagents-code-tavily-opt-in.sh", ); +const dcodeFreshReonboardCheck = path.join( + process.cwd(), + "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh", +); function shellResult(exitCode: number, stdout: string, stderr = ""): ShellProbeResult { return { @@ -49,22 +53,13 @@ describe("P0-E cloud-experimental parity guardrails", () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fake-openshell-")); try { fs.writeFileSync(path.join(binDir, "openshell"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); - const result = spawnSync( - "bash", - [ - path.join( - process.cwd(), - "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh", - ), - ], - { - encoding: "utf8", - env: { - PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, - SANDBOX_NAME: "openclaw-sandbox", - }, + const result = spawnSync("bash", [dcodeFreshReonboardCheck], { + encoding: "utf8", + env: { + PATH: `${binDir}:${process.env.PATH ?? "/usr/bin:/bin"}`, + SANDBOX_NAME: "openclaw-sandbox", }, - ); + }); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toContain( @@ -75,6 +70,18 @@ describe("P0-E cloud-experimental parity guardrails", () => { } }); + it("keeps live DCode config inspection and mutation-boundary coverage in the fresh re-onboard check", () => { + const script = fs.readFileSync(dcodeFreshReonboardCheck, "utf8"); + + expect(script).toContain('"$CLI" "$SANDBOX_NAME" config get'); + expect(script).toContain("config get --key models.default"); + expect(script).toContain("config get --format yaml"); + expect(script).toContain("config set --key models.default"); + expect(script).toContain("sha256sum /sandbox/.deepagents/config.toml"); + expect(script).toContain("config is baked into the sandbox image at build time"); + expect(script).toContain("re-onboard with the new selection"); + }); + it("preserves the repeated env-unset pairs from the failed observability invocation", async () => { await SandboxExecCommand.run( [