mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 09:58:16 +00:00
* 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 <vincentkoc@ieee.org>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user