mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:00:41 +00:00
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:
@@ -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 } : {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: " ",
|
||||
}),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}"
|
||||
|
||||
21
ui/src/ui/control-ui-auth.ts
Normal file
21
ui/src/ui/control-ui-auth.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user