From 30ec8895baffa59439e1ac6109e99dcbbc953d64 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:08:55 -0700 Subject: [PATCH 01/21] fix(onboard): preserve fresh DCode selection Co-authored-by: Chengjie Wang Signed-off-by: Chengjie Wang Signed-off-by: Apurv Kumaria --- .../langchain-deepagents-code/manifest.yaml | 4 +- .../quickstart-langchain-deepagents-code.mdx | 2 + docs/reference/commands-nemohermes.mdx | 3 +- docs/reference/commands.mdx | 3 +- src/lib/onboard.ts | 125 ++++--- .../created-sandbox-finalization.test.ts | 326 ++++++++++++++++++ .../onboard/created-sandbox-finalization.ts | 86 +++++ src/lib/onboard/dcode-selection-drift.test.ts | 147 ++++++++ src/lib/onboard/dcode-selection-drift.ts | 137 ++++++++ .../onboard/machine/core-flow-phases.test.ts | 1 + .../handlers/sandbox-dcode-selection.test.ts | 152 ++++++++ .../machine/handlers/sandbox-resume.test.ts | 2 + .../machine/handlers/sandbox-resume.ts | 29 +- .../machine/handlers/sandbox-test-fixtures.ts | 1 + src/lib/onboard/machine/handlers/sandbox.ts | 53 ++- .../state/dcode-config-restore-input.test.ts | 243 +++++++++++++ src/lib/state/dcode-config-restore-input.ts | 213 ++++++++++++ src/lib/state/sandbox.ts | 44 ++- 18 files changed, 1504 insertions(+), 67 deletions(-) create mode 100644 src/lib/onboard/created-sandbox-finalization.test.ts create mode 100644 src/lib/onboard/created-sandbox-finalization.ts create mode 100644 src/lib/onboard/dcode-selection-drift.test.ts create mode 100644 src/lib/onboard/dcode-selection-drift.ts create mode 100644 src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts create mode 100644 src/lib/state/dcode-config-restore-input.test.ts create mode 100644 src/lib/state/dcode-config-restore-input.ts diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index fe30f5032b3..eff8857908f 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -46,7 +46,9 @@ state_dirs: - agent/skills # ── Top-level durable state files ─────────────────────────────── -# config.toml is non-secret NemoClaw-generated provider/model configuration. +# config.toml mixes durable DCode preferences with NemoClaw-managed model +# routing. Managed re-onboard restore preserves user-owned tables while keeping +# the freshly generated models/update tables and provider metadata authoritative. # .env and user-authored .deepagents/.mcp.json content are intentionally omitted # because they may contain service credentials. NemoClaw writes only direct-HTTP # bridge endpoint config and OpenShell placeholders to its separate diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 5a987b8d397..ff7413d954d 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -110,6 +110,8 @@ For project-specific Python dependencies, create a separate virtual environment Deep Agents Code state lives under `/sandbox/.deepagents`. NemoClaw snapshot and rebuild flows preserve the app state directory, skills, and generated config when those files exist. +During managed re-onboarding, NemoClaw merges restored `config.toml` user preference tables while keeping the freshly generated model routing, update settings, and provider metadata authoritative. +It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check. Run `nemoclaw snapshot create` after active `dcode` tasks finish. For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. NemoClaw intentionally does not back up `.deepagents/.env` or the user-owned `.deepagents/.mcp.json` because users may put Tavily, LangSmith, MCP service, or provider credentials there. diff --git a/docs/reference/commands-nemohermes.mdx b/docs/reference/commands-nemohermes.mdx index a7a0f303380..58fc142d7cf 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -319,7 +319,8 @@ Existing live sandboxes are not deleted by this cancel rollback path. If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection. In interactive mode, the wizard asks for confirmation before delete and recreate. -In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default. +In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs. +For managed Deep Agents Code sandboxes, NemoClaw also recreates when the live `dcode identity` selection is unreadable; other agent paths continue to reuse by default when their stored selection cannot be read. Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected. Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index be6cc145ff6..a1b49e127cd 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -414,7 +414,8 @@ Existing live sandboxes are not deleted by this cancel rollback path. If you run onboarding again with the same sandbox name and choose a different inference provider or model, NemoClaw detects the drift and recreates the sandbox so the running agent config matches your selection. In interactive mode, the wizard asks for confirmation before delete and recreate. -In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs; if NemoClaw cannot read the stored selection, NemoClaw reuses by default. +In non-interactive mode, NemoClaw recreates automatically when the stored selection is readable and differs. +For managed Deep Agents Code sandboxes, NemoClaw also recreates when the live `dcode identity` selection is unreadable; other agent paths continue to reuse by default when their stored selection cannot be read. Set `NEMOCLAW_RECREATE_SANDBOX=1` to force recreation even when no drift is detected. Before deleting an existing sandbox during recreation, NemoClaw backs up the workspace state declared by the selected agent profile and restores it into the new sandbox once it is live. diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 5604c943fee..2ff81abc202 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -107,6 +107,14 @@ const { const { getSelectionDrift, }: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift"); +const { + getDcodeSelectionDrift, + requiresSelectionRecreate, + usesManagedDcodeIdentity, +}: typeof import("./onboard/dcode-selection-drift") = require("./onboard/dcode-selection-drift"); +const { + finalizeCreatedSandbox, +}: typeof import("./onboard/created-sandbox-finalization") = require("./onboard/created-sandbox-finalization"); const { resolveRequestedProviderSelection, }: typeof import("./onboard/provider-selection") = require("./onboard/provider-selection"); @@ -2388,6 +2396,7 @@ async function createSandboxWithBaseImageResolution( const effectiveSandboxGpuConfig = sandboxGpuConfig ?? resolveSandboxGpuConfig(gpu, { flag: null, device: null }); const manageDashboard = dashboardRuntime.shouldManageDashboardForAgent(agent); + const isManagedDcodeAgent = usesManagedDcodeIdentity(agent?.name, fromDockerfile); let effectivePort = 0, chatUiUrl = ""; if (manageDashboard) { @@ -2452,6 +2461,15 @@ async function createSandboxWithBaseImageResolution( // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. const { existingEntry, preservedMcpState, liveExists, effectiveToolDisclosure, toolDisclosureMigrationNeeded, toolDisclosureMigrationNote } = toolDisclosureFlow.prepareSandboxToolDisclosure(sandboxName, preparedBuildContext?.rebuildTarget?.fromDockerfile ? preparedBuildContext.stagedDockerfile : fromDockerfile, isRecreateSandbox(createIntent?.recreate), inspectSandboxForCreate, createIntent?.toolDisclosure ?? null); + if (liveExists && isManagedDcodeAgent && !existingEntry) { + console.error( + ` Sandbox '${sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse or recreation.`, + ); + console.error( + " Choose a different sandbox name, or remove the orphan explicitly with OpenShell.", + ); + process.exit(1); + } // #4614: capture default AFTER prune so a stale registry row isn't read as a live sandbox. const sandboxWasLiveDefault = liveExists && wasSandboxDefault(registry.getDefault(), sandboxName); @@ -2512,8 +2530,12 @@ async function createSandboxWithBaseImageResolution( const needsProviderMigration = hasMessagingTokens && messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); - const selectionDrift = getSelectionDrift(sandboxName, provider, model, { runOpenshell }); - const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown; + const selectionDrift = isManagedDcodeAgent + ? getDcodeSelectionDrift(sandboxName, provider, model, preferredInferenceApi, { + runCaptureOpenshell, + }) + : getSelectionDrift(sandboxName, provider, model, { runOpenshell }); + const actionableSelectionDrift = requiresSelectionRecreate(selectionDrift, isManagedDcodeAgent); const sandboxGpuDrift = hasSandboxGpuDrift(sandboxName, effectiveSandboxGpuConfig); const existingSandboxEntry = registry.getSandbox(sandboxName); const recordedHermesToolGateways = normalizeHermesToolGatewaySelections( @@ -2567,7 +2589,7 @@ async function createSandboxWithBaseImageResolution( if (isNonInteractive()) { if (existingSandboxState === "ready") { - if (confirmedSelectionDrift) { + if (actionableSelectionDrift) { note(" [non-interactive] Recreating sandbox due to provider/model drift."); } else { policyPresetCarry.seedReusedSandboxPolicyPresets(sandboxName, isNonInteractive()); @@ -2615,7 +2637,7 @@ async function createSandboxWithBaseImageResolution( pendingStateRestoreBackupPath = outcome.restoreBackupPath; } } else if (existingSandboxState === "ready") { - if (confirmedSelectionDrift) { + if (actionableSelectionDrift) { const confirmed = await confirmRecreateForSelectionDrift( sandboxName, selectionDrift, @@ -2684,8 +2706,10 @@ async function createSandboxWithBaseImageResolution( } else if (needsProviderMigration) { console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); console.log(" Recreating to ensure credentials flow through the provider pipeline."); - } else if (confirmedSelectionDrift) { - note(` Sandbox '${sandboxName}' exists — recreating to apply model/provider change.`); + } else if (actionableSelectionDrift) { + note( + ` Sandbox '${sandboxName}' exists — recreating because its live model/provider selection is stale or unreadable.`, + ); } else if (sandboxGpuDrift) { note(` Sandbox '${sandboxName}' exists — recreating to apply sandbox GPU settings.`); } else if (hermesToolGatewayDrift) { @@ -3019,49 +3043,62 @@ async function createSandboxWithBaseImageResolution( hermesDashboardForwarding.ensureForState(finalHermesDashboardState, sandboxName, true); } - // Register only after confirmed ready — prevents phantom entries + // Resolve registry metadata now, but publish it only after restored state is + // reconciled and the live agent selection is verified. // openshell tags images with seconds; buildId is ms. Parse actual tag from output. Fixes #2672. const resolvedImageTag = prebuild.imageRef ?? resolveSandboxImageTagFromCreateOutput(createResult.output, buildId); const sandboxRuntimeFields = getSandboxRuntimeRegistryFields(effectiveSandboxGpuConfig); const inferenceSelection = sandboxRegistration.selection; - sandboxRegistration.registerCreatedSandbox({ - sandboxName, - inferenceSelection: inferenceSelection(sandboxName, provider, model, preferredInferenceApi), - runtimeFields: sandboxRuntimeFields, - agent, - agentVersionKnown: !fromDockerfile, - imageTag: resolvedImageTag, - appliedPolicies: initialSandboxPolicy.appliedPresets, - toolDisclosure: effectiveToolDisclosure, - // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. - ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)), - plannedMessagingState, - preservedMcpState, - hermesToolGateways, - hermesDashboardState: finalHermesDashboardState, - dashboardPort: actualDashboardPort, - gatewayName: GATEWAY_NAME, - gatewayPort: GATEWAY_PORT, - }); - restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization - if (restoreBackupPath) { - note( - pendingStateRestoreBackupPath - ? " Restoring workspace state from pre-upgrade backup..." - : " Restoring workspace state from pre-recreate backup...", - ); - const restore = sandboxState.restoreSandboxState(sandboxName, restoreBackupPath); - if (restore.success) { - note( - ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, - ); - } else { - console.error(` Warning: partial restore. Manual recovery: ${restoreBackupPath}`); - } - } + finalizeCreatedSandbox( + { + sandboxName, + restoreBackupPath, + preUpgradeBackup: pendingStateRestoreBackupPath !== null, + validateManagedDcode: isManagedDcodeAgent, + provider, + model, + preferredInferenceApi, + }, + { + restoreSandboxState: sandboxState.restoreSandboxState, + getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => + getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { + runCaptureOpenshell, + }), + note, + error: console.error, + exitProcess: (code) => process.exit(code), + register: () => + sandboxRegistration.registerCreatedSandbox({ + sandboxName, + inferenceSelection: inferenceSelection( + sandboxName, + provider, + model, + preferredInferenceApi, + ), + runtimeFields: sandboxRuntimeFields, + agent, + agentVersionKnown: !fromDockerfile, + imageTag: resolvedImageTag, + appliedPolicies: initialSandboxPolicy.appliedPresets, + toolDisclosure: effectiveToolDisclosure, + // biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail. + ...sandboxRegistration.creationFidelity(webSearchConfig, fromDockerfile, normalizeHermesAuthMethod(hermesAuthMethod)), + plannedMessagingState, + preservedMcpState, + hermesToolGateways, + hermesDashboardState: finalHermesDashboardState, + dashboardPort: actualDashboardPort, + gatewayName: GATEWAY_NAME, + gatewayPort: GATEWAY_PORT, + }), + }, + ); + restoreDefaultAfterRecreate(registry.setDefault, sandboxName, sandboxWasLiveDefault); // #4614: default deferred to finalization // DNS proxy — run a forwarder in the sandbox pod so the isolated // sandbox namespace can resolve hostnames (fixes #626). @@ -4918,6 +4955,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { hydrateMessagingChannelConfig, messagingChannelConfigsEqual, getSandboxReuseState, + getDcodeSelectionDrift: (name, selectedProvider, selectedModel, selectedApi) => + getDcodeSelectionDrift(name, selectedProvider, selectedModel, selectedApi, { + runCaptureOpenshell, + }), hasSandboxGpuDrift, getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways, getSandboxRegistryEntry: registry.getSandbox, diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts new file mode 100644 index 00000000000..972e8f95b11 --- /dev/null +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -0,0 +1,326 @@ +// 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 { afterEach, describe, expect, it, vi } from "vitest"; + +import * as sandboxState from "../state/sandbox"; +import { finalizeCreatedSandbox } from "./created-sandbox-finalization"; +import { getDcodeSelectionDrift } from "./dcode-selection-drift"; + +const fixtures: string[] = []; + +afterEach(() => { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + for (const fixture of fixtures.splice(0)) fs.rmSync(fixture, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +function executable(file: string, contents: string): void { + fs.writeFileSync(file, contents, { mode: 0o755 }); +} + +function makeRestoreFixture(): { + backupPath: string; + currentPath: string; + oldPath: string; +} { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-finalize-")); + fixtures.push(root); + const bin = path.join(root, "bin"); + const backupPath = path.join(root, "backup"); + const liveDir = path.join(root, "live", ".deepagents"); + const currentPath = path.join(liveDir, "config.toml"); + const oldPath = process.env.PATH ?? ""; + fs.mkdirSync(bin, { recursive: true }); + fs.mkdirSync(backupPath); + fs.mkdirSync(liveDir, { recursive: true }); + + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "dcode", + timestamp: "2026-07-06T00:00:00.000Z", + agentType: "langchain-deepagents-code", + agentVersion: "0.1.0", + expectedVersion: "0.1.0", + stateDirs: [], + backedUpDirs: [], + stateFiles: [{ path: "config.toml", strategy: "copy" }], + dir: "/sandbox/.deepagents", + backupPath, + blueprintDigest: null, + }), + ); + fs.writeFileSync( + path.join(backupPath, "config.toml"), + [ + "[models]", + 'default = "openai:old-model"', + "", + "[update]", + "check = true", + "auto_update = true", + "", + "[agents]", + 'default = "reviewer"', + "", + "[ui]", + 'theme = "dark"', + "", + ].join("\n"), + ); + fs.writeFileSync( + currentPath, + [ + "# Generated by NemoClaw. This file contains no provider secrets.", + "# NemoClaw provider route: inference; upstream provider: nvidia-prod; API: openai-completions.", + "", + "[models]", + 'default = "openai:new-model"', + "", + "[models.providers.openai]", + 'models = ["new-model"]', + 'api_key_env = "DEEPAGENTS_CODE_OPENAI_API_KEY"', + 'base_url = "https://inference.local/v1"', + "enabled = true", + "", + "[update]", + "check = false", + "auto_update = false", + "", + ].join("\n"), + ); + + const pythonResult = spawnSync("python3", ["-c", "import sys; print(sys.executable)"], { + encoding: "utf8", + }); + if (pythonResult.status !== 0 || !pythonResult.stdout.trim()) { + throw new Error(`Python 3 is required for TOML restore tests: ${pythonResult.stderr}`); + } + const hostPython = pythonResult.stdout.trim(); + const python = path.join(bin, "python3"); + executable( + python, + `#!${hostPython} +import json, sys, types + +class TOMLDecodeError(ValueError): + pass + +def parse_scalar(value): + if value == "true": return True + if value == "false": return False + try: return json.loads(value) + except (TypeError, ValueError) as error: raise TOMLDecodeError("malformed") from error + +def loads(text): + document = {} + table = document + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): continue + if line.startswith("[") and line.endswith("]"): + table = document + for name in line[1:-1].split("."): + table = table.setdefault(name, {}) + continue + if "=" not in line: raise TOMLDecodeError("malformed") + key, value = line.split("=", 1) + table[key.strip()] = parse_scalar(value.strip()) + return document + +def scalar(value): + if isinstance(value, bool): return "true" if value else "false" + if isinstance(value, str): return json.dumps(value) + if isinstance(value, list): return "[" + ", ".join(scalar(item) for item in value) + "]" + if isinstance(value, (int, float)): return str(value) + raise TypeError("unsupported test TOML value") + +def dumps(document): + lines = [] + def emit(prefix, table): + if prefix: lines.append("[" + ".".join(prefix) + "]") + for key, value in table.items(): + if not isinstance(value, dict): lines.append(key + " = " + scalar(value)) + if prefix: lines.append("") + for key, value in table.items(): + if isinstance(value, dict): emit([*prefix, key], value) + emit([], document) + return "\\n".join(lines).rstrip() + "\\n" + +tomli_w = types.ModuleType("tomli_w") +tomli_w.dumps = dumps +tomllib = types.ModuleType("tomllib") +tomllib.loads = loads +tomllib.TOMLDecodeError = TOMLDecodeError +sys.modules["tomllib"] = tomllib +sys.modules["tomli_w"] = tomli_w +script = sys.argv[3] +sys.argv = [sys.argv[0], *sys.argv[4:]] +exec(script, {"__name__": "__main__"}) +`, + ); + const openshell = path.join(bin, "openshell"); + executable( + openshell, + '#!/usr/bin/env bash\nprintf "Host openshell-dcode\\n HostName 127.0.0.1\\n User sandbox\\n"\n', + ); + executable( + path.join(bin, "ssh"), + `#!/usr/bin/env node +const fs = require("node:fs"); +const { spawnSync } = require("node:child_process"); +const command = process.argv.at(-1) + .replaceAll("/sandbox/.deepagents", ${JSON.stringify(liveDir)}) + .replace("/opt/venv/bin/python3", ${JSON.stringify(python)}); +const result = spawnSync("bash", ["-c", command], { input: fs.readFileSync(0), stdio: ["pipe", "pipe", "pipe"] }); +if (result.stdout) fs.writeSync(1, result.stdout); +if (result.stderr) fs.writeSync(2, result.stderr); +process.exit(result.status ?? 1); +`, + ); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${bin}${path.delimiter}${oldPath}`; + return { backupPath, currentPath, oldPath }; +} + +function identityFromConfig(config: string): string { + const metadata = config.match( + /^# NemoClaw provider route: ([^;]+); upstream provider: ([^;]+);/m, + ); + const model = config.match(/^default = "([^"]+)"$/m)?.[1]; + const endpoint = config.match(/^base_url = "([^"]+)"$/m)?.[1]; + return [ + `Route: ${metadata?.[1] ?? ""}`, + `Provider: ${metadata?.[2] ?? ""}`, + `Model: ${model ?? ""}`, + `Endpoint: ${endpoint ?? ""}`, + ].join("\n"); +} + +describe("created DCode sandbox finalization", () => { + it("merges stale backup preferences before live validation and registry publication (#6311)", () => { + const fixture = makeRestoreFixture(); + const order: string[] = []; + const registeredConfigs: string[] = []; + try { + finalizeCreatedSandbox( + { + sandboxName: "dcode", + restoreBackupPath: fixture.backupPath, + preUpgradeBackup: false, + validateManagedDcode: true, + provider: "nvidia-prod", + model: "new-model", + preferredInferenceApi: null, + }, + { + restoreSandboxState: (name, backup, options) => { + order.push("restore"); + expect(options).toEqual({ mergeManagedDcodeConfig: true }); + return sandboxState.restoreSandboxState(name, backup, options); + }, + getDcodeSelectionDrift: (name, provider, model, api) => { + order.push("validate"); + return getDcodeSelectionDrift(name, provider, model, api, { + runCaptureOpenshell: () => + identityFromConfig(fs.readFileSync(fixture.currentPath, "utf8")), + }); + }, + register: () => { + order.push("register"); + registeredConfigs.push(fs.readFileSync(fixture.currentPath, "utf8")); + }, + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); + + expect(order).toEqual(["restore", "validate", "register"]); + expect(registeredConfigs[0]).toContain('default = "openai:new-model"'); + expect(registeredConfigs[0]).not.toContain("old-model"); + expect(registeredConfigs[0]).toContain('[agents]\ndefault = "reviewer"'); + expect(registeredConfigs[0]).toContain('[ui]\ntheme = "dark"'); + } finally { + process.env.PATH = fixture.oldPath; + } + }); + + it("does not publish registry metadata when live validation fails (#6311)", () => { + const register = vi.fn(); + expect(() => + finalizeCreatedSandbox( + { + sandboxName: "dcode", + restoreBackupPath: null, + preUpgradeBackup: false, + validateManagedDcode: true, + provider: "nvidia-prod", + model: "new-model", + preferredInferenceApi: null, + }, + { + restoreSandboxState: vi.fn(), + getDcodeSelectionDrift: () => ({ + changed: true, + providerChanged: false, + modelChanged: true, + existingProvider: "nvidia-prod", + existingModel: "openai:old-model", + unknown: false, + }), + register, + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ), + ).toThrow("exit 1"); + expect(register).not.toHaveBeenCalled(); + }); + + it("keeps custom-image restores outside the managed config merge (#6311)", () => { + const restoreSandboxState = vi.fn(() => ({ + success: true, + restoredDirs: [], + restoredFiles: ["config.toml"], + })); + + finalizeCreatedSandbox( + { + sandboxName: "custom-dcode", + restoreBackupPath: "/tmp/custom-backup", + preUpgradeBackup: false, + validateManagedDcode: false, + provider: "custom-provider", + model: "custom-model", + preferredInferenceApi: null, + }, + { + restoreSandboxState, + getDcodeSelectionDrift: vi.fn(), + register: vi.fn(), + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); + + expect(restoreSandboxState).toHaveBeenCalledWith("custom-dcode", "/tmp/custom-backup", { + mergeManagedDcodeConfig: false, + }); + }); +}); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts new file mode 100644 index 00000000000..d2539062a47 --- /dev/null +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SelectionDrift } from "./selection-drift"; + +type RestoreResult = { + success: boolean; + restoredDirs: string[]; + restoredFiles: string[]; +}; + +type RestoreOptions = { + mergeManagedDcodeConfig?: boolean; +}; + +export type CreatedSandboxFinalizationOptions = { + sandboxName: string; + restoreBackupPath: string | null; + preUpgradeBackup: boolean; + validateManagedDcode: boolean; + provider: string; + model: string; + preferredInferenceApi: string | null; +}; + +export type CreatedSandboxFinalizationDeps = { + restoreSandboxState( + sandboxName: string, + backupPath: string, + options?: RestoreOptions, + ): RestoreResult; + getDcodeSelectionDrift( + sandboxName: string, + provider: string, + model: string, + preferredInferenceApi: string | null, + ): SelectionDrift; + register(): void; + note(message: string): void; + error(message: string): void; + exitProcess(code: number): never; +}; + +/** Restore state and validate the live managed DCode route before registry publication. */ +export function finalizeCreatedSandbox( + options: CreatedSandboxFinalizationOptions, + deps: CreatedSandboxFinalizationDeps, +): void { + if (options.restoreBackupPath) { + deps.note( + options.preUpgradeBackup + ? " Restoring workspace state from pre-upgrade backup..." + : " Restoring workspace state from pre-recreate backup...", + ); + const restore = deps.restoreSandboxState(options.sandboxName, options.restoreBackupPath, { + mergeManagedDcodeConfig: options.validateManagedDcode, + }); + if (restore.success) { + deps.note( + ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, + ); + } else { + deps.error(` Warning: partial restore. Manual recovery: ${options.restoreBackupPath}`); + } + } + + if (options.validateManagedDcode) { + const finalSelection = deps.getDcodeSelectionDrift( + options.sandboxName, + options.provider, + options.model, + options.preferredInferenceApi, + ); + if (finalSelection.changed || finalSelection.unknown) { + deps.error( + ` DCode live model/provider validation failed for sandbox '${options.sandboxName}'; registry metadata was not updated.`, + ); + if (options.restoreBackupPath) { + deps.error(` Manual recovery: ${options.restoreBackupPath}`); + } + return deps.exitProcess(1); + } + } + + deps.register(); +} diff --git a/src/lib/onboard/dcode-selection-drift.test.ts b/src/lib/onboard/dcode-selection-drift.test.ts new file mode 100644 index 00000000000..0c5471c0ec1 --- /dev/null +++ b/src/lib/onboard/dcode-selection-drift.test.ts @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { + getDcodeSelectionDrift, + getExpectedDcodeInferenceIdentity, + normalizeDcodeModelName, + parseDcodeInferenceIdentity, + requiresSelectionRecreate, + usesManagedDcodeIdentity, +} from "./dcode-selection-drift"; + +function identity( + overrides: Partial> = {}, +) { + return [ + "Sandbox: alpha", + `Route: ${overrides.Route ?? "inference"}`, + `Provider: ${overrides.Provider ?? "nvidia-prod"}`, + `Model: ${overrides.Model ?? "openai:nvidia/nemotron-3-super-120b-a12b"}`, + `Endpoint: ${overrides.Endpoint ?? "https://inference.local/v1"}`, + "Runtime: Deep Agents Code (terminal)", + ].join("\n"); +} + +describe("live DCode selection drift", () => { + it("limits the managed identity contract to stock DCode images (#6311)", () => { + expect(usesManagedDcodeIdentity("langchain-deepagents-code", null)).toBe(true); + expect(usesManagedDcodeIdentity("langchain-deepagents-code", "/tmp/Dockerfile")).toBe(false); + expect(usesManagedDcodeIdentity("openclaw", null)).toBe(false); + }); + + it("fails closed only for unreadable managed DCode selection (#6311)", () => { + expect(requiresSelectionRecreate({ changed: true, unknown: true }, true)).toBe(true); + expect(requiresSelectionRecreate({ changed: true, unknown: true }, false)).toBe(false); + expect(requiresSelectionRecreate({ changed: true, unknown: false }, false)).toBe(true); + }); + + it("strictly parses one value for every managed identity field (#6311)", () => { + expect(parseDcodeInferenceIdentity(identity())).toEqual({ + route: "inference", + provider: "nvidia-prod", + model: "openai:nvidia/nemotron-3-super-120b-a12b", + endpoint: "https://inference.local/v1", + }); + + expect(parseDcodeInferenceIdentity(identity().replace(/^Endpoint:.*$/m, ""))).toBeNull(); + expect(parseDcodeInferenceIdentity(`${identity()}\nProvider: nvidia-prod`)).toBeNull(); + expect(parseDcodeInferenceIdentity(identity().replace(/^Model:.*$/m, "Model:"))).toBeNull(); + }); + + it("mirrors generated DCode model and route identity (#6311)", () => { + expect(normalizeDcodeModelName(" registry:model:tag ")).toBe("model:tag"); + expect( + getExpectedDcodeInferenceIdentity( + "compatible-anthropic-endpoint", + "registry:model:tag", + "anthropic-messages", + ), + ).toEqual({ + route: "anthropic", + provider: "compatible-anthropic-endpoint", + model: "openai:model:tag", + endpoint: "https://inference.local", + }); + }); + + it("accepts only a live identity matching the requested selection (#6311)", () => { + const runCaptureOpenshell = vi.fn(() => identity()); + + expect( + getDcodeSelectionDrift("alpha", "nvidia-prod", "nvidia/nemotron-3-super-120b-a12b", null, { + runCaptureOpenshell, + }), + ).toEqual({ + changed: false, + providerChanged: false, + modelChanged: false, + existingProvider: "nvidia-prod", + existingModel: "openai:nvidia/nemotron-3-super-120b-a12b", + unknown: false, + }); + expect(runCaptureOpenshell).toHaveBeenCalledWith( + ["sandbox", "exec", "-n", "alpha", "--", "dcode", "identity"], + { ignoreError: true }, + ); + }); + + it("reports provider drift for upstream, route, or endpoint changes (#6311)", () => { + for (const output of [ + identity({ Provider: "openai-api" }), + identity({ Route: "openai" }), + identity({ Endpoint: "https://old.example/v1" }), + ]) { + expect( + getDcodeSelectionDrift("alpha", "nvidia-prod", "nvidia/nemotron-3-super-120b-a12b", null, { + runCaptureOpenshell: () => output, + }), + ).toMatchObject({ + changed: true, + providerChanged: true, + modelChanged: false, + unknown: false, + }); + } + }); + + it("reports model drift from the live DCode config (#6311)", () => { + expect( + getDcodeSelectionDrift("alpha", "nvidia-prod", "new-model", null, { + runCaptureOpenshell: () => identity({ Model: "openai:old-model" }), + }), + ).toMatchObject({ + changed: true, + providerChanged: false, + modelChanged: true, + existingModel: "openai:old-model", + unknown: false, + }); + }); + + it.each([ + ["missing output", () => null], + ["malformed output", () => identity().replace(/^Route:.*$/m, "Route:")], + [ + "failed command", + () => { + throw new Error("sandbox unavailable"); + }, + ], + ])("fails closed for %s (#6311)", (_name, runCaptureOpenshell) => { + expect( + getDcodeSelectionDrift("alpha", "nvidia-prod", "model-a", null, { + runCaptureOpenshell, + }), + ).toEqual({ + changed: true, + providerChanged: false, + modelChanged: false, + existingProvider: null, + existingModel: null, + unknown: true, + }); + }); +}); diff --git a/src/lib/onboard/dcode-selection-drift.ts b/src/lib/onboard/dcode-selection-drift.ts new file mode 100644 index 00000000000..bed8b1774a4 --- /dev/null +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getSandboxInferenceConfig } from "../inference/config"; +import type { SelectionDrift } from "./selection-drift"; + +export type DcodeInferenceIdentity = { + route: string; + provider: string; + model: string; + endpoint: string; +}; + +export type DcodeSelectionDriftDeps = { + runCaptureOpenshell( + args: string[], + options?: { ignoreError?: boolean }, + ): string | null | undefined; +}; + +const IDENTITY_FIELDS = ["Route", "Provider", "Model", "Endpoint"] as const; +type IdentityField = (typeof IDENTITY_FIELDS)[number]; + +export function usesManagedDcodeIdentity( + agentName: string | null | undefined, + fromDockerfile: string | null | undefined, +): boolean { + return agentName === "langchain-deepagents-code" && !fromDockerfile; +} + +export function requiresSelectionRecreate( + drift: Pick, + managedDcode: boolean, +): boolean { + return drift.changed && (!drift.unknown || managedDcode); +} + +const UNKNOWN_SELECTION_DRIFT: SelectionDrift = { + changed: true, + providerChanged: false, + modelChanged: false, + existingProvider: null, + existingModel: null, + unknown: true, +}; + +export function normalizeDcodeModelName(model: string): string { + const trimmed = model.trim(); + const providerSeparator = trimmed.indexOf(":"); + return providerSeparator > 0 ? trimmed.slice(providerSeparator + 1) : trimmed; +} + +export function parseDcodeInferenceIdentity( + output: string | null | undefined, +): DcodeInferenceIdentity | null { + if (!output) return null; + + const values = new Map(); + for (const line of output.split(/\r?\n/u)) { + const prefix = line.match(/^(Route|Provider|Model|Endpoint):/u); + if (!prefix) continue; + + const match = line.match(/^(Route|Provider|Model|Endpoint):[ \t]+(\S(?:.*\S)?)$/u); + if (!match) return null; + + const field = match[1] as IdentityField; + const value = match[2]; + if (values.has(field) || /[\u0000-\u001f\u007f-\u009f]/u.test(value)) return null; + values.set(field, value); + } + + if (IDENTITY_FIELDS.some((field) => !values.has(field))) return null; + return { + route: values.get("Route") as string, + provider: values.get("Provider") as string, + model: values.get("Model") as string, + endpoint: values.get("Endpoint") as string, + }; +} + +export function getExpectedDcodeInferenceIdentity( + requestedProvider: string | null, + requestedModel: string | null, + preferredInferenceApi: string | null, +): DcodeInferenceIdentity | null { + if (requestedModel === null) return null; + + const route = getSandboxInferenceConfig(requestedModel, requestedProvider, preferredInferenceApi); + return { + route: route.providerKey, + provider: requestedProvider?.trim() || route.providerKey, + model: `openai:${normalizeDcodeModelName(requestedModel)}`, + endpoint: route.inferenceBaseUrl, + }; +} + +export function getDcodeSelectionDrift( + sandboxName: string, + requestedProvider: string | null, + requestedModel: string | null, + preferredInferenceApi: string | null, + deps: DcodeSelectionDriftDeps, +): SelectionDrift { + const expected = getExpectedDcodeInferenceIdentity( + requestedProvider, + requestedModel, + preferredInferenceApi, + ); + if (!sandboxName || !expected) return { ...UNKNOWN_SELECTION_DRIFT }; + + let output: string | null | undefined; + try { + output = deps.runCaptureOpenshell( + ["sandbox", "exec", "-n", sandboxName, "--", "dcode", "identity"], + { ignoreError: true }, + ); + } catch { + return { ...UNKNOWN_SELECTION_DRIFT }; + } + + const existing = parseDcodeInferenceIdentity(output); + if (!existing) return { ...UNKNOWN_SELECTION_DRIFT }; + + const providerChanged = + existing.provider !== expected.provider || + existing.route !== expected.route || + existing.endpoint !== expected.endpoint; + const modelChanged = existing.model !== expected.model; + return { + changed: providerChanged || modelChanged, + providerChanged, + modelChanged, + existingProvider: existing.provider, + existingModel: existing.model, + unknown: false, + }; +} diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 691c68e4664..66ac5993208 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -149,6 +149,7 @@ function createPhases( hydrateMessagingChannelConfig: (config) => config, messagingChannelConfigsEqual: () => true, getSandboxReuseState: () => "missing", + getDcodeSelectionDrift: () => ({ changed: false, unknown: false }), hasSandboxGpuDrift: () => false, getSandboxHermesToolGateways: () => [], getSandboxRegistryEntry: () => null, diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts new file mode 100644 index 00000000000..9931032629d --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createSession } from "../../../state/onboard-session"; +import type { SandboxEntry } from "../../../state/registry"; +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +vi.mock("../../messaging-channel-setup", () => ({ + detectMessagingChannelsFromEnv: vi.fn(() => []), +})); + +function completedSession() { + const session = createSession({ sandboxName: "saved" }); + session.steps.sandbox.status = "complete"; + return session; +} + +function dcodeRegistryEntry( + name: string, + selection: Partial> = { + provider: "provider", + model: "model", + }, +): SandboxEntry { + return { + name, + agent: "langchain-deepagents-code", + nemoclawVersion: "0.1.0", + toolDisclosure: "progressive", + webSearchEnabled: false, + webSearchProvider: null, + fromDockerfile: null, + hermesAuthMethod: null, + ...selection, + }; +} + +function dcodeOptions(deps: ReturnType["deps"]) { + return { + ...baseOptions(deps, completedSession()), + resume: true, + sandboxName: "saved", + agent: { name: "langchain-deepagents-code", displayName: "Deep Agents Code" }, + }; +} + +describe("handleSandboxState live DCode selection", () => { + it.each([ + ["changed", { changed: true, unknown: false }], + ["unreadable", { changed: false, unknown: true }], + ])("recreates a ready sandbox when live selection is %s (#6311)", async (_label, drift) => { + const getDcodeSelectionDrift = vi.fn(() => drift); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift, + getSandboxRegistryEntry: (name) => dcodeRegistryEntry(name), + }); + + await handleSandboxState(dcodeOptions(deps)); + + expect(getDcodeSelectionDrift).toHaveBeenCalledWith( + "saved", + "provider", + "model", + "openai-completions", + ); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual({ + recreate: true, + toolDisclosure: "progressive", + }); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + }); + + it("reuses a ready sandbox only after the live selection is verified (#6311)", async () => { + const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false })); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift, + getSandboxRegistryEntry: (name) => dcodeRegistryEntry(name), + }); + + await handleSandboxState(dcodeOptions(deps)); + + expect(getDcodeSelectionDrift).toHaveBeenCalledOnce(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.skipped).toHaveBeenCalledWith("sandbox", "saved"); + }); + + it("refuses managed DCode reuse when the registry record is missing (#6311)", async () => { + const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false })); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift, + getSandboxRegistryEntry: () => null, + }); + + await expect(handleSandboxState(dcodeOptions(deps))).rejects.toThrow("exit 1"); + + expect(calls.error).toHaveBeenCalledWith( + expect.stringContaining("missing its NemoClaw registry record"), + ); + expect(getDcodeSelectionDrift).not.toHaveBeenCalled(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + }); + + it("keeps custom DCode images outside the managed identity contract (#6311)", async () => { + const getDcodeSelectionDrift = vi.fn(() => ({ changed: true, unknown: true })); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift, + getSandboxRegistryEntry: (name) => ({ + ...dcodeRegistryEntry(name), + fromDockerfile: "/tmp/CustomDockerfile", + }), + }); + + await handleSandboxState({ + ...dcodeOptions(deps), + fromDockerfile: "/tmp/CustomDockerfile", + }); + + expect(getDcodeSelectionDrift).not.toHaveBeenCalled(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.updateSandbox).not.toHaveBeenCalled(); + }); + + it.each([ + ["missing fields", {}], + ["stale", { provider: "old-provider", model: "old-model" }], + ])("backfills %s registry selection after verified live reuse (#6311)", async (_label, selection) => { + const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false })); + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift, + getSandboxRegistryEntry: (name) => dcodeRegistryEntry(name, selection), + }); + + await handleSandboxState(dcodeOptions(deps)); + + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.updateSandbox).toHaveBeenCalledWith("saved", { + provider: "provider", + model: "model", + }); + expect(getDcodeSelectionDrift.mock.invocationCallOrder[0]).toBeLessThan( + calls.updateSandbox.mock.invocationCallOrder[0], + ); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index fd1ddd02194..6fec7c38418 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -17,6 +17,7 @@ function resumeSignals(overrides: Partial = {}): SandboxRe hermesToolGatewayConfigChanged: false, toolDisclosureMigrationNeeded: false, toolDisclosureChanged: false, + inferenceSelectionChanged: false, ...overrides, }; } @@ -34,6 +35,7 @@ describe("decideSandboxResume", () => { ["Hermes tool gateway", { hermesToolGatewayConfigChanged: true }, true], ["tool disclosure migration", { toolDisclosureMigrationNeeded: true }, false], ["tool disclosure", { toolDisclosureChanged: true }, false], + ["live DCode inference selection", { inferenceSelectionChanged: true }, false], ] as const)("recreates for %s drift", (_label, overrides, removeRegistryEntry) => { expect(decideSandboxResume(resumeSignals(overrides))).toMatchObject({ kind: "recreate", diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.ts b/src/lib/onboard/machine/handlers/sandbox-resume.ts index 9feb66d5102..fc573cbda74 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -16,6 +16,7 @@ export interface SandboxResumeSignals { readonly hermesToolGatewayConfigChanged: boolean; readonly toolDisclosureMigrationNeeded: boolean; readonly toolDisclosureChanged: boolean; + readonly inferenceSelectionChanged: boolean; } export function resolveToolDisclosureResumeSignals( @@ -60,7 +61,6 @@ export interface SandboxResumeDeps { function canReuseSandbox(signals: SandboxResumeSignals): boolean { return ( - !signals.resumeAgentChanged && !signals.webSearchConfigChanged && !signals.sandboxGpuConfigChanged && !signals.messagingChannelConfigChanged && @@ -71,6 +71,24 @@ function canReuseSandbox(signals: SandboxResumeSignals): boolean { ); } +function selectionResumeDecision(signals: SandboxResumeSignals): SandboxResumeDecision | null { + if (signals.inferenceSelectionChanged) { + return { + kind: "recreate", + note: " [resume] Live DCode model/provider selection is stale or unreadable; recreating sandbox.", + removeRegistryEntry: false, + }; + } + if (signals.resumeAgentChanged) { + return { + kind: "recreate", + note: " [resume] Agent selection changed; revalidating sandbox compatibility.", + removeRegistryEntry: false, + }; + } + return null; +} + function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxResumeDecision | null { if (signals.toolDisclosureMigrationNeeded) { return { @@ -94,14 +112,9 @@ function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxRes export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; + const selectionDecision = selectionResumeDecision(signals); + if (selectionDecision) return selectionDecision; if (canReuseSandbox(signals)) return { kind: "reuse" }; - if (signals.resumeAgentChanged) { - return { - kind: "recreate", - note: " [resume] Agent selection changed; revalidating sandbox compatibility.", - removeRegistryEntry: false, - }; - } if (signals.webSearchConfigChanged) { return { kind: "recreate", diff --git a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts index 8deb7a53c8b..c6fd61367a2 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -139,6 +139,7 @@ export function createDeps( hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, messagingChannelConfigsEqual: () => true, getSandboxReuseState: () => "missing", + getDcodeSelectionDrift: () => ({ changed: false, unknown: false }), hasSandboxGpuDrift: () => false, getSandboxHermesToolGateways: () => [], getSandboxRegistryEntry: (name: string) => ({ diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 986bd368568..d8816162b24 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -14,6 +14,7 @@ import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; +import { usesManagedDcodeIdentity } from "../../dcode-selection-drift"; import { withSandboxPhaseTrace } from "../../tracing"; import type { SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; @@ -84,6 +85,12 @@ export interface SandboxStateOptions< right: MessagingChannelConfig | null, ): boolean; getSandboxReuseState(sandboxName: string | null): string; + getDcodeSelectionDrift( + sandboxName: string, + provider: string, + model: string, + preferredInferenceApi: string | null, + ): { changed: boolean; unknown: boolean }; hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean; getSandboxHermesToolGateways(sandboxName: string): unknown; getSandboxRegistryEntry(sandboxName: string): SandboxEntry | null; @@ -374,15 +381,40 @@ class SandboxStateFlow< state.webSearchConfig as unknown as SharedWebSearchConfig | null, this.options.hermesToolGateways, ); - const toolDisclosureSignals = resolveToolDisclosureResumeSignals( - state.sandboxName ? this.deps.getSandboxRegistryEntry(state.sandboxName) : null, - state.session, + const registryEntry = state.sandboxName + ? this.deps.getSandboxRegistryEntry(state.sandboxName) + : null; + const toolDisclosureSignals = resolveToolDisclosureResumeSignals(registryEntry, state.session); + const sandboxReuseState = this.deps.getSandboxReuseState(state.sandboxName); + const managedDcodeResume = Boolean( + this.options.resume && + state.session?.steps?.sandbox?.status === "complete" && + state.sandboxName && + usesManagedDcodeIdentity( + (this.options.agent as { name?: string } | null)?.name, + this.options.fromDockerfile, + ), ); + if (managedDcodeResume && sandboxReuseState === "ready" && !registryEntry) { + this.deps.error( + ` Sandbox '${state.sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse.`, + ); + return this.deps.exitProcess(1); + } + const dcodeSelectionDrift = + managedDcodeResume && state.sandboxName && sandboxReuseState === "ready" && registryEntry + ? this.deps.getDcodeSelectionDrift( + state.sandboxName, + this.options.provider, + this.options.model, + this.options.preferredInferenceApi, + ) + : null; return decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", - sandboxReuseState: this.deps.getSandboxReuseState(state.sandboxName), + sandboxReuseState, webSearchConfigChanged: state.webSearchSupportDropped || state.webSearchConfigChanged, sandboxGpuConfigChanged: state.sandboxName ? this.deps.hasSandboxGpuDrift(state.sandboxName, this.options.sandboxGpuConfig) @@ -396,6 +428,9 @@ class SandboxStateFlow< effectiveToolGateways, ), ...toolDisclosureSignals, + inferenceSelectionChanged: Boolean( + dcodeSelectionDrift?.changed || dcodeSelectionDrift?.unknown, + ), }); } @@ -450,6 +485,16 @@ class SandboxStateFlow< if (existing?.hermesAuthMethod === undefined && this.options.hermesAuthMethod) { fidelity.hermesAuthMethod = this.options.hermesAuthMethod; } + if ( + usesManagedDcodeIdentity( + (this.options.agent as { name?: string } | null)?.name, + this.options.fromDockerfile, + ) && + (existing?.provider !== this.options.provider || existing?.model !== this.options.model) + ) { + fidelity.provider = this.options.provider; + fidelity.model = this.options.model; + } if (Object.keys(fidelity).length > 0) { this.deps.updateSandboxRegistry(state.sandboxName, fidelity); } diff --git a/src/lib/state/dcode-config-restore-input.test.ts b/src/lib/state/dcode-config-restore-input.test.ts new file mode 100644 index 00000000000..b1aee9a6edf --- /dev/null +++ b/src/lib/state/dcode-config-restore-input.test.ts @@ -0,0 +1,243 @@ +// 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"; + +import { + buildDcodeConfigMergeRestoreCommand, + DCODE_CONFIG_MERGE_PYTHON, + shouldMergeManagedDcodeConfigStateFile, +} from "./dcode-config-restore-input"; + +const GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provider secrets."; +const FRESH_PROVIDER_HEADER = + "# NemoClaw provider route: inference; upstream provider: compatible-endpoint; API: openai-completions."; + +const PYTHON_TEST_WRAPPER = String.raw` +import json +import sys +import types + +class TOMLDecodeError(ValueError): + pass + +def loads(text): + payload = "\n".join( + line for line in text.splitlines() if not line.startswith("#") + ).strip() + if payload == "MALFORMED": + raise TOMLDecodeError("malformed") + try: + return json.loads(payload) + except (TypeError, ValueError) as error: + raise TOMLDecodeError("malformed") from error + +tomllib = types.ModuleType("tomllib") +tomllib.loads = loads +tomllib.TOMLDecodeError = TOMLDecodeError +tomli_w = types.ModuleType("tomli_w") +tomli_w.dumps = lambda value: json.dumps(value, sort_keys=True) +sys.modules["tomllib"] = tomllib +sys.modules["tomli_w"] = tomli_w + +script = sys.argv[1] +sys.argv = [sys.argv[0], *sys.argv[2:]] +exec(script, {"__name__": "__main__"}) +`.trim(); + +function generatedCurrent(config: unknown, providerHeader = FRESH_PROVIDER_HEADER): string { + return `${GENERATED_HEADER}\n${providerHeader}\n\n${JSON.stringify(config)}\n`; +} + +function runMergeScript( + backup: string, + current: string, +): { + current: string; + stageExists: boolean; + status: number | null; + stderr: string; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-dcode-config-merge-")); + try { + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-dcode-merged.test"); + fs.writeFileSync(backupPath, backup, { mode: 0o600 }); + fs.writeFileSync(currentPath, current, { mode: 0o660 }); + fs.writeFileSync(stagedPath, "", { mode: 0o600 }); + + const result = spawnSync( + "python3", + [ + "-I", + "-c", + PYTHON_TEST_WRAPPER, + DCODE_CONFIG_MERGE_PYTHON, + backupPath, + currentPath, + stagedPath, + ], + { encoding: "utf-8" }, + ); + return { + current: fs.readFileSync(currentPath, "utf-8"), + stageExists: fs.existsSync(stagedPath), + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function mergedJson(config: string): Record { + return JSON.parse(config.split("\n").slice(2).join("\n").trim()) as Record; +} + +describe("DCode config restore ownership", () => { + it("selects only the canonical copied DCode config file (#6311)", () => { + expect( + shouldMergeManagedDcodeConfigStateFile( + true, + "langchain-deepagents-code", + "/sandbox/.deepagents/", + { + path: "config.toml", + strategy: "copy", + }, + ), + ).toBe(true); + expect( + shouldMergeManagedDcodeConfigStateFile( + false, + "langchain-deepagents-code", + "/sandbox/.deepagents/", + { + path: "config.toml", + strategy: "copy", + }, + ), + ).toBe(false); + expect( + shouldMergeManagedDcodeConfigStateFile(true, "openclaw", "/sandbox/.deepagents", { + path: "config.toml", + strategy: "copy", + }), + ).toBe(false); + expect( + shouldMergeManagedDcodeConfigStateFile( + true, + "langchain-deepagents-code", + "/sandbox/.deepagents", + { + path: "other.toml", + strategy: "copy", + }, + ), + ).toBe(false); + }); + + it("restores user settings while keeping fresh managed tables and headers (#6311)", () => { + const backup = { + models: { + default: "openai:nvidia/old-model", + providers: { openai: { models: ["nvidia/old-model"] } }, + }, + update: { check: true, auto_update: true }, + agents: { default: "reviewer", recent: "researcher" }, + ui: { theme: "nvidia-dark", show_scrollbar: true }, + retries: { openai: { max_attempts: 7 } }, + skills: { extra_allowed_dirs: ["/sandbox/shared-skills"] }, + }; + const fresh = { + models: { + default: "openai:nvidia/new-model", + providers: { + openai: { + models: ["nvidia/new-model"], + api_key_env: "DEEPAGENTS_CODE_OPENAI_API_KEY", + base_url: "https://inference.local/v1", + enabled: true, + }, + }, + }, + update: { check: false, auto_update: false }, + }; + + const result = runMergeScript(JSON.stringify(backup), generatedCurrent(fresh)); + + expect(result.status).toBe(0); + expect(result.stageExists).toBe(false); + expect(result.current.split("\n").slice(0, 2)).toEqual([ + GENERATED_HEADER, + FRESH_PROVIDER_HEADER, + ]); + expect(mergedJson(result.current)).toEqual({ + ...backup, + models: fresh.models, + update: fresh.update, + }); + }); + + it("leaves the fresh config untouched when the backup is malformed (#6311)", () => { + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runMergeScript("MALFORMED", current); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(current); + expect(result.stageExists).toBe(true); + expect(result.stderr).toContain("backed-up DCode config is not valid TOML"); + expect(result.stderr).not.toContain("MALFORMED"); + }); + + it("leaves the current file untouched when fresh managed data is invalid (#6311)", () => { + const missingUpdate = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + }); + + const result = runMergeScript(JSON.stringify({ ui: { theme: "dark" } }), missingUpdate); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(missingUpdate); + expect(result.stageExists).toBe(true); + expect(result.stderr).toContain("current DCode config is missing managed [update] data"); + }); + + it("requires fresh generated headers before replacing the current file (#6311)", () => { + const currentWithoutHeaders = JSON.stringify({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runMergeScript( + JSON.stringify({ agents: { default: "reviewer" } }), + currentWithoutHeaders, + ); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(currentWithoutHeaders); + expect(result.stderr).toContain("missing the generated NemoClaw header"); + }); + + it("builds a same-directory staged atomic restore command (#6311)", () => { + const command = buildDcodeConfigMergeRestoreCommand("/sandbox/.deepagents/"); + + expect(command).toContain(".nemoclaw-dcode-backup.XXXXXX"); + expect(command).toContain(".nemoclaw-dcode-merged.XXXXXX"); + expect(command).toContain("/opt/venv/bin/python3 -I -c"); + expect(command).toContain('"$backup_tmp" "$dst" "$staged_tmp"'); + expect(DCODE_CONFIG_MERGE_PYTHON).toContain("os.replace(staged_path, current_path)"); + expect(() => buildDcodeConfigMergeRestoreCommand("/tmp/.deepagents")).toThrow( + /requires \/sandbox\/\.deepagents/, + ); + }); +}); diff --git a/src/lib/state/dcode-config-restore-input.ts b/src/lib/state/dcode-config-restore-input.ts new file mode 100644 index 00000000000..964b8c20a2f --- /dev/null +++ b/src/lib/state/dcode-config-restore-input.ts @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { shellQuote } from "../runner.js"; + +const DCODE_AGENT_NAME = "langchain-deepagents-code"; +const DCODE_CONFIG_DIR = "/sandbox/.deepagents"; +const DCODE_CONFIG_FILE = "config.toml"; + +export interface DcodeConfigStateFileSpec { + path: string; + strategy: string; +} + +/** + * Deep Agents Code config restore source-of-truth boundary. + * + * DCode stores durable user preferences and NemoClaw-generated inference + * routing in the same TOML file. A wholesale backup restore would replace the + * newly generated provider/model selection, while dropping the file would lose + * user-owned settings. Until the agent manifest can express key-level + * ownership, restore must merge this one canonical file by top-level table. + */ +export function shouldMergeManagedDcodeConfigStateFile( + managedTarget: boolean, + agentType: string | null | undefined, + dir: string, + spec: DcodeConfigStateFileSpec, +): boolean { + return ( + managedTarget && + agentType === DCODE_AGENT_NAME && + dir.replace(/\/+$/, "") === DCODE_CONFIG_DIR && + spec.strategy === "copy" && + spec.path === DCODE_CONFIG_FILE + ); +} + +/** + * Runs inside the freshly rebuilt DCode sandbox. + * + * The backup owns every top-level table except `models` and `update`; those two + * tables and the generated header comments always come from the fresh image. + * Both inputs are parsed before a same-directory staged file atomically replaces + * the live config. Any read, parse, serialization, or race failure leaves the + * freshly generated file untouched. + */ +export const DCODE_CONFIG_MERGE_PYTHON = String.raw` +import copy +import os +import stat +import sys +import tomllib +import tomli_w + +MAX_CONFIG_BYTES = 16 * 1024 * 1024 +GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provider secrets." +PROVIDER_HEADER_PREFIX = "# NemoClaw provider route: " + + +def fail(message): + raise SystemExit(message) + + +def read_regular_file(path, label): + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(path, flags) + except OSError: + fail(f"{label} DCode config is missing or unsafe") + try: + metadata = os.fstat(fd) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + fail(f"{label} DCode config is not a single regular file") + if metadata.st_size > MAX_CONFIG_BYTES: + fail(f"{label} DCode config exceeds the restore size limit") + chunks = [] + total = 0 + while True: + chunk = os.read(fd, 65536) + if not chunk: + break + total += len(chunk) + if total > MAX_CONFIG_BYTES: + fail(f"{label} DCode config exceeds the restore size limit") + chunks.append(chunk) + finally: + os.close(fd) + try: + text = b"".join(chunks).decode("utf-8") + except UnicodeDecodeError: + fail(f"{label} DCode config is not valid UTF-8") + try: + parsed = tomllib.loads(text) + except tomllib.TOMLDecodeError: + fail(f"{label} DCode config is not valid TOML") + if not isinstance(parsed, dict): + fail(f"{label} DCode config must be a TOML document") + return text, parsed, metadata + + +def fresh_generated_headers(text): + lines = text.splitlines() + if len(lines) < 2 or lines[0] != GENERATED_HEADER: + fail("current DCode config is missing the generated NemoClaw header") + provider_header = lines[1] + if not provider_header.startswith(PROVIDER_HEADER_PREFIX): + fail("current DCode config is missing generated provider metadata") + if len(provider_header) > 2048 or any(ord(char) < 32 for char in provider_header): + fail("current DCode config has unsafe generated provider metadata") + return GENERATED_HEADER + "\n" + provider_header + + +def assert_fresh_managed_tables(current): + for table_name in ("models", "update"): + if not isinstance(current.get(table_name), dict): + fail(f"current DCode config is missing managed [{table_name}] data") + + +def render_merged_config(backup, current, headers): + merged = copy.deepcopy(backup) + merged["models"] = copy.deepcopy(current["models"]) + merged["update"] = copy.deepcopy(current["update"]) + try: + rendered = tomli_w.dumps(merged) + except Exception: + fail("merged DCode config could not be serialized safely") + if not isinstance(rendered, str): + fail("merged DCode config serializer returned invalid output") + payload = (headers + "\n\n" + rendered.rstrip() + "\n").encode("utf-8") + if len(payload) > MAX_CONFIG_BYTES: + fail("merged DCode config exceeds the restore size limit") + return payload + + +def write_staged_and_replace(staged_path, current_path, current_metadata, payload): + if os.path.dirname(staged_path) != os.path.dirname(current_path): + fail("DCode config staging path must share the live config directory") + flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(staged_path, flags) + except OSError: + fail("DCode config staging file is missing or unsafe") + try: + staged_metadata = os.fstat(fd) + if not stat.S_ISREG(staged_metadata.st_mode) or staged_metadata.st_nlink != 1: + fail("DCode config staging file is not a single regular file") + written = 0 + while written < len(payload): + written += os.write(fd, payload[written:]) + os.fchmod(fd, 0o660) + os.fsync(fd) + finally: + os.close(fd) + + try: + latest = os.lstat(current_path) + except OSError: + fail("current DCode config changed before atomic restore") + if stat.S_ISLNK(latest.st_mode) or ( + latest.st_dev, + latest.st_ino, + ) != ( + current_metadata.st_dev, + current_metadata.st_ino, + ): + fail("current DCode config changed before atomic restore") + + os.replace(staged_path, current_path) + directory_fd = os.open(os.path.dirname(current_path), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def main(): + if len(sys.argv) != 4: + fail("expected backup, current, and staging DCode config paths") + backup_path, current_path, staged_path = sys.argv[1:] + _backup_text, backup, _backup_metadata = read_regular_file(backup_path, "backed-up") + current_text, current, current_metadata = read_regular_file(current_path, "current") + headers = fresh_generated_headers(current_text) + assert_fresh_managed_tables(current) + payload = render_merged_config(backup, current, headers) + write_staged_and_replace(staged_path, current_path, current_metadata, payload) + + +main() +`.trim(); + +/** + * Build the SSH-side restore command. The backed-up TOML is supplied on stdin. + */ +export function buildDcodeConfigMergeRestoreCommand(dir: string): string { + const normalizedDir = dir.replace(/\/+$/, ""); + if (normalizedDir !== DCODE_CONFIG_DIR) { + throw new Error(`DCode config merge requires ${DCODE_CONFIG_DIR}`); + } + const destination = shellQuote(`${normalizedDir}/${DCODE_CONFIG_FILE}`); + return [ + `dst=${destination}`, + 'parent="$(dirname "$dst")"', + '[ -d "$parent" ] && [ ! -L "$parent" ] || { echo "unsafe DCode config parent" >&2; exit 10; }', + '[ -f "$dst" ] && [ ! -L "$dst" ] || { echo "fresh DCode config is missing or unsafe" >&2; exit 11; }', + 'backup_tmp="$(mktemp "${parent}/.nemoclaw-dcode-backup.XXXXXX")"', + 'staged_tmp="$(mktemp "${parent}/.nemoclaw-dcode-merged.XXXXXX")"', + 'trap \'rm -f -- "$backup_tmp" "$staged_tmp"\' EXIT', + 'cat > "$backup_tmp"', + 'chmod 600 "$backup_tmp" "$staged_tmp"', + `/opt/venv/bin/python3 -I -c ${shellQuote(DCODE_CONFIG_MERGE_PYTHON)} "$backup_tmp" "$dst" "$staged_tmp"`, + ].join("; "); +} diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 6b95ac4cc3b..b665eba6089 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -34,6 +34,10 @@ import { isRecord, type UnknownRecord } from "../core/json-types.js"; import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; +import { + buildDcodeConfigMergeRestoreCommand, + shouldMergeManagedDcodeConfigStateFile, +} from "./dcode-config-restore-input.js"; import { buildOpenClawConfigRestoreInputFromSandbox, shouldMergeOpenClawConfigStateFile, @@ -142,6 +146,11 @@ export interface RestoreResult { failedFiles: string[]; } +export interface RestoreOptions { + /** Enable the mixed-ownership config merge only for a known stock managed DCode target. */ + mergeManagedDcodeConfig?: boolean; +} + export interface TarValidationResult { safe: boolean; entries: string[]; @@ -899,20 +908,25 @@ function restoreStateFile( spec: StateFileSpec, backupPath: string, mergeOpenClawConfig = false, + mergeDcodeConfig = false, ): boolean { const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; - const command = buildStateFileRestoreCommand(dir, spec, mergeOpenClawConfig); + const command = mergeDcodeConfig + ? buildDcodeConfigMergeRestoreCommand(dir) + : buildStateFileRestoreCommand(dir, spec, mergeOpenClawConfig); _log(`Restoring state file ${spec.path} (${spec.strategy})`); - const input = buildStateFileRestoreInput( - configFile, - sandboxName, - dir, - spec, - backupPath, - mergeOpenClawConfig, - ); + const input = mergeDcodeConfig + ? readFileSync(localPath) + : buildStateFileRestoreInput( + configFile, + sandboxName, + dir, + spec, + backupPath, + mergeOpenClawConfig, + ); if (input === null) return false; const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { @@ -1337,7 +1351,11 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = /** * Restore state directories into a sandbox from a prior backup. */ -export function restoreSandboxState(sandboxName: string, backupPath: string): RestoreResult { +export function restoreSandboxState( + sandboxName: string, + backupPath: string, + options: RestoreOptions = {}, +): RestoreResult { _log(`restoreSandboxState: sandbox=${sandboxName}, backupPath=${backupPath}`); const manifest = readManifest(backupPath); if (!manifest) { @@ -1528,6 +1546,12 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re spec, backupPath, shouldMergeOpenClawConfigStateFile(manifest.agentType, dir, spec), + shouldMergeManagedDcodeConfigStateFile( + options.mergeManagedDcodeConfig === true, + manifest.agentType, + dir, + spec, + ), ) ) { restoredFiles.push(spec.path); From c5799bed1b27b9f4de241b8ce45c9598f9b4950e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:20:02 -0700 Subject: [PATCH 02/21] refactor(onboard): extract gateway failure handler Signed-off-by: Apurv Kumaria --- src/lib/onboard.ts | 80 ++---------------------- src/lib/onboard/gateway-start-failure.ts | 80 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 74 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2ff81abc202..a09ffce4a97 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -173,9 +173,6 @@ const os = require("os"); const path = require("path"); const pRetry = require("p-retry"); -/** Strip ANSI escape sequences before printing process output to the terminal. - * Covers CSI (color, erase, cursor), OSC, and C1 two-byte escapes per ECMA-48. */ -const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; const runner: typeof import("./runner") = require("./runner"); const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, runFile, validateName } = runner; const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile"); @@ -536,7 +533,7 @@ const { trackChildExit } = require("./onboard/child-exit-tracker") as typeof import("./onboard/child-exit-tracker"); const { reportDockerDriverGatewayStartFailure } = require("./onboard/docker-driver-gateway-failure") as typeof import("./onboard/docker-driver-gateway-failure"); -const { printDockerDaemonRecovery, reportLegacyGatewayStartResultFailure } = +const { createFinalGatewayStartFailureHandler, reportLegacyGatewayStartResultFailure } = require("./onboard/gateway-start-failure") as typeof import("./onboard/gateway-start-failure"); const dockerDriverGatewayEnv: typeof import("./onboard/docker-driver-gateway-env") = require("./onboard/docker-driver-gateway-env"); @@ -1353,80 +1350,15 @@ function destroyGateway( }); } -type FinalGatewayStartFailureOptions = { - retries: number; - dockerUnreachable?: boolean; - collectDiagnostics?: () => string | null | undefined; - cleanupGateway?: () => void; - exitProcess?: (code: number) => never; - printError?: (message?: string) => void; -}; - -function handleFinalGatewayStartFailure({ - retries, - dockerUnreachable = false, - collectDiagnostics = () => +const handleFinalGatewayStartFailure = createFinalGatewayStartFailureHandler({ + getGatewayName: () => GATEWAY_NAME, + collectDiagnostics: () => runCaptureOpenshell(["doctor", "logs", "--name", GATEWAY_NAME], { ignoreError: true, timeout: 10_000, }), - cleanupGateway = destroyGateway, - exitProcess = (code) => process.exit(code), - printError = (message = "") => console.error(message), -}: FinalGatewayStartFailureOptions): never { - if (dockerUnreachable) { - printDockerDaemonRecovery(printError); - return exitProcess(1); - } - - printError(` Gateway failed to start after ${retries + 1} attempts.`); - printError(" Gateway state preserved until diagnostics are collected."); - printError(""); - - try { - const logs = redact(collectDiagnostics() || ""); - if (logs) { - printError(" Gateway logs:"); - for (const line of String(logs) - .split("\n") - .map((l) => l.replace(/\r/g, "").replace(ANSI_RE, "")) - .filter(Boolean)) { - printError(` ${line}`); - } - printError(""); - } - } catch { - // doctor logs unavailable — continue to best-effort cleanup and manual instructions - } - - printError(" Cleaning up failed gateway state..."); - try { - cleanupGateway(); - printError(" Cleanup attempted."); - } catch (err) { - const message = compactText(err instanceof Error ? err.message : String(err)); - printError(message ? ` Cleanup attempt failed: ${message}` : " Cleanup attempt failed."); - } - printError(""); - printError(" Diagnostic command attempted before cleanup:"); - printError(` openshell doctor logs --name ${GATEWAY_NAME}`); - printError(" openshell doctor check"); - printError(""); - printError(" If gateway cleanup did not complete, run:"); - printError(` openshell gateway remove ${GATEWAY_NAME}`); - printError(` # For OpenShell releases that still expose lifecycle commands:`); - printError(` openshell gateway destroy -g ${GATEWAY_NAME}`); - if (process.platform === "linux") { - printError( - " sudo pkill -f openshell-gateway # if a privileged host gateway process remains", - ); - } - printError( - ` docker volume ls -q --filter "name=openshell-cluster-${GATEWAY_NAME}" | xargs -r docker volume rm`, - ); - printError(` nemoclaw onboard --resume`); - return exitProcess(1); -} + cleanupGateway: destroyGateway, +}); function getGatewayClusterContainerState(): string { const containerName = getGatewayClusterContainerName(GATEWAY_NAME); diff --git a/src/lib/onboard/gateway-start-failure.ts b/src/lib/onboard/gateway-start-failure.ts index 7154db54c47..2a8cbb751c0 100644 --- a/src/lib/onboard/gateway-start-failure.ts +++ b/src/lib/onboard/gateway-start-failure.ts @@ -7,6 +7,21 @@ import { classifyGatewayStartFailure } from "../validation"; const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\)|[@-_])/g; +export type FinalGatewayStartFailureOptions = { + retries: number; + dockerUnreachable?: boolean; + collectDiagnostics?: () => string | null | undefined; + cleanupGateway?: () => void; + exitProcess?: (code: number) => never; + printError?: (message?: string) => void; +}; + +export type FinalGatewayStartFailureDeps = { + getGatewayName(): string; + collectDiagnostics(): string | null | undefined; + cleanupGateway(): void; +}; + export function reportLegacyGatewayStartResultFailure( output: string, log: (message: string) => void, @@ -38,3 +53,68 @@ export function printDockerDaemonRecovery( printError(" Start the Docker daemon."); } } + +export function createFinalGatewayStartFailureHandler(deps: FinalGatewayStartFailureDeps) { + return function handleFinalGatewayStartFailure({ + retries, + dockerUnreachable = false, + collectDiagnostics = deps.collectDiagnostics, + cleanupGateway = deps.cleanupGateway, + exitProcess = (code) => process.exit(code), + printError = (message = "") => console.error(message), + }: FinalGatewayStartFailureOptions): never { + if (dockerUnreachable) { + printDockerDaemonRecovery(printError); + return exitProcess(1); + } + + const gatewayName = deps.getGatewayName(); + printError(` Gateway failed to start after ${retries + 1} attempts.`); + printError(" Gateway state preserved until diagnostics are collected."); + printError(""); + + try { + const logs = redact(collectDiagnostics() || ""); + if (logs) { + printError(" Gateway logs:"); + for (const line of String(logs) + .split("\n") + .map((entry) => entry.replace(/\r/g, "").replace(ANSI_RE, "")) + .filter(Boolean)) { + printError(` ${line}`); + } + printError(""); + } + } catch { + // doctor logs unavailable — continue to best-effort cleanup and manual instructions + } + + printError(" Cleaning up failed gateway state..."); + try { + cleanupGateway(); + printError(" Cleanup attempted."); + } catch (error) { + const message = compactText(error instanceof Error ? error.message : String(error)); + printError(message ? ` Cleanup attempt failed: ${message}` : " Cleanup attempt failed."); + } + printError(""); + printError(" Diagnostic command attempted before cleanup:"); + printError(` openshell doctor logs --name ${gatewayName}`); + printError(" openshell doctor check"); + printError(""); + printError(" If gateway cleanup did not complete, run:"); + printError(` openshell gateway remove ${gatewayName}`); + printError(" # For OpenShell releases that still expose lifecycle commands:"); + printError(` openshell gateway destroy -g ${gatewayName}`); + if (process.platform === "linux") { + printError( + " sudo pkill -f openshell-gateway # if a privileged host gateway process remains", + ); + } + printError( + ` docker volume ls -q --filter "name=openshell-cluster-${gatewayName}" | xargs -r docker volume rm`, + ); + printError(" nemoclaw onboard --resume"); + return exitProcess(1); + }; +} From 621dcae6a4e61db4923c3fffd641bd9c76e88621 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:21:29 -0700 Subject: [PATCH 03/21] test(onboard): keep finalization setup linear Signed-off-by: Apurv Kumaria --- src/lib/onboard/created-sandbox-finalization.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 972e8f95b11..29205dafc1a 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -100,9 +100,8 @@ function makeRestoreFixture(): { const pythonResult = spawnSync("python3", ["-c", "import sys; print(sys.executable)"], { encoding: "utf8", }); - if (pythonResult.status !== 0 || !pythonResult.stdout.trim()) { - throw new Error(`Python 3 is required for TOML restore tests: ${pythonResult.stderr}`); - } + expect(pythonResult.status, `Python 3 is required: ${pythonResult.stderr}`).toBe(0); + expect(pythonResult.stdout.trim(), "Python 3 executable path is required").not.toBe(""); const hostPython = pythonResult.stdout.trim(); const python = path.join(bin, "python3"); executable( From 56a78d62803f87e693ea498581f989449ff54dbf Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:31:24 -0700 Subject: [PATCH 04/21] refactor(onboard): isolate DCode resume policy Signed-off-by: Apurv Kumaria --- .../machine/handlers/sandbox-dcode-resume.ts | 83 +++++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 56 +++---------- 2 files changed, 93 insertions(+), 46 deletions(-) create mode 100644 src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts new file mode 100644 index 00000000000..b80e765fe0f --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Session } from "../../../state/onboard-session"; +import type { SandboxEntry } from "../../../state/registry"; +import { usesManagedDcodeIdentity } from "../../dcode-selection-drift"; + +export interface Deps { + getDcodeSelectionDrift( + sandboxName: string, + provider: string, + model: string, + preferredInferenceApi: string | null, + ): { changed: boolean; unknown: boolean }; + error(message?: string): void; + exitProcess(code: number): never; +} + +interface SelectionOptions { + readonly agent: Agent; + readonly fromDockerfile: string | null; + readonly provider: string; + readonly model: string; +} + +interface ResumeOptions extends SelectionOptions { + readonly resume: boolean; + readonly preferredInferenceApi: string | null; +} + +interface ResumeState { + readonly session: Session | null; + readonly sandboxName: string | null; +} + +function agentName(agent: Agent): string | null | undefined { + return (agent as { name?: string } | null | undefined)?.name; +} + +export function resolveSignals( + options: ResumeOptions, + state: ResumeState, + sandboxReuseState: string, + registryEntry: SandboxEntry | null, + deps: Deps, +): { inferenceSelectionChanged: boolean } { + const sandboxName = state.sandboxName; + if ( + !options.resume || + state.session?.steps?.sandbox?.status !== "complete" || + !sandboxName || + !usesManagedDcodeIdentity(agentName(options.agent), options.fromDockerfile) || + sandboxReuseState !== "ready" + ) { + return { inferenceSelectionChanged: false }; + } + if (!registryEntry) { + deps.error( + ` Sandbox '${sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse.`, + ); + return deps.exitProcess(1); + } + const drift = deps.getDcodeSelectionDrift( + sandboxName, + options.provider, + options.model, + options.preferredInferenceApi, + ); + return { inferenceSelectionChanged: Boolean(drift.changed || drift.unknown) }; +} + +export function selectionFidelity( + options: SelectionOptions, + existing: SandboxEntry | null, +): Partial> { + if ( + !usesManagedDcodeIdentity(agentName(options.agent), options.fromDockerfile) || + (existing?.provider === options.provider && existing?.model === options.model) + ) { + return {}; + } + return { provider: options.provider, model: options.model }; +} diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index d8816162b24..e46d46c292c 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -14,10 +14,10 @@ import type { SandboxMessagingPlan } from "../../../messaging/manifest"; import type { HermesAuthMethod, Session, SessionUpdates } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; import { toolDisclosureOrDefault } from "../../../tool-disclosure"; -import { usesManagedDcodeIdentity } from "../../dcode-selection-drift"; import { withSandboxPhaseTrace } from "../../tracing"; import type { SandboxCreateIntent } from "../../types"; import { branchTo, type OnboardStateTransitionResult } from "../result"; +import * as dcodeResume from "./sandbox-dcode-resume"; import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging"; import { applySandboxResumeDecision, @@ -58,7 +58,7 @@ export interface SandboxStateOptions< controlUiPort: number | null; rootDir: string; env: NodeJS.ProcessEnv; - deps: { + deps: dcodeResume.Deps & { resolvePath(value: string): string; agentSupportsWebSearch( agent: Agent, @@ -85,12 +85,6 @@ export interface SandboxStateOptions< right: MessagingChannelConfig | null, ): boolean; getSandboxReuseState(sandboxName: string | null): string; - getDcodeSelectionDrift( - sandboxName: string, - provider: string, - model: string, - preferredInferenceApi: string | null, - ): { changed: boolean; unknown: boolean }; hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean; getSandboxHermesToolGateways(sandboxName: string): unknown; getSandboxRegistryEntry(sandboxName: string): SandboxEntry | null; @@ -165,8 +159,6 @@ export interface SandboxStateOptions< }, ): Promise; withSandboxMutationLock?(sandboxName: string, action: () => Promise): Promise; - error(message?: string): void; - exitProcess(code: number): never; }; } @@ -386,30 +378,13 @@ class SandboxStateFlow< : null; const toolDisclosureSignals = resolveToolDisclosureResumeSignals(registryEntry, state.session); const sandboxReuseState = this.deps.getSandboxReuseState(state.sandboxName); - const managedDcodeResume = Boolean( - this.options.resume && - state.session?.steps?.sandbox?.status === "complete" && - state.sandboxName && - usesManagedDcodeIdentity( - (this.options.agent as { name?: string } | null)?.name, - this.options.fromDockerfile, - ), + const dcodeResumeSignals = dcodeResume.resolveSignals( + this.options, + state, + sandboxReuseState, + registryEntry, + this.deps, ); - if (managedDcodeResume && sandboxReuseState === "ready" && !registryEntry) { - this.deps.error( - ` Sandbox '${state.sandboxName}' is live but missing its NemoClaw registry record; refusing unverified DCode reuse.`, - ); - return this.deps.exitProcess(1); - } - const dcodeSelectionDrift = - managedDcodeResume && state.sandboxName && sandboxReuseState === "ready" && registryEntry - ? this.deps.getDcodeSelectionDrift( - state.sandboxName, - this.options.provider, - this.options.model, - this.options.preferredInferenceApi, - ) - : null; return decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, @@ -428,9 +403,7 @@ class SandboxStateFlow< effectiveToolGateways, ), ...toolDisclosureSignals, - inferenceSelectionChanged: Boolean( - dcodeSelectionDrift?.changed || dcodeSelectionDrift?.unknown, - ), + ...dcodeResumeSignals, }); } @@ -485,16 +458,7 @@ class SandboxStateFlow< if (existing?.hermesAuthMethod === undefined && this.options.hermesAuthMethod) { fidelity.hermesAuthMethod = this.options.hermesAuthMethod; } - if ( - usesManagedDcodeIdentity( - (this.options.agent as { name?: string } | null)?.name, - this.options.fromDockerfile, - ) && - (existing?.provider !== this.options.provider || existing?.model !== this.options.model) - ) { - fidelity.provider = this.options.provider; - fidelity.model = this.options.model; - } + Object.assign(fidelity, dcodeResume.selectionFidelity(this.options, existing)); if (Object.keys(fidelity).length > 0) { this.deps.updateSandboxRegistry(state.sandboxName, fidelity); } From 4d05d1c918cb5b5df809741fc0d61534778bbb56 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:31:50 -0700 Subject: [PATCH 05/21] refactor(state): generalize file restore policy Signed-off-by: Apurv Kumaria --- .../created-sandbox-finalization.test.ts | 11 ++-- .../onboard/created-sandbox-finalization.ts | 14 ++-- .../state/dcode-config-restore-input.test.ts | 65 ++++++++++--------- src/lib/state/dcode-config-restore-input.ts | 29 ++++++--- src/lib/state/sandbox.ts | 50 ++++++-------- src/lib/state/state-file-restore-policy.ts | 20 ++++++ 6 files changed, 112 insertions(+), 77 deletions(-) create mode 100644 src/lib/state/state-file-restore-policy.ts diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 29205dafc1a..69d6d967be5 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { managedDcodeConfigRestorePolicy } from "../state/dcode-config-restore-input"; import * as sandboxState from "../state/sandbox"; import { finalizeCreatedSandbox } from "./created-sandbox-finalization"; import { getDcodeSelectionDrift } from "./dcode-selection-drift"; @@ -222,7 +223,7 @@ describe("created DCode sandbox finalization", () => { { restoreSandboxState: (name, backup, options) => { order.push("restore"); - expect(options).toEqual({ mergeManagedDcodeConfig: true }); + expect(options?.stateFileRestorePolicy).toBe(managedDcodeConfigRestorePolicy); return sandboxState.restoreSandboxState(name, backup, options); }, getDcodeSelectionDrift: (name, provider, model, api) => { @@ -318,8 +319,10 @@ describe("created DCode sandbox finalization", () => { }, ); - expect(restoreSandboxState).toHaveBeenCalledWith("custom-dcode", "/tmp/custom-backup", { - mergeManagedDcodeConfig: false, - }); + expect(restoreSandboxState).toHaveBeenCalledWith( + "custom-dcode", + "/tmp/custom-backup", + undefined, + ); }); }); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index d2539062a47..4a430d1c131 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -1,6 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { managedDcodeConfigRestorePolicy } from "../state/dcode-config-restore-input"; +import type { StateFileRestorePolicy } from "../state/state-file-restore-policy"; import type { SelectionDrift } from "./selection-drift"; type RestoreResult = { @@ -10,7 +12,7 @@ type RestoreResult = { }; type RestoreOptions = { - mergeManagedDcodeConfig?: boolean; + stateFileRestorePolicy?: StateFileRestorePolicy; }; export type CreatedSandboxFinalizationOptions = { @@ -52,9 +54,13 @@ export function finalizeCreatedSandbox( ? " Restoring workspace state from pre-upgrade backup..." : " Restoring workspace state from pre-recreate backup...", ); - const restore = deps.restoreSandboxState(options.sandboxName, options.restoreBackupPath, { - mergeManagedDcodeConfig: options.validateManagedDcode, - }); + const restore = deps.restoreSandboxState( + options.sandboxName, + options.restoreBackupPath, + options.validateManagedDcode + ? { stateFileRestorePolicy: managedDcodeConfigRestorePolicy } + : undefined, + ); if (restore.success) { deps.note( ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, diff --git a/src/lib/state/dcode-config-restore-input.test.ts b/src/lib/state/dcode-config-restore-input.test.ts index b1aee9a6edf..38589369c23 100644 --- a/src/lib/state/dcode-config-restore-input.test.ts +++ b/src/lib/state/dcode-config-restore-input.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest"; import { buildDcodeConfigMergeRestoreCommand, DCODE_CONFIG_MERGE_PYTHON, - shouldMergeManagedDcodeConfigStateFile, + managedDcodeConfigRestorePolicy, } from "./dcode-config-restore-input"; const GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provider secrets."; @@ -100,46 +100,49 @@ function mergedJson(config: string): Record { } describe("DCode config restore ownership", () => { - it("selects only the canonical copied DCode config file (#6311)", () => { + it("plans a merge only for the canonical copied DCode config file (#6311)", () => { + const backupContents = Buffer.from("backed-up config"); + const plan = managedDcodeConfigRestorePolicy( + "langchain-deepagents-code", + "/sandbox/.deepagents/", + { path: "config.toml", strategy: "copy" }, + backupContents, + ); + + expect(plan?.command).toContain(".nemoclaw-dcode-merged.XXXXXX"); + expect(plan?.input).toBe(backupContents); expect( - shouldMergeManagedDcodeConfigStateFile( - true, - "langchain-deepagents-code", - "/sandbox/.deepagents/", - { - path: "config.toml", - strategy: "copy", - }, + managedDcodeConfigRestorePolicy( + "openclaw", + "/sandbox/.deepagents", + { path: "config.toml", strategy: "copy" }, + backupContents, ), - ).toBe(true); + ).toBeNull(); expect( - shouldMergeManagedDcodeConfigStateFile( - false, + managedDcodeConfigRestorePolicy( "langchain-deepagents-code", - "/sandbox/.deepagents/", - { - path: "config.toml", - strategy: "copy", - }, + "/sandbox/custom-deepagents", + { path: "config.toml", strategy: "copy" }, + backupContents, ), - ).toBe(false); + ).toBeNull(); expect( - shouldMergeManagedDcodeConfigStateFile(true, "openclaw", "/sandbox/.deepagents", { - path: "config.toml", - strategy: "copy", - }), - ).toBe(false); + managedDcodeConfigRestorePolicy( + "langchain-deepagents-code", + "/sandbox/.deepagents", + { path: "other.toml", strategy: "copy" }, + backupContents, + ), + ).toBeNull(); expect( - shouldMergeManagedDcodeConfigStateFile( - true, + managedDcodeConfigRestorePolicy( "langchain-deepagents-code", "/sandbox/.deepagents", - { - path: "other.toml", - strategy: "copy", - }, + { path: "config.toml", strategy: "sqlite_backup" }, + backupContents, ), - ).toBe(false); + ).toBeNull(); }); it("restores user settings while keeping fresh managed tables and headers (#6311)", () => { diff --git a/src/lib/state/dcode-config-restore-input.ts b/src/lib/state/dcode-config-restore-input.ts index 964b8c20a2f..f3bccb114cc 100644 --- a/src/lib/state/dcode-config-restore-input.ts +++ b/src/lib/state/dcode-config-restore-input.ts @@ -2,16 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { shellQuote } from "../runner.js"; +import type { StateFileRestorePolicy, StateFileRestoreSpec } from "./state-file-restore-policy.js"; const DCODE_AGENT_NAME = "langchain-deepagents-code"; const DCODE_CONFIG_DIR = "/sandbox/.deepagents"; const DCODE_CONFIG_FILE = "config.toml"; -export interface DcodeConfigStateFileSpec { - path: string; - strategy: string; -} - /** * Deep Agents Code config restore source-of-truth boundary. * @@ -21,14 +17,12 @@ export interface DcodeConfigStateFileSpec { * user-owned settings. Until the agent manifest can express key-level * ownership, restore must merge this one canonical file by top-level table. */ -export function shouldMergeManagedDcodeConfigStateFile( - managedTarget: boolean, +function shouldMergeManagedDcodeConfigStateFile( agentType: string | null | undefined, dir: string, - spec: DcodeConfigStateFileSpec, + spec: StateFileRestoreSpec, ): boolean { return ( - managedTarget && agentType === DCODE_AGENT_NAME && dir.replace(/\/+$/, "") === DCODE_CONFIG_DIR && spec.strategy === "copy" && @@ -211,3 +205,20 @@ export function buildDcodeConfigMergeRestoreCommand(dir: string): string { `/opt/venv/bin/python3 -I -c ${shellQuote(DCODE_CONFIG_MERGE_PYTHON)} "$backup_tmp" "$dst" "$staged_tmp"`, ].join("; "); } + +/** + * Restore capability supplied only for a known stock managed DCode target. + * Backup provenance and the canonical file boundary are checked again here. + */ +export const managedDcodeConfigRestorePolicy: StateFileRestorePolicy = ( + agentType, + dir, + spec, + backupContents, +) => { + if (!shouldMergeManagedDcodeConfigStateFile(agentType, dir, spec)) return null; + return { + command: buildDcodeConfigMergeRestoreCommand(dir), + input: backupContents, + }; +}; diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index b665eba6089..34e759f1cbe 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -34,10 +34,6 @@ import { isRecord, type UnknownRecord } from "../core/json-types.js"; import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; -import { - buildDcodeConfigMergeRestoreCommand, - shouldMergeManagedDcodeConfigStateFile, -} from "./dcode-config-restore-input.js"; import { buildOpenClawConfigRestoreInputFromSandbox, shouldMergeOpenClawConfigStateFile, @@ -51,6 +47,7 @@ import { import type { CustomPolicyEntry } from "./registry.js"; import * as registry from "./registry.js"; import { isSshTransportFailure } from "./ssh-transport.js"; +import type { StateFileRestorePolicy } from "./state-file-restore-policy.js"; import { runTarListing } from "./tar-listing.js"; const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); @@ -147,8 +144,8 @@ export interface RestoreResult { } export interface RestoreOptions { - /** Enable the mixed-ownership config merge only for a known stock managed DCode target. */ - mergeManagedDcodeConfig?: boolean; + /** Optional file-specific restore capability authorized by the caller. */ + stateFileRestorePolicy?: StateFileRestorePolicy; } export interface TarValidationResult { @@ -882,11 +879,9 @@ function buildStateFileRestoreInput( sandboxName: string, dir: string, spec: StateFileSpec, - backupPath: string, + backupContents: Buffer, mergeOpenClawConfig: boolean, ): Buffer | null { - const localPath = path.join(backupPath, spec.path); - const backupContents = readFileSync(localPath); if (!mergeOpenClawConfig) return backupContents; const result = buildOpenClawConfigRestoreInputFromSandbox({ @@ -904,29 +899,30 @@ function buildStateFileRestoreInput( function restoreStateFile( configFile: string, sandboxName: string, + agentType: string | null | undefined, dir: string, spec: StateFileSpec, backupPath: string, mergeOpenClawConfig = false, - mergeDcodeConfig = false, + stateFileRestorePolicy?: StateFileRestorePolicy, ): boolean { const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; - const command = mergeDcodeConfig - ? buildDcodeConfigMergeRestoreCommand(dir) - : buildStateFileRestoreCommand(dir, spec, mergeOpenClawConfig); + const backupContents = readFileSync(localPath); + const plan = stateFileRestorePolicy?.(agentType, dir, spec, backupContents); + const command = plan?.command ?? buildStateFileRestoreCommand(dir, spec, mergeOpenClawConfig); _log(`Restoring state file ${spec.path} (${spec.strategy})`); - const input = mergeDcodeConfig - ? readFileSync(localPath) - : buildStateFileRestoreInput( - configFile, - sandboxName, - dir, - spec, - backupPath, - mergeOpenClawConfig, - ); + const input = + plan?.input ?? + buildStateFileRestoreInput( + configFile, + sandboxName, + dir, + spec, + backupContents, + mergeOpenClawConfig, + ); if (input === null) return false; const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { @@ -1542,16 +1538,12 @@ export function restoreSandboxState( restoreStateFile( configFile, sandboxName, + manifest.agentType, dir, spec, backupPath, shouldMergeOpenClawConfigStateFile(manifest.agentType, dir, spec), - shouldMergeManagedDcodeConfigStateFile( - options.mergeManagedDcodeConfig === true, - manifest.agentType, - dir, - spec, - ), + options.stateFileRestorePolicy, ) ) { restoredFiles.push(spec.path); diff --git a/src/lib/state/state-file-restore-policy.ts b/src/lib/state/state-file-restore-policy.ts new file mode 100644 index 00000000000..ee9310dc23e --- /dev/null +++ b/src/lib/state/state-file-restore-policy.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface StateFileRestoreSpec { + path: string; + strategy: "copy" | "sqlite_backup"; +} + +export interface StateFileRestorePlan { + command: string; + input: Buffer; +} + +/** Optional capability for a caller-authorized, file-specific restore plan. */ +export type StateFileRestorePolicy = ( + agentType: string | null | undefined, + dir: string, + spec: StateFileRestoreSpec, + backupContents: Buffer, +) => StateFileRestorePlan | null; From 3bdc9e65965ab10882db88d675d1c4f9f58f435f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:37:41 -0700 Subject: [PATCH 06/21] refactor(onboard): reuse restore state types Signed-off-by: Apurv Kumaria --- src/lib/onboard/created-sandbox-finalization.test.ts | 2 ++ src/lib/onboard/created-sandbox-finalization.ts | 12 +----------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 69d6d967be5..f1b1689d160 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -294,7 +294,9 @@ describe("created DCode sandbox finalization", () => { const restoreSandboxState = vi.fn(() => ({ success: true, restoredDirs: [], + failedDirs: [], restoredFiles: ["config.toml"], + failedFiles: [], })); finalizeCreatedSandbox( diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index 4a430d1c131..b9d0c53b9c2 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -2,19 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { managedDcodeConfigRestorePolicy } from "../state/dcode-config-restore-input"; -import type { StateFileRestorePolicy } from "../state/state-file-restore-policy"; +import type { RestoreOptions, RestoreResult } from "../state/sandbox"; import type { SelectionDrift } from "./selection-drift"; -type RestoreResult = { - success: boolean; - restoredDirs: string[]; - restoredFiles: string[]; -}; - -type RestoreOptions = { - stateFileRestorePolicy?: StateFileRestorePolicy; -}; - export type CreatedSandboxFinalizationOptions = { sandboxName: string; restoreBackupPath: string | null; From da93bfe5dd982a0693c698cda9f5d62bd714e91b Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:38:32 -0700 Subject: [PATCH 07/21] fix(onboard): redact normalized gateway logs Signed-off-by: Apurv Kumaria --- src/lib/onboard/gateway-start-failure.test.ts | 31 ++++++++++++++++++- src/lib/onboard/gateway-start-failure.ts | 10 +++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/lib/onboard/gateway-start-failure.test.ts b/src/lib/onboard/gateway-start-failure.test.ts index b4bdb789cdf..9f199ad2040 100644 --- a/src/lib/onboard/gateway-start-failure.test.ts +++ b/src/lib/onboard/gateway-start-failure.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from "vitest"; import { classifyGatewayStartFailure } from "../validation"; -import { reportLegacyGatewayStartResultFailure } from "./gateway-start-failure"; +import { + createFinalGatewayStartFailureHandler, + reportLegacyGatewayStartResultFailure, +} from "./gateway-start-failure"; describe("classifyGatewayStartFailure", () => { // Regression: NemoClaw #2347. When Colima is stopped on macOS, the @@ -78,3 +81,29 @@ describe("reportLegacyGatewayStartResultFailure", () => { expect(log.mock.calls[0][0]).not.toContain("\x1b"); }); }); + +describe("createFinalGatewayStartFailureHandler", () => { + it("normalizes diagnostics before redacting secrets split by terminal control bytes", () => { + const printed: string[] = []; + const handleFailure = createFinalGatewayStartFailureHandler({ + getGatewayName: () => "nemoclaw-test", + collectDiagnostics: () => "NVIDIA_API_KEY=ghp_abcde\r\x1b[31mfghijklmno\x1b[0m", + cleanupGateway: vi.fn(), + }); + + expect(() => + handleFailure({ + retries: 0, + printError: (message = "") => printed.push(message), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }), + ).toThrow("exit 1"); + + const output = printed.join("\n"); + expect(output).not.toContain("\x1b"); + expect(output).not.toContain("fghijklmno"); + expect(output).toMatch(/NVIDIA_API_KEY=ghp_\*+/); + }); +}); diff --git a/src/lib/onboard/gateway-start-failure.ts b/src/lib/onboard/gateway-start-failure.ts index 2a8cbb751c0..e7d15ae9974 100644 --- a/src/lib/onboard/gateway-start-failure.ts +++ b/src/lib/onboard/gateway-start-failure.ts @@ -74,13 +74,13 @@ export function createFinalGatewayStartFailureHandler(deps: FinalGatewayStartFai printError(""); try { - const logs = redact(collectDiagnostics() || ""); + const normalizedLogs = String(collectDiagnostics() || "") + .replace(/\r/g, "") + .replace(ANSI_RE, ""); + const logs = redact(normalizedLogs); if (logs) { printError(" Gateway logs:"); - for (const line of String(logs) - .split("\n") - .map((entry) => entry.replace(/\r/g, "").replace(ANSI_RE, "")) - .filter(Boolean)) { + for (const line of logs.split("\n").filter(Boolean)) { printError(` ${line}`); } printError(""); From a5006037a3f8ce618cd3d18fd85bed6b4c24396f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:49:45 -0700 Subject: [PATCH 08/21] fix(onboard): preserve tagged DCode model IDs Signed-off-by: Apurv Kumaria --- agents/langchain-deepagents-code/generate-config.ts | 6 +----- src/lib/onboard/dcode-selection-drift.test.ts | 11 +++++++++-- src/lib/onboard/dcode-selection-drift.ts | 3 +-- test/langchain-deepagents-code-config.test.ts | 7 +++++++ 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/agents/langchain-deepagents-code/generate-config.ts b/agents/langchain-deepagents-code/generate-config.ts index 836134bf2fb..897f41b2b73 100644 --- a/agents/langchain-deepagents-code/generate-config.ts +++ b/agents/langchain-deepagents-code/generate-config.ts @@ -88,11 +88,7 @@ function tomlArray(values: readonly string[]): string { function modelNameForOpenAiProvider(model: string): string { const trimmed = model.trim(); - const providerSeparator = trimmed.indexOf(":"); - if (providerSeparator > 0) { - return trimmed.slice(providerSeparator + 1); - } - return trimmed; + return trimmed.startsWith("openai:") ? trimmed.slice("openai:".length) : trimmed; } function buildConfig(settings: Settings): string { diff --git a/src/lib/onboard/dcode-selection-drift.test.ts b/src/lib/onboard/dcode-selection-drift.test.ts index 0c5471c0ec1..b869e3c60df 100644 --- a/src/lib/onboard/dcode-selection-drift.test.ts +++ b/src/lib/onboard/dcode-selection-drift.test.ts @@ -52,11 +52,11 @@ describe("live DCode selection drift", () => { }); it("mirrors generated DCode model and route identity (#6311)", () => { - expect(normalizeDcodeModelName(" registry:model:tag ")).toBe("model:tag"); + expect(normalizeDcodeModelName(" openai:model:tag ")).toBe("model:tag"); expect( getExpectedDcodeInferenceIdentity( "compatible-anthropic-endpoint", - "registry:model:tag", + "openai:model:tag", "anthropic-messages", ), ).toEqual({ @@ -67,6 +67,13 @@ describe("live DCode selection drift", () => { }); }); + it("preserves colon-bearing model IDs in expected DCode identity (#6311)", () => { + expect(normalizeDcodeModelName("minimax/minimax-m2.5:free")).toBe("minimax/minimax-m2.5:free"); + expect( + getExpectedDcodeInferenceIdentity("compatible-endpoint", "minimax/minimax-m2.5:free", null), + ).toMatchObject({ model: "openai:minimax/minimax-m2.5:free" }); + }); + it("accepts only a live identity matching the requested selection (#6311)", () => { const runCaptureOpenshell = vi.fn(() => identity()); diff --git a/src/lib/onboard/dcode-selection-drift.ts b/src/lib/onboard/dcode-selection-drift.ts index bed8b1774a4..5a04c5e35b1 100644 --- a/src/lib/onboard/dcode-selection-drift.ts +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -46,8 +46,7 @@ const UNKNOWN_SELECTION_DRIFT: SelectionDrift = { export function normalizeDcodeModelName(model: string): string { const trimmed = model.trim(); - const providerSeparator = trimmed.indexOf(":"); - return providerSeparator > 0 ? trimmed.slice(providerSeparator + 1) : trimmed; + return trimmed.startsWith("openai:") ? trimmed.slice("openai:".length) : trimmed; } export function parseDcodeInferenceIdentity( diff --git a/test/langchain-deepagents-code-config.test.ts b/test/langchain-deepagents-code-config.test.ts index 31ea45b59f3..970337b6515 100644 --- a/test/langchain-deepagents-code-config.test.ts +++ b/test/langchain-deepagents-code-config.test.ts @@ -75,6 +75,13 @@ describe("LangChain Deep Agents Code config generator", () => { expect(config).toContain('models = ["gpt-oss-120b"]'); }); + it("preserves colons that belong to the model ID", () => { + const config = runGenerator({ NEMOCLAW_MODEL: "minimax/minimax-m2.5:free" }); + + expect(config).toContain('default = "openai:minimax/minimax-m2.5:free"'); + expect(config).toContain('models = ["minimax/minimax-m2.5:free"]'); + }); + it("rejects credential-bearing inference base URLs before writing config", () => { const result = runGeneratorProcess({ NEMOCLAW_INFERENCE_BASE_URL: "https://user:pass@example.test/v1", From 24be6e1ce6c07bf5519a39ce67af6d826c1b8a22 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:55:54 -0700 Subject: [PATCH 09/21] fix(state): allowlist restored DCode preferences Signed-off-by: Apurv Kumaria --- .../langchain-deepagents-code/manifest.yaml | 8 +- .../quickstart-langchain-deepagents-code.mdx | 5 +- .../created-sandbox-finalization.test.ts | 2 +- .../state/dcode-config-restore-input.test.ts | 66 +++++++++++++-- src/lib/state/dcode-config-restore-input.ts | 82 +++++++++++++++++-- 5 files changed, 142 insertions(+), 21 deletions(-) diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index eff8857908f..63075a48766 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -46,9 +46,11 @@ state_dirs: - agent/skills # ── Top-level durable state files ─────────────────────────────── -# config.toml mixes durable DCode preferences with NemoClaw-managed model -# routing. Managed re-onboard restore preserves user-owned tables while keeping -# the freshly generated models/update tables and provider metadata authoritative. +# config.toml mixes DCode preferences with NemoClaw-managed model routing. +# Managed re-onboard restore carries forward only explicitly allowlisted, +# validated preference keys. Fresh models/update tables and provider metadata +# remain authoritative. Tables and keys that are unknown or security-sensitive +# are dropped. # .env and user-authored .deepagents/.mcp.json content are intentionally omitted # because they may contain service credentials. NemoClaw writes only direct-HTTP # bridge endpoint config and OpenShell placeholders to its separate diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index ff7413d954d..aabb6db3341 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -109,8 +109,9 @@ For project-specific Python dependencies, create a separate virtual environment ## State and Backup Deep Agents Code state lives under `/sandbox/.deepagents`. -NemoClaw snapshot and rebuild flows preserve the app state directory, skills, and generated config when those files exist. -During managed re-onboarding, NemoClaw merges restored `config.toml` user preference tables while keeping the freshly generated model routing, update settings, and provider metadata authoritative. +NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist. +During managed re-onboarding, NemoClaw restores only explicitly allowlisted, validated `config.toml` preference keys while keeping the freshly generated model routing, update settings, and provider metadata authoritative. +Tables and keys that are unknown or security-sensitive are not restored. It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check. Run `nemoclaw snapshot create` after active `dcode` tasks finish. For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index f1b1689d160..7037981b1e3 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -248,7 +248,7 @@ describe("created DCode sandbox finalization", () => { expect(order).toEqual(["restore", "validate", "register"]); expect(registeredConfigs[0]).toContain('default = "openai:new-model"'); expect(registeredConfigs[0]).not.toContain("old-model"); - expect(registeredConfigs[0]).toContain('[agents]\ndefault = "reviewer"'); + expect(registeredConfigs[0]).not.toContain("[agents]"); expect(registeredConfigs[0]).toContain('[ui]\ntheme = "dark"'); } finally { process.env.PATH = fixture.oldPath; diff --git a/src/lib/state/dcode-config-restore-input.test.ts b/src/lib/state/dcode-config-restore-input.test.ts index 38589369c23..a46fc0a5095 100644 --- a/src/lib/state/dcode-config-restore-input.test.ts +++ b/src/lib/state/dcode-config-restore-input.test.ts @@ -145,17 +145,15 @@ describe("DCode config restore ownership", () => { ).toBeNull(); }); - it("restores user settings while keeping fresh managed tables and headers (#6311)", () => { + it("restores allowlisted display preferences with fresh managed routing (#6311)", () => { const backup = { models: { default: "openai:nvidia/old-model", providers: { openai: { models: ["nvidia/old-model"] } }, }, update: { check: true, auto_update: true }, - agents: { default: "reviewer", recent: "researcher" }, - ui: { theme: "nvidia-dark", show_scrollbar: true }, - retries: { openai: { max_attempts: 7 } }, - skills: { extra_allowed_dirs: ["/sandbox/shared-skills"] }, + ui: { theme: "nvidia-dark", show_scrollbar: true, show_url_open_toast: false }, + threads: { relative_time: false, sort_order: "created_at" }, }; const fresh = { models: { @@ -181,12 +179,68 @@ describe("DCode config restore ownership", () => { FRESH_PROVIDER_HEADER, ]); expect(mergedJson(result.current)).toEqual({ - ...backup, models: fresh.models, update: fresh.update, + ui: backup.ui, + threads: backup.threads, }); }); + it("drops unknown, executable, routing, and credential-shaped backup data (#6311)", () => { + const providerSecret = ["sk", "abcdefghijklmnopqrst"].join("-"); + const tracingSecret = ["lsv2", "pt", "abcdefghijklmnop"].join("_"); + const backup = { + agents: { default: "reviewer", startup_command: "curl attacker.test" }, + ui: { theme: "ghp_abcdefghijklmnop", show_scrollbar: true, unknown: "keep-me-not" }, + retries: { + max_retries: 4, + openai: { max_retries: 5, param: "api_key" }, + attacker: { api_key: providerSecret }, + }, + skills: { + extra_allowed_dirs: ["/sandbox/shared-skills", "/etc", "relative/skills"], + autoload: true, + }, + threads: { + relative_time: "yes", + sort_order: "attacker-first", + columns: { initial_prompt: false }, + unknown: true, + }, + headers: { authorization: "Bearer abcdefghijklmnop" }, + servers: { attacker: { api_key: providerSecret } }, + async_subagents: { attacker: { url: "https://attacker.test", headers: {} } }, + hooks: { post_start: "curl attacker.test" }, + mcp: { config: "/sandbox/attacker-mcp.json" }, + tracing: { langsmith_redact: false, api_key: tracingSecret }, + interpreter: { enable_interpreter: true, ptc: "all" }, + shell: { allow_list: ["all"] }, + events: { external_socket: true }, + sandboxes: { default: "attacker" }, + update: { check: true, auto_update: true }, + models: { default: "openai:old-model" }, + }; + const fresh = { + models: { default: "openai:new-model" }, + update: { check: false, auto_update: false }, + managed: { version: 1 }, + }; + + const result = runMergeScript(JSON.stringify(backup), generatedCurrent(fresh)); + const merged = mergedJson(result.current); + + expect(result.status).toBe(0); + expect(merged).toEqual({ + ...fresh, + ui: { show_scrollbar: true }, + }); + expect(result.current).not.toContain(providerSecret); + expect(result.current).not.toContain(tracingSecret); + expect(result.current).not.toMatch( + /agents|allow_list|async_subagents|authorization|autoload|Bearer|columns|events|extra_allowed_dirs|ghp_|hooks|interpreter|lsv2_|max_retries|mcp|api_key|sandboxes|sk-/, + ); + }); + it("leaves the fresh config untouched when the backup is malformed (#6311)", () => { const current = generatedCurrent({ models: { default: "openai:nvidia/new-model" }, diff --git a/src/lib/state/dcode-config-restore-input.ts b/src/lib/state/dcode-config-restore-input.ts index f3bccb114cc..8baca1cc3ba 100644 --- a/src/lib/state/dcode-config-restore-input.ts +++ b/src/lib/state/dcode-config-restore-input.ts @@ -15,7 +15,8 @@ const DCODE_CONFIG_FILE = "config.toml"; * routing in the same TOML file. A wholesale backup restore would replace the * newly generated provider/model selection, while dropping the file would lose * user-owned settings. Until the agent manifest can express key-level - * ownership, restore must merge this one canonical file by top-level table. + * ownership, restore must merge this one canonical file through a local, + * explicit key allowlist. */ function shouldMergeManagedDcodeConfigStateFile( agentType: string | null | undefined, @@ -33,23 +34,32 @@ function shouldMergeManagedDcodeConfigStateFile( /** * Runs inside the freshly rebuilt DCode sandbox. * - * The backup owns every top-level table except `models` and `update`; those two - * tables and the generated header comments always come from the fresh image. - * Both inputs are parsed before a same-directory staged file atomically replaces - * the live config. Any read, parse, serialization, or race failure leaves the - * freshly generated file untouched. + * The fresh config owns every table. The backup may contribute only validated + * cosmetic UI and thread-list preferences; routing, credentials, executable + * behavior, trust expansion, and unknown keys are dropped. Both inputs are + * parsed before a same-directory staged file atomically replaces the live + * config. Any read, parse, serialization, or race failure leaves the freshly + * generated file untouched. */ export const DCODE_CONFIG_MERGE_PYTHON = String.raw` import copy import os +import re import stat import sys import tomllib import tomli_w +import unicodedata MAX_CONFIG_BYTES = 16 * 1024 * 1024 GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provider secrets." PROVIDER_HEADER_PREFIX = "# NemoClaw provider route: " +CREDENTIAL_SHAPE_PATTERN = re.compile( + r"(?:nvapi-|nvcf-|ghp_|github_pat_|sk-(?:proj-|ant-)?|xox[bpas]-|xapp-|" + r"AKIA|ASIA|hf_|glpat-|gsk_|pypi-|tvly-|lsv2_(?:pt|sk)_|Bearer\s+|" + r"(?:api[_-]?key|secret|token|password|credential)\s*[=:])", + re.IGNORECASE, +) def fail(message): @@ -111,10 +121,64 @@ def assert_fresh_managed_tables(current): fail(f"current DCode config is missing managed [{table_name}] data") +def safe_text(value, max_length=128): + if not isinstance(value, str): + return None + cleaned = value.strip() + if ( + not cleaned + or len(cleaned) > max_length + or any(unicodedata.category(char).startswith("C") for char in cleaned) + or CREDENTIAL_SHAPE_PATTERN.search(cleaned) + ): + return None + return cleaned + + +def safe_ui(backup): + section = backup.get("ui") + if not isinstance(section, dict): + return {} + result = {} + theme = safe_text(section.get("theme"), 128) + if theme is not None: + result["theme"] = theme + for key in ("show_scrollbar", "show_url_open_toast"): + if isinstance(section.get(key), bool): + result[key] = section[key] + return result + + +def safe_threads(backup): + section = backup.get("threads") + if not isinstance(section, dict): + return {} + result = {} + if isinstance(section.get("relative_time"), bool): + result["relative_time"] = section["relative_time"] + if section.get("sort_order") in ("updated_at", "created_at"): + result["sort_order"] = section["sort_order"] + return result + + +def merge_safe_preferences(backup, current): + merged = copy.deepcopy(current) + safe_tables = { + "ui": safe_ui(backup), + "threads": safe_threads(backup), + } + for table_name, preferences in safe_tables.items(): + if not preferences: + continue + current_table = merged.get(table_name) + table = copy.deepcopy(current_table) if isinstance(current_table, dict) else {} + table.update(preferences) + merged[table_name] = table + return merged + + def render_merged_config(backup, current, headers): - merged = copy.deepcopy(backup) - merged["models"] = copy.deepcopy(current["models"]) - merged["update"] = copy.deepcopy(current["update"]) + merged = merge_safe_preferences(backup, current) try: rendered = tomli_w.dumps(merged) except Exception: From 03a41ef21f72dcfdca2ac70ebbb70ee8602a3907 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:56:39 -0700 Subject: [PATCH 10/21] fix(onboard): explain DCode recovery path Signed-off-by: Apurv Kumaria --- src/lib/onboard/created-sandbox-finalization.test.ts | 5 ++++- src/lib/onboard/created-sandbox-finalization.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 7037981b1e3..9e68f615241 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -257,6 +257,7 @@ describe("created DCode sandbox finalization", () => { it("does not publish registry metadata when live validation fails (#6311)", () => { const register = vi.fn(); + const error = vi.fn(); expect(() => finalizeCreatedSandbox( { @@ -280,7 +281,7 @@ describe("created DCode sandbox finalization", () => { }), register, note: vi.fn(), - error: vi.fn(), + error, exitProcess: (code): never => { throw new Error(`exit ${code}`); }, @@ -288,6 +289,8 @@ describe("created DCode sandbox finalization", () => { ), ).toThrow("exit 1"); expect(register).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("sandbox still exists")); + expect(error).toHaveBeenCalledWith(expect.stringContaining("nemoclaw dcode rebuild")); }); it("keeps custom-image restores outside the managed config merge (#6311)", () => { diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index b9d0c53b9c2..71ac0861e9d 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -69,7 +69,10 @@ export function finalizeCreatedSandbox( ); if (finalSelection.changed || finalSelection.unknown) { deps.error( - ` DCode live model/provider validation failed for sandbox '${options.sandboxName}'; registry metadata was not updated.`, + ` DCode live model/provider validation failed for sandbox '${options.sandboxName}'. The sandbox still exists, but its live route is unverified and registry metadata was not updated.`, + ); + deps.error( + ` To recover, run \`nemoclaw ${options.sandboxName} rebuild\` or repair /sandbox/.deepagents/config.toml before retrying.`, ); if (options.restoreBackupPath) { deps.error(` Manual recovery: ${options.restoreBackupPath}`); From 3faed1f2fb927a6264b437b4717a763c550ff3c3 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 10:58:24 -0700 Subject: [PATCH 11/21] chore(state): track DCode ownership migration Signed-off-by: Apurv Kumaria --- src/lib/state/dcode-config-restore-input.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/state/dcode-config-restore-input.ts b/src/lib/state/dcode-config-restore-input.ts index 8baca1cc3ba..fa9bbc11db3 100644 --- a/src/lib/state/dcode-config-restore-input.ts +++ b/src/lib/state/dcode-config-restore-input.ts @@ -17,6 +17,7 @@ const DCODE_CONFIG_FILE = "config.toml"; * user-owned settings. Until the agent manifest can express key-level * ownership, restore must merge this one canonical file through a local, * explicit key allowlist. + * TODO(#6334): remove this policy when manifests support key-level ownership. */ function shouldMergeManagedDcodeConfigStateFile( agentType: string | null | undefined, From 3fc91d3cbd47901f09d565a005c13a830c7225b9 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:03:12 -0700 Subject: [PATCH 12/21] docs(dcode): clarify restored preferences Signed-off-by: Apurv Kumaria --- agents/langchain-deepagents-code/manifest.yaml | 6 +++--- docs/get-started/quickstart-langchain-deepagents-code.mdx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 63075a48766..ab217f14553 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -48,9 +48,9 @@ state_dirs: # ── Top-level durable state files ─────────────────────────────── # config.toml mixes DCode preferences with NemoClaw-managed model routing. # Managed re-onboard restore carries forward only explicitly allowlisted, -# validated preference keys. Fresh models/update tables and provider metadata -# remain authoritative. Tables and keys that are unknown or security-sensitive -# are dropped. +# validated UI and thread-display preference keys. Fresh models/update tables +# and provider metadata remain authoritative. Behavior-bearing, unknown, or +# security-sensitive backup tables and keys are dropped. # .env and user-authored .deepagents/.mcp.json content are intentionally omitted # because they may contain service credentials. NemoClaw writes only direct-HTTP # bridge endpoint config and OpenShell placeholders to its separate diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index aabb6db3341..109e79e720b 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -110,8 +110,8 @@ For project-specific Python dependencies, create a separate virtual environment Deep Agents Code state lives under `/sandbox/.deepagents`. NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist. -During managed re-onboarding, NemoClaw restores only explicitly allowlisted, validated `config.toml` preference keys while keeping the freshly generated model routing, update settings, and provider metadata authoritative. -Tables and keys that are unknown or security-sensitive are not restored. +During managed re-onboarding, NemoClaw restores only explicitly allowlisted, validated `config.toml` UI and thread-display preference keys while keeping the freshly generated model routing, update settings, and provider metadata authoritative. +It does not restore behavior-bearing, unknown, or security-sensitive tables and keys from the backup. It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check. Run `nemoclaw snapshot create` after active `dcode` tasks finish. For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. From 39e4fdbc2792a7689bd19af83fe157cca86ed46d Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:04:39 -0700 Subject: [PATCH 13/21] fix(onboard): make orphan recovery actionable Signed-off-by: Apurv Kumaria --- src/lib/onboard/created-sandbox-finalization.test.ts | 3 ++- src/lib/onboard/created-sandbox-finalization.ts | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 9e68f615241..eb766be1870 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -290,7 +290,8 @@ describe("created DCode sandbox finalization", () => { ).toThrow("exit 1"); expect(register).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith(expect.stringContaining("sandbox still exists")); - expect(error).toHaveBeenCalledWith(expect.stringContaining("nemoclaw dcode rebuild")); + expect(error).toHaveBeenCalledWith(expect.stringContaining('openshell sandbox delete "dcode"')); + expect(error).toHaveBeenCalledWith(expect.stringContaining("nemoclaw onboard")); }); it("keeps custom-image restores outside the managed config merge (#6311)", () => { diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index 71ac0861e9d..fab28f8301c 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -71,9 +71,9 @@ export function finalizeCreatedSandbox( deps.error( ` DCode live model/provider validation failed for sandbox '${options.sandboxName}'. The sandbox still exists, but its live route is unverified and registry metadata was not updated.`, ); - deps.error( - ` To recover, run \`nemoclaw ${options.sandboxName} rebuild\` or repair /sandbox/.deepagents/config.toml before retrying.`, - ); + deps.error(" Remove the unregistered sandbox before retrying:"); + deps.error(` openshell sandbox delete ${JSON.stringify(options.sandboxName)}`); + deps.error(" Then rerun the original `nemoclaw onboard` command."); if (options.restoreBackupPath) { deps.error(` Manual recovery: ${options.restoreBackupPath}`); } From 253fd90a31c5067459eaffd46dafd26f4ff7a7a4 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:08:02 -0700 Subject: [PATCH 14/21] test(onboard): provide live DCode identity fixture Signed-off-by: Apurv Kumaria --- test/onboard-terminal-dashboard.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/onboard-terminal-dashboard.test.ts b/test/onboard-terminal-dashboard.test.ts index 64bbe393500..2ec81172410 100644 --- a/test/onboard-terminal-dashboard.test.ts +++ b/test/onboard-terminal-dashboard.test.ts @@ -78,6 +78,14 @@ runner.runFile = (file, args = [], opts = {}) => { runner.runCapture = (command) => { const normalized = _n(command); commands.push({ command: normalized, env: null }); + if (normalized.includes("sandbox exec -n " + sandboxName + " -- dcode identity")) { + return [ + "Route: inference", + "Provider: nvidia-prod", + "Model: openai:gpt-5.4", + "Endpoint: https://inference.local/v1", + ].join("\n"); + } if (normalized.includes("sandbox get " + sandboxName)) { return scenario === "reuse" ? sandboxName : ""; } From 79858caf35b28e0d067fda3822db120ac644f91a Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:08:45 -0700 Subject: [PATCH 15/21] fix(onboard): preserve DCode recreate metadata Signed-off-by: Apurv Kumaria --- .../machine/handlers/sandbox-dcode-resume.ts | 15 +++++++++++++++ .../handlers/sandbox-dcode-selection.test.ts | 17 +++++++++++++++++ src/lib/onboard/machine/handlers/sandbox.ts | 3 ++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts index b80e765fe0f..4c960aa65b9 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts @@ -4,6 +4,7 @@ import type { Session } from "../../../state/onboard-session"; import type { SandboxEntry } from "../../../state/registry"; import { usesManagedDcodeIdentity } from "../../dcode-selection-drift"; +import type { SandboxResumeDecision } from "./sandbox-resume"; export interface Deps { getDcodeSelectionDrift( @@ -37,6 +38,20 @@ function agentName(agent: Agent): string | null | undefined { return (agent as { name?: string } | null | undefined)?.name; } +export function preserveManagedDcodeRegistryEntry( + options: SelectionOptions, + decision: SandboxResumeDecision, +): SandboxResumeDecision { + if ( + decision.kind !== "recreate" || + !decision.removeRegistryEntry || + !usesManagedDcodeIdentity(agentName(options.agent), options.fromDockerfile) + ) { + return decision; + } + return { ...decision, removeRegistryEntry: false }; +} + export function resolveSignals( options: ResumeOptions, state: ResumeState, diff --git a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts index 9931032629d..cc8aa2f93fd 100644 --- a/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts @@ -74,6 +74,23 @@ describe("handleSandboxState live DCode selection", () => { expect(calls.removeSandbox).not.toHaveBeenCalled(); }); + it("preserves registry fidelity when GPU drift recreates managed DCode (#6311)", async () => { + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getDcodeSelectionDrift: () => ({ changed: false, unknown: false }), + hasSandboxGpuDrift: () => true, + getSandboxRegistryEntry: (name) => dcodeRegistryEntry(name), + }); + + await handleSandboxState(dcodeOptions(deps)); + + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox.mock.calls[0]?.at(-1)).toEqual({ + recreate: true, + toolDisclosure: "progressive", + }); + }); + it("reuses a ready sandbox only after the live selection is verified (#6311)", async () => { const getDcodeSelectionDrift = vi.fn(() => ({ changed: false, unknown: false })); const { deps, calls } = createDeps({ diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index e46d46c292c..86046ed0c3f 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -385,7 +385,7 @@ class SandboxStateFlow< registryEntry, this.deps, ); - return decideSandboxResume({ + const decision = decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", @@ -405,6 +405,7 @@ class SandboxStateFlow< ...toolDisclosureSignals, ...dcodeResumeSignals, }); + return dcodeResume.preserveManagedDcodeRegistryEntry(this.options, decision); } private async reuseSandbox( From c849b21ccee68f23a4c9c147d95e1dc9e4aa2393 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:16:28 -0700 Subject: [PATCH 16/21] test(onboard): cover partial DCode restore Signed-off-by: Apurv Kumaria --- .../created-sandbox-finalization.test.ts | 48 +++++++++++++++++++ .../onboard/created-sandbox-finalization.ts | 6 +++ src/lib/onboard/dcode-selection-drift.ts | 2 + 3 files changed, 56 insertions(+) diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index eb766be1870..5b176bb402c 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -294,6 +294,54 @@ describe("created DCode sandbox finalization", () => { expect(error).toHaveBeenCalledWith(expect.stringContaining("nemoclaw onboard")); }); + it("warns but verifies and registers after a partial workspace restore (#6311)", () => { + const restoreSandboxState = vi.fn(() => ({ + success: false, + restoredDirs: ["workspace"], + failedDirs: ["skills"], + restoredFiles: [], + failedFiles: ["config.toml"], + })); + const getDcodeSelectionDrift = vi.fn(() => ({ + changed: false, + providerChanged: false, + modelChanged: false, + existingProvider: "nvidia-prod", + existingModel: "openai:new-model", + unknown: false, + })); + const register = vi.fn(); + const error = vi.fn(); + + finalizeCreatedSandbox( + { + sandboxName: "dcode", + restoreBackupPath: "/tmp/dcode-backup", + preUpgradeBackup: false, + validateManagedDcode: true, + provider: "nvidia-prod", + model: "new-model", + preferredInferenceApi: null, + }, + { + restoreSandboxState, + getDcodeSelectionDrift, + register, + note: vi.fn(), + error, + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); + + expect(error).toHaveBeenCalledWith( + " Warning: partial restore. Manual recovery: /tmp/dcode-backup", + ); + expect(getDcodeSelectionDrift).toHaveBeenCalledOnce(); + expect(register).toHaveBeenCalledOnce(); + }); + it("keeps custom-image restores outside the managed config merge (#6311)", () => { const restoreSandboxState = vi.fn(() => ({ success: true, diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index fab28f8301c..7dd5c409884 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -56,6 +56,11 @@ export function finalizeCreatedSandbox( ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, ); } else { + // Source-of-truth review: restore.success owns workspace-copy completeness; + // live validation below owns route integrity. External copy failures cannot + // be atomic with sandbox creation, so keep a valid fresh sandbox registered + // and leave failed paths recoverable from the backup. Remove this fallback + // when restore can transactionally roll back sandbox creation. deps.error(` Warning: partial restore. Manual recovery: ${options.restoreBackupPath}`); } } @@ -71,6 +76,7 @@ export function finalizeCreatedSandbox( deps.error( ` DCode live model/provider validation failed for sandbox '${options.sandboxName}'. The sandbox still exists, but its live route is unverified and registry metadata was not updated.`, ); + // Without a registry row, the NemoClaw rebuild command cannot target this sandbox. deps.error(" Remove the unregistered sandbox before retrying:"); deps.error(` openshell sandbox delete ${JSON.stringify(options.sandboxName)}`); deps.error(" Then rerun the original `nemoclaw onboard` command."); diff --git a/src/lib/onboard/dcode-selection-drift.ts b/src/lib/onboard/dcode-selection-drift.ts index 5a04c5e35b1..b6f422d1c78 100644 --- a/src/lib/onboard/dcode-selection-drift.ts +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -32,6 +32,8 @@ export function requiresSelectionRecreate( drift: Pick, managedDcode: boolean, ): boolean { + // Managed DCode also fails closed when its live identity is unreadable; + // ordinary agents retain their legacy behavior for unknown selection state. return drift.changed && (!drift.unknown || managedDcode); } From 3b06dd21fdf59361f721ac084582e036fca73cf4 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:22:15 -0700 Subject: [PATCH 17/21] test(onboard): verify partial restore routing Signed-off-by: Apurv Kumaria --- .../created-sandbox-finalization.test.ts | 84 +++++++++---------- .../onboard/created-sandbox-finalization.ts | 15 ++-- src/lib/onboard/dcode-selection-drift.ts | 4 +- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 5b176bb402c..d6e8cde893c 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -290,56 +290,56 @@ describe("created DCode sandbox finalization", () => { ).toThrow("exit 1"); expect(register).not.toHaveBeenCalled(); expect(error).toHaveBeenCalledWith(expect.stringContaining("sandbox still exists")); + expect(error).toHaveBeenCalledWith(expect.stringContaining("rebuild is unsafe")); expect(error).toHaveBeenCalledWith(expect.stringContaining('openshell sandbox delete "dcode"')); expect(error).toHaveBeenCalledWith(expect.stringContaining("nemoclaw onboard")); }); it("warns but verifies and registers after a partial workspace restore (#6311)", () => { - const restoreSandboxState = vi.fn(() => ({ - success: false, - restoredDirs: ["workspace"], - failedDirs: ["skills"], - restoredFiles: [], - failedFiles: ["config.toml"], - })); - const getDcodeSelectionDrift = vi.fn(() => ({ - changed: false, - providerChanged: false, - modelChanged: false, - existingProvider: "nvidia-prod", - existingModel: "openai:new-model", - unknown: false, - })); - const register = vi.fn(); + const fixture = makeRestoreFixture(); + const registeredConfigs: string[] = []; const error = vi.fn(); - - finalizeCreatedSandbox( - { - sandboxName: "dcode", - restoreBackupPath: "/tmp/dcode-backup", - preUpgradeBackup: false, - validateManagedDcode: true, - provider: "nvidia-prod", - model: "new-model", - preferredInferenceApi: null, - }, - { - restoreSandboxState, - getDcodeSelectionDrift, - register, - note: vi.fn(), - error, - exitProcess: (code): never => { - throw new Error(`exit ${code}`); + try { + finalizeCreatedSandbox( + { + sandboxName: "dcode", + restoreBackupPath: fixture.backupPath, + preUpgradeBackup: false, + validateManagedDcode: true, + provider: "nvidia-prod", + model: "new-model", + preferredInferenceApi: null, }, - }, - ); + { + restoreSandboxState: (name, backup, options) => { + const restored = sandboxState.restoreSandboxState(name, backup, options); + return { ...restored, success: false, failedDirs: ["skills"] }; + }, + getDcodeSelectionDrift: (name, provider, model, api) => + getDcodeSelectionDrift(name, provider, model, api, { + runCaptureOpenshell: () => + identityFromConfig(fs.readFileSync(fixture.currentPath, "utf8")), + }), + register: () => { + registeredConfigs.push(fs.readFileSync(fixture.currentPath, "utf8")); + }, + note: vi.fn(), + error, + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); - expect(error).toHaveBeenCalledWith( - " Warning: partial restore. Manual recovery: /tmp/dcode-backup", - ); - expect(getDcodeSelectionDrift).toHaveBeenCalledOnce(); - expect(register).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith( + ` Warning: partial restore. Manual recovery: ${fixture.backupPath}`, + ); + expect(registeredConfigs).toHaveLength(1); + expect(registeredConfigs[0]).toContain('default = "openai:new-model"'); + expect(registeredConfigs[0]).not.toContain("old-model"); + } finally { + process.env.PATH = fixture.oldPath; + } }); it("keeps custom-image restores outside the managed config merge (#6311)", () => { diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index 7dd5c409884..fa38a311ba6 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -56,11 +56,12 @@ export function finalizeCreatedSandbox( ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, ); } else { - // Source-of-truth review: restore.success owns workspace-copy completeness; - // live validation below owns route integrity. External copy failures cannot - // be atomic with sandbox creation, so keep a valid fresh sandbox registered - // and leave failed paths recoverable from the backup. Remove this fallback - // when restore can transactionally roll back sandbox creation. + // Source-of-truth review: + // - Invalid state: a fresh sandbox exists after an external workspace copy fails. + // - Boundary: restore.success owns copy completeness; live validation owns route integrity. + // - Source-fix constraint: rollback must span sandbox creation and external copies. + // - Regression: the partial-workspace-restore test validates fresh config before registration. + // - Removal: drop this fallback when restore failure can roll back sandbox creation atomically. deps.error(` Warning: partial restore. Manual recovery: ${options.restoreBackupPath}`); } } @@ -76,7 +77,9 @@ export function finalizeCreatedSandbox( deps.error( ` DCode live model/provider validation failed for sandbox '${options.sandboxName}'. The sandbox still exists, but its live route is unverified and registry metadata was not updated.`, ); - // Without a registry row, the NemoClaw rebuild command cannot target this sandbox. + deps.error( + " A NemoClaw rebuild is unsafe here because no verified registry metadata exists.", + ); deps.error(" Remove the unregistered sandbox before retrying:"); deps.error(` openshell sandbox delete ${JSON.stringify(options.sandboxName)}`); deps.error(" Then rerun the original `nemoclaw onboard` command."); diff --git a/src/lib/onboard/dcode-selection-drift.ts b/src/lib/onboard/dcode-selection-drift.ts index b6f422d1c78..47525b4b94f 100644 --- a/src/lib/onboard/dcode-selection-drift.ts +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -32,8 +32,8 @@ export function requiresSelectionRecreate( drift: Pick, managedDcode: boolean, ): boolean { - // Managed DCode also fails closed when its live identity is unreadable; - // ordinary agents retain their legacy behavior for unknown selection state. + // Managed DCode fails closed on any confirmed or unreadable selection drift + // to enforce routing integrity; ordinary agents recreate only on confirmed drift. return drift.changed && (!drift.unknown || managedDcode); } From 0857f8daeeb030528195631f061463a61ca3676d Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Mon, 6 Jul 2026 11:33:11 -0700 Subject: [PATCH 18/21] test(onboard): stub prepared dcode identity Signed-off-by: Carlos Villela --- test/onboard-prepared-build-context.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/onboard-prepared-build-context.test.ts b/test/onboard-prepared-build-context.test.ts index 864600a8331..f9438dc29e0 100644 --- a/test/onboard-prepared-build-context.test.ts +++ b/test/onboard-prepared-build-context.test.ts @@ -123,6 +123,14 @@ runner.runFile = (file, args = []) => { }; runner.runCapture = (command) => { const normalized = normalize(command); + if (normalized.includes("sandbox exec -n " + sandboxName + " -- dcode identity")) { + return [ + "Route: inference", + "Provider: nvidia-prod", + "Model: openai:nvidia/nemotron-3-super-120b-a12b", + "Endpoint: https://inference.local/v1", + ].join("\n"); + } if (normalized.includes("sandbox get")) return ""; if (normalized.includes("sandbox list")) return sandboxName + " Ready"; return ""; From 88b99bfd817fcfd1823a5f00971d974183336185 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 11:35:29 -0700 Subject: [PATCH 19/21] fix(state): drop free-form DCode restore values Signed-off-by: Apurv Kumaria --- .../langchain-deepagents-code/manifest.yaml | 10 +++--- .../quickstart-langchain-deepagents-code.mdx | 5 +-- .../created-sandbox-finalization.test.ts | 6 +++- src/lib/onboard/dcode-selection-drift.ts | 4 +-- .../state/dcode-config-restore-input.test.ts | 4 +-- src/lib/state/dcode-config-restore-input.ts | 35 +++++-------------- 6 files changed, 26 insertions(+), 38 deletions(-) diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index ab217f14553..1d9038178a0 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -47,10 +47,12 @@ state_dirs: # ── Top-level durable state files ─────────────────────────────── # config.toml mixes DCode preferences with NemoClaw-managed model routing. -# Managed re-onboard restore carries forward only explicitly allowlisted, -# validated UI and thread-display preference keys. Fresh models/update tables -# and provider metadata remain authoritative. Behavior-bearing, unknown, or -# security-sensitive backup tables and keys are dropped. +# Managed re-onboard restore carries forward only boolean ui.show_scrollbar, +# ui.show_url_open_toast, and threads.relative_time preferences, plus +# threads.sort_order when it is updated_at or created_at. Fresh models/update +# tables and provider metadata remain authoritative. All other backup keys, +# including ui.theme and behavior-bearing, unknown, or security-sensitive keys, +# are dropped. # .env and user-authored .deepagents/.mcp.json content are intentionally omitted # because they may contain service credentials. NemoClaw writes only direct-HTTP # bridge endpoint config and OpenShell placeholders to its separate diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 109e79e720b..92ef766302a 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -110,8 +110,9 @@ For project-specific Python dependencies, create a separate virtual environment Deep Agents Code state lives under `/sandbox/.deepagents`. NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist. -During managed re-onboarding, NemoClaw restores only explicitly allowlisted, validated `config.toml` UI and thread-display preference keys while keeping the freshly generated model routing, update settings, and provider metadata authoritative. -It does not restore behavior-bearing, unknown, or security-sensitive tables and keys from the backup. +During managed re-onboarding, NemoClaw restores only these `config.toml` preferences from backup: boolean `ui.show_scrollbar`, boolean `ui.show_url_open_toast`, boolean `threads.relative_time`, and `threads.sort_order` when it is `updated_at` or `created_at`. +Freshly generated model routing, update settings, provider metadata, and all other configuration remain authoritative. +NemoClaw drops all other backup settings, including `ui.theme`, behavior-bearing keys, unknown keys, and security-sensitive keys. It recreates the sandbox when its live `dcode identity` output is unreadable or does not match the selected provider and model, then records the selection only after the restored runtime passes the same check. Run `nemoclaw snapshot create` after active `dcode` tasks finish. For `langchain-deepagents-code` sandboxes, NemoClaw refuses backup when it detects an active `dcode` task or cannot verify that the state tree is idle. diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index d6e8cde893c..938bd003599 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -73,6 +73,7 @@ function makeRestoreFixture(): { "", "[ui]", 'theme = "dark"', + "show_scrollbar = true", "", ].join("\n"), ); @@ -249,7 +250,8 @@ describe("created DCode sandbox finalization", () => { expect(registeredConfigs[0]).toContain('default = "openai:new-model"'); expect(registeredConfigs[0]).not.toContain("old-model"); expect(registeredConfigs[0]).not.toContain("[agents]"); - expect(registeredConfigs[0]).toContain('[ui]\ntheme = "dark"'); + expect(registeredConfigs[0]).toContain("[ui]\nshow_scrollbar = true"); + expect(registeredConfigs[0]).not.toContain('theme = "dark"'); } finally { process.env.PATH = fixture.oldPath; } @@ -337,6 +339,8 @@ describe("created DCode sandbox finalization", () => { expect(registeredConfigs).toHaveLength(1); expect(registeredConfigs[0]).toContain('default = "openai:new-model"'); expect(registeredConfigs[0]).not.toContain("old-model"); + expect(registeredConfigs[0]).toContain("[ui]\nshow_scrollbar = true"); + expect(registeredConfigs[0]).not.toContain('theme = "dark"'); } finally { process.env.PATH = fixture.oldPath; } diff --git a/src/lib/onboard/dcode-selection-drift.ts b/src/lib/onboard/dcode-selection-drift.ts index 47525b4b94f..c9bf87c332f 100644 --- a/src/lib/onboard/dcode-selection-drift.ts +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -32,8 +32,8 @@ export function requiresSelectionRecreate( drift: Pick, managedDcode: boolean, ): boolean { - // Managed DCode fails closed on any confirmed or unreadable selection drift - // to enforce routing integrity; ordinary agents recreate only on confirmed drift. + // Managed DCode fails closed on any selection drift (known or unknown) to + // enforce routing integrity; ordinary agents recreate only on confirmed known drift. return drift.changed && (!drift.unknown || managedDcode); } diff --git a/src/lib/state/dcode-config-restore-input.test.ts b/src/lib/state/dcode-config-restore-input.test.ts index a46fc0a5095..5a68db0ffd3 100644 --- a/src/lib/state/dcode-config-restore-input.test.ts +++ b/src/lib/state/dcode-config-restore-input.test.ts @@ -181,12 +181,12 @@ describe("DCode config restore ownership", () => { expect(mergedJson(result.current)).toEqual({ models: fresh.models, update: fresh.update, - ui: backup.ui, + ui: { show_scrollbar: true, show_url_open_toast: false }, threads: backup.threads, }); }); - it("drops unknown, executable, routing, and credential-shaped backup data (#6311)", () => { + it("drops free-form themes, executable, routing, and unknown backup data (#6311)", () => { const providerSecret = ["sk", "abcdefghijklmnopqrst"].join("-"); const tracingSecret = ["lsv2", "pt", "abcdefghijklmnop"].join("_"); const backup = { diff --git a/src/lib/state/dcode-config-restore-input.ts b/src/lib/state/dcode-config-restore-input.ts index fa9bbc11db3..2b29b857f32 100644 --- a/src/lib/state/dcode-config-restore-input.ts +++ b/src/lib/state/dcode-config-restore-input.ts @@ -39,28 +39,21 @@ function shouldMergeManagedDcodeConfigStateFile( * cosmetic UI and thread-list preferences; routing, credentials, executable * behavior, trust expansion, and unknown keys are dropped. Both inputs are * parsed before a same-directory staged file atomically replaces the live - * config. Any read, parse, serialization, or race failure leaves the freshly - * generated file untouched. + * config. Any detected read, parse, serialization, or target-drift failure + * leaves the freshly generated file untouched, and atomic replacement avoids + * exposing partial file contents. */ export const DCODE_CONFIG_MERGE_PYTHON = String.raw` import copy import os -import re import stat import sys import tomllib import tomli_w -import unicodedata MAX_CONFIG_BYTES = 16 * 1024 * 1024 GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provider secrets." PROVIDER_HEADER_PREFIX = "# NemoClaw provider route: " -CREDENTIAL_SHAPE_PATTERN = re.compile( - r"(?:nvapi-|nvcf-|ghp_|github_pat_|sk-(?:proj-|ant-)?|xox[bpas]-|xapp-|" - r"AKIA|ASIA|hf_|glpat-|gsk_|pypi-|tvly-|lsv2_(?:pt|sk)_|Bearer\s+|" - r"(?:api[_-]?key|secret|token|password|credential)\s*[=:])", - re.IGNORECASE, -) def fail(message): @@ -122,28 +115,11 @@ def assert_fresh_managed_tables(current): fail(f"current DCode config is missing managed [{table_name}] data") -def safe_text(value, max_length=128): - if not isinstance(value, str): - return None - cleaned = value.strip() - if ( - not cleaned - or len(cleaned) > max_length - or any(unicodedata.category(char).startswith("C") for char in cleaned) - or CREDENTIAL_SHAPE_PATTERN.search(cleaned) - ): - return None - return cleaned - - def safe_ui(backup): section = backup.get("ui") if not isinstance(section, dict): return {} result = {} - theme = safe_text(section.get("theme"), 128) - if theme is not None: - result["theme"] = theme for key in ("show_scrollbar", "show_url_open_toast"): if isinstance(section.get(key), bool): result[key] = section[key] @@ -225,6 +201,11 @@ def write_staged_and_replace(staged_path, current_path, current_metadata, payloa ): fail("current DCode config changed before atomic restore") + # The fresh, idle DCode runtime and this restore run as the same sandbox + # user, which already owns this directory. This check catches accidental + # target drift; os.replace atomically replaces the directory entry without + # following a swapped destination symlink. Hostile same-UID writes have the + # same config authority immediately before and after this operation. os.replace(staged_path, current_path) directory_fd = os.open(os.path.dirname(current_path), os.O_RDONLY) try: From 95be79f566f205c981bb89344bf7ac0130d40cdb Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 12:12:48 -0700 Subject: [PATCH 20/21] test(e2e): prove fresh DCode model switch Signed-off-by: Apurv Kumaria --- .../04-deepagents-code-fresh-reonboard.sh | 218 ++++++++++++++++++ .../e2e/live/cloud-experimental-check-list.ts | 4 + test/e2e/live/cloud-experimental-checks.ts | 11 +- ...platform-parity-cloud-experimental.test.ts | 15 ++ test/langchain-deepagents-code-image.test.ts | 1 + 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100755 test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh 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 new file mode 100755 index 00000000000..11c7b2ce202 --- /dev/null +++ b/test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Case: same-name managed DCode --fresh re-onboard keeps the new live route (#6311). +# +# Start from the typed target's stock DCode sandbox (model A), seed its config +# with safe preferences plus stale managed/unsafe data, and re-onboard the same +# name to model B. The live identity, host status, registry, and restored config +# must all agree on B before the remaining DCode runtime checks execute. + +set -euo pipefail + +SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-}}" +REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" +CLI="${NEMOCLAW_CLI_BIN:-${REPO}/bin/nemoclaw.js}" +PREFIX="04-deepagents-code-fresh-reonboard" +PRIMARY_TARGET_MODEL="nvidia/nemotron-3-super-120b-a12b" +FALLBACK_TARGET_MODEL="nvidia/nvidia/nemotron-3-ultra" +HOSTED_ENDPOINT="${NEMOCLAW_ENDPOINT_URL:-https://inference-api.nvidia.com/v1}" + +fail() { + printf '%s: FAIL: %s\n' "$PREFIX" "$1" >&2 + exit 1 +} + +pass() { + printf '%s: OK (%s)\n' "$PREFIX" "$1" +} + +sandbox_exec() { + openshell sandbox exec --name "$SANDBOX_NAME" -- bash -c "$1" 2>&1 +} + +dcode_identity() { + openshell sandbox exec --name "$SANDBOX_NAME" -- dcode identity 2>&1 +} + +identity_field() { + local output="$1" + local field="$2" + printf '%s\n' "$output" | sed -n "s/^${field}:[[:space:]]*//p" | tail -n1 +} + +assert_identity() { + local output="$1" + local model="$2" + local phase="$3" + local route provider observed_model endpoint + route="$(identity_field "$output" Route)" + provider="$(identity_field "$output" Provider)" + observed_model="$(identity_field "$output" Model)" + endpoint="$(identity_field "$output" Endpoint)" + [ "$route" = "inference" ] || fail "$phase identity route is '${route:-missing}'" + [ "$provider" = "compatible-endpoint" ] || fail "$phase identity provider is '${provider:-missing}'" + [ "$observed_model" = "openai:${model}" ] || fail "$phase identity model is '${observed_model:-missing}'" + [ "$endpoint" = "https://inference.local/v1" ] || fail "$phase identity endpoint is '${endpoint:-missing}'" +} + +encode_source() { + base64 | tr -d '\n' +} + +seed_config_source() { + cat <<'PY' +import os +from pathlib import Path +import sys +import tomllib +import tomli_w + +path = Path("/sandbox/.deepagents/config.toml") +model = sys.argv[1] +config = tomllib.loads(path.read_text(encoding="utf-8")) +provider = config["models"]["providers"]["openai"] +config["models"]["default"] = f"openai:{model}" +provider["models"] = [model] +provider["base_url"] = "https://stale.invalid/v1" +config["update"] = {"check": True, "auto_update": True} +config["ui"] = {"show_scrollbar": True, "show_url_open_toast": False, "theme": "discard"} +config["threads"] = { + "relative_time": False, + "sort_order": "created_at", + "columns": {"initial_prompt": False}, +} +config["agents"] = {"startup_command": "discard"} +config["headers"] = {"authorization": "discard"} +config["hooks"] = {"post_start": "discard"} +config["mcp"] = {"autoload": True, "config": "/sandbox/discard-mcp.json"} +config["servers"] = {"discard": {"api_key": "discard"}} +config["shell"] = {"allow_list": ["all"]} +config["skills"] = {"autoload": True, "extra_allowed_dirs": ["/etc"]} +config["tracing"] = {"api_key": "discard"} +headers = ( + "# Generated by NemoClaw. This file contains no provider secrets.\n" + "# NemoClaw provider route: anthropic; upstream provider: " + "compatible-anthropic-endpoint; API: anthropic-messages." +) +path.write_text(headers + "\n\n" + tomli_w.dumps(config), encoding="utf-8") +os.chmod(path, 0o600) +print("NEMOCLAW_DCODE_STALE_CONFIG_SEEDED") +PY +} + +verify_config_source() { + cat <<'PY' +from pathlib import Path +import sys +import tomllib + +path = Path("/sandbox/.deepagents/config.toml") +initial_model, target_model = sys.argv[1:] +text = path.read_text(encoding="utf-8") +config = tomllib.loads(text) +provider = config["models"]["providers"]["openai"] +assert set(config) == {"models", "update", "ui", "threads"} +assert config["models"]["default"] == f"openai:{target_model}" +assert provider["models"] == [target_model] +assert provider["api_key_env"] == "DEEPAGENTS_CODE_OPENAI_API_KEY" +assert provider["base_url"] == "https://inference.local/v1" +assert config["update"] == {"check": False, "auto_update": False} +assert config["ui"] == {"show_scrollbar": True, "show_url_open_toast": False} +assert config["threads"] == {"relative_time": False, "sort_order": "created_at"} +assert initial_model not in text +for forbidden in ( + "compatible-anthropic-endpoint", + "https://stale.invalid/v1", + "startup_command", + "authorization", + "autoload", + "allow_list", +): + assert forbidden not in text +print("NEMOCLAW_DCODE_FRESH_CONFIG_VERIFIED") +PY +} + +[ -n "$SANDBOX_NAME" ] || fail "sandbox name is required" +[ -n "${COMPATIBLE_API_KEY:-}" ] || fail "COMPATIBLE_API_KEY is required" +[ -x "$CLI" ] || fail "NemoClaw CLI is not executable at $CLI" + +identity_before="$(dcode_identity)" || fail "could not read initial dcode identity" +model_a="$(identity_field "$identity_before" Model)" +model_a="${model_a#openai:}" +[ -n "$model_a" ] || fail "initial dcode identity did not report a model" +assert_identity "$identity_before" "$model_a" "initial" + +if [ "$model_a" = "$PRIMARY_TARGET_MODEL" ]; then + model_b="$FALLBACK_TARGET_MODEL" +else + model_b="$PRIMARY_TARGET_MODEL" +fi +[ "$model_a" != "$model_b" ] || fail "model A and model B must differ" +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_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" + +if ! reonboard_output="$( + COMPATIBLE_API_KEY="$COMPATIBLE_API_KEY" \ + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ + NEMOCLAW_AGENT=langchain-deepagents-code \ + NEMOCLAW_COMPAT_MODEL="$model_b" \ + NEMOCLAW_E2E_USE_HOSTED_INFERENCE=1 \ + NEMOCLAW_ENDPOINT_URL="$HOSTED_ENDPOINT" \ + NEMOCLAW_MODEL="$model_b" \ + NEMOCLAW_NON_INTERACTIVE=1 \ + NEMOCLAW_PREFERRED_API=openai-completions \ + NEMOCLAW_PROVIDER=custom \ + NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" \ + OPENSHELL_GATEWAY=nemoclaw \ + "$CLI" onboard --agent langchain-deepagents-code --name "$SANDBOX_NAME" \ + --fresh --non-interactive --yes --yes-i-accept-third-party-software 2>&1 +)"; then + fail "same-name --fresh re-onboard failed: $reonboard_output" +fi +printf '%s\n' "$reonboard_output" | grep -Fq "Backing up workspace state before recreating sandbox..." || fail "re-onboard did not take the pre-recreate backup path" +printf '%s\n' "$reonboard_output" | grep -Fq "Restoring workspace state from pre-recreate backup..." || fail "re-onboard did not take the restore path" +pass "same-name --fresh re-onboard crossed backup and restore boundaries" + +sandbox_list="$(openshell sandbox list 2>&1)" || fail "could not list sandbox after re-onboard" +printf '%s\n' "$sandbox_list" | awk -v name="$SANDBOX_NAME" '$1 == name && /Ready/ { found = 1 } END { exit(found ? 0 : 1) }' || fail "same-name sandbox is not Ready after re-onboard" + +identity_after="$(dcode_identity)" || fail "could not read dcode identity after re-onboard" +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" + +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); +if (status.name !== process.env.SANDBOX_NAME || + status.model !== process.env.MODEL_B || + status.provider !== "compatible-endpoint") process.exit(1); +' || fail "nemoclaw status does not report model B and compatible-endpoint" + +SANDBOX_NAME="$SANDBOX_NAME" MODEL_B="$model_b" node -e ' +const fs = require("node:fs"); +const path = require("node:path"); +const registry = JSON.parse(fs.readFileSync(path.join(process.env.HOME, ".nemoclaw", "sandboxes.json"), "utf8")); +const entry = registry.sandboxes?.[process.env.SANDBOX_NAME]; +if (!entry || entry.agent !== "langchain-deepagents-code" || + entry.model !== process.env.MODEL_B || + entry.provider !== "compatible-endpoint" || + entry.credentialEnv !== "COMPATIBLE_API_KEY") process.exit(1); +' || fail "host registry does not report the verified model B selection" +pass "status and registry report model B" + +verify_source="$(verify_config_source | encode_source)" +verify_command="printf '%s' ${verify_source@Q} | base64 -d | /opt/venv/bin/python3 -I - ${model_a@Q} ${model_b@Q}" +verify_output="$(sandbox_exec "$verify_command")" || fail "live DCode config does not preserve the managed restore boundary" +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" diff --git a/test/e2e/live/cloud-experimental-check-list.ts b/test/e2e/live/cloud-experimental-check-list.ts index 6df1d3cbb6d..76cf4f9b82e 100644 --- a/test/e2e/live/cloud-experimental-check-list.ts +++ b/test/e2e/live/cloud-experimental-check-list.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export const DEEPAGENTS_FRESH_REONBOARD_CHECK = + "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh"; + export const DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS = [ + DEEPAGENTS_FRESH_REONBOARD_CHECK, "test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh", "test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh", "test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh", diff --git a/test/e2e/live/cloud-experimental-checks.ts b/test/e2e/live/cloud-experimental-checks.ts index 8cefdf43125..5524ccafb63 100644 --- a/test/e2e/live/cloud-experimental-checks.ts +++ b/test/e2e/live/cloud-experimental-checks.ts @@ -8,9 +8,12 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText } from "../fixtures/clients/command.ts"; import type { E2ETargetFixtures } from "../fixtures/e2e-test.ts"; import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; +import { DEEPAGENTS_FRESH_REONBOARD_CHECK } from "./cloud-experimental-check-list.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const REQUIRED_CHECK_SKIP_PATTERN = /(^|\n).*\bSKIP\b/i; +const DEFAULT_CHECK_TIMEOUT_MS = 180_000; +const FRESH_REONBOARD_TIMEOUT_MS = 15 * 60_000; export type CloudExperimentalChecksEvidence = { targetId: string; @@ -79,6 +82,12 @@ export function assertRequiredCloudExperimentalResult( ); } +export function cloudExperimentalCheckTimeoutMs(scriptPath: string): number { + return scriptPath === DEEPAGENTS_FRESH_REONBOARD_CHECK + ? FRESH_REONBOARD_TIMEOUT_MS + : DEFAULT_CHECK_TIMEOUT_MS; +} + async function assertDeepAgentsRuntimeObserved( sandboxName: string, context: Pick, @@ -124,7 +133,7 @@ export async function runE2eCloudExperimentalChecks( cwd: REPO_ROOT, env: buildCloudExperimentalCommandEnv(sandboxName, apiKey), redactionValues: [apiKey], - timeoutMs: 180_000, + timeoutMs: cloudExperimentalCheckTimeoutMs(scriptPath), }); assertRequiredCloudExperimentalResult(scriptPath, result); } diff --git a/test/e2e/support/platform-parity-cloud-experimental.test.ts b/test/e2e/support/platform-parity-cloud-experimental.test.ts index 590d54c0c0c..0fa4e277a5d 100644 --- a/test/e2e/support/platform-parity-cloud-experimental.test.ts +++ b/test/e2e/support/platform-parity-cloud-experimental.test.ts @@ -12,6 +12,7 @@ import { assertRequiredCloudExperimentalResult, buildCloudExperimentalChecksEvidence, buildCloudExperimentalCommandEnv, + cloudExperimentalCheckTimeoutMs, } from "../live/cloud-experimental-checks.ts"; function shellResult(exitCode: number, stdout: string, stderr = ""): ShellProbeResult { @@ -133,6 +134,7 @@ describe("P0-E cloud-experimental parity guardrails", () => { it("registers executable Deep Agents cloud-experimental checks", () => { expect(DEEPAGENTS_CLOUD_EXPERIMENTAL_CHECKS).toEqual([ + "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh", "test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh", "test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh", "test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh", @@ -147,6 +149,19 @@ describe("P0-E cloud-experimental parity guardrails", () => { } }); + it("gives the destructive fresh re-onboard check its onboarding budget", () => { + expect( + cloudExperimentalCheckTimeoutMs( + "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh", + ), + ).toBe(15 * 60_000); + expect( + cloudExperimentalCheckTimeoutMs( + "test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh", + ), + ).toBe(180_000); + }); + it("documents Deep Agents check scripts in generated launch/QA evidence", () => { const evidence = buildCloudExperimentalChecksEvidence( "cloud-langchain-deepagents-code", diff --git a/test/langchain-deepagents-code-image.test.ts b/test/langchain-deepagents-code-image.test.ts index ae2a736652d..1a157bf7274 100644 --- a/test/langchain-deepagents-code-image.test.ts +++ b/test/langchain-deepagents-code-image.test.ts @@ -647,6 +647,7 @@ describe("LangChain Deep Agents Code image contracts", () => { expect(tavilyOptInCheck).toMatch(expected); } expect(cloudExperimentalChecksForOnboarding("cloud-langchain-deepagents-code")).toEqual([ + "test/e2e/e2e-cloud-experimental/checks/04-deepagents-code-fresh-reonboard.sh", "test/e2e/e2e-cloud-experimental/checks/05-deepagents-code-landlock-readonly.sh", "test/e2e/e2e-cloud-experimental/checks/06-deepagents-code-python-egress.sh", "test/e2e/e2e-cloud-experimental/checks/07-deepagents-code-headless-inference.sh", From da296605a7f29608c4bb2a5abcc449a0f809385e Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Mon, 6 Jul 2026 12:24:46 -0700 Subject: [PATCH 21/21] test(e2e): use compatible DCode switch model Signed-off-by: Apurv Kumaria --- .../checks/04-deepagents-code-fresh-reonboard.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 11c7b2ce202..4e5de37a305 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 @@ -15,7 +15,7 @@ SANDBOX_NAME="${SANDBOX_NAME:-${NEMOCLAW_SANDBOX_NAME:-}}" REPO="${REPO:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)}" CLI="${NEMOCLAW_CLI_BIN:-${REPO}/bin/nemoclaw.js}" PREFIX="04-deepagents-code-fresh-reonboard" -PRIMARY_TARGET_MODEL="nvidia/nemotron-3-super-120b-a12b" +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}"