moving
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user