From d070c44091ff82de9835585c0f575bdeef5b59e5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 6 Mar 2026 12:13:20 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + src/gateway/control-ui-routing.ts | 7 +++ src/gateway/server.plugin-http-auth.test.ts | 52 +++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a063b910640..376a792e50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 77bc9f24a0d..f4c24ddf7f5 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -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/")) { diff --git a/src/gateway/server.plugin-http-auth.test.ts b/src/gateway/server.plugin-http-auth.test.ts index 3c5afceaa35..f58acb0dfe5 100644 --- a/src/gateway/server.plugin-http-auth.test.ts +++ b/src/gateway/server.plugin-http-auth.test.ts @@ -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();