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