diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index af1192f2f8..a9224e0146 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -97,6 +97,91 @@ describe("mergeOpenClawRestoredConfig", () => { }); }); + it("restores reporter-owned model metadata while keeping fresh provider routing (#5202)", () => { + // Reporter scenario: same provider id and same model id after rebuild, but + // the freshly generated v0.0.63 model block resets the user's tuning. The + // merge must keep fresh runtime routing/credentials while restoring the + // backed-up non-secret model metadata. + const merged = mergeOpenClawRestoredConfig( + { + models: { + mode: "merge", + providers: { + inference: { + baseUrl: "http://127.0.0.1:8789/v1", + apiKey: "unused", + api: "chat-completions", + models: [ + { + compat: { supportsUsageInStreaming: true, toolCallStyle: "openai" }, + id: "moonshotai/kimi-k2", + name: "stale-display-name", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.5, output: 1.5, cacheRead: 0.1, cacheWrite: 0.2 }, + contextWindow: 131072, + maxTokens: 32768, + }, + ], + }, + }, + }, + mcp: { servers: { filesystem: { command: "npx", args: ["-y", "fs-server", "/work"] } } }, + }, + { + models: { + mode: "merge", + providers: { + inference: { + baseUrl: "http://127.0.0.1:9999/v1", + apiKey: "unused", + api: "chat-completions", + models: [ + { + id: "moonshotai/kimi-k2", + name: "fresh-display-name", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 4096, + }, + ], + }, + }, + }, + gateway: { auth: { token: "fresh-token" } }, + }, + ); + + const provider = ( + merged as { + models: { providers: { inference: Record } }; + } + ).models.providers.inference; + // Runtime-owned provider routing/credentials win from the fresh rebuild. + expect(provider.baseUrl).toBe("http://127.0.0.1:9999/v1"); + expect(provider.apiKey).toBe("unused"); + expect(provider.api).toBe("chat-completions"); + + const model = (provider.models as Record[])[0]; + // Routing identity (id/name) stays fresh; tuning metadata is restored. + expect(model.id).toBe("moonshotai/kimi-k2"); + expect(model.name).toBe("fresh-display-name"); + expect(model.reasoning).toBe(true); + expect(model.cost).toEqual({ input: 0.5, output: 1.5, cacheRead: 0.1, cacheWrite: 0.2 }); + expect(model.maxTokens).toBe(32768); + expect(model.compat).toEqual({ supportsUsageInStreaming: true, toolCallStyle: "openai" }); + expect(model.input).toEqual(["text", "image"]); + expect(model.contextWindow).toBe(131072); + + // Fresh runtime gateway is preserved; durable user mcp.servers survives. + expect((merged as { gateway: unknown }).gateway).toEqual({ auth: { token: "fresh-token" } }); + expect( + (merged as { mcp: { servers: Record } }).mcp.servers.filesystem, + ).toEqual({ command: "npx", args: ["-y", "fs-server", "/work"] }); + }); + it("keeps current provider and plugin entries for matching keys", () => { const merged = mergeOpenClawRestoredConfig( { diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 53df96e446..7fb622b07b 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -17,15 +17,25 @@ export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { /** NemoClaw-managed channels reflect current add/remove/start/stop state. */ managedChannels: ["discord", "slack", "telegram", "whatsapp", "wechat", "openclaw-weixin"], /** Current generated entries win by id; backup-only user entries are kept. */ - currentGeneratedEntryMaps: ["models.providers", "plugins.entries"], + currentGeneratedEntryMaps: ["plugins.entries"], + /** + * Provider entries are reconciled by id: the fresh rebuild owns routing and + * credential fields, while backed-up non-secret model tuning is restored. + */ + providerRuntimeOwnedFields: ["baseUrl", "api", "apiKey"], + /** A model entry's routing identity is owned by the fresh rebuild. */ + modelRuntimeOwnedFields: ["id", "name"], /** Durable user-owned top-level sections are inherited from the backup. */ - backupDurableSections: ["mcpServers", "customAgents", "agents"], + backupDurableSections: ["mcp", "mcpServers", "customAgents", "agents"], } as const; const MANAGED_OPENCLAW_CHANNELS = new Set( OPENCLAW_CONFIG_RESTORE_OWNERSHIP.managedChannels, ); +const PROVIDER_RUNTIME_OWNED_FIELDS = OPENCLAW_CONFIG_RESTORE_OWNERSHIP.providerRuntimeOwnedFields; +const MODEL_RUNTIME_OWNED_FIELDS = OPENCLAW_CONFIG_RESTORE_OWNERSHIP.modelRuntimeOwnedFields; + function isPlainJsonObject(value: unknown): value is Record { return isRecord(value); } @@ -97,12 +107,115 @@ function mergeOpenClawEntryMap( }; } +function modelEntryId(entry: unknown): string | null { + if (isPlainJsonObject(entry) && typeof entry.id === "string") return entry.id; + return null; +} + +function restoreRuntimeOwnedFields( + merged: Record, + current: Record, + ownedFields: readonly string[], +): void { + for (const field of ownedFields) { + if (field in current) merged[field] = cloneJson(current[field]); + else delete merged[field]; + } +} + +/** + * Reconcile one model entry whose id matches across backup and current. + * + * The fresh rebuild owns the model's routing identity (`id`/`name`); the + * backup restores the user's non-secret tuning (`reasoning`, `cost`, + * `contextWindow`, `maxTokens`, `compat`, `input`, …) that the regenerated + * defaults would otherwise reset (issue #5202). + */ +function mergeOpenClawModelEntry( + backupModel: Record, + currentModel: Record, +): Record { + const merged = mergeJsonObjects(currentModel, backupModel); + restoreRuntimeOwnedFields(merged, currentModel, MODEL_RUNTIME_OWNED_FIELDS); + return merged; +} + +/** + * Merge a provider's `models` array. The fresh rebuild defines the model set + * and order; for each fresh model with an id present in the backup, the + * backed-up tuning is restored. Backup-only and id-less stale models are not + * resurrected so rebuild's regenerated routing stays authoritative. + */ +function mergeOpenClawModelArray(backupModels: unknown, currentModels: unknown): unknown { + if (!Array.isArray(currentModels)) return cloneJson(backupModels ?? currentModels); + + const backupById = new Map>(); + if (Array.isArray(backupModels)) { + for (const entry of backupModels) { + const id = modelEntryId(entry); + if (id && isPlainJsonObject(entry) && !backupById.has(id)) backupById.set(id, entry); + } + } + + return currentModels.map((entry) => { + const id = modelEntryId(entry); + const backupMatch = id ? backupById.get(id) : undefined; + if (backupMatch && isPlainJsonObject(entry)) return mergeOpenClawModelEntry(backupMatch, entry); + return cloneJson(entry); + }); +} + +/** + * Reconcile one provider entry whose id matches across backup and current. + * Runtime-owned routing/credential fields stay fresh; backed-up non-secret + * config (including per-model tuning) is restored. + */ +function mergeOpenClawProviderEntry( + backupProvider: Record, + currentProvider: Record, +): Record { + const merged = mergeJsonObjects(currentProvider, backupProvider); + restoreRuntimeOwnedFields(merged, currentProvider, PROVIDER_RUNTIME_OWNED_FIELDS); + if ("models" in currentProvider || "models" in backupProvider) { + merged.models = mergeOpenClawModelArray(backupProvider.models, currentProvider.models); + } + return merged; +} + +/** + * Merge `models.providers`. Backup-only providers are inherited; fresh-only + * providers win as generated; matching providers are reconciled by ownership + * so the fresh rebuild keeps routing/credentials while the backup restores + * user-owned non-secret model metadata (issue #5202). + */ +function mergeOpenClawProviderMap( + backupProviders: unknown, + currentProviders: unknown, +): Record | undefined { + if (!isPlainJsonObject(backupProviders) && !isPlainJsonObject(currentProviders)) return undefined; + const backup = isPlainJsonObject(backupProviders) ? backupProviders : {}; + const current = isPlainJsonObject(currentProviders) ? currentProviders : {}; + + const merged: Record = {}; + for (const [key, value] of Object.entries(backup)) { + merged[key] = cloneJson(value); + } + for (const [key, value] of Object.entries(current)) { + const backupEntry = backup[key]; + merged[key] = + isPlainJsonObject(backupEntry) && isPlainJsonObject(value) + ? mergeOpenClawProviderEntry(backupEntry, value) + : cloneJson(value); + } + return merged; +} + 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 providers = mergeOpenClawEntryMap(backupModels.providers, currentModels.providers); + const providers = mergeOpenClawProviderMap(backupModels.providers, currentModels.providers); if (providers) merged.providers = providers; return merged; } diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 55442b29de..62c1474b3f 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -31,12 +31,12 @@ 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 { shellQuote } from "../runner.js"; +import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.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"; import { runTarListing } from "./tar-listing.js"; @@ -860,7 +860,7 @@ function backupStateFile( return "backed_up"; } -function buildStateFileRestoreCommand( +export function buildStateFileRestoreCommand( dir: string, spec: StateFileSpec, refreshOpenClawConfigHash = false, @@ -889,12 +889,35 @@ function buildStateFileRestoreCommand( '[ ! -L "$dst" ] || { echo "refusing symlinked state target: $dst" >&2; exit 11; }', 'mkdir -p "$parent"', 'tmp="$(mktemp "${parent}/.nemoclaw-restore.XXXXXX")"', - "trap 'rm -f \"$tmp\"' EXIT", + 'trap \'rm -f "$tmp" "${anchor_tmp:-}"\' EXIT', 'cat > "$tmp"', 'chmod 640 "$tmp"', - 'mv -f "$tmp" "$dst"', ]; + 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"', diff --git a/test/openclaw-config-snapshot.test.ts b/test/openclaw-config-snapshot.test.ts index 8a7698ce47..579e438c56 100644 --- a/test/openclaw-config-snapshot.test.ts +++ b/test/openclaw-config-snapshot.test.ts @@ -30,6 +30,70 @@ function writeExecutable(filePath: string, source: string): void { fs.writeFileSync(filePath, source, { mode: 0o755 }); } +/** + * Write fake `openshell` and `ssh` executables that mirror the backup/restore + * SSH contract against a local sandbox-root directory, so backupSandboxState / + * restoreSandboxState exercise the real code path without a live sandbox. + */ +function writeFakeSandboxBins(binDir: string, fakeRoot: string): void { + 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")) { + const configPath = path.join(dir, "openclaw.json"); + const restored = readStdin(); + // Mirror the real restore command: the OpenClaw .last-good recovery anchor is + // refreshed from the staged temp BEFORE the live config is swapped (#5202). + if (cmd.includes("last-good")) { + fs.writeFileSync(path.join(dir, "openclaw.json.last-good"), restored); + } + fs.writeFileSync(configPath, restored); + 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); +`, + ); +} + function writeOpenClawRegistry(sandboxName: string): void { fs.mkdirSync(path.join(TMP_HOME, ".nemoclaw"), { recursive: true }); fs.writeFileSync( @@ -95,56 +159,7 @@ describe("OpenClaw durable config file (#5027)", () => { }; 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")) { - 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); -`, - ); + writeFakeSandboxBins(binDir, fakeRoot); writeOpenClawRegistry("alpha"); // writeOpenClawRegistry records agent:null → defaults to openclaw. @@ -227,4 +242,148 @@ process.exit(0); fs.rmSync(fixture, { recursive: true, force: true }); } }, 15000); + + it("preserves reporter-owned model metadata and mcp.servers across rebuild (#5202)", async () => { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-snapshot-5202-")); + 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 v0.0.62 config: a tuned inference provider model plus + // top-level mcp.servers, a real inline MCP secret, and a runtime gateway. + const original = { + models: { + mode: "merge", + providers: { + inference: { + baseUrl: "http://127.0.0.1:8789/v1", + apiKey: "unused", + api: "chat-completions", + models: [ + { + compat: { supportsUsageInStreaming: true, toolCallStyle: "openai" }, + id: "moonshotai/kimi-k2", + name: "stale-display-name", + reasoning: true, + input: ["text", "image"], + cost: { input: 0.5, output: 1.5, cacheRead: 0.1, cacheWrite: 0.2 }, + contextWindow: 131072, + maxTokens: 32768, + }, + ], + }, + }, + }, + mcp: { + servers: { + filesystem: { command: "npx", args: ["-y", "fs-server", "/work"] }, + github: { + command: "npx", + env: { GITHUB_TOKEN: "ghp_raw_secret", NODE_ENV: "production" }, + }, + }, + }, + gateway: { port: 18789, authToken: "gw-token" }, + }; + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), JSON.stringify(original, null, 2)); + + writeFakeSandboxBins(binDir, fakeRoot); + writeOpenClawRegistry("alpha"); + + process.env.NEMOCLAW_OPENSHELL_BIN = path.join(binDir, "openshell"); + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const backup = sandboxState.backupSandboxState("alpha"); + expect(backup.success).toBe(true); + + // Local backup keeps non-secret tuning + mcp.servers; secrets are stripped. + const backedUp = JSON.parse( + fs.readFileSync(path.join(backup.manifest!.backupPath, "openclaw.json"), "utf-8"), + ); + expect(backedUp.models.providers.inference.models[0].reasoning).toBe(true); + expect(backedUp.mcp.servers.filesystem.command).toBe("npx"); + expect(backedUp.mcp.servers.github.env.GITHUB_TOKEN).toBe("[STRIPPED_BY_MIGRATION]"); + expect(backedUp.mcp.servers.github.env.NODE_ENV).toBe("production"); + expect(backedUp.gateway).toBeUndefined(); + + // Fresh v0.0.63 rebuild output: same provider/model id, reset tuning, a + // fresh runtime gateway and a fresh base URL. + fs.writeFileSync( + path.join(openclawDir, "openclaw.json"), + JSON.stringify( + { + models: { + mode: "merge", + providers: { + inference: { + baseUrl: "http://127.0.0.1:9999/v1", + apiKey: "unused", + api: "chat-completions", + models: [ + { + id: "moonshotai/kimi-k2", + name: "fresh-display-name", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 4096, + }, + ], + }, + }, + }, + gateway: { auth: { token: "fresh-runtime-token" } }, + }, + null, + 2, + ), + ); + + const restore = sandboxState.restoreSandboxState("alpha", backup.manifest!.backupPath); + expect(restore.success).toBe(true); + + const after = JSON.parse(fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8")); + const model = after.models.providers.inference.models[0]; + // Reporter-owned tuning is restored. + expect(model.reasoning).toBe(true); + expect(model.cost).toEqual({ input: 0.5, output: 1.5, cacheRead: 0.1, cacheWrite: 0.2 }); + expect(model.maxTokens).toBe(32768); + expect(model.compat).toEqual({ supportsUsageInStreaming: true, toolCallStyle: "openai" }); + expect(model.input).toEqual(["text", "image"]); + // Fresh runtime routing/credentials win. + expect(model.id).toBe("moonshotai/kimi-k2"); + expect(model.name).toBe("fresh-display-name"); + expect(after.models.providers.inference.baseUrl).toBe("http://127.0.0.1:9999/v1"); + expect(after.gateway.auth.token).toBe("fresh-runtime-token"); + // Durable mcp.servers survives; the raw MCP secret never returns. + expect(after.mcp.servers.filesystem).toEqual({ + command: "npx", + args: ["-y", "fs-server", "/work"], + }); + expect(after.mcp.servers.github.env.GITHUB_TOKEN).toBe("[STRIPPED_BY_MIGRATION]"); + + // OpenClaw's .last-good recovery anchor is refreshed to the restored + // config so its integrity check does not revert the merge (#5202). + const lastGood = JSON.parse( + fs.readFileSync(path.join(openclawDir, "openclaw.json.last-good"), "utf-8"), + ); + expect(lastGood.models.providers.inference.models[0].reasoning).toBe(true); + expect(lastGood.models.providers.inference.models[0].maxTokens).toBe(32768); + expect(lastGood.mcp.servers.filesystem.command).toBe("npx"); + } 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/state-file-restore-command.test.ts b/test/state-file-restore-command.test.ts new file mode 100644 index 0000000000..c08f4a6e56 --- /dev/null +++ b/test/state-file-restore-command.test.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +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"); + +const spec = { path: "openclaw.json", strategy: "copy" } as const; + +describe("buildStateFileRestoreCommand (#5202)", () => { + it("refreshes the OpenClaw .last-good anchor before swapping the live config", () => { + const cmd = sandboxState.buildStateFileRestoreCommand("/sandbox/.openclaw", spec, true); + + // The anchor write targets openclaw.json.last-good and rejects symlinks. + expect(cmd).toContain('last_good="${dst}.last-good"'); + expect(cmd).toContain("refusing symlinked last-good target"); + + // The anchor is staged through a temp and installed via atomic rename, and + // fails closed (exit 14) so a partial write never reaches .last-good. + expect(cmd).toContain(".nemoclaw-lastgood.XXXXXX"); + expect(cmd).toContain('mv -f "$anchor_tmp" "$last_good"'); + expect(cmd).toContain("exit 14"); + + // Anchor must be installed BEFORE the live file is swapped, so OpenClaw's + // integrity watcher never observes a config that disagrees with .last-good. + const anchorIdx = cmd.indexOf('mv -f "$anchor_tmp" "$last_good"'); + const swapIdx = cmd.indexOf('mv -f "$tmp" "$dst"'); + expect(anchorIdx).toBeGreaterThanOrEqual(0); + expect(swapIdx).toBeGreaterThan(anchorIdx); + + // The .config-hash is still refreshed after the swap. + expect(cmd).toContain("sha256sum"); + }); + + it("does not touch the .last-good anchor for non-OpenClaw state restores", () => { + const cmd = sandboxState.buildStateFileRestoreCommand("/sandbox/.openclaw", spec, false); + expect(cmd).not.toContain("last-good"); + expect(cmd).not.toContain("sha256sum"); + expect(cmd).toContain('mv -f "$tmp" "$dst"'); + }); +});