Plugins: add runtime registry read surface

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 18:32:51 +00:00
parent 89e02b6a89
commit d9fb2cbaf8
11 changed files with 274 additions and 13 deletions

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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,
};
}

View File

@@ -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<PluginRegistry, "providers">;
}): ProviderPlugin[] {
return params.registry.providers.map((entry) => ({
return listExtensionHostProviderRegistrations(params.registry).map((entry) => ({
...entry.provider,
pluginId: entry.pluginId,
}));

View File

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

View File

@@ -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<GatewayRequestHandlers> = 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<PluginRegistry, "providers"> | null | undefined,
): readonly PluginProviderRegistration[] {
return registry?.providers ?? EMPTY_PROVIDERS;
}
export function listExtensionHostToolRegistrations(
registry: Pick<PluginRegistry, "tools"> | null | undefined,
): readonly PluginToolRegistration[] {
return registry?.tools ?? EMPTY_TOOLS;
}
export function listExtensionHostServiceRegistrations(
registry: Pick<PluginRegistry, "services"> | null | undefined,
): readonly PluginServiceRegistration[] {
return registry?.services ?? EMPTY_SERVICES;
}
export function listExtensionHostCliRegistrations(
registry: Pick<PluginRegistry, "cliRegistrars"> | null | undefined,
): readonly PluginCliRegistration[] {
return registry?.cliRegistrars ?? EMPTY_CLI_REGISTRARS;
}
export function listExtensionHostHttpRoutes(
registry: Pick<PluginRegistry, "httpRoutes"> | null | undefined,
): readonly PluginHttpRouteRegistration[] {
return registry?.httpRoutes ?? EMPTY_HTTP_ROUTES;
}
export function getExtensionHostGatewayHandlers(
registry: Pick<PluginRegistry, "gatewayHandlers"> | null | undefined,
): Readonly<GatewayRequestHandlers> {
return registry?.gatewayHandlers ?? EMPTY_GATEWAY_HANDLERS;
}

View File

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

View File

@@ -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<string>();
for (const entry of params.registry.tools) {
for (const entry of listExtensionHostToolRegistrations(params.registry)) {
if (blockedPlugins.has(entry.pluginId)) {
continue;
}

View File

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

View File

@@ -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 [];
}