From efd514d5e470fdf3e4858e61802c687b2ad31be0 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 10 Jun 2026 16:11:35 -0400 Subject: [PATCH 1/5] fix(openclaw): merge restored config with runtime state --- src/lib/state/sandbox.ts | 223 ++++++++++++++++++++++++++++++++++++++- test/snapshot.test.ts | 27 ++++- 2 files changed, 244 insertions(+), 6 deletions(-) diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index e45558aaa0..3ec77995a3 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -856,6 +856,17 @@ 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); @@ -888,12 +899,204 @@ 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 isPlainJsonObject(value: unknown): value is Record { + return isRecord(value); +} + +function cloneJson(value: T): T { + if (value === undefined) return undefined as T; + return JSON.parse(JSON.stringify(value)) as T; +} + +function mergeJsonObjects( + base: Record, + overlay: Record, +): Record { + const merged: Record = cloneJson(base); + for (const [key, value] of Object.entries(overlay)) { + const existing = merged[key]; + if (isPlainJsonObject(existing) && isPlainJsonObject(value)) { + merged[key] = mergeJsonObjects(existing, value); + } else { + merged[key] = cloneJson(value); + } + } + return merged; +} + +const NEMOCLAW_MANAGED_OPENCLAW_CHANNELS = new Set([ + "discord", + "slack", + "telegram", + "whatsapp", + "wechat", + "openclaw-weixin", +]); + +function mergeOpenClawChannels( + backupChannels: unknown, + currentChannels: unknown, +): Record | unknown { + if (!isPlainJsonObject(backupChannels)) return cloneJson(currentChannels); + if (!isPlainJsonObject(currentChannels)) return cloneJson(backupChannels); + + const merged: Record = cloneJson(currentChannels); + for (const [key, value] of Object.entries(backupChannels)) { + if (key === "defaults") { + merged[key] = + isPlainJsonObject(value) && isPlainJsonObject(merged[key]) + ? mergeJsonObjects(merged[key] as Record, value) + : cloneJson(value); + continue; + } + + if (NEMOCLAW_MANAGED_OPENCLAW_CHANNELS.has(key)) { + // Freshly generated channel blocks carry current OpenShell placeholder + // revisions and current start/stop/add/remove state. Never resurrect a + // managed channel that the fresh config omitted, and never overwrite a + // present managed channel with a stale backed-up account block. + continue; + } + + const existing = merged[key]; + merged[key] = + isPlainJsonObject(existing) && isPlainJsonObject(value) + ? mergeJsonObjects(existing, 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 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), + }; + } + return merged; +} + +function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown): unknown { + if (!isPlainJsonObject(backupPlugins)) return cloneJson(currentPlugins); + 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), + }; + } + return merged; +} + +export function mergeOpenClawRestoredConfig( + backedUpConfig: unknown, + currentConfig: unknown, +): unknown { + if (!isPlainJsonObject(backedUpConfig)) return cloneJson(currentConfig ?? backedUpConfig); + if (!isPlainJsonObject(currentConfig)) return cloneJson(backedUpConfig); + + const merged = mergeJsonObjects(currentConfig, backedUpConfig); + + // Runtime-owned sections are regenerated during rebuild and must survive the + // restore. The backup is sanitized (gateway removed) and may contain stale + // OpenShell resolve placeholder revisions, so it is unsafe to wholesale + // replace the fresh config with the backed-up file. + for (const key of ["gateway", "proxy", "diagnostics"] as const) { + if (key in currentConfig) merged[key] = cloneJson(currentConfig[key]); + else delete merged[key]; + } + + merged.channels = mergeOpenClawChannels(backedUpConfig.channels, currentConfig.channels); + merged.models = mergeOpenClawModels(backedUpConfig.models, currentConfig.models); + merged.plugins = mergeOpenClawPlugins(backedUpConfig.plugins, currentConfig.plugins); + + return merged; +} + +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, + dir: string, + spec: StateFileSpec, + backupPath: string, + mergeOpenClawConfig: boolean, +): Buffer { + 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; + } +} + function restoreStateFile( configFile: string, sandboxName: string, dir: string, spec: StateFileSpec, backupPath: string, + mergeOpenClawConfig = false, ): boolean { const localPath = path.join(backupPath, spec.path); if (!existsSync(localPath)) return true; @@ -901,7 +1104,14 @@ function restoreStateFile( const command = buildStateFileRestoreCommand(dir, spec); _log(`Restoring state file ${spec.path} (${spec.strategy})`); const result = spawnSync("ssh", [...sshArgs(configFile, sandboxName), command], { - input: readFileSync(localPath), + input: buildStateFileRestoreInput( + configFile, + sandboxName, + dir, + spec, + backupPath, + mergeOpenClawConfig, + ), stdio: ["pipe", "pipe", "pipe"], timeout: 120000, }); @@ -1476,7 +1686,16 @@ export function restoreSandboxState(sandboxName: string, backupPath: string): Re } for (const spec of localFiles) { - if (restoreStateFile(configFile, sandboxName, dir, spec, backupPath)) { + if ( + restoreStateFile( + configFile, + sandboxName, + dir, + spec, + backupPath, + shouldMergeOpenClawConfig(manifest, dir, spec), + ) + ) { restoredFiles.push(spec.path); } else { failedFiles.push(spec.path); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 4ad657d88d..407a1a1cfb 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -1454,18 +1454,37 @@ process.exit(0); expect(backedUp.mcpServers.github.env.NODE_ENV).toBe("production"); expect(backedUp.gateway).toBeUndefined(); - // Simulate a rebuild that recreates the sandbox with a settings-less - // config, then restore and confirm the reporter's settings survive. fs.writeFileSync( path.join(openclawDir, "openclaw.json"), - JSON.stringify({ models: { mode: "merge" } }, null, 2), + 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.models.providers.nvidia.models[0].id).toBe("moonshotai/kimi-k2"); + 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 { From eb6cdf83d13186f3fda65dfe4a39b7a8c6bb70a3 Mon Sep 17 00:00:00 2001 From: Julie Yaunches Date: Wed, 10 Jun 2026 16:29:39 -0400 Subject: [PATCH 2/5] fix(openclaw): isolate config restore ownership --- src/lib/state/openclaw-config-merge.test.ts | 78 +++++++++++ src/lib/state/openclaw-config-merge.ts | 142 ++++++++++++++++++++ src/lib/state/sandbox.ts | 130 +----------------- 3 files changed, 221 insertions(+), 129 deletions(-) create mode 100644 src/lib/state/openclaw-config-merge.test.ts create mode 100644 src/lib/state/openclaw-config-merge.ts diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts new file mode 100644 index 0000000000..ad0c632233 --- /dev/null +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { mergeOpenClawRestoredConfig } from "../../../dist/lib/state/openclaw-config-merge"; + +describe("mergeOpenClawRestoredConfig", () => { + it("keeps rebuilt runtime-owned config while restoring durable backup-only settings", () => { + const merged = mergeOpenClawRestoredConfig( + { + gateway: undefined, + models: { + providers: { + nvidia: { models: [{ id: "stale-model" }] }, + custom: { models: [{ id: "custom-model" }] }, + }, + }, + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + slack: { accounts: { default: { botToken: "[STRIPPED_BY_MIGRATION]" } } }, + matrix: { accounts: { default: { room: "#ops" } } }, + }, + plugins: { entries: { discord: { enabled: false }, customPlugin: { enabled: true } } }, + mcpServers: { filesystem: { command: "npx" } }, + customAgents: { researcher: { prompt: "be thorough" } }, + }, + { + gateway: { auth: { token: "fresh-token" } }, + diagnostics: { otel: true }, + models: { providers: { nvidia: { models: [{ id: "fresh-model" }] } } }, + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, + whatsapp: { accounts: { default: { enabled: true } } }, + }, + plugins: { entries: { discord: { enabled: true } } }, + }, + ); + + expect(merged).toMatchObject({ + gateway: { auth: { token: "fresh-token" } }, + diagnostics: { otel: true }, + models: { + providers: { + nvidia: { models: [{ id: "fresh-model" }] }, + custom: { models: [{ id: "custom-model" }] }, + }, + }, + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, + whatsapp: { accounts: { default: { enabled: true } } }, + matrix: { accounts: { default: { room: "#ops" } } }, + }, + plugins: { entries: { discord: { enabled: true }, customPlugin: { enabled: true } } }, + mcpServers: { filesystem: { command: "npx" } }, + customAgents: { researcher: { prompt: "be thorough" } }, + }); + expect((merged as { channels: Record }).channels.slack).toBeUndefined(); + }); + + it("does not resurrect managed channels when the rebuilt config omits channels", () => { + const merged = mergeOpenClawRestoredConfig( + { + channels: { + telegram: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + matrix: { accounts: { default: { room: "#ops" } } }, + }, + }, + { gateway: { auth: { token: "fresh-token" } } }, + ); + + expect(merged).toMatchObject({ + gateway: { auth: { token: "fresh-token" } }, + channels: { matrix: { accounts: { default: { room: "#ops" } } } }, + }); + expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); + }); +}); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts new file mode 100644 index 0000000000..2183f78799 --- /dev/null +++ b/src/lib/state/openclaw-config-merge.ts @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isRecord } from "../core/json-types.js"; + +/** + * Ownership contract for restoring OpenClaw's durable openclaw.json snapshot. + * + * Rebuild creates a fresh OpenClaw config before snapshot restore runs. The + * snapshot is sanitized and may contain stale OpenShell placeholder revisions, + * old channel enablement, or no gateway block at all, so restore must merge by + * ownership instead of replacing the freshly generated file wholesale. + */ +export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { + /** Fresh rebuild output owns these whole top-level runtime sections. */ + runtimeSections: ["gateway", "proxy", "diagnostics"], + /** 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"], + /** Durable user-owned top-level sections are inherited from the backup. */ + backupDurableSections: ["mcpServers", "customAgents", "agents"], +} as const; + +const MANAGED_OPENCLAW_CHANNELS = new Set( + OPENCLAW_CONFIG_RESTORE_OWNERSHIP.managedChannels, +); + +function isPlainJsonObject(value: unknown): value is Record { + return isRecord(value); +} + +function cloneJson(value: T): T { + if (value === undefined) return undefined as T; + return JSON.parse(JSON.stringify(value)) as T; +} + +function mergeJsonObjects( + base: Record, + overlay: Record, +): Record { + const merged: Record = cloneJson(base); + for (const [key, value] of Object.entries(overlay)) { + const existing = merged[key]; + if (isPlainJsonObject(existing) && isPlainJsonObject(value)) { + merged[key] = mergeJsonObjects(existing, value); + } else { + merged[key] = cloneJson(value); + } + } + return merged; +} + +function mergeOpenClawChannels(backupChannels: unknown, currentChannels: unknown): unknown { + if (!isPlainJsonObject(backupChannels)) return cloneJson(currentChannels); + + const merged: Record = isPlainJsonObject(currentChannels) + ? cloneJson(currentChannels) + : {}; + + for (const [key, value] of Object.entries(backupChannels)) { + if (key === "defaults") { + merged[key] = + isPlainJsonObject(value) && isPlainJsonObject(merged[key]) + ? mergeJsonObjects(merged[key] as Record, value) + : cloneJson(value); + continue; + } + + if (MANAGED_OPENCLAW_CHANNELS.has(key)) { + // Freshly generated channel blocks carry current OpenShell placeholder + // revisions and current start/stop/add/remove state. Never resurrect a + // managed channel that the fresh config omitted, and never overwrite a + // present managed channel with a stale backed-up account block. + continue; + } + + const existing = merged[key]; + merged[key] = + isPlainJsonObject(existing) && isPlainJsonObject(value) + ? mergeJsonObjects(existing, 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 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), + }; + } + return merged; +} + +function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown): unknown { + if (!isPlainJsonObject(backupPlugins)) return cloneJson(currentPlugins); + 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), + }; + } + return merged; +} + +export function mergeOpenClawRestoredConfig( + backedUpConfig: unknown, + currentConfig: unknown, +): unknown { + if (!isPlainJsonObject(backedUpConfig)) return cloneJson(currentConfig ?? backedUpConfig); + if (!isPlainJsonObject(currentConfig)) return cloneJson(backedUpConfig); + + const merged = mergeJsonObjects(currentConfig, backedUpConfig); + + for (const key of OPENCLAW_CONFIG_RESTORE_OWNERSHIP.runtimeSections) { + if (key in currentConfig) merged[key] = cloneJson(currentConfig[key]); + else delete merged[key]; + } + + merged.channels = mergeOpenClawChannels(backedUpConfig.channels, currentConfig.channels); + merged.models = mergeOpenClawModels(backedUpConfig.models, currentConfig.models); + merged.plugins = mergeOpenClawPlugins(backedUpConfig.plugins, currentConfig.plugins); + + return merged; +} diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 3ec77995a3..311f3513dc 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -31,6 +31,7 @@ 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 { shellQuote } from "../runner.js"; import { isSensitiveFile, sanitizeConfigFile } from "../security/credential-filter.js"; import * as registry from "./registry.js"; @@ -922,135 +923,6 @@ function readCurrentStateFile( return null; } -function isPlainJsonObject(value: unknown): value is Record { - return isRecord(value); -} - -function cloneJson(value: T): T { - if (value === undefined) return undefined as T; - return JSON.parse(JSON.stringify(value)) as T; -} - -function mergeJsonObjects( - base: Record, - overlay: Record, -): Record { - const merged: Record = cloneJson(base); - for (const [key, value] of Object.entries(overlay)) { - const existing = merged[key]; - if (isPlainJsonObject(existing) && isPlainJsonObject(value)) { - merged[key] = mergeJsonObjects(existing, value); - } else { - merged[key] = cloneJson(value); - } - } - return merged; -} - -const NEMOCLAW_MANAGED_OPENCLAW_CHANNELS = new Set([ - "discord", - "slack", - "telegram", - "whatsapp", - "wechat", - "openclaw-weixin", -]); - -function mergeOpenClawChannels( - backupChannels: unknown, - currentChannels: unknown, -): Record | unknown { - if (!isPlainJsonObject(backupChannels)) return cloneJson(currentChannels); - if (!isPlainJsonObject(currentChannels)) return cloneJson(backupChannels); - - const merged: Record = cloneJson(currentChannels); - for (const [key, value] of Object.entries(backupChannels)) { - if (key === "defaults") { - merged[key] = - isPlainJsonObject(value) && isPlainJsonObject(merged[key]) - ? mergeJsonObjects(merged[key] as Record, value) - : cloneJson(value); - continue; - } - - if (NEMOCLAW_MANAGED_OPENCLAW_CHANNELS.has(key)) { - // Freshly generated channel blocks carry current OpenShell placeholder - // revisions and current start/stop/add/remove state. Never resurrect a - // managed channel that the fresh config omitted, and never overwrite a - // present managed channel with a stale backed-up account block. - continue; - } - - const existing = merged[key]; - merged[key] = - isPlainJsonObject(existing) && isPlainJsonObject(value) - ? mergeJsonObjects(existing, 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 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), - }; - } - return merged; -} - -function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown): unknown { - if (!isPlainJsonObject(backupPlugins)) return cloneJson(currentPlugins); - 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), - }; - } - return merged; -} - -export function mergeOpenClawRestoredConfig( - backedUpConfig: unknown, - currentConfig: unknown, -): unknown { - if (!isPlainJsonObject(backedUpConfig)) return cloneJson(currentConfig ?? backedUpConfig); - if (!isPlainJsonObject(currentConfig)) return cloneJson(backedUpConfig); - - const merged = mergeJsonObjects(currentConfig, backedUpConfig); - - // Runtime-owned sections are regenerated during rebuild and must survive the - // restore. The backup is sanitized (gateway removed) and may contain stale - // OpenShell resolve placeholder revisions, so it is unsafe to wholesale - // replace the fresh config with the backed-up file. - for (const key of ["gateway", "proxy", "diagnostics"] as const) { - if (key in currentConfig) merged[key] = cloneJson(currentConfig[key]); - else delete merged[key]; - } - - merged.channels = mergeOpenClawChannels(backedUpConfig.channels, currentConfig.channels); - merged.models = mergeOpenClawModels(backedUpConfig.models, currentConfig.models); - merged.plugins = mergeOpenClawPlugins(backedUpConfig.plugins, currentConfig.plugins); - - return merged; -} - function shouldMergeOpenClawConfig( manifest: RebuildManifest, dir: string, From 73ab69ba93d69b158d0856ec63398afe9828830c Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 14:20:34 -0700 Subject: [PATCH 3/5] fix(openclaw): fail closed on config restore merge errors Signed-off-by: Carlos Villela --- src/lib/state/openclaw-config-merge.test.ts | 20 ++ src/lib/state/sandbox.ts | 32 ++- test/openclaw-config-restore.test.ts | 259 ++++++++++++++++++++ test/snapshot-gateway-guard.test.ts | 10 +- test/snapshot.test.ts | 5 +- 5 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 test/openclaw-config-restore.test.ts diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index ad0c632233..7ff3acbe3d 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -75,4 +75,24 @@ describe("mergeOpenClawRestoredConfig", () => { }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); }); + + it("documents provider and plugin behavior when current generated maps are absent", () => { + const merged = mergeOpenClawRestoredConfig( + { + models: { providers: { custom: { models: [{ id: "custom-model" }] } } }, + plugins: { entries: { customPlugin: { enabled: true } } }, + }, + { + gateway: { auth: { token: "fresh-token" } }, + models: { mode: "merge" }, + plugins: { mode: "auto" }, + }, + ); + + expect(merged).toMatchObject({ + gateway: { auth: { token: "fresh-token" } }, + models: { mode: "merge", providers: { custom: { models: [{ id: "custom-model" }] } } }, + plugins: { mode: "auto", entries: { customPlugin: { enabled: true } } }, + }); + }); }); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 311f3513dc..97f336ae9d 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -942,13 +942,18 @@ 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; + if (!currentContents) { + _log( + "FAILED: openclaw.json selective merge could not read the current rebuild config; leaving current file intact", + ); + return null; + } try { const backedUpConfig = parseJson(backupContents.toString("utf-8")); const currentConfig = parseJson(currentContents.toString("utf-8")); @@ -956,9 +961,9 @@ function buildStateFileRestoreInput( 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)}`, + `FAILED: openclaw.json selective merge failed; leaving current file intact: ${err instanceof Error ? err.message : String(err)}`, ); - return backupContents; + return null; } } @@ -975,15 +980,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, }); diff --git a/test/openclaw-config-restore.test.ts b/test/openclaw-config-restore.test.ts new file mode 100644 index 0000000000..af079a4dc2 --- /dev/null +++ b/test/openclaw-config-restore.test.ts @@ -0,0 +1,259 @@ +// 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, beforeEach, describe, expect, it } from "vitest"; + +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-restore-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); + +type SandboxStateModule = typeof import("../dist/lib/state/sandbox.js"); +type CurrentOpenClawReadMode = "file" | "missing" | "invalid-json"; + +const sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "dist", "lib", "state", "sandbox.js")).href +)) as SandboxStateModule; + +beforeEach(() => { + fs.rmSync(BACKUPS_ROOT, { recursive: true, force: true }); +}); + +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, + }, + }, + }), + ); +} + +function writeFakeOpenshell(binDir: string): string { + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + return openshell; +} + +function writeBackup(sandboxName: string, dirName: string): { backupPath: string } { + const backupPath = path.join(BACKUPS_ROOT, sandboxName, dirName); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify( + { + version: 1, + sandboxName, + timestamp: dirName, + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + stateFiles: [{ path: "openclaw.json", strategy: "copy" }], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + }, + null, + 2, + ), + ); + return { backupPath }; +} + +function freshRuntimeConfig(): string { + return JSON.stringify( + { + gateway: { auth: { token: "fresh-runtime-token" } }, + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, + }, + models: { + providers: { nvidia: { apiKey: "unused", models: [{ id: "nvidia/nemotron" }] } }, + }, + }, + null, + 2, + ); +} + +function staleBackupConfig(): string { + return JSON.stringify( + { + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + slack: { accounts: { default: { botToken: "[STRIPPED_BY_MIGRATION]" } } }, + }, + models: { + providers: { nvidia: { apiKey: "unused", models: [{ id: "stale-model" }] } }, + }, + mcpServers: { filesystem: { command: "npx" } }, + }, + null, + 2, + ); +} + +function restoreOpenClawStateFileWithFakeSsh(options: { + backupContents: string; + currentContents: string; + currentReadMode?: CurrentOpenClawReadMode; +}): { + restore: ReturnType; + currentContents: string; +} { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-restore-fixture-")); + 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 }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), options.currentContents); + + process.env.NEMOCLAW_OPENSHELL_BIN = writeFakeOpenshell(binDir); + 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] || ""; +const currentReadMode = ${JSON.stringify(options.currentReadMode ?? "file")}; +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("openclaw.json") && cmd.includes("cat --")) { + if (currentReadMode === "missing") process.exit(2); + if (currentReadMode === "invalid-json") { + process.stdout.write("{ invalid current json"); + process.exit(0); + } + 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"); + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const { backupPath } = writeBackup("alpha", "2026-06-10T20-00-00-000Z"); + fs.writeFileSync(path.join(backupPath, "openclaw.json"), options.backupContents); + + const restore = sandboxState.restoreSandboxState("alpha", backupPath); + return { + restore, + currentContents: fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8"), + }; + } 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 }); + } +} + +describe("OpenClaw config restore failure modes", () => { + it("fails closed when the current rebuilt openclaw.json cannot be read", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: staleBackupConfig(), + currentContents: freshRuntimeConfig(), + currentReadMode: "missing", + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).channels.slack).toBeUndefined(); + }); + + it("fails closed when the current rebuilt openclaw.json is invalid JSON", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: staleBackupConfig(), + currentContents: freshRuntimeConfig(), + currentReadMode: "invalid-json", + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).models.providers.nvidia.models[0].id).toBe( + "nvidia/nemotron", + ); + }); + + it("fails closed when the backed-up openclaw.json is invalid JSON", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: "{ invalid backup json", + currentContents: freshRuntimeConfig(), + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).channels.discord.accounts.default.token).toBe( + "openshell:resolve:env:v222_TOKEN", + ); + }); +}); diff --git a/test/snapshot-gateway-guard.test.ts b/test/snapshot-gateway-guard.test.ts index b339bcff77..64c14e29ce 100644 --- a/test/snapshot-gateway-guard.test.ts +++ b/test/snapshot-gateway-guard.test.ts @@ -130,7 +130,10 @@ function makeHealthyVmGatewayEnv(prefix: string): Record { "exit 0", ]); - writeExecutable(path.join(localBin, "ssh"), ["exit 0"]); + writeExecutable(path.join(localBin, "ssh"), [ + 'printf "%s" "$*" | grep -q "openclaw.json" && printf "%s" "$*" | grep -q "cat --" && exit 2', + "exit 0", + ]); writeExecutable(path.join(localBin, "docker"), [ 'if [ "$1" = "inspect" ]; then echo "false"; exit 0; fi', "exit 0", @@ -169,7 +172,10 @@ function makeVmRestoreToEnv( "exit 0", ]); - writeExecutable(path.join(localBin, "ssh"), ["exit 0"]); + writeExecutable(path.join(localBin, "ssh"), [ + 'printf "%s" "$*" | grep -q "openclaw.json" && printf "%s" "$*" | grep -q "cat --" && exit 2', + "exit 0", + ]); // `docker exec` must never run: if the fast path regresses, // resolveSrcPodImage falls into the kubectl-via-docker probe and this diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 407a1a1cfb..0c25890037 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -4,7 +4,6 @@ // Tests for snapshot versioning and naming added alongside the --name flag: // - 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"; @@ -447,7 +446,7 @@ if (cmd.includes("[ -d ")) { process.exit(0); } if (cmd.includes("find ")) { - process.exit(0); + process.exit(cmd.includes("openclaw.json") ? 2 : 0); } if (cmd.includes("tar -cf -")) { const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], { @@ -517,7 +516,7 @@ if (cmd.includes("[ -d ")) { process.exit(0); } if (cmd.includes("find ")) { - process.exit(0); + process.exit(cmd.includes("openclaw.json") ? 2 : 0); } if (cmd.includes("tar -cf -")) { const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], { From ee6c67547123a0758efeae783a4dd17c49d80d51 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Wed, 10 Jun 2026 14:20:34 -0700 Subject: [PATCH 4/5] fix(openclaw): fail closed on config restore merge errors Signed-off-by: Carlos Villela --- src/lib/state/openclaw-config-merge.test.ts | 20 ++ src/lib/state/sandbox.ts | 32 ++- test/openclaw-config-restore.test.ts | 259 ++++++++++++++++++++ test/snapshot-gateway-guard.test.ts | 10 +- test/snapshot.test.ts | 5 +- 5 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 test/openclaw-config-restore.test.ts diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index ad0c632233..7ff3acbe3d 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -75,4 +75,24 @@ describe("mergeOpenClawRestoredConfig", () => { }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); }); + + it("documents provider and plugin behavior when current generated maps are absent", () => { + const merged = mergeOpenClawRestoredConfig( + { + models: { providers: { custom: { models: [{ id: "custom-model" }] } } }, + plugins: { entries: { customPlugin: { enabled: true } } }, + }, + { + gateway: { auth: { token: "fresh-token" } }, + models: { mode: "merge" }, + plugins: { mode: "auto" }, + }, + ); + + expect(merged).toMatchObject({ + gateway: { auth: { token: "fresh-token" } }, + models: { mode: "merge", providers: { custom: { models: [{ id: "custom-model" }] } } }, + plugins: { mode: "auto", entries: { customPlugin: { enabled: true } } }, + }); + }); }); diff --git a/src/lib/state/sandbox.ts b/src/lib/state/sandbox.ts index 311f3513dc..97f336ae9d 100644 --- a/src/lib/state/sandbox.ts +++ b/src/lib/state/sandbox.ts @@ -942,13 +942,18 @@ 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; + if (!currentContents) { + _log( + "FAILED: openclaw.json selective merge could not read the current rebuild config; leaving current file intact", + ); + return null; + } try { const backedUpConfig = parseJson(backupContents.toString("utf-8")); const currentConfig = parseJson(currentContents.toString("utf-8")); @@ -956,9 +961,9 @@ function buildStateFileRestoreInput( 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)}`, + `FAILED: openclaw.json selective merge failed; leaving current file intact: ${err instanceof Error ? err.message : String(err)}`, ); - return backupContents; + return null; } } @@ -975,15 +980,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, }); diff --git a/test/openclaw-config-restore.test.ts b/test/openclaw-config-restore.test.ts new file mode 100644 index 0000000000..af079a4dc2 --- /dev/null +++ b/test/openclaw-config-restore.test.ts @@ -0,0 +1,259 @@ +// 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, beforeEach, describe, expect, it } from "vitest"; + +const ORIGINAL_HOME = process.env.HOME; +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-restore-")); +process.env.HOME = TMP_HOME; + +const REPO_ROOT = path.join(import.meta.dirname, ".."); +const BACKUPS_ROOT = path.join(TMP_HOME, ".nemoclaw", "rebuild-backups"); + +type SandboxStateModule = typeof import("../dist/lib/state/sandbox.js"); +type CurrentOpenClawReadMode = "file" | "missing" | "invalid-json"; + +const sandboxState = (await import( + pathToFileURL(path.join(REPO_ROOT, "dist", "lib", "state", "sandbox.js")).href +)) as SandboxStateModule; + +beforeEach(() => { + fs.rmSync(BACKUPS_ROOT, { recursive: true, force: true }); +}); + +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, + }, + }, + }), + ); +} + +function writeFakeOpenshell(binDir: string): string { + const openshell = path.join(binDir, "openshell"); + writeExecutable( + openshell, + `#!/usr/bin/env node +const args = process.argv.slice(2); +if (args[0] === "sandbox" && args[1] === "ssh-config") { + process.stdout.write("Host openshell-alpha\\n HostName 127.0.0.1\\n User sandbox\\n"); + process.exit(0); +} +process.exit(0); +`, + ); + return openshell; +} + +function writeBackup(sandboxName: string, dirName: string): { backupPath: string } { + const backupPath = path.join(BACKUPS_ROOT, sandboxName, dirName); + fs.mkdirSync(backupPath, { recursive: true }); + fs.writeFileSync( + path.join(backupPath, "rebuild-manifest.json"), + JSON.stringify( + { + version: 1, + sandboxName, + timestamp: dirName, + agentType: "openclaw", + agentVersion: null, + expectedVersion: null, + stateDirs: [], + stateFiles: [{ path: "openclaw.json", strategy: "copy" }], + dir: "/sandbox/.openclaw", + backupPath, + blueprintDigest: null, + }, + null, + 2, + ), + ); + return { backupPath }; +} + +function freshRuntimeConfig(): string { + return JSON.stringify( + { + gateway: { auth: { token: "fresh-runtime-token" } }, + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v222_TOKEN" } } }, + }, + models: { + providers: { nvidia: { apiKey: "unused", models: [{ id: "nvidia/nemotron" }] } }, + }, + }, + null, + 2, + ); +} + +function staleBackupConfig(): string { + return JSON.stringify( + { + channels: { + discord: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + slack: { accounts: { default: { botToken: "[STRIPPED_BY_MIGRATION]" } } }, + }, + models: { + providers: { nvidia: { apiKey: "unused", models: [{ id: "stale-model" }] } }, + }, + mcpServers: { filesystem: { command: "npx" } }, + }, + null, + 2, + ); +} + +function restoreOpenClawStateFileWithFakeSsh(options: { + backupContents: string; + currentContents: string; + currentReadMode?: CurrentOpenClawReadMode; +}): { + restore: ReturnType; + currentContents: string; +} { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-restore-fixture-")); + 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 }); + fs.writeFileSync(path.join(openclawDir, "openclaw.json"), options.currentContents); + + process.env.NEMOCLAW_OPENSHELL_BIN = writeFakeOpenshell(binDir); + 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] || ""; +const currentReadMode = ${JSON.stringify(options.currentReadMode ?? "file")}; +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("openclaw.json") && cmd.includes("cat --")) { + if (currentReadMode === "missing") process.exit(2); + if (currentReadMode === "invalid-json") { + process.stdout.write("{ invalid current json"); + process.exit(0); + } + 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"); + process.env.PATH = `${binDir}:${oldPath || ""}`; + + const { backupPath } = writeBackup("alpha", "2026-06-10T20-00-00-000Z"); + fs.writeFileSync(path.join(backupPath, "openclaw.json"), options.backupContents); + + const restore = sandboxState.restoreSandboxState("alpha", backupPath); + return { + restore, + currentContents: fs.readFileSync(path.join(openclawDir, "openclaw.json"), "utf-8"), + }; + } 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 }); + } +} + +describe("OpenClaw config restore failure modes", () => { + it("fails closed when the current rebuilt openclaw.json cannot be read", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: staleBackupConfig(), + currentContents: freshRuntimeConfig(), + currentReadMode: "missing", + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).channels.slack).toBeUndefined(); + }); + + it("fails closed when the current rebuilt openclaw.json is invalid JSON", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: staleBackupConfig(), + currentContents: freshRuntimeConfig(), + currentReadMode: "invalid-json", + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).models.providers.nvidia.models[0].id).toBe( + "nvidia/nemotron", + ); + }); + + it("fails closed when the backed-up openclaw.json is invalid JSON", () => { + const { restore, currentContents } = restoreOpenClawStateFileWithFakeSsh({ + backupContents: "{ invalid backup json", + currentContents: freshRuntimeConfig(), + }); + + expect(restore.success).toBe(false); + expect(restore.restoredFiles).toEqual([]); + expect(restore.failedFiles).toEqual(["openclaw.json"]); + expect(JSON.parse(currentContents).gateway.auth.token).toBe("fresh-runtime-token"); + expect(JSON.parse(currentContents).channels.discord.accounts.default.token).toBe( + "openshell:resolve:env:v222_TOKEN", + ); + }); +}); diff --git a/test/snapshot-gateway-guard.test.ts b/test/snapshot-gateway-guard.test.ts index b339bcff77..64c14e29ce 100644 --- a/test/snapshot-gateway-guard.test.ts +++ b/test/snapshot-gateway-guard.test.ts @@ -130,7 +130,10 @@ function makeHealthyVmGatewayEnv(prefix: string): Record { "exit 0", ]); - writeExecutable(path.join(localBin, "ssh"), ["exit 0"]); + writeExecutable(path.join(localBin, "ssh"), [ + 'printf "%s" "$*" | grep -q "openclaw.json" && printf "%s" "$*" | grep -q "cat --" && exit 2', + "exit 0", + ]); writeExecutable(path.join(localBin, "docker"), [ 'if [ "$1" = "inspect" ]; then echo "false"; exit 0; fi', "exit 0", @@ -169,7 +172,10 @@ function makeVmRestoreToEnv( "exit 0", ]); - writeExecutable(path.join(localBin, "ssh"), ["exit 0"]); + writeExecutable(path.join(localBin, "ssh"), [ + 'printf "%s" "$*" | grep -q "openclaw.json" && printf "%s" "$*" | grep -q "cat --" && exit 2', + "exit 0", + ]); // `docker exec` must never run: if the fast path regresses, // resolveSrcPodImage falls into the kubectl-via-docker probe and this diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 407a1a1cfb..0c25890037 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -4,7 +4,6 @@ // Tests for snapshot versioning and naming added alongside the --name flag: // - 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"; @@ -447,7 +446,7 @@ if (cmd.includes("[ -d ")) { process.exit(0); } if (cmd.includes("find ")) { - process.exit(0); + process.exit(cmd.includes("openclaw.json") ? 2 : 0); } if (cmd.includes("tar -cf -")) { const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], { @@ -517,7 +516,7 @@ if (cmd.includes("[ -d ")) { process.exit(0); } if (cmd.includes("find ")) { - process.exit(0); + process.exit(cmd.includes("openclaw.json") ? 2 : 0); } if (cmd.includes("tar -cf -")) { const r = spawnSync("tar", ["-cf", "-", "-C", ${JSON.stringify(openclawDir)}, ...existingDirs], { From ad73487bc54fa9ce37aed614ab3157f3557747f0 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Thu, 11 Jun 2026 18:54:44 -0700 Subject: [PATCH 5/5] fix(openclaw): refresh config hash after rebuild writes --- scripts/nemoclaw-start.sh | 6 ++ .../sandbox/rebuild-config-hash.test.ts | 73 +++++++++++++++++++ src/lib/actions/sandbox/rebuild.ts | 56 +++++++++++++- test/repro-4538-raw-doctor-perms.test.ts | 17 +++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 src/lib/actions/sandbox/rebuild-config-hash.test.ts diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 9a425036a8..7a9eeb65fe 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -2256,6 +2256,12 @@ _nemoclaw_restore_mutable_config_perms() { chmod -R g+rwX,o-rwx "$_nemoclaw_oc_dir" 2>/dev/null || true find "$_nemoclaw_oc_dir" -type d -exec chmod g+s {} + 2>/dev/null || true chmod 2770 "$_nemoclaw_oc_dir" 2>/dev/null || true + if [ ! -L "$_nemoclaw_oc_dir" ] && + [ ! -L "$_nemoclaw_oc_dir/openclaw.json" ] && + [ ! -L "$_nemoclaw_oc_dir/.config-hash" ] && + [ -f "$_nemoclaw_oc_dir/openclaw.json" ]; then + (cd "$_nemoclaw_oc_dir" && sha256sum openclaw.json >.config-hash) 2>/dev/null || true + fi chmod 660 "$_nemoclaw_oc_dir/openclaw.json" "$_nemoclaw_oc_dir/.config-hash" 2>/dev/null || true # Keep the recovery baseline out of the group-writable contract — it is a # read-only trust anchor (root:sandbox 0440 when root re-locks it). The diff --git a/src/lib/actions/sandbox/rebuild-config-hash.test.ts b/src/lib/actions/sandbox/rebuild-config-hash.test.ts new file mode 100644 index 0000000000..24e7011ef5 --- /dev/null +++ b/src/lib/actions/sandbox/rebuild-config-hash.test.ts @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +type RebuildModule = typeof import("../../../../dist/lib/actions/sandbox/rebuild"); + +const requireDist = createRequire(import.meta.url); +const { buildRefreshMutableOpenClawConfigHashCommand } = requireDist( + "../../../../dist/lib/actions/sandbox/rebuild.js", +) as RebuildModule; + +function sha256Hex(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function runRefresh(configDir: string): ReturnType { + return spawnSync("bash", ["-c", buildRefreshMutableOpenClawConfigHashCommand(configDir)], { + encoding: "utf-8", + timeout: 5000, + }); +} + +describe.skipIf(process.platform !== "linux")("OpenClaw rebuild config hash refresh", () => { + it("refreshes .config-hash for the current openclaw.json", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-hash-")); + const configDir = path.join(tmpDir, ".openclaw"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, '{"gateway":{"auth":{"token":"fresh"}}}\n'); + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + + const result = runRefresh(configDir); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); + expect(fs.readFileSync(hashPath, "utf-8")).toBe(`${sha256Hex(configPath)} openclaw.json\n`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("refuses to refresh through a symlinked config file", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-rebuild-hash-symlink-")); + const configDir = path.join(tmpDir, ".openclaw"); + const targetPath = path.join(tmpDir, "target-openclaw.json"); + const configPath = path.join(configDir, "openclaw.json"); + const hashPath = path.join(configDir, ".config-hash"); + try { + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(targetPath, '{"gateway":{"auth":{"token":"target"}}}\n'); + fs.symlinkSync(targetPath, configPath); + fs.writeFileSync(hashPath, "stale openclaw.json\n"); + + const result = runRefresh(configDir); + + expect(result.status).toBe(11); + expect(result.stderr).toContain("refusing symlinked OpenClaw config file"); + expect(fs.readFileSync(hashPath, "utf-8")).toBe("stale openclaw.json\n"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index b210b241f6..7be232ed7d 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -56,6 +56,7 @@ import { printSandboxListFailureWithRecoveryContext, } from "../../openshell-sandbox-list"; import * as policies from "../../policy"; +import { shellQuote } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; import * as sandboxVersion from "../../sandbox/version"; import { redact } from "../../security/redact"; @@ -83,6 +84,43 @@ import { relockRebuildShieldsWindow, } from "./rebuild-shields"; +export function buildRefreshMutableOpenClawConfigHashCommand( + configDir = "/sandbox/.openclaw", +): string { + return [ + `config_dir=${shellQuote(configDir)}`, + 'config_file="${config_dir}/openclaw.json"', + 'hash_file="${config_dir}/.config-hash"', + '[ -d "$config_dir" ] || exit 0', + '[ ! -L "$config_dir" ] || { echo "refusing symlinked OpenClaw config dir: $config_dir" >&2; exit 10; }', + '[ ! -L "$config_file" ] || { echo "refusing symlinked OpenClaw config file: $config_file" >&2; exit 11; }', + '[ ! -L "$hash_file" ] || { echo "refusing symlinked OpenClaw config hash: $hash_file" >&2; exit 12; }', + 'owner="$(stat -c "%U" "$config_dir" 2>/dev/null || echo unknown)"', + '[ "$owner" != "root" ] || exit 0', + '[ -f "$config_file" ] || exit 0', + 'cd "$config_dir" || exit 13', + "sha256sum openclaw.json > .config-hash", + "chmod 660 .config-hash 2>/dev/null || true", + ].join("; "); +} + +function refreshMutableOpenClawConfigHashAfterPostRestoreWrites( + sandboxName: string, + log: (msg: string) => void, +): boolean { + const result = executeSandboxCommand(sandboxName, buildRefreshMutableOpenClawConfigHashCommand()); + if (result && result.status === 0) { + log("Mutable OpenClaw config hash refreshed after post-restore config writes"); + return true; + } + + const detail = result + ? [result.stderr, result.stdout].filter(Boolean).join("; ") || `exit ${result.status}` + : "could not obtain sandbox SSH config"; + console.error(` ${YW}⚠${R} Mutable OpenClaw config hash was not refreshed: ${redact(detail)}`); + return false; +} + /** * Emit timestamped rebuild diagnostics when verbose rebuild logging is enabled. */ @@ -1103,6 +1141,7 @@ export async function rebuildSandbox( // could not verify the contract — the rebuilt sandbox may still EACCES on // gateway-side config writes, so the final result is downgraded below. let mutablePermsRepairUnverified = false; + let mutableConfigHashRefreshUnverified = false; if (agentDef.name === "openclaw") { // openclaw doctor --fix validates and repairs directory structure. // Idempotent and safe — catches structural changes between OpenClaw versions @@ -1147,6 +1186,16 @@ export async function rebuildSandbox( ); } + // The post-restore structure repair and seed helper can rewrite + // openclaw.json after restoreStateFile has already refreshed + // .config-hash. Refresh the mutable hash here so the gateway token and + // channel seed changes are integrity-valid before the sandbox is handed + // back to the user. + log("Refreshing mutable OpenClaw config hash after post-restore config writes"); + if (!refreshMutableOpenClawConfigHashAfterPostRestoreWrites(sandboxName, log)) { + mutableConfigHashRefreshUnverified = true; + } + // #4538: `openclaw doctor --fix` enforces a single-user 700/600 state // layout, which silently tightens NemoClaw's mutable config contract // (setgid + group-writable /sandbox/.openclaw and group-writable @@ -1208,7 +1257,7 @@ export async function rebuildSandbox( if (!relockShieldsIfNeeded(true)) return bail("Failed to re-apply shields lockdown."); console.log(""); - if (restoreSucceeded && !mutablePermsRepairUnverified) { + if (restoreSucceeded && !mutablePermsRepairUnverified && !mutableConfigHashRefreshUnverified) { console.log(` ${G}\u2713${R} Sandbox '${sandboxName}' rebuilt successfully`); if (staleRecovery) { console.log( @@ -1236,6 +1285,11 @@ export async function rebuildSandbox( ` Mutable config permissions were not verified \u2014 run \`${CLI_NAME} ${sandboxName} doctor --fix\` to restore the OpenClaw config permission contract`, ); } + if (mutableConfigHashRefreshUnverified) { + console.log( + ` Mutable OpenClaw config hash was not refreshed \u2014 restart the sandbox or re-run \`${CLI_NAME} ${sandboxName} rebuild\` before relying on config integrity checks`, + ); + } } // Stale recovery reset the shields state to mutable (the gone sandbox's lock // seal could not carry over to the fresh image). If lockdown had been enabled, diff --git a/test/repro-4538-raw-doctor-perms.test.ts b/test/repro-4538-raw-doctor-perms.test.ts index a47a937866..aa9725fb04 100644 --- a/test/repro-4538-raw-doctor-perms.test.ts +++ b/test/repro-4538-raw-doctor-perms.test.ts @@ -31,6 +31,7 @@ */ import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -65,6 +66,17 @@ function modeBits(filePath: string): number { return fs.statSync(filePath).mode & 0o7777; } +function sha256Hex(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function openClawConfigHashMatches(configDir: string): boolean { + const configFile = path.join(configDir, "openclaw.json"); + const hashFile = path.join(configDir, ".config-hash"); + const [digest, fileName] = fs.readFileSync(hashFile, "utf-8").trim().split(/\s+/); + return digest === sha256Hex(configFile) && fileName === "openclaw.json"; +} + function mkdtempOnPosixFs(prefix: string): string { const roots = process.platform === "linux" ? ["/tmp", os.tmpdir()] : [os.tmpdir()]; let lastError: unknown = null; @@ -126,6 +138,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { // Config + hash: group-writable so the gateway UID can persist edits. expect(modeBits(configFile)).toBe(0o660); expect(modeBits(hashFile)).toBe(0o660); + expect(openClawConfigHashMatches(configDir)).toBe(true); // Recursive: nested dirs regain setgid + group access too. expect(modeBits(nestedDir) & 0o2070).toBe(0o2070); } finally { @@ -183,6 +196,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { "command() {", ' if [ "${1:-}" = "openclaw" ]; then', ' chmod 700 "$OPENCLAW_STATE_DIR";', + ' printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json";', ' chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json";', " return 7;", " fi", @@ -206,6 +220,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { // ...and still restores the mutable contract afterwards. expect(modeBits(configDir)).toBe(0o2770); expect(modeBits(configFile)).toBe(0o660); + expect(openClawConfigHashMatches(configDir)).toBe(true); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -229,6 +244,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { "command() {", ' if [ "${1:-}" = "openclaw" ]; then', ' chmod 700 "$OPENCLAW_STATE_DIR";', + ' printf \'{"doctor":true}\\n\' > "$OPENCLAW_STATE_DIR/openclaw.json";', ' chmod 600 "$OPENCLAW_STATE_DIR/openclaw.json";', " return 7;", " fi", @@ -251,6 +267,7 @@ describe("#4538 raw `openclaw doctor --fix` mutable-perm restore", () => { // ...but the in-function restore ran before the function returned. expect(modeBits(configDir)).toBe(0o2770); expect(modeBits(configFile)).toBe(0o660); + expect(openClawConfigHashMatches(configDir)).toBe(true); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }