Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/main/agentAvatarStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { beforeEach, expect, test, vi } from 'vitest';

vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/lobsterai-tests',
},
}));

import { AGENT_AVATAR_MAX_BYTES, AgentAvatarErrorCode } from '../shared/agentAvatar/constants';
import {
deleteManagedAgentAvatar,
importAgentAvatar,
isManagedAgentAvatarPath,
} from './agentAvatarStore';

let userDataPath: string;

beforeEach(() => {
userDataPath = fs.mkdtempSync(path.join(os.tmpdir(), 'lobsterai-avatar-store-'));
});

test('importAgentAvatar copies a supported image into the managed avatar directory', async () => {
const sourcePath = path.join(userDataPath, 'source.png');
fs.writeFileSync(sourcePath, Buffer.from('avatar-image'));

const importedPath = await importAgentAvatar({
agentId: 'writer',
sourcePath,
userDataPath,
});

expect(importedPath).not.toBe(sourcePath);
expect(path.basename(path.dirname(importedPath))).toBe('agent-avatars');
expect(fs.existsSync(importedPath)).toBe(true);
expect(isManagedAgentAvatarPath(importedPath, userDataPath)).toBe(true);

await deleteManagedAgentAvatar(importedPath, userDataPath);
expect(fs.existsSync(importedPath)).toBe(false);
});

test('importAgentAvatar rejects unsupported file types', async () => {
const sourcePath = path.join(userDataPath, 'source.txt');
fs.writeFileSync(sourcePath, 'not-an-image');

await expect(importAgentAvatar({
agentId: 'writer',
sourcePath,
userDataPath,
})).rejects.toMatchObject({
code: AgentAvatarErrorCode.UnsupportedFileType,
});
});

test('importAgentAvatar rejects files that exceed the size limit', async () => {
const sourcePath = path.join(userDataPath, 'large.png');
fs.writeFileSync(sourcePath, Buffer.alloc(AGENT_AVATAR_MAX_BYTES + 1, 1));

await expect(importAgentAvatar({
agentId: 'writer',
sourcePath,
userDataPath,
})).rejects.toMatchObject({
code: AgentAvatarErrorCode.FileTooLarge,
});
});
130 changes: 130 additions & 0 deletions src/main/agentAvatarStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { app } from 'electron';
import fs from 'fs';
import path from 'path';

import {
AGENT_AVATAR_DIR_NAME,
AGENT_AVATAR_MAX_BYTES,
AgentAvatarAllowedExtensions,
AgentAvatarErrorCode,
type AgentAvatarErrorCode as AgentAvatarErrorCodeValue,
AgentAvatarExtension,
} from '../shared/agentAvatar/constants';

const TAG = '[AgentAvatarStore]';

const isWithinDirectory = (targetPath: string, directoryPath: string): boolean => {
const relative = path.relative(directoryPath, targetPath);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
};

const getUserDataPath = (userDataPath?: string): string => {
return userDataPath ?? app.getPath('userData');
};

const normalizeExtension = (filePath: string): string => {
return path.extname(filePath).replace(/^\./, '').toLowerCase();
};

export class AgentAvatarError extends Error {
code: AgentAvatarErrorCodeValue;

constructor(code: AgentAvatarErrorCodeValue, message?: string) {
super(message ?? code);
this.name = 'AgentAvatarError';
this.code = code;
}
}

export const isAgentAvatarError = (error: unknown): error is AgentAvatarError => {
return error instanceof AgentAvatarError;
};

export const resolveAgentAvatarDir = (userDataPath?: string): string => {
return path.join(getUserDataPath(userDataPath), AGENT_AVATAR_DIR_NAME);
};

export const isManagedAgentAvatarPath = (
avatarPath?: string | null,
userDataPath?: string,
): boolean => {
if (typeof avatarPath !== 'string' || !avatarPath.trim()) {
return false;
}

const resolvedAvatarPath = path.resolve(avatarPath.trim());
return isWithinDirectory(resolvedAvatarPath, resolveAgentAvatarDir(userDataPath));
};

interface ImportAgentAvatarOptions {
agentId: string;
sourcePath: string;
userDataPath?: string;
}

export async function importAgentAvatar({
agentId,
sourcePath,
userDataPath,
}: ImportAgentAvatarOptions): Promise<string> {
const trimmedSourcePath = sourcePath.trim();
if (!trimmedSourcePath) {
throw new AgentAvatarError(AgentAvatarErrorCode.SourceNotFound);
}

const resolvedSourcePath = path.resolve(trimmedSourcePath);
const extension = normalizeExtension(resolvedSourcePath);
if (!AgentAvatarAllowedExtensions.includes(extension as AgentAvatarExtension)) {
throw new AgentAvatarError(AgentAvatarErrorCode.UnsupportedFileType);
}

let stat: fs.Stats;
try {
stat = await fs.promises.stat(resolvedSourcePath);
} catch (error) {
console.warn(`${TAG} Failed to stat source avatar file:`, error);
throw new AgentAvatarError(AgentAvatarErrorCode.SourceNotFound);
}

if (!stat.isFile()) {
throw new AgentAvatarError(AgentAvatarErrorCode.SourceNotFound);
}

if (stat.size > AGENT_AVATAR_MAX_BYTES) {
throw new AgentAvatarError(AgentAvatarErrorCode.FileTooLarge);
}

const avatarDir = resolveAgentAvatarDir(userDataPath);
await fs.promises.mkdir(avatarDir, { recursive: true });

const safeAgentId = agentId.replace(/[^a-z0-9_-]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'agent';
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const destinationPath = path.join(avatarDir, `${safeAgentId}-${uniqueSuffix}.${extension}`);

try {
await fs.promises.copyFile(resolvedSourcePath, destinationPath);
return destinationPath;
} catch (error) {
console.error(`${TAG} Failed to import avatar file:`, error);
throw new AgentAvatarError(AgentAvatarErrorCode.ImportFailed);
}
}

export async function deleteManagedAgentAvatar(
avatarPath?: string | null,
userDataPath?: string,
): Promise<void> {
if (!isManagedAgentAvatarPath(avatarPath, userDataPath)) {
return;
}

const resolvedAvatarPath = path.resolve(avatarPath!.trim());
try {
await fs.promises.unlink(resolvedAvatarPath);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return;
}
console.warn(`${TAG} Failed to delete managed avatar:`, error);
}
}
25 changes: 21 additions & 4 deletions src/main/coworkStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function setupDb(): void {
identity TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
icon TEXT NOT NULL DEFAULT '',
avatar_path TEXT NOT NULL DEFAULT '',
skill_ids TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
is_default INTEGER NOT NULL DEFAULT 0,
Expand Down Expand Up @@ -231,11 +232,11 @@ test('getConfig defaults skipMissedJobs to true when config is missing', () => {
test('backfillEmptyAgentModels assigns the current default model to empty agents only', () => {
const now = Date.now();
db.prepare(
`INSERT INTO agents (id, name, model, icon, skill_ids, enabled, is_default, source, preset_id, description, system_prompt, identity, created_at, updated_at)
`INSERT INTO agents (id, name, model, icon, avatar_path, skill_ids, enabled, is_default, source, preset_id, description, system_prompt, identity, created_at, updated_at)
VALUES
('main', 'main', '', '', '[]', 1, 1, 'custom', '', '', '', '', ?, ?),
('writer', 'Writer', '', '', '[]', 1, 0, 'custom', '', '', '', '', ?, ?),
('stockexpert', 'Stock Expert', 'qwen3.5-plus', '', '[]', 1, 0, 'preset', 'stockexpert', '', '', '', ?, ?)`,
('main', 'main', '', '', '', '[]', 1, 1, 'custom', '', '', '', '', ?, ?),
('writer', 'Writer', '', '', '', '[]', 1, 0, 'custom', '', '', '', '', ?, ?),
('stockexpert', 'Stock Expert', 'qwen3.5-plus', '', '', '[]', 1, 0, 'preset', 'stockexpert', '', '', '', ?, ?)`,
).run(now, now, now, now, now, now);

expect(store.backfillEmptyAgentModels('deepseek-v3.2')).toBe(2);
Expand All @@ -247,3 +248,19 @@ test('backfillEmptyAgentModels assigns the current default model to empty agents
['writer', 'deepseek-v3.2'],
]);
});

test('createAgent and updateAgent persist avatarPath values', () => {
const created = store.createAgent({
name: 'Writer',
icon: '✍️',
avatarPath: '/tmp/writer-avatar.png',
});

expect(created.avatarPath).toBe('/tmp/writer-avatar.png');

const updated = store.updateAgent(created.id, {
avatarPath: '',
});

expect(updated?.avatarPath).toBe('');
});
19 changes: 17 additions & 2 deletions src/main/coworkStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@
return false;
}

function scoreDeleteMatch(targetKey: string, queryKey: string): number {

Check warning on line 272 in src/main/coworkStore.ts

View workflow job for this annotation

GitHub Actions / lint

'scoreDeleteMatch' is defined but never used. Allowed unused vars must match /^_/u
if (!targetKey || !queryKey) return 0;
if (targetKey === queryKey) {
return 1000 + queryKey.length;
Expand Down Expand Up @@ -324,6 +324,7 @@
identity: string;
model: string;
icon: string;
avatarPath: string;
skillIds: string[];
enabled: boolean;
isDefault: boolean;
Expand All @@ -341,6 +342,8 @@
identity?: string;
model?: string;
icon?: string;
avatarPath?: string;
avatarSourcePath?: string;
skillIds?: string[];
source?: AgentSource;
presetId?: string;
Expand All @@ -353,6 +356,9 @@
identity?: string;
model?: string;
icon?: string;
avatarPath?: string;
avatarSourcePath?: string;
removeAvatar?: boolean;
skillIds?: string[];
enabled?: boolean;
}
Expand Down Expand Up @@ -1785,6 +1791,7 @@
identity: string;
model: string;
icon: string;
avatar_path: string;
skill_ids: string;
enabled: number;
is_default: number;
Expand All @@ -1810,6 +1817,7 @@
identity: string;
model: string;
icon: string;
avatar_path: string;
skill_ids: string;
enabled: number;
is_default: number;
Expand Down Expand Up @@ -1844,8 +1852,8 @@
this.db
.prepare(
`
INSERT INTO agents (id, name, description, system_prompt, identity, model, icon, skill_ids, enabled, is_default, source, preset_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
INSERT INTO agents (id, name, description, system_prompt, identity, model, icon, avatar_path, skill_ids, enabled, is_default, source, preset_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
`,
)
.run(
Expand All @@ -1856,6 +1864,7 @@
request.identity || '',
request.model || '',
request.icon || '',
request.avatarPath || '',
JSON.stringify(request.skillIds || []),
request.source || 'custom',
request.presetId || '',
Expand Down Expand Up @@ -1909,6 +1918,10 @@
setClauses.push('icon = ?');
values.push(updates.icon);
}
if (updates.avatarPath !== undefined) {
setClauses.push('avatar_path = ?');
values.push(updates.avatarPath);
}
if (updates.skillIds !== undefined) {
setClauses.push('skill_ids = ?');
values.push(JSON.stringify(updates.skillIds));
Expand Down Expand Up @@ -1937,6 +1950,7 @@
identity: string;
model: string;
icon: string;
avatar_path: string;
skill_ids: string;
enabled: number;
is_default: number;
Expand All @@ -1959,6 +1973,7 @@
identity: row.identity,
model: row.model,
icon: row.icon,
avatarPath: row.avatar_path ?? '',
skillIds,
enabled: Boolean(row.enabled),
isDefault: Boolean(row.is_default),
Expand Down
31 changes: 31 additions & 0 deletions src/main/libs/openclawAgentModels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('buildAgentEntry', () => {
identity: '',
model: 'lobsterai-server/deepseek-v3.2',
icon: '',
avatarPath: '',
skillIds: [],
enabled: true,
isDefault: true,
Expand All @@ -43,6 +44,7 @@ describe('buildAgentEntry', () => {
identity: '',
model: 'deepseek-v3.2',
icon: '',
avatarPath: '',
skillIds: [],
enabled: true,
isDefault: true,
Expand All @@ -57,6 +59,33 @@ describe('buildAgentEntry', () => {
model: { primary: 'anthropic/claude-sonnet-4' },
});
});

test('does not emit image avatar metadata into the OpenClaw identity block', () => {
const result = buildAgentEntry({
id: 'writer',
name: 'Writer',
description: '',
systemPrompt: '',
identity: '',
model: 'openai/gpt-4o',
icon: '',
avatarPath: '/tmp/writer-avatar.png',
skillIds: [],
enabled: true,
isDefault: false,
source: 'custom',
presetId: '',
createdAt: 0,
updatedAt: 0,
}, 'anthropic/claude-sonnet-4');

expect(result).toMatchObject({
id: 'writer',
identity: { name: 'Writer' },
});
expect(result).not.toHaveProperty('identity.avatarPath');
expect(result).not.toHaveProperty('identity.image');
});
});

describe('buildManagedAgentEntries', () => {
Expand All @@ -71,6 +100,7 @@ describe('buildManagedAgentEntries', () => {
identity: '',
model: 'openai/gpt-4o',
icon: '✍️',
avatarPath: '',
skillIds: ['docx'],
enabled: true,
isDefault: false,
Expand Down Expand Up @@ -101,6 +131,7 @@ describe('buildManagedAgentEntries', () => {
identity: '',
model: '',
icon: '✍️',
avatarPath: '',
skillIds: [],
enabled: true,
isDefault: false,
Expand Down
Loading
Loading