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({