Files
openclaw/src/plugins/registry.ts
2026-03-15 23:15:32 +00:00

715 lines
20 KiB
TypeScript

import type { ChannelDock } from "../channels/dock.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import { registerContextEngineForOwner } from "../context-engine/registry.js";
import type {
GatewayRequestHandler,
GatewayRequestHandlers,
} from "../gateway/server-methods/types.js";
import { registerInternalHook } from "../hooks/internal-hooks.js";
import { registerPluginCommand } from "./commands.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import { registerPluginInteractiveHandler } from "./interactive.js";
import { normalizeRegisteredProvider } from "./provider-validation.js";
import type { PluginRuntime } from "./runtime/types.js";
import { defaultSlotIdForKey } from "./slots.js";
import {
isPromptInjectionHookName,
stripPromptMutationFieldsFromLegacyHookResult,
} from "./types.js";
import type {
OpenClawPluginCliRegistrar,
OpenClawPluginCommandDefinition,
OpenClawPluginHttpRouteAuth,
OpenClawPluginHttpRouteMatch,
OpenClawPluginHttpRouteHandler,
ProviderPlugin,
OpenClawPluginService,
OpenClawPluginToolFactory,
PluginConfigUiHint,
PluginDiagnostic,
PluginBundleFormat,
PluginFormat,
PluginLogger,
PluginOrigin,
PluginKind,
PluginHookRegistration as TypedPluginHookRegistration,
} from "./types.js";
export type PluginToolRegistration = {
pluginId: string;
pluginName?: string;
factory: OpenClawPluginToolFactory;
names: string[];
optional: boolean;
source: string;
rootDir?: string;
};
export type PluginCliRegistration = {
pluginId: string;
pluginName?: string;
register: OpenClawPluginCliRegistrar;
commands: string[];
source: string;
rootDir?: string;
};
export type PluginHttpRouteRegistration = {
pluginId?: string;
path: string;
handler: OpenClawPluginHttpRouteHandler;
auth: OpenClawPluginHttpRouteAuth;
match: OpenClawPluginHttpRouteMatch;
source?: string;
};
export type PluginChannelRegistration = {
pluginId: string;
pluginName?: string;
plugin: ChannelPlugin;
dock?: ChannelDock;
source: string;
rootDir?: string;
};
export type PluginProviderRegistration = {
pluginId: string;
pluginName?: string;
provider: ProviderPlugin;
source: string;
rootDir?: string;
};
export type PluginHookRegistration = {
pluginId: string;
entry: HookEntry;
events: string[];
source: string;
rootDir?: string;
};
export type PluginServiceRegistration = {
pluginId: string;
pluginName?: string;
service: OpenClawPluginService;
source: string;
rootDir?: string;
};
export type PluginCommandRegistration = {
pluginId: string;
pluginName?: string;
command: OpenClawPluginCommandDefinition;
source: string;
rootDir?: string;
};
export type PluginRecordLifecycleState =
| "prepared"
| "imported"
| "disabled"
| "validated"
| "registered"
| "ready"
| "error";
export type PluginRecord = {
id: string;
name: string;
version?: string;
description?: string;
format?: PluginFormat;
bundleFormat?: PluginBundleFormat;
bundleCapabilities?: string[];
kind?: PluginKind;
source: string;
rootDir?: string;
origin: PluginOrigin;
workspaceDir?: string;
enabled: boolean;
status: "loaded" | "disabled" | "error";
lifecycleState?: PluginRecordLifecycleState;
error?: string;
toolNames: string[];
hookNames: string[];
channelIds: string[];
providerIds: string[];
gatewayMethods: string[];
cliCommands: string[];
services: string[];
commands: string[];
httpRoutes: number;
hookCount: number;
configSchema: boolean;
configUiHints?: Record<string, PluginConfigUiHint>;
configJsonSchema?: Record<string, unknown>;
};
export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
hooks: PluginHookRegistration[];
typedHooks: TypedPluginHookRegistration[];
channels: PluginChannelRegistration[];
providers: PluginProviderRegistration[];
gatewayHandlers: GatewayRequestHandlers;
httpRoutes: PluginHttpRouteRegistration[];
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
diagnostics: PluginDiagnostic[];
};
export type PluginRegistryParams = {
logger: PluginLogger;
coreGatewayHandlers?: GatewayRequestHandlers;
runtime: PluginRuntime;
};
export function createEmptyPluginRegistry(): PluginRegistry {
return {
plugins: [],
tools: [],
hooks: [],
typedHooks: [],
channels: [],
providers: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
}
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry = createEmptyPluginRegistry();
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 names = opts?.names ?? (opts?.name ? [opts.name] : []);
const optional = opts?.optional === true;
const factory: OpenClawPluginToolFactory =
typeof tool === "function" ? tool : (_ctx: OpenClawPluginToolContext) => tool;
if (typeof tool !== "function") {
names.push(tool.name);
}
const normalized = names.map((name) => name.trim()).filter(Boolean);
if (normalized.length > 0) {
record.toolNames.push(...normalized);
}
registry.tools.push({
pluginId: record.id,
pluginName: record.name,
factory,
names: normalized,
optional,
source: record.source,
rootDir: record.rootDir,
});
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) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: normalized.message,
});
return;
}
const existingHook = registry.hooks.find((entry) => entry.entry.hook.name === name);
if (existingHook) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `hook already registered: ${name} (${existingHook.pluginId})`,
});
return;
}
const description = entry?.hook.description ?? opts?.description ?? "";
const hookEntry: HookEntry = entry
? {
...entry,
hook: {
...entry.hook,
name,
description,
source: "openclaw-plugin",
pluginId: record.id,
},
metadata: {
...entry.metadata,
events: normalizedEvents,
},
}
: {
hook: {
name,
description,
source: "openclaw-plugin",
pluginId: record.id,
filePath: record.source,
baseDir: path.dirname(record.source),
handlerPath: record.source,
},
frontmatter: {},
metadata: { events: normalizedEvents },
invocation: { enabled: true },
};
record.hookNames.push(name);
registry.hooks.push({
pluginId: normalized.entry.pluginId,
entry: normalized.entry.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) {
pushDiagnostic({
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, params: OpenClawPluginHttpRouteParams) => {
const result = resolveExtensionHttpRouteRegistration({
existing: registry.httpRoutes,
ownerPluginId: record.id,
ownerSource: record.source,
route: params,
});
if (!result.ok) {
pushDiagnostic({
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 normalized =
typeof (registration as OpenClawPluginChannelRegistration).plugin === "object"
? (registration as OpenClawPluginChannelRegistration)
: { plugin: registration as ChannelPlugin };
const plugin = normalized.plugin;
const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim();
if (!id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "channel registration missing id",
});
return;
}
const existing = registry.channels.find((entry) => entry.plugin.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `channel already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.channelIds.push(id);
registry.channels.push({
pluginId: record.id,
pluginName: record.name,
plugin,
dock: normalized.dock,
source: record.source,
rootDir: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
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) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: result.message,
});
return;
}
record.providerIds.push(id);
registry.providers.push({
pluginId: record.id,
pluginName: record.name,
provider: normalizedProvider,
source: record.source,
rootDir: record.rootDir,
});
};
const registerCli = (
record: PluginRecord,
registrar: OpenClawPluginCliRegistrar,
opts?: { commands?: string[] },
) => {
const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean);
if (commands.length === 0) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "cli registration missing explicit commands metadata",
});
return;
}
const existing = registry.cliRegistrars.find((entry) =>
entry.commands.some((command) => commands.includes(command)),
);
if (existing) {
const overlap = commands.find((command) => existing.commands.includes(command));
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `cli command already registered: ${overlap ?? commands[0]} (${existing.pluginId})`,
});
return;
}
record.cliCommands.push(...commands);
registry.cliRegistrars.push({
pluginId: record.id,
pluginName: record.name,
register: registrar,
commands,
source: record.source,
rootDir: record.rootDir,
});
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;
}
const existing = registry.services.find((entry) => entry.service.id === id);
if (existing) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `service already registered: ${id} (${existing.pluginId})`,
});
return;
}
record.services.push(id);
registry.services.push({
pluginId: record.id,
pluginName: record.name,
service,
source: record.source,
rootDir: record.rootDir,
});
};
const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => {
const normalized = resolveExtensionCommandRegistration({
ownerPluginId: record.id,
ownerSource: record.source,
command,
});
if (!normalized.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: normalized.message,
});
return;
}
// Register with the plugin command system (validates name and checks for duplicates)
const result = registerPluginCommand(record.id, command, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${result.error}`,
});
return;
}
record.commands.push(name);
registry.commands.push({
pluginId: record.id,
pluginName: record.name,
command,
source: record.source,
rootDir: record.rootDir,
});
};
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) {
pushDiagnostic({
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) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: policyResult.message,
});
return;
}
if (policyResult.warningMessage) {
pushDiagnostic({
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 {
id: record.id,
name: record.name,
version: record.version,
description: record.description,
source: record.source,
rootDir: record.rootDir,
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),
registerInteractiveHandler: (registration) => {
const result = registerPluginInteractiveHandler(record.id, registration, {
pluginName: record.name,
pluginRoot: record.rootDir,
});
if (!result.ok) {
pushDiagnostic({
level: "warn",
pluginId: record.id,
source: record.source,
message: result.error ?? "interactive handler registration failed",
});
}
},
registerCommand: (command) => registerCommand(record, command),
registerContextEngine: (id, factory) => {
if (id === defaultSlotIdForKey("contextEngine")) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `context engine id reserved by core: ${id}`,
});
return;
}
const result = registerContextEngineForOwner(id, factory, `plugin:${record.id}`, {
allowSameOwnerRefresh: true,
});
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `context engine already registered: ${id} (${result.existingOwner})`,
});
}
},
on: (hookName, handler, opts) =>
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
});
};
return {
registry,
createApi,
pushDiagnostic,
registerTool,
registerChannel,
registerProvider,
registerGatewayMethod,
registerCli,
registerService,
registerCommand,
registerHook,
registerTypedHook,
};
}