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(); }, }); });