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

@@ -31,6 +31,8 @@ import { randomUUID } from 'node:crypto';
import { MindDB, ExecutionTraceStore } from '@waggle/core';
import { agentRoutes } from '../../src/local/routes/agent.js';
import { agentEntityRoutes } from '../../src/local/routes/agents.js';
import { AgentRunRegistry } from '../../src/local/agent-run-registry.js';
import { securityMiddleware } from '../../src/local/security-middleware.js';
import type { WorkspaceSession } from '../../src/local/workspace-sessions.js';
interface FakeSession {
@@ -48,6 +50,8 @@ function createTestServer(opts: {
pausedIds?: string[];
spawnCalls?: Array<Record<string, unknown>>;
auditRecords?: Array<Record<string, unknown>>;
agentRunRegistry?: AgentRunRegistry;
workspaceManager?: { get(id: string): unknown };
}) {
const server = Fastify({ logger: false });
server.decorate('localConfig', { dataDir: opts.dataDir });
@@ -65,6 +69,8 @@ function createTestServer(opts: {
return input;
},
});
if (opts.agentRunRegistry) server.decorate('agentRunRegistry', opts.agentRunRegistry);
if (opts.workspaceManager) server.decorate('workspaceManager', opts.workspaceManager as never);
// Minimal agentState so the REAL agentRoutes plugin registers (it reads
// costTracker at register time). No subagentOrchestrator → /api/agents/active
// returns the empty orchestrator state.
@@ -89,6 +95,7 @@ function createTestServer(opts: {
model: body.model,
};
});
if (opts.agentRunRegistry && opts.workspaceManager) server.register(securityMiddleware);
// Same order as local/index.ts: agentRoutes (static /api/agents/active)
// first, then the /:id param plugin.
server.register(agentRoutes);
@@ -143,6 +150,63 @@ describe('Agent entity routes (Phase 3)', () => {
return res.json().agent;
}
it('rejects workspace traversal before /api/history can read an escaped session file', async () => {
const escapedDir = path.join(dataDir, 'outside', 'sessions');
fs.mkdirSync(escapedDir, { recursive: true });
fs.writeFileSync(
path.join(escapedDir, 'leak.jsonl'),
`${JSON.stringify({ role: 'assistant', content: 'outside-secret' })}\n`,
);
const res = await server.inject({
method: 'GET',
url: '/api/history?workspace=..%2Foutside&session=leak',
});
expect(res.statusCode).toBe(400);
expect(res.body).not.toContain('outside-secret');
});
it.each([
['workspace', '..\\outside'],
['workspace', 'C:\\outside'],
['workspace', '\\\\server\\share'],
['workspace', '/absolute'],
['session', '../../../outside/leak'],
['session', '..\\..\\..\\outside\\leak'],
] as const)('rejects unsafe %s history segment %s', async (field, value) => {
const query = new URLSearchParams({ workspace: 'ws-safe', session: 'session-safe' });
query.set(field, value);
const res = await server.inject({
method: 'GET',
url: `/api/history?${query.toString()}`,
});
expect(res.statusCode).toBe(400);
});
it('loads a valid on-disk history after segment validation', async () => {
const sessionDir = path.join(dataDir, 'workspaces', 'ws-safe', 'sessions');
fs.mkdirSync(sessionDir, { recursive: true });
fs.writeFileSync(
path.join(sessionDir, 'session-safe.jsonl'),
`${JSON.stringify({ role: 'assistant', content: 'inside-history' })}\n`,
);
const res = await server.inject({
method: 'GET',
url: '/api/history?workspace=ws-safe&session=session-safe',
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
sessionId: 'session-safe',
count: 1,
messages: [{ role: 'assistant', content: 'inside-history' }],
});
});
/** One-shot run helper — also pushes the spawn's session into the fake
* session manager so liveStatus/pause have something to act on. */
async function runAgent(agent: { id: string }, payload: Record<string, unknown> = {}) {
@@ -351,6 +415,54 @@ describe('Agent entity routes (Phase 3)', () => {
expect(pausedIds).toEqual([]);
});
it('blocks a viewer from pausing the agent durable run resolved by stored ownership', async () => {
const agent = await createAgent({ ...VALID_BODY, workspaceIds: ['viewer-workspace'] });
await server.close();
const registry = new AgentRunRegistry(path.join(dataDir, 'agent-runs.json'));
server = createTestServer({
dataDir,
traceStore,
sessions,
pausedIds,
spawnCalls,
auditRecords,
agentRunRegistry: registry,
workspaceManager: {
get: (id: string) => id === 'viewer-workspace'
? { id, teamId: 'team-1', teamRole: 'viewer' }
: undefined,
},
});
const room = registry.createRoom({
workspaceIds: ['viewer-workspace'],
source: 'fleet',
title: 'Viewer agent room',
task: 'Keep running',
});
const worker = registry.createWorker({
parentRunId: room.id,
workspaceId: 'viewer-workspace',
source: 'fleet',
executor: { kind: 'waggle_agent', agentId: agent.id },
title: 'Viewer agent worker',
task: room.task,
status: 'running',
capabilities: { cancel: true },
});
let controlCalls = 0;
registry.registerControls(worker.id, { cancel: () => { controlCalls++; } });
const response = await server.inject({
method: 'POST',
url: `/api/agents/${agent.id}/pause`,
});
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: 'VIEWER_READ_ONLY' });
expect(controlCalls).toBe(0);
expect(registry.get(worker.id)?.status).toBe('running');
});
it('agent with no workspaceIds is pausable after /run (default-workspace fallback)', async () => {
const agent = await createAgent({ ...VALID_BODY, name: 'NoWs', workspaceIds: undefined });
const run = await runAgent(agent);