From 2fd8264ab03bd178e62a5f0c50d1c8556c17f12d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 16:22:31 +0000 Subject: [PATCH] refactor(gateway): hard-break plugin wildcard http handlers --- CHANGELOG.md | 1 + extensions/bluebubbles/README.md | 2 +- extensions/bluebubbles/index.ts | 2 - extensions/bluebubbles/src/monitor.ts | 10 ++ extensions/diffs/index.test.ts | 23 +-- extensions/diffs/index.ts | 9 +- extensions/diffs/src/tool.test.ts | 1 - extensions/googlechat/index.ts | 2 - extensions/googlechat/src/monitor.ts | 17 +- extensions/lobster/src/lobster-tool.test.ts | 1 - extensions/nostr/index.ts | 7 +- extensions/zalo/index.ts | 2 - extensions/zalo/src/monitor.ts | 10 ++ package.json | 3 +- src/auto-reply/reply/route-reply.test.ts | 1 - src/gateway/server-http.ts | 33 ++-- src/gateway/server-plugins.test.ts | 1 - src/gateway/server-runtime-state.ts | 5 +- src/gateway/server.plugin-http-auth.test.ts | 16 +- src/gateway/server/__tests__/test-utils.ts | 1 - src/gateway/server/plugins-http.test.ts | 103 +++++++----- src/gateway/server/plugins-http.ts | 165 +++++++++++++++----- src/gateway/test-helpers.mocks.ts | 1 - src/plugins/hooks.test-helpers.ts | 1 - src/plugins/http-registry.test.ts | 4 +- src/plugins/http-registry.ts | 15 +- src/plugins/loader.test.ts | 22 ++- src/plugins/loader.ts | 2 +- src/plugins/registry.ts | 42 ++--- src/plugins/types.ts | 18 ++- src/test-utils/channel-plugins.ts | 1 - 31 files changed, 347 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fad654cb6d..b5fd4002785 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path. - **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected. - **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`). +- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`. ### Fixes diff --git a/extensions/bluebubbles/README.md b/extensions/bluebubbles/README.md index bd79f250245..46fdd04e7f4 100644 --- a/extensions/bluebubbles/README.md +++ b/extensions/bluebubbles/README.md @@ -10,7 +10,7 @@ If you’re looking for **how to use BlueBubbles as an agent/tool user**, see: - Extension package: `extensions/bluebubbles/` (entry: `index.ts`). - Channel implementation: `extensions/bluebubbles/src/channel.ts`. -- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`). +- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`). - REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`. - Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`). - Catalog entry for onboarding: `src/channels/plugins/catalog.ts`. diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts index 44b09e24592..92bacb8d51a 100644 --- a/extensions/bluebubbles/index.ts +++ b/extensions/bluebubbles/index.ts @@ -1,7 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { bluebubblesPlugin } from "./src/channel.js"; -import { handleBlueBubblesWebhookRequest } from "./src/monitor.js"; import { setBlueBubblesRuntime } from "./src/runtime.js"; const plugin = { @@ -12,7 +11,6 @@ const plugin = { register(api: OpenClawPluginApi) { setBlueBubblesRuntime(api.runtime); api.registerChannel({ plugin: bluebubblesPlugin }); - api.registerHttpHandler(handleBlueBubblesWebhookRequest); }, }; diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index af2d9a3faa0..d1c296275dd 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -530,10 +530,20 @@ export async function monitorBlueBubblesProvider( path, statusSink, }); + const unregisterRoute = registerPluginHttpRoute({ + path, + auth: "plugin", + match: "exact", + pluginId: "bluebubbles", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + handler: handleBlueBubblesWebhookRequest, + }); return await new Promise((resolve) => { const stop = () => { unregister(); + unregisterRoute(); resolve(); }; diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index b6c8ad96ab2..19d12f9d660 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -4,9 +4,9 @@ import { createMockServerResponse } from "../../src/test-utils/mock-http-respons import plugin from "./index.js"; describe("diffs plugin registration", () => { - it("registers the tool, http handler, and prompt guidance hook", () => { + it("registers the tool, http route, and prompt guidance hook", () => { const registerTool = vi.fn(); - const registerHttpHandler = vi.fn(); + const registerHttpRoute = vi.fn(); const on = vi.fn(); plugin.register?.({ @@ -23,8 +23,7 @@ describe("diffs plugin registration", () => { }, registerTool, registerHook() {}, - registerHttpHandler, - registerHttpRoute() {}, + registerHttpRoute, registerChannel() {}, registerGatewayMethod() {}, registerCli() {}, @@ -38,7 +37,12 @@ describe("diffs plugin registration", () => { }); expect(registerTool).toHaveBeenCalledTimes(1); - expect(registerHttpHandler).toHaveBeenCalledTimes(1); + expect(registerHttpRoute).toHaveBeenCalledTimes(1); + expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({ + path: "/plugins/diffs", + auth: "plugin", + match: "prefix", + }); expect(on).toHaveBeenCalledTimes(1); expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build"); }); @@ -47,7 +51,7 @@ describe("diffs plugin registration", () => { let registeredTool: | { execute?: (toolCallId: string, params: Record) => Promise } | undefined; - let registeredHttpHandler: + let registeredHttpRouteHandler: | (( req: IncomingMessage, res: ReturnType, @@ -85,10 +89,9 @@ describe("diffs plugin registration", () => { registeredTool = typeof tool === "function" ? undefined : tool; }, registerHook() {}, - registerHttpHandler(handler) { - registeredHttpHandler = handler as typeof registeredHttpHandler; + registerHttpRoute(params) { + registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; }, - registerHttpRoute() {}, registerChannel() {}, registerGatewayMethod() {}, registerCli() {}, @@ -109,7 +112,7 @@ describe("diffs plugin registration", () => { (result as { details?: Record } | undefined)?.details?.viewerPath, ); const res = createMockServerResponse(); - const handled = await registeredHttpHandler?.( + const handled = await registeredHttpRouteHandler?.( localReq({ method: "GET", url: viewerPath, diff --git a/extensions/diffs/index.ts b/extensions/diffs/index.ts index a6879f8a512..bef57e83bd3 100644 --- a/extensions/diffs/index.ts +++ b/extensions/diffs/index.ts @@ -25,13 +25,16 @@ const plugin = { }); api.registerTool(createDiffsTool({ api, store, defaults })); - api.registerHttpHandler( - createDiffsHttpHandler({ + api.registerHttpRoute({ + path: "/plugins/diffs", + auth: "plugin", + match: "prefix", + handler: createDiffsHttpHandler({ store, logger: api.logger, allowRemoteViewer: security.allowRemoteViewer, }), - ); + }); api.on("before_prompt_build", async () => ({ prependContext: DIFFS_AGENT_GUIDANCE, })); diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index bef38ca9699..f623599f1dd 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -434,7 +434,6 @@ function createApi(): OpenClawPluginApi { }, registerTool() {}, registerHook() {}, - registerHttpHandler() {}, registerHttpRoute() {}, registerChannel() {}, registerGatewayMethod() {}, diff --git a/extensions/googlechat/index.ts b/extensions/googlechat/index.ts index 1ade57f1e71..c5acead0f61 100644 --- a/extensions/googlechat/index.ts +++ b/extensions/googlechat/index.ts @@ -1,7 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { googlechatDock, googlechatPlugin } from "./src/channel.js"; -import { handleGoogleChatWebhookRequest } from "./src/monitor.js"; import { setGoogleChatRuntime } from "./src/runtime.js"; const plugin = { @@ -12,7 +11,6 @@ const plugin = { register(api: OpenClawPluginApi) { setGoogleChatRuntime(api.runtime); api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock }); - api.registerHttpHandler(handleGoogleChatWebhookRequest); }, }; diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 35f7eb6325e..b43dba6dace 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -7,6 +7,7 @@ import { readJsonBodyWithLimit, registerPluginHttpRoute, registerWebhookTarget, + registerPluginHttpRoute, rejectNonPostWebhookRequest, isDangerousNameMatchingEnabled, resolveAllowlistProviderRuntimeGroupPolicy, @@ -969,7 +970,7 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () const audience = options.account.config.audience?.trim(); const mediaMaxMb = options.account.config.mediaMaxMb ?? 20; - const unregister = registerGoogleChatWebhookTarget({ + const unregisterTarget = registerGoogleChatWebhookTarget({ account: options.account, config: options.config, runtime: options.runtime, @@ -980,8 +981,20 @@ export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () statusSink: options.statusSink, mediaMaxMb, }); + const unregisterRoute = registerPluginHttpRoute({ + path: webhookPath, + auth: "plugin", + match: "exact", + pluginId: "googlechat", + accountId: options.account.accountId, + log: (message) => logVerbose(core, options.runtime, message), + handler: handleGoogleChatWebhookRequest, + }); - return unregister; + return () => { + unregisterTarget(); + unregisterRoute(); + }; } export async function startGoogleChatMonitor( diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index b01fc91d094..d318e2dda8e 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -38,7 +38,6 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi runtime: { version: "test" } as any, logger: { info() {}, warn() {}, error() {}, debug() {} }, registerTool() {}, - registerHttpHandler() {}, registerChannel() {}, registerGatewayMethod() {}, registerCli() {}, diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts index 0d0b15a68c6..de9c6e2276d 100644 --- a/extensions/nostr/index.ts +++ b/extensions/nostr/index.ts @@ -61,7 +61,12 @@ const plugin = { log: api.logger, }); - api.registerHttpHandler(httpHandler); + api.registerHttpRoute({ + path: "/api/channels/nostr", + auth: "gateway", + match: "prefix", + handler: httpHandler, + }); }, }; diff --git a/extensions/zalo/index.ts b/extensions/zalo/index.ts index 20e0ea83c8f..2b8f11b0b1d 100644 --- a/extensions/zalo/index.ts +++ b/extensions/zalo/index.ts @@ -1,7 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { zaloDock, zaloPlugin } from "./src/channel.js"; -import { handleZaloWebhookRequest } from "./src/monitor.js"; import { setZaloRuntime } from "./src/runtime.js"; const plugin = { @@ -12,7 +11,6 @@ const plugin = { register(api: OpenClawPluginApi) { setZaloRuntime(api.runtime); api.registerChannel({ plugin: zaloPlugin, dock: zaloDock }); - api.registerHttpHandler(handleZaloWebhookRequest); }, }; diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 0332286ac9c..72e6ffdba6f 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -653,7 +653,17 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< mediaMaxMb: effectiveMediaMaxMb, fetcher, }); + const unregisterRoute = registerPluginHttpRoute({ + path, + auth: "plugin", + match: "exact", + pluginId: "zalo", + accountId: account.accountId, + log: (message) => logVerbose(core, runtime, message), + handler: handleZaloWebhookRequest, + }); stopHandlers.push(unregister); + stopHandlers.push(unregisterRoute); abortSignal.addEventListener( "abort", () => { diff --git a/package.json b/package.json index 357b4ac2b7a..2d4dd5cd1dd 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", + "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:plugins:no-register-http-handler && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", @@ -102,6 +102,7 @@ "lint:docs": "pnpm dlx markdownlint-cli2", "lint:docs:fix": "pnpm dlx markdownlint-cli2 --fix", "lint:fix": "oxlint --type-aware --fix && pnpm format", + "lint:plugins:no-register-http-handler": "node scripts/check-no-register-http-handler.mjs", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index ca369375870..e33fa1162d7 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -70,7 +70,6 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => channels, providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [], diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 64947b3b34b..447a66ede84 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -48,13 +48,17 @@ import { import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; -import { hasSecurityPathCanonicalizationAnomaly } from "./security-path.js"; -import { isProtectedPluginRoutePath } from "./security-path.js"; import { authorizeCanvasRequest, enforcePluginRouteGatewayAuth, isCanvasPath, } from "./server/http-auth.js"; +import { + isProtectedPluginRoutePathFromContext, + resolvePluginRoutePathContext, + type PluginHttpRequestHandler, + type PluginRoutePathContext, +} from "./server/plugins-http.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -81,8 +85,12 @@ const GATEWAY_PROBE_STATUS_BY_PATH = new Map([ ["/readyz", "ready"], ]); -function shouldEnforceDefaultPluginGatewayAuth(pathname: string): boolean { - return hasSecurityPathCanonicalizationAnomaly(pathname) || isProtectedPluginRoutePath(pathname); +function shouldEnforceDefaultPluginGatewayAuth(pathContext: PluginRoutePathContext): boolean { + return ( + pathContext.malformedEncoding || + pathContext.decodePassLimitReached || + isProtectedPluginRoutePathFromContext(pathContext) + ); } function handleGatewayProbeRequest( @@ -391,8 +399,8 @@ export function createGatewayHttpServer(opts: { openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; strictTransportSecurityHeader?: string; handleHooksRequest: HooksRequestHandler; - handlePluginRequest?: HooksRequestHandler; - shouldEnforcePluginGatewayAuth?: (requestPath: string) => boolean; + handlePluginRequest?: PluginHttpRequestHandler; + shouldEnforcePluginGatewayAuth?: (pathContext: PluginRoutePathContext) => boolean; resolvedAuth: ResolvedGatewayAuth; /** Optional rate limiter for auth brute-force protection. */ rateLimiter?: AuthRateLimiter; @@ -445,7 +453,9 @@ export function createGatewayHttpServer(opts: { req.url = scopedCanvas.rewrittenUrl; } const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; - + const pluginPathContext = handlePluginRequest + ? resolvePluginRoutePathContext(requestPath) + : null; const requestStages: GatewayHttpRequestStage[] = [ { name: "hooks", @@ -466,7 +476,6 @@ export function createGatewayHttpServer(opts: { run: () => handleSlackHttpRequest(req, res), }, ]; - if (openResponsesEnabled) { requestStages.push({ name: "openresponses", @@ -550,9 +559,10 @@ export function createGatewayHttpServer(opts: { requestStages.push({ name: "plugin-auth", run: async () => { + const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath); if ( !(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)( - requestPath, + pathContext, ) ) { return false; @@ -573,7 +583,10 @@ export function createGatewayHttpServer(opts: { }); requestStages.push({ name: "plugin-http", - run: () => handlePluginRequest(req, res), + run: () => { + const pathContext = pluginPathContext ?? resolvePluginRoutePathContext(requestPath); + return handlePluginRequest(req, res, pathContext); + }, }); } diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7fb34ff5efc..4f2a4c84059 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -18,7 +18,6 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ commands: [], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [], diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 8e3dba6904a..100836def45 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -30,6 +30,7 @@ import { listenGatewayHttpServer } from "./server/http-listen.js"; import { createGatewayPluginRequestHandler, shouldEnforceGatewayAuthForPluginPath, + type PluginRoutePathContext, } from "./server/plugins-http.js"; import type { GatewayTlsRuntime } from "./server/tls.js"; import type { GatewayWsClient } from "./server/ws-types.js"; @@ -118,8 +119,8 @@ export async function createGatewayRuntimeState(params: { registry: params.pluginRegistry, log: params.logPlugins, }); - const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => { - return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, requestPath); + const shouldEnforcePluginGatewayAuth = (pathContext: PluginRoutePathContext): boolean => { + return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, pathContext); }; const bindHosts = await resolveGatewayListenHosts(params.bindHost); diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 544bb80d333..13474787fc6 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -145,8 +145,9 @@ describe("gateway plugin HTTP auth boundary", () => { resolvedAuth: AUTH_TOKEN, overrides: { handlePluginRequest, - shouldEnforcePluginGatewayAuth: (requestPath) => - isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public", + shouldEnforcePluginGatewayAuth: (pathContext) => + isProtectedPluginRoutePath(pathContext.pathname) || + pathContext.pathname === "/plugin/public", }, run: async (server) => { const unauthenticated = await sendRequest(server, { @@ -197,8 +198,9 @@ describe("gateway plugin HTTP auth boundary", () => { resolvedAuth: AUTH_TOKEN, overrides: { handlePluginRequest, - shouldEnforcePluginGatewayAuth: (requestPath) => - requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed", + shouldEnforcePluginGatewayAuth: (pathContext) => + pathContext.pathname.startsWith("/api/channels") || + pathContext.pathname === "/plugin/routed", }, run: async (server) => { const unauthenticatedRouted = await sendRequest(server, { path: "/plugin/routed" }); @@ -385,7 +387,8 @@ describe("gateway plugin HTTP auth boundary", () => { resolvedAuth: AUTH_TOKEN, overrides: { handlePluginRequest, - shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, + shouldEnforcePluginGatewayAuth: (pathContext) => + isProtectedPluginRoutePath(pathContext.pathname), }, run: async (server) => { await expectUnauthorizedVariants({ server, variants: CANONICAL_UNAUTH_VARIANTS }); @@ -409,7 +412,8 @@ describe("gateway plugin HTTP auth boundary", () => { resolvedAuth: AUTH_TOKEN, overrides: { handlePluginRequest, - shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath, + shouldEnforcePluginGatewayAuth: (pathContext) => + isProtectedPluginRoutePath(pathContext.pathname), }, run: async (server) => { for (const variant of buildChannelPathFuzzCorpus()) { diff --git a/src/gateway/server/__tests__/test-utils.ts b/src/gateway/server/__tests__/test-utils.ts index 6adf47d9fb9..478d5dda696 100644 --- a/src/gateway/server/__tests__/test-utils.ts +++ b/src/gateway/server/__tests__/test-utils.ts @@ -5,7 +5,6 @@ export const createTestRegistry = (overrides: Partial = {}): Plu return { ...merged, gatewayHandlers: merged.gatewayHandlers ?? {}, - httpHandlers: merged.httpHandlers ?? [], httpRoutes: merged.httpRoutes ?? [], }; }; diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 4fe07c05fd1..0610798a7df 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -17,11 +17,15 @@ function createPluginLog(): PluginHandlerLog { function createRoute(params: { path: string; pluginId?: string; - handler?: (req: IncomingMessage, res: ServerResponse) => void | Promise; + 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", }; @@ -36,7 +40,7 @@ function buildRepeatedEncodedSlash(depth: number): string { } describe("createGatewayPluginRequestHandler", () => { - it("returns false when no handlers are registered", async () => { + it("returns false when no routes are registered", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry(), @@ -47,35 +51,13 @@ describe("createGatewayPluginRequestHandler", () => { expect(handled).toBe(false); }); - it("continues until a handler reports it handled the request", async () => { - const first = vi.fn(async () => false); - const second = vi.fn(async () => true); - const handler = createGatewayPluginRequestHandler({ - registry: createTestRegistry({ - httpHandlers: [ - { pluginId: "first", handler: first, source: "first" }, - { pluginId: "second", handler: second, source: "second" }, - ], - }), - log: createPluginLog(), - }); - - const { res } = makeMockHttpResponse(); - const handled = await handler({} as IncomingMessage, res); - expect(handled).toBe(true); - expect(first).toHaveBeenCalledTimes(1); - expect(second).toHaveBeenCalledTimes(1); - }); - - it("handles registered http routes before generic handlers", async () => { + it("handles exact route matches", async () => { const routeHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; }); - const fallback = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [createRoute({ path: "/demo", handler: routeHandler })], - httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], }), log: createPluginLog(), }); @@ -84,18 +66,57 @@ describe("createGatewayPluginRequestHandler", () => { const handled = await handler({ url: "/demo" } as IncomingMessage, res); expect(handled).toBe(true); expect(routeHandler).toHaveBeenCalledTimes(1); - expect(fallback).not.toHaveBeenCalled(); }); - it("matches canonicalized route variants before generic handlers", async () => { + 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("matches canonicalized route variants", async () => { const routeHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; }); - const fallback = vi.fn(async () => true); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ httpRoutes: [createRoute({ path: "/api/demo", handler: routeHandler })], - httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }], }), log: createPluginLog(), }); @@ -104,28 +125,26 @@ describe("createGatewayPluginRequestHandler", () => { const handled = await handler({ url: "/API//demo" } as IncomingMessage, res); expect(handled).toBe(true); expect(routeHandler).toHaveBeenCalledTimes(1); - expect(fallback).not.toHaveBeenCalled(); }); - it("logs and responds with 500 when a handler throws", async () => { + it("logs and responds with 500 when a route throws", async () => { const log = createPluginLog(); const handler = createGatewayPluginRequestHandler({ registry: createTestRegistry({ - httpHandlers: [ - { - pluginId: "boom", + httpRoutes: [ + createRoute({ + path: "/boom", handler: async () => { throw new Error("boom"); }, - source: "boom", - }, + }), ], }), log, }); const { res, setHeader, end } = makeMockHttpResponse(); - const handled = await handler({} as IncomingMessage, res); + 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); @@ -134,7 +153,7 @@ describe("createGatewayPluginRequestHandler", () => { }); }); -describe("plugin HTTP registry helpers", () => { +describe("plugin HTTP route auth checks", () => { const deeplyEncodedChannelPath = "/api%2525252fchannels%2525252fnostr%2525252fdefault%2525252fprofile"; const decodeOverflowPublicPath = `/googlechat${buildRepeatedEncodedSlash(40)}public`; @@ -156,11 +175,15 @@ describe("plugin HTTP registry helpers", () => { expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true); }); - it("enforces auth for protected and registered plugin routes", () => { + it("enforces auth for protected and gateway-auth routes", () => { const registry = createTestRegistry({ - httpRoutes: [createRoute({ path: "/api/demo" })], + 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); diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index b1d21afa40a..c5f1f132f3b 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -1,31 +1,117 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { createSubsystemLogger } from "../../logging/subsystem.js"; import type { PluginRegistry } from "../../plugins/registry.js"; -import { canonicalizePathVariant } from "../security-path.js"; -import { hasSecurityPathCanonicalizationAnomaly } from "../security-path.js"; -import { isProtectedPluginRoutePath } from "../security-path.js"; +import { + PROTECTED_PLUGIN_ROUTE_PREFIXES, + canonicalizePathForSecurity, + canonicalizePathVariant, +} from "../security-path.js"; type SubsystemLogger = ReturnType; +export type PluginRoutePathContext = { + pathname: string; + canonicalPath: string; + candidates: string[]; + malformedEncoding: boolean; + decodePassLimitReached: boolean; + rawNormalizedPath: string; +}; + export type PluginHttpRequestHandler = ( req: IncomingMessage, res: ServerResponse, + pathContext?: PluginRoutePathContext, ) => Promise; type PluginHttpRouteEntry = NonNullable[number]; +function normalizeProtectedPrefix(prefix: string): string { + const collapsed = prefix.toLowerCase().replace(/\/{2,}/g, "/"); + if (collapsed.length <= 1) { + return collapsed || "/"; + } + return collapsed.replace(/\/+$/, ""); +} + +function prefixMatch(pathname: string, prefix: string): boolean { + return ( + pathname === prefix || pathname.startsWith(`${prefix}/`) || pathname.startsWith(`${prefix}%`) + ); +} + +const NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES = + PROTECTED_PLUGIN_ROUTE_PREFIXES.map(normalizeProtectedPrefix); + +export function isProtectedPluginRoutePathFromContext(context: PluginRoutePathContext): boolean { + if ( + context.candidates.some((candidate) => + NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => prefixMatch(candidate, prefix)), + ) + ) { + return true; + } + if (!context.malformedEncoding) { + return false; + } + return NORMALIZED_PROTECTED_PLUGIN_ROUTE_PREFIXES.some((prefix) => + prefixMatch(context.rawNormalizedPath, prefix), + ); +} + +export function resolvePluginRoutePathContext(pathname: string): PluginRoutePathContext { + const canonical = canonicalizePathForSecurity(pathname); + return { + pathname, + canonicalPath: canonical.canonicalPath, + candidates: canonical.candidates, + malformedEncoding: canonical.malformedEncoding, + decodePassLimitReached: canonical.decodePassLimitReached, + rawNormalizedPath: canonical.rawNormalizedPath, + }; +} + +function doesRouteMatchPath(route: PluginHttpRouteEntry, context: PluginRoutePathContext): boolean { + const routeCanonicalPath = canonicalizePathVariant(route.path); + if (route.match === "prefix") { + return context.candidates.some((candidate) => prefixMatch(candidate, routeCanonicalPath)); + } + return context.candidates.some((candidate) => candidate === routeCanonicalPath); +} + +function findMatchingPluginHttpRoutes( + registry: PluginRegistry, + context: PluginRoutePathContext, +): PluginHttpRouteEntry[] { + const routes = registry.httpRoutes ?? []; + if (routes.length === 0) { + return []; + } + const exactMatches: PluginHttpRouteEntry[] = []; + const prefixMatches: PluginHttpRouteEntry[] = []; + for (const route of routes) { + if (!doesRouteMatchPath(route, context)) { + continue; + } + if (route.match === "prefix") { + prefixMatches.push(route); + } else { + exactMatches.push(route); + } + } + exactMatches.sort((a, b) => b.path.length - a.path.length); + prefixMatches.sort((a, b) => b.path.length - a.path.length); + return [...exactMatches, ...prefixMatches]; +} + export function findRegisteredPluginHttpRoute( registry: PluginRegistry, pathname: string, ): PluginHttpRouteEntry | undefined { - const canonicalPath = canonicalizePathVariant(pathname); - const routes = registry.httpRoutes ?? []; - return routes.find((entry) => canonicalizePathVariant(entry.path) === canonicalPath); + const pathContext = resolvePluginRoutePathContext(pathname); + return findMatchingPluginHttpRoutes(registry, pathContext)[0]; } -// Only checks specific routes registered via registerHttpRoute, not wildcard handlers -// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement -// their own signature-based auth and are handled separately in the auth enforcement logic. export function isRegisteredPluginHttpRoutePath( registry: PluginRegistry, pathname: string, @@ -35,13 +121,23 @@ export function isRegisteredPluginHttpRoutePath( export function shouldEnforceGatewayAuthForPluginPath( registry: PluginRegistry, - pathname: string, + pathnameOrContext: string | PluginRoutePathContext, ): boolean { - return ( - hasSecurityPathCanonicalizationAnomaly(pathname) || - isProtectedPluginRoutePath(pathname) || - isRegisteredPluginHttpRoutePath(registry, pathname) - ); + const pathContext = + typeof pathnameOrContext === "string" + ? resolvePluginRoutePathContext(pathnameOrContext) + : pathnameOrContext; + if (pathContext.malformedEncoding || pathContext.decodePassLimitReached) { + return true; + } + if (isProtectedPluginRoutePathFromContext(pathContext)) { + return true; + } + const route = findMatchingPluginHttpRoutes(registry, pathContext)[0]; + if (!route) { + return false; + } + return route.auth === "gateway"; } export function createGatewayPluginRequestHandler(params: { @@ -49,40 +145,31 @@ export function createGatewayPluginRequestHandler(params: { log: SubsystemLogger; }): PluginHttpRequestHandler { const { registry, log } = params; - return async (req, res) => { + return async (req, res, providedPathContext) => { const routes = registry.httpRoutes ?? []; - const handlers = registry.httpHandlers ?? []; - if (routes.length === 0 && handlers.length === 0) { + if (routes.length === 0) { return false; } - if (routes.length > 0) { - const url = new URL(req.url ?? "/", "http://localhost"); - const route = findRegisteredPluginHttpRoute(registry, url.pathname); - if (route) { - try { - await route.handler(req, res); - return true; - } catch (err) { - log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`); - if (!res.headersSent) { - res.statusCode = 500; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Internal Server Error"); - } - return true; - } - } + const pathContext = + providedPathContext ?? + (() => { + const url = new URL(req.url ?? "/", "http://localhost"); + return resolvePluginRoutePathContext(url.pathname); + })(); + const matchedRoutes = findMatchingPluginHttpRoutes(registry, pathContext); + if (matchedRoutes.length === 0) { + return false; } - for (const entry of handlers) { + for (const route of matchedRoutes) { try { - const handled = await entry.handler(req, res); - if (handled) { + const handled = await route.handler(req, res); + if (handled !== false) { return true; } } catch (err) { - log.warn(`plugin http handler failed (${entry.pluginId}): ${String(err)}`); + log.warn(`plugin http route failed (${route.pluginId ?? "unknown"}): ${String(err)}`); if (!res.headersSent) { res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 19c6d2e91a4..d41cdd56397 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -146,7 +146,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({ ], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [], diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index d1600aca136..e0d7c6b6f58 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -13,7 +13,6 @@ export function createMockPluginRegistry( source: "test", })), tools: [], - httpHandlers: [], httpRoutes: [], channelRegistrations: [], gatewayHandlers: {}, diff --git a/src/plugins/http-registry.test.ts b/src/plugins/http-registry.test.ts index fca12e4dc11..12f8277d0d4 100644 --- a/src/plugins/http-registry.test.ts +++ b/src/plugins/http-registry.test.ts @@ -16,6 +16,8 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(1); expect(registry.httpRoutes[0]?.path).toBe("/plugins/demo"); expect(registry.httpRoutes[0]?.handler).toBe(handler); + expect(registry.httpRoutes[0]?.auth).toBe("gateway"); + expect(registry.httpRoutes[0]?.match).toBe("exact"); unregister(); expect(registry.httpRoutes).toHaveLength(0); @@ -64,7 +66,7 @@ describe("registerPluginHttpRoute", () => { expect(registry.httpRoutes).toHaveLength(1); expect(registry.httpRoutes[0]?.handler).toBe(secondHandler); expect(logs).toContain( - 'plugin: replacing stale webhook path /plugins/synology for account "default" (synology-chat)', + 'plugin: replacing stale webhook path /plugins/synology (exact) for account "default" (synology-chat)', ); // Old unregister must not remove the replacement route. diff --git a/src/plugins/http-registry.ts b/src/plugins/http-registry.ts index 5987fd17370..9e7303424d9 100644 --- a/src/plugins/http-registry.ts +++ b/src/plugins/http-registry.ts @@ -6,12 +6,14 @@ import { requireActivePluginRegistry } from "./runtime.js"; export type PluginHttpRouteHandler = ( req: IncomingMessage, res: ServerResponse, -) => Promise | void; +) => Promise | boolean | void; export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; + auth?: PluginHttpRouteRegistration["auth"]; + match?: PluginHttpRouteRegistration["match"]; pluginId?: string; source?: string; accountId?: string; @@ -29,16 +31,23 @@ export function registerPluginHttpRoute(params: { return () => {}; } - const existingIndex = routes.findIndex((entry) => entry.path === normalizedPath); + const routeMatch = params.match ?? "exact"; + const existingIndex = routes.findIndex( + (entry) => entry.path === normalizedPath && entry.match === routeMatch, + ); if (existingIndex >= 0) { const pluginHint = params.pluginId ? ` (${params.pluginId})` : ""; - params.log?.(`plugin: replacing stale webhook path ${normalizedPath}${suffix}${pluginHint}`); + params.log?.( + `plugin: replacing stale webhook path ${normalizedPath} (${routeMatch})${suffix}${pluginHint}`, + ); routes.splice(existingIndex, 1); } const entry: PluginHttpRouteRegistration = { path: normalizedPath, handler: params.handler, + auth: params.auth ?? "gateway", + match: routeMatch, pluginId: params.pluginId, source: params.source, }; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 9f73087be97..a7881376f04 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -518,13 +518,18 @@ describe("loadOpenClawPlugins", () => { expect(channel).toBeDefined(); }); - it("registers http handlers", () => { + it("registers http routes with auth and match options", () => { useNoBundledPlugins(); const plugin = writePlugin({ id: "http-demo", filename: "http-demo.cjs", body: `module.exports = { id: "http-demo", register(api) { - api.registerHttpHandler(async () => false); + api.registerHttpRoute({ + path: "/webhook", + auth: "plugin", + match: "prefix", + handler: async () => false + }); } };`, }); @@ -535,10 +540,13 @@ describe("loadOpenClawPlugins", () => { }, }); - const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo"); - expect(handler).toBeDefined(); + const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo"); + expect(route).toBeDefined(); + expect(route?.path).toBe("/webhook"); + expect(route?.auth).toBe("plugin"); + expect(route?.match).toBe("prefix"); const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo"); - expect(httpPlugin?.httpHandlers).toBe(1); + expect(httpPlugin?.httpRoutes).toBe(1); }); it("registers http routes", () => { @@ -561,8 +569,10 @@ describe("loadOpenClawPlugins", () => { const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo"); expect(route).toBeDefined(); expect(route?.path).toBe("/demo"); + expect(route?.auth).toBe("gateway"); + expect(route?.match).toBe("exact"); const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo"); - expect(httpPlugin?.httpHandlers).toBe(1); + expect(httpPlugin?.httpRoutes).toBe(1); }); it("respects explicit disable in config", () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2a166a8638b..a52fdff9c3a 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -176,7 +176,7 @@ function createPluginRecord(params: { cliCommands: [], services: [], commands: [], - httpHandlers: 0, + httpRoutes: 0, hookCount: 0, configSchema: params.configSchema, configUiHints: undefined, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index cf709c5713d..bc94a47de73 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -17,8 +17,10 @@ import type { OpenClawPluginChannelRegistration, OpenClawPluginCliRegistrar, OpenClawPluginCommandDefinition, - OpenClawPluginHttpHandler, + OpenClawPluginHttpRouteAuth, + OpenClawPluginHttpRouteMatch, OpenClawPluginHttpRouteHandler, + OpenClawPluginHttpRouteParams, OpenClawPluginHookOptions, ProviderPlugin, OpenClawPluginService, @@ -49,16 +51,12 @@ export type PluginCliRegistration = { source: string; }; -export type PluginHttpRegistration = { - pluginId: string; - handler: OpenClawPluginHttpHandler; - source: string; -}; - export type PluginHttpRouteRegistration = { pluginId?: string; path: string; handler: OpenClawPluginHttpRouteHandler; + auth: OpenClawPluginHttpRouteAuth; + match: OpenClawPluginHttpRouteMatch; source?: string; }; @@ -114,7 +112,7 @@ export type PluginRecord = { cliCommands: string[]; services: string[]; commands: string[]; - httpHandlers: number; + httpRoutes: number; hookCount: number; configSchema: boolean; configUiHints?: Record; @@ -129,7 +127,6 @@ export type PluginRegistry = { channels: PluginChannelRegistration[]; providers: PluginProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; - httpHandlers: PluginHttpRegistration[]; httpRoutes: PluginHttpRouteRegistration[]; cliRegistrars: PluginCliRegistration[]; services: PluginServiceRegistration[]; @@ -152,7 +149,6 @@ export function createEmptyPluginRegistry(): PluginRegistry { channels: [], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [], @@ -288,19 +284,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { record.gatewayMethods.push(trimmed); }; - const registerHttpHandler = (record: PluginRecord, handler: OpenClawPluginHttpHandler) => { - record.httpHandlers += 1; - registry.httpHandlers.push({ - pluginId: record.id, - handler, - source: record.source, - }); - }; - - const registerHttpRoute = ( - record: PluginRecord, - params: { path: string; handler: OpenClawPluginHttpRouteHandler }, - ) => { + const registerHttpRoute = (record: PluginRecord, params: OpenClawPluginHttpRouteParams) => { const normalizedPath = normalizePluginHttpPath(params.path); if (!normalizedPath) { pushDiagnostic({ @@ -311,20 +295,25 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) { + const match = params.match ?? "exact"; + if ( + registry.httpRoutes.some((entry) => entry.path === normalizedPath && entry.match === match) + ) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `http route already registered: ${normalizedPath}`, + message: `http route already registered: ${normalizedPath} (${match})`, }); return; } - record.httpHandlers += 1; + record.httpRoutes += 1; registry.httpRoutes.push({ pluginId: record.id, path: normalizedPath, handler: params.handler, + auth: params.auth ?? "gateway", + match, source: record.source, }); }; @@ -489,7 +478,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool: (tool, opts) => registerTool(record, tool, opts), registerHook: (events, handler, opts) => registerHook(record, events, handler, opts, params.config), - registerHttpHandler: (handler) => registerHttpHandler(record, handler), registerHttpRoute: (params) => registerHttpRoute(record, params), registerChannel: (registration) => registerChannel(record, registration), registerProvider: (provider) => registerProvider(record, provider), diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 7589c785c70..1c120acf314 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -194,15 +194,20 @@ export type OpenClawPluginCommandDefinition = { handler: PluginCommandHandler; }; -export type OpenClawPluginHttpHandler = ( - req: IncomingMessage, - res: ServerResponse, -) => Promise | boolean; +export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin"; +export type OpenClawPluginHttpRouteMatch = "exact" | "prefix"; export type OpenClawPluginHttpRouteHandler = ( req: IncomingMessage, res: ServerResponse, -) => Promise | void; +) => Promise | boolean | void; + +export type OpenClawPluginHttpRouteParams = { + path: string; + handler: OpenClawPluginHttpRouteHandler; + auth?: OpenClawPluginHttpRouteAuth; + match?: OpenClawPluginHttpRouteMatch; +}; export type OpenClawPluginCliContext = { program: Command; @@ -265,8 +270,7 @@ export type OpenClawPluginApi = { handler: InternalHookHandler, opts?: OpenClawPluginHookOptions, ) => void; - registerHttpHandler: (handler: OpenClawPluginHttpHandler) => void; - registerHttpRoute: (params: { path: string; handler: OpenClawPluginHttpRouteHandler }) => void; + registerHttpRoute: (params: OpenClawPluginHttpRouteParams) => void; registerChannel: (registration: OpenClawPluginChannelRegistration | ChannelPlugin) => void; registerGatewayMethod: (method: string, handler: GatewayRequestHandler) => void; registerCli: (registrar: OpenClawPluginCliRegistrar, opts?: { commands?: string[] }) => void; diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 64e24deab52..38f850ab2a5 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -20,7 +20,6 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl channels: channels as unknown as PluginRegistry["channels"], providers: [], gatewayHandlers: {}, - httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [],