From d9fb2cbaf8a1c7667db25797c380239edffb50f8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 18:32:51 +0000 Subject: [PATCH] Plugins: add runtime registry read surface --- src/cli/plugin-registry.test.ts | 67 +++++++++++++ src/cli/plugin-registry.ts | 8 +- src/extension-host/cli-lifecycle.ts | 3 +- src/extension-host/gateway-methods.ts | 6 +- src/extension-host/provider-runtime.ts | 3 +- src/extension-host/runtime-registry.test.ts | 99 +++++++++++++++++++ src/extension-host/runtime-registry.ts | 89 +++++++++++++++++ src/extension-host/service-lifecycle.ts | 3 +- src/extension-host/tool-runtime.ts | 3 +- src/gateway/server/plugins-http.ts | 3 +- .../server/plugins-http/route-match.ts | 3 +- 11 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 src/cli/plugin-registry.test.ts create mode 100644 src/extension-host/runtime-registry.test.ts create mode 100644 src/extension-host/runtime-registry.ts diff --git a/src/cli/plugin-registry.test.ts b/src/cli/plugin-registry.test.ts new file mode 100644 index 00000000000..97387dc5ead --- /dev/null +++ b/src/cli/plugin-registry.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; + +const loadOpenClawPluginsMock = vi.hoisted(() => vi.fn()); +const getActivePluginRegistryMock = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/agent-scope.js", () => ({ + resolveAgentWorkspaceDir: () => "/tmp/workspace", + resolveDefaultAgentId: () => "default-agent", +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../logging.js", () => ({ + createSubsystemLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.mock("../plugins/loader.js", () => ({ + loadOpenClawPlugins: loadOpenClawPluginsMock, +})); + +vi.mock("../plugins/runtime.js", () => ({ + getActivePluginRegistry: getActivePluginRegistryMock, +})); + +describe("ensurePluginRegistryLoaded", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("skips plugin loading when a provider-only registry is already active", async () => { + const registry = createEmptyPluginRegistry(); + registry.providers.push({ + pluginId: "provider-demo", + source: "test", + provider: { + id: "provider-demo", + label: "Provider Demo", + auth: [], + }, + }); + getActivePluginRegistryMock.mockReturnValue(registry); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + ensurePluginRegistryLoaded(); + + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + + it("loads plugins once when the active registry is empty", async () => { + getActivePluginRegistryMock.mockReturnValue(createEmptyPluginRegistry()); + + const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); + ensurePluginRegistryLoaded(); + ensurePluginRegistryLoaded(); + + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index 22d7ce61abb..2a460e863af 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,5 +1,6 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; +import { hasExtensionHostRuntimeEntries } from "../extension-host/runtime-registry.js"; import { createSubsystemLogger } from "../logging.js"; import { loadOpenClawPlugins } from "../plugins/loader.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; @@ -14,11 +15,8 @@ export function ensurePluginRegistryLoaded(): void { } const active = getActivePluginRegistry(); // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid - // doing an expensive load when we already have plugins/channels/tools. - if ( - active && - (active.plugins.length > 0 || active.channels.length > 0 || active.tools.length > 0) - ) { + // doing an expensive load when we already have runtime entries. + if (hasExtensionHostRuntimeEntries(active)) { pluginRegistryLoaded = true; return; } diff --git a/src/extension-host/cli-lifecycle.ts b/src/extension-host/cli-lifecycle.ts index 85f531a0aae..bac6a4f6ffd 100644 --- a/src/extension-host/cli-lifecycle.ts +++ b/src/extension-host/cli-lifecycle.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginLogger } from "../plugins/types.js"; +import { listExtensionHostCliRegistrations } from "./runtime-registry.js"; export function registerExtensionHostCliCommands(params: { program: Command; @@ -12,7 +13,7 @@ export function registerExtensionHostCliCommands(params: { }): void { const existingCommands = new Set(params.program.commands.map((cmd) => cmd.name())); - for (const entry of params.registry.cliRegistrars) { + for (const entry of listExtensionHostCliRegistrations(params.registry)) { if (entry.commands.length > 0) { const overlaps = entry.commands.filter((command) => existingCommands.has(command)); if (overlaps.length > 0) { diff --git a/src/extension-host/gateway-methods.ts b/src/extension-host/gateway-methods.ts index 921e43f9f3d..0198227118c 100644 --- a/src/extension-host/gateway-methods.ts +++ b/src/extension-host/gateway-methods.ts @@ -1,12 +1,13 @@ import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginDiagnostic } from "../plugins/types.js"; +import { getExtensionHostGatewayHandlers } from "./runtime-registry.js"; export function resolveExtensionHostGatewayMethods(params: { registry: PluginRegistry; baseMethods: string[]; }): string[] { - const pluginMethods = Object.keys(params.registry.gatewayHandlers); + const pluginMethods = Object.keys(getExtensionHostGatewayHandlers(params.registry)); return Array.from(new Set([...params.baseMethods, ...pluginMethods])); } @@ -14,8 +15,9 @@ export function createExtensionHostGatewayExtraHandlers(params: { registry: PluginRegistry; extraHandlers?: GatewayRequestHandlers; }): GatewayRequestHandlers { + const pluginHandlers = getExtensionHostGatewayHandlers(params.registry); return { - ...params.registry.gatewayHandlers, + ...pluginHandlers, ...params.extraHandlers, }; } diff --git a/src/extension-host/provider-runtime.ts b/src/extension-host/provider-runtime.ts index 66295a1a072..657765f3866 100644 --- a/src/extension-host/provider-runtime.ts +++ b/src/extension-host/provider-runtime.ts @@ -1,10 +1,11 @@ import type { PluginRegistry } from "../plugins/registry.js"; import type { ProviderPlugin } from "../plugins/types.js"; +import { listExtensionHostProviderRegistrations } from "./runtime-registry.js"; export function resolveExtensionHostProviders(params: { registry: Pick; }): ProviderPlugin[] { - return params.registry.providers.map((entry) => ({ + return listExtensionHostProviderRegistrations(params.registry).map((entry) => ({ ...entry.provider, pluginId: entry.pluginId, })); diff --git a/src/extension-host/runtime-registry.test.ts b/src/extension-host/runtime-registry.test.ts new file mode 100644 index 00000000000..69c940d195f --- /dev/null +++ b/src/extension-host/runtime-registry.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { + getExtensionHostGatewayHandlers, + hasExtensionHostRuntimeEntries, + listExtensionHostCliRegistrations, + listExtensionHostHttpRoutes, + listExtensionHostProviderRegistrations, + listExtensionHostServiceRegistrations, + listExtensionHostToolRegistrations, +} from "./runtime-registry.js"; + +describe("extension host runtime registry accessors", () => { + it("detects runtime entries across non-tool surfaces", () => { + const providerRegistry = createEmptyPluginRegistry(); + providerRegistry.providers.push({ + pluginId: "provider-demo", + source: "test", + provider: { + id: "provider-demo", + label: "Provider Demo", + auth: [], + }, + }); + expect(hasExtensionHostRuntimeEntries(providerRegistry)).toBe(true); + + const routeRegistry = createEmptyPluginRegistry(); + routeRegistry.httpRoutes.push({ + path: "/plugins/demo", + handler: vi.fn(), + auth: "plugin", + match: "exact", + pluginId: "route-demo", + source: "test", + }); + expect(hasExtensionHostRuntimeEntries(routeRegistry)).toBe(true); + + const gatewayRegistry = createEmptyPluginRegistry(); + gatewayRegistry.gatewayHandlers["demo.echo"] = vi.fn(); + expect(hasExtensionHostRuntimeEntries(gatewayRegistry)).toBe(true); + }); + + it("returns stable empty views for missing registries", () => { + expect(hasExtensionHostRuntimeEntries(null)).toBe(false); + expect(listExtensionHostProviderRegistrations(null)).toEqual([]); + expect(listExtensionHostToolRegistrations(null)).toEqual([]); + expect(listExtensionHostServiceRegistrations(null)).toEqual([]); + expect(listExtensionHostCliRegistrations(null)).toEqual([]); + expect(listExtensionHostHttpRoutes(null)).toEqual([]); + expect(getExtensionHostGatewayHandlers(null)).toEqual({}); + }); + + it("projects existing registry collections without copying them", () => { + const registry = createEmptyPluginRegistry(); + registry.tools.push({ + pluginId: "tool-demo", + optional: false, + source: "test", + names: ["tool_demo"], + factory: () => ({ + name: "tool_demo", + description: "tool demo", + parameters: { type: "object", properties: {} }, + async execute() { + return { content: [{ type: "text", text: "ok" }] }; + }, + }), + }); + registry.services.push({ + pluginId: "svc-demo", + source: "test", + service: { + id: "svc-demo", + start: () => undefined, + }, + }); + registry.cliRegistrars.push({ + pluginId: "cli-demo", + source: "test", + commands: ["demo"], + register: () => undefined, + }); + registry.httpRoutes.push({ + path: "/plugins/demo", + handler: vi.fn(), + auth: "plugin", + match: "exact", + pluginId: "route-demo", + source: "test", + }); + registry.gatewayHandlers["demo.echo"] = vi.fn(); + + expect(listExtensionHostToolRegistrations(registry)).toBe(registry.tools); + expect(listExtensionHostServiceRegistrations(registry)).toBe(registry.services); + expect(listExtensionHostCliRegistrations(registry)).toBe(registry.cliRegistrars); + expect(listExtensionHostHttpRoutes(registry)).toBe(registry.httpRoutes); + expect(getExtensionHostGatewayHandlers(registry)).toBe(registry.gatewayHandlers); + }); +}); diff --git a/src/extension-host/runtime-registry.ts b/src/extension-host/runtime-registry.ts new file mode 100644 index 00000000000..f86cf99b992 --- /dev/null +++ b/src/extension-host/runtime-registry.ts @@ -0,0 +1,89 @@ +import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; +import type { + PluginCliRegistration, + PluginHttpRouteRegistration, + PluginProviderRegistration, + PluginRegistry, + PluginServiceRegistration, + PluginToolRegistration, +} from "../plugins/registry.js"; + +const EMPTY_PROVIDERS: readonly PluginProviderRegistration[] = []; +const EMPTY_TOOLS: readonly PluginToolRegistration[] = []; +const EMPTY_SERVICES: readonly PluginServiceRegistration[] = []; +const EMPTY_CLI_REGISTRARS: readonly PluginCliRegistration[] = []; +const EMPTY_HTTP_ROUTES: readonly PluginHttpRouteRegistration[] = []; +const EMPTY_GATEWAY_HANDLERS: Readonly = Object.freeze({}); + +export function hasExtensionHostRuntimeEntries( + registry: + | Pick< + PluginRegistry, + | "plugins" + | "channels" + | "tools" + | "providers" + | "gatewayHandlers" + | "httpRoutes" + | "cliRegistrars" + | "services" + | "commands" + | "hooks" + | "typedHooks" + > + | null + | undefined, +): boolean { + if (!registry) { + return false; + } + return ( + registry.plugins.length > 0 || + registry.channels.length > 0 || + registry.tools.length > 0 || + registry.providers.length > 0 || + Object.keys(registry.gatewayHandlers).length > 0 || + registry.httpRoutes.length > 0 || + registry.cliRegistrars.length > 0 || + registry.services.length > 0 || + registry.commands.length > 0 || + registry.hooks.length > 0 || + registry.typedHooks.length > 0 + ); +} + +export function listExtensionHostProviderRegistrations( + registry: Pick | null | undefined, +): readonly PluginProviderRegistration[] { + return registry?.providers ?? EMPTY_PROVIDERS; +} + +export function listExtensionHostToolRegistrations( + registry: Pick | null | undefined, +): readonly PluginToolRegistration[] { + return registry?.tools ?? EMPTY_TOOLS; +} + +export function listExtensionHostServiceRegistrations( + registry: Pick | null | undefined, +): readonly PluginServiceRegistration[] { + return registry?.services ?? EMPTY_SERVICES; +} + +export function listExtensionHostCliRegistrations( + registry: Pick | null | undefined, +): readonly PluginCliRegistration[] { + return registry?.cliRegistrars ?? EMPTY_CLI_REGISTRARS; +} + +export function listExtensionHostHttpRoutes( + registry: Pick | null | undefined, +): readonly PluginHttpRouteRegistration[] { + return registry?.httpRoutes ?? EMPTY_HTTP_ROUTES; +} + +export function getExtensionHostGatewayHandlers( + registry: Pick | null | undefined, +): Readonly { + return registry?.gatewayHandlers ?? EMPTY_GATEWAY_HANDLERS; +} diff --git a/src/extension-host/service-lifecycle.ts b/src/extension-host/service-lifecycle.ts index 698e66ecd05..2b59078d379 100644 --- a/src/extension-host/service-lifecycle.ts +++ b/src/extension-host/service-lifecycle.ts @@ -3,6 +3,7 @@ import { STATE_DIR } from "../config/paths.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { OpenClawPluginServiceContext, PluginLogger } from "../plugins/types.js"; +import { listExtensionHostServiceRegistrations } from "./runtime-registry.js"; const log = createSubsystemLogger("plugins"); @@ -45,7 +46,7 @@ export async function startExtensionHostServices(params: { workspaceDir: params.workspaceDir, }); - for (const entry of params.registry.services) { + for (const entry of listExtensionHostServiceRegistrations(params.registry)) { const service = entry.service; try { await service.start(serviceContext); diff --git a/src/extension-host/tool-runtime.ts b/src/extension-host/tool-runtime.ts index 66bfb871597..226a6552ecb 100644 --- a/src/extension-host/tool-runtime.ts +++ b/src/extension-host/tool-runtime.ts @@ -3,6 +3,7 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "../plugins/registry.js"; import type { OpenClawPluginToolContext } from "../plugins/types.js"; +import { listExtensionHostToolRegistrations } from "./runtime-registry.js"; const log = createSubsystemLogger("plugins"); @@ -55,7 +56,7 @@ export function resolveExtensionHostPluginTools(params: { const allowlist = normalizeAllowlist(params.toolAllowlist); const blockedPlugins = new Set(); - for (const entry of params.registry.tools) { + for (const entry of listExtensionHostToolRegistrations(params.registry)) { if (blockedPlugins.has(entry.pluginId)) { continue; } diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 6147e1bee99..bef2c9730a4 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { listExtensionHostHttpRoutes } from "../../extension-host/runtime-registry.js"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; @@ -65,7 +66,7 @@ export function createGatewayPluginRequestHandler(params: { }): PluginHttpRequestHandler { const { registry, log } = params; return async (req, res, providedPathContext, dispatchContext) => { - const routes = registry.httpRoutes ?? []; + const routes = listExtensionHostHttpRoutes(registry); if (routes.length === 0) { return false; } diff --git a/src/gateway/server/plugins-http/route-match.ts b/src/gateway/server/plugins-http/route-match.ts index bab082c813e..43035cecb10 100644 --- a/src/gateway/server/plugins-http/route-match.ts +++ b/src/gateway/server/plugins-http/route-match.ts @@ -1,3 +1,4 @@ +import { listExtensionHostHttpRoutes } from "../../../extension-host/runtime-registry.js"; import type { PluginRegistry } from "../../../plugins/registry.js"; import { canonicalizePathVariant } from "../../security-path.js"; import { @@ -23,7 +24,7 @@ export function findMatchingPluginHttpRoutes( registry: PluginRegistry, context: PluginRoutePathContext, ): PluginHttpRouteEntry[] { - const routes = registry.httpRoutes ?? []; + const routes = listExtensionHostHttpRoutes(registry); if (routes.length === 0) { return []; }