From f5dd33c975e5953365c0b3f684c6251f366fa568 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Thu, 11 Jun 2026 07:49:09 +0800 Subject: [PATCH] fix(control-ui): make Control UI bootstrap config endpoint base-path-relative (#66946) (#91305) * fix(control-ui): make bootstrap config endpoint base-path-relative (#66946) CONTROL_UI_BOOTSTRAP_CONFIG_PATH embedded a hard-coded /__openclaw prefix instead of being base-path-relative. When the Control UI is served under /__openclaw__/, both the gateway and the browser loader compose ${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}, producing the doubled /__openclaw__/__openclaw/control-ui-config.json URL that 404s. Make the constant base-path-relative (/control-ui-config.json) so the composed URL is correct under any base path, align the Vite dev stub and the docs, and add gateway.controlUi.basePath "/__openclaw__" coverage. * fix(control-ui): serve bootstrap config at default __openclaw__ entry (#66946) The reporter runs the default deployment (no gateway.controlUi.basePath), so the Control UI SPA is mounted under the default /__openclaw__/ namespace. A browser opening that entry infers basePath="/__openclaw__" from the URL (inferBasePathFromPathname) and fetches /__openclaw__/control-ui-config.json, but an empty-base-path gateway only served the bare /control-ui-config.json, so the default-entry bootstrap request 404'd and chat never finished loading. Make handleControlUiHttpRequest also accept the default-namespace alias /__openclaw__/control-ui-config.json when no base path is configured. The alias is derived from the existing CONTROL_UI_NAMESPACE_PREFIX mount constant and is purely additive: the bare /control-ui-config.json endpoint and the configured-base-path endpoint are both preserved (no route removed). Add gateway HTTP coverage for the real default-entry scenario (empty base path + /__openclaw__/... request) that fails without the alias, alongside the configured-base-path, bare-path compatibility, and doubled-path 404 cases. * fix(control-ui): preserve legacy bootstrap endpoint as compat alias (#66946) Current main and v2026.6.1 serve and document the single-underscore /__openclaw/control-ui-config.json bootstrap endpoint under an empty base path (that literal was CONTROL_UI_BOOTSTRAP_CONFIG_PATH before the path was made base-path-relative). Making the constant relative dropped that match, so older bundles and clients hitting the documented endpoint would 404 after upgrading. Accept the legacy single-underscore path as an empty-base-path compatibility alias in matchesControlUiBootstrapConfigPath, derived from the legacy /__openclaw namespace joined with the canonical config constant (so it tracks any filename rename) and named LEGACY_BOOTSTRAP_CONFIG_PATH with a comment. The canonical /control-ui-config.json and the default-namespace /__openclaw__/control-ui-config.json aliases are unchanged; only this path is added. The doubled /__openclaw__/__openclaw/... path still 404s. Add a focused regression that the legacy endpoint returns config under an empty base path; it 404s without the alias (verified non-vacuous). * fix(control-ui): preserve legacy bootstrap route under configured base path (#66946) The previous revision preserved the single-underscore /__openclaw/control-ui-config.json bootstrap endpoint only under an empty base path. A deployment with a configured gateway.controlUi.basePath (e.g. /x) served and documented that endpoint at ${basePath}/__openclaw/control-ui-config.json before this PR made the config path base-path-relative, so configured-base-path users, older bundles, and clients that still request it would 404 after upgrading. Extend matchesControlUiBootstrapConfigPath so the legacy single-underscore suffix is accepted under every base path, not just the empty one. The matcher now checks the canonical and legacy suffixes uniformly as ${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH} and ${basePath}${LEGACY_BOOTSTRAP_CONFIG_PATH} for both the empty and configured cases, reusing the existing LEGACY_BOOTSTRAP_CONFIG_PATH constant (no new hard-coded literal). The default-namespace /__openclaw__/control-ui-config.json alias stays empty-base-path-only (it is the path the inferred default entry requests when no base path is configured). All three empty-base-path behaviors are unchanged; the doubled /__openclaw__/__openclaw/... path still 404s under both an empty and a configured base path. Add a focused regression that the configured-base-path legacy endpoint returns the bootstrap config; it 404s without the alias (verified non-vacuous). No CHANGELOG.md change. * fix(ui): mount config stub under vite base * fix(ui): preserve default config stub route --------- Co-authored-by: Vincent Koc --- docs/web/control-ui.md | 2 +- src/gateway/control-ui-contract.ts | 2 +- src/gateway/control-ui.http.test.ts | 163 ++++++++++++++++++++++++++++ src/gateway/control-ui.ts | 63 ++++++++++- ui/vite.config.ts | 3 +- 5 files changed, 226 insertions(+), 7 deletions(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 63273955aaf..0fbefff0556 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -74,7 +74,7 @@ The same browser-local pattern applies to the assistant avatar override. Uploade ## Runtime config endpoint -The Control UI fetches its runtime settings from `/__openclaw/control-ui-config.json`. That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity. +The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` when the UI is served under `/__openclaw__/`). That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity. ## Language support diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index c4a0d2ec177..0ee86569404 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -1,7 +1,7 @@ // Control UI bootstrap contract served by the gateway and consumed by the // browser app before it knows runtime branding, media roots, or embed policy. /** HTTP path for the Control UI bootstrap config payload. */ -export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json"; +export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json"; /** Sandbox policy for assistant-provided embed surfaces inside Control UI. */ export type ControlUiEmbedSandboxMode = "strict" | "scripts" | "trusted"; diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 4279e93983a..f16312577a1 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -895,6 +895,169 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("serves bootstrap config under the configured /__openclaw__ basePath (#66946)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end } = makeMockHttpResponse(); + const handled = await handleControlUiHttpRequest( + { + url: "/__openclaw__/control-ui-config.json", + method: "GET", + } as IncomingMessage, + res, + { + basePath: "/__openclaw__", + root: { kind: "resolved", path: tmp }, + config: { + agents: { defaults: { workspace: tmp } }, + ui: { assistant: { name: "Ops", avatar: "ops.png" } }, + }, + }, + ); + expect(handled).toBe(true); + expect(res.statusCode).not.toBe(404); + const parsed = parseBootstrapPayload(end); + expect(parsed.basePath).toBe("/__openclaw__"); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + + // Real reported scenario: the gateway has NO configured `gateway.controlUi.basePath`, + // so the SPA is served at the default `/__openclaw__/` namespace. The browser opens + // the default entry, `inferBasePathFromPathname("/__openclaw__/")` yields `/__openclaw__`, + // and the loader fetches `/__openclaw__/control-ui-config.json`. Before this fix the + // gateway only matched the bare `/control-ui-config.json` for an empty base path, so the + // default-entry request 404ed (issue #66946). This case fails without the namespace alias. + it("serves bootstrap config at the default /__openclaw__ entry with no configured basePath (#66946)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end } = makeMockHttpResponse(); + const handled = await handleControlUiHttpRequest( + { + url: "/__openclaw__/control-ui-config.json", + method: "GET", + } as IncomingMessage, + res, + { + // No basePath: simulates the default deployment from the issue report. + root: { kind: "resolved", path: tmp }, + config: { + agents: { defaults: { workspace: tmp } }, + ui: { assistant: { name: "Ops", avatar: "ops.png" } }, + }, + }, + ); + expect(handled).toBe(true); + expect(res.statusCode).not.toBe(404); + const parsed = parseBootstrapPayload(end); + // Configured base path is empty, so the payload reports "" (the loader keeps + // its own inferred base path; it does not read this field back for the fetch). + expect(parsed.basePath).toBe(""); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + + it("still serves bootstrap config at the bare /control-ui-config.json for compatibility (#66946)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, handled, end } = await runBootstrapConfigRequest({ rootPath: tmp }); + expect(handled).toBe(true); + expect(res.statusCode).not.toBe(404); + const parsed = parseBootstrapPayload(end); + expect(parsed.basePath).toBe(""); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + + // Compatibility regression: current main and v2026.6.1 serve and document the + // single-underscore `/__openclaw/control-ui-config.json` endpoint under an empty + // base path. #66946 makes the config path base-path-relative; this case proves + // the old documented endpoint still returns config (no upgrade 404 break). + // Without the LEGACY_BOOTSTRAP_CONFIG_PATH alias this request 404s, so it is not + // vacuous. + it("still serves bootstrap config at the legacy /__openclaw/control-ui-config.json with no configured basePath (#66946)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end } = makeMockHttpResponse(); + const handled = await handleControlUiHttpRequest( + { + url: "/__openclaw/control-ui-config.json", + method: "GET", + } as IncomingMessage, + res, + { + // No basePath: matches the legacy default deployment that documented + // and served the single-underscore endpoint. + root: { kind: "resolved", path: tmp }, + config: { + agents: { defaults: { workspace: tmp } }, + ui: { assistant: { name: "Ops", avatar: "ops.png" } }, + }, + }, + ); + expect(handled).toBe(true); + expect(res.statusCode).not.toBe(404); + const parsed = parseBootstrapPayload(end); + expect(parsed.basePath).toBe(""); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + + // Compatibility regression for configured-base-path deployments: when a + // `gateway.controlUi.basePath` is set (e.g. `/openclaw`), current main and + // v2026.6.1 serve the bootstrap config at `${basePath}/__openclaw/control-ui-config.json` + // (single underscore). #66946 moves the canonical path to + // `${basePath}/control-ui-config.json`; this case proves the old configured-base-path + // endpoint still returns config so older bundles and proxies that still request it + // do not 404 after upgrade. Without the configured-base-path legacy alias this + // request 404s, so the assertion is not vacuous. + it("still serves bootstrap config at the legacy ${basePath}/__openclaw/control-ui-config.json under a configured basePath (#66946)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end } = makeMockHttpResponse(); + const handled = await handleControlUiHttpRequest( + { + url: "/openclaw/__openclaw/control-ui-config.json", + method: "GET", + } as IncomingMessage, + res, + { + basePath: "/openclaw", + root: { kind: "resolved", path: tmp }, + config: { + agents: { defaults: { workspace: tmp } }, + ui: { assistant: { name: "Ops", avatar: "ops.png" } }, + }, + }, + ); + expect(handled).toBe(true); + expect(res.statusCode).not.toBe(404); + const parsed = parseBootstrapPayload(end); + // The configured base path is reported back so the loader resolves + // base-path-relative URLs against it. + expect(parsed.basePath).toBe("/openclaw"); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + + it("does not serve bootstrap config from the doubled /__openclaw__/__openclaw path (#66946)", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end, handled } = await runControlUiRequest({ + url: "/__openclaw__/__openclaw/control-ui-config.json", + method: "GET", + rootPath: tmp, + }); + expectNotFoundResponse({ handled, res, end }); + }, + }); + }); + it("serves local avatar bytes through hardened avatar handler", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-http-")); try { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 3a965d1a1f5..c9f6174cb55 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -835,6 +835,64 @@ function isSafeRelativePath(relPath: string) { return true; } +// Path served by the gateway under the default Control UI namespace when no +// `gateway.controlUi.basePath` is configured. The SPA is mounted at +// `/__openclaw__/`, so a browser that opens the default entry infers +// `/__openclaw__` as its base path (see `inferBasePathFromPathname`) and fetches +// `/__openclaw__/control-ui-config.json`. Accept that namespaced alias so the +// default entry resolves its bootstrap config instead of 404ing. +const CONTROL_UI_DEFAULT_NAMESPACE_BOOTSTRAP_CONFIG_PATH = `${CONTROL_UI_NAMESPACE_PREFIX.replace( + /\/$/, + "", +)}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`; + +// Single-underscore `/__openclaw` prefix used by the pre-base-path-relative +// bootstrap endpoint. Before #66946 made the config path base-path-relative, +// `CONTROL_UI_BOOTSTRAP_CONFIG_PATH` was hard-coded to +// `/__openclaw/control-ui-config.json`, so current main and the v2026.6.1 +// release serve and document that exact path under an empty base path. +const LEGACY_CONTROL_UI_NAMESPACE_PREFIX = "/__openclaw"; + +// The old documented no-base-path bootstrap endpoint +// (`/__openclaw/control-ui-config.json`, single underscore). It is derived from +// the legacy `/__openclaw` namespace joined with the canonical config constant +// so it tracks any rename of the config filename. Kept as an empty-base-path +// compatibility alias so older bundles and clients that fetch the previously +// documented endpoint keep receiving config after upgrading instead of 404ing. +const LEGACY_BOOTSTRAP_CONFIG_PATH = `${LEGACY_CONTROL_UI_NAMESPACE_PREFIX}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`; + +/** + * Whether `pathname` should be served the Control UI bootstrap config payload. + * + * The canonical endpoint is the configured base path joined with the shared + * bootstrap constant (or the bare constant when no base path is configured). + * For every base path (configured or empty) we additionally accept the legacy + * single-underscore suffix `${basePath}/__openclaw/control-ui-config.json` that + * current main and v2026.6.1 serve and document, so older bundles and clients + * that still request the pre-#66946 endpoint keep receiving config after an + * upgrade instead of 404ing. When no base path is configured we further accept + * the default-namespace alias `/__openclaw__/control-ui-config.json`, which is + * what the default `/__openclaw__/` entry requests after inferring its base path + * from the URL. All compatibility endpoints are preserved; no path is removed. + */ +function matchesControlUiBootstrapConfigPath(pathname: string, basePath: string): boolean { + // Canonical and legacy suffixes apply under both an empty and a configured + // base path. `LEGACY_BOOTSTRAP_CONFIG_PATH` already starts with the legacy + // `/__openclaw` namespace, so joining it with the base path yields + // `${basePath}/__openclaw/control-ui-config.json` (or the bare legacy path + // when no base path is configured). + if ( + pathname === `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` || + pathname === `${basePath}${LEGACY_BOOTSTRAP_CONFIG_PATH}` + ) { + return true; + } + // The default `/__openclaw__/` namespace alias only applies when no base path + // is configured; with a configured base path the canonical endpoint already + // lives under that base path and this inferred alias does not apply. + return basePath === "" && pathname === CONTROL_UI_DEFAULT_NAMESPACE_BOOTSTRAP_CONFIG_PATH; +} + export async function handleControlUiHttpRequest( req: IncomingMessage, res: ServerResponse, @@ -871,10 +929,7 @@ export async function handleControlUiHttpRequest( applyControlUiSecurityHeaders(res); - const bootstrapConfigPath = basePath - ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` - : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; - if (pathname === bootstrapConfigPath) { + if (matchesControlUiBootstrapConfigPath(pathname, basePath)) { if ( !(await authorizeControlUiReadRequest(req, res, { auth: opts?.auth, diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 97514f49840..a9488c35785 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -228,6 +228,7 @@ function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin { export default function controlUiViteConfig(): UserConfig { const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); const base = envBase ? normalizeBase(envBase) : "./"; + const bootstrapConfigPath = base === "./" ? "/control-ui-config.json" : `${base}control-ui-config.json`; const controlUiBuildId = resolveControlUiBuildId(); return { base, @@ -273,7 +274,7 @@ export default function controlUiViteConfig(): UserConfig { { name: "control-ui-dev-stubs", configureServer(server) { - server.middlewares.use("/__openclaw/control-ui-config.json", (_req, res) => { + server.middlewares.use(bootstrapConfigPath, (_req, res) => { res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({