mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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] ?? "";
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
@@ -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