From 41c8734afdb4c9b55187d6f8484a0515e3262b9b Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 3 Mar 2026 02:16:14 +0800 Subject: [PATCH] fix(gateway): move plugin HTTP routes before Control UI SPA catch-all (#31885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): move plugin HTTP routes before Control UI SPA catch-all 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 * fix: add changelog for plugin route precedence landing (#31885) (thanks @Sid-Qin) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + src/gateway/server-http.ts | 34 +++++++++++---------- src/gateway/server.plugin-http-auth.test.ts | 30 +++++++++++++++--- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c82d27c5e8f..0618093540f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai. +- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin. - Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting. - Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting. - Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting. 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(); }, }); });