Plugins: add host-owned route and gateway storage

This commit is contained in:
Gustavo Madeira Santana 2026-03-15 18:37:47 +00:00
parent 4cb3600549
commit a1c5cbabff
No known key found for this signature in database
6 changed files with 208 additions and 28 deletions

View File

@ -5,12 +5,13 @@ import {
logExtensionHostPluginDiagnostics,
resolveExtensionHostGatewayMethods,
} from "./gateway-methods.js";
import { setExtensionHostGatewayHandler } from "./runtime-registry.js";
describe("resolveExtensionHostGatewayMethods", () => {
it("adds plugin methods without duplicating base methods", () => {
const registry = createEmptyPluginRegistry();
registry.gatewayHandlers.health = vi.fn();
registry.gatewayHandlers["plugin.echo"] = vi.fn();
setExtensionHostGatewayHandler({ registry, method: "health", handler: vi.fn() });
setExtensionHostGatewayHandler({ registry, method: "plugin.echo", handler: vi.fn() });
expect(
resolveExtensionHostGatewayMethods({
@ -26,7 +27,7 @@ describe("createExtensionHostGatewayExtraHandlers", () => {
const pluginHandler = vi.fn();
const callerHandler = vi.fn();
const registry = createEmptyPluginRegistry();
registry.gatewayHandlers.demo = pluginHandler;
setExtensionHostGatewayHandler({ registry, method: "demo", handler: pluginHandler });
const handlers = createExtensionHostGatewayExtraHandlers({
registry,

View File

@ -43,6 +43,10 @@ import {
resolveExtensionToolRegistration,
resolveExtensionTypedHookRegistration,
} from "./runtime-registrations.js";
import {
getExtensionHostGatewayHandlers,
listExtensionHostHttpRoutes,
} from "./runtime-registry.js";
export type PluginTypedHookPolicy = {
allowPromptInjection?: boolean;
@ -115,7 +119,7 @@ export function createExtensionHostPluginRegistrationActions(params: {
handler: GatewayRequestHandler,
) => {
const result = resolveExtensionGatewayMethodRegistration({
existing: registry.gatewayHandlers,
existing: { ...getExtensionHostGatewayHandlers(registry) },
coreGatewayMethods,
method,
handler,
@ -140,7 +144,7 @@ export function createExtensionHostPluginRegistrationActions(params: {
const registerHttpRoute = (record: PluginRecord, route: OpenClawPluginHttpRouteParams) => {
const result = resolveExtensionHttpRouteRegistration({
existing: registry.httpRoutes,
existing: [...listExtensionHostHttpRoutes(registry)],
ownerPluginId: record.id,
ownerSource: record.source,
route,

View File

@ -24,6 +24,11 @@ import type {
ExtensionHostServiceRegistration,
ExtensionHostToolRegistration,
} from "./runtime-registrations.js";
import {
addExtensionHostHttpRoute,
replaceExtensionHostHttpRoute,
setExtensionHostGatewayHandler,
} from "./runtime-registry.js";
export function addExtensionGatewayMethodRegistration(params: {
registry: PluginRegistry;
@ -31,7 +36,11 @@ export function addExtensionGatewayMethodRegistration(params: {
method: string;
handler: GatewayRequestHandler;
}): void {
params.registry.gatewayHandlers[params.method] = params.handler;
setExtensionHostGatewayHandler({
registry: params.registry,
method: params.method,
handler: params.handler,
});
params.record.gatewayMethods.push(params.method);
}
@ -46,12 +55,16 @@ export function addExtensionHttpRouteRegistration(params: {
if (params.existingIndex === undefined) {
return;
}
params.registry.httpRoutes[params.existingIndex] = params.entry as PluginHttpRouteRegistration;
replaceExtensionHostHttpRoute({
registry: params.registry,
index: params.existingIndex,
entry: params.entry as PluginHttpRouteRegistration,
});
return;
}
params.record.httpRoutes += 1;
params.registry.httpRoutes.push(params.entry as PluginHttpRouteRegistration);
addExtensionHostHttpRoute(params.registry, params.entry as PluginHttpRouteRegistration);
}
export function addExtensionChannelRegistration(params: {

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import {
addExtensionHostHttpRoute,
getExtensionHostGatewayHandlers,
hasExtensionHostRuntimeEntries,
listExtensionHostCliRegistrations,
@ -8,6 +9,9 @@ import {
listExtensionHostProviderRegistrations,
listExtensionHostServiceRegistrations,
listExtensionHostToolRegistrations,
removeExtensionHostHttpRoute,
replaceExtensionHostHttpRoute,
setExtensionHostGatewayHandler,
} from "./runtime-registry.js";
describe("extension host runtime registry accessors", () => {
@ -25,7 +29,7 @@ describe("extension host runtime registry accessors", () => {
expect(hasExtensionHostRuntimeEntries(providerRegistry)).toBe(true);
const routeRegistry = createEmptyPluginRegistry();
routeRegistry.httpRoutes.push({
addExtensionHostHttpRoute(routeRegistry, {
path: "/plugins/demo",
handler: vi.fn(),
auth: "plugin",
@ -36,7 +40,11 @@ describe("extension host runtime registry accessors", () => {
expect(hasExtensionHostRuntimeEntries(routeRegistry)).toBe(true);
const gatewayRegistry = createEmptyPluginRegistry();
gatewayRegistry.gatewayHandlers["demo.echo"] = vi.fn();
setExtensionHostGatewayHandler({
registry: gatewayRegistry,
method: "demo.echo",
handler: vi.fn(),
});
expect(hasExtensionHostRuntimeEntries(gatewayRegistry)).toBe(true);
});
@ -80,7 +88,7 @@ describe("extension host runtime registry accessors", () => {
commands: ["demo"],
register: () => undefined,
});
registry.httpRoutes.push({
addExtensionHostHttpRoute(registry, {
path: "/plugins/demo",
handler: vi.fn(),
auth: "plugin",
@ -88,12 +96,49 @@ describe("extension host runtime registry accessors", () => {
pluginId: "route-demo",
source: "test",
});
registry.gatewayHandlers["demo.echo"] = vi.fn();
const handler = vi.fn();
setExtensionHostGatewayHandler({
registry,
method: "demo.echo",
handler,
});
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);
expect(listExtensionHostHttpRoutes(registry)).toEqual(registry.httpRoutes);
expect(getExtensionHostGatewayHandlers(registry)).toEqual(registry.gatewayHandlers);
expect(getExtensionHostGatewayHandlers(registry)["demo.echo"]).toBe(handler);
});
it("keeps legacy route and gateway mirrors synchronized with host-owned state", () => {
const registry = createEmptyPluginRegistry();
const firstHandler = vi.fn();
const secondHandler = vi.fn();
const entry = {
path: "/plugins/demo",
handler: firstHandler,
auth: "plugin" as const,
match: "exact" as const,
pluginId: "route-demo",
source: "test",
};
addExtensionHostHttpRoute(registry, entry);
setExtensionHostGatewayHandler({
registry,
method: "demo.echo",
handler: firstHandler,
});
replaceExtensionHostHttpRoute({
registry,
index: 0,
entry: { ...entry, handler: secondHandler },
});
removeExtensionHostHttpRoute(registry, entry);
expect(registry.httpRoutes).toHaveLength(1);
expect(registry.httpRoutes[0]?.handler).toBe(secondHandler);
expect(getExtensionHostGatewayHandlers(registry)).toEqual(registry.gatewayHandlers);
});
});

View File

@ -14,6 +14,56 @@ 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({});
const EXTENSION_HOST_RUNTIME_REGISTRY_STATE = Symbol.for("openclaw.extensionHostRuntimeRegistry");
type ExtensionHostRuntimeRegistryState = {
httpRoutes: PluginHttpRouteRegistration[];
legacyHttpRoutes: PluginHttpRouteRegistration[];
gatewayHandlers: GatewayRequestHandlers;
legacyGatewayHandlers: GatewayRequestHandlers;
};
type RuntimeRegistryBackedPluginRegistry = Pick<
PluginRegistry,
"httpRoutes" | "gatewayHandlers"
> & {
[EXTENSION_HOST_RUNTIME_REGISTRY_STATE]?: ExtensionHostRuntimeRegistryState;
};
function ensureExtensionHostRuntimeRegistryState(
registry: RuntimeRegistryBackedPluginRegistry,
): ExtensionHostRuntimeRegistryState {
if (registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE]) {
return registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE];
}
const legacyHttpRoutes = registry.httpRoutes ?? [];
registry.httpRoutes = legacyHttpRoutes;
const legacyGatewayHandlers = registry.gatewayHandlers ?? {};
registry.gatewayHandlers = legacyGatewayHandlers;
const state: ExtensionHostRuntimeRegistryState = {
httpRoutes: [...legacyHttpRoutes],
legacyHttpRoutes,
gatewayHandlers: { ...legacyGatewayHandlers },
legacyGatewayHandlers,
};
registry[EXTENSION_HOST_RUNTIME_REGISTRY_STATE] = state;
return state;
}
function syncLegacyHttpRoutes(state: ExtensionHostRuntimeRegistryState): void {
state.legacyHttpRoutes.splice(0, state.legacyHttpRoutes.length, ...state.httpRoutes);
}
function syncLegacyGatewayHandlers(state: ExtensionHostRuntimeRegistryState): void {
for (const key of Object.keys(state.legacyGatewayHandlers)) {
if (!(key in state.gatewayHandlers)) {
delete state.legacyGatewayHandlers[key];
}
}
Object.assign(state.legacyGatewayHandlers, state.gatewayHandlers);
}
export function hasExtensionHostRuntimeEntries(
registry:
@ -42,8 +92,8 @@ export function hasExtensionHostRuntimeEntries(
registry.channels.length > 0 ||
registry.tools.length > 0 ||
registry.providers.length > 0 ||
Object.keys(registry.gatewayHandlers).length > 0 ||
registry.httpRoutes.length > 0 ||
Object.keys(getExtensionHostGatewayHandlers(registry)).length > 0 ||
listExtensionHostHttpRoutes(registry).length > 0 ||
registry.cliRegistrars.length > 0 ||
registry.services.length > 0 ||
registry.commands.length > 0 ||
@ -77,13 +127,71 @@ export function listExtensionHostCliRegistrations(
}
export function listExtensionHostHttpRoutes(
registry: Pick<PluginRegistry, "httpRoutes"> | null | undefined,
registry: Pick<PluginRegistry, "httpRoutes" | "gatewayHandlers"> | null | undefined,
): readonly PluginHttpRouteRegistration[] {
return registry?.httpRoutes ?? EMPTY_HTTP_ROUTES;
if (!registry) {
return EMPTY_HTTP_ROUTES;
}
return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry)
.httpRoutes;
}
export function getExtensionHostGatewayHandlers(
registry: Pick<PluginRegistry, "gatewayHandlers"> | null | undefined,
registry: Pick<PluginRegistry, "httpRoutes" | "gatewayHandlers"> | null | undefined,
): Readonly<GatewayRequestHandlers> {
return registry?.gatewayHandlers ?? EMPTY_GATEWAY_HANDLERS;
if (!registry) {
return EMPTY_GATEWAY_HANDLERS;
}
return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry)
.gatewayHandlers;
}
export function addExtensionHostHttpRoute(
registry: Pick<PluginRegistry, "httpRoutes" | "gatewayHandlers">,
entry: PluginHttpRouteRegistration,
): void {
const state = ensureExtensionHostRuntimeRegistryState(
registry as RuntimeRegistryBackedPluginRegistry,
);
state.httpRoutes.push(entry);
syncLegacyHttpRoutes(state);
}
export function replaceExtensionHostHttpRoute(params: {
registry: Pick<PluginRegistry, "httpRoutes" | "gatewayHandlers">;
index: number;
entry: PluginHttpRouteRegistration;
}): void {
const state = ensureExtensionHostRuntimeRegistryState(
params.registry as RuntimeRegistryBackedPluginRegistry,
);
state.httpRoutes[params.index] = params.entry;
syncLegacyHttpRoutes(state);
}
export function removeExtensionHostHttpRoute(
registry: Pick<PluginRegistry, "httpRoutes" | "gatewayHandlers">,
entry: PluginHttpRouteRegistration,
): void {
const state = ensureExtensionHostRuntimeRegistryState(
registry as RuntimeRegistryBackedPluginRegistry,
);
const index = state.httpRoutes.indexOf(entry);
if (index < 0) {
return;
}
state.httpRoutes.splice(index, 1);
syncLegacyHttpRoutes(state);
}
export function setExtensionHostGatewayHandler(params: {
registry: Pick<PluginRegistry, "httpRoutes" | "gatewayHandlers">;
method: string;
handler: GatewayRequestHandlers[string];
}): void {
const state = ensureExtensionHostRuntimeRegistryState(
params.registry as RuntimeRegistryBackedPluginRegistry,
);
state.gatewayHandlers[params.method] = params.handler;
syncLegacyGatewayHandlers(state);
}

View File

@ -1,5 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { requireActiveExtensionHostRegistry } from "../extension-host/active-registry.js";
import {
addExtensionHostHttpRoute,
listExtensionHostHttpRoutes,
removeExtensionHostHttpRoute,
replaceExtensionHostHttpRoute,
} from "../extension-host/runtime-registry.js";
import { normalizePluginHttpPath } from "./http-path.js";
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js";
@ -23,8 +29,7 @@ export function registerPluginHttpRoute(params: {
registry?: PluginRegistry;
}): () => void {
const registry = params.registry ?? requireActiveExtensionHostRegistry();
const routes = registry.httpRoutes ?? [];
registry.httpRoutes = routes;
const routes = listExtensionHostHttpRoutes(registry);
const normalizedPath = normalizePluginHttpPath(params.path, params.fallbackPath);
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
@ -70,7 +75,6 @@ export function registerPluginHttpRoute(params: {
params.log?.(
`plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`,
);
routes.splice(existingIndex, 1);
}
const entry: PluginHttpRouteRegistration = {
@ -81,12 +85,17 @@ export function registerPluginHttpRoute(params: {
pluginId: params.pluginId,
source: params.source,
};
routes.push(entry);
if (existingIndex >= 0) {
replaceExtensionHostHttpRoute({
registry,
index: existingIndex,
entry,
});
} else {
addExtensionHostHttpRoute(registry, entry);
}
return () => {
const index = routes.indexOf(entry);
if (index >= 0) {
routes.splice(index, 1);
}
removeExtensionHostHttpRoute(registry, entry);
};
}