feat: add agent avatar support (#1329) (thanks @dlauer)
This commit is contained in:
110
src/agents/identity-avatar.test.ts
Normal file
110
src/agents/identity-avatar.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveAgentAvatar } from "./identity-avatar.js";
|
||||
|
||||
async function writeFile(filePath: string, contents = "avatar") {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents, "utf-8");
|
||||
}
|
||||
|
||||
describe("resolveAgentAvatar", () => {
|
||||
it("resolves local avatar from config when inside workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "main.png");
|
||||
await writeFile(avatarPath);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace,
|
||||
identity: { avatar: "avatars/main.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const expectedPath = await fs.realpath(avatarPath);
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
expect(resolved.filePath).toBe(expectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects avatars outside the workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
const outsidePath = path.join(root, "outside.png");
|
||||
await writeFile(outsidePath);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace,
|
||||
identity: { avatar: outsidePath },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("none");
|
||||
if (resolved.kind === "none") {
|
||||
expect(resolved.reason).toBe("outside_workspace");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to IDENTITY.md when config has no avatar", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "fallback.png");
|
||||
await writeFile(avatarPath);
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"- Avatar: avatars/fallback.png\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", workspace }],
|
||||
},
|
||||
};
|
||||
|
||||
const expectedPath = await fs.realpath(avatarPath);
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
expect(resolved.filePath).toBe(expectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts remote and data avatars", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", identity: { avatar: "https://example.com/avatar.png" } },
|
||||
{ id: "data", identity: { avatar: "data:image/png;base64,aaaa" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const remote = resolveAgentAvatar(cfg, "main");
|
||||
expect(remote.kind).toBe("remote");
|
||||
|
||||
const data = resolveAgentAvatar(cfg, "data");
|
||||
expect(data.kind).toBe("data");
|
||||
});
|
||||
});
|
||||
99
src/agents/identity-avatar.ts
Normal file
99
src/agents/identity-avatar.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
|
||||
import { resolveAgentIdentity } from "./identity.js";
|
||||
|
||||
export type AgentAvatarResolution =
|
||||
| { kind: "none"; reason: string }
|
||||
| { kind: "local"; filePath: string }
|
||||
| { kind: "remote"; url: string }
|
||||
| { kind: "data"; url: string };
|
||||
|
||||
const ALLOWED_AVATAR_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
||||
|
||||
function normalizeAvatarValue(value: string | undefined | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveAvatarSource(cfg: ClawdbotConfig, agentId: string): string | null {
|
||||
const fromConfig = normalizeAvatarValue(resolveAgentIdentity(cfg, agentId)?.avatar);
|
||||
if (fromConfig) return fromConfig;
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const fromIdentity = normalizeAvatarValue(loadAgentIdentityFromWorkspace(workspace)?.avatar);
|
||||
return fromIdentity;
|
||||
}
|
||||
|
||||
function isRemoteAvatar(value: string): boolean {
|
||||
const lower = value.toLowerCase();
|
||||
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||
}
|
||||
|
||||
function isDataAvatar(value: string): boolean {
|
||||
return value.toLowerCase().startsWith("data:");
|
||||
}
|
||||
|
||||
function resolveExistingPath(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative) return true;
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function resolveLocalAvatarPath(params: {
|
||||
raw: string;
|
||||
workspaceDir: string;
|
||||
}): { ok: true; filePath: string } | { ok: false; reason: string } {
|
||||
const workspaceRoot = resolveExistingPath(params.workspaceDir);
|
||||
const raw = params.raw;
|
||||
const resolved =
|
||||
raw.startsWith("~") || path.isAbsolute(raw)
|
||||
? resolveUserPath(raw)
|
||||
: path.resolve(workspaceRoot, raw);
|
||||
const realPath = resolveExistingPath(resolved);
|
||||
if (!isPathWithin(workspaceRoot, realPath)) {
|
||||
return { ok: false, reason: "outside_workspace" };
|
||||
}
|
||||
const ext = path.extname(realPath).toLowerCase();
|
||||
if (!ALLOWED_AVATAR_EXTS.has(ext)) {
|
||||
return { ok: false, reason: "unsupported_extension" };
|
||||
}
|
||||
try {
|
||||
if (!fs.statSync(realPath).isFile()) {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
return { ok: true, filePath: realPath };
|
||||
}
|
||||
|
||||
export function resolveAgentAvatar(cfg: ClawdbotConfig, agentId: string): AgentAvatarResolution {
|
||||
const source = resolveAvatarSource(cfg, agentId);
|
||||
if (!source) {
|
||||
return { kind: "none", reason: "missing" };
|
||||
}
|
||||
if (isRemoteAvatar(source)) {
|
||||
return { kind: "remote", url: source };
|
||||
}
|
||||
if (isDataAvatar(source)) {
|
||||
return { kind: "data", url: source };
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
|
||||
if (!resolved.ok) {
|
||||
return { kind: "none", reason: resolved.reason };
|
||||
}
|
||||
return { kind: "local", filePath: resolved.filePath };
|
||||
}
|
||||
63
src/agents/identity-file.ts
Normal file
63
src/agents/identity-file.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "./workspace.js";
|
||||
|
||||
export type AgentIdentityFile = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
theme?: string;
|
||||
creature?: string;
|
||||
vibe?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
const identity: AgentIdentityFile = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
||||
const colonIndex = cleaned.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
||||
const value = cleaned
|
||||
.slice(colonIndex + 1)
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
if (label === "theme") identity.theme = value;
|
||||
if (label === "avatar") identity.avatar = value;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function identityHasValues(identity: AgentIdentityFile): boolean {
|
||||
return Boolean(
|
||||
identity.name ||
|
||||
identity.emoji ||
|
||||
identity.theme ||
|
||||
identity.creature ||
|
||||
identity.vibe ||
|
||||
identity.avatar,
|
||||
);
|
||||
}
|
||||
|
||||
export function loadIdentityFromFile(identityPath: string): AgentIdentityFile | null {
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!identityHasValues(parsed)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAgentIdentityFromWorkspace(workspace: string): AgentIdentityFile | null {
|
||||
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
||||
return loadIdentityFromFile(identityPath);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
||||
|
||||
agents
|
||||
.command("set-identity")
|
||||
.description("Update an agent identity (name/theme/emoji)")
|
||||
.description("Update an agent identity (name/theme/emoji/avatar)")
|
||||
.option("--agent <id>", "Agent id to update")
|
||||
.option("--workspace <dir>", "Workspace directory used to locate the agent + IDENTITY.md")
|
||||
.option("--identity-file <path>", "Explicit IDENTITY.md path to read")
|
||||
@@ -147,6 +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("--json", "Output JSON summary", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -174,6 +175,7 @@ ${formatHelpExamples([
|
||||
name: opts.name as string | undefined,
|
||||
theme: opts.theme as string | undefined,
|
||||
emoji: opts.emoji as string | undefined,
|
||||
avatar: opts.avatar as string | undefined,
|
||||
json: Boolean(opts.json),
|
||||
},
|
||||
defaultRuntime,
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||
import type { IdentityConfig } from "../config/types.js";
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
findAgentEntryIndex,
|
||||
listAgentEntries,
|
||||
loadAgentIdentity,
|
||||
parseIdentityMarkdown,
|
||||
} from "./agents.config.js";
|
||||
|
||||
type AgentsSetIdentityOptions = {
|
||||
@@ -25,6 +25,7 @@ type AgentsSetIdentityOptions = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
theme?: string;
|
||||
avatar?: string;
|
||||
fromIdentity?: boolean;
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -40,7 +41,7 @@ async function loadIdentityFromFile(filePath: string): Promise<AgentIdentity | n
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
|
||||
if (!identityHasValues(parsed)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
@@ -75,7 +76,8 @@ export async function agentsSetIdentityCommand(
|
||||
const nameRaw = coerceTrimmed(opts.name);
|
||||
const emojiRaw = coerceTrimmed(opts.emoji);
|
||||
const themeRaw = coerceTrimmed(opts.theme);
|
||||
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw);
|
||||
const avatarRaw = coerceTrimmed(opts.avatar);
|
||||
const hasExplicitIdentity = Boolean(nameRaw || emojiRaw || themeRaw || avatarRaw);
|
||||
|
||||
const identityFileRaw = coerceTrimmed(opts.identityFile);
|
||||
const workspaceRaw = coerceTrimmed(opts.workspace);
|
||||
@@ -141,10 +143,20 @@ export async function agentsSetIdentityCommand(
|
||||
...(nameRaw || identityFromFile?.name ? { name: nameRaw ?? identityFromFile?.name } : {}),
|
||||
...(emojiRaw || identityFromFile?.emoji ? { emoji: emojiRaw ?? identityFromFile?.emoji } : {}),
|
||||
...(themeRaw || fileTheme ? { theme: themeRaw ?? fileTheme } : {}),
|
||||
...(avatarRaw || identityFromFile?.avatar
|
||||
? { avatar: avatarRaw ?? identityFromFile?.avatar }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (!incomingIdentity.name && !incomingIdentity.emoji && !incomingIdentity.theme) {
|
||||
runtime.error("No identity fields provided. Use --name/--emoji/--theme or --from-identity.");
|
||||
if (
|
||||
!incomingIdentity.name &&
|
||||
!incomingIdentity.emoji &&
|
||||
!incomingIdentity.theme &&
|
||||
!incomingIdentity.avatar
|
||||
) {
|
||||
runtime.error(
|
||||
"No identity fields provided. Use --name/--emoji/--theme/--avatar or --from-identity.",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
@@ -204,5 +216,6 @@ export async function agentsSetIdentityCommand(
|
||||
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
||||
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
||||
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
||||
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
|
||||
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||
import type { AgentIdentityFile } from "../agents/identity-file.js";
|
||||
import {
|
||||
identityHasValues,
|
||||
loadAgentIdentityFromWorkspace,
|
||||
parseIdentityMarkdown as parseIdentityMarkdownFile,
|
||||
} from "../agents/identity-file.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
@@ -28,14 +30,7 @@ export type AgentSummary = {
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||
|
||||
export type AgentIdentity = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
creature?: string;
|
||||
vibe?: string;
|
||||
theme?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
export type AgentIdentity = AgentIdentityFile;
|
||||
|
||||
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
||||
const list = cfg.agents?.list;
|
||||
@@ -74,47 +69,13 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) {
|
||||
}
|
||||
|
||||
export function parseIdentityMarkdown(content: string): AgentIdentity {
|
||||
const identity: AgentIdentity = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
||||
const colonIndex = cleaned.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
||||
const value = cleaned
|
||||
.slice(colonIndex + 1)
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
if (label === "theme") identity.theme = value;
|
||||
if (label === "avatar") identity.avatar = value;
|
||||
}
|
||||
return identity;
|
||||
return parseIdentityMarkdownFile(content);
|
||||
}
|
||||
|
||||
export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
||||
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (
|
||||
!parsed.name &&
|
||||
!parsed.emoji &&
|
||||
!parsed.theme &&
|
||||
!parsed.creature &&
|
||||
!parsed.vibe &&
|
||||
!parsed.avatar
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = loadAgentIdentityFromWorkspace(workspace);
|
||||
if (!parsed) return null;
|
||||
return identityHasValues(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] {
|
||||
|
||||
@@ -54,7 +54,13 @@ describe("agents set-identity command", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"),
|
||||
[
|
||||
"- Name: Clawd",
|
||||
"- Creature: helpful sloth",
|
||||
"- Emoji: :)",
|
||||
"- Avatar: avatars/clawd.png",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -81,6 +87,7 @@ describe("agents set-identity command", () => {
|
||||
name: "Clawd",
|
||||
theme: "helpful sloth",
|
||||
emoji: ":)",
|
||||
avatar: "avatars/clawd.png",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +122,13 @@ describe("agents set-identity command", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"),
|
||||
[
|
||||
"- Name: Clawd",
|
||||
"- Theme: space lobster",
|
||||
"- Emoji: :)",
|
||||
"- Avatar: avatars/base.png",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -125,7 +138,7 @@ describe("agents set-identity command", () => {
|
||||
});
|
||||
|
||||
await agentsSetIdentityCommand(
|
||||
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞" },
|
||||
{ workspace, fromIdentity: true, name: "Nova", emoji: "🦞", avatar: "avatars/custom.png" },
|
||||
runtime,
|
||||
);
|
||||
|
||||
@@ -137,6 +150,7 @@ describe("agents set-identity command", () => {
|
||||
name: "Nova",
|
||||
theme: "space lobster",
|
||||
emoji: "🦞",
|
||||
avatar: "avatars/custom.png",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,9 +161,13 @@ describe("agents set-identity command", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
identityPath,
|
||||
["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join(
|
||||
"\n",
|
||||
),
|
||||
[
|
||||
"- **Name:** C-3PO",
|
||||
"- **Creature:** Flustered Protocol Droid",
|
||||
"- **Emoji:** 🤖",
|
||||
"- **Avatar:** avatars/c3po.png",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -168,6 +186,33 @@ describe("agents set-identity command", () => {
|
||||
name: "C-3PO",
|
||||
theme: "Flustered Protocol Droid",
|
||||
emoji: "🤖",
|
||||
avatar: "avatars/c3po.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts avatar-only identity from IDENTITY.md", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"- Avatar: avatars/only.png\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
config: { agents: { list: [{ id: "main", workspace }] } },
|
||||
});
|
||||
|
||||
await agentsSetIdentityCommand({ workspace, fromIdentity: true }, 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: "avatars/only.png",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||
"channels.signal.account": "Signal Account",
|
||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||
"agents.list[].identity.avatar": "Agent Avatar",
|
||||
"plugins.enabled": "Enable Plugins",
|
||||
"plugins.allow": "Plugin Allowlist",
|
||||
"plugins.deny": "Plugin Denylist",
|
||||
@@ -343,6 +344,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"gateway.remote.sshTarget":
|
||||
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
||||
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||
"agents.list[].identity.avatar":
|
||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
|
||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||
"gateway.controlUi.basePath":
|
||||
@@ -613,6 +616,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",
|
||||
};
|
||||
|
||||
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||
|
||||
@@ -154,6 +154,6 @@ export type IdentityConfig = {
|
||||
name?: string;
|
||||
theme?: string;
|
||||
emoji?: string;
|
||||
/** Path to a custom avatar image (relative to workspace or absolute). */
|
||||
/** Avatar image path (workspace-relative) or a URL/data URL. Local files must live in the workspace. */
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
@@ -86,6 +86,7 @@ export const IdentitySchema = z
|
||||
name: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
avatar: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT_PREFIX = "/";
|
||||
const AVATAR_PREFIX = "/avatar";
|
||||
|
||||
export type ControlUiRequestOptions = {
|
||||
basePath?: string;
|
||||
@@ -62,6 +63,10 @@ function contentTypeForExt(ext: string): string {
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".ico":
|
||||
return "image/x-icon";
|
||||
case ".txt":
|
||||
@@ -71,6 +76,83 @@ function contentTypeForExt(ext: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export type ControlUiAvatarResolution =
|
||||
| { kind: "none"; reason: string }
|
||||
| { kind: "local"; filePath: string }
|
||||
| { kind: "remote"; url: string }
|
||||
| { kind: "data"; url: string };
|
||||
|
||||
type ControlUiAvatarMeta = {
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function buildAvatarUrl(basePath: string, agentId: string): string {
|
||||
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
|
||||
}
|
||||
|
||||
function isValidAgentId(agentId: string): boolean {
|
||||
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId);
|
||||
}
|
||||
|
||||
export function handleControlUiAvatarRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution },
|
||||
): boolean {
|
||||
const urlRaw = req.url;
|
||||
if (!urlRaw) return false;
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
||||
|
||||
const url = new URL(urlRaw, "http://localhost");
|
||||
const basePath = normalizeControlUiBasePath(opts.basePath);
|
||||
const pathname = url.pathname;
|
||||
const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
|
||||
if (!pathname.startsWith(pathWithBase)) return false;
|
||||
|
||||
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
|
||||
const agentId = agentIdParts[0] ?? "";
|
||||
if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) {
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.searchParams.get("meta") === "1") {
|
||||
const resolved = opts.resolveAvatar(agentId);
|
||||
const avatarUrl =
|
||||
resolved.kind === "local"
|
||||
? buildAvatarUrl(basePath, agentId)
|
||||
: resolved.kind === "remote" || resolved.kind === "data"
|
||||
? resolved.url
|
||||
: null;
|
||||
sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta);
|
||||
return true;
|
||||
}
|
||||
|
||||
const resolved = opts.resolveAvatar(agentId);
|
||||
if (resolved.kind !== "local") {
|
||||
respondNotFound(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentTypeForExt(path.extname(resolved.filePath).toLowerCase()));
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
serveFile(res, resolved.filePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function respondNotFound(res: ServerResponse) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
|
||||
@@ -11,7 +11,9 @@ import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
getHookChannelError,
|
||||
@@ -244,6 +246,13 @@ export function createGatewayHttpServer(opts: {
|
||||
if (await canvasHost.handleHttpRequest(req, res)) return;
|
||||
}
|
||||
if (controlUiEnabled) {
|
||||
if (
|
||||
handleControlUiAvatarRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId),
|
||||
})
|
||||
)
|
||||
return;
|
||||
if (
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
|
||||
Reference in New Issue
Block a user