feat(gateway): add WebSocket gateway with JSON-RPC protocol and auth

Phase 2 of the Flynn roadmap. Adds a WebSocket gateway server that
starts alongside the Telegram bot, providing real-time API access to
the agent, sessions, and tools.

Protocol: JSON-RPC-like (request/response/event) over WebSocket.
8 methods: agent.send, agent.cancel, sessions.list, sessions.history,
sessions.create, tools.list, tools.invoke, system.health.

Auth: Bearer token + Tailscale identity header support.
Session bridge: per-connection agent instances with shared model router.

New files: src/gateway/ (protocol, router, server, auth, session-bridge,
handlers for agent/sessions/tools/system).
57 new tests (181 total), typecheck clean.
This commit is contained in:
William Valentin
2026-02-05 19:11:25 -08:00
parent ad7fc241f1
commit f30a8bc318
21 changed files with 1878 additions and 2 deletions
+188
View File
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebSocket } from 'ws';
import { GatewayServer } from './server.js';
import type { GatewayServerConfig } from './server.js';
import type { GatewayResponse, GatewayError, GatewayEvent } from './protocol.js';
import { ErrorCode } from './protocol.js';
// Minimal mocks for dependencies
const mockSession = {
id: 'test',
addMessage: vi.fn(),
getHistory: vi.fn(() => []),
clear: vi.fn(),
setHistory: vi.fn(),
};
const mockSessionManager = {
getSession: vi.fn(() => mockSession),
listSessions: vi.fn(() => ['ws:test']),
transferSession: vi.fn(),
closeSession: vi.fn(),
};
const mockModelClient = {
chat: vi.fn(async () => ({
content: 'Hello from Flynn!',
stopReason: 'end_turn',
usage: { inputTokens: 10, outputTokens: 5 },
})),
};
const mockToolRegistry = {
register: vi.fn(),
get: vi.fn((name: string) => (name === 'shell.exec' ? { name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } } : undefined)),
list: vi.fn(() => [{ name: 'shell.exec', description: 'Run shell', inputSchema: { type: 'object', properties: {} } }]),
toAnthropicFormat: vi.fn(() => []),
toOpenAIFormat: vi.fn(() => []),
};
const mockToolExecutor = {
execute: vi.fn(async () => ({ success: true, output: 'executed' })),
};
const TEST_PORT = 18899;
let server: GatewayServer;
function createClient(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${TEST_PORT}`);
ws.on('open', () => resolve(ws));
ws.on('error', reject);
});
}
function sendAndReceive(ws: WebSocket, msg: object): Promise<GatewayResponse | GatewayError | GatewayEvent> {
return new Promise((resolve) => {
ws.once('message', (data) => {
resolve(JSON.parse(data.toString()));
});
ws.send(JSON.stringify(msg));
});
}
function sendAndReceiveAll(ws: WebSocket, msg: object, count: number): Promise<Array<GatewayResponse | GatewayError | GatewayEvent>> {
return new Promise((resolve) => {
const messages: Array<GatewayResponse | GatewayError | GatewayEvent> = [];
const handler = (data: Buffer) => {
messages.push(JSON.parse(data.toString()));
if (messages.length >= count) {
ws.off('message', handler);
resolve(messages);
}
};
ws.on('message', handler);
ws.send(JSON.stringify(msg));
});
}
describe('GatewayServer integration', () => {
beforeAll(async () => {
server = new GatewayServer({
port: TEST_PORT,
sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'],
modelClient: mockModelClient,
systemPrompt: 'Test prompt',
toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'],
toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'],
version: '0.1.0-test',
});
await server.start();
});
afterAll(async () => {
await server.stop();
});
it('responds to system.health', async () => {
const ws = await createClient();
try {
const result = await sendAndReceive(ws, { id: 1, method: 'system.health' });
const response = result as GatewayResponse;
expect(response.id).toBe(1);
const r = response.result as Record<string, unknown>;
expect(r.status).toBe('ok');
expect(r.version).toBe('0.1.0-test');
expect(typeof r.uptime).toBe('number');
} finally {
ws.close();
}
});
it('returns MethodNotFound for unknown method', async () => {
const ws = await createClient();
try {
const result = await sendAndReceive(ws, { id: 2, method: 'unknown.method' });
const error = result as GatewayError;
expect(error.error.code).toBe(ErrorCode.MethodNotFound);
} finally {
ws.close();
}
});
it('returns ParseError for invalid JSON', async () => {
const ws = await createClient();
try {
const result = await new Promise<GatewayError>((resolve) => {
ws.once('message', (data) => resolve(JSON.parse(data.toString())));
ws.send('not valid json');
});
expect(result.error.code).toBe(ErrorCode.ParseError);
} finally {
ws.close();
}
});
it('lists tools via tools.list', async () => {
const ws = await createClient();
try {
const result = await sendAndReceive(ws, { id: 3, method: 'tools.list' });
const response = result as GatewayResponse;
const r = response.result as { tools: Array<{ name: string }> };
expect(r.tools.length).toBeGreaterThan(0);
expect(r.tools[0].name).toBe('shell.exec');
} finally {
ws.close();
}
});
it('sends agent message and receives done event', async () => {
const ws = await createClient();
try {
// agent.send streams events — we expect a 'done' event
const messages = await sendAndReceiveAll(ws, { id: 4, method: 'agent.send', params: { message: 'hi' } }, 1);
const doneEvent = messages[0] as GatewayEvent;
expect(doneEvent.id).toBe(4);
expect(doneEvent.event).toBe('done');
expect((doneEvent.data as any).content).toBe('Hello from Flynn!');
} finally {
ws.close();
}
});
it('tracks connections correctly', async () => {
const ws1 = await createClient();
const ws2 = await createClient();
try {
const result = await sendAndReceive(ws1, { id: 5, method: 'system.health' });
const r = (result as GatewayResponse).result as Record<string, unknown>;
expect(r.connections).toBe(2);
} finally {
ws1.close();
ws2.close();
}
});
it('lists registered methods', () => {
const methods = server.getMethods();
expect(methods).toContain('system.health');
expect(methods).toContain('agent.send');
expect(methods).toContain('agent.cancel');
expect(methods).toContain('sessions.list');
expect(methods).toContain('sessions.history');
expect(methods).toContain('sessions.create');
expect(methods).toContain('tools.list');
expect(methods).toContain('tools.invoke');
});
});