Plugins: extract registry compatibility facade

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 17:10:47 +00:00
parent 50f2293018
commit d7f201fcd7
No known key found for this signature in database
3 changed files with 560 additions and 11 deletions

View File

@ -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();
});
});

View File

@ -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<typeof registerInternalHook>[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 = <K extends PluginHookName>(
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<string, unknown>;
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,
};
}

View File

@ -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: [],