From 0e469f12573d4b01408d7fe25b8d3791fcae855e Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Tue, 3 Mar 2026 00:08:41 +0800 Subject: [PATCH] fix(gateway): move plugin HTTP routes before Control UI SPA catch-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Control UI handler (`handleControlUiHttpRequest`) acts as an SPA catch-all that matches every path, returning HTML for GET requests and 405 for other methods. Because it ran before `handlePluginRequest` in the request chain, any plugin HTTP route that did not live under `/plugins` or `/api` was unreachable — shadowed by the catch-all. Reorder the handlers so plugin routes are evaluated first. Core built-in routes (hooks, tools, Slack, Canvas, etc.) still take precedence because they are checked even earlier in the chain. Unmatched plugin paths continue to fall through to Control UI as before. Closes #31766 --- src/gateway/server-http.ts | 34 +++++++++++---------- src/gateway/server.plugin-http-auth.test.ts | 30 +++++++++++++++--- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 5e493544f27..f16bf6d8a51 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -587,6 +587,24 @@ export function createGatewayHttpServer(opts: { run: () => canvasHost.handleHttpRequest(req, res), }); } + // Plugin routes run before the Control UI SPA catch-all so explicitly + // registered plugin endpoints stay reachable. Core built-in gateway + // routes above still keep precedence on overlapping paths. + requestStages.push( + ...buildPluginRequestStages({ + req, + res, + requestPath, + pluginPathContext, + handlePluginRequest, + shouldEnforcePluginGatewayAuth, + resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }), + ); + if (controlUiEnabled) { requestStages.push({ name: "control-ui-avatar", @@ -606,22 +624,6 @@ export function createGatewayHttpServer(opts: { }), }); } - // Plugins run after built-in gateway routes so core surfaces keep - // precedence on overlapping paths. - requestStages.push( - ...buildPluginRequestStages({ - req, - res, - requestPath, - pluginPathContext, - handlePluginRequest, - shouldEnforcePluginGatewayAuth, - resolvedAuth, - trustedProxies, - allowRealIpFallback, - rateLimiter, - }), - ); requestStages.push({ name: "gateway-probes", diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index fdaabc9b7bb..71bd89ad42f 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -348,13 +348,13 @@ describe("gateway plugin HTTP auth boundary", () => { }); }); - test("does not let plugin handlers shadow control ui routes", async () => { + test("plugin routes take priority over control ui catch-all", async () => { const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => { const pathname = new URL(req.url ?? "/", "http://localhost").pathname; - if (pathname === "/chat") { + if (pathname === "/my-plugin/inbound") { res.statusCode = 200; res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("plugin-shadow"); + res.end("plugin-handled"); return true; } return false; @@ -369,12 +369,34 @@ describe("gateway plugin HTTP auth boundary", () => { controlUiRoot: { kind: "missing" }, handlePluginRequest, }, + run: async (server) => { + const response = await sendRequest(server, { path: "/my-plugin/inbound" }); + + expect(response.res.statusCode).toBe(200); + expect(response.getBody()).toContain("plugin-handled"); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); + }, + }); + }); + + test("unmatched plugin paths fall through to control ui", async () => { + const handlePluginRequest = vi.fn(async () => false); + + await withGatewayServer({ + prefix: "openclaw-plugin-http-control-ui-fallthrough-test-", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: "", + controlUiRoot: { kind: "missing" }, + handlePluginRequest, + }, run: async (server) => { const response = await sendRequest(server, { path: "/chat" }); + expect(handlePluginRequest).toHaveBeenCalledTimes(1); expect(response.res.statusCode).toBe(503); expect(response.getBody()).toContain("Control UI assets not found"); - expect(handlePluginRequest).not.toHaveBeenCalled(); }, }); });