diff --git a/CHANGELOG.md b/CHANGELOG.md index 094aee842ad..fc59726f133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai - Agents/Pi: keep the filtered tool-name allowlist active for embedded OpenAI/OpenAI Codex GPT-5 runs and compaction sessions, so bundled and client tools still execute after the Pi `0.68.1` session-tool allowlist change instead of stopping at plan-only replies with no tool call. (#70281) Thanks @jalehman. - Agents/Pi: honor explicit `strict-agentic` execution contracts for incomplete-turn retry guards across providers, so manually opted-in local or compatible models get the same retry behavior without relying on OpenAI model inference. (#66750) Thanks @ziomancer. - OpenShell/sandbox: pin verified file reads to an already-opened descriptor, walk the ancestor chain for symlinked parents on platforms without fd-path readlink, and re-check file identity so parent symlink swaps cannot redirect in-sandbox reads to host files outside the allowed mount root. (#69798) Thanks @drobison00. +- Gateway/Control UI: require authenticated Control UI read access before serving `/__openclaw/control-ui-config.json` when `gateway.auth` is enabled, so unauthenticated callers can no longer read bootstrap metadata. (#70247) Thanks @drobison00. ## 2026.4.21 diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts index 65931d03aac..e97db9e1a10 100644 --- a/src/gateway/control-ui.auto-root.http.test.ts +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -49,7 +49,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => { resolveControlUiRootSyncMock.mockReturnValue(tmp); const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/assets/app.hl.js", method: "GET" } as IncomingMessage, res, ); @@ -70,7 +70,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => { resolveControlUiRootSyncMock.mockReturnValue(tmp); const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/dashboard", method: "GET" } as IncomingMessage, res, ); @@ -91,7 +91,7 @@ describe("handleControlUiHttpRequest auto-detected root", () => { resolveControlUiRootSyncMock.mockReturnValue(tmp); const { res } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/assets/app.hl.js", method: "GET" } as IncomingMessage, res, ); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 3c87a5e8df8..bba04488ffc 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -48,7 +48,7 @@ describe("handleControlUiHttpRequest", () => { expect(params.end).toHaveBeenCalledWith("Not Found"); } - function runControlUiRequest(params: { + async function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; rootPath: string; @@ -56,7 +56,7 @@ describe("handleControlUiHttpRequest", () => { rootKind?: "resolved" | "bundled"; }) { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: params.url, method: params.method } as IncomingMessage, res, { @@ -67,6 +67,33 @@ describe("handleControlUiHttpRequest", () => { return { res, end, handled }; } + async function runBootstrapConfigRequest(params: { + rootPath: string; + basePath?: string; + auth?: ResolvedGatewayAuth; + headers?: IncomingMessage["headers"]; + }) { + const { res, end } = makeMockHttpResponse(); + const url = params.basePath + ? `${params.basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` + : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; + const handled = await handleControlUiHttpRequest( + { + url, + method: "GET", + headers: params.headers ?? {}, + socket: { remoteAddress: "127.0.0.1" }, + } as IncomingMessage, + res, + { + ...(params.basePath ? { basePath: params.basePath } : {}), + ...(params.auth ? { auth: params.auth } : {}), + root: { kind: "resolved", path: params.rootPath }, + }, + ); + return { res, end, handled }; + } + async function runAvatarRequest(params: { url: string; method: "GET" | "HEAD"; @@ -241,7 +268,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, { @@ -405,7 +432,7 @@ describe("handleControlUiHttpRequest", () => { indexHtml: html, fn: async (tmp) => { const { res, setHeader } = makeMockHttpResponse(); - handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { + await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp }, }); const cspCalls = setHeader.mock.calls.filter( @@ -424,7 +451,7 @@ describe("handleControlUiHttpRequest", () => { indexHtml: html, fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, { @@ -445,7 +472,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, res, { @@ -467,11 +494,43 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("rejects bootstrap config requests without a valid auth token when auth is enabled", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, handled, end } = await runBootstrapConfigRequest({ + rootPath: tmp, + auth: { mode: "token", token: "test-token", allowTailscale: false }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized"); + }, + }); + }); + + it("serves bootstrap config JSON when auth is enabled and the token is valid", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, handled, end } = await runBootstrapConfigRequest({ + rootPath: tmp, + auth: { mode: "token", token: "test-token", allowTailscale: false }, + headers: { + authorization: "Bearer test-token", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + const parsed = parseBootstrapPayload(end); + expect(parsed.assistantAgentId).toBe("main"); + }, + }); + }); + it("serves bootstrap config JSON under basePath", async () => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, method: "GET" } as IncomingMessage, res, { @@ -613,7 +672,7 @@ describe("handleControlUiHttpRequest", () => { await fs.symlink(outsideFile, path.join(assetsDir, "leak.txt")); const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/assets/leak.txt", method: "GET" } as IncomingMessage, res, { @@ -634,7 +693,7 @@ describe("handleControlUiHttpRequest", () => { const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n"); await fs.symlink(filePath, path.join(assetsDir, "linked.txt")); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/linked.txt", method: "GET", rootPath: tmp, @@ -652,7 +711,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { await writeAssetFile(tmp, "actual.txt", "inside-ok\n"); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/actual.txt", method: "HEAD", rootPath: tmp, @@ -675,7 +734,7 @@ describe("handleControlUiHttpRequest", () => { await fs.rm(path.join(tmp, "index.html")); await fs.symlink(outsideIndex, path.join(tmp, "index.html")); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/app/route", method: "GET", rootPath: tmp, @@ -698,7 +757,7 @@ describe("handleControlUiHttpRequest", () => { await fs.rm(path.join(tmp, "index.html")); await fs.link(outsideIndex, path.join(tmp, "index.html")); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/", method: "GET", rootPath: tmp, @@ -716,7 +775,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { await createHardlinkedAssetFile(tmp); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/app.hl.js", method: "GET", rootPath: tmp, @@ -734,7 +793,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { await createHardlinkedAssetFile(tmp); - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/assets/app.hl.js", method: "GET", rootPath: tmp, @@ -753,7 +812,7 @@ describe("handleControlUiHttpRequest", () => { fn: async (tmp) => { for (const webhookPath of ["/bluebubbles-webhook", "/custom-webhook", "/callback"]) { const { res } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: webhookPath, method: "POST" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -770,7 +829,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { const { res } = makeMockHttpResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/bluebubbles-webhook", method: "POST" } as IncomingMessage, res, { basePath: "/openclaw", root: { kind: "resolved", path: tmp } }, @@ -784,7 +843,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { for (const apiPath of ["/api", "/api/sessions", "/api/channels/nostr"]) { - const { handled } = runControlUiRequest({ + const { handled } = await runControlUiRequest({ url: apiPath, method: "GET", rootPath: tmp, @@ -799,7 +858,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { for (const pluginPath of ["/plugins", "/plugins/diffs/view/abc/def"]) { - const { handled } = runControlUiRequest({ + const { handled } = await runControlUiRequest({ url: pluginPath, method: "GET", rootPath: tmp, @@ -813,7 +872,7 @@ describe("handleControlUiHttpRequest", () => { it("falls through POST requests when basePath is empty", async () => { await withControlUiRoot({ fn: async (tmp) => { - const { handled, end } = runControlUiRequest({ + const { handled, end } = await runControlUiRequest({ url: "/webhook/bluebubbles", method: "POST", rootPath: tmp, @@ -828,7 +887,7 @@ describe("handleControlUiHttpRequest", () => { await withControlUiRoot({ fn: async (tmp) => { for (const route of ["/openclaw", "/openclaw/", "/openclaw/some-page"]) { - const { handled, end } = runControlUiRequest({ + const { handled, end } = await runControlUiRequest({ url: route, method: "POST", rootPath: tmp, @@ -850,7 +909,7 @@ describe("handleControlUiHttpRequest", () => { const secretPathUrl = secretPath.split(path.sep).join("/"); const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`; - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: `/openclaw/${absolutePathUrl}`, method: "GET", rootPath: root, @@ -879,7 +938,7 @@ describe("handleControlUiHttpRequest", () => { throw error; } - const { res, end, handled } = runControlUiRequest({ + const { res, end, handled } = await runControlUiRequest({ url: "/openclaw/assets/leak.txt", method: "GET", rootPath: root, diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index e22c3fb72e4..5673b29d6ee 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -55,6 +55,10 @@ export type ControlUiRequestOptions = { config?: OpenClawConfig; agentId?: string; root?: ControlUiRootState; + auth?: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; }; export type ControlUiRootState = @@ -617,11 +621,11 @@ function isSafeRelativePath(relPath: string) { return true; } -export function handleControlUiHttpRequest( +export async function handleControlUiHttpRequest( req: IncomingMessage, res: ServerResponse, opts?: ControlUiRequestOptions, -): boolean { +): Promise { const urlRaw = req.url; if (!urlRaw) { return false; @@ -657,6 +661,16 @@ export function handleControlUiHttpRequest( ? `${basePath}${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}` : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; if (pathname === bootstrapConfigPath) { + if ( + !(await authorizeControlUiReadRequest(req, res, { + auth: opts?.auth, + trustedProxies: opts?.trustedProxies, + allowRealIpFallback: opts?.allowRealIpFallback, + rateLimiter: opts?.rateLimiter, + })) + ) { + return true; + } const config = opts?.config; const identity = config ? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId }) diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index fbee45762d6..70d187584c7 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -83,7 +83,7 @@ describe("GatewayClient", () => { it("returns 404 for missing static asset paths instead of SPA fallback", async () => { await withControlUiRoot({ faviconSvg: "" }, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/favicon.svg", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -96,7 +96,7 @@ describe("GatewayClient", () => { it("returns 404 for missing static assets with query strings", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/favicon.svg?v=1", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -109,7 +109,7 @@ describe("GatewayClient", () => { it("still serves SPA fallback for extensionless paths", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/chat", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -122,7 +122,7 @@ describe("GatewayClient", () => { it("HEAD returns 404 for missing static assets consistent with GET", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/favicon.svg", method: "HEAD" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -136,7 +136,7 @@ describe("GatewayClient", () => { await withControlUiRoot({}, async (tmp) => { for (const route of ["/webchat/user/jane.doe", "/webchat/v2.0", "/settings/v1.2"]) { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: route, method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, @@ -150,7 +150,7 @@ describe("GatewayClient", () => { it("serves SPA fallback for .html paths that do not exist on disk", async () => { await withControlUiRoot({}, async (tmp) => { const { res } = makeControlUiResponse(); - const handled = handleControlUiHttpRequest( + const handled = await handleControlUiHttpRequest( { url: "/webchat/foo.html", method: "GET" } as IncomingMessage, res, { root: { kind: "resolved", path: tmp } }, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index ab7ef2b7666..dbdaad3fbd1 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -1099,6 +1099,10 @@ export function createGatewayHttpServer(opts: { config: configSnapshot, agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, root: controlUiRoot, + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, }), }); } diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 57a7b429181..ea0f290de6d 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -5,6 +5,7 @@ import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts" import type { GatewayHelloOk } from "./gateway.ts"; const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined)); +const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined)); type GatewayClientMock = { start: ReturnType; @@ -94,6 +95,10 @@ vi.mock("./controllers/chat.ts", async (importOriginal) => { }; }); +vi.mock("./controllers/control-ui-bootstrap.ts", () => ({ + loadControlUiBootstrapConfig: loadControlUiBootstrapConfigMock, +})); + type TestGatewayHost = Parameters[0] & { chatSideResult: unknown; chatSideResultTerminalRuns: Set; @@ -192,6 +197,7 @@ describe("connectGateway", () => { beforeEach(() => { gatewayClientInstances.length = 0; loadChatHistoryMock.mockClear(); + loadControlUiBootstrapConfigMock.mockClear(); }); it("ignores stale client onGap callbacks after reconnect", () => { @@ -505,6 +511,19 @@ describe("connectGateway", () => { expect(host.lastError).toBe("disconnected (1006): no reason"); }); + it("refreshes bootstrap config after hello", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitHello(); + + expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledTimes(1); + expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host); + }); + it("keeps shutdown restart reasons on service restart closes", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index d0fcf266cb4..6ef2b7d0e3a 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -30,6 +30,7 @@ import { type ChatEventPayload, type ChatState, } from "./controllers/chat.ts"; +import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts"; import { loadDevices, type DevicesState } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import { @@ -292,6 +293,9 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption host.lastErrorCode = null; host.hello = hello; applySnapshot(host, hello); + void loadControlUiBootstrapConfig( + host as unknown as Parameters[0], + ); // Reset orphaned chat run state from before disconnect. // Any in-flight run's final event was lost during the disconnect window. host.chatRunId = null; diff --git a/ui/src/ui/control-ui-auth.ts b/ui/src/ui/control-ui-auth.ts index f7a622dbaf6..cb6d356627c 100644 --- a/ui/src/ui/control-ui-auth.ts +++ b/ui/src/ui/control-ui-auth.ts @@ -6,11 +6,25 @@ type ControlUiAuthSource = { password?: string | null; }; +// The gateway's shared-secret auth contract accepts either `token` or +// `password` as the Bearer credential on authenticated control-UI routes. +// Passing the password through the Authorization header is the intended +// server-side contract for `gateway.auth.mode="password"`. Callers that need +// resilience to stale credentials should use `resolveControlUiAuthCandidates` +// below to retry with the alternate credential on 401. +function sanitizeHeaderToken(value: string | null): string | null { + if (!value) { + return null; + } + // Reject tokens that would smuggle CR/LF into the HTTP header. + return /[\r\n]/.test(value) ? null : value; +} + export function resolveControlUiAuthToken(source: ControlUiAuthSource): string | null { return ( - normalizeOptionalString(source.hello?.auth?.deviceToken) ?? - normalizeOptionalString(source.settings?.token) ?? - normalizeOptionalString(source.password) ?? + sanitizeHeaderToken(normalizeOptionalString(source.hello?.auth?.deviceToken) ?? null) ?? + sanitizeHeaderToken(normalizeOptionalString(source.settings?.token) ?? null) ?? + sanitizeHeaderToken(normalizeOptionalString(source.password) ?? null) ?? null ); } @@ -19,3 +33,24 @@ export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string const token = resolveControlUiAuthToken(source); return token ? `Bearer ${token}` : null; } + +// Ordered list of non-empty, header-safe shared-secret candidates. Used by +// call sites that can retry a single request against an alternate credential +// when the first returns 401 — for example, recovering from a stale +// `settings.token` when the live session is authenticated via `password`. +export function resolveControlUiAuthCandidates(source: ControlUiAuthSource): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of [ + normalizeOptionalString(source.hello?.auth?.deviceToken), + normalizeOptionalString(source.settings?.token), + normalizeOptionalString(source.password), + ]) { + const sanitized = sanitizeHeaderToken(raw ?? null); + if (sanitized && !seen.has(sanitized)) { + seen.add(sanitized); + out.push(sanitized); + } + } + return out; +} diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index b49fea2e2d0..0f1416f9031 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -101,4 +101,143 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + + it("includes the configured auth token on bootstrap fetches", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "/openclaw", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "session-token" }, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + Authorization: "Bearer session-token", + }), + }), + ); + + vi.unstubAllGlobals(); + }); + + it("retries with the alternate shared-secret credential when the first returns 401", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + basePath: "", + assistantName: "Ops", + assistantAvatar: null, + assistantAgentId: null, + serverVersion: "2026.4.22", + localMediaPreviewRoots: [], + embedSandbox: "scripts", + allowExternalEmbedUrls: false, + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "stale-token" }, + password: "fresh-password", + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, firstInit] = fetchMock.mock.calls[0] ?? []; + const [, secondInit] = fetchMock.mock.calls[1] ?? []; + expect((firstInit?.headers as Record | undefined)?.Authorization).toBe( + "Bearer stale-token", + ); + expect((secondInit?.headers as Record | undefined)?.Authorization).toBe( + "Bearer fresh-password", + ); + expect(state.assistantName).toBe("Ops"); + expect(state.serverVersion).toBe("2026.4.22"); + + vi.unstubAllGlobals(); + }); + + it("stops retrying on non-auth errors", async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "a" }, + password: "b", + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(state.assistantName).toBe("Assistant"); + + vi.unstubAllGlobals(); + }); + + it("does not attach auth headers to protocol-relative bootstrap URLs", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "//evil.example", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + settings: { token: "session-token" }, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + `//evil.example${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + }), + }), + ); + const [, init] = fetchMock.mock.calls[0] ?? []; + expect((init?.headers as Record | undefined)?.Authorization).toBeUndefined(); + + vi.unstubAllGlobals(); + }); }); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index f955f9c6b9f..a65d1f9139e 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -4,6 +4,7 @@ import { type ControlUiEmbedSandboxMode, } from "../../../../src/gateway/control-ui-contract.js"; import { normalizeAssistantIdentity } from "../assistant-identity.ts"; +import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts"; import { normalizeBasePath } from "../navigation.ts"; export type ControlUiBootstrapState = { @@ -15,6 +16,9 @@ export type ControlUiBootstrapState = { localMediaPreviewRoots: string[]; embedSandboxMode: ControlUiEmbedSandboxMode; allowExternalEmbedUrls: boolean; + hello?: { auth?: { deviceToken?: string | null } | null } | null; + settings?: { token?: string | null } | null; + password?: string | null; }; export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) { @@ -31,12 +35,30 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat : CONTROL_UI_BOOTSTRAP_CONFIG_PATH; try { - const res = await fetch(url, { - method: "GET", - headers: { Accept: "application/json" }, - credentials: "same-origin", - }); - if (!res.ok) { + const resolvedUrl = new URL(url, window.location.origin); + const sameOrigin = resolvedUrl.origin === window.location.origin; + const authCandidates = sameOrigin ? resolveControlUiAuthCandidates(state) : []; + // If credentials are available, try them in priority order; on 401/403 + // retry with the next candidate — recovers from a stale `settings.token` + // when the live session is authenticated via `password` (or vice versa). + // If no credentials are available, fall through with no Authorization + // header so bootstrap still works on auth-disabled deployments. + const attempts: string[] = authCandidates.length > 0 ? authCandidates : [""]; + let res: Response | null = null; + for (const candidate of attempts) { + const headers: Record = { Accept: "application/json" }; + if (candidate) { + headers.Authorization = `Bearer ${candidate}`; + } + res = await fetch(url, { method: "GET", headers, credentials: "same-origin" }); + if (res.ok) { + break; + } + if (res.status !== 401 && res.status !== 403) { + return; + } + } + if (!res || !res.ok) { return; } const parsed = (await res.json()) as ControlUiBootstrapConfig;