From a1c5cbabff1a8a8fca5936ab6061281fec271664 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Mar 2026 18:37:47 +0000 Subject: [PATCH] Plugins: add host-owned route and gateway storage --- src/extension-host/gateway-methods.test.ts | 7 +- .../plugin-registry-registrations.ts | 8 +- src/extension-host/registry-writes.ts | 19 ++- src/extension-host/runtime-registry.test.ts | 57 ++++++++- src/extension-host/runtime-registry.ts | 120 +++++++++++++++++- src/plugins/http-registry.ts | 25 ++-- 6 files changed, 208 insertions(+), 28 deletions(-) diff --git a/src/extension-host/gateway-methods.test.ts b/src/extension-host/gateway-methods.test.ts index 281e4525d95..5c9b0b0b57f 100644 --- a/src/extension-host/gateway-methods.test.ts +++ b/src/extension-host/gateway-methods.test.ts @@ -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, diff --git a/src/extension-host/plugin-registry-registrations.ts b/src/extension-host/plugin-registry-registrations.ts index 2db4484c713..d162ff5e077 100644 --- a/src/extension-host/plugin-registry-registrations.ts +++ b/src/extension-host/plugin-registry-registrations.ts @@ -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, diff --git a/src/extension-host/registry-writes.ts b/src/extension-host/registry-writes.ts index b293f8af49b..d2b0ebb6940 100644 --- a/src/extension-host/registry-writes.ts +++ b/src/extension-host/registry-writes.ts @@ -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: { diff --git a/src/extension-host/runtime-registry.test.ts b/src/extension-host/runtime-registry.test.ts index 69c940d195f..47ee4ecbde6 100644 --- a/src/extension-host/runtime-registry.test.ts +++ b/src/extension-host/runtime-registry.test.ts @@ -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); }); }); diff --git a/src/extension-host/runtime-registry.ts b/src/extension-host/runtime-registry.ts index f86cf99b992..f18962e7962 100644 --- a/src/extension-host/runtime-registry.ts +++ b/src/extension-host/runtime-registry.ts @@ -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 = 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 | null | undefined, + registry: Pick | 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 | null | undefined, + registry: Pick | null | undefined, ): Readonly { - return registry?.gatewayHandlers ?? EMPTY_GATEWAY_HANDLERS; + if (!registry) { + return EMPTY_GATEWAY_HANDLERS; + } + return ensureExtensionHostRuntimeRegistryState(registry as RuntimeRegistryBackedPluginRegistry) + .gatewayHandlers; +} + +export function addExtensionHostHttpRoute( + registry: Pick, + entry: PluginHttpRouteRegistration, +): void { + const state = ensureExtensionHostRuntimeRegistryState( + registry as RuntimeRegistryBackedPluginRegistry, + ); + state.httpRoutes.push(entry); + syncLegacyHttpRoutes(state); +} + +export function replaceExtensionHostHttpRoute(params: { + registry: Pick; + 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, + 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; + method: string; + handler: GatewayRequestHandlers[string]; +}): void { + const state = ensureExtensionHostRuntimeRegistryState( + params.registry as RuntimeRegistryBackedPluginRegistry, + ); + state.gatewayHandlers[params.method] = params.handler; + syncLegacyGatewayHandlers(state); } diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index 0a460ba3607..688e4a13284 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -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); }; }