diff --git a/docs/get-started/quickstart-langchain-deepagents-code.mdx b/docs/get-started/quickstart-langchain-deepagents-code.mdx index 5a987b8d39..8e4a74d305 100644 --- a/docs/get-started/quickstart-langchain-deepagents-code.mdx +++ b/docs/get-started/quickstart-langchain-deepagents-code.mdx @@ -110,6 +110,7 @@ For project-specific Python dependencies, create a separate virtual environment Deep Agents Code state lives under `/sandbox/.deepagents`. NemoClaw snapshot and rebuild flows preserve the app state directory, skills, and generated config when those files exist. +During a provider/model drift recreate, NemoClaw preserves the app state directories and skills but keeps the freshly generated `config.toml` from the replacement sandbox so the runtime uses the newly selected model. 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 a7a0f30338..e5f2023ffe 100644 --- a/docs/reference/commands-nemohermes.mdx +++ b/docs/reference/commands-nemohermes.mdx @@ -319,7 +319,7 @@ 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 differs or when an existing sandbox registry entry is missing valid provider/model metadata. 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 be6cc145ff..9b5c3fb617 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -414,7 +414,7 @@ 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 differs or when an existing sandbox registry entry is missing valid provider/model metadata. 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 5604c943fe..6d99100ab4 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -3053,7 +3053,11 @@ async function createSandboxWithBaseImageResolution( ? " Restoring workspace state from pre-upgrade backup..." : " Restoring workspace state from pre-recreate backup...", ); - const restore = sandboxState.restoreSandboxState(sandboxName, restoreBackupPath); + const restore = sandboxState.restoreSandboxState( + sandboxName, + restoreBackupPath, + createIntent?.skipRestoreStateFiles, + ); if (restore.success) { note( ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, @@ -3063,8 +3067,6 @@ async function createSandboxWithBaseImageResolution( } } - // DNS proxy — run a forwarder in the sandbox pod so the isolated - // sandbox namespace can resolve hostnames (fixes #626). if (sandboxRuntimeFields.openshellDriver === "kubernetes") { console.log(" Setting up sandbox DNS proxy..."); runFile("bash", [path.join(SCRIPTS, "setup-dns-proxy.sh"), GATEWAY_NAME, sandboxName], { @@ -3077,8 +3079,6 @@ async function createSandboxWithBaseImageResolution( sandboxRuntimeFields, ); - // Check that messaging providers exist in the gateway (sandbox attachment - // cannot be verified via CLI yet — only gateway-level existence is checked). for (const p of messagingProviders) { if (!providerExistsInGateway(p)) { printMessagingProviderMissing(p); @@ -3089,7 +3089,6 @@ async function createSandboxWithBaseImageResolution( warnIfLandlockUnsupported({ dockerInfoFormat, runCapture }); - // #4614: arm rollback only when the sandbox was not live before (never a recreate/rebuild). if (!liveExists) sandboxCancelRollback.arm(sandboxName); return sandboxName; } diff --git a/src/lib/onboard/machine/handlers/sandbox-drift.test.ts b/src/lib/onboard/machine/handlers/sandbox-drift.test.ts new file mode 100644 index 0000000000..2105f183d9 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-drift.test.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { providerModelConfigChanged } from "./sandbox-drift"; + +describe("providerModelConfigChanged", () => { + it("does not force recreation when no sandbox registry entry exists", () => { + expect(providerModelConfigChanged(null, "nvidia", "nemotron")).toBe(false); + }); + + it("recreates when provider or model differs from recorded sandbox metadata", () => { + expect( + providerModelConfigChanged( + { name: "alpha", provider: "nvidia", model: "old-model" } as any, + "nvidia", + "new-model", + ), + ).toBe(true); + }); + + it("fails closed when existing sandbox metadata is missing provider/model fields", () => { + expect(providerModelConfigChanged({ name: "alpha" } as any, "nvidia", "nemotron")).toBe(true); + }); + + it("fails closed when existing sandbox metadata has malformed provider/model fields", () => { + expect( + providerModelConfigChanged( + { name: "alpha", provider: "", model: ["nemotron"] } as any, + "nvidia", + "nemotron", + ), + ).toBe(true); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox-drift.ts b/src/lib/onboard/machine/handlers/sandbox-drift.ts new file mode 100644 index 0000000000..3b0b78ba72 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-drift.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { SandboxEntry } from "../../../state/registry"; + +function validRecordedValue(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value : null; +} + +export function providerModelConfigChanged( + existing: SandboxEntry | null, + provider: string, + model: string, +): boolean { + if (!existing) return false; + return ( + validRecordedValue(existing.provider) !== provider || + validRecordedValue(existing.model) !== model + ); +} diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts index fd1ddd0219..fd3cd3911a 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.test.ts @@ -7,10 +7,12 @@ import { decideSandboxResume, type SandboxResumeSignals } from "./sandbox-resume function resumeSignals(overrides: Partial = {}): SandboxResumeSignals { return { + fresh: false, resume: true, resumeAgentChanged: false, sandboxStepComplete: true, sandboxReuseState: "ready", + providerModelConfigChanged: false, webSearchConfigChanged: false, sandboxGpuConfigChanged: false, messagingChannelConfigChanged: false, @@ -28,6 +30,7 @@ describe("decideSandboxResume", () => { it.each([ ["agent", { resumeAgentChanged: true }, false], + ["provider/model", { providerModelConfigChanged: true }, false], ["web search", { webSearchConfigChanged: true }, true], ["sandbox GPU", { sandboxGpuConfigChanged: true }, true], ["messaging", { messagingChannelConfigChanged: true }, true], @@ -54,6 +57,24 @@ describe("decideSandboxResume", () => { }); }); + it("recreates fresh runs when an existing sandbox has provider/model drift", () => { + expect( + decideSandboxResume( + resumeSignals({ + fresh: true, + resume: false, + sandboxStepComplete: false, + providerModelConfigChanged: true, + }), + ), + ).toEqual({ + kind: "recreate", + note: " [fresh] Provider/model selection changed; recreating sandbox.", + removeRegistryEntry: false, + skipRestoreStateFiles: ["config.toml"], + }); + }); + it("repairs a recorded sandbox that is present but not ready", () => { expect(decideSandboxResume(resumeSignals({ sandboxReuseState: "not_ready" }))).toEqual({ kind: "repair-and-recreate", diff --git a/src/lib/onboard/machine/handlers/sandbox-resume.ts b/src/lib/onboard/machine/handlers/sandbox-resume.ts index 9feb66d510..cf36258507 100644 --- a/src/lib/onboard/machine/handlers/sandbox-resume.ts +++ b/src/lib/onboard/machine/handlers/sandbox-resume.ts @@ -6,10 +6,12 @@ import type { SandboxEntry } from "../../../state/registry"; import { normalizeToolDisclosure, toolDisclosureOrDefault } from "../../../tool-disclosure"; export interface SandboxResumeSignals { + readonly fresh: boolean; readonly resume: boolean; readonly resumeAgentChanged: boolean; readonly sandboxStepComplete: boolean; readonly sandboxReuseState: string; + readonly providerModelConfigChanged: boolean; readonly webSearchConfigChanged: boolean; readonly sandboxGpuConfigChanged: boolean; readonly messagingChannelConfigChanged: boolean; @@ -41,6 +43,7 @@ export type SandboxResumeDecision = readonly kind: "recreate"; readonly note: string; readonly removeRegistryEntry: boolean; + readonly skipRestoreStateFiles?: readonly string[]; } | { readonly kind: "repair-and-recreate" }; @@ -92,9 +95,23 @@ function toolDisclosureResumeDecision(signals: SandboxResumeSignals): SandboxRes return null; } -export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { - if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; - if (canReuseSandbox(signals)) return { kind: "reuse" }; +function providerModelDriftDecision(signals: SandboxResumeSignals): SandboxResumeDecision | null { + if (!signals.providerModelConfigChanged) return null; + return { + kind: "recreate", + note: ` [${signals.fresh ? "fresh" : signals.resume ? "resume" : "onboard"}] Provider/model selection changed; recreating sandbox.`, + // Keep the registry row until createSandbox captures any registry-only + // fidelity and runs the normal pre-recreate backup/deletion path. + removeRegistryEntry: false, + // Provider/model recreation must preserve user state, but not generated + // agent inference config from the pre-recreate sandbox. + skipRestoreStateFiles: ["config.toml"], + }; +} + +function configurationDriftResumeDecision( + signals: SandboxResumeSignals, +): SandboxResumeDecision | null { if (signals.resumeAgentChanged) { return { kind: "recreate", @@ -130,8 +147,16 @@ export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResum removeRegistryEntry: true, }; } - const toolDisclosureDecision = toolDisclosureResumeDecision(signals); - if (toolDisclosureDecision) return toolDisclosureDecision; + return toolDisclosureResumeDecision(signals); +} + +export function decideSandboxResume(signals: SandboxResumeSignals): SandboxResumeDecision { + const providerModelDecision = providerModelDriftDecision(signals); + if (providerModelDecision) return providerModelDecision; + if (!signals.resume || !signals.sandboxStepComplete) return { kind: "create" }; + if (canReuseSandbox(signals)) return { kind: "reuse" }; + const driftDecision = configurationDriftResumeDecision(signals); + if (driftDecision) return driftDecision; if (signals.sandboxReuseState === "not_ready") return { kind: "repair-and-recreate" }; 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 8deb7a53c8..5bb01eae01 100644 --- a/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts +++ b/src/lib/onboard/machine/handlers/sandbox-test-fixtures.ts @@ -143,6 +143,8 @@ export function createDeps( getSandboxHermesToolGateways: () => [], getSandboxRegistryEntry: (name: string) => ({ name, + provider: "provider", + model: "model", webSearchEnabled: false, toolDisclosure: "progressive" as const, fromDockerfile: null, diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index 80f99f4451..a11d76e0e7 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -164,6 +164,48 @@ describe("handleSandboxState", () => { expect(result.session).toBe(skippedSession); }); + it("recreates an existing ready sandbox on fresh provider/model drift", async () => { + const { deps, calls } = createDeps({ + getSandboxReuseState: () => "ready", + getSandboxRegistryEntry: (name) => ({ + name, + provider: "old-provider", + model: "old-model", + toolDisclosure: "progressive", + }), + }); + + await handleSandboxState({ + ...baseOptions(deps), + fresh: true, + sandboxName: "saved", + provider: "new-provider", + model: "new-model", + }); + + expect(calls.note).toHaveBeenCalledWith( + " [fresh] Provider/model selection changed; recreating sandbox.", + ); + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).toHaveBeenCalledWith( + { type: "nvidia" }, + "new-model", + "new-provider", + "openai-completions", + "saved", + null, + [], + null, + null, + null, + { sandboxGpuEnabled: false, mode: "0" }, + null, + [], + null, + { recreate: true, toolDisclosure: "progressive", skipRestoreStateFiles: ["config.toml"] }, + ); + }); + it("backfills absent rebuild fidelity after validated sandbox reuse", async () => { const session = createSession({ sandboxName: "saved", @@ -175,6 +217,8 @@ describe("handleSandboxState", () => { getSandboxReuseState: () => "ready", getSandboxRegistryEntry: (name) => ({ name, + provider: "provider", + model: "model", nemoclawVersion: "0.1.0", toolDisclosure: "progressive", }), @@ -404,6 +448,8 @@ describe("handleSandboxState", () => { agentSupportsWebSearchProvider: () => true, getSandboxRegistryEntry: (name: string) => ({ name, + provider: "provider", + model: "model", mcp: { bridges: { search: { diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 986bd36856..780c37d2c1 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 { providerModelConfigChanged } from "./sandbox-drift"; import { reconcileReusedSandboxMessaging, reconcileSandboxMessaging } from "./sandbox-messaging"; import { applySandboxResumeDecision, @@ -374,15 +375,21 @@ class SandboxStateFlow< state.webSearchConfig as unknown as SharedWebSearchConfig | null, this.options.hermesToolGateways, ); - const toolDisclosureSignals = resolveToolDisclosureResumeSignals( - state.sandboxName ? this.deps.getSandboxRegistryEntry(state.sandboxName) : null, - state.session, - ); + const registryEntry = state.sandboxName + ? this.deps.getSandboxRegistryEntry(state.sandboxName) + : null; + const toolDisclosureSignals = resolveToolDisclosureResumeSignals(registryEntry, state.session); return decideSandboxResume({ + fresh: this.options.fresh, resume: this.options.resume, resumeAgentChanged: this.options.resumeAgentChanged, sandboxStepComplete: state.session?.steps?.sandbox?.status === "complete", sandboxReuseState: this.deps.getSandboxReuseState(state.sandboxName), + providerModelConfigChanged: providerModelConfigChanged( + registryEntry, + this.options.provider, + this.options.model, + ), webSearchConfigChanged: state.webSearchSupportDropped || state.webSearchConfigChanged, sandboxGpuConfigChanged: state.sandboxName ? this.deps.hasSandboxGpuDrift(state.sandboxName, this.options.sandboxGpuConfig) @@ -519,6 +526,8 @@ class SandboxStateFlow< { recreate: decision.kind !== "create", toolDisclosure: toolDisclosureOrDefault(state.session?.toolDisclosure), + skipRestoreStateFiles: + decision.kind === "recreate" ? decision.skipRestoreStateFiles : undefined, }, ), ); diff --git a/src/lib/onboard/types.ts b/src/lib/onboard/types.ts index b662305003..417ed40b80 100644 --- a/src/lib/onboard/types.ts +++ b/src/lib/onboard/types.ts @@ -56,6 +56,7 @@ export type ModelValidationResult = ModelValidationSuccess | ModelValidationFail export interface SandboxCreateIntent { readonly recreate: boolean; readonly toolDisclosure: import("../tool-disclosure").ToolDisclosure; + readonly skipRestoreStateFiles?: readonly string[]; } export type OnboardOptions = { diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 6b95ac4cc3..41a2546bae 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -142,6 +142,14 @@ export interface RestoreResult { failedFiles: string[]; } +export type RestoreOptions = { readonly skipStateFiles?: readonly string[] } | readonly string[]; + +function isRestoreOptionsObject( + options: RestoreOptions, +): options is { readonly skipStateFiles?: readonly string[] } { + return !Array.isArray(options); +} + export interface TarValidationResult { safe: boolean; entries: string[]; @@ -1337,7 +1345,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) { @@ -1373,7 +1385,12 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re const restorableStateDirs = manifest.backedUpDirs ?? manifest.stateDirs; const localDirs = existingBackupDirs(backupPath, restorableStateDirs); const stateFiles = normalizeStateFileSpecs(manifest.stateFiles ?? []); - const localFiles = stateFiles.filter((f) => existsSync(path.join(backupPath, f.path))); + const skippedFiles = new Set( + isRestoreOptionsObject(options) ? (options.skipStateFiles ?? []) : options, + ); + const localFiles = stateFiles.filter( + (f) => !skippedFiles.has(f.path) && existsSync(path.join(backupPath, f.path)), + ); _log( `Local backup dirs: [${localDirs.join(",")}] (${localDirs.length}/${manifest.stateDirs.length})`, ); diff --git a/test/deepagents-config-restore.test.ts b/test/deepagents-config-restore.test.ts new file mode 100644 index 0000000000..a7b767f05a --- /dev/null +++ b/test/deepagents-config-restore.test.ts @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; + +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-config-restore-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); + +type SandboxStateModule = typeof import("../src/lib/state/sandbox.js"); + +const sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "src", "lib", "state", "sandbox.ts")).href +)) as SandboxStateModule; + +afterAll(() => { + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function writeAgentRegistry(sandboxName: string, agent: string): void { + fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent, + }, + }, + }), + ); +} + +function writeBackup(sandboxName: string, dirName: string): string { + const backupPath = path.join(BACKUPS_ROOT, sandboxName, dirName); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify( + { + version: 1, + sandboxName, + timestamp: dirName, + agentType: "langchain-deepagents-code", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + stateFiles: [{ path: "config.toml", strategy: "copy" }], + dir: "/sandbox/.deepagents", + backupPath, + blueprintDigest: null, + }, + null, + 2, + ), + ); + return backupPath; +} + +describe("Deep Agents Code generated config restore", () => { + it("can skip restoring generated config.toml after provider/model drift recreate (#6311)", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-deepagents-restore-skip-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const fakeRoot = path.join(fixture, "sandbox-root"); + const deepAgentsDir = path.join(fakeRoot, ".deepagents"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(deepAgentsDir, { recursive: true }); + fs.writeFileSync(path.join(deepAgentsDir, "config.toml"), 'model = "new-model"\n'); + + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-deepagents\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const deepAgentsDir = path.join(${JSON.stringify(fakeRoot)}, ".deepagents"); +const cmd = process.argv[process.argv.length - 1] || ""; +function readStdin() { + const chunks = []; + for (;;) { + const buf = Buffer.alloc(65536); + let n = 0; + try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { break; } + if (n === 0) break; + chunks.push(buf.subarray(0, n)); + } + return Buffer.concat(chunks); +} +if (cmd.includes(".nemoclaw-restore") && cmd.includes("config.toml")) { + fs.writeFileSync(path.join(deepAgentsDir, "config.toml"), readStdin()); + process.exit(0); +} +process.exit(0); +`, + ); + + writeAgentRegistry("deepagents", "langchain-deepagents-code"); + process.env.NEMOCLAW_OPENSHELL_BIN = openshell; + process.env.PATH = `${binDir}${path.delimiter}${oldPath || ""}`; + + const backupPath = writeBackup("deepagents", "2026-07-06T10-00-00-000Z"); + fs.writeFileSync(path.join(backupPath, "config.toml"), 'model = "old-model"\n'); + + const restore = sandboxState.restoreSandboxState("deepagents", backupPath, { + skipStateFiles: ["config.toml"], + }); + + expect(restore.success).toBe(true); + expect(restore.restoredFiles).toEqual([]); + expect(fs.readFileSync(path.join(deepAgentsDir, "config.toml"), "utf-8")).toBe( + 'model = "new-model"\n', + ); + } finally { + oldOpenshell === undefined + ? delete process.env.NEMOCLAW_OPENSHELL_BIN + : (process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell); + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/test/package-contract/cli/config-set-cli-dispatch.test.ts b/test/package-contract/cli/config-set-cli-dispatch.test.ts index 5ba2a454e1..11b950dab8 100644 --- a/test/package-contract/cli/config-set-cli-dispatch.test.ts +++ b/test/package-contract/cli/config-set-cli-dispatch.test.ts @@ -93,7 +93,7 @@ describe("config set CLI dispatch", () => { settled = true; }); - await vi.waitFor(() => expect(configSet).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(configSet).toHaveBeenCalledTimes(1), { timeout: 5000 }); expect(configSet).toHaveBeenCalledTimes(1); expect(configSet).toHaveBeenCalledWith("test-sandbox", { key: "inference.endpoints",