fix(gateway): move plugin HTTP routes before Control UI SPA catch-all (#31885)

* 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 <steipete@gmail.com>
This commit is contained in:
Sid
2026-03-03 02:16:14 +08:00
committed by GitHub
parent cf5702233c
commit 41c8734afd
3 changed files with 45 additions and 20 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### 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. - 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. - 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. - 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. - 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.

View File

@@ -587,6 +587,24 @@ export function createGatewayHttpServer(opts: {
run: () => canvasHost.handleHttpRequest(req, res), 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) { if (controlUiEnabled) {
requestStages.push({ requestStages.push({
name: "control-ui-avatar", 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({ requestStages.push({
name: "gateway-probes", 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 handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname; const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/chat") { if (pathname === "/my-plugin/inbound") {
res.statusCode = 200; res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("plugin-shadow"); res.end("plugin-handled");
return true; return true;
} }
return false; return false;
@@ -369,12 +369,34 @@ describe("gateway plugin HTTP auth boundary", () => {
controlUiRoot: { kind: "missing" }, controlUiRoot: { kind: "missing" },
handlePluginRequest, 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) => { run: async (server) => {
const response = await sendRequest(server, { path: "/chat" }); const response = await sendRequest(server, { path: "/chat" });
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
expect(response.res.statusCode).toBe(503); expect(response.res.statusCode).toBe(503);
expect(response.getBody()).toContain("Control UI assets not found"); expect(response.getBody()).toContain("Control UI assets not found");
expect(handlePluginRequest).not.toHaveBeenCalled();
}, },
}); });
}); });