Skip to content
Open
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
85 changes: 85 additions & 0 deletions src/lib/state/openclaw-config-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> } };
}
).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<string, unknown>[])[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<string, unknown> } }).mcp.servers.filesystem,
).toEqual({ command: "npx", args: ["-y", "fs-server", "/work"] });
});

it("keeps current provider and plugin entries for matching keys", () => {
const merged = mergeOpenClawRestoredConfig(
{
Expand Down
119 changes: 116 additions & 3 deletions src/lib/state/openclaw-config-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(
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<string, unknown> {
return isRecord(value);
}
Expand Down Expand Up @@ -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<string, unknown>,
current: Record<string, unknown>,
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<string, unknown>,
currentModel: Record<string, unknown>,
): Record<string, unknown> {
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<string, Record<string, unknown>>();
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<string, unknown>,
currentProvider: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> | undefined {
if (!isPlainJsonObject(backupProviders) && !isPlainJsonObject(currentProviders)) return undefined;
const backup = isPlainJsonObject(backupProviders) ? backupProviders : {};
const current = isPlainJsonObject(currentProviders) ? currentProviders : {};

const merged: Record<string, unknown> = {};
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;
}
Expand Down
33 changes: 28 additions & 5 deletions src/lib/state/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -860,7 +860,7 @@ function backupStateFile(
return "backed_up";
}

function buildStateFileRestoreCommand(
export function buildStateFileRestoreCommand(
dir: string,
spec: StateFileSpec,
refreshOpenClawConfigHash = false,
Expand Down Expand Up @@ -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"',
Expand Down
Loading
Loading