diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e43658f62..a293a5a96d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Control UI/CSP: tighten `img-src` to `'self' data:` only, and make Control UI avatar helpers drop remote `http(s)` and protocol-relative URLs so the UI falls back to the built-in logo/badge instead of issuing arbitrary remote image fetches. Same-origin avatar routes (relative paths) and `data:image/...` avatars still render. (#69773) - CLI/channels: keep `status`, `health`, `channels list`, and `channels status` on read-only channel metadata when Telegram, Slack, Discord, or third-party channel plugins are configured, avoiding full bundled plugin runtime imports on those cold paths. Fixes #69042. (#69479) Thanks @gumadeiras. - Synology Chat: validate outbound webhook `file_url` values against the shared SSRF policy before forwarding to the NAS, rejecting malformed URLs, non-`http(s)` schemes, and private/blocked network targets so the NAS cannot be used as a confused deputy to fetch internal addresses. (#69784) Thanks @eleqtrizit. +- Gateway/Control UI: require gateway auth on the Control UI avatar route (`GET /avatar/` and `?meta=1` metadata) when auth is configured, matching the sibling assistant-media route, and propagate the existing gateway token through the UI avatar fetch (bearer header + authenticated blob URL) so authenticated dashboards still load local avatars. (#69775) ## 2026.4.20 diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 53571b3798f..3c87a5e8df8 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -67,18 +67,29 @@ describe("handleControlUiHttpRequest", () => { return { res, end, handled }; } - function runAvatarRequest(params: { + async function runAvatarRequest(params: { url: string; method: "GET" | "HEAD"; resolveAvatar: Parameters[2]["resolveAvatar"]; basePath?: string; + auth?: ResolvedGatewayAuth; + headers?: IncomingMessage["headers"]; + trustedProxies?: string[]; + remoteAddress?: string; }) { const { res, end } = makeMockHttpResponse(); - const handled = handleControlUiAvatarRequest( - { url: params.url, method: params.method } as IncomingMessage, + const handled = await handleControlUiAvatarRequest( + { + url: params.url, + method: params.method, + headers: params.headers ?? {}, + socket: { remoteAddress: params.remoteAddress ?? "127.0.0.1" }, + } as IncomingMessage, res, { ...(params.basePath ? { basePath: params.basePath } : {}), + ...(params.auth ? { auth: params.auth } : {}), + ...(params.trustedProxies ? { trustedProxies: params.trustedProxies } : {}), resolveAvatar: params.resolveAvatar, }, ); @@ -148,6 +159,24 @@ describe("handleControlUiHttpRequest", () => { }); } + async function runTrustedProxyAvatarRequest(params: { + agentId?: string; + meta?: boolean; + headers?: IncomingMessage["headers"]; + resolveAvatar?: Parameters[2]["resolveAvatar"]; + }) { + return await runAvatarRequest({ + url: `/avatar/${params.agentId ?? "main"}${params.meta ? "?meta=1" : ""}`, + method: "GET", + auth: createTrustedProxyAuth(), + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: createTrustedProxyHeaders(params.headers), + resolveAvatar: + params.resolveAvatar ?? (() => ({ kind: "remote", url: "https://example.com/avatar.png" })), + }); + } + function expectMissingOperatorReadResponse(params: { handled: boolean; res: ReturnType["res"]; @@ -471,7 +500,7 @@ describe("handleControlUiHttpRequest", () => { const avatarPath = path.join(tmp, "main.png"); await fs.writeFile(avatarPath, "avatar-bytes\n"); - const { res, end, handled } = runAvatarRequest({ + const { res, end, handled } = await runAvatarRequest({ url: "/avatar/main", method: "GET", resolveAvatar: () => ({ kind: "local", filePath: avatarPath }), @@ -494,7 +523,7 @@ describe("handleControlUiHttpRequest", () => { const linkPath = path.join(tmp, "avatar-link.png"); await fs.symlink(outsideFile, linkPath); - const { res, end, handled } = runAvatarRequest({ + const { res, end, handled } = await runAvatarRequest({ url: "/avatar/main", method: "GET", resolveAvatar: () => ({ kind: "local", filePath: linkPath }), @@ -507,6 +536,71 @@ describe("handleControlUiHttpRequest", () => { } }); + it("serves local avatar bytes when auth is enabled and the token is valid", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-avatar-auth-")); + try { + const avatarPath = path.join(tmp, "main.png"); + await fs.writeFile(avatarPath, "avatar-bytes\n"); + + const { res, handled } = await runAvatarRequest({ + url: "/avatar/main", + method: "GET", + auth: { mode: "token", token: "test-token", allowTailscale: false }, + headers: { + authorization: "Bearer test-token", + }, + resolveAvatar: () => ({ kind: "local", filePath: avatarPath }), + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + + it("returns avatar metadata when auth is enabled and the token is valid", async () => { + const { res, end, handled } = await runAvatarRequest({ + url: "/avatar/main?meta=1", + method: "GET", + auth: { mode: "token", token: "test-token", allowTailscale: false }, + headers: { + authorization: "Bearer test-token", + }, + resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }), + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toEqual({ + avatarUrl: "https://example.com/avatar.png", + }); + }); + + it("rejects avatar requests without a valid auth token when auth is enabled", async () => { + const { res, handled, end } = await runAvatarRequest({ + url: "/avatar/main", + method: "GET", + auth: { mode: "token", token: "test-token", allowTailscale: false }, + resolveAvatar: () => ({ kind: "remote", url: "https://example.com/avatar.png" }), + }); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + expect(String(end.mock.calls[0]?.[0] ?? "")).toContain("Unauthorized"); + }); + + it("rejects trusted-proxy avatar metadata requests without operator.read scope", async () => { + const { res, handled, end } = await runTrustedProxyAvatarRequest({ + meta: true, + headers: { + "x-openclaw-scopes": "", + }, + }); + + expectMissingOperatorReadResponse({ handled, res, end }); + }); + it("rejects symlinked assets that resolve outside control-ui root", async () => { await withControlUiRoot({ fn: async (tmp) => { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 41e487f32ae..e22c3fb72e4 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -211,6 +211,78 @@ function resolveAssistantMediaAuthToken(req: IncomingMessage): string | undefine } } +function resolveControlUiReadAuthToken( + req: IncomingMessage, + opts?: { allowQueryToken?: boolean }, +): string | undefined { + const bearer = getBearerToken(req); + if (bearer) { + return bearer; + } + if (!opts?.allowQueryToken) { + return undefined; + } + return resolveAssistantMediaAuthToken(req); +} + +async function authorizeControlUiReadRequest( + req: IncomingMessage, + res: ServerResponse, + opts?: { + auth?: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + allowQueryToken?: boolean; + }, +): Promise { + if (!opts?.auth) { + return true; + } + + const token = resolveControlUiReadAuthToken(req, { + allowQueryToken: opts.allowQueryToken, + }); + const authResult = await authorizeHttpGatewayConnect({ + auth: opts.auth, + connectAuth: token ? { token, password: token } : null, + req, + browserOriginPolicy: resolveHttpBrowserOriginPolicy(req), + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + }); + if (!authResult.ok) { + sendGatewayAuthFailure(res, authResult); + return false; + } + + const trustDeclaredOperatorScopes = + authResult.method !== "token" && + authResult.method !== "password" && + authResult.method !== "none"; + if (!trustDeclaredOperatorScopes) { + return true; + } + + const requestedScopes = resolveTrustedHttpOperatorScopes(req, { + trustDeclaredOperatorScopes, + }); + const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes); + if (!scopeAuth.allowed) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return false; + } + + return true; +} + type AssistantMediaAvailability = | { available: true } | { available: false; reason: string; code: string }; @@ -297,41 +369,16 @@ export async function handleControlUiAssistantMediaRequest( } applyControlUiSecurityHeaders(res); - if (opts?.auth) { - const token = resolveAssistantMediaAuthToken(req); - const authResult = await authorizeHttpGatewayConnect({ - auth: opts.auth, - connectAuth: token ? { token, password: token } : null, - req, - browserOriginPolicy: resolveHttpBrowserOriginPolicy(req), - trustedProxies: opts.trustedProxies, - allowRealIpFallback: opts.allowRealIpFallback, - rateLimiter: opts.rateLimiter, - }); - if (!authResult.ok) { - sendGatewayAuthFailure(res, authResult); - return true; - } - const trustDeclaredOperatorScopes = - authResult.method !== "token" && - authResult.method !== "password" && - authResult.method !== "none"; - if (trustDeclaredOperatorScopes) { - const requestedScopes = resolveTrustedHttpOperatorScopes(req, { - trustDeclaredOperatorScopes, - }); - const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes); - if (!scopeAuth.allowed) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: `missing scope: ${scopeAuth.missingScope}`, - }, - }); - return true; - } - } + if ( + !(await authorizeControlUiReadRequest(req, res, { + auth: opts?.auth, + trustedProxies: opts?.trustedProxies, + allowRealIpFallback: opts?.allowRealIpFallback, + rateLimiter: opts?.rateLimiter, + allowQueryToken: true, + })) + ) { + return true; } const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? ""); if (!source) { @@ -401,11 +448,18 @@ export async function handleControlUiAssistantMediaRequest( } } -export function handleControlUiAvatarRequest( +export async function handleControlUiAvatarRequest( req: IncomingMessage, res: ServerResponse, - opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution }, -): boolean { + opts: { + basePath?: string; + resolveAvatar: (agentId: string) => ControlUiAvatarResolution; + auth?: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + }, +): Promise { const urlRaw = req.url; if (!urlRaw) { return false; @@ -425,6 +479,16 @@ export function handleControlUiAvatarRequest( } applyControlUiSecurityHeaders(res); + if ( + !(await authorizeControlUiReadRequest(req, res, { + auth: opts.auth, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + })) + ) { + return true; + } const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean); const agentId = agentIdParts[0] ?? ""; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index a76dad64b6f..b047b237d0b 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -1081,6 +1081,10 @@ export function createGatewayHttpServer(opts: { const { resolveAgentAvatar } = await getIdentityAvatarModule(); return handleControlUiAvatarRequest(req, res, { basePath: controlUiBasePath, + auth: resolvedAuth, + trustedProxies, + allowRealIpFallback, + rateLimiter, resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId, { includeUiOverride: true }), }); diff --git a/ui/src/ui/app-channels.ts b/ui/src/ui/app-channels.ts index f1204b49861..bed32dd6ad5 100644 --- a/ui/src/ui/app-channels.ts +++ b/ui/src/ui/app-channels.ts @@ -5,8 +5,8 @@ import { waitWhatsAppLogin, type ChannelsState, } from "./controllers/channels.ts"; +import { resolveControlUiAuthHeader } from "./control-ui-auth.ts"; import { loadConfig, saveConfig, type ConfigState } from "./controllers/config.ts"; -import { normalizeOptionalString } from "./string-coerce.ts"; import type { NostrProfile } from "./types.ts"; import { createNostrProfileFormState } from "./views/channels.nostr-profile-form.ts"; @@ -78,24 +78,8 @@ function buildNostrProfileUrl(accountId: string, suffix = ""): string { return `/api/channels/nostr/${encodeURIComponent(accountId)}/profile${suffix}`; } -function resolveGatewayHttpAuthHeader(host: ChannelsActionHost): string | null { - const deviceToken = normalizeOptionalString(host.hello?.auth?.deviceToken); - if (deviceToken) { - return `Bearer ${deviceToken}`; - } - const token = normalizeOptionalString(host.settings.token); - if (token) { - return `Bearer ${token}`; - } - const password = normalizeOptionalString(host.password); - if (password) { - return `Bearer ${password}`; - } - return null; -} - function buildGatewayHttpHeaders(host: ChannelsActionHost): Record { - const authorization = resolveGatewayHttpAuthHeader(host); + const authorization = resolveControlUiAuthHeader(host); return authorization ? { Authorization: authorization } : {}; } diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 9b339de2c75..7434a32353e 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -107,12 +107,122 @@ describe("refreshChatAvatar", () => { await refreshChatAvatar(host); expect(fetchMock).toHaveBeenCalledWith( - "avatar/main?meta=1", + "/avatar/main?meta=1", expect.objectContaining({ method: "GET" }), ); expect(host.chatAvatarUrl).toBe("/avatar/main"); }); + it("prefers the paired device token for avatar metadata and local avatar URLs", async () => { + const createObjectURL = vi.fn(() => "blob:device-avatar"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input); + if (url === "/openclaw/avatar/main?meta=1") { + return Promise.resolve({ + ok: true, + json: async () => ({ avatarUrl: "/avatar/main" }), + }); + } + if (url === "/avatar/main") { + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["avatar"]), + }); + } + throw new Error(`Unexpected avatar URL: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const host = makeHost({ + basePath: "/openclaw/", + sessionKey: "agent:main", + settings: { token: "session-token" }, + password: "shared-password", + hello: { auth: { deviceToken: "device-token" } } as ChatHost["hello"], + }); + await refreshChatAvatar(host); + + expect(fetchMock).toHaveBeenCalledWith( + "/openclaw/avatar/main?meta=1", + expect.objectContaining({ + method: "GET", + headers: { Authorization: "Bearer device-token" }, + }), + ); + expect(fetchMock).toHaveBeenCalledWith( + "/avatar/main", + expect.objectContaining({ + method: "GET", + headers: { Authorization: "Bearer device-token" }, + }), + ); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).not.toHaveBeenCalled(); + expect(host.chatAvatarUrl).toBe("blob:device-avatar"); + }); + + it("fetches local avatars through Authorization headers instead of tokenized URLs", async () => { + const createObjectURL = vi.fn(() => "blob:session-avatar"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal( + "URL", + class extends URL { + static createObjectURL = createObjectURL; + static revokeObjectURL = revokeObjectURL; + }, + ); + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = requestUrl(input); + if (url === "/openclaw/avatar/main?meta=1") { + return Promise.resolve({ + ok: true, + json: async () => ({ avatarUrl: "/avatar/main" }), + }); + } + if (url === "/avatar/main") { + return Promise.resolve({ + ok: true, + blob: async () => new Blob(["avatar"]), + }); + } + throw new Error(`Unexpected avatar URL: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const host = makeHost({ + basePath: "/openclaw/", + sessionKey: "agent:main", + settings: { token: "session-token" }, + }); + await refreshChatAvatar(host); + + expect(fetchMock).toHaveBeenCalledWith( + "/openclaw/avatar/main?meta=1", + expect.objectContaining({ + method: "GET", + headers: { Authorization: "Bearer session-token" }, + }), + ); + expect(fetchMock).toHaveBeenCalledWith( + "/avatar/main", + expect.objectContaining({ + method: "GET", + headers: { Authorization: "Bearer session-token" }, + }), + ); + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(revokeObjectURL).not.toHaveBeenCalled(); + expect(host.chatAvatarUrl).toBe("blob:session-avatar"); + }); + it("keeps mounted dashboard avatar endpoints under the normalized base path", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, @@ -148,13 +258,13 @@ describe("refreshChatAvatar", () => { const opsRequest = createDeferred<{ avatarUrl?: string }>(); const fetchMock = vi.fn((input: string | URL | Request) => { const url = requestUrl(input); - if (url === "avatar/main?meta=1") { + if (url === "/avatar/main?meta=1") { return Promise.resolve({ ok: true, json: async () => mainRequest.promise, }); } - if (url === "avatar/ops?meta=1") { + if (url === "/avatar/ops?meta=1") { return Promise.resolve({ ok: true, json: async () => opsRequest.promise, @@ -180,12 +290,12 @@ describe("refreshChatAvatar", () => { expect(host.chatAvatarUrl).toBe("/avatar/ops"); expect(fetchMock).toHaveBeenNthCalledWith( 1, - "avatar/main?meta=1", + "/avatar/main?meta=1", expect.objectContaining({ method: "GET" }), ); expect(fetchMock).toHaveBeenNthCalledWith( 2, - "avatar/ops?meta=1", + "/avatar/ops?meta=1", expect.objectContaining({ method: "GET" }), ); }); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 0c800fa3a90..1a65e23d036 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -13,6 +13,7 @@ import { } from "./controllers/chat.ts"; import { loadModels } from "./controllers/models.ts"; import { loadSessions, type SessionsState } from "./controllers/sessions.ts"; +import { resolveControlUiAuthHeader } from "./control-ui-auth.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import { parseAgentSessionKey } from "./session-key.ts"; @@ -36,6 +37,8 @@ export type ChatHost = { lastError?: string | null; sessionKey: string; basePath: string; + settings?: { token?: string | null }; + password?: string | null; hello: GatewayHelloOk | null; chatAvatarUrl: string | null; chatSideResult?: ChatSideResult | null; @@ -497,6 +500,8 @@ type SessionDefaultsSnapshot = { defaultAgentId?: string; }; +const chatAvatarObjectUrls = new WeakMap(); + function beginChatAvatarRequest(host: ChatHost): number { const key = host as object; const nextVersion = (chatAvatarRequestVersions.get(key) ?? 0) + 1; @@ -525,12 +530,43 @@ function resolveAgentIdForSession(host: ChatHost): string | null { function buildAvatarMetaUrl(basePath: string, agentId: string): string { const base = normalizeBasePath(basePath); const encoded = encodeURIComponent(agentId); - return base ? `${base}/avatar/${encoded}?meta=1` : `avatar/${encoded}?meta=1`; + return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`; +} + +function clearChatAvatarUrl(host: ChatHost) { + const key = host as object; + const previousBlobUrl = chatAvatarObjectUrls.get(key); + if (previousBlobUrl) { + URL.revokeObjectURL(previousBlobUrl); + chatAvatarObjectUrls.delete(key); + } + host.chatAvatarUrl = null; +} + +function setChatAvatarUrl(host: ChatHost, nextUrl: string | null) { + const key = host as object; + const previousBlobUrl = chatAvatarObjectUrls.get(key); + if (previousBlobUrl && previousBlobUrl !== nextUrl) { + URL.revokeObjectURL(previousBlobUrl); + chatAvatarObjectUrls.delete(key); + } + if (nextUrl?.startsWith("blob:")) { + chatAvatarObjectUrls.set(key, nextUrl); + } + host.chatAvatarUrl = nextUrl; +} + +function buildControlUiAuthHeaders(authHeader: string | null): Record | undefined { + return authHeader ? { Authorization: authHeader } : undefined; +} + +function isLocalControlUiAvatarUrl(avatarUrl: string): boolean { + return avatarUrl.startsWith("/"); } export async function refreshChatAvatar(host: ChatHost) { if (!host.connected) { - host.chatAvatarUrl = null; + clearChatAvatarUrl(host); return; } const sessionKey = host.sessionKey; @@ -538,19 +574,21 @@ export async function refreshChatAvatar(host: ChatHost) { const agentId = resolveAgentIdForSession(host); if (!agentId) { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { - host.chatAvatarUrl = null; + clearChatAvatarUrl(host); } return; } - host.chatAvatarUrl = null; + clearChatAvatarUrl(host); + const authHeader = resolveControlUiAuthHeader(host); + const headers = buildControlUiAuthHeaders(authHeader); const url = buildAvatarMetaUrl(host.basePath, agentId); try { - const res = await fetch(url, { method: "GET" }); + const res = await fetch(url, { method: "GET", ...(headers ? { headers } : {}) }); if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { return; } if (!res.ok) { - host.chatAvatarUrl = null; + clearChatAvatarUrl(host); return; } const data = (await res.json()) as { avatarUrl?: unknown }; @@ -558,10 +596,30 @@ export async function refreshChatAvatar(host: ChatHost) { return; } const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : ""; - host.chatAvatarUrl = avatarUrl && isRenderableControlUiAvatarUrl(avatarUrl) ? avatarUrl : null; + if (!avatarUrl || !isRenderableControlUiAvatarUrl(avatarUrl)) { + clearChatAvatarUrl(host); + return; + } + if (!authHeader || !isLocalControlUiAvatarUrl(avatarUrl)) { + setChatAvatarUrl(host, avatarUrl); + return; + } + const avatarRes = await fetch(avatarUrl, { method: "GET", headers: { Authorization: authHeader } }); + if (!avatarRes.ok) { + if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { + clearChatAvatarUrl(host); + } + return; + } + const blobUrl = URL.createObjectURL(await avatarRes.blob()); + if (!shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { + URL.revokeObjectURL(blobUrl); + return; + } + setChatAvatarUrl(host, blobUrl); } catch { if (shouldApplyChatAvatarResult(host, requestVersion, sessionKey)) { - host.chatAvatarUrl = null; + clearChatAvatarUrl(host); } } } diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 101e052f975..44cf48d3415 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -178,9 +178,20 @@ describe("parseSessionKey", () => { }); describe("resolveAssistantAttachmentAuthToken", () => { + it("prefers the paired device token when present", () => { + expect( + resolveAssistantAttachmentAuthToken({ + hello: { auth: { deviceToken: "device-token" } } as AppViewState["hello"], + settings: { token: "session-token" } as AppViewState["settings"], + password: "shared-password", + }), + ).toBe("device-token"); + }); + it("prefers the explicit gateway token when present", () => { expect( resolveAssistantAttachmentAuthToken({ + hello: null, settings: { token: "session-token" } as AppViewState["settings"], password: "shared-password", }), @@ -190,6 +201,7 @@ describe("resolveAssistantAttachmentAuthToken", () => { it("falls back to the shared password when token is blank", () => { expect( resolveAssistantAttachmentAuthToken({ + hello: null, settings: { token: " " } as AppViewState["settings"], password: "shared-password", }), @@ -199,6 +211,7 @@ describe("resolveAssistantAttachmentAuthToken", () => { it("returns null when neither auth secret is available", () => { expect( resolveAssistantAttachmentAuthToken({ + hello: null, settings: { token: "" } as AppViewState["settings"], password: " ", }), diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index f0424cb1d3a..70a027c52e1 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import { t } from "../i18n/index.ts"; import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; +import { resolveControlUiAuthToken } from "./control-ui-auth.ts"; import { syncUrlWithSessionKey } from "./app-settings.ts"; import type { AppViewState } from "./app-view-state.ts"; import { @@ -43,12 +44,8 @@ type ChatRefreshHost = AppViewState & { updateComplete?: Promise; }; -export function resolveAssistantAttachmentAuthToken( - state: Pick, -) { - return ( - normalizeOptionalString(state.settings.token) ?? normalizeOptionalString(state.password) ?? null - ); +export function resolveAssistantAttachmentAuthToken(state: Pick) { + return resolveControlUiAuthToken(state); } function resolveSidebarChatSessionKey(state: AppViewState): string { diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index c4bc0d431dd..7fea8f031f2 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -144,6 +144,24 @@ afterEach(() => { }); describe("grouped chat rendering", () => { + it("falls back to the logo while authenticated avatar routes are loading", () => { + const container = document.createElement("div"); + renderAssistantMessage( + container, + { + role: "assistant", + content: [{ type: "text", text: "Hello" }], + }, + { + assistantAvatar: "/avatar/main", + assistantAttachmentAuthToken: "session-token", + }, + ); + + const img = container.querySelector("img.chat-avatar"); + expect(img?.getAttribute("src")).toBe("/openclaw-logo.svg"); + }); + it("positions delete confirm by message side", () => { const renderDeletable = (role: "user" | "assistant") => { const container = document.createElement("div"); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index ee945ff24b7..b61dc3169c1 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -184,10 +184,14 @@ function extractImages(message: unknown): ImageBlock[] { return images; } -export function renderReadingIndicatorGroup(assistant?: AssistantIdentity, basePath?: string) { +export function renderReadingIndicatorGroup( + assistant?: AssistantIdentity, + basePath?: string, + authToken?: string | null, +) { return html`
- ${renderAvatar("assistant", assistant, basePath)} + ${renderAvatar("assistant", assistant, basePath, authToken)}