fix(gateway): require auth for control ui bootstrap config (#70247)

* fix(gateway): require auth for control ui bootstrap config

* fix(ui): send auth on bootstrap fetch

* fix(ui): keep bootstrap auth same-origin

* fix(ui): refresh bootstrap after auth hello

* docs(changelog): note control ui bootstrap auth

* fix(ui): retry bootstrap auth with alternate shared secret on 401
This commit is contained in:
Devin Robison
2026-04-22 16:52:08 -06:00
committed by GitHub
parent c87c9742ed
commit 2321d67263
11 changed files with 339 additions and 42 deletions

View File

@@ -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

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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<boolean> {
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 })

View File

@@ -83,7 +83,7 @@ describe("GatewayClient", () => {
it("returns 404 for missing static asset paths instead of SPA fallback", async () => {
await withControlUiRoot({ faviconSvg: "<svg/>" }, 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 } },

View File

@@ -1099,6 +1099,10 @@ export function createGatewayHttpServer(opts: {
config: configSnapshot,
agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId,
root: controlUiRoot,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
});
}

View File

@@ -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<typeof vi.fn>;
@@ -94,6 +95,10 @@ vi.mock("./controllers/chat.ts", async (importOriginal) => {
};
});
vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
loadControlUiBootstrapConfig: loadControlUiBootstrapConfigMock,
}));
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
chatSideResult: unknown;
chatSideResultTerminalRuns: Set<string>;
@@ -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();

View File

@@ -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<typeof loadControlUiBootstrapConfig>[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;

View File

@@ -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<string>();
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;
}

View File

@@ -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<string, string> | undefined)?.Authorization).toBe(
"Bearer stale-token",
);
expect((secondInit?.headers as Record<string, string> | 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<string, string> | undefined)?.Authorization).toBeUndefined();
vi.unstubAllGlobals();
});
});

View File

@@ -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<string, string> = { 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;