mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
* fix(agent): return data URIs for workspace-relative identity card avatars (#97602) agent.identity.get resolved workspace-relative agent avatars via resolveAssistantAvatarUrl to /avatar/<agentId> route URLs. <img> tags in the Control UI Personal card cannot load these URLs because they lack Bearer auth headers, showing broken images (401). Read the validated local avatar file into a data URI inline before constructing the identity response. The file-reading helper lives in agent.ts as a private (non-exported) function so it does not leak onto the public Plugin SDK surface. The workspace root is canonicalised with realpathSync to match the already-resolved filePath from resolveAgentAvatar, fixing containment checks for symlinked workspaces. Fixes #97602 Co-Authored-By: Claude <noreply@anthropic.com> * fix(gateway): inline workspace avatars in bootstrap config Personal card (#97602) agent.identity.get already returns inline data URIs for workspace-local avatars, but the Control UI Personal card is initialized from the bootstrap config (control-ui-config.json), which still mapped workspace avatars to the auth-gated /avatar/<agentId> route. An <img> cannot carry Bearer auth, so the linked Personal card 401/broken-image symptom persisted despite the RPC fix. Consolidate the workspace-safe avatar reader into a single gateway-internal helper (readLocalAvatarDataUrl in session-utils) and use it in both agent.identity.get and the bootstrap config handler, so the identity and bootstrap projections produce identical data URIs. Not re-exported on the Plugin SDK surface. Adds a bootstrap-config regression test. Co-Authored-By: Claude <noreply@anthropic.com> * fix(control-ui): inline workspace assistant avatars Project workspace-local assistant avatars into browser-safe data URLs across bootstrap and agent identity RPCs while preserving same-origin avatar routes and shared size limits. Co-authored-by: LZY3538 <LZY3538@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: LZY3538 <LZY3538@users.noreply.github.com>
This commit is contained in:
@@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
),
|
||||
publicExports: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
|
||||
10488,
|
||||
10489,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
|
||||
5235,
|
||||
5236,
|
||||
env,
|
||||
),
|
||||
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Projects resolved agent avatars into browser-safe URLs for internal Gateway/UI payloads.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import type { AgentAvatarResolution } from "./identity-avatar.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { AVATAR_MAX_BYTES, resolveAvatarMime } from "../shared/avatar-policy.js";
|
||||
|
||||
function readLocalAvatarDataUrl(
|
||||
resolved: Extract<AgentAvatarResolution, { kind: "local" }>,
|
||||
): string | undefined {
|
||||
try {
|
||||
// Keep validation and reading on the same opened descriptor. Reopening the
|
||||
// pathname here would let a symlink swap escape the workspace boundary.
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: resolved.filePath,
|
||||
rootPath: resolved.workspaceRoot,
|
||||
rootRealPath: resolved.workspaceRoot,
|
||||
boundaryLabel: "workspace root",
|
||||
maxBytes: AVATAR_MAX_BYTES,
|
||||
rejectHardlinks: true,
|
||||
skipLexicalRootCheck: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const buffer = fs.readFileSync(opened.fd);
|
||||
const mime = resolveAvatarMime(opened.path);
|
||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a verified avatar to the browser-safe value shared by Gateway identity surfaces. */
|
||||
export function resolveAgentAvatarUrl(resolved: AgentAvatarResolution): string | undefined {
|
||||
if (resolved.kind === "remote" || resolved.kind === "data") {
|
||||
return resolved.url;
|
||||
}
|
||||
return resolved.kind === "local" ? readLocalAvatarDataUrl(resolved) : undefined;
|
||||
}
|
||||
@@ -5,7 +5,14 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "./identity-avatar.js";
|
||||
import {
|
||||
resolveAgentAvatar,
|
||||
resolveAgentAvatarFromSource,
|
||||
resolvePublicAgentAvatarSource,
|
||||
} from "./identity-avatar.js";
|
||||
import { resolveAgentAvatarUrl } from "./identity-avatar-projection.js";
|
||||
|
||||
const AVATAR_MAX_DATA_URL_CHARS = 4 * Math.ceil(AVATAR_MAX_BYTES / 3) + 64;
|
||||
|
||||
async function writeFile(filePath: string, contents = "avatar") {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
@@ -212,6 +219,26 @@ describe("resolveAgentAvatar", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("projects an avatar at the exact local byte limit without exceeding the data URL cap", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "max.png");
|
||||
await fs.mkdir(path.dirname(avatarPath), { recursive: true });
|
||||
await fs.writeFile(avatarPath, Buffer.alloc(AVATAR_MAX_BYTES));
|
||||
const resolved = resolveAgentAvatar(
|
||||
{
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "avatars/max.png" } }],
|
||||
},
|
||||
},
|
||||
"main",
|
||||
);
|
||||
const dataUrl = resolveAgentAvatarUrl(resolved);
|
||||
|
||||
expect(dataUrl?.startsWith("data:image/png;base64,")).toBe(true);
|
||||
expect(dataUrl?.length).toBeLessThanOrEqual(AVATAR_MAX_DATA_URL_CHARS);
|
||||
});
|
||||
|
||||
it("accepts remote and data avatars", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
@@ -335,3 +362,116 @@ describe("resolveAgentAvatar", () => {
|
||||
await expectLocalAvatarPath(cfg, workspace, "ui-avatar.png", { includeUiOverride: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent avatar browser projection", () => {
|
||||
it("projects local, remote, and data sources without changing their semantics", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
await writeFile(path.join(workspace, "avatars", "main.png"), "avatar");
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "avatars/main.png" } }],
|
||||
},
|
||||
};
|
||||
const expectedLocal = `data:image/png;base64,${Buffer.from("avatar").toString("base64")}`;
|
||||
const local = resolveAgentAvatar(cfg, "main");
|
||||
|
||||
expect(resolveAgentAvatarUrl(local)).toBe(expectedLocal);
|
||||
expect(
|
||||
resolveAgentAvatarUrl(resolveAgentAvatarFromSource(cfg, "main", "avatars/main.png")),
|
||||
).toBe(expectedLocal);
|
||||
expect(
|
||||
resolveAgentAvatarUrl(
|
||||
resolveAgentAvatarFromSource(cfg, "main", "https://example.com/avatar.png"),
|
||||
),
|
||||
).toBe("https://example.com/avatar.png");
|
||||
expect(
|
||||
resolveAgentAvatarUrl(
|
||||
resolveAgentAvatarFromSource(cfg, "main", "data:image/png;base64,aaaa"),
|
||||
),
|
||||
).toBe("data:image/png;base64,aaaa");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["jpg", "image/jpeg"],
|
||||
["svg", "image/svg+xml"],
|
||||
] as const)("uses the shared MIME policy for .%s files", async (extension, mime) => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
await writeFile(path.join(workspace, `avatar.${extension}`), "avatar");
|
||||
const resolved = resolveAgentAvatar(
|
||||
{
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: `avatar.${extension}` } }],
|
||||
},
|
||||
},
|
||||
"main",
|
||||
);
|
||||
|
||||
expect(resolveAgentAvatarUrl(resolved)).toBe(
|
||||
`data:${mime};base64,${Buffer.from("avatar").toString("base64")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not project rejected local avatar paths", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
const missing = resolveAgentAvatar(
|
||||
{
|
||||
agents: {
|
||||
list: [{ id: "main", workspace, identity: { avatar: "avatars/missing.png" } }],
|
||||
},
|
||||
},
|
||||
"main",
|
||||
);
|
||||
|
||||
expect(resolveAgentAvatarUrl(missing)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rechecks the workspace boundary when a resolved path is replaced before reading", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatar.png");
|
||||
const outsidePath = path.join(root, "outside.png");
|
||||
await writeFile(avatarPath, "avatar");
|
||||
await writeFile(outsidePath, "secret");
|
||||
const resolved = resolveAgentAvatar(
|
||||
{
|
||||
agents: { list: [{ id: "main", workspace, identity: { avatar: "avatar.png" } }] },
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(resolved.kind).toBe("local");
|
||||
|
||||
await fs.rm(avatarPath);
|
||||
try {
|
||||
await fs.symlink(outsidePath, avatarPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
expect(resolveAgentAvatarUrl(resolved)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects hardlinked avatar files at the read boundary", async () => {
|
||||
const root = await createTempAvatarRoot();
|
||||
const workspace = path.join(root, "work");
|
||||
const outsidePath = path.join(root, "outside.png");
|
||||
const avatarPath = path.join(workspace, "avatar.png");
|
||||
await writeFile(outsidePath, "secret");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
try {
|
||||
await fs.link(outsidePath, avatarPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const resolved = resolveAgentAvatar(
|
||||
{
|
||||
agents: { list: [{ id: "main", workspace, identity: { avatar: "avatar.png" } }] },
|
||||
},
|
||||
"main",
|
||||
);
|
||||
expect(resolved.kind).toBe("local");
|
||||
expect(resolveAgentAvatarUrl(resolved)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ import { resolveAgentIdentity } from "./identity.js";
|
||||
// shared avatar policy limits.
|
||||
export type AgentAvatarResolution =
|
||||
| { kind: "none"; reason: string; source?: string }
|
||||
| { kind: "local"; filePath: string; source: string }
|
||||
| { kind: "local"; filePath: string; workspaceRoot: string; source: string }
|
||||
| { kind: "remote"; url: string; source: string }
|
||||
| { kind: "data"; url: string; source: string };
|
||||
|
||||
@@ -37,7 +37,7 @@ type AgentAvatarPublicSourceInput = {
|
||||
const PUBLIC_AVATAR_SOURCE_MAX_CHARS = 256;
|
||||
const PUBLIC_DATA_AVATAR_HEADER_MAX_CHARS = 64;
|
||||
|
||||
function resolveAvatarSource(
|
||||
function resolveEffectiveAvatarSource(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
opts?: { includeUiOverride?: boolean },
|
||||
@@ -77,7 +77,7 @@ function resolveExistingPath(value: string): string {
|
||||
function resolveLocalAvatarPath(params: {
|
||||
raw: string;
|
||||
workspaceDir: string;
|
||||
}): { ok: true; filePath: string } | { ok: false; reason: string } {
|
||||
}): { ok: true; filePath: string; workspaceRoot: string } | { ok: false; reason: string } {
|
||||
const workspaceRoot = resolveExistingPath(params.workspaceDir);
|
||||
const raw = params.raw;
|
||||
const resolved =
|
||||
@@ -104,7 +104,38 @@ function resolveLocalAvatarPath(params: {
|
||||
} catch {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
return { ok: true, filePath: realPath };
|
||||
return { ok: true, filePath: realPath, workspaceRoot };
|
||||
}
|
||||
|
||||
/** Resolve one configured source without applying UI or IDENTITY.md fallback precedence. */
|
||||
export function resolveAgentAvatarFromSource(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
source: string | null | undefined,
|
||||
): AgentAvatarResolution {
|
||||
const normalized = normalizeOptionalString(source) ?? null;
|
||||
if (!normalized) {
|
||||
return { kind: "none", reason: "missing" };
|
||||
}
|
||||
if (isAvatarHttpUrl(normalized)) {
|
||||
return { kind: "remote", url: normalized, source: normalized };
|
||||
}
|
||||
if (isAvatarDataUrl(normalized)) {
|
||||
return { kind: "data", url: normalized, source: normalized };
|
||||
}
|
||||
const resolved = resolveLocalAvatarPath({
|
||||
raw: normalized,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return { kind: "none", reason: resolved.reason, source: normalized };
|
||||
}
|
||||
return {
|
||||
kind: "local",
|
||||
filePath: resolved.filePath,
|
||||
workspaceRoot: resolved.workspaceRoot,
|
||||
source: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
function isSafeRelativeAvatarSource(source: string): boolean {
|
||||
@@ -151,20 +182,9 @@ export function resolveAgentAvatar(
|
||||
agentId: string,
|
||||
opts?: { includeUiOverride?: boolean },
|
||||
): AgentAvatarResolution {
|
||||
const source = resolveAvatarSource(cfg, agentId, opts);
|
||||
if (!source) {
|
||||
return { kind: "none", reason: "missing" };
|
||||
}
|
||||
if (isAvatarHttpUrl(source)) {
|
||||
return { kind: "remote", url: source, source };
|
||||
}
|
||||
if (isAvatarDataUrl(source)) {
|
||||
return { kind: "data", url: source, source };
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
|
||||
if (!resolved.ok) {
|
||||
return { kind: "none", reason: resolved.reason, source };
|
||||
}
|
||||
return { kind: "local", filePath: resolved.filePath, source };
|
||||
return resolveAgentAvatarFromSource(
|
||||
cfg,
|
||||
agentId,
|
||||
resolveEffectiveAvatarSource(cfg, agentId, opts),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* Assistant identity resolution tests for gateway-visible agents.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
DEFAULT_ASSISTANT_IDENTITY,
|
||||
resolveAssistantIdentity,
|
||||
resolvePublicAssistantIdentity,
|
||||
} from "./assistant-identity.js";
|
||||
|
||||
describe("resolveAssistantIdentity", () => {
|
||||
it("keeps ui.assistant identity authoritative for the default agent", () => {
|
||||
@@ -128,3 +135,89 @@ describe("resolveAssistantIdentity", () => {
|
||||
expect(resolveName(`${"x".repeat(48)}🚀suffix`)).toBe(`${"x".repeat(48)}🚀`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePublicAssistantIdentity", () => {
|
||||
it("projects workspace avatars to bounded data URLs", async () => {
|
||||
await withTempDir({ prefix: "openclaw-public-avatar-" }, async (workspace) => {
|
||||
await fs.mkdir(path.join(workspace, "avatars"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspace, "avatars", "main.png"), "avatar", "utf8");
|
||||
const identity = resolvePublicAssistantIdentity({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: { workspace },
|
||||
list: [{ id: "main", identity: { avatar: "avatars/main.png" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
avatar: `data:image/png;base64,${Buffer.from("avatar").toString("base64")}`,
|
||||
avatarSource: "avatars/main.png",
|
||||
avatarStatus: "local",
|
||||
});
|
||||
expect(identity.avatar).not.toContain("/avatar/main");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["remote", "https://example.com/avatar.png"],
|
||||
["data", "data:image/png;base64,aaaa"],
|
||||
["text", "PS"],
|
||||
["emoji", "🦞"],
|
||||
] as const)("preserves %s avatar presentation", (_kind, avatar) => {
|
||||
const identity = resolvePublicAssistantIdentity({
|
||||
cfg: { ui: { assistant: { avatar } } },
|
||||
workspaceDir: "",
|
||||
});
|
||||
|
||||
expect(identity.avatar).toBe(avatar);
|
||||
if (_kind === "text" || _kind === "emoji") {
|
||||
expect(identity).toMatchObject({ avatarStatus: "none", avatarReason: undefined });
|
||||
expect(identity.avatarSource).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves same-origin Control UI avatar routes", () => {
|
||||
expect(
|
||||
resolvePublicAssistantIdentity({
|
||||
cfg: { ui: { assistant: { avatar: "/avatar/main" } } },
|
||||
workspaceDir: "",
|
||||
}).avatar,
|
||||
).toBe("/avatar/main");
|
||||
expect(
|
||||
resolvePublicAssistantIdentity({
|
||||
cfg: { ui: { assistant: { avatar: "/avatar/main" } } },
|
||||
workspaceDir: "",
|
||||
basePath: "/openclaw",
|
||||
}).avatar,
|
||||
).toBe("/openclaw/avatar/main");
|
||||
expect(
|
||||
resolvePublicAssistantIdentity({
|
||||
cfg: { ui: { assistant: { avatar: "/openclaw/avatar/main" } } },
|
||||
workspaceDir: "",
|
||||
basePath: "/openclaw",
|
||||
}).avatar,
|
||||
).toBe("/openclaw/avatar/main");
|
||||
});
|
||||
|
||||
it("replaces rejected paths with the default while preserving repair metadata", async () => {
|
||||
await withTempDir({ prefix: "openclaw-public-avatar-missing-" }, async (workspace) => {
|
||||
const identity = resolvePublicAssistantIdentity({
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: { workspace },
|
||||
list: [{ id: "main", identity: { avatar: "avatars/missing.png" } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(identity).toMatchObject({
|
||||
avatar: DEFAULT_ASSISTANT_IDENTITY.avatar,
|
||||
avatarSource: "avatars/missing.png",
|
||||
avatarStatus: "none",
|
||||
avatarReason: "missing",
|
||||
});
|
||||
expect(identity.avatar).not.toContain("/avatar/main");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,31 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
type AgentAvatarResolution,
|
||||
resolveAgentAvatarFromSource,
|
||||
resolvePublicAgentAvatarSource,
|
||||
} from "../agents/identity-avatar.js";
|
||||
import { resolveAgentAvatarUrl } from "../agents/identity-avatar-projection.js";
|
||||
import { resolveAgentIdentity } from "../agents/identity.js";
|
||||
import { loadAgentIdentity } from "../commands/agents.config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
isAvatarHttpUrl,
|
||||
isAvatarImageDataUrl,
|
||||
looksLikeAvatarPath,
|
||||
} from "../shared/avatar-policy.js";
|
||||
import { CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath } from "./control-ui-shared.js";
|
||||
|
||||
const AVATAR_MAX_DATA_URL_CHARS = 4 * Math.ceil(AVATAR_MAX_BYTES / 3) + 64;
|
||||
|
||||
const ASSISTANT_IDENTITY_LIMITS = {
|
||||
name: 50,
|
||||
// Image-bearing avatars must round-trip without truncation. This matches
|
||||
// MAX_LOCAL_USER_IMAGE_AVATAR / AVATAR_MAX_BYTES expansion.
|
||||
avatar: 2_000_000,
|
||||
avatar: AVATAR_MAX_DATA_URL_CHARS,
|
||||
emoji: 16,
|
||||
} as const;
|
||||
type AssistantIdentityField = keyof typeof ASSISTANT_IDENTITY_LIMITS;
|
||||
@@ -40,7 +50,13 @@ function normalizeIdentityValue(
|
||||
value: string | undefined,
|
||||
): string | undefined {
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
return trimmed ? truncateUtf16Safe(trimmed, ASSISTANT_IDENTITY_LIMITS[field]) : undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const limit = ASSISTANT_IDENTITY_LIMITS[field];
|
||||
return field === "avatar" && trimmed.length > limit
|
||||
? undefined
|
||||
: truncateUtf16Safe(trimmed, limit);
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
@@ -84,6 +100,20 @@ function normalizeEmojiValue(value: string | undefined): string | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveSameOriginControlUiAvatarUrl(params: {
|
||||
avatar: string;
|
||||
basePath?: string;
|
||||
}): string | undefined {
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const baseAvatarPrefix = basePath
|
||||
? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/`
|
||||
: `${CONTROL_UI_AVATAR_PREFIX}/`;
|
||||
if (basePath && params.avatar.startsWith(`${CONTROL_UI_AVATAR_PREFIX}/`)) {
|
||||
return `${basePath}${params.avatar}`;
|
||||
}
|
||||
return params.avatar.startsWith(baseAvatarPrefix) ? params.avatar : undefined;
|
||||
}
|
||||
|
||||
/** Resolve the display name/avatar/emoji for an agent-facing assistant identity. */
|
||||
export function resolveAssistantIdentity(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -129,3 +159,42 @@ export function resolveAssistantIdentity(params: {
|
||||
|
||||
return { agentId, name, avatar, emoji };
|
||||
}
|
||||
|
||||
/** Resolve one consistent browser-facing identity payload for Gateway RPC and bootstrap callers. */
|
||||
export function resolvePublicAssistantIdentity(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId?: string | null;
|
||||
workspaceDir?: string | null;
|
||||
basePath?: string;
|
||||
}) {
|
||||
const identity = resolveAssistantIdentity(params);
|
||||
const sameOriginAvatarUrl = resolveSameOriginControlUiAvatarUrl({
|
||||
avatar: identity.avatar,
|
||||
basePath: params.basePath,
|
||||
});
|
||||
if (sameOriginAvatarUrl) {
|
||||
return {
|
||||
...identity,
|
||||
avatar: sameOriginAvatarUrl,
|
||||
avatarSource: undefined,
|
||||
avatarStatus: undefined,
|
||||
avatarReason: undefined,
|
||||
};
|
||||
}
|
||||
const resolved = resolveAgentAvatarFromSource(params.cfg, identity.agentId, identity.avatar);
|
||||
const avatarUrl = resolveAgentAvatarUrl(resolved);
|
||||
const isTextAvatar = !avatarUrl && !looksLikeAvatarPath(identity.avatar);
|
||||
const projectionFailed = resolved.kind === "local" && !avatarUrl;
|
||||
const publicResolution: AgentAvatarResolution = projectionFailed
|
||||
? { kind: "none", reason: "missing", source: resolved.source }
|
||||
: resolved;
|
||||
|
||||
return {
|
||||
...identity,
|
||||
avatar: avatarUrl ?? (isTextAvatar ? identity.avatar : DEFAULT_ASSISTANT_IDENTITY.avatar),
|
||||
avatarSource: isTextAvatar ? undefined : resolvePublicAgentAvatarSource(publicResolution),
|
||||
avatarStatus: isTextAvatar ? ("none" as const) : publicResolution.kind,
|
||||
avatarReason:
|
||||
!isTextAvatar && publicResolution.kind === "none" ? publicResolution.reason : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// Control UI shared URL helpers.
|
||||
// Normalizes base paths and avatar URLs for browser/gateway surfaces.
|
||||
import {
|
||||
isAvatarHttpUrl,
|
||||
isAvatarImageDataUrl,
|
||||
looksLikeAvatarPath,
|
||||
} from "../shared/avatar-policy.js";
|
||||
|
||||
const CONTROL_UI_AVATAR_PREFIX = "/avatar";
|
||||
|
||||
@@ -36,41 +31,5 @@ export function buildControlUiAvatarUrl(basePath: string, agentId: string): stri
|
||||
: `${CONTROL_UI_AVATAR_PREFIX}/${agentId}`;
|
||||
}
|
||||
|
||||
/** Resolves the assistant avatar URL that Control UI should render for the active agent. */
|
||||
export function resolveAssistantAvatarUrl(params: {
|
||||
avatar?: string | null;
|
||||
agentId?: string | null;
|
||||
basePath?: string;
|
||||
}): string | undefined {
|
||||
const avatar = params.avatar?.trim();
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAvatarHttpUrl(avatar) || isAvatarImageDataUrl(avatar)) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const baseAvatarPrefix = basePath
|
||||
? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/`
|
||||
: `${CONTROL_UI_AVATAR_PREFIX}/`;
|
||||
if (basePath && avatar.startsWith(`${CONTROL_UI_AVATAR_PREFIX}/`)) {
|
||||
return `${basePath}${avatar}`;
|
||||
}
|
||||
if (avatar.startsWith(baseAvatarPrefix)) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
if (!params.agentId) {
|
||||
return avatar;
|
||||
}
|
||||
// Local filesystem-ish avatar config is exposed through the gateway avatar
|
||||
// route instead of being handed directly to the browser.
|
||||
if (looksLikeAvatarPath(avatar)) {
|
||||
return buildControlUiAvatarUrl(basePath, params.agentId);
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
|
||||
/** URL prefix for gateway-served Control UI avatar assets. */
|
||||
export { CONTROL_UI_AVATAR_PREFIX };
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import {
|
||||
approveDevicePairing,
|
||||
@@ -44,6 +45,9 @@ describe("handleControlUiHttpRequest", () => {
|
||||
basePath: string;
|
||||
assistantName: string;
|
||||
assistantAvatar: string;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
assistantAgentId: string;
|
||||
localMediaPreviewRoots?: string[];
|
||||
chatMessageMaxWidth?: string;
|
||||
@@ -99,6 +103,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
basePath?: string;
|
||||
auth?: ResolvedGatewayAuth;
|
||||
headers?: IncomingMessage["headers"];
|
||||
config?: OpenClawConfig;
|
||||
}) {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const url = params.basePath
|
||||
@@ -115,6 +120,7 @@ describe("handleControlUiHttpRequest", () => {
|
||||
{
|
||||
...(params.basePath ? { basePath: params.basePath } : {}),
|
||||
...(params.auth ? { auth: params.auth } : {}),
|
||||
...(params.config ? { config: params.config } : {}),
|
||||
root: { kind: "resolved", path: params.rootPath },
|
||||
},
|
||||
);
|
||||
@@ -954,7 +960,9 @@ describe("handleControlUiHttpRequest", () => {
|
||||
const parsed = parseBootstrapPayload(end);
|
||||
expect(parsed.basePath).toBe("");
|
||||
expect(parsed.assistantName).toBe("</script><script>alert(1)//");
|
||||
expect(parsed.assistantAvatar).toBe("/avatar/main");
|
||||
expect(parsed.assistantAvatar).toBe("A");
|
||||
expect(parsed.assistantAvatarStatus).toBe("none");
|
||||
expect(parsed.assistantAvatarReason).toBe("missing");
|
||||
expect(parsed.assistantAgentId).toBe("main");
|
||||
expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)");
|
||||
expect(parsed.seamColor).toBe("#1A2b3C");
|
||||
@@ -965,9 +973,40 @@ describe("handleControlUiHttpRequest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("inlines a workspace-local assistant avatar as a data URI in bootstrap config (#97602)", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
// A real workspace-relative avatar file must be projected as an inline
|
||||
// data URI so the Personal card <img> renders without hitting the
|
||||
// auth-gated /avatar/<agentId> route.
|
||||
await fs.writeFile(path.join(tmp, "avatar.png"), "avatar-bytes\n");
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = await handleControlUiHttpRequest(
|
||||
{ url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
root: { kind: "resolved", path: tmp },
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { name: "Ops", avatar: "avatar.png" } },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
const parsed = parseBootstrapPayload(end);
|
||||
expect(parsed).toMatchObject({
|
||||
assistantAvatar: `data:image/png;base64,${Buffer.from("avatar-bytes\n").toString("base64")}`,
|
||||
assistantAvatarSource: "avatar.png",
|
||||
assistantAvatarStatus: "local",
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects bootstrap config requests without a valid auth token when auth is enabled", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
await fs.writeFile(path.join(tmp, "avatar.png"), "avatar-bytes\n");
|
||||
const { res, handled, end } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-token", allowTailscale: false },
|
||||
@@ -982,17 +1021,26 @@ describe("handleControlUiHttpRequest", () => {
|
||||
it("serves bootstrap config JSON when auth is enabled and the token is valid", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
await fs.writeFile(path.join(tmp, "avatar.png"), "avatar-bytes\n");
|
||||
const { res, handled, end } = await runBootstrapConfigRequest({
|
||||
rootPath: tmp,
|
||||
auth: { mode: "token", token: "test-token", allowTailscale: false },
|
||||
headers: {
|
||||
authorization: "Bearer test-token",
|
||||
},
|
||||
config: {
|
||||
agents: { defaults: { workspace: tmp } },
|
||||
ui: { assistant: { avatar: "avatar.png" } },
|
||||
},
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
const parsed = parseBootstrapPayload(end);
|
||||
expect(parsed.assistantAgentId).toBe("main");
|
||||
expect(parsed).toMatchObject({
|
||||
assistantAgentId: "main",
|
||||
assistantAvatar: `data:image/png;base64,${Buffer.from("avatar-bytes\n").toString("base64")}`,
|
||||
assistantAvatarStatus: "local",
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1039,7 +1087,9 @@ describe("handleControlUiHttpRequest", () => {
|
||||
const parsed = parseBootstrapPayload(end);
|
||||
expect(parsed.basePath).toBe("/openclaw");
|
||||
expect(parsed.assistantName).toBe("Ops");
|
||||
expect(parsed.assistantAvatar).toBe("/openclaw/avatar/main");
|
||||
expect(parsed.assistantAvatar).toBe("A");
|
||||
expect(parsed.assistantAvatarStatus).toBe("none");
|
||||
expect(parsed.assistantAvatarReason).toBe("missing");
|
||||
expect(parsed.assistantAgentId).toBe("main");
|
||||
expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true);
|
||||
},
|
||||
|
||||
+16
-19
@@ -9,7 +9,7 @@ import {
|
||||
asDateTimestampMs,
|
||||
resolveTimestampMsToIsoString,
|
||||
} from "@openclaw/normalization-core/number-coercion";
|
||||
import { resolveAgentAvatar, resolvePublicAgentAvatarSource } from "../agents/identity-avatar.js";
|
||||
import { resolvePublicAgentAvatarSource } from "../agents/identity-avatar.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { matchRootFileOpenFailure, openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import {
|
||||
@@ -31,7 +31,10 @@ import { extractOriginalFilename } from "../media/store.js";
|
||||
import { AVATAR_MAX_BYTES } from "../shared/avatar-policy.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveRuntimeServiceVersion } from "../version.js";
|
||||
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
||||
import {
|
||||
DEFAULT_ASSISTANT_IDENTITY,
|
||||
resolvePublicAssistantIdentity,
|
||||
} from "./assistant-identity.js";
|
||||
import {
|
||||
AUTH_RATE_LIMIT_SCOPE_DEVICE_TOKEN,
|
||||
AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
@@ -55,7 +58,6 @@ import {
|
||||
buildControlUiAvatarUrl,
|
||||
CONTROL_UI_AVATAR_PREFIX,
|
||||
normalizeControlUiBasePath,
|
||||
resolveAssistantAvatarUrl,
|
||||
} from "./control-ui-shared.js";
|
||||
import { buildMissingScopeForbiddenBody, sendGatewayAuthFailure } from "./http-common.js";
|
||||
import {
|
||||
@@ -1011,18 +1013,13 @@ export async function handleControlUiHttpRequest(
|
||||
}
|
||||
const config = opts?.config;
|
||||
const identity = config
|
||||
? resolveAssistantIdentity({ cfg: config, agentId: opts?.agentId })
|
||||
: DEFAULT_ASSISTANT_IDENTITY;
|
||||
const avatarValue = resolveAssistantAvatarUrl({
|
||||
avatar: identity.avatar,
|
||||
agentId: identity.agentId,
|
||||
basePath,
|
||||
});
|
||||
const avatarMeta = config
|
||||
? controlUiAvatarResolutionMeta(
|
||||
resolveAgentAvatar(config, identity.agentId, { includeUiOverride: true }),
|
||||
)
|
||||
: controlUiAvatarResolutionMeta(null);
|
||||
? resolvePublicAssistantIdentity({ cfg: config, agentId: opts?.agentId, basePath })
|
||||
: {
|
||||
...DEFAULT_ASSISTANT_IDENTITY,
|
||||
avatarSource: undefined,
|
||||
avatarStatus: undefined,
|
||||
avatarReason: undefined,
|
||||
};
|
||||
if (req.method === "HEAD") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
@@ -1033,10 +1030,10 @@ export async function handleControlUiHttpRequest(
|
||||
sendJson(res, 200, {
|
||||
basePath,
|
||||
assistantName: identity.name,
|
||||
assistantAvatar: avatarValue ?? identity.avatar,
|
||||
assistantAvatarSource: avatarMeta.avatarSource,
|
||||
assistantAvatarStatus: avatarMeta.avatarStatus,
|
||||
assistantAvatarReason: avatarMeta.avatarReason,
|
||||
assistantAvatar: identity.avatar,
|
||||
assistantAvatarSource: identity.avatarSource,
|
||||
assistantAvatarStatus: identity.avatarStatus,
|
||||
assistantAvatarReason: identity.avatarReason,
|
||||
assistantAgentId: identity.agentId,
|
||||
serverVersion: resolveRuntimeServiceVersion(process.env),
|
||||
localMediaPreviewRoots: [...getAgentScopedMediaLocalRoots(config ?? {}, identity.agentId)],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Agent method tests cover run/steer/reset/wait behavior, task/subagent state,
|
||||
// approval followups, lifecycle hooks, and emitted gateway events.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import type { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
|
||||
@@ -7600,6 +7601,85 @@ describe("gateway agent handler", () => {
|
||||
expect(mockCallArg(respond, 0, 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns workspace-relative avatars as data URLs in agent.identity.get", async () => {
|
||||
await withTempDir({ prefix: "openclaw-agent-avatar-" }, async (workspace) => {
|
||||
await fs.mkdir(path.join(workspace, "avatars"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspace, "avatars", "main.png"), "avatar", "utf8");
|
||||
mocks.loadConfigReturn = {
|
||||
agents: {
|
||||
defaults: { workspace },
|
||||
list: [{ id: "main", identity: { avatar: "avatars/main.png" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
{ sessionKey: "agent:main:main" },
|
||||
{ reqId: "5-avatar-data" },
|
||||
);
|
||||
|
||||
expectRecordFields(mockCallArg(respond, 0, 1), {
|
||||
agentId: "main",
|
||||
avatar: `data:image/png;base64,${Buffer.from("avatar").toString("base64")}`,
|
||||
avatarSource: "avatars/main.png",
|
||||
avatarStatus: "local",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["remote", "https://example.com/avatar.png"],
|
||||
["data", "data:image/png;base64,aaaa"],
|
||||
["text", "PS"],
|
||||
] as const)("preserves %s avatar values in agent.identity.get", async (_kind, avatar) => {
|
||||
mocks.loadConfigReturn = { ui: { assistant: { avatar } } };
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
{ sessionKey: "agent:main:main" },
|
||||
{ reqId: `5-avatar-${_kind}` },
|
||||
);
|
||||
|
||||
expect((mockCallArg(respond, 0, 1) as { avatar?: unknown }).avatar).toBe(avatar);
|
||||
});
|
||||
|
||||
it("prefixes same-origin avatar routes in agent.identity.get when Control UI has a base path", async () => {
|
||||
mocks.loadConfigReturn = {
|
||||
gateway: { controlUi: { basePath: "/openclaw" } },
|
||||
ui: { assistant: { avatar: "/avatar/main" } },
|
||||
};
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
{ sessionKey: "agent:main:main" },
|
||||
{ reqId: "5-avatar-route-base-path" },
|
||||
);
|
||||
|
||||
expect((mockCallArg(respond, 0, 1) as { avatar?: unknown }).avatar).toBe(
|
||||
"/openclaw/avatar/main",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces rejected local avatar paths with the default instead of a protected route", async () => {
|
||||
await withTempDir({ prefix: "openclaw-agent-avatar-missing-" }, async (workspace) => {
|
||||
mocks.loadConfigReturn = {
|
||||
agents: {
|
||||
defaults: { workspace },
|
||||
list: [{ id: "main", identity: { avatar: "avatars/missing.png" } }],
|
||||
},
|
||||
};
|
||||
|
||||
const respond = await invokeAgentIdentityGet(
|
||||
{ sessionKey: "agent:main:main" },
|
||||
{ reqId: "5-avatar-missing" },
|
||||
);
|
||||
|
||||
expectRecordFields(mockCallArg(respond, 0, 1), {
|
||||
avatar: "A",
|
||||
avatarSource: "avatars/missing.png",
|
||||
avatarStatus: "none",
|
||||
avatarReason: "missing",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("allows non-delivery agent invocations when sendPolicy is deny", async () => {
|
||||
mocks.agentCommand.mockClear();
|
||||
primeMainAgentRun();
|
||||
|
||||
@@ -46,10 +46,6 @@ import {
|
||||
retainEmbeddedAgentRunAbortabilityForRunId,
|
||||
} from "../../agents/embedded-agent-runner/runs.js";
|
||||
import { isTimeoutError } from "../../agents/failover-error.js";
|
||||
import {
|
||||
resolveAgentAvatar,
|
||||
resolvePublicAgentAvatarSource,
|
||||
} from "../../agents/identity-avatar.js";
|
||||
import {
|
||||
AGENT_INTERNAL_EVENT_TYPE_TASK_COMPLETION,
|
||||
hasGeneratedMediaCompletionEvent,
|
||||
@@ -162,7 +158,7 @@ import {
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { setSafeTimeout } from "../../utils/timer-delay.js";
|
||||
import { resolveAssistantIdentity } from "../assistant-identity.js";
|
||||
import { resolvePublicAssistantIdentity } from "../assistant-identity.js";
|
||||
import {
|
||||
type ChatAbortControllerEntry,
|
||||
registerChatAbortController,
|
||||
@@ -174,7 +170,6 @@ import {
|
||||
parseMessageWithAttachments,
|
||||
resolveChatAttachmentMaxBytes,
|
||||
} from "../chat-attachments.js";
|
||||
import { resolveAssistantAvatarUrl } from "../control-ui-shared.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import {
|
||||
emitGatewaySessionEndPluginHook,
|
||||
@@ -3975,25 +3970,12 @@ export const agentHandlers: GatewayRequestHandlers = {
|
||||
agentId = resolved;
|
||||
}
|
||||
const cfg = context.getRuntimeConfig();
|
||||
const identity = resolveAssistantIdentity({ cfg, agentId });
|
||||
const avatarValue =
|
||||
resolveAssistantAvatarUrl({
|
||||
avatar: identity.avatar,
|
||||
agentId: identity.agentId,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
}) ?? identity.avatar;
|
||||
const avatarResolution = resolveAgentAvatar(cfg, identity.agentId, { includeUiOverride: true });
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
...identity,
|
||||
avatar: avatarValue,
|
||||
avatarSource: resolvePublicAgentAvatarSource(avatarResolution),
|
||||
avatarStatus: avatarResolution.kind,
|
||||
avatarReason: avatarResolution.kind === "none" ? avatarResolution.reason : undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
const identity = resolvePublicAssistantIdentity({
|
||||
cfg,
|
||||
agentId,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
});
|
||||
respond(true, identity, undefined);
|
||||
},
|
||||
"agent.wait": async ({ params, respond, context }) => {
|
||||
if (!validateAgentWaitParams(params)) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// Gateway session listing and projection helpers.
|
||||
// Normalizes persisted session stores into UI/RPC rows without mutating state.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -26,6 +24,8 @@ import {
|
||||
import { lookupContextTokens, resolveContextTokensForModel } from "../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveFastModeState } from "../agents/fast-mode.js";
|
||||
import { resolveAgentAvatarFromSource } from "../agents/identity-avatar.js";
|
||||
import { resolveAgentAvatarUrl } from "../agents/identity-avatar-projection.js";
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
modelSupportsInput,
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
} from "../config/sessions.js";
|
||||
import { listSessionEntries as listAccessorSessionEntries } from "../config/sessions/session-accessor.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
||||
import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js";
|
||||
import { withPinnedActivePluginRegistryWorkspaceDir } from "../plugins/runtime-workspace-state.js";
|
||||
import {
|
||||
@@ -87,14 +86,6 @@ import {
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { isAcpSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
|
||||
import {
|
||||
AVATAR_MAX_BYTES,
|
||||
isAvatarDataUrl,
|
||||
isAvatarHttpUrl,
|
||||
isPathWithinRoot,
|
||||
isWorkspaceRelativeAvatarPath,
|
||||
resolveAvatarMime,
|
||||
} from "../shared/avatar-policy.js";
|
||||
import { resolveNonNegativeNumber } from "../shared/number-coercion.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
|
||||
@@ -165,64 +156,6 @@ export {
|
||||
|
||||
const DERIVED_TITLE_MAX_LEN = 60;
|
||||
|
||||
function tryResolveExistingPath(value: string): string | null {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveIdentityAvatarUrl(
|
||||
cfg: OpenClawConfig,
|
||||
agentId: string,
|
||||
avatar: string | undefined,
|
||||
): string | undefined {
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = normalizeOptionalString(avatar) ?? "";
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAvatarDataUrl(trimmed) || isAvatarHttpUrl(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (!isWorkspaceRelativeAvatarPath(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const workspaceRoot = tryResolveExistingPath(workspaceDir) ?? path.resolve(workspaceDir);
|
||||
const resolvedCandidate = path.resolve(workspaceRoot, trimmed);
|
||||
if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
// Avatars can be workspace-relative, but projection must keep the file
|
||||
// read inside the agent workspace and cap bytes before encoding.
|
||||
const opened = openRootFileSync({
|
||||
absolutePath: resolvedCandidate,
|
||||
rootPath: workspaceRoot,
|
||||
rootRealPath: workspaceRoot,
|
||||
boundaryLabel: "workspace root",
|
||||
maxBytes: AVATAR_MAX_BYTES,
|
||||
skipLexicalRootCheck: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const buffer = fs.readFileSync(opened.fd);
|
||||
const mime = resolveAvatarMime(resolvedCandidate);
|
||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
||||
const prefix = sessionId.slice(0, 8);
|
||||
if (updatedAt && updatedAt > 0) {
|
||||
@@ -1263,21 +1196,20 @@ export function listAgentsForGateway(
|
||||
if (!entry?.id) {
|
||||
continue;
|
||||
}
|
||||
const agentId = normalizeAgentId(entry.id);
|
||||
const configuredName = normalizeOptionalString(entry.name);
|
||||
const avatar = normalizeOptionalString(entry.identity?.avatar);
|
||||
const avatarUrl = resolveAgentAvatarUrl(resolveAgentAvatarFromSource(cfg, agentId, avatar));
|
||||
const identity = entry.identity
|
||||
? {
|
||||
name: normalizeOptionalString(entry.identity.name),
|
||||
theme: normalizeOptionalString(entry.identity.theme),
|
||||
emoji: normalizeOptionalString(entry.identity.emoji),
|
||||
avatar: normalizeOptionalString(entry.identity.avatar),
|
||||
avatarUrl: resolveIdentityAvatarUrl(
|
||||
cfg,
|
||||
normalizeAgentId(entry.id),
|
||||
normalizeOptionalString(entry.identity.avatar),
|
||||
),
|
||||
avatar,
|
||||
avatarUrl,
|
||||
}
|
||||
: undefined;
|
||||
configuredById.set(normalizeAgentId(entry.id), {
|
||||
configuredById.set(agentId, {
|
||||
name: configuredName ?? identity?.name,
|
||||
identity,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
|
||||
|
||||
const AVATAR_MAX_DATA_URL_CHARS = 4 * Math.ceil((2 * 1024 * 1024) / 3) + 64;
|
||||
|
||||
describe("normalizeAssistantIdentity", () => {
|
||||
it("truncates names without splitting a surrogate pair", () => {
|
||||
expect(normalizeAssistantIdentity({ name: `${"x".repeat(49)}🚀suffix` }).name).toBe(
|
||||
@@ -17,6 +19,15 @@ describe("normalizeAssistantIdentity", () => {
|
||||
expect(normalizeAssistantIdentity({ avatar: dataUrl }).avatar).toBe(dataUrl);
|
||||
});
|
||||
|
||||
it("accepts the full local-avatar data URL bound and rejects larger values", () => {
|
||||
const prefix = "data:image/svg+xml;base64,";
|
||||
const bounded = prefix + "A".repeat(AVATAR_MAX_DATA_URL_CHARS - prefix.length);
|
||||
const oversized = `${bounded}A`;
|
||||
|
||||
expect(normalizeAssistantIdentity({ avatar: bounded }).avatar).toBe(bounded);
|
||||
expect(normalizeAssistantIdentity({ avatar: oversized }).avatar).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves same-origin Control UI avatar routes", () => {
|
||||
expect(normalizeAssistantIdentity({ avatar: "/avatar/main" }).avatar).toBe("/avatar/main");
|
||||
});
|
||||
|
||||
@@ -5,10 +5,10 @@ import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
// Short text/emoji avatars (e.g. "A", "PS", "🦞"). Anything longer that is not
|
||||
// a renderable image URL is dropped during normalization.
|
||||
const MAX_ASSISTANT_TEXT_AVATAR = 64;
|
||||
// Mirrors server AVATAR_MAX_BYTES expansion without importing Node-only avatar policy.
|
||||
const AVATAR_MAX_DATA_URL_CHARS = 4 * Math.ceil((2 * 1024 * 1024) / 3) + 64;
|
||||
const ASSISTANT_IDENTITY_LIMITS = {
|
||||
name: 50,
|
||||
// Image-bearing avatars use the local-user image cap so uploads round-trip.
|
||||
avatar: 2_000_000,
|
||||
avatarSource: 500,
|
||||
avatarReason: 200,
|
||||
} as const;
|
||||
@@ -38,12 +38,14 @@ function normalizeAssistantValue(
|
||||
}
|
||||
|
||||
function normalizeAssistantAvatar(value: string | null | undefined): string | null {
|
||||
const trimmed = normalizeAssistantValue("avatar", value);
|
||||
const trimmed = normalizeOptionalString(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (RENDERABLE_AVATAR_URL_RE.test(trimmed)) {
|
||||
return trimmed;
|
||||
// Reject instead of truncating: a truncated data URL still looks valid but
|
||||
// decodes to a broken image.
|
||||
return trimmed.length <= AVATAR_MAX_DATA_URL_CHARS ? trimmed : null;
|
||||
}
|
||||
if (/[\r\n]/.test(trimmed)) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user