fix(gateway): require auth for control UI avatar route (#69775)

* fix(gateway): require auth for control UI avatar route

* chore: add changelog for control UI avatar auth

* fix(control-ui): honor device auth for avatar urls

* fix(control-ui): avoid query tokens for avatar auth

* fix(control-ui): render authenticated avatar blob URLs in chat views

* fix(control-ui): restore normalizeOptionalString import in render helpers
This commit is contained in:
Devin Robison
2026-04-21 13:51:03 -06:00
committed by GitHub
parent 6b185e2849
commit 2ce16e558e
15 changed files with 513 additions and 87 deletions

View File

@@ -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/<agentId>` 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

View File

@@ -67,18 +67,29 @@ describe("handleControlUiHttpRequest", () => {
return { res, end, handled };
}
function runAvatarRequest(params: {
async function runAvatarRequest(params: {
url: string;
method: "GET" | "HEAD";
resolveAvatar: Parameters<typeof handleControlUiAvatarRequest>[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<typeof handleControlUiAvatarRequest>[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<typeof makeMockHttpResponse>["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) => {

View File

@@ -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<boolean> {
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<boolean> {
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] ?? "";

View File

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

View File

@@ -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<string, string> {
const authorization = resolveGatewayHttpAuthHeader(host);
const authorization = resolveControlUiAuthHeader(host);
return authorization ? { Authorization: authorization } : {};
}

View File

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

View File

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

View File

@@ -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: " ",
}),

View File

@@ -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<unknown>;
};
export function resolveAssistantAttachmentAuthToken(
state: Pick<AppViewState, "settings" | "password">,
) {
return (
normalizeOptionalString(state.settings.token) ?? normalizeOptionalString(state.password) ?? null
);
export function resolveAssistantAttachmentAuthToken(state: Pick<AppViewState, "hello" | "settings" | "password">) {
return resolveControlUiAuthToken(state);
}
function resolveSidebarChatSessionKey(state: AppViewState): string {

View File

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

View File

@@ -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`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, basePath)}
${renderAvatar("assistant", assistant, basePath, authToken)}
<div class="chat-group-messages">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
@@ -205,6 +209,7 @@ export function renderStreamingGroup(
onOpenSidebar?: (content: SidebarContent) => void,
assistant?: AssistantIdentity,
basePath?: string,
authToken?: string | null,
) {
const timestamp = new Date(startedAt).toLocaleTimeString([], {
hour: "numeric",
@@ -214,7 +219,7 @@ export function renderStreamingGroup(
return html`
<div class="chat-group assistant">
${renderAvatar("assistant", assistant, basePath)}
${renderAvatar("assistant", assistant, basePath, authToken)}
<div class="chat-group-messages">
${renderGroupedMessage(
{
@@ -295,6 +300,7 @@ export function renderMessageGroup(
avatar: opts.assistantAvatar ?? null,
},
opts.basePath,
opts.assistantAttachmentAuthToken,
)}
<div class="chat-group-messages">
${group.messages.map((item, index) =>
@@ -586,6 +592,7 @@ function renderAvatar(
role: string,
assistant?: Pick<AssistantIdentity, "name" | "avatar">,
basePath?: string,
authToken?: string | null,
) {
const normalized = normalizeRoleForGrouping(role);
const assistantName = assistant?.name?.trim() || "Assistant";
@@ -638,6 +645,13 @@ function renderAvatar(
if (assistantAvatar && normalized === "assistant") {
if (isAvatarUrl(assistantAvatar)) {
if (authToken?.trim() && assistantAvatar.startsWith("/")) {
return html`<img
class="chat-avatar ${className} chat-avatar--logo"
src="${agentLogoUrl(basePath ?? "")}"
alt="${assistantName}"
/>`;
}
return html`<img
class="chat-avatar ${className}"
src="${assistantAvatar}"

View File

@@ -0,0 +1,21 @@
import { normalizeOptionalString } from "./string-coerce.ts";
type ControlUiAuthSource = {
hello?: { auth?: { deviceToken?: string | null } | null } | null;
settings?: { token?: string | null } | null;
password?: string | null;
};
export function resolveControlUiAuthToken(source: ControlUiAuthSource): string | null {
return (
normalizeOptionalString(source.hello?.auth?.deviceToken) ??
normalizeOptionalString(source.settings?.token) ??
normalizeOptionalString(source.password) ??
null
);
}
export function resolveControlUiAuthHeader(source: ControlUiAuthSource): string | null {
const token = resolveControlUiAuthToken(source);
return token ? `Bearer ${token}` : null;
}

View File

@@ -4,6 +4,7 @@ import {
buildAgentContext,
resolveConfiguredCronModelSuggestions,
resolveAgentAvatarUrl,
resolveChatAvatarRenderUrl,
resolveEffectiveModelFallbacks,
sortLocaleStrings,
} from "./agents-utils.ts";
@@ -149,6 +150,32 @@ describe("resolveAgentAvatarUrl", () => {
});
});
describe("resolveChatAvatarRenderUrl", () => {
it("accepts a blob: URL produced by an authenticated avatar fetch", () => {
expect(
resolveChatAvatarRenderUrl("blob:http://localhost/uuid-123", {
identity: { avatarUrl: "/avatar/main" },
}),
).toBe("blob:http://localhost/uuid-123");
});
it("falls back to the config-sanitized avatar when no blob candidate is present", () => {
expect(
resolveChatAvatarRenderUrl(null, {
identity: { avatarUrl: "/avatar/main" },
}),
).toBe("/avatar/main");
});
it("rejects remote URLs passed as the render candidate", () => {
expect(
resolveChatAvatarRenderUrl("https://example.com/avatar.png", {
identity: { avatarUrl: "/avatar/main" },
}),
).toBe("/avatar/main");
});
});
describe("buildAgentContext", () => {
it("falls back to agent payload workspace/model when config form is unavailable", () => {
const context = buildAgentContext(

View File

@@ -224,6 +224,22 @@ export function resolveAgentAvatarUrl(
return null;
}
// Chat-render variant: accept `blob:` URLs (produced locally by
// `URL.createObjectURL` after an authenticated avatar fetch) in addition to
// config-sanitized candidates. The config path still gates untrusted
// http(s)/data sources through `resolveAgentAvatarUrl`.
export function resolveChatAvatarRenderUrl(
candidate: string | null | undefined,
agent: { identity?: { avatar?: string; avatarUrl?: string } },
agentIdentity?: AgentIdentityResult | null,
): string | null {
const trimmed = normalizeOptionalString(candidate);
if (trimmed?.startsWith("blob:")) {
return trimmed;
}
return resolveAgentAvatarUrl(agent, agentIdentity);
}
export function agentLogoUrl(basePath: string): string {
const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? "";
return base ? `${base}/favicon.svg` : "favicon.svg";

View File

@@ -40,7 +40,7 @@ import type { SidebarContent } from "../sidebar-content.ts";
import { detectTextDirection } from "../text-direction.ts";
import type { SessionsListResult } from "../types.ts";
import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts";
import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts";
import { agentLogoUrl, resolveChatAvatarRenderUrl } from "./agents-utils.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@@ -465,7 +465,7 @@ const WELCOME_SUGGESTIONS = [
function renderWelcomeState(props: ChatProps): TemplateResult {
const name = props.assistantName || "Assistant";
const avatar = resolveAgentAvatarUrl({
const avatar = resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
@@ -741,7 +741,7 @@ export function renderChat(props: ChatProps) {
const assistantIdentity = {
name: props.assistantName,
avatar:
resolveAgentAvatarUrl({
resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
identity: {
avatar: props.assistantAvatar ?? undefined,
avatarUrl: props.assistantAvatarUrl ?? undefined,
@@ -866,7 +866,11 @@ export function renderChat(props: ChatProps) {
`;
}
if (item.kind === "reading-indicator") {
return renderReadingIndicatorGroup(assistantIdentity, props.basePath);
return renderReadingIndicatorGroup(
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "stream") {
return renderStreamingGroup(
@@ -875,6 +879,7 @@ export function renderChat(props: ChatProps) {
props.onOpenSidebar,
assistantIdentity,
props.basePath,
props.assistantAttachmentAuthToken ?? null,
);
}
if (item.kind === "group") {