From 3e9c8721fb7fea9d890774d4141b3f6faaec438d Mon Sep 17 00:00:00 2001 From: ademczuk Date: Tue, 3 Mar 2026 00:51:23 +0100 Subject: [PATCH] fix(gateway): let non-GET requests fall through controlUi routing when basePath is set When controlUiBasePath is set, classifyControlUiRequest returned method-not-allowed (405) for all non-GET/HEAD requests under basePath, blocking plugin webhook handlers (BlueBubbles, Mattermost, etc.) from receiving POST requests. This is a 2026.3.1 regression. Return not-control-ui instead, matching the empty-basePath behavior, so requests fall through to plugin HTTP handlers. Remove the now-dead method-not-allowed type variant, handler branch, and utility function. Closes #31983 Closes #32275 Co-Authored-By: Claude Opus 4.6 --- src/gateway/control-ui-http-utils.ts | 4 ---- src/gateway/control-ui-routing.test.ts | 16 ++++++++++++++-- src/gateway/control-ui-routing.ts | 3 +-- src/gateway/control-ui.http.test.ts | 9 ++++----- src/gateway/control-ui.ts | 5 ----- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/gateway/control-ui-http-utils.ts b/src/gateway/control-ui-http-utils.ts index d88cd32fe40..b670d413dec 100644 --- a/src/gateway/control-ui-http-utils.ts +++ b/src/gateway/control-ui-http-utils.ts @@ -13,7 +13,3 @@ export function respondPlainText(res: ServerResponse, statusCode: number, body: export function respondNotFound(res: ServerResponse): void { respondPlainText(res, 404, "Not Found"); } - -export function respondMethodNotAllowed(res: ServerResponse): void { - respondPlainText(res, 405, "Method Not Allowed"); -} diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index 73710f1a822..f3f172cc7d4 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -22,14 +22,26 @@ describe("classifyControlUiRequest", () => { expect(classified).toEqual({ kind: "not-found" }); }); - it("returns method-not-allowed for basePath non-read methods", () => { + it("falls through basePath non-read methods for plugin webhooks", () => { const classified = classifyControlUiRequest({ basePath: "/openclaw", pathname: "/openclaw", search: "", method: "POST", }); - expect(classified).toEqual({ kind: "method-not-allowed" }); + expect(classified).toEqual({ kind: "not-control-ui" }); + }); + + it("falls through PUT/DELETE/PATCH/OPTIONS under basePath for plugin handlers", () => { + for (const method of ["PUT", "DELETE", "PATCH", "OPTIONS"]) { + const classified = classifyControlUiRequest({ + basePath: "/openclaw", + pathname: "/openclaw/webhook", + search: "", + method, + }); + expect(classified, `${method} should fall through`).toEqual({ kind: "not-control-ui" }); + } }); it("returns redirect for basePath entrypoint GET", () => { diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 44635e92e1d..77bc9f24a0d 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -3,7 +3,6 @@ import { isReadHttpMethod } from "./control-ui-http-utils.js"; export type ControlUiRequestClassification = | { kind: "not-control-ui" } | { kind: "not-found" } - | { kind: "method-not-allowed" } | { kind: "redirect"; location: string } | { kind: "serve" }; @@ -36,7 +35,7 @@ export function classifyControlUiRequest(params: { return { kind: "not-control-ui" }; } if (!isReadHttpMethod(method)) { - return { kind: "method-not-allowed" }; + return { kind: "not-control-ui" }; } if (pathname === basePath) { return { kind: "redirect", location: `${basePath}/${search}` }; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index d0d5adec41c..4810d987a5f 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -402,19 +402,18 @@ describe("handleControlUiHttpRequest", () => { }); }); - it("returns 405 for POST requests under configured basePath", async () => { + it("falls through POST requests under configured basePath (plugin webhook passthrough)", async () => { await withControlUiRoot({ fn: async (tmp) => { for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) { - const { handled, res, end } = runControlUiRequest({ + const { handled, end } = runControlUiRequest({ url: route, method: "POST", rootPath: tmp, basePath: "/openclaw", }); - expect(handled, `expected ${route} to be handled`).toBe(true); - expect(res.statusCode, `expected ${route} status`).toBe(405); - expect(end, `expected ${route} body`).toHaveBeenCalledWith("Method Not Allowed"); + expect(handled, `POST to ${route} should pass through to plugin handlers`).toBe(false); + expect(end, `POST to ${route} should not write a response`).not.toHaveBeenCalled(); } }, }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index fc1ad4633ec..73d727f15a5 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -15,7 +15,6 @@ import { import { buildControlUiCspHeader } from "./control-ui-csp.js"; import { isReadHttpMethod, - respondMethodNotAllowed, respondNotFound as respondControlUiNotFound, respondPlainText, } from "./control-ui-http-utils.js"; @@ -293,10 +292,6 @@ export function handleControlUiHttpRequest( respondControlUiNotFound(res); return true; } - if (route.kind === "method-not-allowed") { - respondMethodNotAllowed(res); - return true; - } if (route.kind === "redirect") { applyControlUiSecurityHeaders(res); res.statusCode = 302;