diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index f833e591983..406e450a3e2 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -528,6 +528,13 @@ Optional manifest `activation` and `setup` blocks stay on the control plane. They are metadata-only descriptors for activation planning and setup discovery; they do not replace runtime registration, `register(...)`, or `setupEntry`. +Setup discovery now prefers descriptor-owned ids such as `setup.providers` and +`setup.cliBackends` to narrow candidate plugins before it falls back to +`setup-api` for plugins that still need setup-time runtime hooks. If more than +one discovered plugin claims the same normalized setup provider or CLI backend +id, setup lookup refuses the ambiguous owner instead of relying on discovery +order. + ### What the loader caches OpenClaw keeps short in-process caches for: diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index d6818dc9bda..f1a6d424cd5 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -268,22 +268,33 @@ Top-level `cliBackends` stays valid and continues to describe CLI inference backends. `setup.cliBackends` is the setup-specific descriptor surface for control-plane/setup flows that should stay metadata-only. +When present, `setup.providers` and `setup.cliBackends` are the preferred +descriptor-first lookup surface for setup discovery. If the descriptor only +narrows the candidate plugin and setup still needs richer setup-time runtime +hooks, set `requiresRuntime: true` and keep `setup-api` in place as the +fallback execution path. + +Because setup lookup can execute plugin-owned `setup-api` code, normalized +`setup.providers[].id` and `setup.cliBackends[]` values must stay unique across +discovered plugins. Ambiguous ownership fails closed instead of picking a +winner from discovery order. + ### setup.providers reference -| Field | Required | Type | What it means | -| ------------- | -------- | ---------- | ---------------------------------------------------------------------------------- | -| `id` | Yes | `string` | Provider id exposed during setup or onboarding. | -| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. | -| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. | +| Field | Required | Type | What it means | +| ------------- | -------- | ---------- | ------------------------------------------------------------------------------------ | +| `id` | Yes | `string` | Provider id exposed during setup or onboarding. Keep normalized ids globally unique. | +| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. | +| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. | ### setup fields -| Field | Required | Type | What it means | -| ------------------ | -------- | ---------- | --------------------------------------------------------------------------- | -| `providers` | No | `object[]` | Provider setup descriptors exposed during setup and onboarding. | -| `cliBackends` | No | `string[]` | Setup-time backend ids available without full runtime activation. | -| `configMigrations` | No | `string[]` | Config migration ids owned by this plugin's setup surface. | -| `requiresRuntime` | No | `boolean` | Whether setup still needs plugin runtime execution after descriptor lookup. | +| Field | Required | Type | What it means | +| ------------------ | -------- | ---------- | --------------------------------------------------------------------------------------------------- | +| `providers` | No | `object[]` | Provider setup descriptors exposed during setup and onboarding. | +| `cliBackends` | No | `string[]` | Setup-time backend ids used for descriptor-first setup lookup. Keep normalized ids globally unique. | +| `configMigrations` | No | `string[]` | Config migration ids owned by this plugin's setup surface. | +| `requiresRuntime` | No | `boolean` | Whether setup still needs `setup-api` execution after descriptor lookup. | ## uiHints reference diff --git a/src/plugins/setup-descriptors.ts b/src/plugins/setup-descriptors.ts new file mode 100644 index 00000000000..a22f081af16 --- /dev/null +++ b/src/plugins/setup-descriptors.ts @@ -0,0 +1,11 @@ +import type { PluginManifestRecord } from "./manifest-registry.js"; + +type SetupDescriptorRecord = Pick; + +export function listSetupProviderIds(record: SetupDescriptorRecord): readonly string[] { + return record.setup?.providers?.map((entry) => entry.id) ?? record.providers; +} + +export function listSetupCliBackendIds(record: SetupDescriptorRecord): readonly string[] { + return record.setup?.cliBackends ?? record.cliBackends; +} diff --git a/src/plugins/setup-registry.runtime.test.ts b/src/plugins/setup-registry.runtime.test.ts index d618ed7db41..953ba876348 100644 --- a/src/plugins/setup-registry.runtime.test.ts +++ b/src/plugins/setup-registry.runtime.test.ts @@ -11,14 +11,18 @@ afterEach(() => { }); describe("setup-registry runtime fallback", () => { - it("uses bundled manifest cliBackends when the runtime registry has no match", async () => { + it("uses bundled manifest cliBackends when the setup-registry runtime is unavailable", async () => { loadPluginManifestRegistryMock.mockReturnValue({ diagnostics: [], plugins: [ { id: "openai", origin: "bundled", - cliBackends: ["Codex-CLI"], + cliBackends: ["legacy-openai-cli"], + setup: { + cliBackends: ["Codex-CLI"], + requiresRuntime: true, + }, }, { id: "local", @@ -31,9 +35,7 @@ describe("setup-registry runtime fallback", () => { const { __testing, resolvePluginSetupCliBackendRuntime } = await import("./setup-registry.runtime.js"); __testing.resetRuntimeState(); - __testing.setRuntimeModuleForTest({ - resolvePluginSetupCliBackend: () => undefined, - }); + __testing.setRuntimeModuleForTest(null); expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toEqual({ pluginId: "openai", @@ -43,4 +45,31 @@ describe("setup-registry runtime fallback", () => { expect(loadPluginManifestRegistryMock).toHaveBeenCalledTimes(1); expect(loadPluginManifestRegistryMock).toHaveBeenCalledWith({ cache: true }); }); + + it("preserves fail-closed setup lookup when the runtime module explicitly declines to resolve", async () => { + loadPluginManifestRegistryMock.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "openai", + origin: "bundled", + cliBackends: ["legacy-openai-cli"], + setup: { + cliBackends: ["Codex-CLI"], + requiresRuntime: true, + }, + }, + ], + }); + + const { __testing, resolvePluginSetupCliBackendRuntime } = + await import("./setup-registry.runtime.js"); + __testing.resetRuntimeState(); + __testing.setRuntimeModuleForTest({ + resolvePluginSetupCliBackend: () => undefined, + }); + + expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toBeUndefined(); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/setup-registry.runtime.ts b/src/plugins/setup-registry.runtime.ts index 3943874815c..e0801447d4a 100644 --- a/src/plugins/setup-registry.runtime.ts +++ b/src/plugins/setup-registry.runtime.ts @@ -1,6 +1,7 @@ import { createRequire } from "node:module"; import { normalizeProviderId } from "../agents/provider-id.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { listSetupCliBackendIds } from "./setup-descriptors.js"; type SetupRegistryRuntimeModule = Pick< typeof import("./setup-registry.js"), @@ -17,7 +18,7 @@ type SetupCliBackendRuntimeEntry = { const require = createRequire(import.meta.url); const SETUP_REGISTRY_RUNTIME_CANDIDATES = ["./setup-registry.js", "./setup-registry.ts"] as const; -let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | undefined; +let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | null | undefined; let bundledSetupCliBackendsCache: SetupCliBackendRuntimeEntry[] | undefined; export const __testing = { @@ -25,7 +26,7 @@ export const __testing = { setupRegistryRuntimeModule = undefined; bundledSetupCliBackendsCache = undefined; }, - setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | undefined): void { + setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | null | undefined): void { setupRegistryRuntimeModule = module; }, }; @@ -34,22 +35,29 @@ function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] { if (bundledSetupCliBackendsCache) { return bundledSetupCliBackendsCache; } - bundledSetupCliBackendsCache = loadPluginManifestRegistry({ cache: true }) - .plugins.filter((plugin) => plugin.origin === "bundled" && plugin.cliBackends.length > 0) - .flatMap((plugin) => - plugin.cliBackends.map( + bundledSetupCliBackendsCache = loadPluginManifestRegistry({ cache: true }).plugins.flatMap( + (plugin) => { + if (plugin.origin !== "bundled") { + return []; + } + const backendIds = listSetupCliBackendIds(plugin); + if (backendIds.length === 0) { + return []; + } + return backendIds.map( (backendId) => ({ pluginId: plugin.id, backend: { id: backendId }, }) satisfies SetupCliBackendRuntimeEntry, - ), - ); + ); + }, + ); return bundledSetupCliBackendsCache; } function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null { - if (setupRegistryRuntimeModule) { + if (setupRegistryRuntimeModule !== undefined) { return setupRegistryRuntimeModule; } for (const candidate of SETUP_REGISTRY_RUNTIME_CANDIDATES) { @@ -66,11 +74,8 @@ function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null { export function resolvePluginSetupCliBackendRuntime(params: { backend: string }) { const normalized = normalizeProviderId(params.backend); const runtime = loadSetupRegistryRuntime(); - if (runtime) { - const resolved = runtime.resolvePluginSetupCliBackend(params); - if (resolved) { - return resolved; - } + if (runtime !== null) { + return runtime.resolvePluginSetupCliBackend(params); } return resolveBundledSetupCliBackends().find( (entry) => normalizeProviderId(entry.backend.id) === normalized, diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index 1208a7f9183..ddb90bd4db4 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; import { getRegistryJitiMocks, @@ -11,25 +11,48 @@ const tempDirs: string[] = []; const mocks = getRegistryJitiMocks(); let clearPluginSetupRegistryCache: typeof import("./setup-registry.js").clearPluginSetupRegistryCache; +let setupRegistryTesting: typeof import("./setup-registry.js").__testing; let resolvePluginSetupRegistry: typeof import("./setup-registry.js").resolvePluginSetupRegistry; +let resolvePluginSetupProvider: typeof import("./setup-registry.js").resolvePluginSetupProvider; +let resolvePluginSetupCliBackend: typeof import("./setup-registry.js").resolvePluginSetupCliBackend; let runPluginSetupConfigMigrations: typeof import("./setup-registry.js").runPluginSetupConfigMigrations; function makeTempDir(): string { return makeTrackedTempDir("openclaw-setup-registry", tempDirs); } +async function expectNoUnhandledRejection(run: () => void | Promise): Promise { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + try { + await run(); + await Promise.resolve(); + await Promise.resolve(); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + expect(unhandledRejections).toEqual([]); +} + afterEach(() => { cleanupTrackedTempDirs(tempDirs); }); describe("setup-registry getJiti", () => { - beforeAll(async () => { - ({ clearPluginSetupRegistryCache, resolvePluginSetupRegistry, runPluginSetupConfigMigrations } = - await import("./setup-registry.js")); - }); - - beforeEach(() => { + beforeEach(async () => { resetRegistryJitiMocks(); + vi.resetModules(); + ({ + __testing: setupRegistryTesting, + clearPluginSetupRegistryCache, + resolvePluginSetupRegistry, + resolvePluginSetupProvider, + resolvePluginSetupCliBackend, + runPluginSetupConfigMigrations, + } = await import("./setup-registry.js")); clearPluginSetupRegistryCache(); }); @@ -195,4 +218,445 @@ describe("setup-registry getJiti", () => { expect(result.changes).toEqual(["voice-call"]); expect(mocks.createJiti).toHaveBeenCalledTimes(1); }); + + it("prefers setup provider descriptors over top-level provider ids", () => { + 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, + providers: ["legacy-bedrock"], + setup: { + providers: [{ id: "amazon-bedrock" }], + requiresRuntime: true, + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerProvider: (provider: { id: string; label: string; auth: [] }) => void; + }) { + api.registerProvider({ + id: "amazon-bedrock", + label: "Amazon Bedrock", + auth: [], + }); + }, + }, + }); + }); + + expect(resolvePluginSetupProvider({ provider: "amazon-bedrock", env: {} })).toEqual( + expect.objectContaining({ + id: "amazon-bedrock", + label: "Amazon Bedrock", + }), + ); + expect(resolvePluginSetupProvider({ provider: "legacy-bedrock", env: {} })).toBeUndefined(); + expect(mocks.createJiti).toHaveBeenCalledTimes(1); + expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js")); + }); + + it("resolves setup cli backends from descriptors without loading every setup-api", () => { + const openaiRoot = makeTempDir(); + const anthropicRoot = makeTempDir(); + fs.writeFileSync(path.join(openaiRoot, "setup-api.js"), "export default {};\n", "utf-8"); + fs.writeFileSync(path.join(anthropicRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + rootDir: openaiRoot, + cliBackends: ["legacy-openai-cli"], + setup: { + cliBackends: ["codex-cli"], + requiresRuntime: true, + }, + }, + { + id: "anthropic", + rootDir: anthropicRoot, + cliBackends: ["claude-cli"], + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation((modulePath: string) => { + return () => ({ + default: { + register(api: { + registerCliBackend: (backend: { id: string; config: { command: string } }) => void; + }) { + api.registerCliBackend( + modulePath.includes(openaiRoot) + ? { id: "codex-cli", config: { command: "codex" } } + : { id: "claude-cli", config: { command: "claude" } }, + ); + }, + }, + }); + }); + + const first = resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} }); + const second = resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} }); + + expect(first).toEqual({ + pluginId: "openai", + backend: { + id: "codex-cli", + config: { + command: "codex", + }, + }, + }); + expect(second).toEqual(first); + expect(resolvePluginSetupCliBackend({ backend: "legacy-openai-cli", env: {} })).toBeUndefined(); + expect(mocks.createJiti).toHaveBeenCalledTimes(1); + expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(openaiRoot, "setup-api.js")); + }); + + it("keeps synchronously registered cli backends even when register returns a promise", () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + rootDir: pluginRoot, + setup: { + cliBackends: ["codex-cli"], + requiresRuntime: true, + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerCliBackend: (backend: { id: string; config: { command: string } }) => void; + }) { + api.registerCliBackend({ + id: "codex-cli", + config: { command: "codex" }, + }); + return Promise.resolve(); + }, + }, + }); + }); + + expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toEqual({ + pluginId: "openai", + backend: { + id: "codex-cli", + config: { + command: "codex", + }, + }, + }); + }); + + it("swallows rejected async setup provider registration returns", async () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + rootDir: pluginRoot, + setup: { + providers: [{ id: "openai" }], + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerProvider: (provider: { id: string; label: string; auth: [] }) => void; + }) { + api.registerProvider({ + id: "openai", + label: "OpenAI", + auth: [], + }); + return Promise.reject(new Error("async provider register failed")); + }, + }, + }); + }); + + await expectNoUnhandledRejection(() => { + expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toEqual( + expect.objectContaining({ + id: "openai", + label: "OpenAI", + }), + ); + }); + }); + + it("swallows rejected async setup cli backend registration returns", async () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + rootDir: pluginRoot, + setup: { + cliBackends: ["codex-cli"], + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerCliBackend: (backend: { id: string; config: { command: string } }) => void; + }) { + api.registerCliBackend({ + id: "codex-cli", + config: { command: "codex" }, + }); + return Promise.reject(new Error("async cli backend register failed")); + }, + }, + }); + }); + + await expectNoUnhandledRejection(() => { + expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toEqual({ + pluginId: "openai", + backend: { + id: "codex-cli", + config: { + command: "codex", + }, + }, + }); + }); + }); + + it("swallows rejected async setup registry registration returns", async () => { + 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"] })); + return Promise.reject(new Error("async setup registry register failed")); + }, + }, + }); + }); + + await expectNoUnhandledRejection(() => { + expect(resolvePluginSetupRegistry({ env: {} }).configMigrations).toHaveLength(1); + }); + }); + + it("fails closed when multiple plugins claim the same setup provider id", () => { + const bundledRoot = makeTempDir(); + const workspaceRoot = makeTempDir(); + fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8"); + fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + origin: "bundled", + rootDir: bundledRoot, + setup: { + providers: [{ id: "openai" }], + }, + }, + { + id: "workspace-shadow", + origin: "workspace", + rootDir: workspaceRoot, + setup: { + providers: [{ id: "OpenAI" }], + }, + }, + ], + diagnostics: [], + }); + + expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toBeUndefined(); + expect(mocks.createJiti).not.toHaveBeenCalled(); + }); + + it("fails closed when duplicate plugin ids shadow the same setup provider id", () => { + const bundledRoot = makeTempDir(); + const workspaceRoot = makeTempDir(); + fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8"); + fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + origin: "bundled", + rootDir: bundledRoot, + setup: { + providers: [{ id: "openai" }], + }, + }, + { + id: "openai", + origin: "workspace", + rootDir: workspaceRoot, + setup: { + providers: [{ id: "OpenAI" }], + }, + }, + ], + diagnostics: [], + }); + + expect(resolvePluginSetupProvider({ provider: "openai", env: {} })).toBeUndefined(); + expect(mocks.createJiti).not.toHaveBeenCalled(); + }); + + it("fails closed when multiple plugins claim the same setup cli backend id", () => { + const bundledRoot = makeTempDir(); + const workspaceRoot = makeTempDir(); + fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8"); + fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + origin: "bundled", + rootDir: bundledRoot, + setup: { + cliBackends: ["codex-cli"], + }, + }, + { + id: "workspace-shadow", + origin: "workspace", + rootDir: workspaceRoot, + setup: { + cliBackends: ["CODEX-CLI"], + }, + }, + ], + diagnostics: [], + }); + + expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toBeUndefined(); + expect(mocks.createJiti).not.toHaveBeenCalled(); + }); + + it("fails closed when duplicate plugin ids shadow the same setup cli backend id", () => { + const bundledRoot = makeTempDir(); + const workspaceRoot = makeTempDir(); + fs.writeFileSync(path.join(bundledRoot, "setup-api.js"), "export default {};\n", "utf-8"); + fs.writeFileSync(path.join(workspaceRoot, "setup-api.js"), "export default {};\n", "utf-8"); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + origin: "bundled", + rootDir: bundledRoot, + setup: { + cliBackends: ["codex-cli"], + }, + }, + { + id: "openai", + origin: "workspace", + rootDir: workspaceRoot, + setup: { + cliBackends: ["CODEX-CLI"], + }, + }, + ], + diagnostics: [], + }); + + expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })).toBeUndefined(); + expect(mocks.createJiti).not.toHaveBeenCalled(); + }); + + it("bounds setup lookup caches with least-recently-used eviction", () => { + const pluginRoot = makeTempDir(); + fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); + setupRegistryTesting.setMaxSetupLookupCacheEntriesForTest(1); + mocks.loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "openai", + rootDir: pluginRoot, + setup: { + providers: [{ id: "openai" }, { id: "anthropic" }], + cliBackends: ["codex-cli", "claude-cli"], + requiresRuntime: true, + }, + }, + ], + diagnostics: [], + }); + const loadSetupModule = vi.fn(() => ({ + default: { + register(api: { + registerProvider: (provider: { id: string; label: string; auth: [] }) => void; + registerCliBackend: (backend: { id: string; config: { command: string } }) => void; + }) { + api.registerProvider({ id: "openai", label: "OpenAI", auth: [] }); + api.registerProvider({ id: "anthropic", label: "Anthropic", auth: [] }); + api.registerCliBackend({ id: "codex-cli", config: { command: "codex" } }); + api.registerCliBackend({ id: "claude-cli", config: { command: "claude" } }); + }, + }, + })); + mocks.createJiti.mockImplementation(() => loadSetupModule); + + expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai"); + expect(resolvePluginSetupProvider({ provider: "anthropic", env: {} })?.id).toBe("anthropic"); + expect(setupRegistryTesting.getCacheSizes().setupProvider).toBe(1); + expect(resolvePluginSetupProvider({ provider: "openai", env: {} })?.id).toBe("openai"); + + expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe( + "codex-cli", + ); + expect(resolvePluginSetupCliBackend({ backend: "claude-cli", env: {} })?.backend.id).toBe( + "claude-cli", + ); + expect(setupRegistryTesting.getCacheSizes().setupCliBackend).toBe(1); + expect(resolvePluginSetupCliBackend({ backend: "codex-cli", env: {} })?.backend.id).toBe( + "codex-cli", + ); + + resolvePluginSetupRegistry({ + env: {}, + pluginIds: ["openai"], + }); + resolvePluginSetupRegistry({ + env: {}, + pluginIds: ["anthropic"], + }); + expect(setupRegistryTesting.getCacheSizes().setupRegistry).toBe(1); + expect(loadSetupModule).toHaveBeenCalledTimes(7); + }); }); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 9df407496ab..4243fff7931 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -3,20 +3,22 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { CliBackendPlugin } from "./cli-backend.types.js"; +import { buildPluginApi } from "./api-builder.js"; import { collectPluginConfigContractMatches } from "./config-contracts.js"; import { discoverOpenClawPlugins } from "./discovery.js"; import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js"; -import { loadPluginManifestRegistry } from "./manifest-registry.js"; +import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import { resolvePluginCacheInputs } from "./roots.js"; +import type { PluginRuntime } from "./runtime/types.js"; +import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js"; import type { - SetupOnlyPluginApi, - SetupOnlyPluginModule, - SetupPluginAutoEnableProbe, - SetupPluginConfigMigration, - SetupPluginLogger, - SetupProviderPlugin, -} from "./setup-registry.types.js"; + CliBackendPlugin, + OpenClawPluginModule, + PluginConfigMigration, + PluginLogger, + PluginSetupAutoEnableProbe, + ProviderPlugin, +} from "./types.js"; const SETUP_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); @@ -26,7 +28,7 @@ const RUNNING_FROM_BUILT_ARTIFACT = type SetupProviderEntry = { pluginId: string; - provider: SetupProviderPlugin; + provider: ProviderPlugin; }; type SetupCliBackendEntry = { @@ -36,12 +38,12 @@ type SetupCliBackendEntry = { type SetupConfigMigrationEntry = { pluginId: string; - migrate: SetupPluginConfigMigration; + migrate: PluginConfigMigration; }; type SetupAutoEnableProbeEntry = { pluginId: string; - probe: SetupPluginAutoEnableProbe; + probe: PluginSetupAutoEnableProbe; }; type PluginSetupRegistry = { @@ -56,21 +58,45 @@ type SetupAutoEnableReason = { reason: string; }; -const EMPTY_SETUP_RUNTIME = {}; -const NOOP_LOGGER: SetupPluginLogger = { +const EMPTY_RUNTIME = {} as PluginRuntime; +const NOOP_LOGGER: PluginLogger = { info() {}, warn() {}, error() {}, }; +const MAX_SETUP_LOOKUP_CACHE_ENTRIES = 128; + const jitiLoaders: PluginJitiLoaderCache = new Map(); const setupRegistryCache = new Map(); -const setupProviderCache = new Map(); +const setupProviderCache = new Map(); +const setupCliBackendCache = new Map(); +let setupLookupCacheEntryCap = MAX_SETUP_LOOKUP_CACHE_ENTRIES; + +export const __testing = { + get maxSetupLookupCacheEntries() { + return setupLookupCacheEntryCap; + }, + setMaxSetupLookupCacheEntriesForTest(value?: number) { + setupLookupCacheEntryCap = + typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.max(1, Math.floor(value)) + : MAX_SETUP_LOOKUP_CACHE_ENTRIES; + }, + getCacheSizes() { + return { + setupRegistry: setupRegistryCache.size, + setupProvider: setupProviderCache.size, + setupCliBackend: setupCliBackendCache.size, + }; + }, +} as const; export function clearPluginSetupRegistryCache(): void { jitiLoaders.clear(); setupRegistryCache.clear(); setupProviderCache.clear(); + setupCliBackendCache.clear(); } function getJiti(modulePath: string) { @@ -81,6 +107,33 @@ function getJiti(modulePath: string) { }); } +function getCachedSetupValue( + cache: Map, + key: string, +): { hit: true; value: T } | { hit: false } { + if (!cache.has(key)) { + return { hit: false }; + } + const cached = cache.get(key) as T; + cache.delete(key); + cache.set(key, cached); + return { hit: true, value: cached }; +} + +function setCachedSetupValue(cache: Map, key: string, value: T): void { + if (cache.has(key)) { + cache.delete(key); + } + cache.set(key, value); + while (cache.size > setupLookupCacheEntryCap) { + const oldestKey = cache.keys().next().value; + if (typeof oldestKey !== "string") { + break; + } + cache.delete(oldestKey); + } +} + function buildSetupRegistryCacheKey(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -108,6 +161,17 @@ function buildSetupProviderCacheKey(params: { }); } +function buildSetupCliBackendCacheKey(params: { + backend: string; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): string { + return JSON.stringify({ + backend: normalizeProviderId(params.backend), + registry: buildSetupRegistryCacheKey(params), + }); +} + function resolveSetupApiPath(rootDir: string): string | null { const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT ? SETUP_API_EXTENSIONS @@ -189,9 +253,9 @@ function resolveRelevantSetupMigrationPluginIds(params: { return [...ids].toSorted(); } -function resolveRegister(mod: SetupOnlyPluginModule): { +function resolveRegister(mod: OpenClawPluginModule): { definition?: { id?: string }; - register?: (api: SetupOnlyPluginApi) => void | Promise; + register?: (api: ReturnType) => void | Promise; } { if (typeof mod === "function") { return { register: mod }; @@ -205,7 +269,16 @@ function resolveRegister(mod: SetupOnlyPluginModule): { return {}; } -function matchesProvider(provider: SetupProviderPlugin, providerId: string): boolean { +function ignoreAsyncSetupRegisterResult(result: void | Promise): void { + if (!result || typeof result.then !== "function") { + return; + } + // Setup-only registration is sync-only. Swallow async rejections so they do + // not trip the global unhandledRejection fatal path. + void Promise.resolve(result).catch(() => undefined); +} + +function matchesProvider(provider: ProviderPlugin, providerId: string): boolean { const normalized = normalizeProviderId(providerId); if (normalizeProviderId(provider.id) === normalized) { return true; @@ -215,71 +288,36 @@ function matchesProvider(provider: SetupProviderPlugin, providerId: string): boo ); } -function createSetupOnlyPluginApi(params: { - id: string; - name: string; - version?: string; - description?: string; - source: string; - rootDir?: string; - config?: OpenClawConfig; - registerProvider?: (provider: SetupProviderPlugin) => void; - registerCliBackend?: (backend: CliBackendPlugin) => void; - registerConfigMigration?: (migrate: SetupPluginConfigMigration) => void; - registerAutoEnableProbe?: (probe: SetupPluginAutoEnableProbe) => void; -}): SetupOnlyPluginApi { - const noop = (..._args: unknown[]) => {}; - return { - id: params.id, - name: params.name, - version: params.version, - description: params.description, - source: params.source, - rootDir: params.rootDir, - registrationMode: "setup-only", - config: params.config ?? ({} as OpenClawConfig), - runtime: EMPTY_SETUP_RUNTIME, - logger: NOOP_LOGGER, - resolvePath: (input) => input, - registerProvider: params.registerProvider ?? noop, - registerCliBackend: params.registerCliBackend ?? noop, - registerConfigMigration: params.registerConfigMigration ?? noop, - registerAutoEnableProbe: params.registerAutoEnableProbe ?? noop, - registerTool: noop, - registerHook: noop, - registerHttpRoute: noop, - registerChannel: noop, - registerGatewayMethod: noop, - registerCli: noop, - registerReload: noop, - registerNodeHostCommand: noop, - registerSecurityAuditCollector: noop, - registerService: noop, - registerTextTransforms: noop, - registerSpeechProvider: noop, - registerRealtimeTranscriptionProvider: noop, - registerRealtimeVoiceProvider: noop, - registerMediaUnderstandingProvider: noop, - registerImageGenerationProvider: noop, - registerVideoGenerationProvider: noop, - registerMusicGenerationProvider: noop, - registerWebFetchProvider: noop, - registerWebSearchProvider: noop, - registerInteractiveHandler: noop, - onConversationBindingResolved: noop, - registerCommand: noop, - registerContextEngine: noop, - registerCompactionProvider: noop, - registerAgentHarness: noop, - registerMemoryCapability: noop, - registerMemoryPromptSection: noop, - registerMemoryPromptSupplement: noop, - registerMemoryCorpusSupplement: noop, - registerMemoryFlushPlan: noop, - registerMemoryRuntime: noop, - registerMemoryEmbeddingProvider: noop, - on: noop, - }; +function loadSetupManifestRegistry(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv }) { + const env = params?.env ?? process.env; + const discovery = discoverOpenClawPlugins({ + workspaceDir: params?.workspaceDir, + env, + cache: true, + }); + return loadPluginManifestRegistry({ + workspaceDir: params?.workspaceDir, + env, + cache: true, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); +} + +function findUniqueSetupManifestOwner(params: { + registry: ReturnType; + normalizedId: string; + listIds: (record: PluginManifestRecord) => readonly string[]; +}): PluginManifestRecord | undefined { + const matches = params.registry.plugins.filter((entry) => + params.listIds(entry).some((id) => normalizeProviderId(id) === params.normalizedId), + ); + if (matches.length === 0) { + return undefined; + } + // Setup lookup can execute plugin code. Refuse ambiguous ownership instead of + // depending on manifest ordering across bundled/workspace/global sources. + return matches.length === 1 ? matches[0] : undefined; } export function resolvePluginSetupRegistry(params?: { @@ -293,9 +331,9 @@ export function resolvePluginSetupRegistry(params?: { env, pluginIds: params?.pluginIds, }); - const cached = setupRegistryCache.get(cacheKey); - if (cached) { - return cached; + const cached = getCachedSetupValue(setupRegistryCache, cacheKey); + if (cached.hit) { + return cached.value; } const selectedPluginIds = params?.pluginIds @@ -308,7 +346,7 @@ export function resolvePluginSetupRegistry(params?: { configMigrations: [], autoEnableProbes: [], } satisfies PluginSetupRegistry; - setupRegistryCache.set(cacheKey, empty); + setCachedSetupValue(setupRegistryCache, cacheKey, empty); return empty; } @@ -319,17 +357,9 @@ export function resolvePluginSetupRegistry(params?: { const providerKeys = new Set(); const cliBackendKeys = new Set(); - const discovery = discoverOpenClawPlugins({ + const manifestRegistry = loadSetupManifestRegistry({ workspaceDir: params?.workspaceDir, env, - cache: true, - }); - const manifestRegistry = loadPluginManifestRegistry({ - workspaceDir: params?.workspaceDir, - env, - cache: true, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, }); for (const record of manifestRegistry.plugins) { @@ -341,14 +371,14 @@ export function resolvePluginSetupRegistry(params?: { continue; } - let mod: SetupOnlyPluginModule; + let mod: OpenClawPluginModule; try { - mod = getJiti(setupSource)(setupSource) as SetupOnlyPluginModule; + mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule; } catch { continue; } - const resolved = resolveRegister((mod as { default?: SetupOnlyPluginModule }).default ?? mod); + const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod); if (!resolved.register) { continue; } @@ -356,46 +386,53 @@ export function resolvePluginSetupRegistry(params?: { continue; } - const api = createSetupOnlyPluginApi({ + const api = buildPluginApi({ id: record.id, name: record.name ?? record.id, version: record.version, description: record.description, source: setupSource, rootDir: record.rootDir, - registerProvider(provider) { - const key = `${record.id}:${normalizeProviderId(provider.id)}`; - if (providerKeys.has(key)) { - return; - } - providerKeys.add(key); - providers.push({ - pluginId: record.id, - provider, - }); - }, - registerCliBackend(backend) { - const key = `${record.id}:${normalizeProviderId(backend.id)}`; - if (cliBackendKeys.has(key)) { - return; - } - cliBackendKeys.add(key); - cliBackends.push({ - pluginId: record.id, - backend, - }); - }, - registerConfigMigration(migrate) { - configMigrations.push({ - pluginId: record.id, - migrate, - }); - }, - registerAutoEnableProbe(probe) { - autoEnableProbes.push({ - pluginId: record.id, - probe, - }); + registrationMode: "setup-only", + config: {} as OpenClawConfig, + runtime: EMPTY_RUNTIME, + logger: NOOP_LOGGER, + resolvePath: (input) => input, + handlers: { + registerProvider(provider) { + const key = `${record.id}:${normalizeProviderId(provider.id)}`; + if (providerKeys.has(key)) { + return; + } + providerKeys.add(key); + providers.push({ + pluginId: record.id, + provider, + }); + }, + registerCliBackend(backend) { + const key = `${record.id}:${normalizeProviderId(backend.id)}`; + if (cliBackendKeys.has(key)) { + return; + } + cliBackendKeys.add(key); + cliBackends.push({ + pluginId: record.id, + backend, + }); + }, + registerConfigMigration(migrate) { + configMigrations.push({ + pluginId: record.id, + migrate, + }); + }, + registerAutoEnableProbe(probe) { + autoEnableProbes.push({ + pluginId: record.id, + probe, + }); + }, }, }); @@ -403,6 +440,7 @@ export function resolvePluginSetupRegistry(params?: { const result = resolved.register(api); if (result && typeof result.then === "function") { // Keep setup registration sync-only. + ignoreAsyncSetupRegisterResult(result); } } catch { continue; @@ -415,7 +453,7 @@ export function resolvePluginSetupRegistry(params?: { configMigrations, autoEnableProbes, } satisfies PluginSetupRegistry; - setupRegistryCache.set(cacheKey, registry); + setCachedSetupValue(setupRegistryCache, cacheKey, registry); return registry; } @@ -423,76 +461,80 @@ export function resolvePluginSetupProvider(params: { provider: string; workspaceDir?: string; env?: NodeJS.ProcessEnv; -}): SetupProviderPlugin | undefined { +}): ProviderPlugin | undefined { const cacheKey = buildSetupProviderCacheKey(params); - if (setupProviderCache.has(cacheKey)) { - return setupProviderCache.get(cacheKey) ?? undefined; + const cached = getCachedSetupValue(setupProviderCache, cacheKey); + if (cached.hit) { + return cached.value ?? undefined; } const env = params.env ?? process.env; const normalizedProvider = normalizeProviderId(params.provider); - const discovery = discoverOpenClawPlugins({ + const manifestRegistry = loadSetupManifestRegistry({ workspaceDir: params.workspaceDir, env, - cache: true, }); - const manifestRegistry = loadPluginManifestRegistry({ - workspaceDir: params.workspaceDir, - env, - cache: true, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, + const record = findUniqueSetupManifestOwner({ + registry: manifestRegistry, + normalizedId: normalizedProvider, + listIds: listSetupProviderIds, }); - const record = manifestRegistry.plugins.find((entry) => - entry.providers.some((providerId) => normalizeProviderId(providerId) === normalizedProvider), - ); if (!record) { - setupProviderCache.set(cacheKey, null); + setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } const setupSource = record.setupSource ?? resolveSetupApiPath(record.rootDir); if (!setupSource) { - setupProviderCache.set(cacheKey, null); + setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } - let mod: SetupOnlyPluginModule; + let mod: OpenClawPluginModule; try { - mod = getJiti(setupSource)(setupSource) as SetupOnlyPluginModule; + mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule; } catch { - setupProviderCache.set(cacheKey, null); + setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } - const resolved = resolveRegister((mod as { default?: SetupOnlyPluginModule }).default ?? mod); + const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod); if (!resolved.register) { - setupProviderCache.set(cacheKey, null); + setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } if (resolved.definition?.id && resolved.definition.id !== record.id) { - setupProviderCache.set(cacheKey, null); + setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } - let matchedProvider: SetupProviderPlugin | undefined; + let matchedProvider: ProviderPlugin | undefined; const localProviderKeys = new Set(); - const api = createSetupOnlyPluginApi({ + const api = buildPluginApi({ id: record.id, name: record.name ?? record.id, version: record.version, description: record.description, source: setupSource, rootDir: record.rootDir, - registerProvider(provider) { - const key = normalizeProviderId(provider.id); - if (localProviderKeys.has(key)) { - return; - } - localProviderKeys.add(key); - if (matchesProvider(provider, normalizedProvider)) { - matchedProvider = provider; - } + registrationMode: "setup-only", + config: {} as OpenClawConfig, + runtime: EMPTY_RUNTIME, + logger: NOOP_LOGGER, + resolvePath: (input) => input, + handlers: { + registerProvider(provider) { + const key = normalizeProviderId(provider.id); + if (localProviderKeys.has(key)) { + return; + } + localProviderKeys.add(key); + if (matchesProvider(provider, normalizedProvider)) { + matchedProvider = provider; + } + }, + registerConfigMigration() {}, + registerAutoEnableProbe() {}, }, }); @@ -500,13 +542,14 @@ export function resolvePluginSetupProvider(params: { const result = resolved.register(api); if (result && typeof result.then === "function") { // Keep setup registration sync-only. + ignoreAsyncSetupRegisterResult(result); } } catch { - setupProviderCache.set(cacheKey, null); + setCachedSetupValue(setupProviderCache, cacheKey, null); return undefined; } - setupProviderCache.set(cacheKey, matchedProvider ?? null); + setCachedSetupValue(setupProviderCache, cacheKey, matchedProvider ?? null); return matchedProvider; } @@ -515,84 +558,100 @@ export function resolvePluginSetupCliBackend(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): SetupCliBackendEntry | undefined { - const normalized = normalizeProviderId(params.backend); - const direct = resolvePluginSetupRegistry(params).cliBackends.find( - (entry) => normalizeProviderId(entry.backend.id) === normalized, - ); - if (direct) { - return direct; + const cacheKey = buildSetupCliBackendCacheKey(params); + const cached = getCachedSetupValue(setupCliBackendCache, cacheKey); + if (cached.hit) { + return cached.value ?? undefined; } + const normalized = normalizeProviderId(params.backend); + const env = params.env ?? process.env; - const discovery = discoverOpenClawPlugins({ + // Narrow setup lookup from manifest-owned descriptors before executing any + // plugin setup module. This avoids booting every setup-api just to find one + // backend owner. + const manifestRegistry = loadSetupManifestRegistry({ workspaceDir: params.workspaceDir, env, - cache: true, }); - const manifestRegistry = loadPluginManifestRegistry({ - workspaceDir: params.workspaceDir, - env, - cache: true, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, + const record = findUniqueSetupManifestOwner({ + registry: manifestRegistry, + normalizedId: normalized, + listIds: listSetupCliBackendIds, }); - const record = manifestRegistry.plugins.find((entry) => - entry.cliBackends.some((backendId) => normalizeProviderId(backendId) === normalized), - ); if (!record) { + setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } const setupSource = record.setupSource ?? resolveSetupApiPath(record.rootDir); if (!setupSource) { + setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } - let mod: SetupOnlyPluginModule; + let mod: OpenClawPluginModule; try { - mod = getJiti(setupSource)(setupSource) as SetupOnlyPluginModule; + mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule; } catch { + setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } - const resolved = resolveRegister((mod as { default?: SetupOnlyPluginModule }).default ?? mod); + const resolved = resolveRegister((mod as { default?: OpenClawPluginModule }).default ?? mod); if (!resolved.register) { + setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } if (resolved.definition?.id && resolved.definition.id !== record.id) { + setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } let matchedBackend: CliBackendPlugin | undefined; const localBackendKeys = new Set(); - const api = createSetupOnlyPluginApi({ + const api = buildPluginApi({ id: record.id, name: record.name ?? record.id, version: record.version, description: record.description, source: setupSource, rootDir: record.rootDir, - registerCliBackend(backend) { - const key = normalizeProviderId(backend.id); - if (localBackendKeys.has(key)) { - return; - } - localBackendKeys.add(key); - if (key === normalized) { - matchedBackend = backend; - } + registrationMode: "setup-only", + config: {} as OpenClawConfig, + runtime: EMPTY_RUNTIME, + logger: NOOP_LOGGER, + resolvePath: (input) => input, + handlers: { + registerProvider() {}, + registerConfigMigration() {}, + registerAutoEnableProbe() {}, + registerCliBackend(backend) { + const key = normalizeProviderId(backend.id); + if (localBackendKeys.has(key)) { + return; + } + localBackendKeys.add(key); + if (key === normalized) { + matchedBackend = backend; + } + }, }, }); try { const result = resolved.register(api); if (result && typeof result.then === "function") { - return undefined; + // Keep setup registration sync-only. + ignoreAsyncSetupRegisterResult(result); } } catch { + setCachedSetupValue(setupCliBackendCache, cacheKey, null); return undefined; } - return matchedBackend ? { pluginId: record.id, backend: matchedBackend } : undefined; + const resolvedEntry = matchedBackend ? { pluginId: record.id, backend: matchedBackend } : null; + setCachedSetupValue(setupCliBackendCache, cacheKey, resolvedEntry); + return resolvedEntry ?? undefined; } export function runPluginSetupConfigMigrations(params: {