From 6bc0dc8fb6714b4e858fb65f9f8051e6b64c8440 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 24 Apr 2026 11:15:30 -0700 Subject: [PATCH] feat(plugins): report setup descriptor drift (#71194) --- CHANGELOG.md | 1 + docs/plugins/architecture-internals.md | 4 +- docs/plugins/manifest.md | 5 ++ src/plugins/setup-registry.test.ts | 75 ++++++++++++++++++++ src/plugins/setup-registry.ts | 97 ++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e0aa386153..fb7ba6bf826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Gradium: add a bundled text-to-speech provider with voice-note and telephony output support. (#64958) Thanks @LaurentMazare. - Plugins/setup: honor explicit `setup.requiresRuntime: false` as a descriptor-only setup contract while keeping omitted values on the legacy setup-api fallback path. Thanks @vincentkoc. +- Plugins/setup: report descriptor/runtime drift when setup-api registrations disagree with `setup.providers` or `setup.cliBackends`, without rejecting legacy setup plugins. Thanks @vincentkoc. - TUI/dependencies: remove direct `cli-highlight` usage from the OpenClaw TUI code-block renderer, keeping themed code coloring without the extra root dependency. Thanks @vincentkoc. - Diagnostics/OTEL: export run, model-call, and tool-execution diagnostic lifecycle events as OTEL spans without retaining live span state. Thanks @vincentkoc. - Plugins/activation: expose activation plan reasons and a richer plan API so callers can inspect why a plugin was selected while preserving existing id-list activation behavior. (#70943) Thanks @vincentkoc. diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index ba1f4c94d92..3bbc2294a6c 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -76,7 +76,9 @@ Setup discovery now prefers descriptor-owned ids such as `setup.providers` and `requiresRuntime` keeps the legacy setup-api fallback for compatibility. 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. +discovery order. When setup runtime does execute, registry diagnostics report +drift between `setup.providers` / `setup.cliBackends` and the providers or CLI +backends registered by setup-api without blocking legacy plugins. ### What the loader caches diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index fbd3712b851..6d460f9c021 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -338,6 +338,11 @@ Because setup lookup can execute plugin-owned `setup-api` code, normalized discovered plugins. Ambiguous ownership fails closed instead of picking a winner from discovery order. +When setup runtime does execute, setup registry diagnostics report descriptor +drift if `setup-api` registers a provider or CLI backend that the manifest +descriptors do not declare, or if a descriptor has no matching runtime +registration. These diagnostics are additive and do not reject legacy plugins. + ### setup.providers reference | Field | Required | Type | What it means | diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index 7d20fc1c00a..df7adfbd8f8 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -378,10 +378,85 @@ describe("setup-registry getJiti", () => { cliBackends: [], configMigrations: [], autoEnableProbes: [], + diagnostics: [], }); expect(mocks.createJiti).not.toHaveBeenCalled(); }); + it("reports setup descriptor drift without rejecting runtime registrations", () => { + 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" }], + cliBackends: ["codex-cli"], + requiresRuntime: true, + }, + }, + ], + diagnostics: [], + }); + mocks.createJiti.mockImplementation(() => { + return () => ({ + default: { + register(api: { + registerProvider: (provider: { id: string; label: string; auth: [] }) => void; + registerCliBackend: (backend: { id: string; config: { command: string } }) => void; + }) { + api.registerProvider({ + id: "anthropic", + label: "Anthropic", + auth: [], + }); + api.registerCliBackend({ + id: "claude-cli", + config: { command: "claude" }, + }); + }, + }, + }); + }); + + const registry = resolvePluginSetupRegistry({ env: {} }); + + expect(registry.providers.map((entry) => entry.provider.id)).toEqual(["anthropic"]); + expect(registry.cliBackends.map((entry) => entry.backend.id)).toEqual(["claude-cli"]); + expect(registry.diagnostics).toEqual([ + expect.objectContaining({ + pluginId: "openai", + code: "setup-descriptor-provider-missing-runtime", + declaredId: "openai", + }), + expect.objectContaining({ + pluginId: "openai", + code: "setup-descriptor-provider-runtime-undeclared", + runtimeId: "anthropic", + }), + expect.objectContaining({ + pluginId: "openai", + code: "setup-descriptor-cli-backend-missing-runtime", + declaredId: "codex-cli", + }), + expect.objectContaining({ + pluginId: "openai", + code: "setup-descriptor-cli-backend-runtime-undeclared", + runtimeId: "claude-cli", + }), + ]); + }); + + it("does not report drift when setup descriptors match runtime registrations", () => { + mockOpenAiCliBackendRegistration({ + requiresRuntime: true, + }); + + expect(resolvePluginSetupRegistry({ env: {} }).diagnostics).toEqual([]); + }); + it("does not load setup-api modules from the current working directory", () => { const pluginRoot = makeTempDir(); const workspaceRoot = makeTempDir(); diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index b1d9645af89..bf5441bf071 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -46,11 +46,26 @@ type SetupAutoEnableProbeEntry = { probe: PluginSetupAutoEnableProbe; }; +export type PluginSetupRegistryDiagnosticCode = + | "setup-descriptor-provider-missing-runtime" + | "setup-descriptor-provider-runtime-undeclared" + | "setup-descriptor-cli-backend-missing-runtime" + | "setup-descriptor-cli-backend-runtime-undeclared"; + +export type PluginSetupRegistryDiagnostic = { + pluginId: string; + code: PluginSetupRegistryDiagnosticCode; + declaredId?: string; + runtimeId?: string; + message: string; +}; + type PluginSetupRegistry = { providers: SetupProviderEntry[]; cliBackends: SetupCliBackendEntry[]; configMigrations: SetupConfigMigrationEntry[]; autoEnableProbes: SetupAutoEnableProbeEntry[]; + diagnostics: PluginSetupRegistryDiagnostic[]; }; type SetupAutoEnableReason = { @@ -372,6 +387,75 @@ function findUniqueSetupManifestOwner(params: { return matches.length === 1 ? matches[0] : undefined; } +function mapNormalizedIds(ids: readonly string[]): Map { + const mapped = new Map(); + for (const id of ids) { + const normalized = normalizeProviderId(id); + if (!normalized || mapped.has(normalized)) { + continue; + } + mapped.set(normalized, id); + } + return mapped; +} + +function pushSetupDescriptorDriftDiagnostics(params: { + record: PluginManifestRecord; + providers: readonly ProviderPlugin[]; + cliBackends: readonly CliBackendPlugin[]; + diagnostics: PluginSetupRegistryDiagnostic[]; +}): void { + const declaredProviderIds = params.record.setup?.providers?.map((entry) => entry.id); + if (declaredProviderIds) { + for (const declaredId of declaredProviderIds) { + if (!params.providers.some((provider) => matchesProvider(provider, declaredId))) { + params.diagnostics.push({ + pluginId: params.record.id, + code: "setup-descriptor-provider-missing-runtime", + declaredId, + message: `setup.providers declares "${declaredId}" but setup runtime did not register a matching provider.`, + }); + } + } + for (const provider of params.providers) { + if (!declaredProviderIds.some((declaredId) => matchesProvider(provider, declaredId))) { + params.diagnostics.push({ + pluginId: params.record.id, + code: "setup-descriptor-provider-runtime-undeclared", + runtimeId: provider.id, + message: `setup runtime registered provider "${provider.id}" but setup.providers does not declare it.`, + }); + } + } + } + + const declaredCliBackendIds = params.record.setup?.cliBackends; + if (declaredCliBackendIds) { + const declaredCliBackends = mapNormalizedIds(declaredCliBackendIds); + const runtimeCliBackends = mapNormalizedIds(params.cliBackends.map((backend) => backend.id)); + for (const [normalized, declaredId] of declaredCliBackends) { + if (!runtimeCliBackends.has(normalized)) { + params.diagnostics.push({ + pluginId: params.record.id, + code: "setup-descriptor-cli-backend-missing-runtime", + declaredId, + message: `setup.cliBackends declares "${declaredId}" but setup runtime did not register a matching CLI backend.`, + }); + } + } + for (const [normalized, runtimeId] of runtimeCliBackends) { + if (!declaredCliBackends.has(normalized)) { + params.diagnostics.push({ + pluginId: params.record.id, + code: "setup-descriptor-cli-backend-runtime-undeclared", + runtimeId, + message: `setup runtime registered CLI backend "${runtimeId}" but setup.cliBackends does not declare it.`, + }); + } + } + } +} + export function resolvePluginSetupRegistry(params?: { workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -397,6 +481,7 @@ export function resolvePluginSetupRegistry(params?: { cliBackends: [], configMigrations: [], autoEnableProbes: [], + diagnostics: [], } satisfies PluginSetupRegistry; setCachedSetupValue(setupRegistryCache, cacheKey, empty); return empty; @@ -406,6 +491,7 @@ export function resolvePluginSetupRegistry(params?: { const cliBackends: SetupCliBackendEntry[] = []; const configMigrations: SetupConfigMigrationEntry[] = []; const autoEnableProbes: SetupAutoEnableProbeEntry[] = []; + const diagnostics: PluginSetupRegistryDiagnostic[] = []; const providerKeys = new Set(); const cliBackendKeys = new Set(); @@ -423,6 +509,8 @@ export function resolvePluginSetupRegistry(params?: { continue; } + const recordProviders: ProviderPlugin[] = []; + const recordCliBackends: CliBackendPlugin[] = []; const api = buildSetupPluginApi({ record, setupSource: setupRegistration.setupSource, @@ -437,6 +525,7 @@ export function resolvePluginSetupRegistry(params?: { pluginId: record.id, provider, }); + recordProviders.push(provider); }, registerCliBackend(backend) { const key = `${record.id}:${normalizeProviderId(backend.id)}`; @@ -448,6 +537,7 @@ export function resolvePluginSetupRegistry(params?: { pluginId: record.id, backend, }); + recordCliBackends.push(backend); }, registerConfigMigration(migrate) { configMigrations.push({ @@ -473,6 +563,12 @@ export function resolvePluginSetupRegistry(params?: { } catch { continue; } + pushSetupDescriptorDriftDiagnostics({ + record, + providers: recordProviders, + cliBackends: recordCliBackends, + diagnostics, + }); } const registry = { @@ -480,6 +576,7 @@ export function resolvePluginSetupRegistry(params?: { cliBackends, configMigrations, autoEnableProbes, + diagnostics, } satisfies PluginSetupRegistry; setCachedSetupValue(setupRegistryCache, cacheKey, registry); return registry;