From dc7b21bf366d05095c7654fb0209fbeec1a69b8d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 7 Apr 2026 09:52:00 +0100 Subject: [PATCH] perf(secrets): scope compat migration scans --- .../amazon-bedrock/openclaw.plugin.json | 3 + extensions/elevenlabs/openclaw.plugin.json | 9 ++ extensions/memory-wiki/openclaw.plugin.json | 3 + extensions/voice-call/openclaw.plugin.json | 3 + .../doctor/shared/channel-doctor.test.ts | 73 +++++++++ src/commands/doctor/shared/channel-doctor.ts | 25 +++- src/config/defaults.test.ts | 57 +++++++ src/config/defaults.ts | 3 + src/plugins/manifest-registry.test.ts | 2 + src/plugins/manifest.ts | 9 ++ src/plugins/setup-registry.test.ts | 139 +++++++++++++++++- src/plugins/setup-registry.ts | 74 +++++++++- 12 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 src/commands/doctor/shared/channel-doctor.test.ts create mode 100644 src/config/defaults.test.ts diff --git a/extensions/amazon-bedrock/openclaw.plugin.json b/extensions/amazon-bedrock/openclaw.plugin.json index 69491a0b310..fbb443606ed 100644 --- a/extensions/amazon-bedrock/openclaw.plugin.json +++ b/extensions/amazon-bedrock/openclaw.plugin.json @@ -34,6 +34,9 @@ } } }, + "configContracts": { + "compatibilityMigrationPaths": ["models.bedrockDiscovery"] + }, "uiHints": { "discovery": { "label": "Model Discovery", diff --git a/extensions/elevenlabs/openclaw.plugin.json b/extensions/elevenlabs/openclaw.plugin.json index 8e46a7cbbae..df36c9d38b7 100644 --- a/extensions/elevenlabs/openclaw.plugin.json +++ b/extensions/elevenlabs/openclaw.plugin.json @@ -3,6 +3,15 @@ "contracts": { "speechProviders": ["elevenlabs"] }, + "configContracts": { + "compatibilityMigrationPaths": [ + "talk.voiceId", + "talk.voiceAliases", + "talk.modelId", + "talk.outputFormat", + "talk.apiKey" + ] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 9c14e713fe0..cbd0c19bfb5 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -170,5 +170,8 @@ } } } + }, + "configContracts": { + "compatibilityMigrationPaths": ["plugins.entries.memory-wiki.config.bridge.readMemoryCore"] } } diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index ca3658e5e29..a0445fc8fcc 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -711,5 +711,8 @@ "minimum": 1 } } + }, + "configContracts": { + "compatibilityMigrationPaths": ["plugins.entries.voice-call.config"] } } diff --git a/src/commands/doctor/shared/channel-doctor.test.ts b/src/commands/doctor/shared/channel-doctor.test.ts new file mode 100644 index 00000000000..84767897588 --- /dev/null +++ b/src/commands/doctor/shared/channel-doctor.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + listChannelPlugins: vi.fn(), + listBundledChannelPlugins: vi.fn(), +})); + +vi.mock("../../../channels/plugins/registry.js", () => ({ + listChannelPlugins: (...args: Parameters) => + mocks.listChannelPlugins(...args), +})); + +vi.mock("../../../channels/plugins/bundled.js", () => ({ + listBundledChannelPlugins: (...args: Parameters) => + mocks.listBundledChannelPlugins(...args), +})); + +let collectChannelDoctorCompatibilityMutations: typeof import("./channel-doctor.js").collectChannelDoctorCompatibilityMutations; + +describe("channel doctor compatibility mutations", () => { + beforeEach(async () => { + vi.resetModules(); + ({ collectChannelDoctorCompatibilityMutations } = await import("./channel-doctor.js")); + mocks.listChannelPlugins.mockReset(); + mocks.listBundledChannelPlugins.mockReset(); + mocks.listChannelPlugins.mockReturnValue([]); + mocks.listBundledChannelPlugins.mockReturnValue([]); + }); + + it("skips plugin discovery when no channels are configured", () => { + const result = collectChannelDoctorCompatibilityMutations({} as never); + + expect(result).toEqual([]); + expect(mocks.listChannelPlugins).not.toHaveBeenCalled(); + expect(mocks.listBundledChannelPlugins).not.toHaveBeenCalled(); + }); + + it("only evaluates configured channel ids", () => { + const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ + config: cfg, + changes: ["matrix"], + })); + mocks.listBundledChannelPlugins.mockReturnValue([ + { + id: "matrix", + doctor: { normalizeCompatibilityConfig }, + }, + { + id: "discord", + doctor: { + normalizeCompatibilityConfig: vi.fn(() => ({ + config: {}, + changes: ["discord"], + })), + }, + }, + ]); + + const cfg = { + channels: { + matrix: { + enabled: true, + }, + }, + }; + + const result = collectChannelDoctorCompatibilityMutations(cfg as never); + + expect(result).toHaveLength(1); + expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); + expect(mocks.listBundledChannelPlugins).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts index f3d65d23df6..c35661f0593 100644 --- a/src/commands/doctor/shared/channel-doctor.ts +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -13,6 +13,19 @@ type ChannelDoctorEntry = { doctor: ChannelDoctorAdapter; }; +function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { + const channels = + cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels) + ? cfg.channels + : null; + if (!channels) { + return []; + } + return Object.keys(channels) + .filter((channelId) => channelId !== "defaults") + .toSorted(); +} + function safeListActiveChannelPlugins() { try { return listChannelPlugins(); @@ -29,9 +42,13 @@ function safeListBundledChannelPlugins() { } } -function listChannelDoctorEntries(): ChannelDoctorEntry[] { +function listChannelDoctorEntries(channelIds?: readonly string[]): ChannelDoctorEntry[] { const byId = new Map(); + const selectedIds = channelIds ? new Set(channelIds) : null; for (const plugin of [...safeListActiveChannelPlugins(), ...safeListBundledChannelPlugins()]) { + if (selectedIds && !selectedIds.has(plugin.id)) { + continue; + } if (!plugin.doctor) { continue; } @@ -64,9 +81,13 @@ export async function runChannelDoctorConfigSequences(params: { export function collectChannelDoctorCompatibilityMutations( cfg: OpenClawConfig, ): ChannelDoctorConfigMutation[] { + const channelIds = collectConfiguredChannelIds(cfg); + if (channelIds.length === 0) { + return []; + } const mutations: ChannelDoctorConfigMutation[] = []; let nextCfg = cfg; - for (const entry of listChannelDoctorEntries()) { + for (const entry of listChannelDoctorEntries(channelIds)) { const mutation = entry.doctor.normalizeCompatibilityConfig?.({ cfg: nextCfg }); if (!mutation || mutation.changes.length === 0) { continue; diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts new file mode 100644 index 00000000000..9456b0e4e08 --- /dev/null +++ b/src/config/defaults.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + applyProviderConfigDefaultsWithPlugin: vi.fn(), +})); + +vi.mock("../plugins/provider-runtime.js", () => ({ + applyProviderConfigDefaultsWithPlugin: ( + ...args: Parameters + ) => mocks.applyProviderConfigDefaultsWithPlugin(...args), +})); + +let applyContextPruningDefaults: typeof import("./defaults.js").applyContextPruningDefaults; + +describe("config defaults", () => { + beforeEach(async () => { + vi.resetModules(); + ({ applyContextPruningDefaults } = await import("./defaults.js")); + mocks.applyProviderConfigDefaultsWithPlugin.mockReset(); + }); + + it("skips provider defaults when agent defaults are absent", () => { + const cfg = { + models: { + providers: { + openai: { + api: "openai-completions", + }, + }, + }, + }; + + expect(applyContextPruningDefaults(cfg as never)).toBe(cfg); + expect(mocks.applyProviderConfigDefaultsWithPlugin).not.toHaveBeenCalled(); + }); + + it("uses anthropic provider defaults when agent defaults exist", () => { + const cfg = { + agents: { + defaults: {}, + }, + }; + const nextCfg = { + agents: { + defaults: { + contextPruning: { + mode: "cache-ttl", + }, + }, + }, + }; + mocks.applyProviderConfigDefaultsWithPlugin.mockReturnValue(nextCfg); + + expect(applyContextPruningDefaults(cfg as never)).toBe(nextCfg); + expect(mocks.applyProviderConfigDefaultsWithPlugin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 44410669cb2..99672ef1706 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -335,6 +335,9 @@ export function applyLoggingDefaults(cfg: OpenClawConfig): OpenClawConfig { } export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig { + if (!cfg.agents?.defaults) { + return cfg; + } return ( applyProviderConfigDefaultsWithPlugin({ provider: "anthropic", diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index f86d59b0fe4..8f2cf2af455 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -529,6 +529,7 @@ describe("loadPluginManifestRegistry", () => { id: "acpx", configSchema: { type: "object" }, configContracts: { + compatibilityMigrationPaths: ["models.bedrockDiscovery"], dangerousFlags: [{ path: "permissionMode", equals: "approve-all" }], secretInputs: { bundledDefaultEnabled: false, @@ -544,6 +545,7 @@ describe("loadPluginManifestRegistry", () => { }); expect(registry.plugins[0]?.configContracts).toEqual({ + compatibilityMigrationPaths: ["models.bedrockDiscovery"], dangerousFlags: [{ path: "permissionMode", equals: "approve-all" }], secretInputs: { bundledDefaultEnabled: false, diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 1207d2c3000..3ccecaa4f90 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -66,6 +66,13 @@ export type PluginManifestSecretInputContracts = { }; export type PluginManifestConfigContracts = { + /** + * Root-relative config paths that indicate this plugin's setup-time + * compatibility migrations might apply. Use this to keep generic runtime + * config reads from loading every plugin setup surface when the config does + * not reference the plugin at all. + */ + compatibilityMigrationPaths?: string[]; dangerousFlags?: PluginManifestDangerousConfigFlag[]; secretInputs?: PluginManifestSecretInputContracts; }; @@ -277,6 +284,7 @@ function normalizeManifestConfigContracts( if (!isRecord(value)) { return undefined; } + const compatibilityMigrationPaths = normalizeTrimmedStringList(value.compatibilityMigrationPaths); const rawSecretInputs = isRecord(value.secretInputs) ? value.secretInputs : undefined; const dangerousFlags = normalizeManifestDangerousConfigFlags(value.dangerousFlags); const secretInputPaths = rawSecretInputs @@ -294,6 +302,7 @@ function normalizeManifestConfigContracts( } satisfies PluginManifestSecretInputContracts) : undefined; const configContracts = { + ...(compatibilityMigrationPaths.length > 0 ? { compatibilityMigrationPaths } : {}), ...(dangerousFlags ? { dangerousFlags } : {}), ...(secretInputs ? { secretInputs } : {}), } satisfies PluginManifestConfigContracts; diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index 3db526bc5df..68368691324 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -27,6 +27,7 @@ vi.mock("./manifest-registry.js", () => ({ let clearPluginSetupRegistryCache: typeof import("./setup-registry.js").clearPluginSetupRegistryCache; let resolvePluginSetupRegistry: typeof import("./setup-registry.js").resolvePluginSetupRegistry; +let runPluginSetupConfigMigrations: typeof import("./setup-registry.js").runPluginSetupConfigMigrations; function makeTempDir(): string { return makeTrackedTempDir("openclaw-setup-registry", tempDirs); @@ -39,7 +40,7 @@ afterEach(() => { describe("setup-registry getJiti", () => { beforeEach(async () => { vi.resetModules(); - ({ clearPluginSetupRegistryCache, resolvePluginSetupRegistry } = + ({ clearPluginSetupRegistryCache, resolvePluginSetupRegistry, runPluginSetupConfigMigrations } = await import("./setup-registry.js")); clearPluginSetupRegistryCache(); mocks.createJiti.mockReset(); @@ -82,4 +83,140 @@ describe("setup-registry getJiti", () => { }), ); }); + + it("skips setup-api loading when config has no relevant migration triggers", () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "amazon-bedrock", + rootDir: pluginRoot, + configContracts: { + compatibilityMigrationPaths: ["models.bedrockDiscovery"], + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerConfigMigration: (migrate: (config: unknown) => unknown) => void; + }) { + api.registerConfigMigration((config) => ({ config, changes: ["unexpected"] })); + }, + }, + }); + }); + + const result = runPluginSetupConfigMigrations({ + config: { + models: { + providers: { + openai: { baseUrl: "https://api.openai.com/v1" }, + }, + }, + } as never, + env: {}, + }); + + expect(result.changes).toEqual([]); + expect(mocks.createJiti).not.toHaveBeenCalled(); + }); + + it("loads only plugins whose manifest migration triggers match the config", () => { + const bedrockRoot = makeTempDir(); + const voiceCallRoot = makeTempDir(); + fs.writeFileSync(path.join(bedrockRoot, "setup-api.js"), "export default {};\n", "utf-8"); + fs.writeFileSync(path.join(voiceCallRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "amazon-bedrock", + rootDir: bedrockRoot, + configContracts: { + compatibilityMigrationPaths: ["models.bedrockDiscovery"], + }, + }, + { + id: "voice-call", + rootDir: voiceCallRoot, + configContracts: { + compatibilityMigrationPaths: ["plugins.entries.voice-call.config"], + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation((modulePath: string) => { + const pluginId = modulePath.includes(bedrockRoot) ? "amazon-bedrock" : "voice-call"; + return () => ({ + default: { + register(api: { + registerConfigMigration: (migrate: (config: unknown) => unknown) => void; + }) { + api.registerConfigMigration((config) => ({ + config, + changes: [pluginId], + })); + }, + }, + }); + }); + + const result = runPluginSetupConfigMigrations({ + config: { + models: { + bedrockDiscovery: { + enabled: true, + }, + }, + } as never, + env: {}, + }); + + expect(result.changes).toEqual(["amazon-bedrock"]); + expect(mocks.createJiti).toHaveBeenCalledTimes(1); + expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(bedrockRoot, "setup-api.js")); + }); + + it("still loads explicitly configured plugin entries without manifest trigger metadata", () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [{ id: "voice-call", rootDir: pluginRoot }], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerConfigMigration: (migrate: (config: unknown) => unknown) => void; + }) { + api.registerConfigMigration((config) => ({ config, changes: ["voice-call"] })); + }, + }, + }); + }); + + const result = runPluginSetupConfigMigrations({ + config: { + plugins: { + entries: { + "voice-call": { + config: { + provider: "log", + }, + }, + }, + }, + } as never, + env: {}, + }); + + expect(result.changes).toEqual(["voice-call"]); + expect(mocks.createJiti).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 053b16288f6..c1afc107d5c 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -5,6 +5,7 @@ import { createJiti } from "jiti"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; import { buildPluginApi } from "./api-builder.js"; +import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; @@ -99,6 +100,7 @@ function getJiti(modulePath: string) { function buildSetupRegistryCacheKey(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -107,6 +109,7 @@ function buildSetupRegistryCacheKey(params: { return JSON.stringify({ roots, loadPaths, + pluginIds: params.pluginIds ? [...new Set(params.pluginIds)].toSorted() : null, }); } @@ -160,6 +163,48 @@ function resolveSetupApiPath(rootDir: string): string | null { return null; } +function collectConfiguredPluginEntryIds(config: OpenClawConfig): string[] { + const entries = config.plugins?.entries; + if (!entries || typeof entries !== "object") { + return []; + } + return Object.keys(entries) + .map((pluginId) => pluginId.trim()) + .filter(Boolean) + .toSorted(); +} + +function resolveRelevantSetupMigrationPluginIds(params: { + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string[] { + const ids = new Set(collectConfiguredPluginEntryIds(params.config)); + const registry = loadPluginManifestRegistry({ + workspaceDir: params.workspaceDir, + env: params.env, + cache: true, + }); + for (const plugin of registry.plugins) { + const paths = plugin.configContracts?.compatibilityMigrationPaths; + if (!paths?.length) { + continue; + } + if ( + paths.some( + (pathPattern) => + collectPluginConfigContractMatches({ + root: params.config, + pathPattern, + }).length > 0, + ) + ) { + ids.add(plugin.id); + } + } + return [...ids].toSorted(); +} + function resolveRegister(mod: OpenClawPluginModule): { definition?: { id?: string }; register?: (api: ReturnType) => void | Promise; @@ -189,17 +234,33 @@ function matchesProvider(provider: ProviderPlugin, providerId: string): boolean export function resolvePluginSetupRegistry(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): PluginSetupRegistry { const env = params?.env ?? process.env; const cacheKey = buildSetupRegistryCacheKey({ workspaceDir: params?.workspaceDir, env, + pluginIds: params?.pluginIds, }); const cached = setupRegistryCache.get(cacheKey); if (cached) { return cached; } + const selectedPluginIds = params?.pluginIds + ? new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)) + : null; + if (selectedPluginIds && selectedPluginIds.size === 0) { + const empty = { + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + } satisfies PluginSetupRegistry; + setupRegistryCache.set(cacheKey, empty); + return empty; + } + const providers: SetupProviderEntry[] = []; const cliBackends: SetupCliBackendEntry[] = []; const configMigrations: SetupConfigMigrationEntry[] = []; @@ -221,6 +282,9 @@ export function resolvePluginSetupRegistry(params?: { }); for (const record of manifestRegistry.plugins) { + if (selectedPluginIds && !selectedPluginIds.has(record.id)) { + continue; + } const setupSource = record.setupSource ?? resolveSetupApiPath(record.rootDir); if (!setupSource) { continue; @@ -516,8 +580,16 @@ export function runPluginSetupConfigMigrations(params: { } { let next = params.config; const changes: string[] = []; + const pluginIds = resolveRelevantSetupMigrationPluginIds(params); + if (pluginIds.length === 0) { + return { config: next, changes }; + } - for (const entry of resolvePluginSetupRegistry(params).configMigrations) { + for (const entry of resolvePluginSetupRegistry({ + workspaceDir: params.workspaceDir, + env: params.env, + pluginIds, + }).configMigrations) { const migration = entry.migrate(next); if (!migration || migration.changes.length === 0) { continue;