From 004846e9c32e6a4b421efb1286786a2e5ed8de1e Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Tue, 7 Jul 2026 22:39:35 -0700 Subject: [PATCH 01/24] feat(state): add key-level restore ownership to agent manifests Agent manifests can now declare per-key restore ownership for mixed-ownership state files. A key-allowlist merge restores only typed, bounded, or enum user keys and requires the declared fresh tables and headers; a named openclaw-config strategy covers OpenClaw's deep merge. Ownership is validated at manifest load time and read from the live manifest during restore, so snapshot format is unchanged and existing manifests keep whole-file behavior until they opt in. The generic restore engine now dispatches on the manifest instead of agent-specific conditionals. Migrates Deep Agents Code onto the declarative schema and removes its local config-restore workaround, and re-homes OpenClaw's merge behind the manifest marker. Resolves #6334 Co-Authored-By: Claude Signed-off-by: Tinson Lai --- .../langchain-deepagents-code/manifest.yaml | 21 ++ agents/openclaw/manifest.yaml | 2 + src/lib/agent/definition-types.ts | 26 ++ src/lib/agent/defs.test.ts | 235 ++++++++++++- src/lib/agent/defs.ts | 5 + src/lib/agent/manifest-readers.ts | 210 +++++++++++- .../created-sandbox-finalization.test.ts | 11 +- .../onboard/created-sandbox-finalization.ts | 11 +- .../state/dcode-config-restore-input.test.ts | 300 ---------------- src/lib/state/dcode-config-restore-input.ts | 270 --------------- .../openclaw-config-restore-input.test.ts | 34 +- .../state/openclaw-config-restore-input.ts | 33 -- src/lib/state/sandbox.ts | 92 ++--- src/lib/state/state-file-key-merge.test.ts | 322 ++++++++++++++++++ src/lib/state/state-file-key-merge.ts | 293 ++++++++++++++++ src/lib/state/state-file-restore-policy.ts | 20 -- 16 files changed, 1166 insertions(+), 719 deletions(-) delete mode 100644 src/lib/state/dcode-config-restore-input.test.ts delete mode 100644 src/lib/state/dcode-config-restore-input.ts create mode 100644 src/lib/state/state-file-key-merge.test.ts create mode 100644 src/lib/state/state-file-key-merge.ts delete mode 100644 src/lib/state/state-file-restore-policy.ts diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 90bfe3fb96..347c02a5a2 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -61,6 +61,27 @@ state_dirs: # not user-authored durable state. state_files: - path: config.toml + restore: + merge: key-allowlist + require_fresh_tables: + - models + - update + 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/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index e99a92d0e4..1bc9d7efa6 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -25,9 +25,35 @@ 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 StateFileRestoreOwnership { + merge: StateFileRestoreMerge; + userKeys?: readonly StateFileUserKey[]; + requireFreshTables?: readonly string[]; + requireFreshHeaders?: readonly StateFileFreshHeader[]; +} + export interface AgentStateFile { path: string; strategy: AgentStateFileStrategy; + restore?: StateFileRestoreOwnership; } export type AgentDashboardKind = "ui" | "api"; diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index a9324b9226..9073ad6383 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"); }); @@ -131,7 +133,29 @@ 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).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: " }, + ], + }, + }, + ]); expect(deepAgentsCode.stateFiles.map((entry) => entry.path)).not.toContain(".env"); expect(deepAgentsCode.userManagedFiles).toEqual([".deepagents/.env", ".deepagents/.mcp.json"]); }); @@ -501,4 +525,211 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*control characters/); }); + + it("parses a declarative key-allowlist restore ownership block", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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", () => { + 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'/, + ); + }); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index c8b7dc21c6..dfa9172b0b 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -60,6 +60,11 @@ export type { AgentStateFile, AgentStateFileStrategy, AgentVersionScheme, + StateFileFreshHeader, + 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 45913a5d0e..fb1c26680a 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -10,9 +10,14 @@ import type { AgentInference, AgentMcpCapability, AgentStateFile, + AgentStateFileStrategy, AgentVersionScheme, ManifestRecord, ManifestValue, + StateFileFreshHeader, + StateFileRestoreOwnership, + StateFileUserKey, + StateFileUserKeyType, StringMap, } from "./definition-types"; @@ -109,6 +114,206 @@ export function readUserManagedFiles(record: ManifestRecord): string[] | undefin }); } +const STATE_FILE_MERGE_STRATEGIES = ["key-allowlist", "openclaw-config"] as const; +const STATE_FILE_USER_KEY_TYPES: readonly StateFileUserKeyType[] = [ + "boolean", + "string", + "integer", + "number", + "enum", +]; + +function readNumber(record: ManifestRecord, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +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 readStateFileUserKey(raw: ManifestValue, field: string): StateFileUserKey { + if (!isManifestRecord(raw)) { + throw new Error(`Agent manifest field '${field}' must be an object`); + } + 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`); + } + const headerValue = readString(raw, "value"); + if (!headerValue) { + throw new Error(`Agent manifest field '${itemField}.value' is required`); + } + 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 }; + }); +} + +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`); + } + 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)}]`), + ); + const ownership: StateFileRestoreOwnership = { merge: "key-allowlist", userKeys }; + const requireFreshTables = readStateFileDottedKeys( + value, + "require_fresh_tables", + `${field}.require_fresh_tables`, + ); + if (requireFreshTables) ownership.requireFreshTables = requireFreshTables; + const requireFreshHeaders = readStateFileFreshHeaders(value, `${field}.require_fresh_headers`); + if (requireFreshHeaders) ownership.requireFreshHeaders = requireFreshHeaders; + return ownership; +} + export function readStateFiles(record: ManifestRecord): AgentStateFile[] | undefined { const value = record.state_files; if (value === undefined) return undefined; @@ -135,7 +340,10 @@ export function readStateFiles(record: ManifestRecord): AgentStateFile[] | undef `Agent manifest field 'state_files[${String(index)}].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/onboard/created-sandbox-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 938bd00359..c9fa6dd730 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"; @@ -224,7 +223,7 @@ describe("created DCode sandbox finalization", () => { { restoreSandboxState: (name, backup, options) => { order.push("restore"); - expect(options?.stateFileRestorePolicy).toBe(managedDcodeConfigRestorePolicy); + expect(options?.applyManagedStateFileRestore).toBe(true); return sandboxState.restoreSandboxState(name, backup, options); }, getDcodeSelectionDrift: (name, provider, model, api) => { @@ -377,10 +376,8 @@ describe("created DCode sandbox finalization", () => { }, ); - expect(restoreSandboxState).toHaveBeenCalledWith( - "custom-dcode", - "/tmp/custom-backup", - undefined, - ); + expect(restoreSandboxState).toHaveBeenCalledWith("custom-dcode", "/tmp/custom-backup", { + applyManagedStateFileRestore: false, + }); }); }); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index fa38a311ba..a4a3573b6f 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 { RestoreOptions, RestoreResult } from "../state/sandbox"; import type { SelectionDrift } from "./selection-drift"; @@ -44,13 +43,9 @@ export function finalizeCreatedSandbox( ? " Restoring workspace state from pre-upgrade backup..." : " Restoring workspace state from pre-recreate backup...", ); - const restore = deps.restoreSandboxState( - options.sandboxName, - options.restoreBackupPath, - options.validateManagedDcode - ? { stateFileRestorePolicy: managedDcodeConfigRestorePolicy } - : undefined, - ); + const restore = deps.restoreSandboxState(options.sandboxName, options.restoreBackupPath, { + applyManagedStateFileRestore: options.validateManagedDcode, + }); if (restore.success) { deps.note( ` ✓ State restored (${restore.restoredDirs.length} directories, ${restore.restoredFiles.length} files)`, diff --git a/src/lib/state/dcode-config-restore-input.test.ts b/src/lib/state/dcode-config-restore-input.test.ts 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/openclaw-config-restore-input.test.ts b/src/lib/state/openclaw-config-restore-input.test.ts index dbe6493f34..68f6fff352 100644 --- a/src/lib/state/openclaw-config-restore-input.test.ts +++ b/src/lib/state/openclaw-config-restore-input.test.ts @@ -3,44 +3,12 @@ import { describe, expect, it } from "vitest"; -import { - buildOpenClawConfigRestoreInput, - shouldMergeOpenClawConfigStateFile, -} from "./openclaw-config-restore-input"; +import { buildOpenClawConfigRestoreInput } 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 e270af10fd..b143205e3b 100644 --- a/src/lib/state/openclaw-config-restore-input.ts +++ b/src/lib/state/openclaw-config-restore-input.ts @@ -6,39 +6,6 @@ import { spawnSync } from "child_process"; import { shellQuote } from "../runner.js"; import { mergeOpenClawRestoredConfig } from "./openclaw-config-merge.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.ts b/src/lib/state/sandbox.ts index 792d6f7e16..65ee8ceb72 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -28,16 +28,13 @@ import { spawnSync } from "child_process"; import { captureSandboxSshConfigCommand } from "../adapters/openshell/client.js"; import { resolveOpenshell } from "../adapters/openshell/resolve.js"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; -import type { AgentStateFile } from "../agent/defs.js"; +import type { AgentStateFile, StateFileRestoreOwnership } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; import { isRecord, type UnknownRecord } from "../core/json-types.js"; import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; -import { - buildOpenClawConfigRestoreInputFromSandbox, - shouldMergeOpenClawConfigStateFile, -} from "./openclaw-config-restore-input.js"; +import { buildOpenClawConfigRestoreInputFromSandbox } from "./openclaw-config-restore-input.js"; import { buildRestoreCleanupCommand, buildRestoreTarArgs, @@ -47,7 +44,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 { buildKeyAllowlistMergeRestoreCommand } from "./state-file-key-merge.js"; import { runTarListing } from "./tar-listing.js"; const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); @@ -144,8 +141,7 @@ export interface RestoreResult { } export interface RestoreOptions { - /** Optional file-specific restore capability authorized by the caller. */ - stateFileRestorePolicy?: StateFileRestorePolicy; + applyManagedStateFileRestore?: boolean; } export interface TarValidationResult { @@ -874,55 +870,61 @@ export function buildStateFileRestoreCommand( return steps.join("; "); } -function buildStateFileRestoreInput( - configFile: string, - sandboxName: string, - dir: string, - spec: StateFileSpec, - backupContents: Buffer, - mergeOpenClawConfig: boolean, -): Buffer | null { - if (!mergeOpenClawConfig) return backupContents; - - const result = buildOpenClawConfigRestoreInputFromSandbox({ - backupContents, - dir, - log: _log, - specPath: spec.path, - sshArgs: sshArgs(configFile, sandboxName), - }); - if (result.ok) return result.input; - _log(`FAILED: ${result.error}`); - return null; +function loadStateFileRestoreOwnership(agentType: string): Map { + const ownership = new Map(); + let agent: ReturnType | null = null; + try { + agent = loadAgent(agentType); + } catch { + return ownership; + } + for (const file of agent.stateFiles) { + if (!file.restore) continue; + const normalized = normalizeStateFilePath(file.path); + if (normalized) ownership.set(normalized, file.restore); + } + return ownership; } function restoreStateFile( configFile: string, sandboxName: string, - agentType: string | null | undefined, dir: string, spec: StateFileSpec, backupPath: string, - mergeOpenClawConfig = false, - stateFileRestorePolicy?: StateFileRestorePolicy, + ownership: StateFileRestoreOwnership | undefined, + applyManagedStateFileRestore: boolean, ): 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, + + let command: string; + let input: Buffer | null; + if (ownership?.merge === "openclaw-config") { + command = buildStateFileRestoreCommand(dir, spec, true); + const result = buildOpenClawConfigRestoreInputFromSandbox({ backupContents, - mergeOpenClawConfig, - ); + dir, + log: _log, + specPath: spec.path, + sshArgs: sshArgs(configFile, sandboxName), + }); + if (result.ok) { + input = result.input; + } else { + _log(`FAILED: ${result.error}`); + input = null; + } + } else if (ownership?.merge === "key-allowlist" && applyManagedStateFileRestore) { + command = buildKeyAllowlistMergeRestoreCommand(dir, spec, ownership); + input = backupContents; + } else { + command = buildStateFileRestoreCommand(dir, spec, false); + input = backupContents; + } if (input === null) return false; const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { @@ -1533,17 +1535,17 @@ export function restoreSandboxState( } } + const restoreOwnership = loadStateFileRestoreOwnership(manifest.agentType); for (const spec of localFiles) { if ( restoreStateFile( configFile, sandboxName, - manifest.agentType, dir, spec, backupPath, - shouldMergeOpenClawConfigStateFile(manifest.agentType, dir, spec), - options.stateFileRestorePolicy, + restoreOwnership.get(spec.path), + options.applyManagedStateFileRestore ?? false, ) ) { restoredFiles.push(spec.path); diff --git a/src/lib/state/state-file-key-merge.test.ts b/src/lib/state/state-file-key-merge.test.ts new file mode 100644 index 0000000000..d36ada35e1 --- /dev/null +++ b/src/lib/state/state-file-key-merge.test.ts @@ -0,0 +1,322 @@ +// 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 type { StateFileRestoreOwnership } from "../agent/defs"; +import { + buildKeyAllowlistMergeRestoreCommand, + KEY_ALLOWLIST_MERGE_PYTHON, + stateFileKeyMergeSpec, +} from "./state-file-key-merge"; + +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(); + +const DCODE_OWNERSHIP: StateFileRestoreOwnership = { + 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: " }, + ], +}; + +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, + ownership: StateFileRestoreOwnership, +): { + current: string; + stageExists: boolean; + status: number | null; + stderr: string; +} { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-file-key-merge-")); + try { + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-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, + KEY_ALLOWLIST_MERGE_PYTHON, + backupPath, + currentPath, + stagedPath, + JSON.stringify(stateFileKeyMergeSpec(ownership)), + ], + { 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("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" }], + }); + }); +}); + +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(JSON.stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); + + 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, 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(JSON.stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); + + expect(result.status).toBe(0); + expect(mergedJson(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 result = runMergeScript("MALFORMED", current, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.current).toBe(current); + expect(result.stageExists).toBe(true); + 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( + JSON.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 = JSON.stringify({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + }); + + const result = runMergeScript( + JSON.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: StateFileRestoreOwnership = { + 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( + JSON.stringify({ limits: { retries: 3 } }), + generatedCurrent(fresh), + ownership, + ); + expect(within.status).toBe(0); + expect(mergedJson(within.current)).toEqual({ ...fresh, limits: { retries: 3 } }); + + const outside = runMergeScript( + JSON.stringify({ limits: { retries: 9 } }), + generatedCurrent(fresh), + ownership, + ); + expect(outside.status).toBe(0); + expect(mergedJson(outside.current)).toEqual(fresh); + }); + + it("enforces string max_length and rejects non-string values", () => { + const ownership: StateFileRestoreOwnership = { + merge: "key-allowlist", + userKeys: [{ key: "label", type: "string", maxLength: 4 }], + requireFreshHeaders: DCODE_OWNERSHIP.requireFreshHeaders, + }; + const fresh = { models: { default: "m" } }; + + const shortValue = runMergeScript( + JSON.stringify({ label: "abc" }), + generatedCurrent(fresh), + ownership, + ); + expect(mergedJson(shortValue.current)).toEqual({ ...fresh, label: "abc" }); + + const longValue = runMergeScript( + JSON.stringify({ label: "abcdef" }), + generatedCurrent(fresh), + ownership, + ); + expect(mergedJson(longValue.current)).toEqual(fresh); + + const wrongType = runMergeScript( + JSON.stringify({ label: 12 }), + generatedCurrent(fresh), + ownership, + ); + expect(mergedJson(wrongType.current)).toEqual(fresh); + }); + + 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(".nemoclaw-restore-backup.XXXXXX"); + expect(command).toContain(".nemoclaw-restore-merged.XXXXXX"); + expect(command).toContain("/opt/venv/bin/python3 -I -c"); + expect(command).toContain('"$backup_tmp" "$dst" "$staged_tmp"'); + expect(command).toContain("/sandbox/.deepagents/config.toml"); + expect(command).toContain("show_scrollbar"); + expect(KEY_ALLOWLIST_MERGE_PYTHON).toContain("os.replace(staged_path, current_path)"); + + 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.ts b/src/lib/state/state-file-key-merge.ts new file mode 100644 index 0000000000..54a1b5b6eb --- /dev/null +++ b/src/lib/state/state-file-key-merge.ts @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { StateFileRestoreOwnership, StateFileUserKeyType } from "../agent/defs.js"; +import { shellQuote } from "../runner.js"; + +export const KEY_ALLOWLIST_MERGE_PYTHON = String.raw` +import copy +import json +import os +import stat +import sys +import tomllib +import tomli_w + +MAX_CONFIG_BYTES = 16 * 1024 * 1024 + + +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} 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) + try: + text = b"".join(chunks).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, 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 + + +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 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") + if len(line) > 2048 or any(ord(char) < 32 for char in line): + fail("current config has unsafe generated header metadata") + return header_lines[: len(required_headers)] + + +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 "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 value in spec.get("values", []) + return False + + +def set_path(root, path, value): + node = root + for segment in path[:-1]: + child = node.get(segment) + if not isinstance(child, dict): + child = {} + node[segment] = child + node = child + node[path[-1]] = value + + +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 + + +def render_merged_config(merged, header_lines): + try: + rendered = tomli_w.dumps(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 write_staged_and_replace(staged_path, current_path, current_metadata, payload): + if os.path.dirname(staged_path) != os.path.dirname(current_path): + fail("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("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("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 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 config changed before atomic restore") + + os.replace(staged_path, current_path) + directory_fd = os.open(os.path.dirname(current_path), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + +def main(): + if len(sys.argv) != 5: + fail("expected backup, current, staging paths, and an ownership spec") + backup_path, current_path, staged_path, spec_raw = sys.argv[1:] + spec = load_spec(spec_raw) + _backup_text, backup, _backup_metadata = read_regular_file(backup_path, "backed-up") + current_text, current, current_metadata = read_regular_file(current_path, "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(staged_path, current_path, current_metadata, payload) + + +main() +`.trim(); + +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: StateFileRestoreOwnership): 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, + })), + }; +} + +export function buildKeyAllowlistMergeRestoreCommand( + dir: string, + spec: { path: string }, + ownership: StateFileRestoreOwnership, +): string { + const normalizedDir = dir.replace(/\/+$/, ""); + const destination = shellQuote(`${normalizedDir}/${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; }', + 'backup_tmp="$(mktemp "${parent}/.nemoclaw-restore-backup.XXXXXX")"', + 'staged_tmp="$(mktemp "${parent}/.nemoclaw-restore-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(KEY_ALLOWLIST_MERGE_PYTHON)} "$backup_tmp" "$dst" "$staged_tmp" ${mergeSpec}`, + ].join("; "); +} 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; From f5db1899f4c699d0f99c0f86ef17d86384c97246 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 8 Jul 2026 10:19:26 +0000 Subject: [PATCH 02/24] fix(state): make key-allowlist restore ownership authoritative on rebuild and snapshot restore Signed-off-by: Tinson Lai --- .../sandbox/rebuild-dcode-recovery.test.ts | 1 + .../rebuild-local-provider-recreate.test.ts | 1 + src/lib/actions/sandbox/rebuild-pipeline.ts | 2 + .../sandbox/rebuild-prepared-recovery.test.ts | 2 + .../sandbox/rebuild-restore-phase.test.ts | 15 + .../actions/sandbox/rebuild-restore-phase.ts | 6 +- .../actions/sandbox/snapshot-restore.test.ts | 883 ++++++++++++++++++ src/lib/actions/sandbox/snapshot.test.ts | 603 +----------- src/lib/actions/sandbox/snapshot.ts | 9 +- src/lib/state/sandbox.ts | 13 +- test/helpers/rebuild-flow-lifecycle-cases.ts | 1 + test/helpers/rebuild-flow-recovery-cases.ts | 1 + 12 files changed, 933 insertions(+), 604 deletions(-) create mode 100644 src/lib/actions/sandbox/snapshot-restore.test.ts diff --git a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts index 36c72dedaa..6c5685f214 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts @@ -50,6 +50,7 @@ describe("rebuildSandbox DCode flow: recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { applyManagedStateFileRestore: true }, ); }); it("replays captured custom policies during stale DCode recovery without a backup (#6195)", async () => { diff --git a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts index 5f1499c2ae..a415dc3137 100644 --- a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts +++ b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts @@ -214,6 +214,7 @@ describe("rebuild local-provider recreation", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", "/tmp/nemoclaw-rebuild-backup", + { applyManagedStateFileRestore: false }, ); }); }); diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index c982e54d72..505597011b 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -6,6 +6,7 @@ import { BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV } from "../../inference/web-searc import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../../messaging/applier/types"; import { MESSAGING_CHANNEL_CONFIG_ENV_KEYS } from "../../messaging-channel-config"; import { hydrateCredentialEnv } from "../../onboard/credential-env"; +import { usesManagedDcodeIdentity } from "../../onboard/dcode-selection-drift"; import { DOCKER_GPU_PATCH_NETWORK_ENV } from "../../onboard/docker-gpu-patch"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; import * as onboardSession from "../../state/onboard-session"; @@ -300,6 +301,7 @@ async function rebuildSandboxUnlocked( backup.backupManifest?.customPolicies?.map((entry) => ({ ...entry })) ?? preservedCustomPolicies, reconcileManagedDcodeObservability: rebuildAgent === DCODE_AGENT_NAME, + applyManagedStateFileRestore: usesManagedDcodeIdentity(rebuildAgent, fromDockerfile), log, }); await runRebuildPostRestorePhase({ diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index e17bc59bfa..7a6ad38fd8 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -47,6 +47,7 @@ describe("prepared rebuild recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { applyManagedStateFileRestore: false }, ); }); @@ -87,6 +88,7 @@ describe("prepared rebuild recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { applyManagedStateFileRestore: false }, ); }); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index 99d28c8714..dc214f17f3 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -51,6 +51,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm", "brave", "tavily", "nous-web"], customPolicies, reconcileManagedDcodeObservability: false, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -92,6 +93,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies, reconcileManagedDcodeObservability: false, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -125,6 +127,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [genuineCustomPolicy, generatedMcpPolicy], reconcileManagedDcodeObservability: false, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -154,6 +157,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm"], customPolicies: [], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -179,6 +183,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm"], customPolicies: [], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -209,6 +214,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm", "bad", "throw"], customPolicies: [], reconcileManagedDcodeObservability: false, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -230,6 +236,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -251,6 +258,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -267,6 +275,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -292,6 +301,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -324,6 +334,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -358,6 +369,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -387,6 +399,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -415,6 +428,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); @@ -442,6 +456,7 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, + applyManagedStateFileRestore: false, log: vi.fn(), }); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index c658496843..26b8afeb4b 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -20,6 +20,7 @@ export interface RebuildRestorePhaseInput { policyPresets: string[]; customPolicies: NonNullable; reconcileManagedDcodeObservability: boolean; + applyManagedStateFileRestore: boolean; log: RebuildLog; } @@ -180,6 +181,7 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild policyPresets, customPolicies, reconcileManagedDcodeObservability, + applyManagedStateFileRestore, log, } = input; let restoreSucceeded = true; @@ -187,7 +189,9 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild console.log(""); console.log(" Restoring workspace state..."); log(`Restoring from: ${backupManifest.backupPath} into sandbox: ${sandboxName}`); - const restore = sandboxState.restoreSandboxState(sandboxName, backupManifest.backupPath); + const restore = sandboxState.restoreSandboxState(sandboxName, backupManifest.backupPath, { + applyManagedStateFileRestore, + }); log( `Restore result: success=${restore.success}, restored=${restore.restoredDirs.join(",")}; files=${restore.restoredFiles.join(",")}, failed=${restore.failedDirs.join(",")}; failedFiles=${restore.failedFiles.join(",")}`, ); diff --git a/src/lib/actions/sandbox/snapshot-restore.test.ts b/src/lib/actions/sandbox/snapshot-restore.test.ts new file mode 100644 index 0000000000..f3580a8747 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-restore.test.ts @@ -0,0 +1,883 @@ +// 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 { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output"; + +type OpenshellCaptureResult = { + status: number | null; + output: string; + stdout?: string; + stderr?: string; + error?: Error; + signal?: NodeJS.Signals | null; +}; +type SandboxRecord = { + name: string; + agent?: string | null; + gatewayName?: string | null; + imageTag?: string | null; + openshellDriver?: string | null; + observabilityEnabled?: boolean; + provider?: string | null; + model?: string | null; +}; +type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime"; + +function dcodeProbeOutput(state: DcodeProbeState, extra = ""): string { + return `${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=${state}\n${extra}`; +} + +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 }; +} + +function openshellResponses( + args: string[], + responses: Record, +): OpenshellCaptureResult { + const result = responses[`${args[0] ?? ""} ${args[1] ?? ""}`] ?? { + status: 0, + output: "", + }; + return captureOpenshellStreams(args, result); +} + +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(); + }, + ), + }; +}); + +const backupSandboxStateMock = vi.fn(); +const captureOpenshellMock = vi.fn< + (args: string[], opts?: Record) => OpenshellCaptureResult +>((args) => defaultOpenshellResponses(args)); +const dockerInspectMock = vi.fn(() => ({ status: 0, stdout: "true\n" })); +const findBackupMock = vi.fn(); +const getAppliedPresetsMock = vi.fn(() => [] as string[]); +const getCustomPoliciesMock = vi.fn( + () => [] as Array<{ name: string; content: string; sourcePath?: string }>, +); +const getLatestBackupMock = vi.fn(() => null as Record | null); +const applyPresetMock = vi.fn((_sandbox: string, _preset: string) => true); +const applyPresetContentMock = vi.fn( + (_sandbox: string, _name: string, _content: string, _options?: unknown) => true, +); +const removePresetMock = vi.fn((_sandbox: string, _preset: string) => true); +const getPresetContentGatewayStateMock = vi.fn< + (_sandbox: string, _content: string, _policyKey?: string) => "match" | "absent" | "drift" | null +>(() => "absent"); +const builtinObservabilityPolicy = + "network_policies:\n observability-otlp-local:\n endpoints:\n - host: host.openshell.internal\n"; +const loadPresetForSandboxMock = vi.fn((_sandbox: string, preset: string) => + preset === "observability-otlp-local" ? builtinObservabilityPolicy : null, +); +const getSandboxMock = vi.fn<(name?: string) => SandboxRecord | null>(() => null); +const isGatewayHealthyMock = vi.fn(() => true); +const listBackupsMock = vi.fn<() => Array>>(() => []); +const parseLiveSandboxNamesMock = vi.fn(() => new Set(["alpha"])); +const registerSandboxMock = vi.fn(); +const updateSandboxMock = vi.fn(); +const restoreSandboxStateMock = vi.fn(); +const runOpenshellMock = vi.fn((args: string[]) => { + args[0] === "sandbox" && args[1] === "delete" && lifecycleMock.events.push("delete"); + return { status: 0, output: "" }; +}); +const streamSandboxCreateMock = vi.fn( + async (_command: string, _env: NodeJS.ProcessEnv, _options?: Record) => ({ + status: 0, + output: "", + forcedReady: false, + }), +); +const latestBackupFixture = { + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", +}; + +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(), +})); + +describe("runSandboxSnapshot restore", () => { + beforeEach(() => { + 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"])); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it("restores the latest snapshot into the source sandbox", async () => { + const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); + getLatestBackupMock.mockReturnValue({ + snapshotVersion: 4, + name: "stable", + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + }); + restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["user.md"], + failedDirs: [], + failedFiles: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); + 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("keeps active-timer restore, permission repair, and policy reconciliation serialized", async () => { + lifecycleMock.readTimerMarkerMock.mockReturnValue({ + pid: 4242, + sandboxName: "alpha", + snapshotPath: "/tmp/policy.yaml", + restoreAt: "2026-06-27T06:00:00.000Z", + processToken: "a".repeat(32), + }); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["github"], + }); + restoreSandboxStateMock.mockReturnValue({ + success: true, + restoredDirs: ["workspace"], + restoredFiles: ["openclaw.json"], + failedDirs: [], + failedFiles: [], + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(lifecycleMock.events).toContain("lock:restore sandbox snapshot"); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); + expect(shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); + }); + + it("hardens an active timer window before force-deleting a restore destination", async () => { + lifecycleMock.readTimerMarkerMock.mockReturnValue({ + pid: 4242, + sandboxName: "beta", + snapshotPath: "/tmp/policy.yaml", + restoreAt: "2026-06-27T06:00:00.000Z", + processToken: "b".repeat(32), + }); + 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", + }, + ); + parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + captureOpenshellMock.mockImplementation((args) => + openshellResponses(args, { + "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); + 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(shieldsMock.shieldsUpMock).toHaveBeenCalledWith("beta", { + throwOnError: true, + allowLegacyHermesProtocol: true, + }); + expect(lifecycleMock.events.indexOf("harden")).toBeLessThan( + lifecycleMock.events.indexOf("delete"), + ); + expect(lifecycleMock.events.indexOf("delete")).toBeLessThan( + lifecycleMock.events.indexOf("cleanup-shields"), + ); + expect(streamSandboxCreateMock).toHaveBeenCalled(); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); + }); + + it("blocks auto-create before deleting a destination when a gateway peer conflicts", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + 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", + })); + parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); + captureOpenshellMock.mockImplementation((args) => + openshellResponses(args, { + "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + getLatestBackupMock.mockReturnValue({ ...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(lifecycleMock.events).not.toContain("delete"); + expect(streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(registerSandboxMock).not.toHaveBeenCalled(); + }); + + it.each([ + { enabled: true, assignmentPresent: true }, + { enabled: false, assignmentPresent: false }, + ])("starts a snapshot clone with the authoritative source observability state when enabled=$enabled", async ({ + enabled, + assignmentPresent, + }) => { + let registeredClone: SandboxRecord | null = null; + registerSandboxMock.mockImplementation((entry) => (registeredClone = entry as SandboxRecord)); + vi.stubEnv("NEMOCLAW_OBSERVABILITY", "1"); + 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, + ); + captureOpenshellMock.mockImplementation((args) => + openshellResponses(args, { + "sandbox exec": { status: 0, output: dcodeProbeOutput("idle") }, + "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, + }), + ); + parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); + getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); + const [createCommandValue, createEnv] = streamSandboxCreateMock.mock.calls[0] ?? []; + const createCommand = String(createCommandValue ?? ""); + expect(createCommand.includes("'NEMOCLAW_OBSERVABILITY=1'")).toBe(assignmentPresent); + expect(createEnv?.NEMOCLAW_OBSERVABILITY).toBeUndefined(); + expect(registerSandboxMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "beta", + observabilityEnabled: enabled, + }), + ); + expect(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 }) => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: true, + policyTier: "balanced", + } as never); + getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets }); + getAppliedPresetsMock.mockReturnValue(["npm"]); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(removePresetMock).not.toHaveBeenCalled(); + }); + + it("removes historical built-in OTLP egress when observability was disabled after the snapshot", async () => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm", "observability-otlp-local"], + }); + getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); + getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + }); + + it("removes an exact unrecorded built-in OTLP policy when observability is disabled", async () => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: [], + } as never); + getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets: [] }); + getAppliedPresetsMock.mockReturnValue([]); + getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith( + "alpha", + builtinObservabilityPolicy, + ); + expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(updateSandboxMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "returns false", + configureRemoval: () => removePresetMock.mockReturnValue(false), + }, + { + label: "throws", + configureRemoval: () => + removePresetMock.mockImplementation(() => { + throw new Error("remove exploded"); + }), + }, + { + label: "claims success without removing", + configureRemoval: () => removePresetMock.mockReturnValue(true), + }, + ])("retains built-in OTLP attribution when removal $label", async ({ configureRemoval }) => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: [], + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [], + }); + getAppliedPresetsMock.mockReturnValue([]); + getPresetContentGatewayStateMock.mockReturnValue("match"); + configureRemoval(); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); + expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { + policies: ["observability-otlp-local"], + }); + expect(consoleWarn.mock.calls.flat().join("\n")).toContain( + "exact content still live after remove", + ); + }); + + 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"], + }; + getSandboxMock.mockImplementation(() => registryEntry as never); + updateSandboxMock.mockImplementation((_sandboxName, update) => { + registryEntry = { ...registryEntry, ...(update as Partial) }; + }); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [], + }); + getAppliedPresetsMock.mockReturnValue(["github", "observability-otlp-local"]); + getPresetContentGatewayStateMock.mockReturnValue("match"); + 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(removePresetMock.mock.calls.map((call) => call[1])).toEqual([ + "github", + "observability-otlp-local", + ]); + expect(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, + }) => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled, + policyTier: "balanced", + policies: recordedPolicies, + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm"], + }); + getAppliedPresetsMock.mockReturnValue(recordedPolicies); + getPresetContentGatewayStateMock.mockReturnValue(liveState); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: expectedPolicies }); + expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(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", + }; + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: [customPolicy.name], + customPolicies: [customPolicy], + }); + getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); + getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); + getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(applyPresetContentMock).toHaveBeenCalledWith( + "alpha", + customPolicy.name, + customPolicy.content, + { custom: { sourcePath: customPolicy.sourcePath } }, + ); + expect(removePresetMock).toHaveBeenCalledTimes(1); + expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); + expect(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", + }; + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: ["npm", "observability-otlp-local"], + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm", "observability-otlp-local"], + customPolicies: [customPolicy], + }); + getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); + getAppliedPresetsMock.mockReturnValue(["npm", "corp-otel", "observability-otlp-local"]); + getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => + content === customPolicy.content ? "match" : "drift", + ); + const { runSandboxSnapshot } = await import("./snapshot"); + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(applyPresetContentMock).toHaveBeenCalledWith( + "alpha", + customPolicy.name, + customPolicy.content, + { custom: { sourcePath: customPolicy.sourcePath } }, + ); + expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(removePresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); + expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: ["npm"] }); + expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(1); + expect(getPresetContentGatewayStateMock.mock.calls[0]?.[1]).toBe(customPolicy.content); + expect(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", + }; + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + policies: ["npm", "observability-otlp-local"], + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["npm", "observability-otlp-local"], + customPolicies: [customPolicy], + }); + getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); + applyPresetContentMock.mockReturnValue(false); + 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(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); + expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); + expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith( + "alpha", + 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", + }; + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: true, + policyTier: "balanced", + } as never); + getLatestBackupMock.mockReturnValue({ + ...latestBackupFixture, + policyPresets: [], + customPolicies: [], + }); + getCustomPoliciesMock.mockReturnValue([currentCustomPolicy]); + removePresetMock.mockReturnValue(false); + 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(removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name); + expect(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) => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: false, + policyTier: "balanced", + } as never); + getLatestBackupMock.mockReturnValue({ + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + policyPresets: ["observability-otlp-local"], + }); + getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); + getPresetContentGatewayStateMock.mockReturnValue(gatewayState); + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runSandboxSnapshot } = await import("./snapshot"); + + await runSandboxSnapshot("alpha", { kind: "restore" }); + + expect(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 () => { + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + observabilityEnabled: true, + policyTier: " Restricted ", + } as never); + 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(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); + }); +}); diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index aa2d0a1499..27588789cd 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -150,10 +150,6 @@ const dcodeSandboxEntry = { name: "alpha", agent: "langchain-deepagents-code", }; -const latestBackupFixture = { - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", -}; vi.mock("../../adapters/docker", () => ({ dockerCapture: vi.fn(() => ""), @@ -794,601 +790,6 @@ describe("runSandboxSnapshot", () => { expect(output).toContain("alpha snapshot restore"); }); - it("restores the latest snapshot into the source sandbox", async () => { - const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); - getLatestBackupMock.mockReturnValue({ - snapshotVersion: 4, - name: "stable", - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - }); - restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["user.md"], - failedDirs: [], - failedFiles: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(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("keeps active-timer restore, permission repair, and policy reconciliation serialized", async () => { - lifecycleMock.readTimerMarkerMock.mockReturnValue({ - pid: 4242, - sandboxName: "alpha", - snapshotPath: "/tmp/policy.yaml", - restoreAt: "2026-06-27T06:00:00.000Z", - processToken: "a".repeat(32), - }); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["github"], - }); - restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["openclaw.json"], - failedDirs: [], - failedFiles: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(lifecycleMock.events).toContain("lock:restore sandbox snapshot"); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); - expect(shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); - }); - - it("hardens an active timer window before force-deleting a restore destination", async () => { - lifecycleMock.readTimerMarkerMock.mockReturnValue({ - pid: 4242, - sandboxName: "beta", - snapshotPath: "/tmp/policy.yaml", - restoreAt: "2026-06-27T06:00:00.000Z", - processToken: "b".repeat(32), - }); - 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", - }, - ); - parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - captureOpenshellMock.mockImplementation((args) => - openshellResponses(args, { - "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); - 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(shieldsMock.shieldsUpMock).toHaveBeenCalledWith("beta", { - throwOnError: true, - allowLegacyHermesProtocol: true, - }); - expect(lifecycleMock.events.indexOf("harden")).toBeLessThan( - lifecycleMock.events.indexOf("delete"), - ); - expect(lifecycleMock.events.indexOf("delete")).toBeLessThan( - lifecycleMock.events.indexOf("cleanup-shields"), - ); - expect(streamSandboxCreateMock).toHaveBeenCalled(); - expect(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(() => {}); - 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", - })); - parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - captureOpenshellMock.mockImplementation((args) => - openshellResponses(args, { - "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - getLatestBackupMock.mockReturnValue({ ...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(lifecycleMock.events).not.toContain("delete"); - expect(streamSandboxCreateMock).not.toHaveBeenCalled(); - expect(registerSandboxMock).not.toHaveBeenCalled(); - }); - - it.each([ - { enabled: true, assignmentPresent: true }, - { enabled: false, assignmentPresent: false }, - ])("starts a snapshot clone with the authoritative source observability state when enabled=$enabled", async ({ - enabled, - assignmentPresent, - }) => { - let registeredClone: SandboxRecord | null = null; - registerSandboxMock.mockImplementation((entry) => (registeredClone = entry as SandboxRecord)); - vi.stubEnv("NEMOCLAW_OBSERVABILITY", "1"); - 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, - ); - captureOpenshellMock.mockImplementation((args) => - openshellResponses(args, { - "sandbox exec": { status: 0, output: dcodeProbeOutput("idle") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); - const [createCommandValue, createEnv] = streamSandboxCreateMock.mock.calls[0] ?? []; - const createCommand = String(createCommandValue ?? ""); - expect(createCommand.includes("'NEMOCLAW_OBSERVABILITY=1'")).toBe(assignmentPresent); - expect(createEnv?.NEMOCLAW_OBSERVABILITY).toBeUndefined(); - expect(registerSandboxMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "beta", - observabilityEnabled: enabled, - }), - ); - expect(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 }) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets }); - getAppliedPresetsMock.mockReturnValue(["npm"]); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(removePresetMock).not.toHaveBeenCalled(); - }); - - it("removes historical built-in OTLP egress when observability was disabled after the snapshot", async () => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - }); - getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); - - it("removes an exact unrecorded built-in OTLP policy when observability is disabled", async () => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: [], - } as never); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets: [] }); - getAppliedPresetsMock.mockReturnValue([]); - getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith( - "alpha", - builtinObservabilityPolicy, - ); - expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(updateSandboxMock).not.toHaveBeenCalled(); - }); - - it.each([ - { - label: "returns false", - configureRemoval: () => removePresetMock.mockReturnValue(false), - }, - { - label: "throws", - configureRemoval: () => - removePresetMock.mockImplementation(() => { - throw new Error("remove exploded"); - }), - }, - { - label: "claims success without removing", - configureRemoval: () => removePresetMock.mockReturnValue(true), - }, - ])("retains built-in OTLP attribution when removal $label", async ({ configureRemoval }) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: [], - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - getAppliedPresetsMock.mockReturnValue([]); - getPresetContentGatewayStateMock.mockReturnValue("match"); - configureRemoval(); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { - policies: ["observability-otlp-local"], - }); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain( - "exact content still live after remove", - ); - }); - - 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"], - }; - getSandboxMock.mockImplementation(() => registryEntry as never); - updateSandboxMock.mockImplementation((_sandboxName, update) => { - registryEntry = { ...registryEntry, ...(update as Partial) }; - }); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - getAppliedPresetsMock.mockReturnValue(["github", "observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValue("match"); - 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(removePresetMock.mock.calls.map((call) => call[1])).toEqual([ - "github", - "observability-otlp-local", - ]); - expect(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, - }) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled, - policyTier: "balanced", - policies: recordedPolicies, - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm"], - }); - getAppliedPresetsMock.mockReturnValue(recordedPolicies); - getPresetContentGatewayStateMock.mockReturnValue(liveState); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: expectedPolicies }); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [customPolicy.name], - customPolicies: [customPolicy], - }); - getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); - getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(removePresetMock).toHaveBeenCalledTimes(1); - expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); - expect(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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["npm", "observability-otlp-local"], - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - customPolicies: [customPolicy], - }); - getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); - getAppliedPresetsMock.mockReturnValue(["npm", "corp-otel", "observability-otlp-local"]); - getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => - content === customPolicy.content ? "match" : "drift", - ); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(removePresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: ["npm"] }); - expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(1); - expect(getPresetContentGatewayStateMock.mock.calls[0]?.[1]).toBe(customPolicy.content); - expect(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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["npm", "observability-otlp-local"], - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - customPolicies: [customPolicy], - }); - getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); - applyPresetContentMock.mockReturnValue(false); - 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(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); - expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith( - "alpha", - 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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - ...latestBackupFixture, - policyPresets: [], - customPolicies: [], - }); - getCustomPoliciesMock.mockReturnValue([currentCustomPolicy]); - removePresetMock.mockReturnValue(false); - 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(removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name); - expect(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) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["observability-otlp-local"], - }); - getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValue(gatewayState); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(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 () => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: " Restricted ", - } as never); - 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(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); - it("refuses snapshot creation before backup when the sandbox is not live", async () => { parseLiveSandboxNamesMock.mockReturnValue(new Set(["beta"])); const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -1461,7 +862,9 @@ describe("runSandboxSnapshot", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/alpha/v2"); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/alpha/v2", { + applyManagedStateFileRestore: false, + }); expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-preset"); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-custom"); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 6e79e4c38e..1dc8e06299 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -20,6 +20,7 @@ import { import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; import * as nim from "../../inference/nim"; import { listMessagingProviderSuffixes } from "../../messaging/channels"; +import { usesManagedDcodeIdentity } from "../../onboard/dcode-selection-drift"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { isDcodeAgent, @@ -993,7 +994,13 @@ async function runSnapshotRestoreUnlocked( } else { console.log(` Restoring snapshot into '${sandboxName}'...`); } - const result = sandboxState.restoreSandboxState(targetSandbox, backupPath); + const restoreTargetEntry = registry.getSandbox(targetSandbox); + const result = sandboxState.restoreSandboxState(targetSandbox, backupPath, { + applyManagedStateFileRestore: usesManagedDcodeIdentity( + restoreTargetEntry?.agent, + restoreTargetEntry?.fromDockerfile, + ), + }); if (result.success) { console.log( ` ${G}\u2713${R} Restored ${result.restoredDirs.length} directories, ${result.restoredFiles.length} files`, diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 65ee8ceb72..396fba4a0b 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -870,13 +870,15 @@ export function buildStateFileRestoreCommand( return steps.join("; "); } -function loadStateFileRestoreOwnership(agentType: string): Map { +function loadStateFileRestoreOwnership( + agentType: string, +): Map | null { const ownership = new Map(); let agent: ReturnType | null = null; try { agent = loadAgent(agentType); } catch { - return ownership; + return null; } for (const file of agent.stateFiles) { if (!file.restore) continue; @@ -1537,6 +1539,13 @@ export function restoreSandboxState( const restoreOwnership = loadStateFileRestoreOwnership(manifest.agentType); for (const spec of localFiles) { + if (restoreOwnership === null) { + _log( + `FAILED: state file restore ${spec.path}: could not load agent manifest '${manifest.agentType}' to determine restore ownership`, + ); + failedFiles.push(spec.path); + continue; + } if ( restoreStateFile( configFile, diff --git a/test/helpers/rebuild-flow-lifecycle-cases.ts b/test/helpers/rebuild-flow-lifecycle-cases.ts index b5a28742a8..4a454ea676 100644 --- a/test/helpers/rebuild-flow-lifecycle-cases.ts +++ b/test/helpers/rebuild-flow-lifecycle-cases.ts @@ -97,6 +97,7 @@ export function registerRebuildFlowLifecycleTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", "/tmp/nemoclaw-rebuild-backup", + { applyManagedStateFileRestore: false }, ); expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); diff --git a/test/helpers/rebuild-flow-recovery-cases.ts b/test/helpers/rebuild-flow-recovery-cases.ts index 512d828e34..9d21d9a8f9 100644 --- a/test/helpers/rebuild-flow-recovery-cases.ts +++ b/test/helpers/rebuild-flow-recovery-cases.ts @@ -31,6 +31,7 @@ export function registerRebuildFlowRecoveryTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, + { applyManagedStateFileRestore: false }, ); }); From 27ba5d77ba535ef82b992fa39f1d2927c96a02a0 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 8 Jul 2026 10:20:46 +0000 Subject: [PATCH 03/24] refactor(agent): extract state-file restore ownership parsing into its own module Signed-off-by: Tinson Lai --- .../langchain-deepagents-code/manifest.yaml | 1 + src/lib/agent/defs.test.ts | 207 -------------- src/lib/agent/manifest-readers.ts | 206 +------------- .../agent/state-file-restore-reader.test.ts | 236 ++++++++++++++++ src/lib/agent/state-file-restore-reader.ts | 264 ++++++++++++++++++ 5 files changed, 502 insertions(+), 412 deletions(-) create mode 100644 src/lib/agent/state-file-restore-reader.test.ts create mode 100644 src/lib/agent/state-file-restore-reader.ts diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 347c02a5a2..9d8cbad1f6 100644 --- a/agents/langchain-deepagents-code/manifest.yaml +++ b/agents/langchain-deepagents-code/manifest.yaml @@ -66,6 +66,7 @@ state_files: require_fresh_tables: - models - update + # Checked positionally: only the first N leading '#' lines (N = entries below) are validated; further leading comment lines are preserved but unchecked. require_fresh_headers: - "# Generated by NemoClaw. This file contains no provider secrets." - match: prefix diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 9073ad6383..ccc3580f86 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -525,211 +525,4 @@ describe("agent definitions", () => { expect(() => loadAgent(agentName)).toThrow(/user_managed_files\[0\].*control characters/); }); - - it("parses a declarative key-allowlist restore ownership block", () => { - 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", () => { - 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", () => { - 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", () => { - 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", () => { - 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", () => { - 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", () => { - 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", () => { - 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", () => { - 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'/, - ); - }); }); diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index fb1c26680a..b9564c2a8f 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -10,16 +10,12 @@ import type { AgentInference, AgentMcpCapability, AgentStateFile, - AgentStateFileStrategy, AgentVersionScheme, ManifestRecord, ManifestValue, - StateFileFreshHeader, - StateFileRestoreOwnership, - StateFileUserKey, - StateFileUserKeyType, StringMap, } from "./definition-types"; +import { readStateFileRestore } from "./state-file-restore-reader"; const yaml: { load(input: string): unknown } = require("js-yaml"); @@ -114,206 +110,6 @@ export function readUserManagedFiles(record: ManifestRecord): string[] | undefin }); } -const STATE_FILE_MERGE_STRATEGIES = ["key-allowlist", "openclaw-config"] as const; -const STATE_FILE_USER_KEY_TYPES: readonly StateFileUserKeyType[] = [ - "boolean", - "string", - "integer", - "number", - "enum", -]; - -function readNumber(record: ManifestRecord, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -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 readStateFileUserKey(raw: ManifestValue, field: string): StateFileUserKey { - if (!isManifestRecord(raw)) { - throw new Error(`Agent manifest field '${field}' must be an object`); - } - 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`); - } - const headerValue = readString(raw, "value"); - if (!headerValue) { - throw new Error(`Agent manifest field '${itemField}.value' is required`); - } - 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 }; - }); -} - -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`); - } - 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)}]`), - ); - const ownership: StateFileRestoreOwnership = { merge: "key-allowlist", userKeys }; - const requireFreshTables = readStateFileDottedKeys( - value, - "require_fresh_tables", - `${field}.require_fresh_tables`, - ); - if (requireFreshTables) ownership.requireFreshTables = requireFreshTables; - const requireFreshHeaders = readStateFileFreshHeaders(value, `${field}.require_fresh_headers`); - if (requireFreshHeaders) ownership.requireFreshHeaders = requireFreshHeaders; - return ownership; -} - export function readStateFiles(record: ManifestRecord): AgentStateFile[] | undefined { const value = record.state_files; if (value === undefined) return undefined; 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..5ed90b9463 --- /dev/null +++ b/src/lib/agent/state-file-restore-reader.test.ts @@ -0,0 +1,236 @@ +// 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(() => { + while (tempAgentDirs.length > 0) { + const agentDir = tempAgentDirs.pop(); + if (agentDir) { + 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'/, + ); + }); +}); 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..fe695b0527 --- /dev/null +++ b/src/lib/agent/state-file-restore-reader.ts @@ -0,0 +1,264 @@ +// 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 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"]); + +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`); + } + const headerValue = readString(raw, "value"); + if (!headerValue) { + throw new Error(`Agent manifest field '${itemField}.value' is required`); + } + 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)}]`), + ); + const ownership: StateFileRestoreOwnership = { merge: "key-allowlist", userKeys }; + const requireFreshTables = readStateFileDottedKeys( + value, + "require_fresh_tables", + `${field}.require_fresh_tables`, + ); + if (requireFreshTables) ownership.requireFreshTables = requireFreshTables; + const requireFreshHeaders = readStateFileFreshHeaders(value, `${field}.require_fresh_headers`); + if (requireFreshHeaders) ownership.requireFreshHeaders = requireFreshHeaders; + return ownership; +} From a25f9edd2fbcc2192b7c2ed67f88b32a5f884b08 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Wed, 8 Jul 2026 10:21:06 +0000 Subject: [PATCH 04/24] fix(state): reject non-finite numbers and unsafe paths in key-allowlist merge Signed-off-by: Tinson Lai --- src/lib/state/state-file-key-merge.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/state/state-file-key-merge.ts b/src/lib/state/state-file-key-merge.ts index 54a1b5b6eb..f660cdd590 100644 --- a/src/lib/state/state-file-key-merge.ts +++ b/src/lib/state/state-file-key-merge.ts @@ -7,6 +7,7 @@ import { shellQuote } from "../runner.js"; export const KEY_ALLOWLIST_MERGE_PYTHON = String.raw` import copy import json +import math import os import stat import sys @@ -120,6 +121,8 @@ def value_allowed(spec, value): 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"]: @@ -132,7 +135,7 @@ def value_allowed(spec, value): return False return True if kind == "enum": - return value in spec.get("values", []) + return any(type(value) is type(candidate) and value == candidate for candidate in spec.get("values", [])) return False @@ -270,11 +273,18 @@ export function stateFileKeyMergeSpec(ownership: StateFileRestoreOwnership): Key }; } +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: StateFileRestoreOwnership, ): string { + assertSafeStateFilePath(spec.path); const normalizedDir = dir.replace(/\/+$/, ""); const destination = shellQuote(`${normalizedDir}/${spec.path}`); const mergeSpec = shellQuote(JSON.stringify(stateFileKeyMergeSpec(ownership))); From 91b39e1db91aa9a7b74f6659386787bc7bc94e8a Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 02:28:15 +0000 Subject: [PATCH 05/24] test(agent): keep restore-reader cleanup linear for growth guardrail Signed-off-by: Tinson Lai --- src/lib/agent/state-file-restore-reader.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/agent/state-file-restore-reader.test.ts b/src/lib/agent/state-file-restore-reader.test.ts index 5ed90b9463..42df71d358 100644 --- a/src/lib/agent/state-file-restore-reader.test.ts +++ b/src/lib/agent/state-file-restore-reader.test.ts @@ -18,11 +18,8 @@ function writeTempAgentManifest(name: string, contents: string): void { } afterEach(() => { - while (tempAgentDirs.length > 0) { - const agentDir = tempAgentDirs.pop(); - if (agentDir) { - fs.rmSync(agentDir, { recursive: true, force: true }); - } + for (const agentDir of tempAgentDirs.splice(0)) { + fs.rmSync(agentDir, { recursive: true, force: true }); } }); From 2ea27e5c738f53cb632362f051532ac784de6739 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 05:13:08 +0000 Subject: [PATCH 06/24] fix(agent): fail closed on unknown fresh-header fields and non-string match Signed-off-by: Tinson Lai --- src/lib/agent/state-file-restore-reader.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/agent/state-file-restore-reader.ts b/src/lib/agent/state-file-restore-reader.ts index fe695b0527..a58eea7705 100644 --- a/src/lib/agent/state-file-restore-reader.ts +++ b/src/lib/agent/state-file-restore-reader.ts @@ -83,6 +83,7 @@ const STATE_FILE_RESTORE_FIELDS = new Set([ "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)) { @@ -199,10 +200,14 @@ function readStateFileFreshHeaders( 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`); From ae2d89d3783a3e29e995c294fe75cb625aaa2e5b Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 05:17:24 +0000 Subject: [PATCH 07/24] fix(state): sort TOML keys deterministically before serializing merged config Signed-off-by: Tinson Lai --- src/lib/state/state-file-key-merge.test.ts | 23 +++++++++++++++++++++- src/lib/state/state-file-key-merge.ts | 10 +++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/state/state-file-key-merge.test.ts b/src/lib/state/state-file-key-merge.test.ts index d36ada35e1..1f459bfcb3 100644 --- a/src/lib/state/state-file-key-merge.test.ts +++ b/src/lib/state/state-file-key-merge.test.ts @@ -41,7 +41,7 @@ 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) +tomli_w.dumps = lambda value: json.dumps(value) sys.modules["tomllib"] = tomllib sys.modules["tomli_w"] = tomli_w @@ -173,6 +173,27 @@ describe("key-allowlist state-file merge", () => { }); }); + 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(JSON.stringify(backup), current, DCODE_OWNERSHIP); + const second = runMergeScript(JSON.stringify(backup), current, DCODE_OWNERSHIP); + + expect(first.status).toBe(0); + expect(second.current).toBe(first.current); + expect(Object.keys(mergedJson(first.current))).toEqual(["models", "threads", "ui", "update"]); + }); + it("drops free-form, executable, routing, and unknown backup data", () => { const providerSecret = ["sk", "abcdefghijklmnopqrst"].join("-"); const backup = { diff --git a/src/lib/state/state-file-key-merge.ts b/src/lib/state/state-file-key-merge.ts index f660cdd590..cca1701a3e 100644 --- a/src/lib/state/state-file-key-merge.ts +++ b/src/lib/state/state-file-key-merge.ts @@ -163,9 +163,17 @@ def merge_user_keys(backup, current, user_keys): return merged +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(merged) + rendered = tomli_w.dumps(sorted_deep(merged)) except Exception: fail("merged config could not be serialized safely") if not isinstance(rendered, str): From fcf2e0a9d3ec1e6d29119e3c7bb1d7f736c2beff Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 05:24:30 +0000 Subject: [PATCH 08/24] test(sandbox): cover positive applyManagedStateFileRestore dispatch on snapshot restore Signed-off-by: Tinson Lai --- .../actions/sandbox/snapshot-restore.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/lib/actions/sandbox/snapshot-restore.test.ts b/src/lib/actions/sandbox/snapshot-restore.test.ts index 1894ef50c8..8e2693e79e 100644 --- a/src/lib/actions/sandbox/snapshot-restore.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore.test.ts @@ -15,6 +15,7 @@ type OpenshellCaptureResult = { type SandboxRecord = { name: string; agent?: string | null; + fromDockerfile?: string | null; gatewayName?: string | null; imageTag?: string | null; openshellDriver?: string | null; @@ -308,6 +309,32 @@ describe("runSandboxSnapshot restore", () => { expect(output).toContain("Restored 1 directories, 1 files"); }); + it("applies managed state file restore for a managed DCode target but not a custom-image DCode target", async () => { + getLatestBackupMock.mockReturnValue({ + snapshotVersion: 4, + name: "stable", + timestamp: "2026-06-15T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + }); + const { runSandboxSnapshot } = await import("./snapshot"); + + getSandboxMock.mockReturnValue({ name: "alpha", agent: "langchain-deepagents-code" }); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { + applyManagedStateFileRestore: true, + }); + + getSandboxMock.mockReturnValue({ + name: "alpha", + agent: "langchain-deepagents-code", + fromDockerfile: "/tmp/Dockerfile", + }); + await runSandboxSnapshot("alpha", { kind: "restore" }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); + }); + it("keeps active-timer restore, permission repair, and policy reconciliation serialized", async () => { lifecycleMock.readTimerMarkerMock.mockReturnValue({ pid: 4242, From cfab14ab8f5f3f3bb5cb45d506c2ca865a3932b9 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 05:59:07 +0000 Subject: [PATCH 09/24] fix(state): warn when agent manifest load fails during restore ownership lookup Signed-off-by: Tinson Lai --- src/lib/state/sandbox.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 11e2f671a4..241b91af92 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -933,6 +933,7 @@ function loadStateFileRestoreOwnership( try { agent = loadAgent(agentType); } catch { + console.warn(`Could not load agent manifest for restore ownership lookup: ${agentType}`); return null; } for (const file of agent.stateFiles) { From af2238f54408035a7f48e6e9955a1c0359ddde69 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 06:09:54 +0000 Subject: [PATCH 10/24] fix(state): re-verify config parent directory is not a symlink before staged write Signed-off-by: Tinson Lai --- src/lib/state/state-file-key-merge.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/state/state-file-key-merge.ts b/src/lib/state/state-file-key-merge.ts index cca1701a3e..1f13a61a42 100644 --- a/src/lib/state/state-file-key-merge.ts +++ b/src/lib/state/state-file-key-merge.ts @@ -189,8 +189,15 @@ def render_merged_config(merged, header_lines): def write_staged_and_replace(staged_path, current_path, current_metadata, payload): - if os.path.dirname(staged_path) != os.path.dirname(current_path): + parent = os.path.dirname(staged_path) + if parent != os.path.dirname(current_path): fail("config staging path must share the live config directory") + try: + parent_metadata = os.lstat(parent) + except OSError: + fail("config parent directory changed before atomic restore") + if not stat.S_ISDIR(parent_metadata.st_mode): + fail("config parent directory changed before atomic restore") flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) try: fd = os.open(staged_path, flags) From e9e4e1436ea14fab432e39247d914db0828e9e8c Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Thu, 9 Jul 2026 06:52:06 +0000 Subject: [PATCH 11/24] fix(state): open config parent via O_NOFOLLOW dir_fd for staged write and replace Signed-off-by: Tinson Lai --- src/lib/state/state-file-key-merge.test.ts | 4 +- src/lib/state/state-file-key-merge.ts | 73 +++++++++++----------- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/lib/state/state-file-key-merge.test.ts b/src/lib/state/state-file-key-merge.test.ts index 1f459bfcb3..1809f0c122 100644 --- a/src/lib/state/state-file-key-merge.test.ts +++ b/src/lib/state/state-file-key-merge.test.ts @@ -331,7 +331,9 @@ describe("key-allowlist state-file merge", () => { expect(command).toContain('"$backup_tmp" "$dst" "$staged_tmp"'); expect(command).toContain("/sandbox/.deepagents/config.toml"); expect(command).toContain("show_scrollbar"); - expect(KEY_ALLOWLIST_MERGE_PYTHON).toContain("os.replace(staged_path, current_path)"); + 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", diff --git a/src/lib/state/state-file-key-merge.ts b/src/lib/state/state-file-key-merge.ts index 1f13a61a42..8c713e7380 100644 --- a/src/lib/state/state-file-key-merge.ts +++ b/src/lib/state/state-file-key-merge.ts @@ -192,48 +192,47 @@ def write_staged_and_replace(staged_path, current_path, current_metadata, payloa parent = os.path.dirname(staged_path) if parent != os.path.dirname(current_path): fail("config staging path must share the live config directory") + staged_name = os.path.basename(staged_path) + current_name = os.path.basename(current_path) try: - parent_metadata = os.lstat(parent) + parent_fd = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)) except OSError: fail("config parent directory changed before atomic restore") - if not stat.S_ISDIR(parent_metadata.st_mode): - fail("config parent directory changed before atomic restore") - flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) - try: - fd = os.open(staged_path, flags) - except OSError: - fail("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("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 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 config changed before atomic restore") - - os.replace(staged_path, current_path) - directory_fd = os.open(os.path.dirname(current_path), os.O_RDONLY) try: - os.fsync(directory_fd) + flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(staged_name, flags, dir_fd=parent_fd) + except OSError: + fail("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("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.stat(current_name, dir_fd=parent_fd, follow_symlinks=False) + except OSError: + fail("current 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 config changed before atomic restore") + + os.replace(staged_name, current_name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd) + os.fsync(parent_fd) finally: - os.close(directory_fd) + os.close(parent_fd) def main(): From dd65850f8232694cd5f5c0b4df06dfd41f9b5709 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 9 Jul 2026 13:49:57 -0700 Subject: [PATCH 12/24] fix(state): harden key-level restore ownership Signed-off-by: Charan Jagwani --- .../langchain-deepagents-code/manifest.yaml | 14 +- docs/manage-sandboxes/backup-restore.mdx | 10 +- docs/manage-sandboxes/workspace-files.mdx | 10 +- .../sandbox/rebuild-dcode-recovery.test.ts | 2 +- .../rebuild-local-provider-recreate.test.ts | 2 +- src/lib/actions/sandbox/rebuild-pipeline.ts | 4 +- .../sandbox/rebuild-prepared-recovery.test.ts | 4 +- .../sandbox/rebuild-restore-phase.test.ts | 78 ++- .../actions/sandbox/rebuild-restore-phase.ts | 10 +- .../actions/sandbox/snapshot-restore.test.ts | 23 +- src/lib/actions/sandbox/snapshot.test.ts | 4 +- src/lib/actions/sandbox/snapshot.ts | 9 +- src/lib/agent/definition-types.ts | 17 +- src/lib/agent/defs.test.ts | 16 + src/lib/agent/defs.ts | 2 + src/lib/agent/manifest-readers.ts | 42 +- .../agent/state-file-restore-reader.test.ts | 88 ++++ src/lib/agent/state-file-restore-reader.ts | 39 +- src/lib/onboard.ts | 1 + .../created-sandbox-finalization.test.ts | 154 +++--- .../onboard/created-sandbox-finalization.ts | 3 +- ...andbox-state-file-restore-contract.test.ts | 109 ++++ src/lib/state/sandbox.ts | 196 ++++--- src/lib/state/state-file-key-merge.test.ts | 488 +++++++++++++++--- src/lib/state/state-file-key-merge.ts | 229 +++++--- test/helpers/rebuild-flow-lifecycle-cases.ts | 2 +- test/helpers/rebuild-flow-recovery-cases.ts | 4 +- test/snapshot.test.ts | 1 + 28 files changed, 1195 insertions(+), 366 deletions(-) create mode 100644 src/lib/state/sandbox-state-file-restore-contract.test.ts diff --git a/agents/langchain-deepagents-code/manifest.yaml b/agents/langchain-deepagents-code/manifest.yaml index 52eb022144..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 @@ -67,7 +66,8 @@ state_files: require_fresh_tables: - models - update - # Checked positionally: only the first N leading '#' lines (N = entries below) are validated; further leading comment lines are preserved but unchecked. + # 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 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 cdc2d5b329..2fc1ef3d66 100644 --- a/docs/manage-sandboxes/workspace-files.mdx +++ b/docs/manage-sandboxes/workspace-files.mdx @@ -189,7 +189,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. @@ -201,7 +207,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-dcode-recovery.test.ts b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts index 2316637182..76fd49f550 100644 --- a/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-dcode-recovery.test.ts @@ -50,7 +50,7 @@ describe("rebuildSandbox DCode flow: recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, - { targetAgentType: "langchain-deepagents-code", applyManagedStateFileRestore: true }, + { targetAgentType: "langchain-deepagents-code" }, ); }); it("replays captured custom policies during stale DCode recovery without a backup (#6195)", async () => { diff --git a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts index 9de85b37af..e7439fe3e3 100644 --- a/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts +++ b/src/lib/actions/sandbox/rebuild-local-provider-recreate.test.ts @@ -214,7 +214,7 @@ describe("rebuild local-provider recreation", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", "/tmp/nemoclaw-rebuild-backup", - { targetAgentType: "openclaw", applyManagedStateFileRestore: false }, + { targetAgentType: "openclaw" }, ); }); }); diff --git a/src/lib/actions/sandbox/rebuild-pipeline.ts b/src/lib/actions/sandbox/rebuild-pipeline.ts index 0334304613..c3c2a87042 100644 --- a/src/lib/actions/sandbox/rebuild-pipeline.ts +++ b/src/lib/actions/sandbox/rebuild-pipeline.ts @@ -7,7 +7,6 @@ import { BRAVE_API_KEY_ENV, TAVILY_API_KEY_ENV } from "../../inference/web-searc import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../../messaging/applier/types"; import { MESSAGING_CHANNEL_CONFIG_ENV_KEYS } from "../../messaging-channel-config"; import { hydrateCredentialEnv } from "../../onboard/credential-env"; -import { usesManagedDcodeIdentity } from "../../onboard/dcode-selection-drift"; import { DOCKER_GPU_PATCH_NETWORK_ENV } from "../../onboard/docker-gpu-patch"; import { withMcpLifecycleLock } from "../../state/mcp-lifecycle-lock"; import * as onboardSession from "../../state/onboard-session"; @@ -300,13 +299,14 @@ async function rebuildSandboxUnlocked( const restored = runRebuildRestorePhase({ sandboxName, + targetAgentType: rebuildAgent || "openclaw", + targetImageIsCustom: Boolean(fromDockerfile), backupManifest: backup.backupManifest, policyPresets: targetPolicyPresets, customPolicies: backup.backupManifest?.customPolicies?.map((entry) => ({ ...entry })) ?? preservedCustomPolicies, reconcileManagedDcodeObservability: rebuildAgent === DCODE_AGENT_NAME, - applyManagedStateFileRestore: usesManagedDcodeIdentity(rebuildAgent, fromDockerfile), log, }); await runRebuildPostRestorePhase({ diff --git a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts index fe1445a8a8..7fdffebbd4 100644 --- a/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts +++ b/src/lib/actions/sandbox/rebuild-prepared-recovery.test.ts @@ -47,7 +47,7 @@ describe("prepared rebuild recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, - { targetAgentType: "openclaw", applyManagedStateFileRestore: false }, + { targetAgentType: "openclaw" }, ); }); @@ -88,7 +88,7 @@ describe("prepared rebuild recovery", () => { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, - { targetAgentType: "openclaw", applyManagedStateFileRestore: false }, + { targetAgentType: "openclaw" }, ); }); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts index 91e4b9e1a1..af79395f55 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.test.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.test.ts @@ -46,7 +46,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [], reconcileManagedDcodeObservability: false, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log, }); @@ -59,6 +60,35 @@ describe("rebuild policy restore fidelity", () => { ); }); + 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, + }); + }); + it("replays custom web-policy names from exact content instead of same-name built-ins", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -89,13 +119,13 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm", "brave", "tavily", "nous-web"], customPolicies, reconcileManagedDcodeObservability: false, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); expect(restoreRecreatedSandboxState).toHaveBeenCalledWith("alpha", "/tmp/rebuild-backup", { targetAgentType: "openclaw", - applyManagedStateFileRestore: false, }); expect(applyPreset).toHaveBeenCalledOnce(); expect(applyPreset).toHaveBeenCalledWith("alpha", "npm"); @@ -135,7 +165,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies, reconcileManagedDcodeObservability: false, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -169,7 +200,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [genuineCustomPolicy, generatedMcpPolicy], reconcileManagedDcodeObservability: false, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -199,7 +231,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm"], customPolicies: [], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -225,7 +258,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm"], customPolicies: [], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -256,7 +290,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm", "bad", "throw"], customPolicies: [], reconcileManagedDcodeObservability: false, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -278,7 +313,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -300,7 +336,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -317,7 +354,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -343,7 +381,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -376,7 +415,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -411,7 +451,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -441,7 +482,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -470,7 +512,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); @@ -498,7 +541,8 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["observability-otlp-local"], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - applyManagedStateFileRestore: false, + targetAgentType: "openclaw", + targetImageIsCustom: false, log: vi.fn(), }); diff --git a/src/lib/actions/sandbox/rebuild-restore-phase.ts b/src/lib/actions/sandbox/rebuild-restore-phase.ts index 9a0a4d85e1..809865a508 100644 --- a/src/lib/actions/sandbox/rebuild-restore-phase.ts +++ b/src/lib/actions/sandbox/rebuild-restore-phase.ts @@ -16,11 +16,12 @@ import type { RebuildSandboxEntry } from "./rebuild-flow-helpers"; export interface RebuildRestorePhaseInput { sandboxName: string; + targetAgentType: string; + targetImageIsCustom: boolean; backupManifest: RebuildBackupManifest; policyPresets: string[]; customPolicies: NonNullable; reconcileManagedDcodeObservability: boolean; - applyManagedStateFileRestore: boolean; log: RebuildLog; } @@ -177,11 +178,12 @@ function reconcileFinalManagedObservability( export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): RebuildRestorePhaseResult { const { sandboxName, + targetAgentType, + targetImageIsCustom, backupManifest, policyPresets, customPolicies, reconcileManagedDcodeObservability, - applyManagedStateFileRestore, log, } = input; let restoreSucceeded = true; @@ -193,8 +195,8 @@ export function runRebuildRestorePhase(input: RebuildRestorePhaseInput): Rebuild sandboxName, backupManifest.backupPath, { - targetAgentType: backupManifest.agentType, - applyManagedStateFileRestore, + targetAgentType, + ...(targetImageIsCustom ? { allowCustomImageWholeStateFileRestore: true } : {}), }, ); log( diff --git a/src/lib/actions/sandbox/snapshot-restore.test.ts b/src/lib/actions/sandbox/snapshot-restore.test.ts index f4093a7cfd..5600e2c18f 100644 --- a/src/lib/actions/sandbox/snapshot-restore.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore.test.ts @@ -300,16 +300,14 @@ describe("runSandboxSnapshot restore", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { - applyManagedStateFileRestore: false, - }); + expect(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("applies managed state file restore for a managed DCode target but not a custom-image DCode target", async () => { + it("delegates managed and custom-image restore policy to the state layer", async () => { getLatestBackupMock.mockReturnValue({ snapshotVersion: 4, name: "stable", @@ -320,9 +318,7 @@ describe("runSandboxSnapshot restore", () => { getSandboxMock.mockReturnValue({ name: "alpha", agent: "langchain-deepagents-code" }); await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { - applyManagedStateFileRestore: true, - }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); getSandboxMock.mockReturnValue({ name: "alpha", @@ -330,9 +326,8 @@ describe("runSandboxSnapshot restore", () => { fromDockerfile: "/tmp/Dockerfile", }); await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { - applyManagedStateFileRestore: false, - }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + expect(restoreSandboxStateMock).toHaveBeenCalledTimes(2); }); it("keeps active-timer restore, permission repair, and policy reconciliation serialized", async () => { @@ -360,9 +355,7 @@ describe("runSandboxSnapshot restore", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); expect(lifecycleMock.events).toContain("lock:restore sandbox snapshot"); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { - applyManagedStateFileRestore: false, - }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); expect(shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); }); @@ -429,9 +422,7 @@ describe("runSandboxSnapshot restore", () => { lifecycleMock.events.indexOf("cleanup-shields"), ); expect(streamSandboxCreateMock).toHaveBeenCalled(); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha", { - applyManagedStateFileRestore: false, - }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); }); it("blocks auto-create before deleting a destination when a gateway peer conflicts", async () => { diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index 7f671f9758..182af92c98 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -852,9 +852,7 @@ describe("runSandboxSnapshot", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/alpha/v2", { - applyManagedStateFileRestore: false, - }); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/alpha/v2"); expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-preset"); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); expect(removePresetMock).toHaveBeenCalledWith("alpha", "old-custom"); diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index 40e898f352..b070e3d24b 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -20,7 +20,6 @@ import { import { withGatewayRouteMutationLock } from "../../inference/gateway-route-mutation-lock"; import * as nim from "../../inference/nim"; import { listMessagingProviderSuffixes } from "../../messaging/channels"; -import { usesManagedDcodeIdentity } from "../../onboard/dcode-selection-drift"; import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { isDcodeAgent, @@ -995,13 +994,7 @@ async function runSnapshotRestoreUnlocked( } else { console.log(` Restoring snapshot into '${sandboxName}'...`); } - const restoreTargetEntry = registry.getSandbox(targetSandbox); - const result = sandboxState.restoreSandboxState(targetSandbox, backupPath, { - applyManagedStateFileRestore: usesManagedDcodeIdentity( - restoreTargetEntry?.agent, - restoreTargetEntry?.fromDockerfile, - ), - }); + const result = sandboxState.restoreSandboxState(targetSandbox, backupPath); if (result.success) { console.log( ` ${G}\u2713${R} Restored ${result.restoredDirs.length} directories, ${result.restoredFiles.length} files`, diff --git a/src/lib/agent/definition-types.ts b/src/lib/agent/definition-types.ts index 1bc9d7efa6..4664876db1 100644 --- a/src/lib/agent/definition-types.ts +++ b/src/lib/agent/definition-types.ts @@ -43,13 +43,24 @@ export interface StateFileFreshHeader { value: string; } -export interface StateFileRestoreOwnership { - merge: StateFileRestoreMerge; - userKeys?: readonly StateFileUserKey[]; +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; diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index 4ca52a6781..f3476a3304 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -158,6 +158,22 @@ describe("agent definitions", () => { }, ]); expect(deepAgentsCode.stateFiles.map((entry) => entry.path)).not.toContain(".env"); + const configOwnership = deepAgentsCode.stateFiles[0]?.restore; + expect(configOwnership?.merge).toBe("key-allowlist"); + if (configOwnership?.merge !== "key-allowlist") { + throw new Error("Deep Agents config must declare key-allowlist restore ownership"); + } + 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); + } expect(deepAgentsCode.userManagedFiles).toEqual([".deepagents/.env", ".deepagents/.mcp.json"]); }); diff --git a/src/lib/agent/defs.ts b/src/lib/agent/defs.ts index dfa9172b0b..f7bf87ecc1 100644 --- a/src/lib/agent/defs.ts +++ b/src/lib/agent/defs.ts @@ -61,6 +61,8 @@ export type { AgentStateFileStrategy, AgentVersionScheme, StateFileFreshHeader, + StateFileKeyAllowlistRestoreOwnership, + StateFileOpenClawRestoreOwnership, StateFileRestoreMerge, StateFileRestoreOwnership, StateFileUserKey, diff --git a/src/lib/agent/manifest-readers.ts b/src/lib/agent/manifest-readers.ts index b9564c2a8f..ab5f336230 100644 --- a/src/lib/agent/manifest-readers.ts +++ b/src/lib/agent/manifest-readers.ts @@ -71,6 +71,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; @@ -118,23 +139,30 @@ 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`); } const restore = readStateFileRestore(entry, index, rawStrategy); return restore diff --git a/src/lib/agent/state-file-restore-reader.test.ts b/src/lib/agent/state-file-restore-reader.test.ts index 42df71d358..0decbc339f 100644 --- a/src/lib/agent/state-file-restore-reader.test.ts +++ b/src/lib/agent/state-file-restore-reader.test.ts @@ -230,4 +230,92 @@ describe("state file restore ownership", () => { /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 index a58eea7705..be73856d94 100644 --- a/src/lib/agent/state-file-restore-reader.ts +++ b/src/lib/agent/state-file-restore-reader.ts @@ -56,6 +56,27 @@ function assertDottedKey(key: string, field: string): void { } } +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, @@ -256,13 +277,29 @@ export function readStateFileRestore( 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) ownership.requireFreshTables = requireFreshTables; + 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 68403a1dc2..88389331c1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2970,6 +2970,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-finalization.test.ts b/src/lib/onboard/created-sandbox-finalization.test.ts index 7defe9c9e9..f470719fa5 100644 --- a/src/lib/onboard/created-sandbox-finalization.test.ts +++ b/src/lib/onboard/created-sandbox-finalization.test.ts @@ -108,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__"}) `, ); @@ -229,7 +206,7 @@ describe("created DCode sandbox finalization", () => { }), restoreRecreatedSandboxState: (name, backup, options) => { order.push("restore"); - expect(options.applyManagedStateFileRestore).toBe(true); + expect(options.allowCustomImageWholeStateFileRestore).toBeUndefined(); return sandboxState.restoreRecreatedSandboxState(name, backup, options); }, getDcodeSelectionDrift: (name, provider, model, api) => { @@ -356,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", applyManagedStateFileRestore: false }, - ); + 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; + } }); }); @@ -520,7 +499,6 @@ describe("created OpenClaw sandbox finalization", () => { expect(order).toEqual(["discover", "restore", "register"]); expect(restoreRecreatedSandboxState).toHaveBeenCalledWith("openclaw", "/tmp/openclaw-backup", { targetAgentType: "openclaw", - applyManagedStateFileRestore: false, freshOpenClawImagePluginInstalls: pluginInstalls, }); expect(register).toHaveBeenCalledWith(pluginInstalls); diff --git a/src/lib/onboard/created-sandbox-finalization.ts b/src/lib/onboard/created-sandbox-finalization.ts index 10e8a314e8..f95dc3c22e 100644 --- a/src/lib/onboard/created-sandbox-finalization.ts +++ b/src/lib/onboard/created-sandbox-finalization.ts @@ -17,6 +17,7 @@ export type CreatedSandboxFinalizationOptions = { restoreBackupPath: string | null; preUpgradeBackup: boolean; targetAgentType: string; + customImage?: boolean; discoverOpenClawImagePluginInstalls?: boolean; validateManagedDcode: boolean; provider: string; @@ -78,7 +79,7 @@ export function finalizeCreatedSandbox( options.restoreBackupPath, { targetAgentType: options.targetAgentType, - applyManagedStateFileRestore: options.validateManagedDcode, + ...(options.customImage ? { allowCustomImageWholeStateFileRestore: true } : {}), ...(freshOpenClawImagePluginInstalls !== undefined ? { freshOpenClawImagePluginInstalls } : {}), 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..bcbb7d4c16 --- /dev/null +++ b/src/lib/state/sandbox-state-file-restore-contract.test.ts @@ -0,0 +1,109 @@ +// 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("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 241b91af92..8058259630 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -154,18 +154,19 @@ export interface RestoreResult { error?: string; } -export interface RestoreOptions { - applyManagedStateFileRestore?: boolean; -} - -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[]; } @@ -925,25 +926,6 @@ export function buildStateFileRestoreCommand( return steps.join("; "); } -function loadStateFileRestoreOwnership( - agentType: string, -): Map | null { - const ownership = new Map(); - let agent: ReturnType | null = null; - try { - agent = loadAgent(agentType); - } catch { - console.warn(`Could not load agent manifest for restore ownership lookup: ${agentType}`); - return null; - } - for (const file of agent.stateFiles) { - if (!file.restore) continue; - const normalized = normalizeStateFilePath(file.path); - if (normalized) ownership.set(normalized, file.restore); - } - return ownership; -} - function restoreStateFile( configFile: string, sandboxName: string, @@ -951,7 +933,7 @@ function restoreStateFile( spec: StateFileSpec, backupPath: string, ownership: StateFileRestoreOwnership | undefined, - applyManagedStateFileRestore: boolean, + allowCustomImageWholeStateFileRestore: boolean, freshImagePluginInstalls?: readonly OpenClawImagePluginInstall[], previousImagePluginInstalls?: readonly OpenClawImagePluginInstall[], ): boolean { @@ -980,8 +962,10 @@ function restoreStateFile( _log(`FAILED: ${result.error}`); input = null; } - } else if (ownership?.merge === "key-allowlist" && applyManagedStateFileRestore) { - command = buildKeyAllowlistMergeRestoreCommand(dir, spec, ownership); + } else if (ownership?.merge === "key-allowlist") { + command = allowCustomImageWholeStateFileRestore + ? buildStateFileRestoreCommand(dir, spec, false) + : buildKeyAllowlistMergeRestoreCommand(dir, spec, ownership); input = backupContents; } else { command = buildStateFileRestoreCommand(dir, spec, false); @@ -1436,12 +1420,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( @@ -1449,27 +1443,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, { - applyManagedStateFileRestore: options.applyManagedStateFileRestore, - freshOpenClawImagePluginInstalls, + targetAgentType: options.targetAgentType, + ...(options.allowCustomImageWholeStateFileRestore + ? { allowCustomImageWholeStateFileRestore: true } + : {}), + ...(options.targetAgentType === "openclaw" && + options.freshOpenClawImagePluginInstalls === undefined + ? { discoverFreshOpenClawImagePluginInstalls: true } + : {}), + freshOpenClawImagePluginInstalls: options.freshOpenClawImagePluginInstalls, }); } @@ -1525,6 +1508,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 }; @@ -1545,7 +1618,6 @@ function restoreSandboxStateInternal( const tempSshConfig = createTempSshConfig(sshConfig, "nemoclaw-state-"); const configFile = tempSshConfig.file; - const freshOpenClawImagePluginInstalls = options.freshOpenClawImagePluginInstalls; const previousOpenClawImagePluginInstalls = freshOpenClawImagePluginInstalls !== undefined ? manifest.openclawImagePluginInstalls @@ -1709,15 +1781,9 @@ function restoreSandboxStateInternal( } } - const restoreOwnership = loadStateFileRestoreOwnership(manifest.agentType); for (const spec of localFiles) { - if (restoreOwnership === null) { - _log( - `FAILED: state file restore ${spec.path}: could not load agent manifest '${manifest.agentType}' to determine restore ownership`, - ); - failedFiles.push(spec.path); - continue; - } + const targetStateFile = targetStateFiles.get(spec.path); + if (!targetStateFile) throw new Error(`Validated target state file missing: ${spec.path}`); if ( restoreStateFile( configFile, @@ -1725,8 +1791,8 @@ function restoreSandboxStateInternal( dir, spec, backupPath, - restoreOwnership.get(spec.path), - options.applyManagedStateFileRestore ?? false, + targetStateFile.restore, + options.allowCustomImageWholeStateFileRestore === true, configFreshOpenClawImagePluginInstalls, previousOpenClawImagePluginInstalls, ) diff --git a/src/lib/state/state-file-key-merge.test.ts b/src/lib/state/state-file-key-merge.test.ts index 1809f0c122..244fa563cf 100644 --- a/src/lib/state/state-file-key-merge.test.ts +++ b/src/lib/state/state-file-key-merge.test.ts @@ -5,9 +5,11 @@ 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 { describe, expect, it } from "vitest"; -import type { StateFileRestoreOwnership } from "../agent/defs"; +import type { StateFileKeyAllowlistRestoreOwnership } from "../agent/defs"; +import { shellQuote } from "../runner"; import { buildKeyAllowlistMergeRestoreCommand, KEY_ALLOWLIST_MERGE_PYTHON, @@ -18,39 +20,138 @@ const GENERATED_HEADER = "# Generated by NemoClaw. This file contains no provide const FRESH_PROVIDER_HEADER = "# NemoClaw provider route: inference; upstream provider: compatible-endpoint; API: openai-completions."; -const PYTHON_TEST_WRAPPER = String.raw` +const PYTHON_TEST_TOMLI_WRITER_SHIM = String.raw` import json +import subprocess 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 +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 = lambda value: json.dumps(value) -sys.modules["tomllib"] = tomllib +tomli_w.dumps = dumps sys.modules["tomli_w"] = tomli_w -script = sys.argv[1] -sys.argv = [sys.argv[0], *sys.argv[2:]] +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(); -const DCODE_OWNERSHIP: StateFileRestoreOwnership = { +const INODE_SWAP_MARKER = "# replacement created during race test\n"; +const INODE_REVALIDATION_POINT = String.raw` try: + latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False)`; +const INODE_SWAP_SCRIPT = 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)`, +); + +if (INODE_SWAP_SCRIPT === KEY_ALLOWLIST_MERGE_PYTHON) { + throw new Error("Could not instrument the current-config inode revalidation point"); +} + +const IN_PLACE_MUTATION_SCRIPT = 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)`, +); + +if (IN_PLACE_MUTATION_SCRIPT === KEY_ALLOWLIST_MERGE_PYTHON) { + throw new Error("Could not instrument the 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)`; + +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)`; + const instrumented = 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)`, + ); + if (instrumented === KEY_ALLOWLIST_MERGE_PYTHON) { + throw new Error("Could not instrument the staged-config inode revalidation point"); + } + return instrumented; +} + +const DCODE_OWNERSHIP: StateFileKeyAllowlistRestoreOwnership = { merge: "key-allowlist", userKeys: [ { key: "ui.show_scrollbar", type: "boolean" }, @@ -66,45 +167,127 @@ const DCODE_OWNERSHIP: StateFileRestoreOwnership = { }; function generatedCurrent(config: unknown, providerHeader = FRESH_PROVIDER_HEADER): string { - return `${GENERATED_HEADER}\n${providerHeader}\n\n${JSON.stringify(config)}\n`; + return `${GENERATED_HEADER}\n${providerHeader}\n\n${stringify(config)}`; +} + +interface RunMergeOptions { + script?: string; } function runMergeScript( backup: string, current: string, - ownership: StateFileRestoreOwnership, + ownership: StateFileKeyAllowlistRestoreOwnership, + options: RunMergeOptions = {}, ): { current: string; - stageExists: boolean; 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 backupPath = path.join(dir, "backup.toml"); const currentPath = path.join(dir, "config.toml"); - const stagedPath = path.join(dir, ".nemoclaw-restore-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, - KEY_ALLOWLIST_MERGE_PYTHON, - backupPath, - currentPath, - stagedPath, + PYTHON_TEST_TOMLI_WRITER_SHIM, + options.script ?? KEY_ALLOWLIST_MERGE_PYTHON, + dir, + "config.toml", JSON.stringify(stateFileKeyMergeSpec(ownership)), ], - { encoding: "utf-8" }, + { 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 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-")); + let configDir = dir; + let stateFilePath = "config.toml"; + if (options.parentAncestorSymlink) { + fs.mkdirSync(path.join(dir, "real", "nested"), { recursive: true }); + fs.symlinkSync("real", path.join(dir, "alias")); + configDir = path.join(dir, "alias", "nested"); + stateFilePath = "alias/nested/config.toml"; + } + const currentPath = path.join(configDir, "config.toml"); + let currentTargetPath: string | null = null; + try { + if (options.currentLink) { + currentTargetPath = path.join(dir, "current.target"); + fs.writeFileSync(currentTargetPath, current, { mode: 0o660 }); + if (options.currentLink === "symlink") { + fs.symlinkSync(path.basename(currentTargetPath), currentPath); + } else { + fs.linkSync(currentTargetPath, currentPath); + } + } else { + fs.writeFileSync(currentPath, current, { mode: 0o660 }); + } + let command = buildKeyAllowlistMergeRestoreCommand( + dir, + { path: stateFilePath }, + DCODE_OWNERSHIP, ); + if (options.script) { + command = command.replace(shellQuote(KEY_ALLOWLIST_MERGE_PYTHON), shellQuote(options.script)); + } + command = command.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"), - stageExists: fs.existsSync(stagedPath), + currentTarget: currentTargetPath ? fs.readFileSync(currentTargetPath, "utf-8") : null, + dir, + entries: fs.readdirSync(dir), status: result.status, stderr: result.stderr, }; @@ -113,8 +296,8 @@ function runMergeScript( } } -function mergedJson(config: string): Record { - return JSON.parse(config.split("\n").slice(2).join("\n").trim()) as Record; +function mergedToml(config: string): Record { + return parse(config) as Record; } describe("stateFileKeyMergeSpec", () => { @@ -157,15 +340,16 @@ describe("key-allowlist state-file merge", () => { update: { check: false, auto_update: false }, }; - const result = runMergeScript(JSON.stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); + const result = runMergeScript(stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); expect(result.status).toBe(0); - expect(result.stageExists).toBe(false); + expect(result.stageEntries).toEqual([]); expect(result.current.split("\n").slice(0, 2)).toEqual([ GENERATED_HEADER, FRESH_PROVIDER_HEADER, ]); - expect(mergedJson(result.current)).toEqual({ + 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 }, @@ -186,12 +370,12 @@ describe("key-allowlist state-file merge", () => { }; const current = generatedCurrent(fresh); - const first = runMergeScript(JSON.stringify(backup), current, DCODE_OWNERSHIP); - const second = runMergeScript(JSON.stringify(backup), current, DCODE_OWNERSHIP); + 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(mergedJson(first.current))).toEqual(["models", "threads", "ui", "update"]); + expect(Object.keys(mergedToml(first.current))).toEqual(["models", "threads", "ui", "update"]); }); it("drops free-form, executable, routing, and unknown backup data", () => { @@ -209,10 +393,10 @@ describe("key-allowlist state-file merge", () => { update: { check: false, auto_update: false }, }; - const result = runMergeScript(JSON.stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); + const result = runMergeScript(stringify(backup), generatedCurrent(fresh), DCODE_OWNERSHIP); expect(result.status).toBe(0); - expect(mergedJson(result.current)).toEqual({ ...fresh, ui: { show_scrollbar: true } }); + 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/); }); @@ -223,20 +407,21 @@ describe("key-allowlist state-file merge", () => { update: { check: false, auto_update: false }, }); - const result = runMergeScript("MALFORMED", current, DCODE_OWNERSHIP); + 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.stageExists).toBe(true); + expect(result.stageEntries).toEqual([]); expect(result.stderr).toContain("backed-up config is not valid TOML"); - expect(result.stderr).not.toContain("MALFORMED"); + 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( - JSON.stringify({ ui: { show_scrollbar: true } }), + stringify({ ui: { show_scrollbar: true } }), missingUpdate, DCODE_OWNERSHIP, ); @@ -247,13 +432,13 @@ describe("key-allowlist state-file merge", () => { }); it("requires the declared fresh headers before replacing the current file", () => { - const withoutHeaders = JSON.stringify({ + const withoutHeaders = stringify({ models: { default: "openai:nvidia/new-model" }, update: { check: false, auto_update: false }, }); const result = runMergeScript( - JSON.stringify({ ui: { show_scrollbar: true } }), + stringify({ ui: { show_scrollbar: true } }), withoutHeaders, DCODE_OWNERSHIP, ); @@ -264,7 +449,7 @@ describe("key-allowlist state-file merge", () => { }); it("enforces integer bounds and drops out-of-range values", () => { - const ownership: StateFileRestoreOwnership = { + const ownership: StateFileKeyAllowlistRestoreOwnership = { merge: "key-allowlist", userKeys: [{ key: "limits.retries", type: "integer", min: 0, max: 5 }], requireFreshHeaders: DCODE_OWNERSHIP.requireFreshHeaders, @@ -272,24 +457,24 @@ describe("key-allowlist state-file merge", () => { const fresh = { models: { default: "m" } }; const within = runMergeScript( - JSON.stringify({ limits: { retries: 3 } }), + stringify({ limits: { retries: 3 } }), generatedCurrent(fresh), ownership, ); expect(within.status).toBe(0); - expect(mergedJson(within.current)).toEqual({ ...fresh, limits: { retries: 3 } }); + expect(mergedToml(within.current)).toEqual({ ...fresh, limits: { retries: 3 } }); const outside = runMergeScript( - JSON.stringify({ limits: { retries: 9 } }), + stringify({ limits: { retries: 9 } }), generatedCurrent(fresh), ownership, ); expect(outside.status).toBe(0); - expect(mergedJson(outside.current)).toEqual(fresh); + expect(mergedToml(outside.current)).toEqual(fresh); }); it("enforces string max_length and rejects non-string values", () => { - const ownership: StateFileRestoreOwnership = { + const ownership: StateFileKeyAllowlistRestoreOwnership = { merge: "key-allowlist", userKeys: [{ key: "label", type: "string", maxLength: 4 }], requireFreshHeaders: DCODE_OWNERSHIP.requireFreshHeaders, @@ -297,25 +482,188 @@ describe("key-allowlist state-file merge", () => { const fresh = { models: { default: "m" } }; const shortValue = runMergeScript( - JSON.stringify({ label: "abc" }), + stringify({ label: "abc" }), generatedCurrent(fresh), ownership, ); - expect(mergedJson(shortValue.current)).toEqual({ ...fresh, label: "abc" }); + expect(mergedToml(shortValue.current)).toEqual({ ...fresh, label: "abc" }); const longValue = runMergeScript( - JSON.stringify({ label: "abcdef" }), + stringify({ label: "abcdef" }), generatedCurrent(fresh), ownership, ); - expect(mergedJson(longValue.current)).toEqual(fresh); + expect(mergedToml(longValue.current)).toEqual(fresh); - const wrongType = runMergeScript( - JSON.stringify({ label: 12 }), + 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(mergedJson(wrongType.current)).toEqual(fresh); + + 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"); + }); + + 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", () => { @@ -325,10 +673,10 @@ describe("key-allowlist state-file merge", () => { DCODE_OWNERSHIP, ); - expect(command).toContain(".nemoclaw-restore-backup.XXXXXX"); - expect(command).toContain(".nemoclaw-restore-merged.XXXXXX"); expect(command).toContain("/opt/venv/bin/python3 -I -c"); - expect(command).toContain('"$backup_tmp" "$dst" "$staged_tmp"'); + 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( diff --git a/src/lib/state/state-file-key-merge.ts b/src/lib/state/state-file-key-merge.ts index 8c713e7380..436452caaf 100644 --- a/src/lib/state/state-file-key-merge.ts +++ b/src/lib/state/state-file-key-merge.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { StateFileRestoreOwnership, StateFileUserKeyType } from "../agent/defs.js"; +import type { StateFileKeyAllowlistRestoreOwnership, StateFileUserKeyType } from "../agent/defs.js"; import { shellQuote } from "../runner.js"; export const KEY_ALLOWLIST_MERGE_PYTHON = String.raw` @@ -9,6 +9,7 @@ import copy import json import math import os +import secrets import stat import sys import tomllib @@ -21,10 +22,55 @@ def fail(message): raise SystemExit(message) -def read_regular_file(path, label): +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(path, flags) + fd = os.open(name, flags, dir_fd=parent_fd) except OSError: fail(f"{label} config is missing or unsafe") try: @@ -45,16 +91,7 @@ def read_regular_file(path, label): chunks.append(chunk) finally: os.close(fd) - try: - text = b"".join(chunks).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") + text, parsed = parse_config_payload(b"".join(chunks), label) return text, parsed, metadata @@ -76,6 +113,9 @@ def preserved_headers(text, required_headers): 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") @@ -86,9 +126,7 @@ def preserved_headers(text, required_headers): fail("current config generated header is missing a required prefix") elif line != value: fail("current config generated header does not match") - if len(line) > 2048 or any(ord(char) < 32 for char in line): - fail("current config has unsafe generated header metadata") - return header_lines[: len(required_headers)] + return header_lines def resolve(node, path): @@ -143,11 +181,14 @@ def set_path(root, path, value): node = root for segment in path[:-1]: child = node.get(segment) - if not isinstance(child, dict): + 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): @@ -188,65 +229,126 @@ def render_merged_config(merged, header_lines): return payload -def write_staged_and_replace(staged_path, current_path, current_metadata, payload): - parent = os.path.dirname(staged_path) - if parent != os.path.dirname(current_path): - fail("config staging path must share the live config directory") - staged_name = os.path.basename(staged_path) - current_name = os.path.basename(current_path) +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: - parent_fd = os.open(parent, os.O_RDONLY | os.O_DIRECTORY | getattr(os, "O_NOFOLLOW", 0)) + latest = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False) except OSError: - fail("config parent directory changed before atomic restore") + return + if (latest.st_dev, latest.st_ino) != (staged_metadata.st_dev, staged_metadata.st_ino): + return try: - flags = os.O_WRONLY | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) - try: - fd = os.open(staged_name, flags, dir_fd=parent_fd) - except OSError: - fail("config staging file is missing or unsafe") + 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: - staged_metadata = os.fstat(fd) - if not stat.S_ISREG(staged_metadata.st_mode) or staged_metadata.st_nlink != 1: - fail("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) + written += os.write(staged_fd, payload[written:]) + os.fchmod(staged_fd, 0o660) + os.fsync(staged_fd) + staged_metadata = os.fstat(staged_fd) finally: - os.close(fd) + if staged_metadata is None and staged_fd >= 0: + staged_metadata = os.fstat(staged_fd) try: - latest = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False) + latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False) except OSError: fail("current 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, - ): + 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: - os.close(parent_fd) + if staged_fd >= 0: + os.close(staged_fd) + if not installed and staged_name: + unlink_staged_if_owned(parent_fd, staged_name, staged_metadata) def main(): - if len(sys.argv) != 5: - fail("expected backup, current, staging paths, and an ownership spec") - backup_path, current_path, staged_path, spec_raw = sys.argv[1:] + 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, _backup_metadata = read_regular_file(backup_path, "backed-up") - current_text, current, current_metadata = read_regular_file(current_path, "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(staged_path, current_path, current_metadata, payload) + _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() @@ -267,7 +369,9 @@ export interface KeyAllowlistMergeSpec { require_fresh_headers: { match: "exact" | "prefix"; value: string }[]; } -export function stateFileKeyMergeSpec(ownership: StateFileRestoreOwnership): KeyAllowlistMergeSpec { +export function stateFileKeyMergeSpec( + ownership: StateFileKeyAllowlistRestoreOwnership, +): KeyAllowlistMergeSpec { return { user_keys: (ownership.userKeys ?? []).map((key) => { const spec: PythonUserKey = { path: key.key.split("."), type: key.type }; @@ -296,22 +400,19 @@ function assertSafeStateFilePath(path: string): void { export function buildKeyAllowlistMergeRestoreCommand( dir: string, spec: { path: string }, - ownership: StateFileRestoreOwnership, + 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; }', - 'backup_tmp="$(mktemp "${parent}/.nemoclaw-restore-backup.XXXXXX")"', - 'staged_tmp="$(mktemp "${parent}/.nemoclaw-restore-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(KEY_ALLOWLIST_MERGE_PYTHON)} "$backup_tmp" "$dst" "$staged_tmp" ${mergeSpec}`, + `/opt/venv/bin/python3 -I -c ${shellQuote(KEY_ALLOWLIST_MERGE_PYTHON)} ${baseDir} ${relativePath} ${mergeSpec}`, ].join("; "); } diff --git a/test/helpers/rebuild-flow-lifecycle-cases.ts b/test/helpers/rebuild-flow-lifecycle-cases.ts index b3bd6fcb6d..8a0c57c2c5 100644 --- a/test/helpers/rebuild-flow-lifecycle-cases.ts +++ b/test/helpers/rebuild-flow-lifecycle-cases.ts @@ -98,7 +98,7 @@ export function registerRebuildFlowLifecycleTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", "/tmp/nemoclaw-rebuild-backup", - { targetAgentType: "openclaw", applyManagedStateFileRestore: false }, + { targetAgentType: "openclaw" }, ); expect(harness.restoreMcpBridgesAfterRebuildSpy).toHaveBeenCalledWith("alpha", [mcpEntry]); expect(harness.removeSandboxRegistryEntryWithReceiptSpy).not.toHaveBeenCalled(); diff --git a/test/helpers/rebuild-flow-recovery-cases.ts b/test/helpers/rebuild-flow-recovery-cases.ts index 0b7ce68f62..c1b32bb194 100644 --- a/test/helpers/rebuild-flow-recovery-cases.ts +++ b/test/helpers/rebuild-flow-recovery-cases.ts @@ -33,7 +33,7 @@ export function registerRebuildFlowRecoveryTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, - { targetAgentType: "openclaw", applyManagedStateFileRestore: false }, + { targetAgentType: "openclaw" }, ); }); @@ -69,7 +69,7 @@ export function registerRebuildFlowRecoveryTests(): void { expect(harness.restoreSandboxStateSpy).toHaveBeenCalledWith( "alpha", recoveryManifest.backupPath, - { targetAgentType: "openclaw", applyManagedStateFileRestore: false }, + { targetAgentType: "openclaw" }, ); }); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index cacddad140..fd20f865c0 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)); From f9e4537f1c61fd0d6863ca6d2b67816eacf03c08 Mon Sep 17 00:00:00 2001 From: Charan Jagwani Date: Thu, 9 Jul 2026 13:54:22 -0700 Subject: [PATCH 13/24] chore(onboard): keep entrypoint net-neutral Signed-off-by: Charan Jagwani --- src/lib/onboard.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 88389331c1..5b401eb0d1 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2962,7 +2962,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( { From 68415a372c445a4454bd729cf87ff365a59f99f1 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 10 Jul 2026 05:30:50 +0000 Subject: [PATCH 14/24] test(state): add file-safety and real TOML coverage for key-allowlist merge Signed-off-by: Tinson Lai --- .github/workflows/pr.yaml | 3 + src/lib/state/state-file-key-merge.test.ts | 293 +++++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 9e7c9599bc..8d3848e119 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -222,6 +222,9 @@ jobs: .github/actions/ci-installer-integration sparse-checkout-cone-mode: false + - name: Install tomli-w for real-TOML state-file merge tests + run: python3 -m pip install --user tomli-w + - name: Run CLI coverage shard uses: ./.trusted-ci-actions/.github/actions/ci-cli-coverage-shard with: diff --git a/src/lib/state/state-file-key-merge.test.ts b/src/lib/state/state-file-key-merge.test.ts index 1809f0c122..4f4d060d44 100644 --- a/src/lib/state/state-file-key-merge.test.ts +++ b/src/lib/state/state-file-key-merge.test.ts @@ -50,6 +50,55 @@ sys.argv = [sys.argv[0], *sys.argv[2:]] exec(script, {"__name__": "__main__"}) `.trim(); +const PYTHON_TOCTOU_WRAPPER = String.raw` +import json +import os +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() + 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) +sys.modules["tomllib"] = tomllib +sys.modules["tomli_w"] = tomli_w + +script = sys.argv[1] +backup_path, current_path, staged_path, spec_raw, swap_path = sys.argv[2:7] + +driven_script = script.replace("\nmain()", "\npass") +assert driven_script != script, "expected to find the trailing main() call to detach" +namespace = {"__name__": "toctou_harness"} +exec(driven_script, namespace) + +spec = namespace["load_spec"](spec_raw) +_backup_text, backup, _backup_metadata = namespace["read_regular_file"](backup_path, "backed-up") +current_text, current, current_metadata = namespace["read_regular_file"](current_path, "current") +header_lines = namespace["preserved_headers"](current_text, spec.get("require_fresh_headers", [])) +namespace["assert_fresh_tables"](current, spec.get("require_fresh_tables", [])) +merged = namespace["merge_user_keys"](backup, current, spec.get("user_keys", [])) +payload = namespace["render_merged_config"](merged, header_lines) + +# Simulate an attacker swapping the live config for a different inode after it +# was read and validated, but before the staged replace commits. +os.replace(swap_path, current_path) + +namespace["write_staged_and_replace"](staged_path, current_path, current_metadata, payload) +`.trim(); + const DCODE_OWNERSHIP: StateFileRestoreOwnership = { merge: "key-allowlist", userKeys: [ @@ -117,6 +166,38 @@ function mergedJson(config: string): Record { return JSON.parse(config.split("\n").slice(2).join("\n").trim()) as Record; } +function withTempDir(fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-file-key-merge-")); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function spawnMergeScript( + backupPath: string, + currentPath: string, + stagedPath: string, + ownership: StateFileRestoreOwnership, +): { status: number | null; stderr: string } { + const result = spawnSync( + "python3", + [ + "-I", + "-c", + PYTHON_TEST_WRAPPER, + KEY_ALLOWLIST_MERGE_PYTHON, + backupPath, + currentPath, + stagedPath, + JSON.stringify(stateFileKeyMergeSpec(ownership)), + ], + { encoding: "utf-8" }, + ); + return { status: result.status, stderr: result.stderr }; +} + describe("stateFileKeyMergeSpec", () => { it("maps declarative ownership to the python merge spec", () => { expect( @@ -342,4 +423,216 @@ describe("key-allowlist state-file merge", () => { ); expect(custom).toContain("/sandbox/.custom/config.toml"); }); + + it("rejects a symlinked current config instead of following it", () => { + withTempDir((dir) => { + const backupPath = path.join(dir, "backup.toml"); + const realConfigPath = path.join(dir, "real-config.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-merged.test"); + const realContent = generatedCurrent({ models: { default: "m" } }); + fs.writeFileSync(backupPath, JSON.stringify({ ui: { show_scrollbar: true } }), { + mode: 0o600, + }); + fs.writeFileSync(realConfigPath, realContent, { mode: 0o660 }); + fs.symlinkSync(realConfigPath, currentPath); + fs.writeFileSync(stagedPath, "", { mode: 0o600 }); + + const result = spawnMergeScript(backupPath, currentPath, stagedPath, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("current config is missing or unsafe"); + expect(fs.readFileSync(realConfigPath, "utf-8")).toBe(realContent); + }); + }); + + it("rejects a symlinked backup config instead of following it", () => { + withTempDir((dir) => { + const realBackupPath = path.join(dir, "real-backup.toml"); + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-merged.test"); + fs.writeFileSync(realBackupPath, JSON.stringify({ ui: { show_scrollbar: true } }), { + mode: 0o600, + }); + fs.symlinkSync(realBackupPath, backupPath); + fs.writeFileSync(currentPath, generatedCurrent({ models: { default: "m" } }), { + mode: 0o660, + }); + fs.writeFileSync(stagedPath, "", { mode: 0o600 }); + + const result = spawnMergeScript(backupPath, currentPath, stagedPath, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("backed-up config is missing or unsafe"); + }); + }); + + it("rejects a hard-linked current config", () => { + withTempDir((dir) => { + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const extraLinkPath = path.join(dir, "config-alias.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-merged.test"); + const currentContent = generatedCurrent({ models: { default: "m" } }); + fs.writeFileSync(backupPath, JSON.stringify({ ui: { show_scrollbar: true } }), { + mode: 0o600, + }); + fs.writeFileSync(currentPath, currentContent, { mode: 0o660 }); + fs.linkSync(currentPath, extraLinkPath); + fs.writeFileSync(stagedPath, "", { mode: 0o600 }); + + const result = spawnMergeScript(backupPath, currentPath, stagedPath, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("current config is not a single regular file"); + expect(fs.readFileSync(currentPath, "utf-8")).toBe(currentContent); + }); + }); + + it("rejects a symlinked staging file instead of writing through it", () => { + withTempDir((dir) => { + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-merged.test"); + const stagedEscapeTarget = path.join(dir, "escape-target.toml"); + fs.writeFileSync(backupPath, JSON.stringify({ ui: { show_scrollbar: true } }), { + mode: 0o600, + }); + fs.writeFileSync( + currentPath, + generatedCurrent({ + models: { default: "m" }, + update: { check: false, auto_update: false }, + }), + { mode: 0o660 }, + ); + fs.writeFileSync(stagedEscapeTarget, "", { mode: 0o600 }); + fs.symlinkSync(stagedEscapeTarget, stagedPath); + + const result = spawnMergeScript(backupPath, currentPath, stagedPath, DCODE_OWNERSHIP); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("config staging file is missing or unsafe"); + expect(fs.readFileSync(stagedEscapeTarget, "utf-8")).toBe(""); + }); + }); + + it("rejects the staged replace when the current config's inode changes after it was read (TOCTOU)", () => { + withTempDir((dir) => { + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-merged.test"); + const swapPath = path.join(dir, "attacker-config.toml"); + fs.writeFileSync(backupPath, JSON.stringify({ ui: { show_scrollbar: true } }), { + mode: 0o600, + }); + fs.writeFileSync( + currentPath, + generatedCurrent({ + models: { default: "m" }, + update: { check: false, auto_update: false }, + }), + { mode: 0o660 }, + ); + fs.writeFileSync(stagedPath, "", { mode: 0o600 }); + fs.writeFileSync(swapPath, "attacker-controlled", { mode: 0o660 }); + + const result = spawnSync( + "python3", + [ + "-I", + "-c", + PYTHON_TOCTOU_WRAPPER, + KEY_ALLOWLIST_MERGE_PYTHON, + backupPath, + currentPath, + stagedPath, + JSON.stringify(stateFileKeyMergeSpec(DCODE_OWNERSHIP)), + swapPath, + ], + { encoding: "utf-8" }, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("current config changed before atomic restore"); + expect(fs.readFileSync(currentPath, "utf-8")).toBe("attacker-controlled"); + }); + }); + + it("round-trips real TOML syntax through the real tomllib/tomli_w parser and serializer", () => { + withTempDir((dir) => { + const backupPath = path.join(dir, "backup.toml"); + const currentPath = path.join(dir, "config.toml"); + const stagedPath = path.join(dir, ".nemoclaw-restore-merged.test"); + const backupToml = [ + "[ui]", + "show_scrollbar = true", + "show_url_open_toast = false", + "", + "[threads]", + "relative_time = false", + 'sort_order = "created_at"', + "", + "[servers.attacker.nested]", + 'value = "should not survive key-allowlist merge"', + "", + ].join("\n"); + const currentToml = [ + GENERATED_HEADER, + FRESH_PROVIDER_HEADER, + "", + "[models]", + 'default = "openai:nvidia/new-model"', + "", + "[update]", + "check = false", + "auto_update = false", + "", + ].join("\n"); + fs.writeFileSync(backupPath, backupToml, { mode: 0o600 }); + fs.writeFileSync(currentPath, currentToml, { mode: 0o660 }); + fs.writeFileSync(stagedPath, "", { mode: 0o600 }); + + const result = spawnSync( + "python3", + // No -I here (unlike the production invocation): isolated mode drops the + // user site-packages directory, which is where the CI-installed real + // tomli_w lives. This test targets parser/serializer fidelity, not the + // isolation flag, which the other tests in this file already cover. + [ + "-c", + KEY_ALLOWLIST_MERGE_PYTHON, + backupPath, + currentPath, + stagedPath, + JSON.stringify(stateFileKeyMergeSpec(DCODE_OWNERSHIP)), + ], + { encoding: "utf-8" }, + ); + + expect(result.status).toBe(0); + const merged = fs.readFileSync(currentPath, "utf-8"); + expect(merged.split("\n").slice(0, 2)).toEqual([GENERATED_HEADER, FRESH_PROVIDER_HEADER]); + expect(merged).not.toContain("servers"); + expect(merged).not.toContain("attacker"); + + const reparsed = spawnSync( + "python3", + [ + "-I", + "-c", + "import json, sys, tomllib; print(json.dumps(tomllib.loads(sys.stdin.read())))", + ], + { input: merged, encoding: "utf-8" }, + ); + expect(reparsed.status).toBe(0); + expect(JSON.parse(reparsed.stdout)).toEqual({ + models: { default: "openai:nvidia/new-model" }, + update: { check: false, auto_update: false }, + ui: { show_scrollbar: true, show_url_open_toast: false }, + threads: { relative_time: false, sort_order: "created_at" }, + }); + }); + }); }); From 43cfaeb5874dc11f1c941b1298767f1fbdc4db38 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 10 Jul 2026 05:31:34 +0000 Subject: [PATCH 15/24] fix(test): repair sandbox snapshot test assertions broken by main merge Signed-off-by: Tinson Lai --- .../sandbox/snapshot-auto-create-failure.test.ts | 5 ++++- src/lib/actions/sandbox/snapshot-restore.test.ts | 8 +++++--- src/lib/actions/sandbox/snapshot.test.ts | 12 +++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) 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.test.ts b/src/lib/actions/sandbox/snapshot-restore.test.ts index 8e2693e79e..2014e04f1b 100644 --- a/src/lib/actions/sandbox/snapshot-restore.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore.test.ts @@ -503,9 +503,11 @@ describe("runSandboxSnapshot restore", () => { getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); const { runSandboxSnapshot } = await import("./snapshot"); await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); - const [createCommandValue, createEnv] = streamSandboxCreateMock.mock.calls[0] ?? []; - const createCommand = String(createCommandValue ?? ""); - expect(createCommand).toContain(`'NEMOCLAW_OBSERVABILITY=${expectedValue}'`); + const createCall = 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(registerSandboxMock).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/lib/actions/sandbox/snapshot.test.ts b/src/lib/actions/sandbox/snapshot.test.ts index 820d3351e6..f9088a8932 100644 --- a/src/lib/actions/sandbox/snapshot.test.ts +++ b/src/lib/actions/sandbox/snapshot.test.ts @@ -803,7 +803,9 @@ describe("runSandboxSnapshot", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); const output = consoleLog.mock.calls.flat().join("\n"); expect(output).toContain("Using latest snapshot v4 name=stable"); expect(output).toContain("Restoring snapshot into 'alpha'"); @@ -835,7 +837,9 @@ describe("runSandboxSnapshot", () => { await runSandboxSnapshot("alpha", { kind: "restore" }); expect(lifecycleMock.events).toContain("lock:restore sandbox snapshot"); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); expect(shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); }); @@ -902,7 +906,9 @@ describe("runSandboxSnapshot", () => { lifecycleMock.events.indexOf("cleanup-shields"), ); expect(streamSandboxCreateMock).toHaveBeenCalled(); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha"); + expect(restoreSandboxStateMock).toHaveBeenCalledWith("beta", "/tmp/backup-alpha", { + applyManagedStateFileRestore: false, + }); }); it("blocks auto-create before deleting a destination when a gateway peer conflicts", async () => { From 06ed085bb8ca8a9836d1577e7dc598b4fd96425b Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 10 Jul 2026 05:44:48 +0000 Subject: [PATCH 16/24] fix(test): type streamSandboxCreateMock to the current 4-arg signature Signed-off-by: Tinson Lai --- src/lib/actions/sandbox/snapshot-restore.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/actions/sandbox/snapshot-restore.test.ts b/src/lib/actions/sandbox/snapshot-restore.test.ts index 2014e04f1b..f4093a7cfd 100644 --- a/src/lib/actions/sandbox/snapshot-restore.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output"; +import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types"; type OpenshellCaptureResult = { status: number | null; @@ -132,13 +133,12 @@ const runOpenshellMock = vi.fn((args: string[]) => { args[0] === "sandbox" && args[1] === "delete" && lifecycleMock.events.push("delete"); return { status: 0, output: "" }; }); -const streamSandboxCreateMock = vi.fn( - async (_command: string, _env: NodeJS.ProcessEnv, _options?: Record) => ({ - status: 0, - output: "", - forcedReady: false, - }), -); +const streamSandboxCreateMock = vi.fn(async () => ({ + status: 0, + output: "", + sawProgress: false, + forcedReady: false, +})); const latestBackupFixture = { timestamp: "2026-06-15T00:00:00.000Z", backupPath: "/tmp/backup-alpha", From 88f53cb4d231ee44a658b2846fbe57ab62674fa5 Mon Sep 17 00:00:00 2001 From: Tinson Lai Date: Fri, 10 Jul 2026 08:41:27 +0000 Subject: [PATCH 17/24] fix(test): restructure conditional test setup to satisfy growth guardrail Signed-off-by: Tinson Lai --- src/lib/agent/defs.test.ts | 14 ++- src/lib/state/state-file-key-merge.test.ts | 111 ++++++++++++--------- 2 files changed, 76 insertions(+), 49 deletions(-) diff --git a/src/lib/agent/defs.test.ts b/src/lib/agent/defs.test.ts index f3476a3304..abd72eaf07 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -13,6 +13,7 @@ import { resolveAgentName, resolveAgentNameAlias, } from "./defs"; +import type { StateFileKeyAllowlistRestoreOwnership, StateFileRestoreOwnership } from "./defs"; const tempAgentDirs: string[] = []; @@ -23,6 +24,15 @@ function writeTempAgentManifest(name: string, contents: string): void { fs.writeFileSync(path.join(agentDir, "manifest.yaml"), contents); } +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"); + })(); +} + afterEach(() => { vi.restoreAllMocks(); delete process.env.NEMOCLAW_AGENT; @@ -160,9 +170,7 @@ describe("agent definitions", () => { expect(deepAgentsCode.stateFiles.map((entry) => entry.path)).not.toContain(".env"); const configOwnership = deepAgentsCode.stateFiles[0]?.restore; expect(configOwnership?.merge).toBe("key-allowlist"); - if (configOwnership?.merge !== "key-allowlist") { - throw new Error("Deep Agents config must declare key-allowlist restore ownership"); - } + assertKeyAllowlistOwnership(configOwnership); const userOwnedKeys = configOwnership.userKeys.map((entry) => entry.key); expect(userOwnedKeys).toEqual([ "ui.show_scrollbar", diff --git a/src/lib/state/state-file-key-merge.test.ts b/src/lib/state/state-file-key-merge.test.ts index 244fa563cf..5b8c882551 100644 --- a/src/lib/state/state-file-key-merge.test.ts +++ b/src/lib/state/state-file-key-merge.test.ts @@ -57,11 +57,20 @@ exec(script, {"__name__": "__main__"}) `.trim(); 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)`; -const INODE_SWAP_SCRIPT = KEY_ALLOWLIST_MERGE_PYTHON.replace( - INODE_REVALIDATION_POINT, - String.raw` swapped_name = current_name + ".pre-swap" +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, @@ -77,15 +86,14 @@ const INODE_SWAP_SCRIPT = KEY_ALLOWLIST_MERGE_PYTHON.replace( try: latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False)`, + ), + "current-config inode revalidation point", ); -if (INODE_SWAP_SCRIPT === KEY_ALLOWLIST_MERGE_PYTHON) { - throw new Error("Could not instrument the current-config inode revalidation point"); -} - -const IN_PLACE_MUTATION_SCRIPT = KEY_ALLOWLIST_MERGE_PYTHON.replace( - INODE_REVALIDATION_POINT, - String.raw` mutation_fd = os.open( +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, @@ -99,12 +107,10 @@ const IN_PLACE_MUTATION_SCRIPT = KEY_ALLOWLIST_MERGE_PYTHON.replace( try: latest_current = os.stat(current_name, dir_fd=parent_fd, follow_symlinks=False)`, + ), + "current-config version revalidation point", ); -if (IN_PLACE_MUTATION_SCRIPT === KEY_ALLOWLIST_MERGE_PYTHON) { - throw new Error("Could not instrument the 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)`; @@ -125,9 +131,10 @@ function stageSwapScript(kind: "hardlink" | "regular" | "symlink"): string { os.fsync(replacement_fd) finally: os.close(replacement_fd)`; - const instrumented = KEY_ALLOWLIST_MERGE_PYTHON.replace( - STAGE_REVALIDATION_POINT, - String.raw` attack_name = ".nemoclaw-stage-attack-target" + 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, @@ -144,11 +151,9 @@ function stageSwapScript(kind: "hardlink" | "regular" | "symlink"): string { try: latest_staged = os.stat(staged_name, dir_fd=parent_fd, follow_symlinks=False)`, + ), + "staged-config inode revalidation point", ); - if (instrumented === KEY_ALLOWLIST_MERGE_PYTHON) { - throw new Error("Could not instrument the staged-config inode revalidation point"); - } - return instrumented; } const DCODE_OWNERSHIP: StateFileKeyAllowlistRestoreOwnership = { @@ -226,6 +231,34 @@ interface ProductionRunOptions { 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; +} + function runProductionCommand( backup: string, current: string, @@ -241,37 +274,23 @@ function runProductionCommand( stderr: string; } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-state-file-command-")); - let configDir = dir; - let stateFilePath = "config.toml"; - if (options.parentAncestorSymlink) { - fs.mkdirSync(path.join(dir, "real", "nested"), { recursive: true }); - fs.symlinkSync("real", path.join(dir, "alias")); - configDir = path.join(dir, "alias", "nested"); - stateFilePath = "alias/nested/config.toml"; - } + const { configDir, stateFilePath } = options.parentAncestorSymlink + ? ancestorSymlinkConfigDir(dir) + : { configDir: dir, stateFilePath: "config.toml" }; const currentPath = path.join(configDir, "config.toml"); - let currentTargetPath: string | null = null; try { - if (options.currentLink) { - currentTargetPath = path.join(dir, "current.target"); - fs.writeFileSync(currentTargetPath, current, { mode: 0o660 }); - if (options.currentLink === "symlink") { - fs.symlinkSync(path.basename(currentTargetPath), currentPath); - } else { - fs.linkSync(currentTargetPath, currentPath); - } - } else { - fs.writeFileSync(currentPath, current, { mode: 0o660 }); - } - let command = buildKeyAllowlistMergeRestoreCommand( + const currentTargetPath = options.currentLink + ? writeLinkedCurrentConfig(dir, currentPath, current, options.currentLink) + : writePlainCurrentConfig(currentPath, current); + const baseCommand = buildKeyAllowlistMergeRestoreCommand( dir, { path: stateFilePath }, DCODE_OWNERSHIP, ); - if (options.script) { - command = command.replace(shellQuote(KEY_ALLOWLIST_MERGE_PYTHON), shellQuote(options.script)); - } - command = command.replace( + 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)}`, ); From 0713daa24c61ddfd475b1c2f054bee034af6f9bf Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 11:53:28 -0700 Subject: [PATCH 18/24] refactor(state): decompose restore ownership helpers Signed-off-by: Carlos Villela --- .../snapshot-restore-lifecycle.test.ts | 190 ++++ ...pshot-restore-observability-policy.test.ts | 169 ++++ ...store-observability-reconciliation.test.ts | 293 ++++++ .../sandbox/snapshot-restore-test-fixture.ts | 283 ++++++ .../actions/sandbox/snapshot-restore.test.ts | 903 ------------------ .../key-allowlist-merge/python-config.ts | 104 ++ .../key-allowlist-merge/python-entrypoint.ts | 27 + .../key-allowlist-merge/python-ownership.ts | 103 ++ .../key-allowlist-merge/python-script.ts | 15 + .../python-serialization.ts | 131 +++ src/lib/state/sandbox.ts | 161 +--- .../state-file-key-merge-behavior.test.ts | 242 +++++ .../state-file-key-merge-file-safety.test.ts | 161 ++++ .../state/state-file-key-merge-spec.test.ts | 33 + .../state-file-key-merge-test-fixture.ts | 318 ++++++ src/lib/state/state-file-key-merge.test.ts | 712 -------------- src/lib/state/state-file-key-merge.ts | 350 +------ src/lib/state/state-file-restore.ts | 164 ++++ 18 files changed, 2240 insertions(+), 2119 deletions(-) create mode 100644 src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts create mode 100644 src/lib/actions/sandbox/snapshot-restore-observability-policy.test.ts create mode 100644 src/lib/actions/sandbox/snapshot-restore-observability-reconciliation.test.ts create mode 100644 src/lib/actions/sandbox/snapshot-restore-test-fixture.ts delete mode 100644 src/lib/actions/sandbox/snapshot-restore.test.ts create mode 100644 src/lib/state/key-allowlist-merge/python-config.ts create mode 100644 src/lib/state/key-allowlist-merge/python-entrypoint.ts create mode 100644 src/lib/state/key-allowlist-merge/python-ownership.ts create mode 100644 src/lib/state/key-allowlist-merge/python-script.ts create mode 100644 src/lib/state/key-allowlist-merge/python-serialization.ts create mode 100644 src/lib/state/state-file-key-merge-behavior.test.ts create mode 100644 src/lib/state/state-file-key-merge-file-safety.test.ts create mode 100644 src/lib/state/state-file-key-merge-spec.test.ts create mode 100644 src/lib/state/state-file-key-merge-test-fixture.ts delete mode 100644 src/lib/state/state-file-key-merge.test.ts create mode 100644 src/lib/state/state-file-restore.ts 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..a1dd7ce7b6 --- /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 restore policy 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/actions/sandbox/snapshot-restore.test.ts b/src/lib/actions/sandbox/snapshot-restore.test.ts deleted file mode 100644 index 5600e2c18f..0000000000 --- a/src/lib/actions/sandbox/snapshot-restore.test.ts +++ /dev/null @@ -1,903 +0,0 @@ -// 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 { SANDBOX_EXEC_STARTED_MARKER } from "./sandbox-exec-output"; -import type { SnapshotStreamSandboxCreateMock } from "./snapshot-create-stream-test-types"; - -type OpenshellCaptureResult = { - status: number | null; - output: string; - stdout?: string; - stderr?: string; - error?: Error; - signal?: NodeJS.Signals | null; -}; -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; -}; -type DcodeProbeState = "active" | "idle" | "unverifiable" | "no-runtime"; - -function dcodeProbeOutput(state: DcodeProbeState, extra = ""): string { - return `${SANDBOX_EXEC_STARTED_MARKER}\nNEMOCLAW_DCODE_PROBE=${state}\n${extra}`; -} - -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 }; -} - -function openshellResponses( - args: string[], - responses: Record, -): OpenshellCaptureResult { - const result = responses[`${args[0] ?? ""} ${args[1] ?? ""}`] ?? { - status: 0, - output: "", - }; - return captureOpenshellStreams(args, result); -} - -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(); - }, - ), - }; -}); - -const backupSandboxStateMock = vi.fn(); -const captureOpenshellMock = vi.fn< - (args: string[], opts?: Record) => OpenshellCaptureResult ->((args) => defaultOpenshellResponses(args)); -const dockerInspectMock = vi.fn(() => ({ status: 0, stdout: "true\n" })); -const findBackupMock = vi.fn(); -const getAppliedPresetsMock = vi.fn(() => [] as string[]); -const getCustomPoliciesMock = vi.fn( - () => [] as Array<{ name: string; content: string; sourcePath?: string }>, -); -const getLatestBackupMock = vi.fn(() => null as Record | null); -const applyPresetMock = vi.fn((_sandbox: string, _preset: string) => true); -const applyPresetContentMock = vi.fn( - (_sandbox: string, _name: string, _content: string, _options?: unknown) => true, -); -const removePresetMock = vi.fn((_sandbox: string, _preset: string) => true); -const getPresetContentGatewayStateMock = vi.fn< - (_sandbox: string, _content: string, _policyKey?: string) => "match" | "absent" | "drift" | null ->(() => "absent"); -const builtinObservabilityPolicy = - "network_policies:\n observability-otlp-local:\n endpoints:\n - host: host.openshell.internal\n"; -const loadPresetForSandboxMock = vi.fn((_sandbox: string, preset: string) => - preset === "observability-otlp-local" ? builtinObservabilityPolicy : null, -); -const getSandboxMock = vi.fn<(name?: string) => SandboxRecord | null>(() => null); -const isGatewayHealthyMock = vi.fn(() => true); -const listBackupsMock = vi.fn<() => Array>>(() => []); -const parseLiveSandboxNamesMock = vi.fn(() => new Set(["alpha"])); -const registerSandboxMock = vi.fn(); -const updateSandboxMock = vi.fn(); -const restoreSandboxStateMock = vi.fn(); -const runOpenshellMock = vi.fn((args: string[]) => { - args[0] === "sandbox" && args[1] === "delete" && lifecycleMock.events.push("delete"); - return { status: 0, output: "" }; -}); -const streamSandboxCreateMock = vi.fn(async () => ({ - status: 0, - output: "", - sawProgress: false, - forcedReady: false, -})); -const latestBackupFixture = { - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", -}; - -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(), -})); - -describe("runSandboxSnapshot restore", () => { - beforeEach(() => { - 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"])); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - }); - - it("restores the latest snapshot into the source sandbox", async () => { - const consoleLog = vi.spyOn(console, "log").mockImplementation(() => {}); - getLatestBackupMock.mockReturnValue({ - snapshotVersion: 4, - name: "stable", - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - }); - restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["user.md"], - failedDirs: [], - failedFiles: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(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 restore policy to the state layer", async () => { - getLatestBackupMock.mockReturnValue({ - snapshotVersion: 4, - name: "stable", - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - getSandboxMock.mockReturnValue({ name: "alpha", agent: "langchain-deepagents-code" }); - await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); - - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - fromDockerfile: "/tmp/Dockerfile", - }); - await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); - expect(restoreSandboxStateMock).toHaveBeenCalledTimes(2); - }); - - it("keeps active-timer restore, permission repair, and policy reconciliation serialized", async () => { - lifecycleMock.readTimerMarkerMock.mockReturnValue({ - pid: 4242, - sandboxName: "alpha", - snapshotPath: "/tmp/policy.yaml", - restoreAt: "2026-06-27T06:00:00.000Z", - processToken: "a".repeat(32), - }); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["github"], - }); - restoreSandboxStateMock.mockReturnValue({ - success: true, - restoredDirs: ["workspace"], - restoredFiles: ["openclaw.json"], - failedDirs: [], - failedFiles: [], - }); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(lifecycleMock.events).toContain("lock:restore sandbox snapshot"); - expect(restoreSandboxStateMock).toHaveBeenCalledWith("alpha", "/tmp/backup-alpha"); - expect(shieldsMock.repairMutableConfigPermsMock).toHaveBeenCalledWith("alpha"); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "github"); - }); - - it("hardens an active timer window before force-deleting a restore destination", async () => { - lifecycleMock.readTimerMarkerMock.mockReturnValue({ - pid: 4242, - sandboxName: "beta", - snapshotPath: "/tmp/policy.yaml", - restoreAt: "2026-06-27T06:00:00.000Z", - processToken: "b".repeat(32), - }); - 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", - }, - ); - parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - captureOpenshellMock.mockImplementation((args) => - openshellResponses(args, { - "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); - 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(shieldsMock.shieldsUpMock).toHaveBeenCalledWith("beta", { - throwOnError: true, - allowLegacyHermesProtocol: true, - }); - expect(lifecycleMock.events.indexOf("harden")).toBeLessThan( - lifecycleMock.events.indexOf("delete"), - ); - expect(lifecycleMock.events.indexOf("delete")).toBeLessThan( - lifecycleMock.events.indexOf("cleanup-shields"), - ); - expect(streamSandboxCreateMock).toHaveBeenCalled(); - expect(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(() => {}); - 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", - })); - parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha", "beta"])); - captureOpenshellMock.mockImplementation((args) => - openshellResponses(args, { - "sandbox exec": { status: 0, output: dcodeProbeOutput("no-runtime") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - getLatestBackupMock.mockReturnValue({ ...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(lifecycleMock.events).not.toContain("delete"); - expect(streamSandboxCreateMock).not.toHaveBeenCalled(); - expect(registerSandboxMock).not.toHaveBeenCalled(); - }); - - 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: SandboxRecord | null = null; - registerSandboxMock.mockImplementation((entry) => (registeredClone = entry as SandboxRecord)); - vi.stubEnv("NEMOCLAW_OBSERVABILITY", "1"); - 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, - ); - captureOpenshellMock.mockImplementation((args) => - openshellResponses(args, { - "sandbox exec": { status: 0, output: dcodeProbeOutput("idle") }, - "sandbox list": { status: 0, output: "alpha Ready\nbeta Ready\n" }, - }), - ); - parseLiveSandboxNamesMock.mockReturnValue(new Set(["alpha"])); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture }); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore", to: "beta" }); - const createCall = 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(registerSandboxMock).toHaveBeenCalledWith( - expect.objectContaining({ - name: "beta", - observabilityEnabled: enabled, - }), - ); - expect(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 }) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets }); - getAppliedPresetsMock.mockReturnValue(["npm"]); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - expect(applyPresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(removePresetMock).not.toHaveBeenCalled(); - }); - - it("removes historical built-in OTLP egress when observability was disabled after the snapshot", async () => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - }); - getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); - - it("removes an exact unrecorded built-in OTLP policy when observability is disabled", async () => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: [], - } as never); - getLatestBackupMock.mockReturnValue({ ...latestBackupFixture, policyPresets: [] }); - getAppliedPresetsMock.mockReturnValue([]); - getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith( - "alpha", - builtinObservabilityPolicy, - ); - expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(updateSandboxMock).not.toHaveBeenCalled(); - }); - - it.each([ - { - label: "returns false", - configureRemoval: () => removePresetMock.mockReturnValue(false), - }, - { - label: "throws", - configureRemoval: () => - removePresetMock.mockImplementation(() => { - throw new Error("remove exploded"); - }), - }, - { - label: "claims success without removing", - configureRemoval: () => removePresetMock.mockReturnValue(true), - }, - ])("retains built-in OTLP attribution when removal $label", async ({ configureRemoval }) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: [], - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - getAppliedPresetsMock.mockReturnValue([]); - getPresetContentGatewayStateMock.mockReturnValue("match"); - configureRemoval(); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { - policies: ["observability-otlp-local"], - }); - expect(consoleWarn.mock.calls.flat().join("\n")).toContain( - "exact content still live after remove", - ); - }); - - 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"], - }; - getSandboxMock.mockImplementation(() => registryEntry as never); - updateSandboxMock.mockImplementation((_sandboxName, update) => { - registryEntry = { ...registryEntry, ...(update as Partial) }; - }); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [], - }); - getAppliedPresetsMock.mockReturnValue(["github", "observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValue("match"); - 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(removePresetMock.mock.calls.map((call) => call[1])).toEqual([ - "github", - "observability-otlp-local", - ]); - expect(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, - }) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled, - policyTier: "balanced", - policies: recordedPolicies, - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm"], - }); - getAppliedPresetsMock.mockReturnValue(recordedPolicies); - getPresetContentGatewayStateMock.mockReturnValue(liveState); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: expectedPolicies }); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: [customPolicy.name], - customPolicies: [customPolicy], - }); - getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); - getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValueOnce("match").mockReturnValueOnce("absent"); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(removePresetMock).toHaveBeenCalledTimes(1); - expect(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); - expect(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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["npm", "observability-otlp-local"], - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - customPolicies: [customPolicy], - }); - getCustomPoliciesMock.mockReturnValueOnce([]).mockReturnValue([customPolicy]); - getAppliedPresetsMock.mockReturnValue(["npm", "corp-otel", "observability-otlp-local"]); - getPresetContentGatewayStateMock.mockImplementation((_sandbox, content) => - content === customPolicy.content ? "match" : "drift", - ); - const { runSandboxSnapshot } = await import("./snapshot"); - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(applyPresetContentMock).toHaveBeenCalledWith( - "alpha", - customPolicy.name, - customPolicy.content, - { custom: { sourcePath: customPolicy.sourcePath } }, - ); - expect(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(removePresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(removePresetMock).not.toHaveBeenCalledWith("alpha", customPolicy.name); - expect(updateSandboxMock).toHaveBeenCalledWith("alpha", { policies: ["npm"] }); - expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(1); - expect(getPresetContentGatewayStateMock.mock.calls[0]?.[1]).toBe(customPolicy.content); - expect(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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - policies: ["npm", "observability-otlp-local"], - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["npm", "observability-otlp-local"], - customPolicies: [customPolicy], - }); - getAppliedPresetsMock.mockReturnValue(["npm", "observability-otlp-local"]); - applyPresetContentMock.mockReturnValue(false); - 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(removePresetMock).toHaveBeenCalledWith("alpha", "observability-otlp-local"); - expect(getPresetContentGatewayStateMock).toHaveBeenCalledTimes(2); - expect(getPresetContentGatewayStateMock).toHaveBeenCalledWith( - "alpha", - 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", - }; - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - ...latestBackupFixture, - policyPresets: [], - customPolicies: [], - }); - getCustomPoliciesMock.mockReturnValue([currentCustomPolicy]); - removePresetMock.mockReturnValue(false); - 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(removePresetMock).toHaveBeenCalledWith("alpha", currentCustomPolicy.name); - expect(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) => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: false, - policyTier: "balanced", - } as never); - getLatestBackupMock.mockReturnValue({ - timestamp: "2026-06-15T00:00:00.000Z", - backupPath: "/tmp/backup-alpha", - policyPresets: ["observability-otlp-local"], - }); - getAppliedPresetsMock.mockReturnValue(["observability-otlp-local"]); - getPresetContentGatewayStateMock.mockReturnValue(gatewayState); - const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const { runSandboxSnapshot } = await import("./snapshot"); - - await runSandboxSnapshot("alpha", { kind: "restore" }); - - expect(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 () => { - getSandboxMock.mockReturnValue({ - name: "alpha", - agent: "langchain-deepagents-code", - observabilityEnabled: true, - policyTier: " Restricted ", - } as never); - 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(applyPresetMock).not.toHaveBeenCalledWith("alpha", "observability-otlp-local"); - }); -}); 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/sandbox.ts b/src/lib/state/sandbox.ts index a6b4bf9016..4887041f3d 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -28,13 +28,12 @@ import { spawnSync } from "child_process"; import { captureSandboxSshConfigCommand } from "../adapters/openshell/client.js"; import { resolveOpenshell } from "../adapters/openshell/resolve.js"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; -import type { AgentStateFile, StateFileRestoreOwnership } from "../agent/defs.js"; +import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; import { isObjectRecord, type UnknownRecord } from "../core/json-types.js"; import { shellQuote } from "../runner.js"; import { createTempSshConfig } from "../sandbox/temp-ssh-config.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; -import { buildOpenClawConfigRestoreInputFromSandbox } from "./openclaw-config-restore-input.js"; import { buildRestoreCleanupCommand, buildRestoreTarArgs, @@ -50,7 +49,7 @@ import { import type { CustomPolicyEntry } from "./registry.js"; import * as registry from "./registry.js"; import { isSshTransportFailure } from "./ssh-transport.js"; -import { buildKeyAllowlistMergeRestoreCommand } from "./state-file-key-merge.js"; +import { restoreStateFile } from "./state-file-restore.js"; import { runTarListing } from "./tar-listing.js"; const HOME_DIR = path.resolve(process.env.HOME || os.homedir()); @@ -764,24 +763,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); @@ -856,139 +837,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 restoreStateFile( - configFile: string, - sandboxName: string, - dir: string, - spec: StateFileSpec, - backupPath: string, - ownership: StateFileRestoreOwnership | undefined, - allowCustomImageWholeStateFileRestore: boolean, - 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: _log, - previousImagePluginInstalls, - specPath: spec.path, - sshArgs: sshArgs(configFile, sandboxName), - }); - 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(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 ───────────────────────────────────────────────────────── /** @@ -996,6 +844,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. @@ -1786,13 +1635,13 @@ function restoreSandboxStateInternal( if (!targetStateFile) throw new Error(`Validated target state file missing: ${spec.path}`); if ( restoreStateFile( - configFile, - sandboxName, + sshArgs(configFile, sandboxName), dir, spec, backupPath, targetStateFile.restore, options.allowCustomImageWholeStateFileRestore === true, + _log, configFreshOpenClawImagePluginInstalls, previousOpenClawImagePluginInstalls, ) 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.test.ts b/src/lib/state/state-file-key-merge.test.ts deleted file mode 100644 index 5b8c882551..0000000000 --- a/src/lib/state/state-file-key-merge.test.ts +++ /dev/null @@ -1,712 +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 { parse, stringify } from "smol-toml"; -import { describe, expect, it } from "vitest"; - -import type { StateFileKeyAllowlistRestoreOwnership } from "../agent/defs"; -import { shellQuote } from "../runner"; -import { - buildKeyAllowlistMergeRestoreCommand, - KEY_ALLOWLIST_MERGE_PYTHON, - stateFileKeyMergeSpec, -} from "./state-file-key-merge"; - -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_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(); - -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)`; -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", -); - -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)`; - -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", - ); -} - -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: " }, - ], -}; - -function generatedCurrent(config: unknown, providerHeader = FRESH_PROVIDER_HEADER): string { - return `${GENERATED_HEADER}\n${providerHeader}\n\n${stringify(config)}`; -} - -interface RunMergeOptions { - script?: string; -} - -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; -} - -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 }); - } -} - -function mergedToml(config: string): Record { - return parse(config) as Record; -} - -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" }], - }); - }); -}); - -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"); - }); - - 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.ts b/src/lib/state/state-file-key-merge.ts index 436452caaf..ffc6558dd8 100644 --- a/src/lib/state/state-file-key-merge.ts +++ b/src/lib/state/state-file-key-merge.ts @@ -4,355 +4,9 @@ import type { StateFileKeyAllowlistRestoreOwnership, StateFileUserKeyType } from "../agent/defs.js"; import { shellQuote } from "../runner.js"; -export const KEY_ALLOWLIST_MERGE_PYTHON = String.raw` -import copy -import json -import math -import os -import secrets -import stat -import sys -import tomllib -import tomli_w +export { KEY_ALLOWLIST_MERGE_PYTHON } from "./key-allowlist-merge/python-script.js"; -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 - - -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 - - -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) - - -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(); +import { KEY_ALLOWLIST_MERGE_PYTHON } from "./key-allowlist-merge/python-script.js"; interface PythonUserKey { path: string[]; 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; +} From 97e44b29228de3e6d57bd0da35415e176c1410dc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 12:04:53 -0700 Subject: [PATCH 19/24] test(state): split restore ownership hotspots Signed-off-by: Carlos Villela --- .../rebuild-restore-forwarding.test.ts | 42 +++++++ .../sandbox/rebuild-restore-phase.test.ts | 106 +++++------------- .../defs-state-file-restore-ownership.test.ts | 68 +++++++++++ src/lib/agent/defs.test.ts | 53 +-------- 4 files changed, 145 insertions(+), 124 deletions(-) create mode 100644 src/lib/actions/sandbox/rebuild-restore-forwarding.test.ts create mode 100644 src/lib/agent/defs-state-file-restore-ownership.test.ts 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 af79395f55..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,14 +53,12 @@ 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: [], customPolicies: [], reconcileManagedDcodeObservability: false, - targetAgentType: "openclaw", - targetImageIsCustom: false, log, }); @@ -60,35 +71,6 @@ describe("rebuild policy restore fidelity", () => { ); }); - 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, - }); - }); - it("replays custom web-policy names from exact content instead of same-name built-ins", () => { vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -109,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", @@ -119,8 +101,6 @@ describe("rebuild policy restore fidelity", () => { policyPresets: ["npm", "brave", "tavily", "nous-web"], customPolicies, reconcileManagedDcodeObservability: false, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -159,14 +139,12 @@ describe("rebuild policy restore fidelity", () => { sourcePath: "/tmp/custom-egress.yaml", }, ]; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], customPolicies, reconcileManagedDcodeObservability: false, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -194,14 +172,12 @@ 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: [], customPolicies: [genuineCustomPolicy, generatedMcpPolicy], reconcileManagedDcodeObservability: false, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -225,14 +201,12 @@ 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"], customPolicies: [], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -252,14 +226,12 @@ 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"], customPolicies: [], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -284,14 +256,12 @@ describe("rebuild policy restore fidelity", () => { throw new Error("apply failed"); }); - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: ["npm", "bad", "throw"], customPolicies: [], reconcileManagedDcodeObservability: false, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -307,14 +277,12 @@ 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"], customPolicies: [], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -330,14 +298,12 @@ 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"], customPolicies: [], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -348,14 +314,12 @@ 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: [], customPolicies: [], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -375,14 +339,12 @@ describe("rebuild policy restore fidelity", () => { sourcePath: "/tmp/operator-collector.yaml", }; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -409,14 +371,12 @@ describe("rebuild policy restore fidelity", () => { sourcePath: "/tmp/corp-otel.yaml", }; - const result = runRebuildRestorePhase({ + const result = runStandardRebuildRestorePhase({ sandboxName: "alpha", backupManifest: null, policyPresets: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -445,14 +405,12 @@ 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"], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -476,14 +434,12 @@ 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: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -506,14 +462,12 @@ 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: [], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); @@ -535,14 +489,12 @@ 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"], customPolicies: [customPolicy], reconcileManagedDcodeObservability: true, - targetAgentType: "openclaw", - targetImageIsCustom: false, log: vi.fn(), }); 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 abd72eaf07..2221e72213 100644 --- a/src/lib/agent/defs.test.ts +++ b/src/lib/agent/defs.test.ts @@ -13,7 +13,6 @@ import { resolveAgentName, resolveAgentNameAlias, } from "./defs"; -import type { StateFileKeyAllowlistRestoreOwnership, StateFileRestoreOwnership } from "./defs"; const tempAgentDirs: string[] = []; @@ -24,15 +23,6 @@ function writeTempAgentManifest(name: string, contents: string): void { fs.writeFileSync(path.join(agentDir, "manifest.yaml"), contents); } -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"); - })(); -} - afterEach(() => { vi.restoreAllMocks(); delete process.env.NEMOCLAW_AGENT; @@ -144,44 +134,13 @@ describe("agent definitions", () => { adapter: "deepagents-config", }); expect(deepAgentsCode.stateDirs).toEqual([".state", "skills", "agent/skills"]); - expect(deepAgentsCode.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: " }, - ], - }, - }, - ]); + 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"); - const configOwnership = deepAgentsCode.stateFiles[0]?.restore; - expect(configOwnership?.merge).toBe("key-allowlist"); - 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); - } expect(deepAgentsCode.userManagedFiles).toEqual([".deepagents/.env", ".deepagents/.mcp.json"]); }); From e2daeb0068453123e8a630812cb69dbe56401c23 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 12:19:22 -0700 Subject: [PATCH 20/24] test(state): cover custom-image whole-file restore Signed-off-by: Carlos Villela --- .../state-file-restore-custom-image.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/lib/state/state-file-restore-custom-image.test.ts 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..81bf648a81 --- /dev/null +++ b/src/lib/state/state-file-restore-custom-image.test.ts @@ -0,0 +1,69 @@ +// 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[] = []; + +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 = 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); + const ownership: StateFileKeyAllowlistRestoreOwnership = { + merge: "key-allowlist", + userKeys: [{ key: "ui.show_scrollbar", type: "boolean" }], + requireFreshTables: ["models"], + }; + + 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); + }); +}); From 38b8a6aa7f06cebacbe7a921f5f949f6d782438e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 12:28:02 -0700 Subject: [PATCH 21/24] test(state): clarify snapshot restore delegation Signed-off-by: Carlos Villela --- src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts index a1dd7ce7b6..ff244e01b8 100644 --- a/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts +++ b/src/lib/actions/sandbox/snapshot-restore-lifecycle.test.ts @@ -34,7 +34,7 @@ describe("runSandboxSnapshot restore: lifecycle and destination safety", () => { expect(output).toContain("Restored 1 directories, 1 files"); }); - it("delegates managed and custom-image restore policy to the state layer", async () => { + it("delegates managed and custom-image snapshot restores to the state layer", async () => { f.getLatestBackupMock.mockReturnValue({ snapshotVersion: 4, name: "stable", From 506ceedf7c12560b332f7f84cf0ed122170e3c6f Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 12:30:49 -0700 Subject: [PATCH 22/24] test(state): require custom restore capability Signed-off-by: Carlos Villela --- .../state-file-restore-custom-image.test.ts | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/src/lib/state/state-file-restore-custom-image.test.ts b/src/lib/state/state-file-restore-custom-image.test.ts index 81bf648a81..6ede727bb8 100644 --- a/src/lib/state/state-file-restore-custom-image.test.ts +++ b/src/lib/state/state-file-restore-custom-image.test.ts @@ -25,6 +25,21 @@ vi.mock("node:child_process", () => ({ })); 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(); @@ -35,17 +50,7 @@ afterEach(() => { describe("custom-image state-file restore capability (#6334)", () => { it("restores the complete backup without invoking the managed key allowlist", () => { - 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); - const ownership: StateFileKeyAllowlistRestoreOwnership = { - merge: "key-allowlist", - userKeys: [{ key: "ui.show_scrollbar", type: "boolean" }], - requireFreshTables: ["models"], - }; + const { backupPath, backupContents } = createBackupFixture(); const restored = restoreStateFile( ["-F", "/tmp/ssh-config", "openshell-alpha"], @@ -66,4 +71,28 @@ describe("custom-image state-file restore capability (#6334)", () => { 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); + }); }); From 306d9a395abe273d475fca62ba55964ad6c91bdc Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 10 Jul 2026 12:40:07 -0700 Subject: [PATCH 23/24] fix(state): reject duplicate backup state files Signed-off-by: Carlos Villela --- ...andbox-state-file-restore-contract.test.ts | 27 +++++++++++++++++++ src/lib/state/sandbox.ts | 19 +++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/lib/state/sandbox-state-file-restore-contract.test.ts b/src/lib/state/sandbox-state-file-restore-contract.test.ts index bcbb7d4c16..04a77425ab 100644 --- a/src/lib/state/sandbox-state-file-restore-contract.test.ts +++ b/src/lib/state/sandbox-state-file-restore-contract.test.ts @@ -50,6 +50,33 @@ afterEach(() => { }); 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" }], diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 4887041f3d..05a065e07c 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -706,14 +706,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); @@ -1348,7 +1355,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})`, @@ -1705,7 +1712,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 { From 8c6e699e44c4107f93a60847113966e81ee0a343 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Sat, 11 Jul 2026 17:05:01 -0700 Subject: [PATCH 24/24] fix(state): exclude ChatGPT auth from snapshots --- src/lib/security/credential-filter.test.ts | 4 +++- src/lib/security/credential-filter.ts | 6 +++++- test/snapshot.test.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) 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/test/snapshot.test.ts b/test/snapshot.test.ts index 57c6b6f264..12fcec7174 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1134,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( @@ -1222,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, );