feat: add agent identity avatars (#1329) (thanks @dlauer)
This commit is contained in:
54
src/config/config.identity-avatar.test.ts
Normal file
54
src/config/config.identity-avatar.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user