fix(gateway): keep probe routes reachable with root-mounted control ui (#38199)

* fix(gateway): keep probe routes reachable with root-mounted control ui

* Changelog: add root-mounted probe precedence fix entry

* Update CHANGELOG.md
This commit is contained in:
Vincent Koc 2026-03-06 12:13:20 -05:00 committed by GitHub
parent 4ed5febc38
commit d070c44091
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 60 additions and 0 deletions

View File

@ -205,6 +205,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp media upload caps: make outbound media sends and auto-replies honor `channels.whatsapp.mediaMaxMb` with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc.
- Windows/Plugin install: when OpenClaw runs on Windows via Bun and `npm-cli.js` is not colocated with the runtime binary, fall back to `npm.cmd`/`npx.cmd` through the existing `cmd.exe` wrapper so `openclaw plugins install` no longer fails with `spawn EINVAL`. (#38056) Thanks @0xlin2023.
- Telegram/send retry classification: retry grammY `Network request ... failed after N attempts` envelopes in send flows without reclassifying plain `Network request ... failed!` wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023.
- Gateway/probe route precedence: keep `/health`, `/healthz`, `/ready`, and `/readyz` reachable when the Control UI is mounted at `/`, so root-mounted SPA fallbacks no longer swallow machine probe routes while plugin-owned routes on those paths still keep precedence. (#18446) Thanks @vibecodooor and @vincentkoc.
## 2026.3.2

View File

@ -6,6 +6,8 @@ export type ControlUiRequestClassification =
| { kind: "redirect"; location: string }
| { kind: "serve" };
const ROOT_MOUNTED_GATEWAY_PROBE_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz"]);
export function classifyControlUiRequest(params: {
basePath: string;
pathname: string;
@ -17,6 +19,11 @@ export function classifyControlUiRequest(params: {
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
return { kind: "not-found" };
}
// Keep core probe routes outside the root-mounted SPA catch-all so the
// gateway probe handler can answer them even when the Control UI owns `/`.
if (ROOT_MOUNTED_GATEWAY_PROBE_PATHS.has(pathname)) {
return { kind: "not-control-ui" };
}
// Keep plugin-owned HTTP routes outside the root-mounted Control UI SPA
// fallback so untrusted plugins cannot claim arbitrary UI paths.
if (pathname === "/plugins" || pathname.startsWith("/plugins/")) {

View File

@ -494,6 +494,58 @@ describe("gateway plugin HTTP auth boundary", () => {
});
});
test("root-mounted control ui does not swallow gateway probe routes", async () => {
const handlePluginRequest = vi.fn(async () => false);
await withRootMountedControlUiServer({
prefix: "openclaw-plugin-http-control-ui-probes-test-",
handlePluginRequest,
run: async (server) => {
const probeCases = [
{ path: "/health", status: "live" },
{ path: "/healthz", status: "live" },
{ path: "/ready", status: "ready" },
{ path: "/readyz", status: "ready" },
] as const;
for (const probeCase of probeCases) {
const response = await sendRequest(server, { path: probeCase.path });
expect(response.res.statusCode, probeCase.path).toBe(200);
expect(response.getBody(), probeCase.path).toBe(
JSON.stringify({ ok: true, status: probeCase.status }),
);
}
expect(handlePluginRequest).toHaveBeenCalledTimes(probeCases.length);
},
});
});
test("root-mounted control ui still lets plugins claim probe paths first", async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname !== "/healthz") {
return false;
}
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "plugin-health" }));
return true;
});
await withRootMountedControlUiServer({
prefix: "openclaw-plugin-http-control-ui-probe-shadow-test-",
handlePluginRequest,
run: async (server) => {
const response = await sendRequest(server, { path: "/healthz" });
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" }));
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
},
});
});
test("requires gateway auth for canonicalized /api/channels variants", async () => {
const handlePluginRequest = createCanonicalizedChannelPluginHandler();