fix(config): persist doctor compatibility migrations

This commit is contained in:
Vincent Koc
2026-03-19 23:25:56 -07:00
parent 098a0d0d0d
commit 9af42c6590
3 changed files with 330 additions and 16 deletions

View File

@@ -394,4 +394,178 @@ describe("normalizeCompatibilityConfigValues", () => {
expect(res.config.skills?.allowBundled).toEqual(["peekaboo"]);
expect(res.changes).toEqual(["Removed nano-banana-pro from skills.allowBundled."]);
});
it("migrates legacy web search provider config to plugin-owned config paths", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
web: {
search: {
provider: "gemini",
maxResults: 5,
apiKey: "brave-key",
gemini: {
apiKey: "gemini-key",
model: "gemini-2.5-flash",
},
firecrawl: {
apiKey: "firecrawl-key",
baseUrl: "https://api.firecrawl.dev",
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
provider: "gemini",
maxResults: 5,
});
expect(res.config.plugins?.entries?.brave).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "brave-key",
},
},
});
expect(res.config.plugins?.entries?.google).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "gemini-key",
model: "gemini-2.5-flash",
},
},
});
expect(res.config.plugins?.entries?.firecrawl).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "firecrawl-key",
baseUrl: "https://api.firecrawl.dev",
},
},
});
expect(res.changes).toEqual([
"Moved tools.web.search.apiKey → plugins.entries.brave.config.webSearch.apiKey.",
"Moved tools.web.search.firecrawl → plugins.entries.firecrawl.config.webSearch.",
"Moved tools.web.search.gemini → plugins.entries.google.config.webSearch.",
]);
});
it("merges legacy web search provider config into explicit plugin config without overriding it", () => {
const res = normalizeCompatibilityConfigValues({
tools: {
web: {
search: {
provider: "gemini",
gemini: {
apiKey: "legacy-gemini-key",
model: "legacy-model",
},
},
},
},
plugins: {
entries: {
google: {
enabled: true,
config: {
webSearch: {
model: "explicit-model",
baseUrl: "https://generativelanguage.googleapis.com",
},
},
},
},
},
});
expect(res.config.tools?.web?.search).toEqual({
provider: "gemini",
});
expect(res.config.plugins?.entries?.google).toEqual({
enabled: true,
config: {
webSearch: {
apiKey: "legacy-gemini-key",
model: "explicit-model",
baseUrl: "https://generativelanguage.googleapis.com",
},
},
});
expect(res.changes).toEqual([
"Merged tools.web.search.gemini → plugins.entries.google.config.webSearch (filled missing fields from legacy; kept explicit plugin config values).",
]);
});
it("migrates legacy talk flat fields to provider/providers", () => {
const res = normalizeCompatibilityConfigValues({
talk: {
voiceId: "voice-123",
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
},
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key",
interruptOnSpeech: false,
silenceTimeoutMs: 1500,
},
});
expect(res.config.talk).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
},
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key",
},
},
voiceId: "voice-123",
voiceAliases: {
Clawd: "EXAVITQu4vr4xnSDxMaL",
},
modelId: "eleven_v3",
outputFormat: "pcm_44100",
apiKey: "secret-key",
interruptOnSpeech: false,
silenceTimeoutMs: 1500,
});
expect(res.changes).toEqual([
"Moved legacy talk flat fields → talk.provider/talk.providers.elevenlabs.",
]);
});
it("normalizes talk provider ids without overriding explicit provider config", () => {
const res = normalizeCompatibilityConfigValues({
talk: {
provider: " elevenlabs ",
providers: {
" elevenlabs ": {
voiceId: "voice-123",
},
},
apiKey: "secret-key",
},
});
expect(res.config.talk).toEqual({
provider: "elevenlabs",
providers: {
elevenlabs: {
voiceId: "voice-123",
},
},
apiKey: "secret-key",
});
expect(res.changes).toEqual([
"Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).",
]);
});
});

View File

@@ -8,6 +8,8 @@ import {
resolveSlackStreamingMode,
resolveTelegramPreviewStreamMode,
} from "../config/discord-preview-streaming.js";
import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js";
import { DEFAULT_TALK_PROVIDER, normalizeTalkSection } from "../config/talk.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
@@ -429,6 +431,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
normalizeProvider("discord");
seedMissingDefaultAccountsFromSingleAccountBase();
normalizeLegacyBrowserProfiles();
const webSearchMigration = migrateLegacyWebSearchConfig(next);
if (webSearchMigration.changes.length > 0) {
next = webSearchMigration.config;
changes.push(...webSearchMigration.changes);
}
const normalizeBrowserSsrFPolicyAlias = () => {
const rawBrowser = next.browser;
@@ -597,8 +604,43 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
}
};
const normalizeLegacyTalkConfig = () => {
const rawTalk = next.talk;
if (!isRecord(rawTalk)) {
return;
}
const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]);
if (!normalizedTalk) {
return;
}
const sameShape = JSON.stringify(normalizedTalk) === JSON.stringify(rawTalk);
if (sameShape) {
return;
}
const hasProviderShape = typeof rawTalk.provider === "string" || isRecord(rawTalk.providers);
next = {
...next,
talk: normalizedTalk,
};
if (hasProviderShape) {
changes.push(
"Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).",
);
return;
}
changes.push(
`Moved legacy talk flat fields → talk.provider/talk.providers.${DEFAULT_TALK_PROVIDER}.`,
);
};
normalizeBrowserSsrFPolicyAlias();
normalizeLegacyNanoBananaSkill();
normalizeLegacyTalkConfig();
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined;

View File

@@ -1,4 +1,5 @@
import type { OpenClawConfig } from "./config.js";
import { mergeMissing } from "./legacy.shared.js";
type JsonRecord = Record<string, unknown>;
@@ -56,19 +57,60 @@ function copyLegacyProviderConfig(
return isRecord(current) ? cloneRecord(current) : undefined;
}
function setPluginWebSearchConfig(
target: JsonRecord,
pluginId: string,
webSearchConfig: JsonRecord,
): void {
const plugins = ensureRecord(target, "plugins");
function hasOwnKey(target: JsonRecord, key: string): boolean {
return Object.prototype.hasOwnProperty.call(target, key);
}
function hasMappedLegacyWebSearchConfig(raw: unknown): boolean {
const search = resolveLegacySearchConfig(raw);
if (!search) {
return false;
}
if (hasOwnKey(search, "apiKey")) {
return true;
}
return (Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]).some((providerId) =>
isRecord(search[providerId]),
);
}
function migratePluginWebSearchConfig(params: {
root: JsonRecord;
legacyPath: string;
targetPath: string;
pluginId: string;
payload: JsonRecord;
changes: string[];
}) {
const plugins = ensureRecord(params.root, "plugins");
const entries = ensureRecord(plugins, "entries");
const entry = ensureRecord(entries, pluginId);
if (entry.enabled === undefined) {
const entry = ensureRecord(entries, params.pluginId);
const config = ensureRecord(entry, "config");
const hadEnabled = entry.enabled !== undefined;
const existing = isRecord(config.webSearch) ? cloneRecord(config.webSearch) : undefined;
if (!hadEnabled) {
entry.enabled = true;
}
const config = ensureRecord(entry, "config");
config.webSearch = webSearchConfig;
if (!existing) {
config.webSearch = cloneRecord(params.payload);
params.changes.push(`Moved ${params.legacyPath}${params.targetPath}.`);
return;
}
const merged = cloneRecord(existing);
mergeMissing(merged, params.payload);
const changed = JSON.stringify(merged) !== JSON.stringify(existing) || !hadEnabled;
config.webSearch = merged;
if (changed) {
params.changes.push(
`Merged ${params.legacyPath}${params.targetPath} (filled missing fields from legacy; kept explicit plugin config values).`,
);
return;
}
params.changes.push(`Removed ${params.legacyPath} (${params.targetPath} already set).`);
}
export function listLegacyWebSearchConfigPaths(raw: unknown): string[] {
@@ -102,24 +144,73 @@ export function normalizeLegacyWebSearchConfig<T>(raw: T): T {
return raw;
}
return normalizeLegacyWebSearchConfigRecord(raw).config;
}
export function migrateLegacyWebSearchConfig<T>(raw: T): { config: T; changes: string[] } {
if (!isRecord(raw)) {
return { config: raw, changes: [] };
}
if (!hasMappedLegacyWebSearchConfig(raw)) {
return { config: raw, changes: [] };
}
return normalizeLegacyWebSearchConfigRecord(raw);
}
function normalizeLegacyWebSearchConfigRecord<T extends JsonRecord>(
raw: T,
): {
config: T;
changes: string[];
} {
const nextRoot = cloneRecord(raw);
const tools = ensureRecord(nextRoot, "tools");
const web = ensureRecord(tools, "web");
const search = resolveLegacySearchConfig(nextRoot);
if (!search) {
return { config: raw, changes: [] };
}
const nextSearch: JsonRecord = {};
const changes: string[] = [];
for (const [key, value] of Object.entries(search)) {
if (GENERIC_WEB_SEARCH_KEYS.has(key)) {
if (key === "apiKey") {
continue;
}
if (
(Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]).includes(key as LegacyProviderId)
) {
if (isRecord(value)) {
continue;
}
}
if (GENERIC_WEB_SEARCH_KEYS.has(key) || !isRecord(value)) {
nextSearch[key] = value;
}
}
web.search = nextSearch;
const braveConfig = copyLegacyProviderConfig(search, "brave") ?? {};
if ("apiKey" in search) {
const legacyBraveConfig = copyLegacyProviderConfig(search, "brave");
const braveConfig = legacyBraveConfig ?? {};
if (hasOwnKey(search, "apiKey")) {
braveConfig.apiKey = search.apiKey;
}
if (Object.keys(braveConfig).length > 0) {
setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP.brave, braveConfig);
migratePluginWebSearchConfig({
root: nextRoot,
legacyPath: hasOwnKey(search, "apiKey")
? "tools.web.search.apiKey"
: "tools.web.search.brave",
targetPath:
hasOwnKey(search, "apiKey") && !legacyBraveConfig
? "plugins.entries.brave.config.webSearch.apiKey"
: "plugins.entries.brave.config.webSearch",
pluginId: LEGACY_PROVIDER_MAP.brave,
payload: braveConfig,
changes,
});
}
for (const providerId of ["firecrawl", "gemini", "grok", "kimi", "perplexity"] as const) {
@@ -127,10 +218,17 @@ export function normalizeLegacyWebSearchConfig<T>(raw: T): T {
if (!scoped || Object.keys(scoped).length === 0) {
continue;
}
setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP[providerId], scoped);
migratePluginWebSearchConfig({
root: nextRoot,
legacyPath: `tools.web.search.${providerId}`,
targetPath: `plugins.entries.${LEGACY_PROVIDER_MAP[providerId]}.config.webSearch`,
pluginId: LEGACY_PROVIDER_MAP[providerId],
payload: scoped,
changes,
});
}
return nextRoot as T;
return { config: nextRoot, changes };
}
export function resolvePluginWebSearchConfig(