mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
fix(config): persist doctor compatibility migrations
This commit is contained in:
@@ -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).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user