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/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index fe30f5032b3..1d9038178a0 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -46,7 +46,13 @@ state_dirs: - agent/skills # ── Top-level durable state files ─────────────────────────────── -# config.toml is non-secret NemoClaw-generated provider/model configuration. +# config.toml mixes DCode preferences with NemoClaw-managed model routing. +# 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 5a987b8d397..92ef766302a 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -109,7 +109,11 @@ 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. +NemoClaw snapshot and rebuild flows preserve the app state directory and skills when those paths exist. +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. 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 a42ee5dc3a9..0a527ba1f0d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -105,6 +105,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 providerKeyBridge: typeof import("./onboard/provider-key-bridge") = require("./onboard/provider-key-bridge"); const { isLinuxDockerDriverGatewayEnabled, @@ -154,9 +162,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"); @@ -514,7 +519,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"); @@ -1331,80 +1336,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); @@ -2374,6 +2314,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) { @@ -2438,6 +2379,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); @@ -2498,8 +2448,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( @@ -2553,7 +2507,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()); @@ -2601,7 +2555,7 @@ async function createSandboxWithBaseImageResolution( pendingStateRestoreBackupPath = outcome.restoreBackupPath; } } else if (existingSandboxState === "ready") { - if (confirmedSelectionDrift) { + if (actionableSelectionDrift) { const confirmed = await confirmRecreateForSelectionDrift( sandboxName, selectionDrift, @@ -2670,8 +2624,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) { @@ -3005,49 +2961,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). @@ -4565,6 +4534,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..938bd003599 --- /dev/null +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -0,0 +1,386 @@ +// 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 { 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"; + +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"', + "show_scrollbar = true", + "", + ].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", + }); + 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( + 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?.stateFileRestorePolicy).toBe(managedDcodeConfigRestorePolicy); + 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]).not.toContain("[agents]"); + expect(registeredConfigs[0]).toContain("[ui]\nshow_scrollbar = true"); + expect(registeredConfigs[0]).not.toContain('theme = "dark"'); + } finally { + process.env.PATH = fixture.oldPath; + } + }); + + it("does not publish registry metadata when live validation fails (#6311)", () => { + const register = vi.fn(); + const error = 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, + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ), + ).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 fixture = makeRestoreFixture(); + const registeredConfigs: string[] = []; + const error = vi.fn(); + 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: ${fixture.backupPath}`, + ); + 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; + } + }); + + it("keeps custom-image restores outside the managed config merge (#6311)", () => { + const restoreSandboxState = vi.fn(() => ({ + success: true, + restoredDirs: [], + failedDirs: [], + restoredFiles: ["config.toml"], + failedFiles: [], + })); + + 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", + undefined, + ); + }); +}); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts new file mode 100644 index 00000000000..fa38a311ba6 --- /dev/null +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -0,0 +1,94 @@ +// 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 { RestoreOptions, RestoreResult } from "../state/sandbox"; +import type { SelectionDrift } from "./selection-drift"; + +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, + options.validateManagedDcode + ? { stateFileRestorePolicy: managedDcodeConfigRestorePolicy } + : undefined, + ); + if (restore.success) { + deps.note( + ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, + ); + } else { + // 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}`); + } + } + + 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}'. The sandbox still exists, but its live route is unverified and registry metadata was not updated.`, + ); + 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."); + 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..b869e3c60df --- /dev/null +++ b/src/lib/onboard/dcode-selection-drift.test.ts @@ -0,0 +1,154 @@ +// 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(" openai:model:tag ")).toBe("model:tag"); + expect( + getExpectedDcodeInferenceIdentity( + "compatible-anthropic-endpoint", + "openai:model:tag", + "anthropic-messages", + ), + ).toEqual({ + route: "anthropic", + provider: "compatible-anthropic-endpoint", + model: "openai:model:tag", + endpoint: "https://inference.local", + }); + }); + + 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()); + + 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..c9bf87c332f --- /dev/null +++ b/src/lib/onboard/dcode-selection-drift.ts @@ -0,0 +1,138 @@ +// 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 { + // 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); +} + +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(); + return trimmed.startsWith("openai:") ? trimmed.slice("openai:".length) : 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/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 7154db54c47..e7d15ae9974 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 normalizedLogs = String(collectDiagnostics() || "") + .replace(/\r/g, "") + .replace(ANSI_RE, ""); + const logs = redact(normalizedLogs); + if (logs) { + printError(" Gateway logs:"); + for (const line of logs.split("\n").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); + }; +} diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 919b1903214..84082dd39a2 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -150,6 +150,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-resume.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts new file mode 100644 index 00000000000..4c960aa65b9 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-resume.ts @@ -0,0 +1,98 @@ +// 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"; +import type { SandboxResumeDecision } from "./sandbox-resume"; + +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 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, + 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-dcode-selection.test.ts b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts new file mode 100644 index 00000000000..cc8aa2f93fd --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-dcode-selection.test.ts @@ -0,0 +1,169 @@ +// 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("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({ + 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 44fe4a8754b..ea230601e56 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -22,6 +22,7 @@ function resumeSignals(overrides: Partial = {}): SandboxRe hermesToolGatewayConfigChanged: false, toolDisclosureMigrationNeeded: false, toolDisclosureChanged: false, + inferenceSelectionChanged: false, ...overrides, }; } @@ -39,6 +40,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 bf0901cf222..375050890a3 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -17,6 +17,7 @@ export interface SandboxResumeSignals { readonly hermesToolGatewayConfigChanged: boolean; readonly toolDisclosureMigrationNeeded: boolean; readonly toolDisclosureChanged: boolean; + readonly inferenceSelectionChanged: boolean; } interface InferenceRouteResumeInput { @@ -99,6 +100,7 @@ function canReuseSandbox(signals: SandboxResumeSignals): boolean { return ( !signals.resumeAgentChanged && !signals.inferenceRouteConfigChanged && + !signals.inferenceSelectionChanged && !signals.webSearchConfigChanged && !signals.sandboxGpuConfigChanged && !signals.messagingChannelConfigChanged && @@ -131,6 +133,13 @@ function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxRes } function compatibilityResumeDecision(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", @@ -152,9 +161,9 @@ function compatibilityResumeDecision(signals: SandboxResumeSignals): SandboxResu export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; - if (canReuseSandbox(signals)) return { kind: "reuse" }; const compatibilityDecision = compatibilityResumeDecision(signals); if (compatibilityDecision) return compatibilityDecision; + if (canReuseSandbox(signals)) return { kind: "reuse" }; 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 c81ecf5cdab..111af3c7536 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -17,6 +17,7 @@ import { toolDisclosureOrDefault } from "../../../tool-disclosure"; 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 +59,7 @@ export interface SandboxStateOptions< controlUiPort: number | null; rootDir: string; env: NodeJS.ProcessEnv; - deps: { + deps: dcodeResume.Deps & { resolvePath(value: string): string; agentSupportsWebSearch( agent: Agent, @@ -159,8 +160,6 @@ export interface SandboxStateOptions< }, ): Promise; withSandboxMutationLock?(sandboxName: string, action: () => Promise): Promise; - error(message?: string): void; - exitProcess(code: number): never; }; } @@ -379,11 +378,19 @@ class SandboxStateFlow< ? this.deps.getSandboxRegistryEntry(state.sandboxName) : null; const toolDisclosureSignals = resolveToolDisclosureResumeSignals(registryEntry, state.session); - return decideSandboxResume({ + const sandboxReuseState = this.deps.getSandboxReuseState(state.sandboxName); + const dcodeResumeSignals = dcodeResume.resolveSignals( + this.options, + state, + sandboxReuseState, + registryEntry, + this.deps, + ); + const decision = decideSandboxResume({ resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", - sandboxReuseState: this.deps.getSandboxReuseState(state.sandboxName), + sandboxReuseState, inferenceRouteConfigChanged: hasHermesCompatibleAnthropicInferenceRouteDrift({ agentName: (this.options.agent as { name?: string } | null)?.name, provider: this.options.provider, @@ -404,7 +411,9 @@ class SandboxStateFlow< effectiveToolGateways, ), ...toolDisclosureSignals, + ...dcodeResumeSignals, }); + return dcodeResume.preserveManagedDcodeRegistryEntry(this.options, decision); } private async reuseSandbox( @@ -458,6 +467,7 @@ class SandboxStateFlow< if (existing?.hermesAuthMethod === undefined && this.options.hermesAuthMethod) { fidelity.hermesAuthMethod = this.options.hermesAuthMethod; } + Object.assign(fidelity, dcodeResume.selectionFidelity(this.options, existing)); 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..5a68db0ffd3 --- /dev/null +++ b/src/lib/state/dcode-config-restore-input.test.ts @@ -0,0 +1,300 @@ +// 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, + managedDcodeConfigRestorePolicy, +} 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("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( + managedDcodeConfigRestorePolicy( + "openclaw", + "/sandbox/.deepagents", + { path: "config.toml", strategy: "copy" }, + backupContents, + ), + ).toBeNull(); + expect( + managedDcodeConfigRestorePolicy( + "langchain-deepagents-code", + "/sandbox/custom-deepagents", + { path: "config.toml", strategy: "copy" }, + backupContents, + ), + ).toBeNull(); + expect( + managedDcodeConfigRestorePolicy( + "langchain-deepagents-code", + "/sandbox/.deepagents", + { path: "other.toml", strategy: "copy" }, + backupContents, + ), + ).toBeNull(); + expect( + managedDcodeConfigRestorePolicy( + "langchain-deepagents-code", + "/sandbox/.deepagents", + { path: "config.toml", strategy: "sqlite_backup" }, + backupContents, + ), + ).toBeNull(); + }); + + 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 }, + ui: { theme: "nvidia-dark", show_scrollbar: true, show_url_open_toast: false }, + threads: { relative_time: false, sort_order: "created_at" }, + }; + 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({ + models: fresh.models, + update: fresh.update, + ui: { show_scrollbar: true, show_url_open_toast: false }, + threads: backup.threads, + }); + }); + + 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 = { + 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" }, + 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..2b29b857f32 --- /dev/null +++ b/src/lib/state/dcode-config-restore-input.ts @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// 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"; + +/** + * 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 through a local, + * explicit key allowlist. + * TODO(#6334): remove this policy when manifests support key-level ownership. + */ +function shouldMergeManagedDcodeConfigStateFile( + agentType: string | null | undefined, + dir: string, + spec: StateFileRestoreSpec, +): boolean { + return ( + 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 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 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 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 safe_ui(backup): + section = backup.get("ui") + if not isinstance(section, dict): + return {} + result = {} + 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 = merge_safe_preferences(backup, current) + 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") + + # 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: + 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("; "); +} + +/** + * 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 6b95ac4cc3b..34e759f1cbe 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -47,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()); @@ -142,6 +143,11 @@ export interface RestoreResult { failedFiles: string[]; } +export interface RestoreOptions { + /** Optional file-specific restore capability authorized by the caller. */ + stateFileRestorePolicy?: StateFileRestorePolicy; +} + export interface TarValidationResult { safe: boolean; entries: string[]; @@ -873,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({ @@ -895,24 +899,30 @@ function buildStateFileRestoreInput( function restoreStateFile( configFile: string, sandboxName: string, + agentType: string | null | undefined, dir: string, spec: StateFileSpec, backupPath: string, mergeOpenClawConfig = false, + stateFileRestorePolicy?: StateFileRestorePolicy, ): boolean { const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; - const command = 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 = 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], { @@ -1337,7 +1347,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) { @@ -1524,10 +1538,12 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re restoreStateFile( configFile, sandboxName, + manifest.agentType, dir, spec, backupPath, shouldMergeOpenClawConfigStateFile(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; 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..4e5de37a305 --- /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="openai/openai/gpt-5.5" +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-config.test.ts b/test/langchain-deepagents-code-config.test.ts index 383a839aaf1..bc393cac826 100644 --- a/test/langchain-deepagents-code-config.test.ts +++ b/test/langchain-deepagents-code-config.test.ts @@ -82,6 +82,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", 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", 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 ""; 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 : ""; }