From d7f201fcd7afc0bcdd9c674c42dd9506b2f2a504 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 17:10:47 +0000 Subject: [PATCH] Plugins: extract registry compatibility facade --- src/extension-host/plugin-registry.test.ts | 95 +++++ src/extension-host/plugin-registry.ts | 465 +++++++++++++++++++++ src/plugins/registry.ts | 11 - 3 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 src/extension-host/plugin-registry.test.ts create mode 100644 src/extension-host/plugin-registry.ts diff --git a/src/extension-host/plugin-registry.test.ts b/src/extension-host/plugin-registry.test.ts new file mode 100644 index 00000000000..9130e020fe6 --- /dev/null +++ b/src/extension-host/plugin-registry.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { clearPluginCommands } from "../plugins/commands.js"; +import { createEmptyPluginRegistry, type PluginRecord } from "../plugins/registry.js"; +import { createExtensionHostPluginRegistry } from "./plugin-registry.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", () => { + it("registers providers through the host-owned facade", () => { + const registry = createEmptyPluginRegistry(); + const facade = createExtensionHostPluginRegistry({ + registry, + registryParams: { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + runtime: {} as never, + }, + }); + + facade.registerProvider(createRecord(), { + id: " demo-provider ", + label: " Demo Provider ", + auth: [{ id: " api-key ", label: " API Key " }], + } as never); + + expect(registry.providers).toHaveLength(1); + expect(registry.providers[0]?.provider.id).toBe("demo-provider"); + expect(registry.providers[0]?.provider.label).toBe("Demo Provider"); + expect(registry.providers[0]?.provider.auth[0]?.id).toBe("api-key"); + }); + + it("records command registration failures as diagnostics through the host-owned facade", () => { + clearPluginCommands(); + const registry = createEmptyPluginRegistry(); + const facade = createExtensionHostPluginRegistry({ + registry, + registryParams: { + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + runtime: {} as never, + }, + }); + const record = createRecord(); + + facade.registerCommand(record, { + name: "demo", + description: "first", + handler: async () => ({ handled: true }), + }); + facade.registerCommand(record, { + name: "demo", + description: "second", + handler: async () => ({ handled: true }), + }); + + expect(registry.commands).toHaveLength(1); + 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.ts b/src/extension-host/plugin-registry.ts new file mode 100644 index 00000000000..f68acbfb1c9 --- /dev/null +++ b/src/extension-host/plugin-registry.ts @@ -0,0 +1,465 @@ +import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { registerContextEngine } from "../context-engine/registry.js"; +import { + applyExtensionHostTypedHookPolicy, + bridgeExtensionHostLegacyHooks, +} from "../extension-host/hook-compat.js"; +import { createExtensionHostPluginApi } from "../extension-host/plugin-api.js"; +import { + addExtensionChannelRegistration, + addExtensionCliRegistration, + addExtensionCommandRegistration, + addExtensionContextEngineRegistration, + addExtensionGatewayMethodRegistration, + addExtensionLegacyHookRegistration, + addExtensionHttpRouteRegistration, + addExtensionProviderRegistration, + addExtensionServiceRegistration, + addExtensionToolRegistration, + addExtensionTypedHookRegistration, +} from "../extension-host/registry-writes.js"; +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, + PluginHookHandlerMap, + PluginHookName, + OpenClawPluginApi, + OpenClawPluginChannelRegistration, + OpenClawPluginCliRegistrar, + OpenClawPluginCommandDefinition, + OpenClawPluginHookOptions, + OpenClawPluginHttpRouteParams, + OpenClawPluginService, + OpenClawPluginToolFactory, + ProviderPlugin, + PluginHookRegistration as TypedPluginHookRegistration, +} from "../plugins/types.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); + }; + + const registerTool = ( + record: PluginRecord, + tool: AnyAgentTool | OpenClawPluginToolFactory, + opts?: { name?: string; names?: string[]; optional?: boolean }, + ) => { + const result = resolveExtensionToolRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + tool, + opts, + }); + addExtensionToolRegistration({ registry, record, names: result.names, entry: result.entry }); + }; + + const registerHook = ( + record: PluginRecord, + events: string | string[], + handler: Parameters[1], + opts: OpenClawPluginHookOptions | undefined, + config: OpenClawPluginApi["config"], + ) => { + const normalized = resolveExtensionLegacyHookRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + events, + handler, + opts, + }); + if (!normalized.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: normalized.message, + }); + return; + } + addExtensionLegacyHookRegistration({ + registry, + record, + hookName: normalized.hookName, + entry: normalized.entry, + events: normalized.events, + }); + + bridgeExtensionHostLegacyHooks({ + events: normalized.events, + handler, + hookSystemEnabled: config?.hooks?.internal?.enabled === true, + register: opts?.register, + registerHook: registerInternalHook, + }); + }; + + const registerGatewayMethod = ( + record: PluginRecord, + method: string, + handler: GatewayRequestHandler, + ) => { + const result = resolveExtensionGatewayMethodRegistration({ + existing: registry.gatewayHandlers, + coreGatewayMethods, + method, + handler, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + addExtensionGatewayMethodRegistration({ + registry, + record, + method: result.method, + handler: result.handler, + }); + }; + + const registerHttpRoute = (record: PluginRecord, route: OpenClawPluginHttpRouteParams) => { + const result = resolveExtensionHttpRouteRegistration({ + existing: registry.httpRoutes, + ownerPluginId: record.id, + ownerSource: record.source, + route, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: result.message === "http route registration missing path" ? "warn" : "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + if (result.action === "replace") { + addExtensionHttpRouteRegistration({ + registry, + record, + action: "replace", + existingIndex: result.existingIndex, + entry: result.entry, + }); + return; + } + addExtensionHttpRouteRegistration({ + registry, + record, + action: "append", + entry: result.entry, + }); + }; + + const registerChannel = ( + record: PluginRecord, + registration: OpenClawPluginChannelRegistration | ChannelPlugin, + ) => { + const result = resolveExtensionChannelRegistration({ + existing: registry.channels, + ownerPluginId: record.id, + ownerSource: record.source, + registration, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + addExtensionChannelRegistration({ + registry, + record, + channelId: result.channelId, + entry: result.entry, + }); + }; + + const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { + const normalizedProvider = normalizeRegisteredProvider({ + pluginId: record.id, + source: record.source, + 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({ + registry, + record, + providerId: result.providerId, + entry: result.entry, + }); + }; + + const registerCli = ( + record: PluginRecord, + registrar: OpenClawPluginCliRegistrar, + opts?: { commands?: string[] }, + ) => { + const result = resolveExtensionCliRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + registrar, + opts, + }); + addExtensionCliRegistration({ + registry, + record, + commands: result.commands, + entry: result.entry, + }); + }; + + const registerService = (record: PluginRecord, service: OpenClawPluginService) => { + const result = resolveExtensionServiceRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + service, + }); + if (!result.ok) { + return; + } + addExtensionServiceRegistration({ + registry, + record, + serviceId: result.serviceId, + entry: result.entry, + }); + }; + + const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => { + const normalized = resolveExtensionCommandRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + 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, + commandName: normalized.commandName, + entry: normalized.entry, + }); + }; + + const registerTypedHook = ( + record: PluginRecord, + hookName: K, + handler: PluginHookHandlerMap[K], + opts?: { priority?: number }, + policy?: PluginTypedHookPolicy, + ) => { + const normalized = resolveExtensionTypedHookRegistration({ + ownerPluginId: record.id, + ownerSource: record.source, + hookName, + handler, + priority: opts?.priority, + }); + if (!normalized.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: normalized.message, + }); + return; + } + const policyResult = applyExtensionHostTypedHookPolicy({ + hookName: normalized.hookName, + handler, + policy, + blockedMessage: `typed hook "${normalized.hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + constrainedMessage: `typed hook "${normalized.hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, + }); + if (!policyResult.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: policyResult.message, + }); + return; + } + if (policyResult.warningMessage) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "warn", + pluginId: record.id, + source: record.source, + message: policyResult.warningMessage, + }); + } + addExtensionTypedHookRegistration({ + registry, + record, + entry: { + ...normalized.entry, + pluginId: record.id, + hookName: normalized.hookName, + handler: policyResult.entryHandler, + } as TypedPluginHookRegistration, + }); + }; + + const createApi = ( + record: PluginRecord, + params: { + config: OpenClawPluginApi["config"]; + pluginConfig?: Record; + hookPolicy?: PluginTypedHookPolicy; + }, + ): OpenClawPluginApi => { + return createExtensionHostPluginApi({ + record, + runtime: registryParams.runtime, + logger: registryParams.logger, + config: params.config, + pluginConfig: params.pluginConfig, + registerTool: (tool, opts) => registerTool(record, tool, opts), + registerHook: (events, handler, opts) => + registerHook(record, events, handler, opts, params.config), + registerHttpRoute: (routeParams) => registerHttpRoute(record, routeParams), + registerChannel: (registration) => registerChannel(record, registration as never), + registerProvider: (provider) => registerProvider(record, provider), + registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), + registerCli: (registrar, opts) => registerCli(record, registrar, opts), + registerService: (service) => registerService(record, service), + registerCommand: (command) => registerCommand(record, command), + registerContextEngine: (id, factory) => { + const result = resolveExtensionContextEngineRegistration({ + engineId: id, + factory, + }); + if (!result.ok) { + pushExtensionHostRegistryDiagnostic({ + registry, + level: "error", + pluginId: record.id, + source: record.source, + message: result.message, + }); + return; + } + addExtensionContextEngineRegistration({ + entry: result.entry, + registerEngine: registerContextEngine, + }); + }, + on: (hookName, handler, opts) => + registerTypedHook(record, hookName, handler, opts, params.hookPolicy), + }); + }; + + return { + registry, + createApi, + pushDiagnostic, + registerTool, + registerChannel, + registerProvider, + registerGatewayMethod, + registerCli, + registerService, + registerCommand, + registerHook, + registerTypedHook, + }; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 3a14c3fc3f9..e36c9b10f76 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,4 +1,3 @@ -import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelDock } from "../channels/dock.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { registerContextEngineForOwner } from "../context-engine/registry.js"; @@ -19,15 +18,11 @@ import { stripPromptMutationFieldsFromLegacyHookResult, } from "./types.js"; import type { - OpenClawPluginApi, - OpenClawPluginChannelRegistration, OpenClawPluginCliRegistrar, OpenClawPluginCommandDefinition, OpenClawPluginHttpRouteAuth, OpenClawPluginHttpRouteMatch, OpenClawPluginHttpRouteHandler, - OpenClawPluginHttpRouteParams, - OpenClawPluginHookOptions, ProviderPlugin, OpenClawPluginService, OpenClawPluginToolFactory, @@ -38,8 +33,6 @@ import type { PluginLogger, PluginOrigin, PluginKind, - PluginHookName, - PluginHookHandlerMap, PluginHookRegistration as TypedPluginHookRegistration, } from "./types.js"; @@ -174,10 +167,6 @@ export type PluginRegistryParams = { runtime: PluginRuntime; }; -type PluginTypedHookPolicy = { - allowPromptInjection?: boolean; -}; - export function createEmptyPluginRegistry(): PluginRegistry { return { plugins: [],