import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { once } from 'node:events'; import { mkdtempSync, rmSync } from 'node:fs'; import { createServer } from 'node:http'; import { createConnection } from 'node:net'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import Fastify from 'fastify'; import sensible from '@fastify/sensible'; import { WebSocket, WebSocketServer } from 'ws'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-')); process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`; process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789'; execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe', }); const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([ import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'), ]); const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }; const privateKey = 'sybil-agent-private-key-for-test'; const requests: { method?: string; url?: string; body: any }[] = []; const accepted = new Map(); let upstreamReply: { status: number; body: string } | undefined; let connections = 0; const subscriptions: any[] = []; const upstream = createServer(async (req, res) => { assert.equal(req.headers.authorization, `Bearer ${privateKey}`); const chunks = []; for await (const chunk of req) chunks.push(chunk); const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null; requests.push({ method: req.method, url: req.url, body }); res.setHeader('Content-Type', 'application/json'); if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; } if (req.method === 'POST') { const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id); res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` })); } else if (req.url?.includes('/messages')) { res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false })); } else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } })); }); const sourceSockets = new WebSocketServer({ noServer: true }); upstream.on('upgrade', (req, socket, head) => { assert.equal(req.headers.authorization, `Bearer ${privateKey}`); assert.equal(req.url, '/api/ws'); connections++; sourceSockets.handleUpgrade(req, socket, head, ws => { ws.on('message', raw => { const frame = JSON.parse(raw.toString()); subscriptions.push(frame); ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' })); ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false })); ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } })); }); }); }); upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); const upstreamAddress = upstream.address() as { port: number }; const app = Fastify({ logger: false }); await app.register(sensible); await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey }); await app.listen({ port: 0, host: '127.0.0.1' }); const address = app.server.address() as { port: number }; const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`; test.after(async () => { await app.close(); for (const socket of sourceSockets.clients) socket.terminate(); sourceSockets.close(); await new Promise(resolve => upstream.close(() => resolve())); await prisma.$disconnect(); rmSync(directory, { recursive: true, force: true }); }); async function eventually(check: () => boolean) { for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); } assert.fail('Expected condition was not reached'); } test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => { assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401); const descriptor = await app.inject({ url: '/v1/agent', headers: auth }); assert.equal(descriptor.statusCode, 200); assert.equal(descriptor.json().websocket, '/v1/agent/ws'); assert.ok(!descriptor.body.includes(privateKey)); const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth }); assert.equal(page.json().messages[0].role, 'assistant'); assert.match(requests.at(-1)!.url!, /after=42&limit=20/); const input = { content: ' Keep watching\n', requestId: 'same-client-message' }; const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input }); const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input }); assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json()); assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false }); assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats'); assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400); assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400); }); test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => { const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth }); assert.equal(initial.json().cursor, '0'); assert.equal(initial.headers['cache-control'], 'no-store'); await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } }))); assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900'); await prisma.$disconnect(); assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900'); assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400); }); test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => { const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' }; try { upstreamReply = { status: 200, body: '{truncated' }; for (const url of ['/v1/agent', '/v1/agent/messages']) { const response = await app.inject({ url, headers: auth }); assert.equal(response.statusCode, 502); assert.equal(response.headers['cache-control'], 'no-store'); } for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }), JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) { upstreamReply = { status: 202, body }; const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload }); assert.equal(response.statusCode, 502, body); assert.equal(response.headers['cache-control'], 'no-store'); } } finally { upstreamReply = undefined; } const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload }); assert.equal(retry.statusCode, 202); assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`); }); test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => { const before = connections; const requestUpgrade = (target: string): Promise => new Promise((resolve, reject) => { let response = ''; const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => { socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`); }); socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket'))); socket.on('data', chunk => { response += chunk.toString(); }); socket.on('error', reject); socket.on('end', () => { socket.destroy(); resolve(response); }); }); assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /); assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /); assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /); assert.equal(connections, before); assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200); }); test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => { const before = connections; const denied = new WebSocket(wsUrl); await once(denied, 'open'); denied.send(JSON.stringify({ type: 'auth', token: 'wrong' })); const [code] = await once(denied, 'close'); assert.equal(code, 4401); assert.equal(connections, before); const browser = new WebSocket(wsUrl); const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString()))); await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' })); await eventually(() => frames.some(frame => frame.type === 'activity')); assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' }); assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']); assert.equal(frames[1].messages[0].content, 'Later result'); assert.equal(frames[2].activity.thinking, true); assert.ok(!JSON.stringify(frames).includes(privateKey)); browser.send(JSON.stringify({ type: 'subscribe', after: '43' })); await eventually(() => subscriptions.at(-1)?.after === '43'); browser.close(); await once(browser, 'close'); await eventually(() => sourceSockets.clients.size === 0); }); test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => { const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined; try { const browser = new WebSocket(wsUrl); const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString()))); await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' })); await eventually(() => frames.some(frame => frame.type === 'ready')); browser.close(); await once(browser, 'close'); } finally { env.ADMIN_TOKEN = saved; } });