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
This commit is contained in:
SidQin-cyber
2026-03-03 00:08:41 +08:00
committed by Peter Steinberger
parent 718d418b32
commit 0e469f1257
2 changed files with 44 additions and 20 deletions

View File

@@ -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",

View File

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