From 9af42c6590f751324c5b283dec7126bcdea7fb6b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 23:25:56 -0700 Subject: [PATCH] fix(config): persist doctor compatibility migrations --- .../doctor-legacy-config.migrations.test.ts | 174 ++++++++++++++++++ src/commands/doctor-legacy-config.ts | 42 +++++ src/config/legacy-web-search.ts | 130 +++++++++++-- 3 files changed, 330 insertions(+), 16 deletions(-) diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index b8ec52ca171..f9155bf7cf7 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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).", + ]); + }); }); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index c3376bd74e9..5e51c74614c 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -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; diff --git a/src/config/legacy-web-search.ts b/src/config/legacy-web-search.ts index 4b42eca8311..71f7929d673 100644 --- a/src/config/legacy-web-search.ts +++ b/src/config/legacy-web-search.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "./config.js"; +import { mergeMissing } from "./legacy.shared.js"; type JsonRecord = Record; @@ -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(raw: T): T { return raw; } + return normalizeLegacyWebSearchConfigRecord(raw).config; +} + +export function migrateLegacyWebSearchConfig(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( + 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(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(