Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/lib/state/openclaw-config-merge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }).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<string, unknown> }).channels.telegram).toBeUndefined();
});
});
142 changes: 142 additions & 0 deletions src/lib/state/openclaw-config-merge.ts
Original file line number Diff line number Diff line change
@@ -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<string>(
OPENCLAW_CONFIG_RESTORE_OWNERSHIP.managedChannels,
);

function isPlainJsonObject(value: unknown): value is Record<string, unknown> {
return isRecord(value);
}

function cloneJson<T>(value: T): T {
if (value === undefined) return undefined as T;
return JSON.parse(JSON.stringify(value)) as T;
}

function mergeJsonObjects(
base: Record<string, unknown>,
overlay: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = 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<string, unknown> = 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<string, unknown>, 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;
}
95 changes: 93 additions & 2 deletions src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -856,6 +857,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);
Expand Down Expand Up @@ -888,20 +900,90 @@ 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,
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<unknown>(backupContents.toString("utf-8"));
const currentConfig = parseJson<unknown>(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;

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,
});
Expand Down Expand Up @@ -1476,7 +1558,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);
Expand Down
27 changes: 23 additions & 4 deletions test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading