From 69bfdaa7b1620e73030d64a5aa7d10b8b5815307 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 10 Jun 2026 17:00:35 -0400 Subject: [PATCH 1/4] fix(openclaw): fail unsafe config restore fallbacks --- src/lib/state/openclaw-config-merge.test.ts | 68 ++++++++++ src/lib/state/openclaw-config-merge.ts | 37 +++--- .../openclaw-config-restore-input.test.ts | 77 +++++++++++ .../state/openclaw-config-restore-input.ts | 125 ++++++++++++++++++ src/lib/state/sandbox.ts | 97 ++++---------- test/snapshot.test.ts | 7 + 6 files changed, 321 insertions(+), 90 deletions(-) create mode 100644 src/lib/state/openclaw-config-restore-input.test.ts create mode 100644 src/lib/state/openclaw-config-restore-input.ts diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index ad0c632233b..af1192f2f80 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -75,4 +75,72 @@ describe("mergeOpenClawRestoredConfig", () => { }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); }); + + it("preserves backup provider and plugin entries when current entry maps are absent", () => { + const merged = mergeOpenClawRestoredConfig( + { + models: { providers: { custom: { models: [{ id: "custom-model" }] } } }, + plugins: { entries: { customPlugin: { enabled: true } } }, + }, + { models: { mode: "route-through-gateway" }, plugins: { load: { paths: ["/plugins"] } } }, + ); + + expect(merged).toMatchObject({ + models: { + mode: "route-through-gateway", + providers: { custom: { models: [{ id: "custom-model" }] } }, + }, + plugins: { + load: { paths: ["/plugins"] }, + entries: { customPlugin: { enabled: true } }, + }, + }); + }); + + it("keeps current provider and plugin entries for matching keys", () => { + const merged = mergeOpenClawRestoredConfig( + { + models: { + providers: { + nvidia: { models: [{ id: "stale" }], apiKey: "unused" }, + custom: { models: [{ id: "stale-custom" }] }, + backupOnly: { models: [{ id: "backup-only" }] }, + }, + }, + plugins: { + entries: { + discord: { enabled: false }, + customPlugin: { enabled: true }, + backupOnlyPlugin: { enabled: true }, + }, + }, + }, + { + models: { + providers: { + nvidia: { models: [{ id: "fresh" }], apiKey: "unused" }, + custom: { models: [{ id: "fresh-custom" }] }, + }, + }, + plugins: { entries: { discord: { enabled: true }, customPlugin: { enabled: false } } }, + }, + ); + + expect(merged).toMatchObject({ + models: { + providers: { + nvidia: { models: [{ id: "fresh" }], apiKey: "unused" }, + custom: { models: [{ id: "fresh-custom" }] }, + backupOnly: { models: [{ id: "backup-only" }] }, + }, + }, + plugins: { + entries: { + discord: { enabled: true }, + customPlugin: { enabled: false }, + backupOnlyPlugin: { enabled: true }, + }, + }, + }); + }); }); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 2183f787999..53df96e4463 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -84,21 +84,26 @@ function mergeOpenClawChannels(backupChannels: unknown, currentChannels: unknown return merged; } +function mergeOpenClawEntryMap( + backupEntries: unknown, + currentEntries: unknown, +): Record | undefined { + if (!isPlainJsonObject(backupEntries) && !isPlainJsonObject(currentEntries)) return undefined; + return { + ...(isPlainJsonObject(backupEntries) ? cloneJson(backupEntries) : {}), + // Current generated entries win so rebuild does not restore stale runtime + // placeholders, model routing, or plugin enablement for NemoClaw-managed ids. + ...(isPlainJsonObject(currentEntries) ? cloneJson(currentEntries) : {}), + }; +} + function mergeOpenClawModels(backupModels: unknown, currentModels: unknown): unknown { if (!isPlainJsonObject(backupModels)) return cloneJson(currentModels); if (!isPlainJsonObject(currentModels)) return cloneJson(backupModels); const merged = mergeJsonObjects(currentModels, backupModels); - const backupProviders = backupModels.providers; - const currentProviders = currentModels.providers; - if (isPlainJsonObject(backupProviders) && isPlainJsonObject(currentProviders)) { - merged.providers = { - ...cloneJson(backupProviders), - // Current generated provider entries win so rebuild does not restore stale - // runtime placeholders or model routing for providers NemoClaw manages. - ...cloneJson(currentProviders), - }; - } + const providers = mergeOpenClawEntryMap(backupModels.providers, currentModels.providers); + if (providers) merged.providers = providers; return merged; } @@ -107,16 +112,8 @@ function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown): if (!isPlainJsonObject(currentPlugins)) return cloneJson(backupPlugins); const merged = mergeJsonObjects(currentPlugins, backupPlugins); - const backupEntries = backupPlugins.entries; - const currentEntries = currentPlugins.entries; - if (isPlainJsonObject(backupEntries) && isPlainJsonObject(currentEntries)) { - merged.entries = { - ...cloneJson(backupEntries), - // Current generated plugin enablement wins for channels/provider plugins; - // backup-only custom plugin entries are still preserved. - ...cloneJson(currentEntries), - }; - } + const entries = mergeOpenClawEntryMap(backupPlugins.entries, currentPlugins.entries); + if (entries) merged.entries = entries; return merged; } diff --git a/src/lib/state/openclaw-config-restore-input.test.ts b/src/lib/state/openclaw-config-restore-input.test.ts new file mode 100644 index 00000000000..09a03538286 --- /dev/null +++ b/src/lib/state/openclaw-config-restore-input.test.ts @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + buildOpenClawConfigRestoreInput, + shouldMergeOpenClawConfigStateFile, +} from "../../../dist/lib/state/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); + + expect(result).toMatchObject({ + ok: false, + error: "openclaw.json selective merge requires current rebuilt config", + }); + }); + + it("fails closed instead of wholesale restoring backup on invalid current JSON", () => { + const result = buildOpenClawConfigRestoreInput( + bufferJson({ channels: { discord: { token: "stale" } } }), + Buffer.from("{ invalid json"), + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("refusing unsafe wholesale backup restore"); + } + }); + + it("fails closed instead of wholesale restoring invalid backup JSON", () => { + const result = buildOpenClawConfigRestoreInput( + Buffer.from("{ invalid json"), + bufferJson({ gateway: { auth: { token: "fresh" } } }), + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("refusing unsafe wholesale backup restore"); + } + }); +}); diff --git a/src/lib/state/openclaw-config-restore-input.ts b/src/lib/state/openclaw-config-restore-input.ts new file mode 100644 index 00000000000..e270af10fd7 --- /dev/null +++ b/src/lib/state/openclaw-config-restore-input.ts @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 }; + +export interface OpenClawConfigRestoreFromSandboxOptions { + backupContents: Buffer; + dir: string; + log?: (message: string) => void; + specPath: string; + sshArgs: readonly string[]; +} + +function openClawConfigRemotePath(dir: string, specPath: string): string { + return `${dir.replace(/\/+$/, "")}/${specPath}`; +} + +export function buildOpenClawConfigReadCommand(dir: string, specPath: string): string { + const remotePath = openClawConfigRemotePath(dir, specPath); + const quotedRemotePath = shellQuote(remotePath); + return [ + `src=${quotedRemotePath}`, + '[ ! -e "$src" ] && exit 2', + '[ -f "$src" ] && [ ! -L "$src" ] || { echo "unsafe state file: $src" >&2; exit 10; }', + 'cat -- "$src"', + ].join("; "); +} + +function readCurrentOpenClawConfig( + sshArgs: readonly string[], + dir: string, + specPath: string, + log: (message: string) => void, +): Buffer | null { + const command = buildOpenClawConfigReadCommand(dir, specPath); + const result = spawnSync("ssh", [...sshArgs, command], { + stdio: ["ignore", "pipe", "pipe"], + timeout: 120000, + maxBuffer: 256 * 1024 * 1024, + }); + if (result.status === 0 && !result.error && !result.signal) return result.stdout; + if (result.status !== 2) { + const detail = + (result.stderr?.toString() || "").trim() || + result.error?.message || + (result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`); + log(`WARNING: state file current read ${specPath} failed: ${detail.substring(0, 200)}`); + } + return null; +} + +export function buildOpenClawConfigRestoreInput( + backupContents: Buffer, + currentContents: Buffer | null, +): OpenClawConfigRestoreInputResult { + if (!currentContents) { + return { ok: false, error: "openclaw.json selective merge requires current rebuilt config" }; + } + + try { + const backedUpConfig = JSON.parse(backupContents.toString("utf-8")) as unknown; + const currentConfig = JSON.parse(currentContents.toString("utf-8")) as unknown; + const merged = mergeOpenClawRestoredConfig(backedUpConfig, currentConfig); + return { ok: true, input: Buffer.from(`${JSON.stringify(merged, null, 2)}\n`) }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { + ok: false, + error: `openclaw.json selective merge failed; refusing unsafe wholesale backup restore: ${detail}`, + }; + } +} + +export function buildOpenClawConfigRestoreInputFromSandbox({ + backupContents, + dir, + log = () => {}, + specPath, + sshArgs, +}: OpenClawConfigRestoreFromSandboxOptions): OpenClawConfigRestoreInputResult { + return buildOpenClawConfigRestoreInput( + backupContents, + readCurrentOpenClawConfig(sshArgs, dir, specPath, log), + ); +} diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 311f3513dc5..c92988c6d9f 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -31,7 +31,10 @@ import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts.js"; import type { AgentStateFile } from "../agent/defs.js"; import { loadAgent } from "../agent/defs.js"; import { isRecord, type UnknownRecord } from "../core/json-types.js"; -import { mergeOpenClawRestoredConfig } from "./openclaw-config-merge.js"; +import { + buildOpenClawConfigRestoreInputFromSandbox, + shouldMergeOpenClawConfigStateFile, +} from "./openclaw-config-restore-input.js"; import { shellQuote } from "../runner.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; import * as registry from "./registry.js"; @@ -857,17 +860,6 @@ function backupStateFile( return "backed_up"; } -function buildStateFileReadCommand(dir: string, spec: StateFileSpec): string { - const remotePath = stateFileRemotePath(dir, spec.path); - const quotedRemotePath = shellQuote(remotePath); - return [ - `src=${quotedRemotePath}`, - '[ ! -e "$src" ] && exit 2', - '[ -f "$src" ] && [ ! -L "$src" ] || { echo "unsafe state file: $src" >&2; exit 10; }', - 'cat -- "$src"', - ].join("; "); -} - function buildStateFileRestoreCommand(dir: string, spec: StateFileSpec): string { const remotePath = stateFileRemotePath(dir, spec.path); const quotedRemotePath = shellQuote(remotePath); @@ -900,41 +892,6 @@ function buildStateFileRestoreCommand(dir: string, spec: StateFileSpec): string ].join("; "); } -function readCurrentStateFile( - configFile: string, - sandboxName: string, - dir: string, - spec: StateFileSpec, -): Buffer | null { - const command = buildStateFileReadCommand(dir, spec); - const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { - stdio: ["ignore", "pipe", "pipe"], - timeout: 120000, - maxBuffer: 256 * 1024 * 1024, - }); - if (result.status === 0 && !result.error && !result.signal) return result.stdout; - if (result.status !== 2) { - const detail = - (result.stderr?.toString() || "").trim() || - result.error?.message || - (result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`); - _log(`WARNING: state file current read ${spec.path} failed: ${detail.substring(0, 200)}`); - } - return null; -} - -function shouldMergeOpenClawConfig( - manifest: RebuildManifest, - dir: string, - spec: StateFileSpec, -): boolean { - return ( - spec.strategy === "copy" && - spec.path === "openclaw.json" && - (manifest.agentType === "openclaw" || dir.replace(/\/+$/, "").endsWith("/.openclaw")) - ); -} - function buildStateFileRestoreInput( configFile: string, sandboxName: string, @@ -942,24 +899,21 @@ function buildStateFileRestoreInput( spec: StateFileSpec, backupPath: string, mergeOpenClawConfig: boolean, -): Buffer { +): Buffer | null { const localPath = path.join(backupPath, spec.path); const backupContents = readFileSync(localPath); if (!mergeOpenClawConfig) return backupContents; - const currentContents = readCurrentStateFile(configFile, sandboxName, dir, spec); - if (!currentContents) return backupContents; - try { - const backedUpConfig = parseJson(backupContents.toString("utf-8")); - const currentConfig = parseJson(currentContents.toString("utf-8")); - const merged = mergeOpenClawRestoredConfig(backedUpConfig, currentConfig); - return Buffer.from(`${JSON.stringify(merged, null, 2)}\n`); - } catch (err) { - _log( - `WARNING: openclaw.json selective merge failed; restoring sanitized backup as-is: ${err instanceof Error ? err.message : String(err)}`, - ); - 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 restoreStateFile( @@ -975,15 +929,18 @@ function restoreStateFile( const command = buildStateFileRestoreCommand(dir, spec); _log(`Restoring state file ${spec.path} (${spec.strategy})`); + const input = buildStateFileRestoreInput( + configFile, + sandboxName, + dir, + spec, + backupPath, + mergeOpenClawConfig, + ); + if (input === null) return false; + const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { - input: buildStateFileRestoreInput( - configFile, - sandboxName, - dir, - spec, - backupPath, - mergeOpenClawConfig, - ), + input, stdio: ["pipe", "pipe", "pipe"], timeout: 120000, }); @@ -1565,7 +1522,7 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re dir, spec, backupPath, - shouldMergeOpenClawConfig(manifest, dir, spec), + shouldMergeOpenClawConfigStateFile(manifest.agentType, dir, spec), ) ) { restoredFiles.push(spec.path); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 407a1a1cfb2..cacf0612055 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -446,6 +446,9 @@ if (cmd.includes("[ -d ")) { process.stdout.write(existingDirs.join("\\n") + "\\n"); process.exit(0); } +if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + process.exit(2); +} if (cmd.includes("find ")) { process.exit(0); } @@ -516,6 +519,10 @@ if (cmd.includes("[ -d ")) { process.stdout.write(existingDirs.join("\\n") + "\\n"); process.exit(0); } +if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + process.stdout.write(JSON.stringify({ gateway: { auth: { token: "fresh" } }, channels: {} })); + process.exit(0); +} if (cmd.includes("find ")) { process.exit(0); } From 968278cb88b49d4aeed05bce771c2bcb433734e9 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 10 Jun 2026 17:46:14 -0400 Subject: [PATCH 2/4] test(snapshot): split openclaw config restore coverage --- test/openclaw-config-snapshot.test.ts | 217 ++++++++++++++++++++++++++ test/snapshot-gateway-guard.test.ts | 17 +- test/snapshot.test.ts | 163 ------------------- 3 files changed, 231 insertions(+), 166 deletions(-) create mode 100644 test/openclaw-config-snapshot.test.ts diff --git a/test/openclaw-config-snapshot.test.ts b/test/openclaw-config-snapshot.test.ts new file mode 100644 index 00000000000..9ee6645270b --- /dev/null +++ b/test/openclaw-config-snapshot.test.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterAll, describe, expect, it } from "vitest"; + +// sandbox-state computes its backup root from HOME at module load time. +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-snapshot-home-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "dist", "lib", "state", "sandbox.js")).href +)) as typeof import("../dist/lib/state/sandbox.js"); + +afterAll(() => { + if (ORIGINAL_HOME === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = ORIGINAL_HOME; + } + fs.rmSync(TMP_HOME, { recursive: true, force: true }); +}); + +function writeExecutable(filePath: string, source: string): void { + fs.writeFileSync(filePath, source, { mode: 0o755 }); +} + +function writeOpenClawRegistry(sandboxName: string): void { + fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, ".nemoclaw", "sandboxes.json"), + JSON.stringify({ + defaultSandbox: sandboxName, + sandboxes: { + [sandboxName]: { + name: sandboxName, + model: "m", + provider: "p", + gpuEnabled: false, + policies: [], + agent: null, + }, + }, + }), + ); +} + +describe("OpenClaw durable config file (#5027)", () => { + it("backs up and restores openclaw.json settings while sanitizing secrets", () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-snapshot-")); + const oldPath = process.env.PATH; + const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; + try { + const binDir = path.join(fixture, "bin"); + const fakeRoot = path.join(fixture, "sandbox-root"); + const openclawDir = path.join(fakeRoot, ".openclaw"); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(openclawDir, { recursive: true }); + + // Reporter-shaped config: model/provider/MCP/agent settings plus a + // provider apiKey sentinel, a channel resolve placeholder, a real inline + // secret, and a gateway block (regenerated at startup). + const original = { + models: { + mode: "merge", + providers: { + nvidia: { + baseUrl: "https://integrate.api.nvidia.com/v1", + apiKey: "unused", + models: [{ id: "moonshotai/kimi-k2" }], + }, + }, + }, + mcpServers: { + filesystem: { command: "npx" }, + github: { + command: "npx", + env: { GITHUB_TOKEN: "ghp_raw_secret", NODE_ENV: "production" }, + }, + }, + channels: { + discord: { + accounts: { default: { token: "openshell:resolve:env:DISCORD_BOT_TOKEN" } }, + }, + slack: { accounts: { default: { botToken: "xoxb-123-raw-secret" } } }, + }, + customAgents: { researcher: { prompt: "be thorough" } }, + leaked: { apiKey: "sk-real-secret" }, + gateway: { port: 18789, authToken: "gw-token" }, + }; + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), JSON.stringify(original, null, 2)); + + writeExecutable( + path.join(binDir, "openshell"), + `#!/bin/sh +if [ "$1" = "sandbox" ] && [ "$2" = "get" ]; then + printf '{"name":"%s"}\n' "\${3:-alpha}" + exit 0 +fi +if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ]; then + printf 'Host openshell-alpha\n HostName 127.0.0.1\n User sandbox\n' + exit 0 +fi +exit 0 +`, + ); + + writeExecutable( + path.join(binDir, "ssh"), + `#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); +const dir = path.join(${JSON.stringify(fakeRoot)}, ".openclaw"); +const cmd = process.argv[process.argv.length - 1] || ""; +function readStdin() { + const chunks = []; + for (;;) { + const buf = Buffer.alloc(65536); + let n = 0; + try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { break; } + if (n === 0) break; + chunks.push(buf.subarray(0, n)); + } + return Buffer.concat(chunks); +} +if (cmd.includes("[ -d ")) { process.exit(0); } +if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { + process.stdout.write(fs.readFileSync(path.join(dir, "openclaw.json"))); + process.exit(0); +} +if (cmd.includes(".nemoclaw-restore") && cmd.includes("openclaw.json")) { + fs.writeFileSync(path.join(dir, "openclaw.json"), readStdin()); + process.exit(0); +} +process.exit(0); +`, + ); + + writeOpenClawRegistry("alpha"); + // writeOpenClawRegistry records agent:null → defaults to openclaw. + + process.env.NEMOCLAW_OPENSHELL_BIN = path.join(binDir, "openshell"); + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(true); + expect(backup.backedUpFiles).toEqual(["openclaw.json"]); + expect(backup.manifest?.stateFiles).toEqual([{ path: "openclaw.json", strategy: "copy" }]); + + // The local backup is sanitized: secret stripped, gateway removed, + // restorable references preserved. + const backedUp = JSON.parse( + fs.readFileSync(path.join(backup.manifest!.backupPath, "openclaw.json"), "utf-8"), + ); + expect(backedUp.models.providers.nvidia.apiKey).toBe("unused"); + expect(backedUp.models.providers.nvidia.models[0].id).toBe("moonshotai/kimi-k2"); + expect(backedUp.mcpServers.filesystem.command).toBe("npx"); + expect(backedUp.channels.discord.accounts.default.token).toBe( + "openshell:resolve:env:DISCORD_BOT_TOKEN", + ); + expect(backedUp.customAgents.researcher.prompt).toBe("be thorough"); + expect(backedUp.leaked.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); + // Raw channel tokens and MCP env secrets must not leak into backups. + expect(backedUp.channels.slack.accounts.default.botToken).toBe("[STRIPPED_BY_MIGRATION]"); + expect(backedUp.mcpServers.github.env.GITHUB_TOKEN).toBe("[STRIPPED_BY_MIGRATION]"); + expect(backedUp.mcpServers.github.env.NODE_ENV).toBe("production"); + expect(backedUp.gateway).toBeUndefined(); + + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify( + { + models: { + mode: "merge", + providers: { nvidia: { apiKey: "unused", models: [{ id: "nvidia/nemotron" }] } }, + }, + channels: { + defaults: {}, + discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, + whatsapp: { accounts: { default: { enabled: true } } }, + }, + gateway: { auth: { token: "fresh-runtime-token" } }, + }, + null, + 2, + ), + ); + const restore = sandboxState.restoreSandboxState("alpha", backup.manifest!.backupPath); + expect(restore.success).toBe(true); + expect(restore.restoredFiles).toEqual(["openclaw.json"]); + + const after = JSON.parse(fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8")); + expect(after.gateway.auth.token).toBe("fresh-runtime-token"); + expect(after.models.providers.nvidia.models[0].id).toBe("nvidia/nemotron"); + expect(after.channels.discord.accounts.default.token).toBe( + "openshell:resolve:env:v222_TOKEN", + ); + expect(after.channels.whatsapp.accounts.default.enabled).toBe(true); + expect(after.channels.slack).toBeUndefined(); + expect(after.mcpServers.filesystem.command).toBe("npx"); + expect(after.customAgents.researcher.prompt).toBe("be thorough"); + } finally { + if (oldOpenshell === undefined) { + delete process.env.NEMOCLAW_OPENSHELL_BIN; + } else { + process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; + } + process.env.PATH = oldPath; + fs.rmSync(fixture, { recursive: true, force: true }); + } + }, 15000); +}); diff --git a/test/snapshot-gateway-guard.test.ts b/test/snapshot-gateway-guard.test.ts index b339bcff774..e3bdeaca2a9 100644 --- a/test/snapshot-gateway-guard.test.ts +++ b/test/snapshot-gateway-guard.test.ts @@ -161,6 +161,7 @@ function makeVmRestoreToEnv( writeExecutable(path.join(localBin, "openshell"), [ 'case "$1 $2" in', ' "gateway info") printf "Gateway Info\\n\\nGateway: nemoclaw\\nGateway endpoint: https://127.0.0.1:8080/\\n"; exit 0 ;;', + ' "sandbox get") printf "{\\"name\\":\\"%s\\"}\\n" "$3"; exit 0 ;;', ` "sandbox list") if [ -f ${JSON.stringify(cloneReadyMarker)} ]; then printf "NAME STATUS\\nalpha Ready\\nclone-1 Ready\\n"; else printf "NAME STATUS\\nalpha Ready\\n"; fi; exit 0 ;;`, ' "sandbox ssh-config") printf "Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"; exit 0 ;;', ` "sandbox create") touch ${JSON.stringify(cloneReadyMarker)}; printf "created clone-1\\n"; exit 0 ;;`, @@ -169,7 +170,17 @@ function makeVmRestoreToEnv( "exit 0", ]); - writeExecutable(path.join(localBin, "ssh"), ["exit 0"]); + const remoteOpenClawJson = path.join(home, "remote-openclaw.json"); + fs.writeFileSync(remoteOpenClawJson, JSON.stringify({ gateway: { auth: { token: "fresh" } } })); + writeExecutable(path.join(localBin, "ssh"), [ + `REMOTE_OPENCLAW_JSON=${JSON.stringify(remoteOpenClawJson)}`, + 'cmd=""; for arg do cmd="$arg"; done', + 'if printf "%s" "$cmd" | grep -q "openclaw.json"; then', + ' if printf "%s" "$cmd" | grep -q "cat --"; then cat "$REMOTE_OPENCLAW_JSON"; exit 0; fi', + ' if printf "%s" "$cmd" | grep -q ".nemoclaw-restore"; then cat > "$REMOTE_OPENCLAW_JSON"; exit 0; fi', + "fi", + "exit 0", + ]); // `docker exec` must never run: if the fast path regresses, // resolveSrcPodImage falls into the kubectl-via-docker probe and this @@ -227,7 +238,7 @@ describe("snapshot VM-driver gateway guard", () => { expect(r.out).not.toContain("could not resolve"); expect(r.out).not.toContain("kubectl-must-not-run"); expect(r.out).toContain("openshell/sandbox-from:fast-path-test"); - }); + }, 15000); it("snapshot restore --to fails closed for VM-driver entries missing imageTag", () => { const env = makeVmRestoreToEnv("nemoclaw-snap-vm-gw-restore-to-missing-image-", { @@ -241,5 +252,5 @@ describe("snapshot VM-driver gateway guard", () => { expect(r.code).toBe(1); expect(r.out).toContain("Cannot resolve image"); expect(r.out).not.toContain("kubectl-must-not-run"); - }); + }, 15000); }); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index cacf0612055..c46520d8f1e 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1342,166 +1342,3 @@ process.exit(0); } }); }); - -describe("OpenClaw durable config file (#5027)", () => { - it("backs up and restores openclaw.json settings while sanitizing secrets", () => { - const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-snapshot-")); - const oldPath = process.env.PATH; - const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; - try { - const binDir = path.join(fixture, "bin"); - const fakeRoot = path.join(fixture, "sandbox-root"); - const openclawDir = path.join(fakeRoot, ".openclaw"); - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(openclawDir, { recursive: true }); - - // Reporter-shaped config: model/provider/MCP/agent settings plus a - // provider apiKey sentinel, a channel resolve placeholder, a real inline - // secret, and a gateway block (regenerated at startup). - const original = { - models: { - mode: "merge", - providers: { - nvidia: { - baseUrl: "https://integrate.api.nvidia.com/v1", - apiKey: "unused", - models: [{ id: "moonshotai/kimi-k2" }], - }, - }, - }, - mcpServers: { - filesystem: { command: "npx" }, - github: { - command: "npx", - env: { GITHUB_TOKEN: "ghp_raw_secret", NODE_ENV: "production" }, - }, - }, - channels: { - discord: { - accounts: { default: { token: "openshell:resolve:env:DISCORD_BOT_TOKEN" } }, - }, - slack: { accounts: { default: { botToken: "xoxb-123-raw-secret" } } }, - }, - customAgents: { researcher: { prompt: "be thorough" } }, - leaked: { apiKey: "sk-real-secret" }, - gateway: { port: 18789, authToken: "gw-token" }, - }; - fs.writeFileSync(path.join(openclawDir, "openclaw.json"), JSON.stringify(original, null, 2)); - - writeExecutable( - path.join(binDir, "openshell"), - `#!/usr/bin/env node -const args = process.argv.slice(2); -if (args[0] === "sandbox" && args[1] === "ssh-config") { - process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); - process.exit(0); -} -process.exit(0); -`, - ); - - writeExecutable( - path.join(binDir, "ssh"), - `#!/usr/bin/env node -const fs = require("fs"); -const path = require("path"); -const dir = path.join(${JSON.stringify(fakeRoot)}, ".openclaw"); -const cmd = process.argv[process.argv.length - 1] || ""; -function readStdin() { - const chunks = []; - for (;;) { - const buf = Buffer.alloc(65536); - let n = 0; - try { n = fs.readSync(0, buf, 0, buf.length, null); } catch { break; } - if (n === 0) break; - chunks.push(buf.subarray(0, n)); - } - return Buffer.concat(chunks); -} -if (cmd.includes("[ -d ")) { process.exit(0); } -if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { - process.stdout.write(fs.readFileSync(path.join(dir, "openclaw.json"))); - process.exit(0); -} -if (cmd.includes(".nemoclaw-restore") && cmd.includes("openclaw.json")) { - fs.writeFileSync(path.join(dir, "openclaw.json"), readStdin()); - process.exit(0); -} -process.exit(0); -`, - ); - - writeOpenClawRegistry("alpha"); - // writeOpenClawRegistry records agent:null → defaults to openclaw. - - process.env.NEMOCLAW_OPENSHELL_BIN = path.join(binDir, "openshell"); - process.env.PATH = `${binDir}:${oldPath || ""}`; - - const backup = sandboxState.backupSandboxState("alpha"); - expect(backup.success).toBe(true); - expect(backup.backedUpFiles).toEqual(["openclaw.json"]); - expect(backup.manifest?.stateFiles).toEqual([{ path: "openclaw.json", strategy: "copy" }]); - - // The local backup is sanitized: secret stripped, gateway removed, - // restorable references preserved. - const backedUp = JSON.parse( - fs.readFileSync(path.join(backup.manifest!.backupPath, "openclaw.json"), "utf-8"), - ); - expect(backedUp.models.providers.nvidia.apiKey).toBe("unused"); - expect(backedUp.models.providers.nvidia.models[0].id).toBe("moonshotai/kimi-k2"); - expect(backedUp.mcpServers.filesystem.command).toBe("npx"); - expect(backedUp.channels.discord.accounts.default.token).toBe( - "openshell:resolve:env:DISCORD_BOT_TOKEN", - ); - expect(backedUp.customAgents.researcher.prompt).toBe("be thorough"); - expect(backedUp.leaked.apiKey).toBe("[STRIPPED_BY_MIGRATION]"); - // Raw channel tokens and MCP env secrets must not leak into backups. - expect(backedUp.channels.slack.accounts.default.botToken).toBe("[STRIPPED_BY_MIGRATION]"); - expect(backedUp.mcpServers.github.env.GITHUB_TOKEN).toBe("[STRIPPED_BY_MIGRATION]"); - expect(backedUp.mcpServers.github.env.NODE_ENV).toBe("production"); - expect(backedUp.gateway).toBeUndefined(); - - fs.writeFileSync( - path.join(openclawDir, "openclaw.json"), - JSON.stringify( - { - models: { - mode: "merge", - providers: { nvidia: { apiKey: "unused", models: [{ id: "nvidia/nemotron" }] } }, - }, - channels: { - defaults: {}, - discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, - whatsapp: { accounts: { default: { enabled: true } } }, - }, - gateway: { auth: { token: "fresh-runtime-token" } }, - }, - null, - 2, - ), - ); - const restore = sandboxState.restoreSandboxState("alpha", backup.manifest!.backupPath); - expect(restore.success).toBe(true); - expect(restore.restoredFiles).toEqual(["openclaw.json"]); - - const after = JSON.parse(fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8")); - expect(after.gateway.auth.token).toBe("fresh-runtime-token"); - expect(after.models.providers.nvidia.models[0].id).toBe("nvidia/nemotron"); - expect(after.channels.discord.accounts.default.token).toBe( - "openshell:resolve:env:v222_TOKEN", - ); - expect(after.channels.whatsapp.accounts.default.enabled).toBe(true); - expect(after.channels.slack).toBeUndefined(); - expect(after.mcpServers.filesystem.command).toBe("npx"); - expect(after.customAgents.researcher.prompt).toBe("be thorough"); - } finally { - if (oldOpenshell === undefined) { - delete process.env.NEMOCLAW_OPENSHELL_BIN; - } else { - process.env.NEMOCLAW_OPENSHELL_BIN = oldOpenshell; - } - process.env.PATH = oldPath; - fs.rmSync(fixture, { recursive: true, force: true }); - } - }); -}); From c7e96f176e1e6665b8f2d84f81bea93ed53a311e Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 10 Jun 2026 18:47:34 -0400 Subject: [PATCH 3/4] fix(openclaw): refresh restored config hash --- src/lib/state/sandbox.ts | 23 +++++++++++++++---- test/openclaw-config-snapshot.test.ts | 17 ++++++++++++-- test/snapshot.test.ts | 33 ++------------------------- 3 files changed, 36 insertions(+), 37 deletions(-) diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index c92988c6d9f..86b84c95fec 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -860,7 +860,11 @@ function backupStateFile( return "backed_up"; } -function buildStateFileRestoreCommand(dir: string, spec: StateFileSpec): string { +function buildStateFileRestoreCommand( + dir: string, + spec: StateFileSpec, + refreshOpenClawConfigHash = false, +): string { const remotePath = stateFileRemotePath(dir, spec.path); const quotedRemotePath = shellQuote(remotePath); if (spec.strategy === "sqlite_backup") { @@ -878,7 +882,7 @@ function buildStateFileRestoreCommand(dir: string, spec: StateFileSpec): string ].join("; "); } - return [ + const steps = [ `dst=${quotedRemotePath}`, 'parent="$(dirname "$dst")"', '[ ! -L "$parent" ] || { echo "refusing symlinked state parent: $parent" >&2; exit 10; }', @@ -889,7 +893,18 @@ function buildStateFileRestoreCommand(dir: string, spec: StateFileSpec): string 'cat > "$tmp"', 'chmod 640 "$tmp"', 'mv -f "$tmp" "$dst"', - ].join("; "); + ]; + + if (refreshOpenClawConfigHash) { + steps.push( + 'hash_file="${parent}/.config-hash"', + '[ ! -L "$hash_file" ] || { echo "refusing symlinked config hash target: $hash_file" >&2; exit 12; }', + '(cd "$parent" && sha256sum "$(basename "$dst")" > .config-hash)', + 'chmod 660 "$hash_file" 2>/dev/null || true', + ); + } + + return steps.join("; "); } function buildStateFileRestoreInput( @@ -927,7 +942,7 @@ function restoreStateFile( const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; - const command = buildStateFileRestoreCommand(dir, spec); + const command = buildStateFileRestoreCommand(dir, spec, mergeOpenClawConfig); _log(`Restoring state file ${spec.path} (${spec.strategy})`); const input = buildStateFileRestoreInput( configFile, diff --git a/test/openclaw-config-snapshot.test.ts b/test/openclaw-config-snapshot.test.ts index 9ee6645270b..1fb602b7549 100644 --- a/test/openclaw-config-snapshot.test.ts +++ b/test/openclaw-config-snapshot.test.ts @@ -51,7 +51,7 @@ function writeOpenClawRegistry(sandboxName: string): void { } describe("OpenClaw durable config file (#5027)", () => { - it("backs up and restores openclaw.json settings while sanitizing secrets", () => { + it("backs up and restores openclaw.json settings while sanitizing secrets", async () => { const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-snapshot-")); const oldPath = process.env.PATH; const oldOpenshell = process.env.NEMOCLAW_OPENSHELL_BIN; @@ -134,7 +134,12 @@ if (cmd.includes("openclaw.json") && cmd.includes("cat --")) { process.exit(0); } if (cmd.includes(".nemoclaw-restore") && cmd.includes("openclaw.json")) { - fs.writeFileSync(path.join(dir, "openclaw.json"), readStdin()); + const configPath = path.join(dir, "openclaw.json"); + fs.writeFileSync(configPath, readStdin()); + if (cmd.includes("sha256sum") && cmd.includes(".config-hash")) { + const digest = require("crypto").createHash("sha256").update(fs.readFileSync(configPath)).digest("hex"); + fs.writeFileSync(path.join(dir, ".config-hash"), digest + " openclaw.json\\n"); + } process.exit(0); } process.exit(0); @@ -204,6 +209,14 @@ process.exit(0); expect(after.channels.slack).toBeUndefined(); expect(after.mcpServers.filesystem.command).toBe("npx"); expect(after.customAgents.researcher.prompt).toBe("be thorough"); + const expectedHash = await import("node:crypto").then(({ createHash }) => + createHash("sha256") + .update(fs.readFileSync(path.join(openclawDir, "openclaw.json"))) + .digest("hex"), + ); + expect(fs.readFileSync(path.join(openclawDir, ".config-hash"), "utf-8")).toBe( + `${expectedHash} openclaw.json\n`, + ); } finally { if (oldOpenshell === undefined) { delete process.env.NEMOCLAW_OPENSHELL_BIN; diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index c46520d8f1e..5e2a4a06caa 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -5,13 +5,11 @@ // - validateSnapshotName accepts/rejects names // - listBackups computes virtual v versions by timestamp-ascending position // - findBackup resolves selectors (v, name, exact timestamp) - import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; - // Override HOME BEFORE importing sandbox-state — it reads process.env.HOME // at module-load time to compute REBUILD_BACKUPS_DIR. Captured original is // restored in afterAll so sibling tests running in the same worker don't @@ -19,15 +17,11 @@ import { afterAll, beforeEach, describe, expect, it } from "vitest"; const ORIGINAL_HOME = process.env.HOME; const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-snap-naming-")); process.env.HOME = TMP_HOME; - const REPO_ROOT = path.join(import.meta.dirname, ".."); - type BackupScalar = string | number | boolean | null | undefined; type BackupValue = BackupScalar | BackupManifestOverrides | BackupValue[]; - type SandboxStateModule = typeof import("../dist/lib/state/sandbox.js"); type SandboxStateModuleCandidate = Partial | null; - function isSandboxStateModule(value: SandboxStateModuleCandidate): value is SandboxStateModule { return ( value !== null && @@ -37,7 +31,6 @@ function isSandboxStateModule(value: SandboxStateModuleCandidate): value is Sand typeof value.parseRestoreArgs === "function" ); } - const loadedSandboxState = await import( pathToFileURL(path.join(REPO_ROOT, "dist", "lib", "state", "sandbox.js")).href ); @@ -46,11 +39,8 @@ if (!isSandboxStateModule(loadedSandboxState)) { } const sandboxState = loadedSandboxState; const { parseRestoreArgs } = sandboxState; - const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); - type BackupManifestOverrides = { [key: string]: BackupValue }; - function writeBackup( sandboxName: string, dirName: string, @@ -74,7 +64,6 @@ function writeBackup( fs.writeFileSync(path.join(dir, "rebuild-manifest.json"), JSON.stringify(manifest, null, 2)); return manifest; } - afterAll(() => { if (ORIGINAL_HOME === undefined) { delete process.env.HOME; @@ -83,15 +72,12 @@ afterAll(() => { } fs.rmSync(TMP_HOME, { recursive: true, force: true }); }); - beforeEach(() => { fs.rmSync(BACKUPS_ROOT, { recursive: true, force: true }); }); - function writeExecutable(filePath: string, source: string): void { fs.writeFileSync(filePath, source, { mode: 0o755 }); } - function writeOpenClawRegistry(sandboxName: string): void { fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); fs.writeFileSync( @@ -111,7 +97,6 @@ function writeOpenClawRegistry(sandboxName: string): void { }), ); } - function writeFakeOpenshell(binDir: string): string { const openshell = path.join(binDir, "openshell"); writeExecutable( @@ -127,32 +112,27 @@ process.exit(0); ); return openshell; } - describe("validateSnapshotName", () => { it("accepts normal names", () => { expect(sandboxState.validateSnapshotName("before-upgrade")).toBeNull(); expect(sandboxState.validateSnapshotName("clean_state.v2")).toBeNull(); expect(sandboxState.validateSnapshotName("A")).toBeNull(); }); - it("rejects names matching the v version pattern", () => { expect(sandboxState.validateSnapshotName("v1")).toMatch(/conflicts with.*v/); expect(sandboxState.validateSnapshotName("V42")).toMatch(/conflicts with.*v/); }); - it("rejects empty, leading-symbol, or too-long names", () => { expect(sandboxState.validateSnapshotName("")).toMatch(/Invalid/); expect(sandboxState.validateSnapshotName("-foo")).toMatch(/Invalid/); expect(sandboxState.validateSnapshotName(".hidden")).toMatch(/Invalid/); expect(sandboxState.validateSnapshotName("x".repeat(64))).toMatch(/Invalid/); }); - it("rejects names with spaces or slashes", () => { expect(sandboxState.validateSnapshotName("hello world")).toMatch(/Invalid/); expect(sandboxState.validateSnapshotName("foo/bar")).toMatch(/Invalid/); }); }); - describe("listBackups computes virtual versions", () => { it("assigns v1 to the oldest by timestamp and vN to the newest", () => { // Written out of chronological order to verify sort-by-timestamp. @@ -167,21 +147,18 @@ describe("listBackups computes virtual versions", () => { [1, "2026-04-21T14-01-00-000Z"], ]); }); - it("ignores any snapshotVersion persisted in legacy manifests", () => { // Old on-disk value should be overridden by position-based virtual version. writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { snapshotVersion: 99 }); const [entry] = sandboxState.listBackups("test-sandbox"); expect(entry.snapshotVersion).toBe(1); }); - it("surfaces the name field when present", () => { writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { name: "before-upgrade" }); const [entry] = sandboxState.listBackups("test-sandbox"); expect(entry.name).toBe("before-upgrade"); expect(entry.snapshotVersion).toBe(1); }); - it("preserves legacy manifests created before blueprintDigest existed", () => { const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T13-59-00-000Z"); fs.mkdirSync(dir, { recursive: true }); @@ -199,14 +176,12 @@ describe("listBackups computes virtual versions", () => { backupPath: dir, }), ); - const [entry] = sandboxState.listBackups("test-sandbox"); expect(entry?.timestamp).toBe("2026-04-21T13-59-00-000Z"); expect(entry?.dir).toBe("/sandbox/.openclaw-data"); expect(entry?.writableDir).toBe("/sandbox/.openclaw-data"); expect(entry?.blueprintDigest).toBeNull(); }); - it("ignores rebuild manifests with invalid typed fields", () => { const dir = path.join(BACKUPS_ROOT, "test-sandbox", "2026-04-21T14-00-00-000Z"); fs.mkdirSync(dir, { recursive: true }); @@ -226,28 +201,22 @@ describe("listBackups computes virtual versions", () => { policyPresets: [1], }), ); - expect(sandboxState.listBackups("test-sandbox")).toEqual([]); }); - it("ignores rebuild manifests with unsafe backed-up directory paths", () => { writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { stateDirs: ["workspace"], backedUpDirs: ["../outside"], }); - expect(sandboxState.listBackups("test-sandbox")).toEqual([]); }); - it("ignores rebuild manifests whose backed-up dirs are not declared state dirs", () => { writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { stateDirs: ["workspace"], backedUpDirs: ["workspace", "agents"], }); - expect(sandboxState.listBackups("test-sandbox")).toEqual([]); }); - it("does not restore backed-up directory entries that are plain files", () => { const manifest = writeBackup("test-sandbox", "2026-04-21T14-00-00-000Z", { stateDirs: ["workspace"], @@ -1342,3 +1311,5 @@ process.exit(0); } }); }); + + From 9f4fd594fb28ac204db00696542db5d7a959f909 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 10 Jun 2026 19:05:06 -0400 Subject: [PATCH 4/4] test(snapshot): normalize file ending --- test/snapshot.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 5e2a4a06caa..64a0d93e350 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1311,5 +1311,3 @@ process.exit(0); } }); }); - -