This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -7,16 +7,58 @@
* - Does NOT include systemPrompt (sensitive/large)
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, it, expect } from 'vitest';
import Fastify from 'fastify';
import {
deleteCustomPersona,
deployPersonaOverride,
isValidCustomPersonaId,
listPersonas,
rollbackPersonaOverride,
saveCustomPersona,
type AgentPersona,
} from '@waggle/agent';
import { personaRoutes } from '../../src/local/routes/personas.js';
function createTestServer() {
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-personas-route-'));
tempDirs.push(dir);
return dir;
}
function createTestServer(dataDir = os.tmpdir()) {
const server = Fastify({ logger: false });
server.decorate('localConfig', { dataDir } as never);
server.register(personaRoutes);
return server;
}
function makePersona(id: string): AgentPersona {
return {
id,
name: 'Test Persona',
description: 'A test persona',
icon: 'test',
systemPrompt: 'Be useful.',
modelPreference: 'claude-sonnet-4-6',
tools: [],
workspaceAffinity: [],
suggestedCommands: [],
defaultWorkflow: null,
};
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* Windows handle lag */ }
}
});
describe('Personas Routes', () => {
// ── GET /api/personas ─────────────────────────────────────────────
@@ -100,4 +142,149 @@ describe('Personas Routes', () => {
await server.close();
});
});
describe('custom persona ID boundaries', () => {
it('accepts every shipped persona ID', () => {
for (const persona of listPersonas()) {
expect(isValidCustomPersonaId(persona.id), persona.id).toBe(true);
}
});
it('rejects unsafe explicit IDs before persistence', async () => {
const dataDir = makeTempDir();
const server = createTestServer(dataDir);
const unsafeIds = [
'../outside',
'..\\outside',
'folder/agent',
'folder\\agent',
'.',
'..',
'profile:secret',
'profile::$DATA',
'control\u0000id',
'control\u001fid',
'CON',
'prn.json',
'COM1',
'lPt9.txt',
'COM¹',
'lpt².txt',
'trailing.',
'trailing ',
];
try {
for (const id of unsafeIds) {
const res = await server.inject({
method: 'POST',
url: '/api/personas',
payload: { id, name: 'Unsafe Persona', systemPrompt: 'Do unsafe work.' },
});
expect(res.statusCode, id).toBe(400);
expect(res.json()).toMatchObject({ error: 'Invalid request body' });
}
} finally {
await server.close();
}
});
it('keeps portable custom IDs and built-in conflict responses working', async () => {
const dataDir = makeTempDir();
const server = createTestServer(dataDir);
try {
const created = await server.inject({
method: 'POST',
url: '/api/personas',
payload: { id: 'qa.v2_agent-1', name: 'QA Agent', systemPrompt: 'Verify changes.' },
});
expect(created.statusCode).toBe(201);
expect(fs.existsSync(path.join(dataDir, 'personas', 'qa.v2_agent-1.json'))).toBe(true);
const conflict = await server.inject({
method: 'POST',
url: '/api/personas',
payload: { id: 'coder', name: 'Coder Clone', systemPrompt: 'Write code.' },
});
expect(conflict.statusCode).toBe(409);
expect(conflict.json()).toEqual({ error: 'A built-in persona with this ID already exists' });
} finally {
await server.close();
}
});
it('rejects unsafe IDs on PATCH and DELETE route parameters', async () => {
const server = createTestServer(makeTempDir());
try {
const patched = await server.inject({
method: 'PATCH',
url: '/api/personas/CON',
payload: { name: 'Changed' },
});
expect(patched.statusCode).toBe(400);
const deleted = await server.inject({ method: 'DELETE', url: '/api/personas/CON' });
expect(deleted.statusCode).toBe(400);
} finally {
await server.close();
}
});
it('saveCustomPersona rejects traversal before writing outside personas', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'outside.json');
fs.writeFileSync(outsidePath, 'sentinel', 'utf-8');
expect(() => saveCustomPersona(dataDir, makePersona('../outside')))
.toThrow('Invalid custom persona ID');
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('sentinel');
});
it('deleteCustomPersona rejects traversal before deleting outside personas', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'outside.json');
fs.writeFileSync(outsidePath, 'sentinel', 'utf-8');
expect(() => deleteCustomPersona(dataDir, '../outside'))
.toThrow('Invalid custom persona ID');
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('sentinel');
});
it('deployPersonaOverride rejects traversal before writing an override', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'evolved.json');
expect(() => deployPersonaOverride(dataDir, {
personaId: '../evolved',
systemPrompt: 'Escaped override',
})).toThrow('Invalid custom persona ID');
expect(fs.existsSync(outsidePath)).toBe(false);
});
it('deployPersonaOverride keeps its validated ID authoritative over overrides', () => {
const dataDir = makeTempDir();
const result = deployPersonaOverride(dataDir, {
personaId: 'safe-persona',
systemPrompt: 'Validated prompt',
overrides: { id: '../evolved', systemPrompt: 'Spoofed prompt' } as never,
});
const deployed = JSON.parse(fs.readFileSync(result.path, 'utf-8')) as AgentPersona;
expect(deployed.id).toBe('safe-persona');
expect(deployed.systemPrompt).toBe('Validated prompt');
expect(fs.existsSync(path.join(dataDir, 'evolved.json'))).toBe(false);
});
it('rollbackPersonaOverride rejects traversal before deleting an override', () => {
const dataDir = makeTempDir();
const outsidePath = path.join(dataDir, 'evolved.json');
fs.writeFileSync(outsidePath, 'sentinel', 'utf-8');
expect(() => rollbackPersonaOverride(dataDir, '../evolved'))
.toThrow('Invalid custom persona ID');
expect(fs.readFileSync(outsidePath, 'utf-8')).toBe('sentinel');
});
});
});