diff --git a/extensions/discord/doctor-contract-api.ts b/extensions/discord/doctor-contract-api.ts new file mode 100644 index 00000000000..a7a56f23442 --- /dev/null +++ b/extensions/discord/doctor-contract-api.ts @@ -0,0 +1 @@ +export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js"; diff --git a/extensions/discord/src/preview-streaming.ts b/extensions/discord/src/preview-streaming.ts index bda6efdbaf5..7b1d416d090 100644 --- a/extensions/discord/src/preview-streaming.ts +++ b/extensions/discord/src/preview-streaming.ts @@ -1,12 +1,32 @@ -import { resolveChannelPreviewStreamMode } from "openclaw/plugin-sdk/channel-streaming"; - export type DiscordPreviewStreamMode = "off" | "partial" | "block"; +function parsePreviewStreamingMode(value: unknown): DiscordPreviewStreamMode | undefined { + return value === "off" || value === "partial" || value === "block" ? value : undefined; +} + export function resolveDiscordPreviewStreamMode( params: { streamMode?: unknown; streaming?: unknown; } = {}, ): DiscordPreviewStreamMode { - return resolveChannelPreviewStreamMode(params, "off"); + const parsedStreaming = + params.streaming && typeof params.streaming === "object" && !Array.isArray(params.streaming) + ? parsePreviewStreamingMode( + (params.streaming as Record).mode ?? + (params.streaming as Record).streaming, + ) + : parsePreviewStreamingMode(params.streaming); + if (parsedStreaming) { + return parsedStreaming; + } + + const legacy = parsePreviewStreamingMode(params.streamMode); + if (legacy) { + return legacy; + } + if (typeof params.streaming === "boolean") { + return params.streaming ? "partial" : "off"; + } + return "off"; } diff --git a/src/channels/plugins/legacy-config.ts b/src/channels/plugins/legacy-config.ts index bdbb850ecee..d7b44784f6f 100644 --- a/src/channels/plugins/legacy-config.ts +++ b/src/channels/plugins/legacy-config.ts @@ -1,9 +1,27 @@ import type { LegacyConfigRule } from "../../config/legacy.shared.js"; -import { iterateBootstrapChannelPlugins } from "./bootstrap-registry.js"; +import { getBootstrapChannelPlugin } from "./bootstrap-registry.js"; +import type { ChannelId } from "./types.js"; -export function collectChannelLegacyConfigRules(): LegacyConfigRule[] { +function collectConfiguredChannelIds(raw: unknown): ChannelId[] { + if (!raw || typeof raw !== "object") { + return []; + } + const channels = (raw as { channels?: unknown }).channels; + if (!channels || typeof channels !== "object" || Array.isArray(channels)) { + return []; + } + return Object.keys(channels) + .filter((channelId) => channelId !== "defaults") + .map((channelId) => channelId as ChannelId); +} + +export function collectChannelLegacyConfigRules(raw?: unknown): LegacyConfigRule[] { const rules: LegacyConfigRule[] = []; - for (const plugin of iterateBootstrapChannelPlugins()) { + for (const channelId of collectConfiguredChannelIds(raw)) { + const plugin = getBootstrapChannelPlugin(channelId); + if (!plugin) { + continue; + } rules.push(...(plugin.doctor?.legacyConfigRules ?? [])); } return rules; diff --git a/src/commands/doctor/shared/channel-legacy-config-migrate.ts b/src/commands/doctor/shared/channel-legacy-config-migrate.ts index ec0e3c92b72..8ff6b460612 100644 --- a/src/commands/doctor/shared/channel-legacy-config-migrate.ts +++ b/src/commands/doctor/shared/channel-legacy-config-migrate.ts @@ -1,19 +1,18 @@ -import { iterateBootstrapChannelPlugins } from "../../../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../../../config/types.js"; +import { + applyPluginDoctorCompatibilityMigrations, + collectRelevantDoctorPluginIds, +} from "../../../plugins/doctor-contract-registry.js"; export function applyChannelDoctorCompatibilityMigrations(cfg: Record): { next: Record; changes: string[]; } { - let nextCfg = cfg as OpenClawConfig & Record; - const changes: string[] = []; - for (const plugin of iterateBootstrapChannelPlugins()) { - const mutation = plugin.doctor?.normalizeCompatibilityConfig?.({ cfg: nextCfg }); - if (!mutation || mutation.changes.length === 0) { - continue; - } - nextCfg = mutation.config as OpenClawConfig & Record; - changes.push(...mutation.changes); - } - return { next: nextCfg, changes }; + const compat = applyPluginDoctorCompatibilityMigrations(cfg as OpenClawConfig, { + pluginIds: collectRelevantDoctorPluginIds(cfg), + }); + return { + next: compat.config as OpenClawConfig & Record, + changes: compat.changes, + }; } diff --git a/src/commands/doctor/shared/runtime-compat-api.ts b/src/commands/doctor/shared/runtime-compat-api.ts new file mode 100644 index 00000000000..5b250295141 --- /dev/null +++ b/src/commands/doctor/shared/runtime-compat-api.ts @@ -0,0 +1,8 @@ +import { applyLegacyDoctorMigrations } from "./legacy-config-migrate.js"; + +export function applyRuntimeLegacyConfigMigrations(raw: unknown): { + next: Record | null; + changes: string[]; +} { + return applyLegacyDoctorMigrations(raw); +} diff --git a/src/config/config.legacy-config-provider-shapes.test.ts b/src/config/config.legacy-config-provider-shapes.test.ts index 8e046d7dae8..34e3e35ac3e 100644 --- a/src/config/config.legacy-config-provider-shapes.test.ts +++ b/src/config/config.legacy-config-provider-shapes.test.ts @@ -36,7 +36,7 @@ describe("legacy provider-shaped config snapshots", () => { expect(res.ok).toBe(false); }); - it("detects legacy messages.tts provider keys and reports legacyIssues", async () => { + it("accepts legacy messages.tts provider keys via auto-migration and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { messages: { @@ -52,19 +52,24 @@ describe("legacy provider-shaped config snapshots", () => { const snap = await readConfigFileSnapshot(); - expect(snap.valid).toBe(false); + expect(snap.valid).toBe(true); expect(snap.legacyIssues.some((issue) => issue.path === "messages.tts")).toBe(true); expect(snap.sourceConfig.messages?.tts).toEqual({ provider: "elevenlabs", - elevenlabs: { - apiKey: "test-key", - voiceId: "voice-1", + providers: { + elevenlabs: { + apiKey: "test-key", + voiceId: "voice-1", + }, }, }); + expect( + (snap.sourceConfig.messages?.tts as Record | undefined)?.elevenlabs, + ).toBeUndefined(); }); }); - it("reports legacy talk flat fields without auto-migrating them at config load", async () => { + it("accepts legacy talk flat fields via auto-migration and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { talk: { @@ -76,17 +81,26 @@ describe("legacy provider-shaped config snapshots", () => { const snap = await readConfigFileSnapshot(); - expect(snap.valid).toBe(false); + expect(snap.valid).toBe(true); expect(snap.legacyIssues.some((issue) => issue.path === "talk")).toBe(true); - expect(snap.sourceConfig.talk).toEqual({ + expect(snap.sourceConfig.talk?.providers?.elevenlabs).toEqual({ voiceId: "voice-1", modelId: "eleven_v3", apiKey: "test-key", }); + expect( + (snap.sourceConfig.talk as Record | undefined)?.voiceId, + ).toBeUndefined(); + expect( + (snap.sourceConfig.talk as Record | undefined)?.modelId, + ).toBeUndefined(); + expect( + (snap.sourceConfig.talk as Record | undefined)?.apiKey, + ).toBeUndefined(); }); }); - it("detects legacy plugins.entries.*.config.tts provider keys", async () => { + it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { plugins: { @@ -108,7 +122,7 @@ describe("legacy provider-shaped config snapshots", () => { const snap = await readConfigFileSnapshot(); - expect(snap.valid).toBe(false); + expect(snap.valid).toBe(true); expect(snap.legacyIssues.some((issue) => issue.path === "plugins.entries")).toBe(true); const voiceCallTts = ( snap.sourceConfig.plugins?.entries as @@ -127,15 +141,18 @@ describe("legacy provider-shaped config snapshots", () => { )?.["voice-call"]?.config?.tts; expect(voiceCallTts).toEqual({ provider: "openai", - openai: { - model: "gpt-4o-mini-tts", - voice: "alloy", + providers: { + openai: { + model: "gpt-4o-mini-tts", + voice: "alloy", + }, }, }); + expect(voiceCallTts?.openai).toBeUndefined(); }); }); - it("detects legacy discord voice tts provider keys and reports legacyIssues", async () => { + it("accepts legacy discord voice tts provider keys via auto-migration and reports legacyIssues", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { channels: { @@ -165,7 +182,7 @@ describe("legacy provider-shaped config snapshots", () => { const snap = await readConfigFileSnapshot(); - expect(snap.valid).toBe(false); + expect(snap.valid).toBe(true); expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.voice.tts")).toBe( true, ); @@ -174,13 +191,17 @@ describe("legacy provider-shaped config snapshots", () => { ); expect(snap.sourceConfig.channels?.discord?.voice?.tts).toEqual({ provider: "elevenlabs", - elevenlabs: { - voiceId: "voice-1", + providers: { + elevenlabs: { + voiceId: "voice-1", + }, }, }); expect(snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts).toEqual({ - edge: { - voice: "en-US-AvaNeural", + providers: { + microsoft: { + voice: "en-US-AvaNeural", + }, }, }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 61962a063a1..7ee88fffa1b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import JSON5 from "json5"; import { ensureOwnerDisplaySecret } from "../agents/owner-display.js"; +import { applyRuntimeLegacyConfigMigrations } from "../commands/doctor/shared/runtime-compat-api.js"; import { loadDotEnv } from "../infra/dotenv.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { @@ -13,7 +14,10 @@ import { shouldDeferShellEnvFallback, shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; -import { listPluginDoctorLegacyConfigRules } from "../plugins/doctor-contract-registry.js"; +import { + collectRelevantDoctorPluginIds, + listPluginDoctorLegacyConfigRules, +} from "../plugins/doctor-contract-registry.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; @@ -1619,12 +1623,20 @@ function resolveLegacyConfigForRead( resolvedConfigRaw: unknown, sourceRaw: unknown, ): LegacyMigrationResolution { + const pluginIds = collectRelevantDoctorPluginIds(resolvedConfigRaw); const sourceLegacyIssues = findLegacyConfigIssues( resolvedConfigRaw, sourceRaw, - listPluginDoctorLegacyConfigRules(), + listPluginDoctorLegacyConfigRules({ pluginIds }), ); - return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues }; + if (!resolvedConfigRaw || typeof resolvedConfigRaw !== "object") { + return { effectiveConfigRaw: resolvedConfigRaw, sourceLegacyIssues }; + } + const compat = applyRuntimeLegacyConfigMigrations(resolvedConfigRaw); + return { + effectiveConfigRaw: compat.next ?? resolvedConfigRaw, + sourceLegacyIssues, + }; } type ReadConfigFileSnapshotInternalResult = { diff --git a/src/config/legacy.ts b/src/config/legacy.ts index c71fd38ceda..0664d307880 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -28,7 +28,7 @@ export function findLegacyConfigIssues( const issues: LegacyConfigIssue[] = []; for (const rule of [ ...LEGACY_CONFIG_RULES, - ...collectChannelLegacyConfigRules(), + ...collectChannelLegacyConfigRules(raw), ...extraRules, ]) { const cursor = getPathValue(root, rule.path); diff --git a/src/config/validation.ts b/src/config/validation.ts index 01c320414c2..0122ecc5b89 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -7,7 +7,10 @@ import { resolveEffectivePluginActivationState, resolveMemorySlotDecision, } from "../plugins/config-state.js"; -import { listPluginDoctorLegacyConfigRules } from "../plugins/doctor-contract-registry.js"; +import { + collectRelevantDoctorPluginIds, + listPluginDoctorLegacyConfigRules, +} from "../plugins/doctor-contract-registry.js"; import { loadPluginManifestRegistry, resolveManifestContractPluginIds, @@ -455,7 +458,11 @@ export function validateConfigObjectRaw( raw: unknown, ): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } { const policyIssues = collectUnsupportedSecretRefPolicyIssues(raw); - const legacyIssues = findLegacyConfigIssues(raw, raw, listPluginDoctorLegacyConfigRules()); + const legacyIssues = findLegacyConfigIssues( + raw, + raw, + listPluginDoctorLegacyConfigRules({ pluginIds: collectRelevantDoctorPluginIds(raw) }), + ); if (legacyIssues.length > 0) { return { ok: false, diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index e3e074362de..2b764c214ca 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -25,10 +25,8 @@ vi.mock("./manifest-registry.js", () => ({ mocks.loadPluginManifestRegistry(...args), })); -import { - clearPluginDoctorContractRegistryCache, - listPluginDoctorLegacyConfigRules, -} from "./doctor-contract-registry.js"; +let clearPluginDoctorContractRegistryCache: typeof import("./doctor-contract-registry.js").clearPluginDoctorContractRegistryCache; +let listPluginDoctorLegacyConfigRules: typeof import("./doctor-contract-registry.js").listPluginDoctorLegacyConfigRules; function makeTempDir(): string { return makeTrackedTempDir("openclaw-doctor-contract-registry", tempDirs); @@ -39,8 +37,7 @@ afterEach(() => { }); describe("doctor-contract-registry getJiti", () => { - beforeEach(() => { - clearPluginDoctorContractRegistryCache(); + beforeEach(async () => { mocks.createJiti.mockReset(); mocks.discoverOpenClawPlugins.mockReset(); mocks.loadPluginManifestRegistry.mockReset(); @@ -53,6 +50,10 @@ describe("doctor-contract-registry getJiti", () => { return () => ({ default: {} }); }, ); + vi.resetModules(); + ({ clearPluginDoctorContractRegistryCache, listPluginDoctorLegacyConfigRules } = + await import("./doctor-contract-registry.js")); + clearPluginDoctorContractRegistryCache(); }); it("disables native jiti loading on Windows for contract-api modules", () => { diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 587b4292e77..9d2873fcfe8 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import type { LegacyConfigRule } from "../config/legacy.shared.js"; +import type { OpenClawConfig } from "../config/types.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; @@ -20,11 +21,22 @@ const RUNNING_FROM_BUILT_ARTIFACT = type PluginDoctorContractModule = { legacyConfigRules?: unknown; + normalizeCompatibilityConfig?: unknown; }; +type PluginDoctorCompatibilityMutation = { + config: OpenClawConfig; + changes: string[]; +}; + +type PluginDoctorCompatibilityNormalizer = (params: { + cfg: OpenClawConfig; +}) => PluginDoctorCompatibilityMutation; + type PluginDoctorContractEntry = { pluginId: string; rules: LegacyConfigRule[]; + normalizeCompatibilityConfig?: PluginDoctorCompatibilityNormalizer; }; const jitiLoaders = new Map>(); @@ -52,6 +64,7 @@ function getJiti(modulePath: string) { function buildDoctorContractCacheKey(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -60,6 +73,7 @@ function buildDoctorContractCacheKey(params: { return JSON.stringify({ roots, loadPaths, + pluginIds: [...(params.pluginIds ?? [])].toSorted(), }); } @@ -67,6 +81,12 @@ function resolveContractApiPath(rootDir: string): string | null { const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT ? CONTRACT_API_EXTENSIONS : ([...CONTRACT_API_EXTENSIONS.slice(3), ...CONTRACT_API_EXTENSIONS.slice(0, 3)] as const); + for (const extension of orderedExtensions) { + const candidate = path.join(rootDir, `doctor-contract-api${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } for (const extension of orderedExtensions) { const candidate = path.join(rootDir, `contract-api${extension}`); if (fs.existsSync(candidate)) { @@ -89,14 +109,68 @@ function coerceLegacyConfigRules(value: unknown): LegacyConfigRule[] { }) as LegacyConfigRule[]; } +function coerceNormalizeCompatibilityConfig( + value: unknown, +): PluginDoctorCompatibilityNormalizer | undefined { + return typeof value === "function" ? (value as PluginDoctorCompatibilityNormalizer) : undefined; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function hasLegacyElevenLabsTalkFields(raw: unknown): boolean { + const talk = asRecord(asRecord(raw)?.talk); + if (!talk) { + return false; + } + return ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) => + Object.prototype.hasOwnProperty.call(talk, key), + ); +} + +export function collectRelevantDoctorPluginIds(raw: unknown): string[] { + const ids = new Set(); + const root = asRecord(raw); + if (!root) { + return []; + } + + const channels = asRecord(root.channels); + if (channels) { + for (const channelId of Object.keys(channels)) { + if (channelId !== "defaults") { + ids.add(channelId); + } + } + } + + const pluginsEntries = asRecord(asRecord(root.plugins)?.entries); + if (pluginsEntries) { + for (const pluginId of Object.keys(pluginsEntries)) { + ids.add(pluginId); + } + } + + if (hasLegacyElevenLabsTalkFields(root)) { + ids.add("elevenlabs"); + } + + return [...ids].toSorted(); +} + function resolvePluginDoctorContracts(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): PluginDoctorContractEntry[] { const env = params?.env ?? process.env; const cacheKey = buildDoctorContractCacheKey({ workspaceDir: params?.workspaceDir, env, + pluginIds: params?.pluginIds, }); const cached = doctorContractCache.get(cacheKey); if (cached) { @@ -117,7 +191,17 @@ function resolvePluginDoctorContracts(params?: { }); const entries: PluginDoctorContractEntry[] = []; + const selectedPluginIds = + params?.pluginIds && params.pluginIds.length > 0 ? new Set(params.pluginIds) : null; for (const record of manifestRegistry.plugins) { + if ( + selectedPluginIds && + !selectedPluginIds.has(record.id) && + !record.channels.some((channelId) => selectedPluginIds.has(channelId)) && + !record.providers.some((providerId) => selectedPluginIds.has(providerId)) + ) { + continue; + } const contractSource = resolveContractApiPath(record.rootDir); if (!contractSource) { continue; @@ -132,12 +216,17 @@ function resolvePluginDoctorContracts(params?: { (mod as { default?: PluginDoctorContractModule }).default?.legacyConfigRules ?? mod.legacyConfigRules, ); - if (rules.length === 0) { + const normalizeCompatibilityConfig = coerceNormalizeCompatibilityConfig( + mod.normalizeCompatibilityConfig ?? + (mod as { default?: PluginDoctorContractModule }).default?.normalizeCompatibilityConfig, + ); + if (rules.length === 0 && !normalizeCompatibilityConfig) { continue; } entries.push({ pluginId: record.id, rules, + normalizeCompatibilityConfig, }); } @@ -147,11 +236,37 @@ function resolvePluginDoctorContracts(params?: { export function clearPluginDoctorContractRegistryCache(): void { doctorContractCache.clear(); + jitiLoaders.clear(); } export function listPluginDoctorLegacyConfigRules(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): LegacyConfigRule[] { return resolvePluginDoctorContracts(params).flatMap((entry) => entry.rules); } + +export function applyPluginDoctorCompatibilityMigrations( + cfg: OpenClawConfig, + params?: { + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; + }, +): { + config: OpenClawConfig; + changes: string[]; +} { + let nextCfg = cfg; + const changes: string[] = []; + for (const entry of resolvePluginDoctorContracts(params)) { + const mutation = entry.normalizeCompatibilityConfig?.({ cfg: nextCfg }); + if (!mutation || mutation.changes.length === 0) { + continue; + } + nextCfg = mutation.config; + changes.push(...mutation.changes); + } + return { config: nextCfg, changes }; +}