100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
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 };
|
|
}
|