Plugins: extract registry compatibility facade
This commit is contained in:
parent
50f2293018
commit
d7f201fcd7
95
src/extension-host/plugin-registry.test.ts
Normal file
95
src/extension-host/plugin-registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
465
src/extension-host/plugin-registry.ts
Normal file
465
src/extension-host/plugin-registry.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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: [],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user