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