diff --git a/src/extension-host/plugin-registry-compat.test.ts b/src/extension-host/plugin-registry-compat.test.ts new file mode 100644 index 00000000000..c45313fb95f --- /dev/null +++ b/src/extension-host/plugin-registry-compat.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearPluginCommands } from "../plugins/commands.js"; +import { createEmptyPluginRegistry, type PluginRecord } from "../plugins/registry.js"; +import { + resolveExtensionHostCommandCompatibility, + resolveExtensionHostProviderCompatibility, +} from "./plugin-registry-compat.js"; + +function createRecord(): PluginRecord { + return { + id: "demo", + name: "Demo", + source: "/plugins/demo.ts", + origin: "workspace", + enabled: true, + status: "loaded", + toolNames: [], + hookNames: [], + channelIds: [], + providerIds: [], + gatewayMethods: [], + cliCommands: [], + services: [], + commands: [], + httpRoutes: 0, + hookCount: 0, + configSchema: false, + }; +} + +describe("extension host plugin registry compatibility", () => { + it("normalizes provider registration through the host-owned compatibility helper", () => { + const result = resolveExtensionHostProviderCompatibility({ + registry: createEmptyPluginRegistry(), + record: createRecord(), + provider: { + id: " demo-provider ", + label: " Demo Provider ", + auth: [{ id: " api-key ", label: " API Key " }], + } as never, + }); + + expect(result).toMatchObject({ + ok: true, + providerId: "demo-provider", + entry: { + provider: { + id: "demo-provider", + label: "Demo Provider", + auth: [{ id: "api-key", label: "API Key" }], + }, + }, + }); + }); + + it("reports duplicate command registration through the host-owned compatibility helper", () => { + clearPluginCommands(); + const registry = createEmptyPluginRegistry(); + const record = createRecord(); + + const first = resolveExtensionHostCommandCompatibility({ + registry, + record, + command: { + name: "demo", + description: "first", + handler: vi.fn(async () => ({ handled: true })), + }, + }); + const second = resolveExtensionHostCommandCompatibility({ + registry, + record, + command: { + name: "demo", + description: "second", + handler: vi.fn(async () => ({ handled: true })), + }, + }); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(false); + expect(registry.diagnostics).toContainEqual( + expect.objectContaining({ + level: "error", + pluginId: "demo", + message: 'command registration failed: Command "demo" already registered by plugin "demo"', + }), + ); + + clearPluginCommands(); + }); +}); diff --git a/src/extension-host/plugin-registry-compat.ts b/src/extension-host/plugin-registry-compat.ts new file mode 100644 index 00000000000..5a794ad59db --- /dev/null +++ b/src/extension-host/plugin-registry-compat.ts @@ -0,0 +1,116 @@ +import { registerPluginCommand } from "../plugins/commands.js"; +import { normalizeRegisteredProvider } from "../plugins/provider-validation.js"; +import type { PluginRecord, PluginRegistry } from "../plugins/registry.js"; +import type { + OpenClawPluginCommandDefinition, + PluginDiagnostic, + ProviderPlugin, +} from "../plugins/types.js"; +import { + type ExtensionHostCommandRegistration, + type ExtensionHostProviderRegistration, + resolveExtensionCommandRegistration, + resolveExtensionProviderRegistration, +} from "./runtime-registrations.js"; + +export function pushExtensionHostRegistryDiagnostic(params: { + registry: PluginRegistry; + level: PluginDiagnostic["level"]; + pluginId: string; + source: string; + message: string; +}) { + params.registry.diagnostics.push({ + level: params.level, + pluginId: params.pluginId, + source: params.source, + message: params.message, + }); +} + +export function resolveExtensionHostProviderCompatibility(params: { + registry: PluginRegistry; + record: PluginRecord; + provider: ProviderPlugin; +}): + | { + ok: true; + providerId: string; + entry: ExtensionHostProviderRegistration; + } + | { ok: false } { + const pushDiagnostic = (diag: PluginDiagnostic) => { + params.registry.diagnostics.push(diag); + }; + + const normalizedProvider = normalizeRegisteredProvider({ + pluginId: params.record.id, + source: params.record.source, + provider: params.provider, + pushDiagnostic, + }); + if (!normalizedProvider) { + return { ok: false }; + } + + const result = resolveExtensionProviderRegistration({ + existing: params.registry.providers, + ownerPluginId: params.record.id, + ownerSource: params.record.source, + provider: normalizedProvider, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry: params.registry, + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: result.message, + }); + return { ok: false }; + } + + return result; +} + +export function resolveExtensionHostCommandCompatibility(params: { + registry: PluginRegistry; + record: PluginRecord; + command: OpenClawPluginCommandDefinition; +}): + | { + ok: true; + commandName: string; + entry: ExtensionHostCommandRegistration; + } + | { ok: false } { + const normalized = resolveExtensionCommandRegistration({ + ownerPluginId: params.record.id, + ownerSource: params.record.source, + command: params.command, + }); + if (!normalized.ok) { + pushExtensionHostRegistryDiagnostic({ + registry: params.registry, + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: normalized.message, + }); + return { ok: false }; + } + + const result = registerPluginCommand(params.record.id, normalized.entry.command); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry: params.registry, + level: "error", + pluginId: params.record.id, + source: params.record.source, + message: `command registration failed: ${result.error}`, + }); + return { ok: false }; + } + + return normalized; +} diff --git a/src/extension-host/plugin-registry.ts b/src/extension-host/plugin-registry.ts index f68acbfb1c9..63a4764a12a 100644 --- a/src/extension-host/plugin-registry.ts +++ b/src/extension-host/plugin-registry.ts @@ -22,20 +22,16 @@ import { import { resolveExtensionChannelRegistration, resolveExtensionCliRegistration, - resolveExtensionCommandRegistration, resolveExtensionContextEngineRegistration, resolveExtensionGatewayMethodRegistration, resolveExtensionLegacyHookRegistration, resolveExtensionHttpRouteRegistration, - resolveExtensionProviderRegistration, resolveExtensionServiceRegistration, resolveExtensionToolRegistration, resolveExtensionTypedHookRegistration, } from "../extension-host/runtime-registrations.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { registerInternalHook } from "../hooks/internal-hooks.js"; -import { registerPluginCommand } from "../plugins/commands.js"; -import { normalizeRegisteredProvider } from "../plugins/provider-validation.js"; import type { PluginRecord, PluginRegistry, PluginRegistryParams } from "../plugins/registry.js"; import type { PluginDiagnostic, @@ -52,33 +48,22 @@ import type { ProviderPlugin, PluginHookRegistration as TypedPluginHookRegistration, } from "../plugins/types.js"; +import { + pushExtensionHostRegistryDiagnostic, + resolveExtensionHostCommandCompatibility, + resolveExtensionHostProviderCompatibility, +} from "./plugin-registry-compat.js"; type PluginTypedHookPolicy = { allowPromptInjection?: boolean; }; -function pushExtensionHostRegistryDiagnostic(params: { - registry: PluginRegistry; - level: PluginDiagnostic["level"]; - pluginId: string; - source: string; - message: string; -}) { - params.registry.diagnostics.push({ - level: params.level, - pluginId: params.pluginId, - source: params.source, - message: params.message, - }); -} - export function createExtensionHostPluginRegistry(params: { registry: PluginRegistry; registryParams: PluginRegistryParams; }) { const { registry, registryParams } = params; const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); - const pushDiagnostic = (diag: PluginDiagnostic) => { registry.diagnostics.push(diag); }; @@ -231,29 +216,12 @@ export function createExtensionHostPluginRegistry(params: { }; const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { - const normalizedProvider = normalizeRegisteredProvider({ - pluginId: record.id, - source: record.source, + const result = resolveExtensionHostProviderCompatibility({ + registry, + record, provider, - pushDiagnostic, - }); - if (!normalizedProvider) { - return; - } - const result = resolveExtensionProviderRegistration({ - existing: registry.providers, - ownerPluginId: record.id, - ownerSource: record.source, - provider: normalizedProvider, }); if (!result.ok) { - pushExtensionHostRegistryDiagnostic({ - registry, - level: "error", - pluginId: record.id, - source: record.source, - message: result.message, - }); return; } addExtensionProviderRegistration({ @@ -301,34 +269,10 @@ export function createExtensionHostPluginRegistry(params: { }; const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => { - const normalized = resolveExtensionCommandRegistration({ - ownerPluginId: record.id, - ownerSource: record.source, - command, - }); + const normalized = resolveExtensionHostCommandCompatibility({ registry, record, command }); if (!normalized.ok) { - pushExtensionHostRegistryDiagnostic({ - registry, - level: "error", - pluginId: record.id, - source: record.source, - message: normalized.message, - }); return; } - - const result = registerPluginCommand(record.id, normalized.entry.command); - if (!result.ok) { - pushExtensionHostRegistryDiagnostic({ - registry, - level: "error", - pluginId: record.id, - source: record.source, - message: `command registration failed: ${result.error}`, - }); - return; - } - addExtensionCommandRegistration({ registry, record,