| import path from "node:path"; |
| import type { AnyAgentTool } from "../agents/tools/common.js"; |
| import type { ChannelDock } from "../channels/dock.js"; |
| import type { ChannelPlugin } from "../channels/plugins/types.js"; |
| import { registerContextEngine } from "../context-engine/registry.js"; |
| import type { |
| GatewayRequestHandler, |
| GatewayRequestHandlers, |
| } from "../gateway/server-methods/types.js"; |
| import { registerInternalHook } from "../hooks/internal-hooks.js"; |
| import type { HookEntry } from "../hooks/types.js"; |
| import { resolveUserPath } from "../utils.js"; |
| import { registerPluginCommand } from "./commands.js"; |
| import { normalizePluginHttpPath } from "./http-path.js"; |
| import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; |
| import { normalizeRegisteredProvider } from "./provider-validation.js"; |
| import type { PluginRuntime } from "./runtime/types.js"; |
| import { |
| isPluginHookName, |
| isPromptInjectionHookName, |
| stripPromptMutationFieldsFromLegacyHookResult, |
| } from "./types.js"; |
| import type { |
| OpenClawPluginApi, |
| OpenClawPluginChannelRegistration, |
| OpenClawPluginCliRegistrar, |
| OpenClawPluginCommandDefinition, |
| OpenClawPluginHttpRouteAuth, |
| OpenClawPluginHttpRouteMatch, |
| OpenClawPluginHttpRouteHandler, |
| OpenClawPluginHttpRouteParams, |
| OpenClawPluginHookOptions, |
| ProviderPlugin, |
| OpenClawPluginService, |
| OpenClawPluginToolContext, |
| OpenClawPluginToolFactory, |
| PluginConfigUiHint, |
| PluginDiagnostic, |
| PluginLogger, |
| PluginOrigin, |
| PluginKind, |
| PluginHookName, |
| PluginHookHandlerMap, |
| PluginHookRegistration as TypedPluginHookRegistration, |
| } from "./types.js"; |
|
|
| export type PluginToolRegistration = { |
| pluginId: string; |
| factory: OpenClawPluginToolFactory; |
| names: string[]; |
| optional: boolean; |
| source: string; |
| }; |
|
|
| export type PluginCliRegistration = { |
| pluginId: string; |
| register: OpenClawPluginCliRegistrar; |
| commands: string[]; |
| source: string; |
| }; |
|
|
| export type PluginHttpRouteRegistration = { |
| pluginId?: string; |
| path: string; |
| handler: OpenClawPluginHttpRouteHandler; |
| auth: OpenClawPluginHttpRouteAuth; |
| match: OpenClawPluginHttpRouteMatch; |
| source?: string; |
| }; |
|
|
| export type PluginChannelRegistration = { |
| pluginId: string; |
| plugin: ChannelPlugin; |
| dock?: ChannelDock; |
| source: string; |
| }; |
|
|
| export type PluginProviderRegistration = { |
| pluginId: string; |
| provider: ProviderPlugin; |
| source: string; |
| }; |
|
|
| export type PluginHookRegistration = { |
| pluginId: string; |
| entry: HookEntry; |
| events: string[]; |
| source: string; |
| }; |
|
|
| export type PluginServiceRegistration = { |
| pluginId: string; |
| service: OpenClawPluginService; |
| source: string; |
| }; |
|
|
| export type PluginCommandRegistration = { |
| pluginId: string; |
| command: OpenClawPluginCommandDefinition; |
| source: string; |
| }; |
|
|
| export type PluginRecord = { |
| id: string; |
| name: string; |
| version?: string; |
| description?: string; |
| kind?: PluginKind; |
| source: string; |
| origin: PluginOrigin; |
| workspaceDir?: string; |
| enabled: boolean; |
| status: "loaded" | "disabled" | "error"; |
| 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; |
| }; |
|
|
| type PluginTypedHookPolicy = { |
| allowPromptInjection?: boolean; |
| }; |
|
|
| const constrainLegacyPromptInjectionHook = ( |
| handler: PluginHookHandlerMap["before_agent_start"], |
| ): PluginHookHandlerMap["before_agent_start"] => { |
| return (event, ctx) => { |
| const result = handler(event, ctx); |
| if (result && typeof result === "object" && "then" in result) { |
| return Promise.resolve(result).then((resolved) => |
| stripPromptMutationFieldsFromLegacyHookResult(resolved), |
| ); |
| } |
| return stripPromptMutationFieldsFromLegacyHookResult(result); |
| }; |
| }; |
|
|
| 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, |
| factory, |
| names: normalized, |
| optional, |
| source: record.source, |
| }); |
| }; |
|
|
| const registerHook = ( |
| record: PluginRecord, |
| events: string | string[], |
| handler: Parameters<typeof registerInternalHook>[1], |
| opts: OpenClawPluginHookOptions | undefined, |
| config: OpenClawPluginApi["config"], |
| ) => { |
| const eventList = Array.isArray(events) ? events : [events]; |
| const normalizedEvents = eventList.map((event) => event.trim()).filter(Boolean); |
| const entry = opts?.entry ?? null; |
| const name = entry?.hook.name ?? opts?.name?.trim(); |
| if (!name) { |
| pushDiagnostic({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: "hook registration missing name", |
| }); |
| 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: record.id, |
| entry: hookEntry, |
| events: normalizedEvents, |
| source: record.source, |
| }); |
|
|
| const hookSystemEnabled = config?.hooks?.internal?.enabled === true; |
| if (!hookSystemEnabled || opts?.register === false) { |
| return; |
| } |
|
|
| for (const event of normalizedEvents) { |
| registerInternalHook(event, handler); |
| } |
| }; |
|
|
| const registerGatewayMethod = ( |
| record: PluginRecord, |
| method: string, |
| handler: GatewayRequestHandler, |
| ) => { |
| const trimmed = method.trim(); |
| if (!trimmed) { |
| return; |
| } |
| if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: `gateway method already registered: ${trimmed}`, |
| }); |
| return; |
| } |
| registry.gatewayHandlers[trimmed] = handler; |
| record.gatewayMethods.push(trimmed); |
| }; |
|
|
| const describeHttpRouteOwner = (entry: PluginHttpRouteRegistration): string => { |
| const plugin = entry.pluginId?.trim() || "unknown-plugin"; |
| const source = entry.source?.trim() || "unknown-source"; |
| return `${plugin} (${source})`; |
| }; |
|
|
| const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { |
| const normalizedPath = normalizePluginHttpPath(params.path); |
| if (!normalizedPath) { |
| pushDiagnostic({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: "http route registration missing path", |
| }); |
| return; |
| } |
| if (params.auth !== "gateway" && params.auth !== "plugin") { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: `http route registration missing or invalid auth: ${normalizedPath}`, |
| }); |
| return; |
| } |
| const match = params.match ?? "exact"; |
| const overlappingRoute = findOverlappingPluginHttpRoute(registry.httpRoutes, { |
| path: normalizedPath, |
| match, |
| }); |
| if (overlappingRoute && overlappingRoute.auth !== params.auth) { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: |
| `http route overlap rejected: ${normalizedPath} (${match}, ${params.auth}) ` + |
| `overlaps ${overlappingRoute.path} (${overlappingRoute.match}, ${overlappingRoute.auth}) ` + |
| `owned by ${describeHttpRouteOwner(overlappingRoute)}`, |
| }); |
| return; |
| } |
| const existingIndex = registry.httpRoutes.findIndex( |
| (entry) => entry.path === normalizedPath && entry.match === match, |
| ); |
| if (existingIndex >= 0) { |
| const existing = registry.httpRoutes[existingIndex]; |
| if (!existing) { |
| return; |
| } |
| if (!params.replaceExisting) { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: `http route already registered: ${normalizedPath} (${match}) by ${describeHttpRouteOwner(existing)}`, |
| }); |
| return; |
| } |
| if (existing.pluginId && existing.pluginId !== record.id) { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: `http route replacement rejected: ${normalizedPath} (${match}) owned by ${describeHttpRouteOwner(existing)}`, |
| }); |
| return; |
| } |
| registry.httpRoutes[existingIndex] = { |
| pluginId: record.id, |
| path: normalizedPath, |
| handler: params.handler, |
| auth: params.auth, |
| match, |
| source: record.source, |
| }; |
| return; |
| } |
| record.httpRoutes += 1; |
| registry.httpRoutes.push({ |
| pluginId: record.id, |
| path: normalizedPath, |
| handler: params.handler, |
| auth: params.auth, |
| match, |
| source: record.source, |
| }); |
| }; |
|
|
| 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; |
| } |
| record.channelIds.push(id); |
| registry.channels.push({ |
| pluginId: record.id, |
| plugin, |
| dock: normalized.dock, |
| source: record.source, |
| }); |
| }; |
|
|
| const registerProvider = (record: PluginRecord, provider: ProviderPlugin) => { |
| const normalizedProvider = normalizeRegisteredProvider({ |
| pluginId: record.id, |
| source: record.source, |
| provider, |
| pushDiagnostic, |
| }); |
| if (!normalizedProvider) { |
| return; |
| } |
| const id = normalizedProvider.id; |
| const existing = registry.providers.find((entry) => entry.provider.id === id); |
| if (existing) { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: `provider already registered: ${id} (${existing.pluginId})`, |
| }); |
| return; |
| } |
| record.providerIds.push(id); |
| registry.providers.push({ |
| pluginId: record.id, |
| provider: normalizedProvider, |
| source: record.source, |
| }); |
| }; |
|
|
| const registerCli = ( |
| record: PluginRecord, |
| registrar: OpenClawPluginCliRegistrar, |
| opts?: { commands?: string[] }, |
| ) => { |
| const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean); |
| record.cliCommands.push(...commands); |
| registry.cliRegistrars.push({ |
| pluginId: record.id, |
| register: registrar, |
| commands, |
| source: record.source, |
| }); |
| }; |
|
|
| const registerService = (record: PluginRecord, service: OpenClawPluginService) => { |
| const id = service.id.trim(); |
| if (!id) { |
| return; |
| } |
| record.services.push(id); |
| registry.services.push({ |
| pluginId: record.id, |
| service, |
| source: record.source, |
| }); |
| }; |
|
|
| const registerCommand = (record: PluginRecord, command: OpenClawPluginCommandDefinition) => { |
| const name = command.name.trim(); |
| if (!name) { |
| pushDiagnostic({ |
| level: "error", |
| pluginId: record.id, |
| source: record.source, |
| message: "command registration missing name", |
| }); |
| return; |
| } |
|
|
| |
| const result = registerPluginCommand(record.id, command); |
| 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, |
| command, |
| source: record.source, |
| }); |
| }; |
|
|
| const registerTypedHook = <K extends PluginHookName>( |
| record: PluginRecord, |
| hookName: K, |
| handler: PluginHookHandlerMap[K], |
| opts?: { priority?: number }, |
| policy?: PluginTypedHookPolicy, |
| ) => { |
| if (!isPluginHookName(hookName)) { |
| pushDiagnostic({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: `unknown typed hook "${String(hookName)}" ignored`, |
| }); |
| return; |
| } |
| let effectiveHandler = handler; |
| if (policy?.allowPromptInjection === false && isPromptInjectionHookName(hookName)) { |
| if (hookName === "before_prompt_build") { |
| pushDiagnostic({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: `typed hook "${hookName}" blocked by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, |
| }); |
| return; |
| } |
| if (hookName === "before_agent_start") { |
| pushDiagnostic({ |
| level: "warn", |
| pluginId: record.id, |
| source: record.source, |
| message: `typed hook "${hookName}" prompt fields constrained by plugins.entries.${record.id}.hooks.allowPromptInjection=false`, |
| }); |
| effectiveHandler = constrainLegacyPromptInjectionHook( |
| handler as PluginHookHandlerMap["before_agent_start"], |
| ) as PluginHookHandlerMap[K]; |
| } |
| } |
| record.hookCount += 1; |
| registry.typedHooks.push({ |
| pluginId: record.id, |
| hookName, |
| handler: effectiveHandler, |
| priority: opts?.priority, |
| source: record.source, |
| } as TypedPluginHookRegistration); |
| }; |
|
|
| const normalizeLogger = (logger: PluginLogger): PluginLogger => ({ |
| info: logger.info, |
| warn: logger.warn, |
| error: logger.error, |
| debug: logger.debug, |
| }); |
|
|
| 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, |
| config: params.config, |
| pluginConfig: params.pluginConfig, |
| runtime: registryParams.runtime, |
| logger: normalizeLogger(registryParams.logger), |
| registerTool: (tool, opts) => registerTool(record, tool, opts), |
| registerHook: (events, handler, opts) => |
| registerHook(record, events, handler, opts, params.config), |
| registerHttpRoute: (params) => registerHttpRoute(record, params), |
| registerChannel: (registration) => registerChannel(record, registration), |
| 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) => registerContextEngine(id, factory), |
| resolvePath: (input: string) => resolveUserPath(input), |
| on: (hookName, handler, opts) => |
| registerTypedHook(record, hookName, handler, opts, params.hookPolicy), |
| }; |
| }; |
|
|
| return { |
| registry, |
| createApi, |
| pushDiagnostic, |
| registerTool, |
| registerChannel, |
| registerProvider, |
| registerGatewayMethod, |
| registerCli, |
| registerService, |
| registerCommand, |
| registerHook, |
| registerTypedHook, |
| }; |
| } |
|
|