feat: add agent identity avatars (#1329) (thanks @dlauer)

This commit is contained in:
Peter Steinberger
2026-01-22 05:21:47 +00:00
parent a2bea8e366
commit a59ac5cf6f
26 changed files with 477 additions and 22 deletions

View File

@@ -147,7 +147,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--name <name>", "Identity name")
.option("--theme <theme>", "Identity theme")
.option("--emoji <emoji>", "Identity emoji")
.option("--avatar <path>", "Identity avatar (workspace-relative, URL, or data: URL)")
.option("--avatar <value>", "Identity avatar (workspace path, http(s) URL, or data URI)")
.option("--json", "Output JSON summary", false)
.addHelpText(
"after",
@@ -156,6 +156,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
${theme.heading("Examples:")}
${formatHelpExamples([
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
["clawdbot agents set-identity --agent main --avatar avatars/clawd.png", "Set avatar path."],
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
[
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",

View File

@@ -126,7 +126,7 @@ describe("agents set-identity command", () => {
"- Name: Clawd",
"- Theme: space lobster",
"- Emoji: :)",
"- Avatar: avatars/base.png",
"- Avatar: avatars/clawd.png",
"",
].join("\n"),
"utf-8",
@@ -138,7 +138,13 @@ describe("agents set-identity command", () => {
});
await agentsSetIdentityCommand(
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞", avatar: "avatars/custom.png" },
{
workspace,
fromIdentity: true,
name: "Nova",
emoji: "🦞",
avatar: "https://example.com/override.png",
},
runtime,
);
@@ -150,7 +156,7 @@ describe("agents set-identity command", () => {
name: "Nova",
theme: "space lobster",
emoji: "🦞",
avatar: "avatars/custom.png",
avatar: "https://example.com/override.png",
});
});
@@ -216,6 +222,26 @@ describe("agents set-identity command", () => {
});
});
it("accepts avatar-only updates via flags", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseSnapshot,
config: { agents: { list: [{ id: "main" }] } },
});
await agentsSetIdentityCommand(
{ agent: "main", avatar: "https://example.com/avatar.png" },
runtime,
);
const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as {
agents?: { list?: Array<{ id: string; identity?: Record<string, string> }> };
};
const main = written.agents?.list?.find((entry) => entry.id === "main");
expect(main?.identity).toEqual({
avatar: "https://example.com/avatar.png",
});
});
it("errors when identity data is missing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
const workspace = path.join(root, "work");

View File

@@ -0,0 +1,54 @@
import path from "node:path";
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
import { withTempHome } from "./test-helpers.js";
describe("identity avatar validation", () => {
it("accepts workspace-relative avatar paths", async () => {
await withTempHome(async (home) => {
const workspace = path.join(home, "clawd");
const res = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "avatars/clawd.png" } }],
},
});
expect(res.ok).toBe(true);
});
});
it("accepts http(s) and data avatars", async () => {
await withTempHome(async (home) => {
const workspace = path.join(home, "clawd");
const httpRes = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "https://example.com/avatar.png" } }],
},
});
expect(httpRes.ok).toBe(true);
const dataRes = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "data:image/png;base64,AAA" } }],
},
});
expect(dataRes.ok).toBe(true);
});
});
it("rejects avatar paths outside workspace", async () => {
await withTempHome(async (home) => {
const workspace = path.join(home, "clawd");
const res = validateConfigObject({
agents: {
list: [{ id: "main", workspace, identity: { avatar: "../oops.png" } }],
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("agents.list.0.identity.avatar");
}
});
});
});

View File

@@ -119,6 +119,7 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
@@ -511,6 +512,8 @@ const FIELD_HELP: Record<string, string> = {
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
"plugins.installs.*.version": "Version recorded at install time (if available).",
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
"agents.list.*.identity.avatar":
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
"agents.defaults.model.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.",
@@ -616,7 +619,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
"gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/clawdbot",
"agents.list[].identity.avatar": "avatars/assistant.png",
"agents.list[].identity.avatar": "avatars/clawd.png",
};
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];

View File

@@ -154,6 +154,6 @@ export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
/** Avatar image path (workspace-relative) or a URL/data URL. Local files must live in the workspace. */
/** Avatar image: workspace-relative path, http(s) URL, or data URI. */
avatar?: string;
};

View File

@@ -1,3 +1,5 @@
import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { CHANNEL_IDS } from "../channels/registry.js";
import {
@@ -13,6 +15,60 @@ import { findLegacyConfigIssues } from "./legacy.js";
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
import { ClawdbotSchema } from "./zod-schema.js";
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
const workspaceRoot = path.resolve(workspaceDir);
const resolved = path.resolve(workspaceRoot, value);
const relative = path.relative(workspaceRoot, resolved);
if (relative === "") return true;
if (relative.startsWith("..")) return false;
return !path.isAbsolute(relative);
}
function validateIdentityAvatar(config: ClawdbotConfig): ConfigValidationIssue[] {
const agents = config.agents?.list;
if (!Array.isArray(agents) || agents.length === 0) return [];
const issues: ConfigValidationIssue[] = [];
for (const [index, entry] of agents.entries()) {
if (!entry || typeof entry !== "object") continue;
const avatarRaw = entry.identity?.avatar;
if (typeof avatarRaw !== "string") continue;
const avatar = avatarRaw.trim();
if (!avatar) continue;
if (AVATAR_DATA_RE.test(avatar) || AVATAR_HTTP_RE.test(avatar)) continue;
if (avatar.startsWith("~")) {
issues.push({
path: `agents.list.${index}.identity.avatar`,
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
});
continue;
}
const hasScheme = AVATAR_SCHEME_RE.test(avatar);
if (hasScheme && !WINDOWS_ABS_RE.test(avatar)) {
issues.push({
path: `agents.list.${index}.identity.avatar`,
message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.",
});
continue;
}
const workspaceDir = resolveAgentWorkspaceDir(
config,
entry.id ?? resolveDefaultAgentId(config),
);
if (!isWorkspaceAvatarPath(avatar, workspaceDir)) {
issues.push({
path: `agents.list.${index}.identity.avatar`,
message: "identity.avatar must stay within the agent workspace.",
});
}
}
return issues;
}
export function validateConfigObject(
raw: unknown,
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
@@ -48,6 +104,10 @@ export function validateConfigObject(
],
};
}
const avatarIssues = validateIdentityAvatar(validated.data as ClawdbotConfig);
if (avatarIssues.length > 0) {
return { ok: false, issues: avatarIssues };
}
return {
ok: true,
config: applyModelDefaults(

View File

@@ -17,6 +17,18 @@ export const AgentSummarySchema = Type.Object(
{
id: NonEmptyString,
name: Type.Optional(NonEmptyString),
identity: Type.Optional(
Type.Object(
{
name: Type.Optional(NonEmptyString),
theme: Type.Optional(NonEmptyString),
emoji: Type.Optional(NonEmptyString),
avatar: Type.Optional(NonEmptyString),
avatarUrl: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
@@ -50,6 +50,62 @@ export type {
} from "./session-utils.types.js";
const DERIVED_TITLE_MAX_LEN = 60;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
const AVATAR_MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".bmp": "image/bmp",
".tif": "image/tiff",
".tiff": "image/tiff",
};
function resolveAvatarMime(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream";
}
function isWorkspaceRelativePath(value: string): boolean {
if (!value) return false;
if (value.startsWith("~")) return false;
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) return false;
return true;
}
function resolveIdentityAvatarUrl(
cfg: ClawdbotConfig,
agentId: string,
avatar: string | undefined,
): string | undefined {
if (!avatar) return undefined;
const trimmed = avatar.trim();
if (!trimmed) return undefined;
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) return trimmed;
if (!isWorkspaceRelativePath(trimmed)) return undefined;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const workspaceRoot = path.resolve(workspaceDir);
const resolved = path.resolve(workspaceRoot, trimmed);
const relative = path.relative(workspaceRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
try {
const stat = fs.statSync(resolved);
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) return undefined;
const buffer = fs.readFileSync(resolved);
const mime = resolveAvatarMime(resolved);
return `data:${mime};base64,${buffer.toString("base64")}`;
} catch {
return undefined;
}
}
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
const prefix = sessionId.slice(0, 8);
@@ -189,11 +245,28 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
const mainKey = normalizeMainKey(cfg.session?.mainKey);
const scope = cfg.session?.scope ?? "per-sender";
const configuredById = new Map<string, { name?: string }>();
const configuredById = new Map<
string,
{ name?: string; identity?: GatewayAgentRow["identity"] }
>();
for (const entry of cfg.agents?.list ?? []) {
if (!entry?.id) continue;
const identity = entry.identity
? {
name: entry.identity.name?.trim() || undefined,
theme: entry.identity.theme?.trim() || undefined,
emoji: entry.identity.emoji?.trim() || undefined,
avatar: entry.identity.avatar?.trim() || undefined,
avatarUrl: resolveIdentityAvatarUrl(
cfg,
normalizeAgentId(entry.id),
entry.identity.avatar?.trim(),
),
}
: undefined;
configuredById.set(normalizeAgentId(entry.id), {
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
identity,
});
}
const explicitIds = new Set(
@@ -213,6 +286,7 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
return {
id,
name: meta?.name,
identity: meta?.identity,
};
});
return { defaultId, mainKey, scope, agents };

View File

@@ -46,6 +46,13 @@ export type GatewaySessionRow = {
export type GatewayAgentRow = {
id: string;
name?: string;
identity?: {
name?: string;
theme?: string;
emoji?: string;
avatar?: string;
avatarUrl?: string;
};
};
export type SessionsListResult = {

View File

@@ -1,4 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
escapeRegExp,
formatLocalEnvelopeTimestamp,
} from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
@@ -176,7 +180,11 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset();
@@ -217,7 +225,11 @@ describe("createTelegramBot", () => {
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset();

View File

@@ -1,4 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
escapeRegExp,
formatLocalEnvelopeTimestamp,
} from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
import { resolveTelegramFetch } from "./fetch.js";
@@ -328,8 +332,12 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
new RegExp(
`^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`,
),
);
expect(payload.Body).toContain("hello world");
} finally {

View File

@@ -10,6 +10,10 @@ import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import * as replyModule from "../auto-reply/reply.js";
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
import {
escapeRegExp,
formatLocalEnvelopeTimestamp,
} from "../../test/helpers/envelope-timestamp.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
import { resolveTelegramFetch } from "./fetch.js";
@@ -450,8 +454,12 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
new RegExp(
`^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`,
),
);
expect(payload.Body).toContain("hello world");
} finally {
@@ -585,7 +593,11 @@ describe("createTelegramBot", () => {
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
});
@@ -627,7 +639,11 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");

View File

@@ -3,6 +3,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
escapeRegExp,
formatLocalEnvelopeTimestamp,
} from "../../test/helpers/envelope-timestamp.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -328,12 +332,16 @@ describe("web auto-reply", () => {
expect(resolver).toHaveBeenCalledTimes(2);
const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][0];
const firstTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-01T00:00:00Z"));
const secondTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-01T01:00:00Z"));
const firstPattern = escapeRegExp(firstTimestamp);
const secondPattern = escapeRegExp(secondTimestamp);
expect(firstArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\[clawdbot\\] first`),
);
expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\[clawdbot\\] second`),
);
expect(secondArgs.Body).not.toContain("first");