import type { IncomingMessage, ServerResponse } from "node:http"; import { afterEach, 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"; import { createTestRegistry } from "./__tests__/test-utils.js"; import { createGatewayPluginRequestHandler, isRegisteredPluginHttpRoutePath, shouldEnforceGatewayAuthForPluginPath, } from "./plugins-http.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); type HandleGatewayRequestOptions = GatewayRequestOptions & { extraHandlers?: Record; }; const handleGatewayRequest = vi.hoisted(() => vi.fn(async (_opts: HandleGatewayRequestOptions) => {}), ); vi.mock("../../plugins/loader.js", () => ({ loadOpenClawPlugins, })); vi.mock("../server-methods.js", () => ({ handleGatewayRequest, })); type PluginHandlerLog = Parameters[0]["log"]; function createPluginLog(): PluginHandlerLog { return { warn: vi.fn() } as unknown as PluginHandlerLog; } function createRoute(params: { path: string; pluginId?: string; auth?: "gateway" | "plugin"; match?: "exact" | "prefix"; handler?: (req: IncomingMessage, res: ServerResponse) => boolean | void | Promise; }) { return { pluginId: params.pluginId ?? "route", path: params.path, auth: params.auth ?? "gateway", match: params.match ?? "exact", handler: params.handler ?? (() => {}), source: params.pluginId ?? "route", }; } function buildRepeatedEncodedSlash(depth: number): string { let encodedSlash = "%2f"; for (let i = 1; i < depth; i++) { encodedSlash = encodedSlash.replace(/%/g, "%25"); } return encodedSlash; } function createSubagentRuntimeRegistry() { return createTestRegistry(); } async function createSubagentRuntime(): Promise { const serverPlugins = await import("../server-plugins.js"); const serverPluginBootstrap = await import("../server-plugin-bootstrap.js"); const runtimeModule = await import("../../plugins/runtime/index.js"); loadOpenClawPlugins.mockReturnValue(createSubagentRuntimeRegistry()); serverPluginBootstrap.loadGatewayStartupPlugins({ cfg: {}, workspaceDir: "/tmp", log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, coreGatewayHandlers: {}, baseMethods: [], }); serverPlugins.setFallbackGatewayContext({} as GatewayRequestContext); const call = loadOpenClawPlugins.mock.calls.at(-1)?.[0] as | { runtimeOptions?: { allowGatewaySubagentBinding?: boolean } } | undefined; if (call?.runtimeOptions?.allowGatewaySubagentBinding !== true) { throw new Error("Expected loadGatewayPlugins to opt into gateway subagent binding"); } return runtimeModule.createPluginRuntime({ allowGatewaySubagentBinding: true }).subagent; } function createSecurePluginRouteHandler(params: { exactPluginHandler: () => boolean | Promise; prefixGatewayHandler: () => boolean | Promise; }) { return createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [ createRoute({ path: "/plugin/secure/report", match: "exact", auth: "plugin", handler: params.exactPluginHandler, }), createRoute({ path: "/plugin/secure", match: "prefix", auth: "gateway", handler: params.prefixGatewayHandler, }), ], }), log: createPluginLog(), }); } async function invokeSecureGatewayRoute(params: { gatewayAuthSatisfied: boolean }) { const exactPluginHandler = vi.fn(async () => false); const prefixGatewayHandler = vi.fn(async () => true); const handler = createSecurePluginRouteHandler({ exactPluginHandler, prefixGatewayHandler, }); const { res } = makeMockHttpResponse(); const handled = await handler( { url: "/plugin/secure/report" } as IncomingMessage, res, undefined, { gatewayAuthSatisfied: params.gatewayAuthSatisfied }, ); return { handled, exactPluginHandler, prefixGatewayHandler }; } describe("createGatewayPluginRequestHandler", () => { afterEach(() => { releasePinnedPluginHttpRouteRegistry(); setActivePluginRegistry(createEmptyPluginRegistry()); }); it("caps unauthenticated plugin routes to non-admin subagent scopes", async () => { loadOpenClawPlugins.mockReset(); handleGatewayRequest.mockReset(); handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { const scopes = opts.client?.connect.scopes ?? []; if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) { opts.respond(false, undefined, { code: "invalid_request", message: "missing scope: operator.admin", }); return; } opts.respond(true, {}); }); const subagent = await createSubagentRuntime(); const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [ createRoute({ path: "/hook", auth: "plugin", handler: async (_req, _res) => { await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" }); return true; }, }), ], }), log, }); const { res, setHeader, end } = makeMockHttpResponse(); const handled = await handler({ url: "/hook" } as IncomingMessage, res, undefined, { gatewayAuthSatisfied: false, }); expect(handled).toBe(true); expect(handleGatewayRequest).toHaveBeenCalledTimes(1); expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([ "operator.write", ]); expect(res.statusCode).toBe(500); expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); expect(end).toHaveBeenCalledWith("Internal Server Error"); expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.admin")); }); it("keeps gateway-authenticated plugin routes on least-privilege runtime scopes", async () => { loadOpenClawPlugins.mockReset(); handleGatewayRequest.mockReset(); handleGatewayRequest.mockImplementation(async (opts: HandleGatewayRequestOptions) => { const scopes = opts.client?.connect.scopes ?? []; if (opts.req.method === "sessions.delete" && !scopes.includes("operator.admin")) { opts.respond(false, undefined, { code: "invalid_request", message: "missing scope: operator.admin", }); return; } opts.respond(true, {}); }); const subagent = await createSubagentRuntime(); const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [ createRoute({ path: "/secure-hook", auth: "gateway", handler: async (_req, _res) => { await subagent.deleteSession({ sessionKey: "agent:main:subagent:child" }); return true; }, }), ], }), log, }); const { res, setHeader, end } = makeMockHttpResponse(); const handled = await handler({ url: "/secure-hook" } as IncomingMessage, res, undefined, { gatewayAuthSatisfied: true, }); expect(handled).toBe(true); expect(handleGatewayRequest).toHaveBeenCalledTimes(1); expect(handleGatewayRequest.mock.calls[0]?.[0]?.client?.connect.scopes).toEqual([ "operator.write", ]); expect(res.statusCode).toBe(500); expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); expect(end).toHaveBeenCalledWith("Internal Server Error"); expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("missing scope: operator.admin")); }); it("returns false when no routes are registered", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry(), log, }); const { res } = makeMockHttpResponse(); const handled = await handler({} as IncomingMessage, res); expect(handled).toBe(false); }); it("handles exact route matches", async () => { const routeHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; }); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })], }), log: createPluginLog(), }); const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/demo" } as IncomingMessage, res); expect(handled).toBe(true); expect(routeHandler).toHaveBeenCalledTimes(1); }); it("prefers exact matches before prefix matches", async () => { const exactHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; }); const prefixHandler = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [ createRoute({ path: "/api", match: "prefix", handler: prefixHandler }), createRoute({ path: "/api/demo", match: "exact", handler: exactHandler }), ], }), log: createPluginLog(), }); const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/api/demo" } as IncomingMessage, res); expect(handled).toBe(true); expect(exactHandler).toHaveBeenCalledTimes(1); expect(prefixHandler).not.toHaveBeenCalled(); }); it("supports route fallthrough when handler returns false", async () => { const first = vi.fn(async () => false); const second = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [ createRoute({ path: "/hook", match: "exact", handler: first }), createRoute({ path: "/hook", match: "prefix", handler: second }), ], }), log: createPluginLog(), }); const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/hook" } as IncomingMessage, res); expect(handled).toBe(true); expect(first).toHaveBeenCalledTimes(1); expect(second).toHaveBeenCalledTimes(1); }); it("fails closed when a matched gateway route reaches dispatch without auth", async () => { const { handled, exactPluginHandler, prefixGatewayHandler } = await invokeSecureGatewayRoute({ gatewayAuthSatisfied: false, }); expect(handled).toBe(false); expect(exactPluginHandler).not.toHaveBeenCalled(); expect(prefixGatewayHandler).not.toHaveBeenCalled(); }); it("allows gateway route fallthrough only after gateway auth succeeds", async () => { const { handled, exactPluginHandler, prefixGatewayHandler } = await invokeSecureGatewayRoute({ gatewayAuthSatisfied: true, }); expect(handled).toBe(true); expect(exactPluginHandler).toHaveBeenCalledTimes(1); expect(prefixGatewayHandler).toHaveBeenCalledTimes(1); }); it("matches canonicalized route variants", async () => { const routeHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; }); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })], }), log: createPluginLog(), }); const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/API//demo" } as IncomingMessage, res); expect(handled).toBe(true); expect(routeHandler).toHaveBeenCalledTimes(1); }); it("falls back to the provided registry when the pinned route registry is empty", async () => { const explicitRouteHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; return true; }); const startupRegistry = createTestRegistry(); const explicitRegistry = createTestRegistry({ httpRoutes: [createRoute({ path: "/demo", auth: "plugin", handler: explicitRouteHandler })], }); setActivePluginRegistry(startupRegistry); pinActivePluginHttpRouteRegistry(startupRegistry); const handler = createGatewayPluginRequestHandler({ registry: explicitRegistry, log: createPluginLog(), }); const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/demo" } as IncomingMessage, res); expect(handled).toBe(true); expect(explicitRouteHandler).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(); } }); it("prefers the pinned route registry over a stale explicit registry", async () => { const startupRegistry = createTestRegistry(); const staleExplicitRegistry = createTestRegistry({ httpRoutes: [createRoute({ path: "/plugins/diffs", auth: "plugin" })], }); const routeHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 204; return true; }); setActivePluginRegistry(createTestRegistry()); pinActivePluginHttpRouteRegistry(startupRegistry); const unregister = registerPluginHttpRoute({ path: "/bluebubbles-webhook", auth: "plugin", handler: routeHandler, }); try { const handler = createGatewayPluginRequestHandler({ registry: staleExplicitRegistry, log: createPluginLog(), }); const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/bluebubbles-webhook" } as IncomingMessage, res); expect(handled).toBe(true); expect(routeHandler).toHaveBeenCalledTimes(1); expect(staleExplicitRegistry.httpRoutes).toHaveLength(1); expect(startupRegistry.httpRoutes).toHaveLength(1); } finally { unregister(); } }); it("logs and responds with 500 when a route throws", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [ createRoute({ path: "/boom", handler: async () => { throw new Error("boom"); }, }), ], }), log, }); const { res, setHeader, end } = makeMockHttpResponse(); const handled = await handler({ url: "/boom" } as IncomingMessage, res); expect(handled).toBe(true); expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("boom")); expect(res.statusCode).toBe(500); expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); expect(end).toHaveBeenCalledWith("Internal Server Error"); }); }); describe("plugin HTTP route auth checks", () => { const deeplyEncodedChannelPath = "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile"; const decodeOverflowPublicPath = `/googlechat${buildRepeatedEncodedSlash(40)}public`; it("detects registered route paths", () => { const registry = createTestRegistry({ httpRoutes: [createRoute({ path: "/demo" })], }); expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false); }); it("matches canonicalized variants of registered route paths", () => { const registry = createTestRegistry({ httpRoutes: [createRoute({ path: "/api/demo" })], }); expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true); expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true); }); it("enforces auth for protected and gateway-auth routes", () => { const registry = createTestRegistry({ httpRoutes: [ createRoute({ path: "/googlechat", match: "prefix", auth: "plugin" }), createRoute({ path: "/api/demo", auth: "gateway" }), ], }); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/googlechat/public")).toBe(false); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, deeplyEncodedChannelPath)).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, decodeOverflowPublicPath)).toBe(true); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/not-plugin")).toBe(false); }); it("enforces auth when any overlapping matched route requires gateway auth", () => { const registry = createTestRegistry({ httpRoutes: [ createRoute({ path: "/plugin/secure/report", match: "exact", auth: "plugin" }), createRoute({ path: "/plugin/secure", match: "prefix", auth: "gateway" }), ], }); expect(shouldEnforceGatewayAuthForPluginPath(registry, "/plugin/secure/report")).toBe(true); }); });