Skip to content

Commit d070c44

Browse files
authored
fix(gateway): keep probe routes reachable with root-mounted control ui (openclaw#38199)
* fix(gateway): keep probe routes reachable with root-mounted control ui * Changelog: add root-mounted probe precedence fix entry * Update CHANGELOG.md
1 parent 4ed5feb commit d070c44

File tree

3 files changed

+60
-0
lines changed

3 files changed

+60
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ Docs: https://docs.openclaw.ai
205205
- 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.
206206
- 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.
207207
- 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.
208+
- 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.
208209

209210
## 2026.3.2
210211

src/gateway/control-ui-routing.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export type ControlUiRequestClassification =
66
| { kind: "redirect"; location: string }
77
| { kind: "serve" };
88

9+
const ROOT_MOUNTED_GATEWAY_PROBE_PATHS = new Set(["/health", "/healthz", "/ready", "/readyz"]);
10+
911
export function classifyControlUiRequest(params: {
1012
basePath: string;
1113
pathname: string;
@@ -17,6 +19,11 @@ export function classifyControlUiRequest(params: {
1719
if (pathname === "/ui" || pathname.startsWith("/ui/")) {
1820
return { kind: "not-found" };
1921
}
22+
// Keep core probe routes outside the root-mounted SPA catch-all so the
23+
// gateway probe handler can answer them even when the Control UI owns `/`.
24+
if (ROOT_MOUNTED_GATEWAY_PROBE_PATHS.has(pathname)) {
25+
return { kind: "not-control-ui" };
26+
}
2027
// Keep plugin-owned HTTP routes outside the root-mounted Control UI SPA
2128
// fallback so untrusted plugins cannot claim arbitrary UI paths.
2229
if (pathname === "/plugins" || pathname.startsWith("/plugins/")) {

src/gateway/server.plugin-http-auth.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,58 @@ describe("gateway plugin HTTP auth boundary", () => {
494494
});
495495
});
496496

497+
test("root-mounted control ui does not swallow gateway probe routes", async () => {
498+
const handlePluginRequest = vi.fn(async () => false);
499+
500+
await withRootMountedControlUiServer({
501+
prefix: "openclaw-plugin-http-control-ui-probes-test-",
502+
handlePluginRequest,
503+
run: async (server) => {
504+
const probeCases = [
505+
{ path: "/health", status: "live" },
506+
{ path: "/healthz", status: "live" },
507+
{ path: "/ready", status: "ready" },
508+
{ path: "/readyz", status: "ready" },
509+
] as const;
510+
511+
for (const probeCase of probeCases) {
512+
const response = await sendRequest(server, { path: probeCase.path });
513+
expect(response.res.statusCode, probeCase.path).toBe(200);
514+
expect(response.getBody(), probeCase.path).toBe(
515+
JSON.stringify({ ok: true, status: probeCase.status }),
516+
);
517+
}
518+
519+
expect(handlePluginRequest).toHaveBeenCalledTimes(probeCases.length);
520+
},
521+
});
522+
});
523+
524+
test("root-mounted control ui still lets plugins claim probe paths first", async () => {
525+
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
526+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
527+
if (pathname !== "/healthz") {
528+
return false;
529+
}
530+
res.statusCode = 200;
531+
res.setHeader("Content-Type", "application/json; charset=utf-8");
532+
res.end(JSON.stringify({ ok: true, route: "plugin-health" }));
533+
return true;
534+
});
535+
536+
await withRootMountedControlUiServer({
537+
prefix: "openclaw-plugin-http-control-ui-probe-shadow-test-",
538+
handlePluginRequest,
539+
run: async (server) => {
540+
const response = await sendRequest(server, { path: "/healthz" });
541+
542+
expect(response.res.statusCode).toBe(200);
543+
expect(response.getBody()).toBe(JSON.stringify({ ok: true, route: "plugin-health" }));
544+
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
545+
},
546+
});
547+
});
548+
497549
test("requires gateway auth for canonicalized /api/channels variants", async () => {
498550
const handlePluginRequest = createCanonicalizedChannelPluginHandler();
499551

0 commit comments

Comments
 (0)