Skip to content
68 changes: 68 additions & 0 deletions src/lib/state/openclaw-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,72 @@ describe("mergeOpenClawRestoredConfig", () => {
});
expect((merged as { channels: Record<string, unknown> }).channels.telegram).toBeUndefined();
});

it("preserves backup provider and plugin entries when current entry maps are absent", () => {
const merged = mergeOpenClawRestoredConfig(
{
models: { providers: { custom: { models: [{ id: "custom-model" }] } } },
plugins: { entries: { customPlugin: { enabled: true } } },
},
{ models: { mode: "route-through-gateway" }, plugins: { load: { paths: ["/plugins"] } } },
);

expect(merged).toMatchObject({
models: {
mode: "route-through-gateway",
providers: { custom: { models: [{ id: "custom-model" }] } },
},
plugins: {
load: { paths: ["/plugins"] },
entries: { customPlugin: { enabled: true } },
},
});
});

it("keeps current provider and plugin entries for matching keys", () => {
const merged = mergeOpenClawRestoredConfig(
{
models: {
providers: {
nvidia: { models: [{ id: "stale" }], apiKey: "unused" },
custom: { models: [{ id: "stale-custom" }] },
backupOnly: { models: [{ id: "backup-only" }] },
},
},
plugins: {
entries: {
discord: { enabled: false },
customPlugin: { enabled: true },
backupOnlyPlugin: { enabled: true },
},
},
},
{
models: {
providers: {
nvidia: { models: [{ id: "fresh" }], apiKey: "unused" },
custom: { models: [{ id: "fresh-custom" }] },
},
},
plugins: { entries: { discord: { enabled: true }, customPlugin: { enabled: false } } },
},
);

expect(merged).toMatchObject({
models: {
providers: {
nvidia: { models: [{ id: "fresh" }], apiKey: "unused" },
custom: { models: [{ id: "fresh-custom" }] },
backupOnly: { models: [{ id: "backup-only" }] },
},
},
plugins: {
entries: {
discord: { enabled: true },
customPlugin: { enabled: false },
backupOnlyPlugin: { enabled: true },
},
},
});
});
});
37 changes: 17 additions & 20 deletions src/lib/state/openclaw-config-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,21 +84,26 @@ function mergeOpenClawChannels(backupChannels: unknown, currentChannels: unknown
return merged;
}

function mergeOpenClawEntryMap(
backupEntries: unknown,
currentEntries: unknown,
): Record<string, unknown> | undefined {
if (!isPlainJsonObject(backupEntries) && !isPlainJsonObject(currentEntries)) return undefined;
return {
...(isPlainJsonObject(backupEntries) ? cloneJson(backupEntries) : {}),
// Current generated entries win so rebuild does not restore stale runtime
// placeholders, model routing, or plugin enablement for NemoClaw-managed ids.
...(isPlainJsonObject(currentEntries) ? cloneJson(currentEntries) : {}),
};
}

function mergeOpenClawModels(backupModels: unknown, currentModels: unknown): unknown {
if (!isPlainJsonObject(backupModels)) return cloneJson(currentModels);
if (!isPlainJsonObject(currentModels)) return cloneJson(backupModels);

const merged = mergeJsonObjects(currentModels, backupModels);
const backupProviders = backupModels.providers;
const currentProviders = currentModels.providers;
if (isPlainJsonObject(backupProviders) && isPlainJsonObject(currentProviders)) {
merged.providers = {
...cloneJson(backupProviders),
// Current generated provider entries win so rebuild does not restore stale
// runtime placeholders or model routing for providers NemoClaw manages.
...cloneJson(currentProviders),
};
}
const providers = mergeOpenClawEntryMap(backupModels.providers, currentModels.providers);
if (providers) merged.providers = providers;
return merged;
}

Expand All @@ -107,16 +112,8 @@ function mergeOpenClawPlugins(backupPlugins: unknown, currentPlugins: unknown):
if (!isPlainJsonObject(currentPlugins)) return cloneJson(backupPlugins);

const merged = mergeJsonObjects(currentPlugins, backupPlugins);
const backupEntries = backupPlugins.entries;
const currentEntries = currentPlugins.entries;
if (isPlainJsonObject(backupEntries) && isPlainJsonObject(currentEntries)) {
merged.entries = {
...cloneJson(backupEntries),
// Current generated plugin enablement wins for channels/provider plugins;
// backup-only custom plugin entries are still preserved.
...cloneJson(currentEntries),
};
}
const entries = mergeOpenClawEntryMap(backupPlugins.entries, currentPlugins.entries);
if (entries) merged.entries = entries;
return merged;
}

Expand Down
77 changes: 77 additions & 0 deletions src/lib/state/openclaw-config-restore-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";

import {
buildOpenClawConfigRestoreInput,
shouldMergeOpenClawConfigStateFile,
} from "../../../dist/lib/state/openclaw-config-restore-input";

function bufferJson(value: unknown): Buffer {
return Buffer.from(JSON.stringify(value));
}

describe("shouldMergeOpenClawConfigStateFile", () => {
it("documents the OpenClaw manifest/config-path boundary for selective restore", () => {
expect(
shouldMergeOpenClawConfigStateFile("openclaw", "/sandbox/.openclaw", {
path: "openclaw.json",
strategy: "copy",
}),
).toBe(true);
expect(
shouldMergeOpenClawConfigStateFile("custom", "/sandbox/.openclaw", {
path: "openclaw.json",
strategy: "copy",
}),
).toBe(true);
expect(
shouldMergeOpenClawConfigStateFile("openclaw", "/sandbox/.openclaw", {
path: "other.json",
strategy: "copy",
}),
).toBe(false);
expect(
shouldMergeOpenClawConfigStateFile("openclaw", "/sandbox/.openclaw", {
path: "openclaw.json",
strategy: "sqlite_backup",
}),
).toBe(false);
});
});

describe("buildOpenClawConfigRestoreInput", () => {
it("fails closed when the current rebuilt OpenClaw config is missing", () => {
const result = buildOpenClawConfigRestoreInput(bufferJson({ mcpServers: {} }), null);

expect(result).toMatchObject({
ok: false,
error: "openclaw.json selective merge requires current rebuilt config",
});
});

it("fails closed instead of wholesale restoring backup on invalid current JSON", () => {
const result = buildOpenClawConfigRestoreInput(
bufferJson({ channels: { discord: { token: "stale" } } }),
Buffer.from("{ invalid json"),
);

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("refusing unsafe wholesale backup restore");
}
});

it("fails closed instead of wholesale restoring invalid backup JSON", () => {
const result = buildOpenClawConfigRestoreInput(
Buffer.from("{ invalid json"),
bufferJson({ gateway: { auth: { token: "fresh" } } }),
);

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("refusing unsafe wholesale backup restore");
}
});
});
125 changes: 125 additions & 0 deletions src/lib/state/openclaw-config-restore-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "child_process";

import { shellQuote } from "../runner.js";
import { mergeOpenClawRestoredConfig } from "./openclaw-config-merge.js";

export interface OpenClawConfigStateFileSpec {
path: string;
strategy: string;
}

/**
* OpenClaw openclaw.json restore source-of-truth boundary.
*
* The OpenClaw agent manifest currently declares openclaw.json as a durable
* state file, but it cannot yet express key-level ownership. Until that schema
* exists, this module is the localized restore policy for reconciling the
* sanitized backup with the freshly rebuilt runtime config.
*
* Invalid state: replacing fresh runtime-owned config when the current file is
* missing, unreadable, or invalid JSON. In those cases restore must fail the
* file explicitly instead of falling back to a wholesale sanitized backup write.
*
* Source-fix constraint: remove or shrink this policy when OpenClaw or the
* agent manifest can declare key-level ownership/migration rules for
* openclaw.json directly.
*/
export function shouldMergeOpenClawConfigStateFile(
agentType: string | null | undefined,
dir: string,
spec: OpenClawConfigStateFileSpec,
): boolean {
return (
spec.strategy === "copy" &&
spec.path === "openclaw.json" &&
(agentType === "openclaw" || dir.replace(/\/+$/, "").endsWith("/.openclaw"))
);
}

export type OpenClawConfigRestoreInputResult =
| { ok: true; input: Buffer }
| { ok: false; error: string };

export interface OpenClawConfigRestoreFromSandboxOptions {
backupContents: Buffer;
dir: string;
log?: (message: string) => void;
specPath: string;
sshArgs: readonly string[];
}

function openClawConfigRemotePath(dir: string, specPath: string): string {
return `${dir.replace(/\/+$/, "")}/${specPath}`;
}

export function buildOpenClawConfigReadCommand(dir: string, specPath: string): string {
const remotePath = openClawConfigRemotePath(dir, specPath);
const quotedRemotePath = shellQuote(remotePath);
return [
`src=${quotedRemotePath}`,
'[ ! -e "$src" ] && exit 2',
'[ -f "$src" ] && [ ! -L "$src" ] || { echo "unsafe state file: $src" >&2; exit 10; }',
'cat -- "$src"',
].join("; ");
}

function readCurrentOpenClawConfig(
sshArgs: readonly string[],
dir: string,
specPath: string,
log: (message: string) => void,
): Buffer | null {
const command = buildOpenClawConfigReadCommand(dir, specPath);
const result = spawnSync("ssh", [...sshArgs, command], {
stdio: ["ignore", "pipe", "pipe"],
timeout: 120000,
maxBuffer: 256 * 1024 * 1024,
});
if (result.status === 0 && !result.error && !result.signal) return result.stdout;
if (result.status !== 2) {
const detail =
(result.stderr?.toString() || "").trim() ||
result.error?.message ||
(result.signal ? `signal ${result.signal}` : `exit ${String(result.status)}`);
log(`WARNING: state file current read ${specPath} failed: ${detail.substring(0, 200)}`);
}
return null;
}

export function buildOpenClawConfigRestoreInput(
backupContents: Buffer,
currentContents: Buffer | null,
): OpenClawConfigRestoreInputResult {
if (!currentContents) {
return { ok: false, error: "openclaw.json selective merge requires current rebuilt config" };
}

try {
const backedUpConfig = JSON.parse(backupContents.toString("utf-8")) as unknown;
const currentConfig = JSON.parse(currentContents.toString("utf-8")) as unknown;
const merged = mergeOpenClawRestoredConfig(backedUpConfig, currentConfig);
return { ok: true, input: Buffer.from(`${JSON.stringify(merged, null, 2)}\n`) };
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: `openclaw.json selective merge failed; refusing unsafe wholesale backup restore: ${detail}`,
};
}
}

export function buildOpenClawConfigRestoreInputFromSandbox({
backupContents,
dir,
log = () => {},
specPath,
sshArgs,
}: OpenClawConfigRestoreFromSandboxOptions): OpenClawConfigRestoreInputResult {
return buildOpenClawConfigRestoreInput(
backupContents,
readCurrentOpenClawConfig(sshArgs, dir, specPath, log),
);
}
Loading
Loading