diff --git a/src/gateway/control-ui-http-utils.ts b/src/gateway/control-ui-http-utils.ts index cff5ea1b4f5f..74deeee39411 100644 --- a/src/gateway/control-ui-http-utils.ts +++ b/src/gateway/control-ui-http-utils.ts @@ -9,6 +9,27 @@ export function isReadHttpMethod(method: string | undefined): boolean { return method === "GET" || method === "HEAD"; } +/** Returns whether an Accept header permits an HTML document response. */ +export function acceptsControlUiHtmlResponse(accept: string | undefined): boolean { + const normalized = accept?.trim(); + if (!normalized) { + return true; + } + return normalized.split(",").some((entry) => { + const [rawMediaType, ...parameters] = entry.split(";"); + if (parameters.some((parameter) => /^\s*q\s*=\s*0(?:\.0{0,3})?\s*$/i.test(parameter))) { + return false; + } + const mediaType = rawMediaType?.trim().toLowerCase(); + return ( + mediaType === "*/*" || + mediaType === "text/*" || + mediaType === "text/html" || + mediaType === "application/xhtml+xml" + ); + }); +} + /** Sends a plain-text response with the standard UTF-8 content type. */ export function respondPlainText(res: ServerResponse, statusCode: number, body: string): void { res.statusCode = statusCode; diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index ac850a644f47..22633919fca4 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -44,6 +44,160 @@ describe("isControlUiApprovalDocumentPath", () => { }); }); +describe("Control UI SPA fallback Accept routing", () => { + it.each([ + { + name: "missing Accept header", + basePath: "", + pathname: "/chat", + method: "GET", + accept: undefined, + expected: true, + }, + { + name: "empty Accept header", + basePath: "/openclaw", + pathname: "/openclaw/chat", + method: "HEAD", + accept: " ", + expected: true, + }, + { + name: "browser Accept header", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html, application/xhtml+xml;q=0.9, application/xml;q=0.8", + expected: true, + }, + { + name: "text wildcard at root", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/*", + expected: true, + }, + { + name: "nonzero text wildcard under a base path", + basePath: "/openclaw", + pathname: "/openclaw/chat", + method: "HEAD", + accept: "application/json, text/*;q=0.5", + expected: true, + }, + { + name: "zero-quality text wildcard rejection", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/*;q=0", + expected: false, + }, + { + name: "zero-quality HTML rejection", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html;q=0", + expected: false, + }, + { + name: "rejected HTML entry with an accepting wildcard", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html;q=0, */*", + expected: true, + }, + { + name: "nonzero HTML quality", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html;q=0.5", + expected: true, + }, + { + name: "mixed-case zero-quality parameter", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html; Q = 0", + expected: false, + }, + { + name: "trailing-dot zero quality", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html; q=0.", + expected: false, + }, + { + name: "one-decimal zero quality", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "text/html;q=0.0", + expected: false, + }, + { + name: "mixed-case XHTML three-decimal rejection", + basePath: "/openclaw", + pathname: "/openclaw/chat", + method: "HEAD", + accept: "application/json, Application/XHTML+XML ; q=0.000", + expected: false, + }, + { + name: "zero-quality wildcard rejection", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "application/json, */*;q=0", + expected: false, + }, + { + name: "JSON-only Accept header", + basePath: "", + pathname: "/chat", + method: "GET", + accept: "application/json", + expected: false, + }, + { + name: "event-stream Accept header under a base path", + basePath: "/openclaw", + pathname: "/openclaw/chat", + method: "HEAD", + accept: "text/event-stream", + expected: false, + }, + { + name: "plugin manager recovery at root", + basePath: "", + pathname: "/settings/plugins", + method: "GET", + accept: "application/json", + expected: true, + }, + { + name: "plugin manager recovery under a base path", + basePath: "/openclaw", + pathname: "/openclaw/settings/plugins/", + method: "HEAD", + accept: "text/event-stream", + expected: true, + }, + ])("classifies $name", ({ basePath, pathname, method, accept, expected }) => { + expect(classifyControlUiRequest({ basePath, pathname, search: "", method, accept })).toEqual({ + kind: "serve", + spaFallback: expected, + }); + }); +}); + describe("classifyControlUiRequest", () => { describe("root-mounted control ui", () => { it.each([ @@ -51,19 +205,19 @@ describe("classifyControlUiRequest", () => { name: "serves the root entrypoint", pathname: "/", method: "GET", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "serves other read-only SPA routes", pathname: "/chat", method: "HEAD", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "serves the plugin manager without claiming plugin HTTP routes", pathname: "/settings/plugins", method: "GET", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "keeps health probes outside the SPA catch-all", @@ -141,7 +295,7 @@ describe("classifyControlUiRequest", () => { name: "preserves SPA routes that only resemble the standalone MCP App namespace", pathname: "/__openclaw__/mcp-apps", method: "GET", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "keeps MCP App descendants outside the SPA catch-all", @@ -165,19 +319,19 @@ describe("classifyControlUiRequest", () => { name: "preserves SPA routes that only resemble probe paths", pathname: "/healthcheck", method: "GET", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "preserves the SPA root that only resembles the OpenAI-compatible API", pathname: "/v12", method: "GET", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "preserves SPA routes that only resemble the OpenAI-compatible API", pathname: "/v12/models", method: "GET", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "returns not-found for legacy ui routes", @@ -217,7 +371,7 @@ describe("classifyControlUiRequest", () => { pathname: "/openclaw/chat", search: "", method: "HEAD", - expected: { kind: "serve" as const }, + expected: { kind: "serve" as const, spaFallback: true }, }, { name: "falls through unmatched paths", diff --git a/src/gateway/control-ui-routing.ts b/src/gateway/control-ui-routing.ts index 5d49dad5ab55..72cc7d96c59a 100644 --- a/src/gateway/control-ui-routing.ts +++ b/src/gateway/control-ui-routing.ts @@ -1,5 +1,5 @@ // Control UI route classifier for base-path and root-mounted SPA serving. -import { isReadHttpMethod } from "./control-ui-http-utils.js"; +import { acceptsControlUiHtmlResponse, isReadHttpMethod } from "./control-ui-http-utils.js"; import { classifyGatewayProbePath, classifyMcpAppStandalonePath, @@ -9,7 +9,7 @@ type ControlUiRequestClassification = | { kind: "not-control-ui" } | { kind: "not-found" } | { kind: "redirect"; location: string } - | { kind: "serve" }; + | { kind: "serve"; spaFallback: boolean }; const CONTROL_UI_PLUGIN_MANAGER_PATH = "/settings/plugins"; @@ -49,8 +49,13 @@ export function classifyControlUiRequest(params: { pathname: string; search: string; method: string | undefined; + accept?: string; }): ControlUiRequestClassification { const { basePath, pathname, search, method } = params; + // SPA fallback owns ambiguous browser reads, while plugin recovery is explicit. + // Decline only clearly non-HTML Accept values so headerless/wildcard clients keep working. + const spaFallback = + isControlUiPluginManagerRequest(params) || acceptsControlUiHtmlResponse(params.accept); if (!basePath) { if (pathname === "/ui" || pathname.startsWith("/ui/")) { return { kind: "not-found" }; @@ -80,7 +85,7 @@ export function classifyControlUiRequest(params: { if (!isReadHttpMethod(method)) { return { kind: "not-control-ui" }; } - return { kind: "serve" }; + return { kind: "serve", spaFallback }; } if (!pathname.startsWith(`${basePath}/`) && pathname !== basePath) { @@ -92,5 +97,5 @@ export function classifyControlUiRequest(params: { if (pathname === basePath) { return { kind: "redirect", location: `${basePath}/${search}` }; } - return { kind: "serve" }; + return { kind: "serve", spaFallback }; } diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index c38c2bf41bb0..f64291209089 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -2680,6 +2680,35 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("keeps JSON-Accept requests for explicit assets and plugin recovery routes", async () => { + await withControlUiRoot({ + indexHtml: "plugin-recovery\n", + fn: async (tmp) => { + await writeAssetFile(tmp, "actual.txt", "inside-ok\n"); + + const asset = await runControlUiRequest({ + url: "/assets/actual.txt", + method: "GET", + rootPath: tmp, + headers: { accept: "application/json" }, + }); + expect(asset.handled).toBe(true); + expect(asset.res.statusCode).toBe(200); + expect(responseBody(asset.end)).toBe("inside-ok\n"); + + const recovery = await runControlUiRequest({ + url: "/settings/plugins", + method: "GET", + rootPath: tmp, + headers: { accept: "application/json" }, + }); + expect(recovery.handled).toBe(true); + expect(recovery.res.statusCode).toBe(200); + expect(responseBody(recovery.end)).toContain("plugin-recovery"); + }, + }); + }); + it("compresses bundled assets and caches them immutably", async () => { await withControlUiRoot({ fn: async (tmp) => { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index c698d7e37ac1..82045926c581 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -1001,6 +1001,7 @@ export async function handleControlUiHttpRequest( pathname, search: url.search, method: req.method, + accept: req.headers?.accept, }); if (route.kind === "not-control-ui") { return false; @@ -1239,6 +1240,10 @@ export async function handleControlUiHttpRequest( return true; } + if (!route.spaFallback) { + return false; + } + // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); const safeIndex = resolveSafeControlUiFile(rootReal, indexPath, rejectHardlinks); diff --git a/src/gateway/server-http.probe.test.ts b/src/gateway/server-http.probe.test.ts index 465b3a5a04d0..414efff2a2f4 100644 --- a/src/gateway/server-http.probe.test.ts +++ b/src/gateway/server-http.probe.test.ts @@ -166,6 +166,86 @@ describe("startup plugin HTTP routing", () => { }); }); }); + + it("uses Accept to route only the unclaimed Control UI SPA fallback", async () => { + await withMarkedControlUiRoot(async (controlUiRoot) => { + let sidecarsReady = false; + await withGatewayServer({ + prefix: "startup-plugin-get-accept-root-control-ui", + resolvedAuth: AUTH_NONE, + overrides: { + controlUiEnabled: true, + controlUiBasePath: "", + controlUiRoot: { kind: "resolved", path: controlUiRoot }, + handlePluginRequest: async () => false, + shouldEnforcePluginGatewayAuth: () => false, + isStartupPluginRuntimeReady: () => sidecarsReady, + }, + run: async (server) => { + const htmlCases = [ + { + name: "browser", + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + { name: "bare curl", accept: "*/*" }, + { name: "missing header", accept: undefined }, + { name: "empty header", accept: "" }, + { name: "rejected HTML with wildcard", accept: "text/html;q=0, */*" }, + { name: "nonzero HTML quality", accept: "text/html;q=0.5" }, + { name: "text wildcard", accept: "text/*" }, + ]; + const nonHtmlCases = [ + { name: "JSON", accept: "application/json" }, + { name: "event stream", accept: "text/event-stream" }, + { name: "zero-quality HTML", accept: "text/html;q=0" }, + { name: "zero-quality wildcard", accept: "*/*;q=0" }, + { name: "mixed-case zero quality", accept: "text/html;Q=0" }, + { name: "zero-quality text wildcard", accept: "text/*;q=0" }, + ]; + for (const ready of [false, true]) { + sidecarsReady = ready; + for (const testCase of htmlCases) { + const { res, getBody } = await sendGatewayRequest(server, { + path: "/unclaimed-spa-route", + method: "GET", + headers: testCase.accept === undefined ? undefined : { accept: testCase.accept }, + }); + + expect(res.statusCode, `${testCase.name} ready=${ready}`).toBe(200); + expect(getBody(), `${testCase.name} ready=${ready}`).toContain("spa fallback"); + } + + for (const testCase of nonHtmlCases) { + const response = createResponse(); + await dispatchRequest( + server, + createRequest({ + path: "/unclaimed-spa-route", + method: "GET", + headers: { accept: testCase.accept }, + }), + response.res, + ); + + expect(response.res.statusCode, `${testCase.name} ready=${ready}`).toBe( + ready ? 404 : 503, + ); + expect(response.setHeader).toHaveBeenCalledWith( + "Content-Type", + "text/plain; charset=utf-8", + ); + expect(response.getBody()).toBe(ready ? "Not Found" : "Plugin runtime is starting"); + if (ready) { + expect(response.setHeader).not.toHaveBeenCalledWith("Retry-After", "1"); + } else { + expect(response.setHeader).toHaveBeenCalledWith("Retry-After", "1"); + } + } + } + }, + }); + }); + }); }); describe("standalone MCP App HTTP routing", () => {