From 6783f59232e70ab51b8419699b33c21433763afb Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Fri, 13 Mar 2026 13:16:46 -0700 Subject: [PATCH] fix: preserve gateway plugin webhook routes --- src/gateway/server-close.ts | 191 ++++++++-------- src/gateway/server-runtime-state.ts | 280 +++++++++++++----------- src/gateway/server.impl.ts | 2 + src/gateway/server/plugins-http.test.ts | 82 +++++++ src/gateway/server/plugins-http.ts | 17 +- src/plugins/http-registry.test.ts | 34 ++- src/plugins/http-registry.ts | 4 +- src/plugins/runtime.ts | 37 ++++ 8 files changed, 420 insertions(+), 227 deletions(-) diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 1d941c0e206..7d07cb1abd5 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -11,6 +11,7 @@ export function createGatewayCloseHandler(params: { tailscaleCleanup: (() => Promise) | null; canvasHost: CanvasHostHandler | null; canvasHostServer: CanvasHostServer | null; + releasePluginRouteRegistry?: (() => void) | null; stopChannel: (name: ChannelId, accountId?: string) => Promise; pluginServices: PluginServicesHandle | null; cron: { stop: () => void }; @@ -33,106 +34,114 @@ export function createGatewayCloseHandler(params: { httpServers?: HttpServer[]; }) { return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => { - const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : ""; - const reason = reasonRaw || "gateway stopping"; - const restartExpectedMs = - typeof opts?.restartExpectedMs === "number" && Number.isFinite(opts.restartExpectedMs) - ? Math.max(0, Math.floor(opts.restartExpectedMs)) - : null; - if (params.bonjourStop) { - try { - await params.bonjourStop(); - } catch { - /* ignore */ - } - } - if (params.tailscaleCleanup) { - await params.tailscaleCleanup(); - } - if (params.canvasHost) { - try { - await params.canvasHost.close(); - } catch { - /* ignore */ - } - } - if (params.canvasHostServer) { - try { - await params.canvasHostServer.close(); - } catch { - /* ignore */ - } - } - for (const plugin of listChannelPlugins()) { - await params.stopChannel(plugin.id); - } - if (params.pluginServices) { - await params.pluginServices.stop().catch(() => {}); - } - await stopGmailWatcher(); - params.cron.stop(); - params.heartbeatRunner.stop(); try { - params.updateCheckStop?.(); - } catch { - /* ignore */ - } - for (const timer of params.nodePresenceTimers.values()) { - clearInterval(timer); - } - params.nodePresenceTimers.clear(); - params.broadcast("shutdown", { - reason, - restartExpectedMs, - }); - clearInterval(params.tickInterval); - clearInterval(params.healthInterval); - clearInterval(params.dedupeCleanup); - if (params.mediaCleanup) { - clearInterval(params.mediaCleanup); - } - if (params.agentUnsub) { + const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : ""; + const reason = reasonRaw || "gateway stopping"; + const restartExpectedMs = + typeof opts?.restartExpectedMs === "number" && Number.isFinite(opts.restartExpectedMs) + ? Math.max(0, Math.floor(opts.restartExpectedMs)) + : null; + if (params.bonjourStop) { + try { + await params.bonjourStop(); + } catch { + /* ignore */ + } + } + if (params.tailscaleCleanup) { + await params.tailscaleCleanup(); + } + if (params.canvasHost) { + try { + await params.canvasHost.close(); + } catch { + /* ignore */ + } + } + if (params.canvasHostServer) { + try { + await params.canvasHostServer.close(); + } catch { + /* ignore */ + } + } + for (const plugin of listChannelPlugins()) { + await params.stopChannel(plugin.id); + } + if (params.pluginServices) { + await params.pluginServices.stop().catch(() => {}); + } + await stopGmailWatcher(); + params.cron.stop(); + params.heartbeatRunner.stop(); try { - params.agentUnsub(); + params.updateCheckStop?.(); } catch { /* ignore */ } - } - if (params.heartbeatUnsub) { + for (const timer of params.nodePresenceTimers.values()) { + clearInterval(timer); + } + params.nodePresenceTimers.clear(); + params.broadcast("shutdown", { + reason, + restartExpectedMs, + }); + clearInterval(params.tickInterval); + clearInterval(params.healthInterval); + clearInterval(params.dedupeCleanup); + if (params.mediaCleanup) { + clearInterval(params.mediaCleanup); + } + if (params.agentUnsub) { + try { + params.agentUnsub(); + } catch { + /* ignore */ + } + } + if (params.heartbeatUnsub) { + try { + params.heartbeatUnsub(); + } catch { + /* ignore */ + } + } + params.chatRunState.clear(); + for (const c of params.clients) { + try { + c.socket.close(1012, "service restart"); + } catch { + /* ignore */ + } + } + params.clients.clear(); + await params.configReloader.stop().catch(() => {}); + if (params.browserControl) { + await params.browserControl.stop().catch(() => {}); + } + await new Promise((resolve) => params.wss.close(() => resolve())); + const servers = + params.httpServers && params.httpServers.length > 0 + ? params.httpServers + : [params.httpServer]; + for (const server of servers) { + const httpServer = server as HttpServer & { + closeIdleConnections?: () => void; + }; + if (typeof httpServer.closeIdleConnections === "function") { + httpServer.closeIdleConnections(); + } + await new Promise((resolve, reject) => + httpServer.close((err) => (err ? reject(err) : resolve())), + ); + } + } finally { try { - params.heartbeatUnsub(); + params.releasePluginRouteRegistry?.(); } catch { /* ignore */ } } - params.chatRunState.clear(); - for (const c of params.clients) { - try { - c.socket.close(1012, "service restart"); - } catch { - /* ignore */ - } - } - params.clients.clear(); - await params.configReloader.stop().catch(() => {}); - if (params.browserControl) { - await params.browserControl.stop().catch(() => {}); - } - await new Promise((resolve) => params.wss.close(() => resolve())); - const servers = - params.httpServers && params.httpServers.length > 0 - ? params.httpServers - : [params.httpServer]; - for (const server of servers) { - const httpServer = server as HttpServer & { - closeIdleConnections?: () => void; - }; - if (typeof httpServer.closeIdleConnections === "function") { - httpServer.closeIdleConnections(); - } - await new Promise((resolve, reject) => - httpServer.close((err) => (err ? reject(err) : resolve())), - ); - } }; } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index a569b896e54..eea02bb91f3 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -5,6 +5,11 @@ import { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/ import type { CliDeps } from "../cli/deps.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { PluginRegistry } from "../plugins/registry.js"; +import { + getActivePluginHttpRouteRegistry, + pinActivePluginHttpRouteRegistry, + releasePinnedPluginHttpRouteRegistry, +} from "../plugins/runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; @@ -70,6 +75,7 @@ export async function createGatewayRuntimeState(params: { getReadiness?: ReadinessChecker; }): Promise<{ canvasHost: CanvasHostHandler | null; + releasePluginRouteRegistry: () => void; httpServer: HttpServer; httpServers: HttpServer[]; httpBindHosts: string[]; @@ -91,147 +97,157 @@ export async function createGatewayRuntimeState(params: { chatAbortControllers: Map; toolEventRecipients: ReturnType; }> { - let canvasHost: CanvasHostHandler | null = null; - if (params.canvasHostEnabled) { - try { - const handler = await createCanvasHostHandler({ - runtime: params.canvasRuntime, - rootDir: params.cfg.canvasHost?.root, - basePath: CANVAS_HOST_PATH, - allowInTests: params.allowCanvasHostInTests, - liveReload: params.cfg.canvasHost?.liveReload, - }); - if (handler.rootDir) { - canvasHost = handler; - params.logCanvas.info( - `canvas host mounted at http://${params.bindHost}:${params.port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`, - ); + pinActivePluginHttpRouteRegistry(params.pluginRegistry); + try { + let canvasHost: CanvasHostHandler | null = null; + if (params.canvasHostEnabled) { + try { + const handler = await createCanvasHostHandler({ + runtime: params.canvasRuntime, + rootDir: params.cfg.canvasHost?.root, + basePath: CANVAS_HOST_PATH, + allowInTests: params.allowCanvasHostInTests, + liveReload: params.cfg.canvasHost?.liveReload, + }); + if (handler.rootDir) { + canvasHost = handler; + params.logCanvas.info( + `canvas host mounted at http://${params.bindHost}:${params.port}${CANVAS_HOST_PATH}/ (root ${handler.rootDir})`, + ); + } + } catch (err) { + params.logCanvas.warn(`canvas host failed to start: ${String(err)}`); } - } catch (err) { - params.logCanvas.warn(`canvas host failed to start: ${String(err)}`); } - } - const clients = new Set(); - const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients }); + const clients = new Set(); + const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients }); - const handleHooksRequest = createGatewayHooksRequestHandler({ - deps: params.deps, - getHooksConfig: params.hooksConfig, - getClientIpConfig: params.getHookClientIpConfig, - bindHost: params.bindHost, - port: params.port, - logHooks: params.logHooks, - }); - - const handlePluginRequest = createGatewayPluginRequestHandler({ - registry: params.pluginRegistry, - log: params.logPlugins, - }); - const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => { - return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, pathContext); - }; - - const bindHosts = await resolveGatewayListenHosts(params.bindHost); - if (!isLoopbackHost(params.bindHost)) { - params.log.warn( - "⚠️ Gateway is binding to a non-loopback address. " + - "Ensure authentication is configured before exposing to public networks.", - ); - } - if (params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) { - params.log.warn( - "⚠️ gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true is enabled. " + - "Host-header origin fallback weakens origin checks and should only be used as break-glass.", - ); - } - const httpServers: HttpServer[] = []; - const httpBindHosts: string[] = []; - for (const host of bindHosts) { - const httpServer = createGatewayHttpServer({ - canvasHost, - clients, - controlUiEnabled: params.controlUiEnabled, - controlUiBasePath: params.controlUiBasePath, - controlUiRoot: params.controlUiRoot, - openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, - openAiChatCompletionsConfig: params.openAiChatCompletionsConfig, - openResponsesEnabled: params.openResponsesEnabled, - openResponsesConfig: params.openResponsesConfig, - strictTransportSecurityHeader: params.strictTransportSecurityHeader, - handleHooksRequest, - handlePluginRequest, - shouldEnforcePluginGatewayAuth, - resolvedAuth: params.resolvedAuth, - rateLimiter: params.rateLimiter, - getReadiness: params.getReadiness, - tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + const handleHooksRequest = createGatewayHooksRequestHandler({ + deps: params.deps, + getHooksConfig: params.hooksConfig, + getClientIpConfig: params.getHookClientIpConfig, + bindHost: params.bindHost, + port: params.port, + logHooks: params.logHooks, }); - try { - await listenGatewayHttpServer({ - httpServer, - bindHost: host, - port: params.port, - }); - httpServers.push(httpServer); - httpBindHosts.push(host); - } catch (err) { - if (host === bindHosts[0]) { - throw err; - } + + const handlePluginRequest = createGatewayPluginRequestHandler({ + registry: params.pluginRegistry, + log: params.logPlugins, + }); + const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => { + return shouldEnforceGatewayAuthForPluginPath( + getActivePluginHttpRouteRegistry() ?? params.pluginRegistry, + pathContext, + ); + }; + + const bindHosts = await resolveGatewayListenHosts(params.bindHost); + if (!isLoopbackHost(params.bindHost)) { params.log.warn( - `gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`, + "⚠️ Gateway is binding to a non-loopback address. " + + "Ensure authentication is configured before exposing to public networks.", ); } - } - const httpServer = httpServers[0]; - if (!httpServer) { - throw new Error("Gateway HTTP server failed to start"); - } + if (params.cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) { + params.log.warn( + "⚠️ gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true is enabled. " + + "Host-header origin fallback weakens origin checks and should only be used as break-glass.", + ); + } + const httpServers: HttpServer[] = []; + const httpBindHosts: string[] = []; + for (const host of bindHosts) { + const httpServer = createGatewayHttpServer({ + canvasHost, + clients, + controlUiEnabled: params.controlUiEnabled, + controlUiBasePath: params.controlUiBasePath, + controlUiRoot: params.controlUiRoot, + openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, + openAiChatCompletionsConfig: params.openAiChatCompletionsConfig, + openResponsesEnabled: params.openResponsesEnabled, + openResponsesConfig: params.openResponsesConfig, + strictTransportSecurityHeader: params.strictTransportSecurityHeader, + handleHooksRequest, + handlePluginRequest, + shouldEnforcePluginGatewayAuth, + resolvedAuth: params.resolvedAuth, + rateLimiter: params.rateLimiter, + getReadiness: params.getReadiness, + tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + }); + try { + await listenGatewayHttpServer({ + httpServer, + bindHost: host, + port: params.port, + }); + httpServers.push(httpServer); + httpBindHosts.push(host); + } catch (err) { + if (host === bindHosts[0]) { + throw err; + } + params.log.warn( + `gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`, + ); + } + } + const httpServer = httpServers[0]; + if (!httpServer) { + throw new Error("Gateway HTTP server failed to start"); + } - const wss = new WebSocketServer({ - noServer: true, - maxPayload: MAX_PREAUTH_PAYLOAD_BYTES, - }); - for (const server of httpServers) { - attachGatewayUpgradeHandler({ - httpServer: server, - wss, - canvasHost, - clients, - resolvedAuth: params.resolvedAuth, - rateLimiter: params.rateLimiter, + const wss = new WebSocketServer({ + noServer: true, + maxPayload: MAX_PREAUTH_PAYLOAD_BYTES, }); + for (const server of httpServers) { + attachGatewayUpgradeHandler({ + httpServer: server, + wss, + canvasHost, + clients, + resolvedAuth: params.resolvedAuth, + rateLimiter: params.rateLimiter, + }); + } + + const agentRunSeq = new Map(); + const dedupe = new Map(); + const chatRunState = createChatRunState(); + const chatRunRegistry = chatRunState.registry; + const chatRunBuffers = chatRunState.buffers; + const chatDeltaSentAt = chatRunState.deltaSentAt; + const addChatRun = chatRunRegistry.add; + const removeChatRun = chatRunRegistry.remove; + const chatAbortControllers = new Map(); + const toolEventRecipients = createToolEventRecipientRegistry(); + + return { + canvasHost, + releasePluginRouteRegistry: () => releasePinnedPluginHttpRouteRegistry(params.pluginRegistry), + httpServer, + httpServers, + httpBindHosts, + wss, + clients, + broadcast, + broadcastToConnIds, + agentRunSeq, + dedupe, + chatRunState, + chatRunBuffers, + chatDeltaSentAt, + addChatRun, + removeChatRun, + chatAbortControllers, + toolEventRecipients, + }; + } catch (err) { + releasePinnedPluginHttpRouteRegistry(params.pluginRegistry); + throw err; } - - const agentRunSeq = new Map(); - const dedupe = new Map(); - const chatRunState = createChatRunState(); - const chatRunRegistry = chatRunState.registry; - const chatRunBuffers = chatRunState.buffers; - const chatDeltaSentAt = chatRunState.deltaSentAt; - const addChatRun = chatRunRegistry.add; - const removeChatRun = chatRunRegistry.remove; - const chatAbortControllers = new Map(); - const toolEventRecipients = createToolEventRecipientRegistry(); - - return { - canvasHost, - httpServer, - httpServers, - httpBindHosts, - wss, - clients, - broadcast, - broadcastToConnIds, - agentRunSeq, - dedupe, - chatRunState, - chatRunBuffers, - chatDeltaSentAt, - addChatRun, - removeChatRun, - chatAbortControllers, - toolEventRecipients, - }; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 9b3941d1432..7b8a7336acd 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -583,6 +583,7 @@ export async function startGatewayServer( }); const { canvasHost, + releasePluginRouteRegistry, httpServer, httpServers, httpBindHosts, @@ -1022,6 +1023,7 @@ export async function startGatewayServer( tailscaleCleanup, canvasHost, canvasHostServer, + releasePluginRouteRegistry, stopChannel, pluginServices, cron, diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index e5062686246..c2c8d433dd7 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -1,5 +1,12 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { describe, expect, it, vi } from "vitest"; +import { registerPluginHttpRoute } from "../../plugins/http-registry.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { + pinActivePluginHttpRouteRegistry, + releasePinnedPluginHttpRouteRegistry, + setActivePluginRegistry, +} from "../../plugins/runtime.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "../server-methods/types.js"; import { makeMockHttpResponse } from "../test-http-response.js"; @@ -292,6 +299,81 @@ describe("createGatewayPluginRequestHandler", () => { expect(routeHandler).toHaveBeenCalledTimes(1); }); + it("handles routes registered into the pinned startup registry after the active registry changes", async () => { + const startupRegistry = createTestRegistry(); + const laterActiveRegistry = createTestRegistry(); + const routeHandler = vi.fn(async (_req, res: ServerResponse) => { + res.statusCode = 202; + return true; + }); + + setActivePluginRegistry(startupRegistry); + pinActivePluginHttpRouteRegistry(startupRegistry); + setActivePluginRegistry(laterActiveRegistry); + + const unregister = registerPluginHttpRoute({ + path: "/bluebubbles-webhook", + auth: "plugin", + handler: routeHandler, + }); + + try { + const handler = createGatewayPluginRequestHandler({ + registry: startupRegistry, + log: createPluginLog(), + }); + + const { res } = makeMockHttpResponse(); + const handled = await handler({ url: "/bluebubbles-webhook" } as IncomingMessage, res); + expect(handled).toBe(true); + expect(routeHandler).toHaveBeenCalledTimes(1); + expect(laterActiveRegistry.httpRoutes).toHaveLength(0); + } finally { + unregister(); + releasePinnedPluginHttpRouteRegistry(startupRegistry); + setActivePluginRegistry(createEmptyPluginRegistry()); + } + }); + + it("dispatches using the pinned route registry when both startup and active registries are stale", async () => { + const startupRegistry = createTestRegistry(); + const routeRegistry = createTestRegistry(); + const laterActiveRegistry = createTestRegistry(); + const routeHandler = vi.fn(async (_req, res: ServerResponse) => { + res.statusCode = 204; + return true; + }); + + setActivePluginRegistry(startupRegistry); + pinActivePluginHttpRouteRegistry(routeRegistry); + setActivePluginRegistry(laterActiveRegistry); + + const unregister = registerPluginHttpRoute({ + path: "/bluebubbles-webhook", + auth: "plugin", + handler: routeHandler, + }); + + try { + const handler = createGatewayPluginRequestHandler({ + registry: startupRegistry, + log: createPluginLog(), + }); + + const { res } = makeMockHttpResponse(); + const handled = await handler({ url: "/bluebubbles-webhook" } as IncomingMessage, res); + expect(handled).toBe(true); + expect(routeHandler).toHaveBeenCalledTimes(1); + expect(startupRegistry.httpRoutes).toHaveLength(0); + expect(laterActiveRegistry.httpRoutes).toHaveLength(0); + expect(routeRegistry.httpRoutes).toHaveLength(1); + } finally { + unregister(); + releasePinnedPluginHttpRouteRegistry(routeRegistry); + setActivePluginRegistry(createEmptyPluginRegistry()); + } + }); + it("logs and responds with 500 when a route throws", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 6147e1bee99..5972c5846e1 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; +import { getActivePluginHttpRouteRegistry } from "../../plugins/runtime.js"; import { withPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import { ADMIN_SCOPE, APPROVALS_SCOPE, PAIRING_SCOPE, WRITE_SCOPE } from "../method-scopes.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../protocol/client-info.js"; @@ -59,12 +60,26 @@ export type PluginHttpRequestHandler = ( dispatchContext?: { gatewayAuthSatisfied?: boolean }, ) => Promise; +function resolveCurrentPluginRegistry(fallback: PluginRegistry): PluginRegistry { + const routeRegistry = getActivePluginHttpRouteRegistry(); + if (!routeRegistry) { + return fallback; + } + const routeCount = routeRegistry.httpRoutes?.length ?? 0; + const fallbackRouteCount = fallback.httpRoutes?.length ?? 0; + if (routeCount === 0 && fallbackRouteCount > 0) { + return fallback; + } + return routeRegistry; +} + export function createGatewayPluginRequestHandler(params: { registry: PluginRegistry; log: SubsystemLogger; }): PluginHttpRequestHandler { - const { registry, log } = params; + const { log } = params; return async (req, res, providedPathContext, dispatchContext) => { + const registry = resolveCurrentPluginRegistry(params.registry); const routes = registry.httpRoutes ?? []; if (routes.length === 0) { return false; diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index 9993c7cb39d..05bd337eed6 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { registerPluginHttpRoute } from "./http-registry.js"; import { createEmptyPluginRegistry } from "./registry.js"; +import { + pinActivePluginHttpRouteRegistry, + releasePinnedPluginHttpRouteRegistry, + setActivePluginRegistry, +} from "./runtime.js"; function expectRouteRegistrationDenied(params: { replaceExisting: boolean; @@ -38,6 +43,11 @@ function expectRouteRegistrationDenied(params: { } describe("registerPluginHttpRoute", () => { + afterEach(() => { + releasePinnedPluginHttpRouteRegistry(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + it("registers route and unregisters it", () => { const registry = createEmptyPluginRegistry(); const handler = vi.fn(); @@ -164,4 +174,26 @@ describe("registerPluginHttpRoute", () => { unregister(); expect(registry.httpRoutes).toHaveLength(1); }); + + it("uses the pinned route registry when the active registry changes later", () => { + const startupRegistry = createEmptyPluginRegistry(); + const laterActiveRegistry = createEmptyPluginRegistry(); + + setActivePluginRegistry(startupRegistry); + pinActivePluginHttpRouteRegistry(startupRegistry); + setActivePluginRegistry(laterActiveRegistry); + + const unregister = registerPluginHttpRoute({ + path: "/bluebubbles-webhook", + auth: "plugin", + handler: vi.fn(), + }); + + expect(startupRegistry.httpRoutes).toHaveLength(1); + expect(startupRegistry.httpRoutes[0]?.path).toBe("/bluebubbles-webhook"); + expect(laterActiveRegistry.httpRoutes).toHaveLength(0); + + unregister(); + expect(startupRegistry.httpRoutes).toHaveLength(0); + }); }); diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index bf45f1b076a..4dc5bfd23b5 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { normalizePluginHttpPath } from "./http-path.js"; import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js"; import type { PluginHttpRouteRegistration, PluginRegistry } from "./registry.js"; -import { requireActivePluginRegistry } from "./runtime.js"; +import { requireActivePluginHttpRouteRegistry } from "./runtime.js"; export type PluginHttpRouteHandler = ( req: IncomingMessage, @@ -22,7 +22,7 @@ export function registerPluginHttpRoute(params: { log?: (message: string) => void; registry?: PluginRegistry; }): () => void { - const registry = params.registry ?? requireActivePluginRegistry(); + const registry = params.registry ?? requireActivePluginHttpRouteRegistry(); const routes = registry.httpRoutes ?? []; registry.httpRoutes = routes; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 752908ddf75..8352795672f 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -4,6 +4,8 @@ const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); type RegistryState = { registry: PluginRegistry | null; + httpRouteRegistry: PluginRegistry | null; + httpRouteRegistryPinned: boolean; key: string | null; version: number; }; @@ -15,6 +17,8 @@ const state: RegistryState = (() => { if (!globalState[REGISTRY_STATE]) { globalState[REGISTRY_STATE] = { registry: createEmptyPluginRegistry(), + httpRouteRegistry: null, + httpRouteRegistryPinned: false, key: null, version: 0, }; @@ -24,6 +28,9 @@ const state: RegistryState = (() => { export function setActivePluginRegistry(registry: PluginRegistry, cacheKey?: string) { state.registry = registry; + if (!state.httpRouteRegistryPinned) { + state.httpRouteRegistry = registry; + } state.key = cacheKey ?? null; state.version += 1; } @@ -35,11 +42,41 @@ export function getActivePluginRegistry(): PluginRegistry | null { export function requireActivePluginRegistry(): PluginRegistry { if (!state.registry) { state.registry = createEmptyPluginRegistry(); + if (!state.httpRouteRegistryPinned) { + state.httpRouteRegistry = state.registry; + } state.version += 1; } return state.registry; } +export function pinActivePluginHttpRouteRegistry(registry: PluginRegistry) { + state.httpRouteRegistry = registry; + state.httpRouteRegistryPinned = true; +} + +export function releasePinnedPluginHttpRouteRegistry(registry?: PluginRegistry) { + if (registry && state.httpRouteRegistry !== registry) { + return; + } + state.httpRouteRegistryPinned = false; + state.httpRouteRegistry = state.registry; +} + +export function getActivePluginHttpRouteRegistry(): PluginRegistry | null { + return state.httpRouteRegistry ?? state.registry; +} + +export function requireActivePluginHttpRouteRegistry(): PluginRegistry { + const existing = getActivePluginHttpRouteRegistry(); + if (existing) { + return existing; + } + const created = requireActivePluginRegistry(); + state.httpRouteRegistry = created; + return created; +} + export function getActivePluginRegistryKey(): string | null { return state.key; }