import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createSystemHandlers } from './system.js'; import type { TokenUsageEntry } from './system.js'; import { createSessionHandlers } from './sessions.js'; import { createToolHandlers } from './tools.js'; import { createAgentHandlers } from './agent.js'; import { createIntentHandlers } from './intents.js'; import { createRoutingHandlers } from './routing.js'; import { createHistoryHandlers } from './history.js'; import { createConfigHandlers, redactConfig } from './config.js'; import { createPairingHandlers } from './pairing.js'; import { PairingManager } from '../../channels/pairing.js'; import { LaneQueue } from '../lane-queue.js'; import { ErrorCode } from '../protocol.js'; import type { GatewayRequest, GatewayResponse, GatewayError, GatewayEvent, OutboundMessage } from '../protocol.js'; import { ComponentRegistry } from '../../intents/index.js'; import { RoutingPolicy } from '../../routing/index.js'; function asSessionHandlerSessionManager(value: unknown): Parameters[0]['sessionManager'] { return value as Parameters[0]['sessionManager']; } function asToolRegistry(value: unknown): Parameters[0]['toolRegistry'] { return value as Parameters[0]['toolRegistry']; } function asToolExecutor(value: unknown): Parameters[0]['toolExecutor'] { return value as Parameters[0]['toolExecutor']; } function asSessionBridge(value: unknown): Parameters[0]['sessionBridge'] { return value as Parameters[0]['sessionBridge']; } function asHistorySessionManager(value: unknown): Parameters[0]['sessionManager'] { return value as Parameters[0]['sessionManager']; } function asConfigValue(value: unknown): Parameters[0]['config'] { return value as Parameters[0]['config']; } function asRedactInput(value: unknown): Parameters[0] { return value as Parameters[0]; } function getPath(value: unknown, ...path: string[]): unknown { let current: unknown = value; for (const key of path) { if (!current || typeof current !== 'object') { return undefined; } current = (current as Record)[key]; } return current; } describe('system handlers', () => { const deps = { startTime: Date.now() - 60_000, version: '0.1.0', getSessionCount: () => 3, getToolCount: () => 6, getConnectionCount: () => 2, }; const handlers = createSystemHandlers(deps); it('system.health returns status info', async () => { const req: GatewayRequest = { id: 1, method: 'system.health' }; const result = await handlers['system.health'](req) as GatewayResponse; expect(result.id).toBe(1); const r = result.result as Record; expect(r.status).toBe('ok'); expect(r.version).toBe('0.1.0'); expect(r.sessions).toBe(3); expect(r.tools).toBe(6); expect(r.connections).toBe(2); expect(typeof r.uptime).toBe('number'); expect(r.uptime).toBeGreaterThanOrEqual(59); }); it('system.services returns empty list when getServices is not provided', async () => { const req: GatewayRequest = { id: 2, method: 'system.services' }; const result = await handlers['system.services'](req) as GatewayResponse; expect(result.id).toBe(2); expect(getPath(result.result, 'services')).toEqual([]); }); it('system.services returns services from getServices callback', async () => { const handlers = createSystemHandlers({ ...deps, getServices: () => [ { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }, { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, ], }); const req: GatewayRequest = { id: 3, method: 'system.services' }; const result = await handlers['system.services'](req) as GatewayResponse; expect(getPath(result.result, 'services')).toEqual([ { name: 'telegram', type: 'channel', status: 'connected', description: 'Telegram bot' }, { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, ]); }); it('system.presence returns empty result when getPresence is not provided', async () => { const req: GatewayRequest = { id: 4, method: 'system.presence' }; const result = await handlers['system.presence'](req) as GatewayResponse; expect(result.id).toBe(4); expect(getPath(result.result, 'presence')).toEqual([]); expect(getPath(result.result, 'summary')).toEqual({ total: 0, online: 0, offline: 0 }); }); it('system.presence returns filtered presence entries', async () => { const handlers = createSystemHandlers({ ...deps, getPresence: ({ channel, status, limit } = {}) => { const all = [ { channel: 'telegram', senderId: '1', senderName: 'alice', firstSeenAt: 1000, lastSeenAt: 2000, messageCount: 3, status: 'online' as const, }, { channel: 'discord', senderId: '2', senderName: 'bob', firstSeenAt: 1000, lastSeenAt: 1500, messageCount: 1, status: 'offline' as const, }, ]; return all .filter((entry) => !channel || entry.channel === channel) .filter((entry) => !status || entry.status === status) .slice(0, limit ?? 100); }, }); const req: GatewayRequest = { id: 5, method: 'system.presence', params: { channel: 'telegram', status: 'online', limit: 10 }, }; const result = await handlers['system.presence'](req) as GatewayResponse; const presence = getPath(result.result, 'presence') as Array<{ channel: string }>; expect(presence).toHaveLength(1); expect(presence[0]?.channel).toBe('telegram'); expect(getPath(result.result, 'summary')).toEqual({ total: 1, online: 1, offline: 0 }); }); it('system.location returns empty result when getNodeLocations is not provided', async () => { const req: GatewayRequest = { id: 6, method: 'system.location' }; const result = await handlers['system.location'](req) as GatewayResponse; expect(result.id).toBe(6); expect(getPath(result.result, 'locations')).toEqual([]); expect(getPath(result.result, 'summary')).toEqual({ total: 0 }); }); it('system.location returns filtered node locations', async () => { const handlers = createSystemHandlers({ ...deps, getNodeLocations: ({ role, nodeId, limit } = {}) => { const all = [ { nodeId: 'node-1', role: 'companion', connectionId: 'c1', location: { latitude: 37.7, longitude: -122.4, source: 'gps' as const, capturedAt: 1000, receivedAt: 1005, }, }, { nodeId: 'node-2', role: 'observer', connectionId: 'c2', location: { latitude: 40.7, longitude: -74.0, source: 'network' as const, capturedAt: 900, receivedAt: 905, }, }, ]; return all .filter((entry) => !role || entry.role === role) .filter((entry) => !nodeId || entry.nodeId === nodeId) .slice(0, limit ?? 100); }, }); const req: GatewayRequest = { id: 7, method: 'system.location', params: { role: 'companion', limit: 1 }, }; const result = await handlers['system.location'](req) as GatewayResponse; const locations = getPath(result.result, 'locations') as Array<{ nodeId: string }>; expect(locations).toHaveLength(1); expect(locations[0]?.nodeId).toBe('node-1'); expect(getPath(result.result, 'summary')).toEqual({ total: 1 }); }); }); describe('system.tokenUsage handler', () => { it('returns empty sessions when no getTokenUsage provided', async () => { const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, }); const req: GatewayRequest = { id: 1, method: 'system.tokenUsage' }; const result = await handlers['system.tokenUsage'](req) as GatewayResponse; expect(result.id).toBe(1); const r = result.result as { sessions: unknown[] }; expect(r.sessions).toEqual([]); }); it('returns session usage data from getTokenUsage callback', async () => { const mockUsage: TokenUsageEntry[] = [ { sessionId: 'telegram:user1', primary: { inputTokens: 1000, outputTokens: 500, calls: 3 }, delegation: { fast: { inputTokens: 200, outputTokens: 100, calls: 1 } }, total: { inputTokens: 1200, outputTokens: 600, calls: 4, estimatedCost: 0.0234 }, }, { sessionId: 'ws:abc-123', primary: { inputTokens: 50, outputTokens: 25, calls: 1 }, delegation: {}, total: { inputTokens: 50, outputTokens: 25, calls: 1, estimatedCost: 0 }, }, ]; const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 2, getToolCount: () => 0, getConnectionCount: () => 1, getTokenUsage: () => mockUsage, }); const req: GatewayRequest = { id: 2, method: 'system.tokenUsage' }; const result = await handlers['system.tokenUsage'](req) as GatewayResponse; expect(result.id).toBe(2); const r = result.result as { sessions: typeof mockUsage }; expect(r.sessions).toHaveLength(2); expect(r.sessions[0].sessionId).toBe('telegram:user1'); expect(r.sessions[0].total.inputTokens).toBe(1200); expect(r.sessions[0].total.estimatedCost).toBe(0.0234); expect(r.sessions[0].delegation.fast.inputTokens).toBe(200); expect(r.sessions[1].sessionId).toBe('ws:abc-123'); expect(r.sessions[1].total.calls).toBe(1); }); }); describe('session handlers', () => { const mockHistory = [ { role: 'user' as const, content: 'hello' }, { role: 'assistant' as const, content: 'hi' }, ]; const mockSession = { id: 'ws:test', addMessage: vi.fn(), getHistory: vi.fn(() => mockHistory), clear: vi.fn(), replaceHistory: vi.fn(), }; const mockSessionManager = { listSessions: vi.fn(() => ['ws:test']), getSession: vi.fn(() => mockSession), transferSession: vi.fn(), closeSession: vi.fn(), }; const handlers = createSessionHandlers({ sessionManager: asSessionHandlerSessionManager(mockSessionManager), }); beforeEach(() => { vi.clearAllMocks(); mockSessionManager.listSessions.mockReturnValue(['ws:test']); mockSessionManager.getSession.mockReturnValue(mockSession); mockSession.getHistory.mockReturnValue(mockHistory); }); it('sessions.list returns session list with message counts', async () => { const req: GatewayRequest = { id: 1, method: 'sessions.list' }; const result = await handlers['sessions.list'](req) as GatewayResponse; expect(result.id).toBe(1); const r = result.result as { sessions: Array<{ id: string; messageCount: number }> }; expect(r.sessions).toHaveLength(1); expect(r.sessions[0].id).toBe('ws:test'); expect(r.sessions[0].messageCount).toBe(2); }); it('sessions.history returns messages with pagination', async () => { const req: GatewayRequest = { id: 2, method: 'sessions.history', params: { sessionId: 'ws:test', limit: 1, offset: 0 } }; const result = await handlers['sessions.history'](req) as GatewayResponse; const r = result.result as { messages: unknown[]; total: number }; expect(r.messages).toHaveLength(1); expect(r.total).toBe(2); }); it('sessions.history requires sessionId', async () => { const req: GatewayRequest = { id: 3, method: 'sessions.history', params: {} }; const result = await handlers['sessions.history'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('sessions.create creates a new session', async () => { const req: GatewayRequest = { id: 4, method: 'sessions.create', params: { sessionId: 'ws:new' } }; const result = await handlers['sessions.create'](req) as GatewayResponse; const r = result.result as { sessionId: string }; expect(r.sessionId).toBe('ws:new'); expect(mockSessionManager.getSession).toHaveBeenCalledWith('ws', 'new'); }); it('sessions.create auto-generates session ID', async () => { const req: GatewayRequest = { id: 5, method: 'sessions.create' }; const result = await handlers['sessions.create'](req) as GatewayResponse; const r = result.result as { sessionId: string }; expect(r.sessionId).toMatch(/^ws:\d+$/); }); }); describe('tool handlers', () => { const mockTool = { name: 'test.tool', description: 'A test tool', inputSchema: { type: 'object' as const, properties: {} }, execute: vi.fn(), }; const mockRegistry = { list: vi.fn(() => [mockTool]), filteredList: vi.fn(() => [mockTool]), get: vi.fn((name: string) => (name === 'test.tool' ? mockTool : undefined)), register: vi.fn(), toAnthropicFormat: vi.fn(), toOpenAIFormat: vi.fn(), }; const mockExecutor = { execute: vi.fn(async () => ({ success: true, output: 'done' })), }; const handlers = createToolHandlers({ toolRegistry: asToolRegistry(mockRegistry), toolExecutor: asToolExecutor(mockExecutor), }); beforeEach(() => { vi.clearAllMocks(); mockRegistry.list.mockReturnValue([mockTool]); mockRegistry.filteredList.mockReturnValue([mockTool]); mockRegistry.get.mockImplementation((name: string) => (name === 'test.tool' ? mockTool : undefined)); mockExecutor.execute.mockResolvedValue({ success: true, output: 'done' }); }); it('tools.list returns tool definitions', async () => { const req: GatewayRequest = { id: 1, method: 'tools.list' }; const result = await handlers['tools.list'](req) as GatewayResponse; const r = result.result as { tools: Array<{ name: string }> }; expect(r.tools).toHaveLength(1); expect(r.tools[0].name).toBe('test.tool'); }); it('tools.invoke executes a tool', async () => { const req: GatewayRequest = { id: 2, method: 'tools.invoke', params: { tool: 'test.tool', args: {} } }; const result = await handlers['tools.invoke'](req) as GatewayResponse; expect(result.result).toEqual({ success: true, output: 'done' }); expect(mockExecutor.execute).toHaveBeenCalledWith('test.tool', {}); }); it('tools.invoke errors on missing tool name', async () => { const req: GatewayRequest = { id: 3, method: 'tools.invoke', params: {} }; const result = await handlers['tools.invoke'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); it('tools.invoke errors on unknown tool', async () => { const req: GatewayRequest = { id: 4, method: 'tools.invoke', params: { tool: 'unknown' } }; const result = await handlers['tools.invoke'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.ToolNotFound); }); }); describe('agent handlers', () => { const mockAgent = { process: vi.fn(async () => 'response text'), setOnToolUse: vi.fn(), }; const mockBridge = { getAgent: vi.fn(() => mockAgent), getSessionId: vi.fn(() => 'ws:conn-1'), isBusy: vi.fn(() => false), cancel: vi.fn(() => false), setBusy: vi.fn(), setOnToolUse: vi.fn(), }; const laneQueue = new LaneQueue(); const handlers = createAgentHandlers({ sessionBridge: asSessionBridge(mockBridge), laneQueue, }); beforeEach(() => { vi.clearAllMocks(); mockBridge.isBusy.mockReturnValue(false); mockBridge.cancel.mockReturnValue(false); mockBridge.getAgent.mockReturnValue(mockAgent); mockAgent.process.mockResolvedValue('response text'); }); it('agent.send processes message and sends done event', async () => { const req: GatewayRequest = { id: 1, method: 'agent.send', params: { message: 'hello', connectionId: 'conn-1' } }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('hello', undefined); expect(sent).toHaveLength(1); const doneEvent = sent[0] as GatewayEvent; expect(doneEvent.event).toBe('done'); expect(getPath(doneEvent.data, 'content')).toBe('response text'); }); it('agent.send passes attachments to agent.process', async () => { const attachments = [ { mimeType: 'image/png', data: 'iVBOR...', filename: 'screenshot.png' }, { mimeType: 'application/pdf', url: 'https://example.com/doc.pdf' }, ]; const req: GatewayRequest = { id: 10, method: 'agent.send', params: { message: 'describe this', connectionId: 'conn-1', attachments }, }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('describe this', [ { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: 'screenshot.png' }, { mimeType: 'application/pdf', data: undefined, url: 'https://example.com/doc.pdf', filename: undefined }, ]); const doneEvent = sent[0] as GatewayEvent; expect(doneEvent.event).toBe('done'); }); it('agent.send works with empty attachments array', async () => { const req: GatewayRequest = { id: 11, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1', attachments: [] }, }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('hi', []); expect(sent).toHaveLength(1); }); it('agent.send accepts attachment-only requests', async () => { const req: GatewayRequest = { id: 12, method: 'agent.send', params: { connectionId: 'conn-1', attachments: [{ mimeType: 'image/png', data: 'iVBOR...' }], }, }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); expect(mockAgent.process).toHaveBeenCalledWith('', [ { mimeType: 'image/png', data: 'iVBOR...', url: undefined, filename: undefined }, ]); expect(sent).toHaveLength(1); expect((sent[0] as GatewayEvent).event).toBe('done'); }); it('agent.send requires message or attachments', async () => { const req: GatewayRequest = { id: 2, method: 'agent.send', params: { connectionId: 'conn-1' } }; const send = vi.fn(); const result = await handlers['agent.send'](req, send) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); expect(result.error.message).toContain('message'); }); it('agent.send queues concurrent requests instead of rejecting', async () => { // Simulate the first request blocking let resolveFirst!: () => void; const firstBlocks = new Promise((r) => { resolveFirst = r; }); let callCount = 0; mockAgent.process.mockImplementation(async () => { callCount++; if (callCount === 1) { await firstBlocks; return 'first response'; } return 'second response'; }); const req1: GatewayRequest = { id: 3, method: 'agent.send', params: { message: 'first', connectionId: 'conn-1' } }; const req2: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'second', connectionId: 'conn-1' } }; const sent1: OutboundMessage[] = []; const sent2: OutboundMessage[] = []; const p1 = handlers['agent.send'](req1, vi.fn((msg: OutboundMessage) => sent1.push(msg))); const p2 = handlers['agent.send'](req2, vi.fn((msg: OutboundMessage) => sent2.push(msg))); // Release the first request resolveFirst(); await Promise.all([p1, p2]); // Both should have completed — no AgentBusy error expect(sent1).toHaveLength(1); expect((sent1[0] as GatewayEvent).event).toBe('done'); expect(sent2).toHaveLength(1); expect((sent2[0] as GatewayEvent).event).toBe('done'); expect(mockAgent.process).toHaveBeenCalledTimes(2); }); it('agent.send handles errors gracefully', async () => { mockAgent.process.mockRejectedValue(new Error('model failed')); const req: GatewayRequest = { id: 4, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } }; const sent: OutboundMessage[] = []; const send = vi.fn((msg: OutboundMessage) => sent.push(msg)); await handlers['agent.send'](req, send); const errorEvent = sent[0] as GatewayEvent; expect(errorEvent.event).toBe('error'); expect(getPath(errorEvent.data, 'message')).toBe('model failed'); }); it('agent.send sets and cleans up tool use callback', async () => { const req: GatewayRequest = { id: 5, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } }; const send = vi.fn(); await handlers['agent.send'](req, send); // setOnToolUse called twice: once to set callback, once to clear it expect(mockBridge.setOnToolUse).toHaveBeenCalledTimes(2); expect(mockBridge.setOnToolUse).toHaveBeenLastCalledWith('conn-1', undefined); }); it('agent.send sets busy state correctly', async () => { const req: GatewayRequest = { id: 6, method: 'agent.send', params: { message: 'hi', connectionId: 'conn-1' } }; const send = vi.fn(); await handlers['agent.send'](req, send); expect(mockBridge.setBusy).toHaveBeenCalledWith('conn-1', true); expect(mockBridge.setBusy).toHaveBeenCalledWith('conn-1', false); }); it('agent.cancel returns cancelled state', async () => { mockBridge.cancel.mockReturnValue(true); const req: GatewayRequest = { id: 7, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; const result = await handlers['agent.cancel'](req) as GatewayResponse; expect(getPath(result.result, 'cancelled')).toBe(true); expect(getPath(result.result, 'message')).toContain('Cancellation requested'); expect(mockBridge.cancel).toHaveBeenCalledWith('conn-1'); }); it('agent.cancel returns not-cancelled when no active operation exists', async () => { mockBridge.cancel.mockReturnValue(false); const req: GatewayRequest = { id: 8, method: 'agent.cancel', params: { connectionId: 'conn-1' } }; const result = await handlers['agent.cancel'](req) as GatewayResponse; expect(getPath(result.result, 'cancelled')).toBe(false); expect(getPath(result.result, 'message')).toContain('No active operation'); }); }); describe('intent handlers', () => { it('intents.list returns configured rules', async () => { const registry = new ComponentRegistry({ matchThreshold: 0.6 }); registry.register({ name: 'deploy-route', patterns: ['deploy *'], target: { type: 'agent', name: 'coder' }, priority: 5, enabled: true, }); const handlers = createIntentHandlers({ intentRegistry: registry, enabled: true, }); const req: GatewayRequest = { id: 10, method: 'intents.list' }; const result = await handlers['intents.list'](req) as GatewayResponse; const payload = result.result as { enabled: boolean; rules: Array<{ name: string }> }; expect(payload.enabled).toBe(true); expect(payload.rules).toHaveLength(1); expect(payload.rules[0].name).toBe('deploy-route'); }); it('intents.match returns best rule match', async () => { const registry = new ComponentRegistry({ matchThreshold: 0.5 }); registry.register({ name: 'deploy-route', patterns: ['deploy *'], target: { type: 'agent', name: 'coder' }, priority: 5, enabled: true, }); const handlers = createIntentHandlers({ intentRegistry: registry, enabled: true, }); const req: GatewayRequest = { id: 11, method: 'intents.match', params: { input: 'deploy backend service' }, }; const result = await handlers['intents.match'](req) as GatewayResponse; const payload = result.result as { match: { rule: { name: string } } }; expect(payload.match.rule.name).toBe('deploy-route'); }); }); describe('routing handlers', () => { it('routing.decide returns match and policy decision', async () => { const registry = new ComponentRegistry({ matchThreshold: 0.5 }); registry.register({ name: 'deploy-route', patterns: ['deploy *'], target: { type: 'agent', name: 'coder' }, priority: 5, enabled: true, }); const policy = new RoutingPolicy({ enabled: true, fastPathThreshold: 0.7, llmThreshold: 0.3, defaultPath: 'llm', }); const handlers = createRoutingHandlers({ intentRegistry: registry, routingPolicy: policy, }); const req: GatewayRequest = { id: 12, method: 'routing.decide', params: { input: 'deploy service' }, }; const result = await handlers['routing.decide'](req) as GatewayResponse; const payload = result.result as { match: { rule: { name: string } }; decision: { path: string }; }; expect(payload.match.rule.name).toBe('deploy-route'); expect(payload.decision.path).toBe('fast'); }); }); describe('history handlers', () => { it('history.search returns ranked results', async () => { const historySessionManager = asHistorySessionManager({ searchHistory: () => [{ sessionId: 'ws:test', messageId: 1, role: 'user', content: 'deploy', score: 0.9, createdAt: 123 }], reindexHistory: () => 0, }); const handlers = createHistoryHandlers({ sessionManager: historySessionManager, }); const req: GatewayRequest = { id: 13, method: 'history.search', params: { query: 'deploy' } }; const result = await handlers['history.search'](req) as GatewayResponse; const payload = result.result as { results: Array<{ sessionId: string }> }; expect(payload.results[0].sessionId).toBe('ws:test'); }); it('history.reindex returns count', async () => { const historySessionManager = asHistorySessionManager({ searchHistory: () => [], reindexHistory: () => 42, }); const handlers = createHistoryHandlers({ sessionManager: historySessionManager, }); const req: GatewayRequest = { id: 14, method: 'history.reindex' }; const result = await handlers['history.reindex'](req) as GatewayResponse; expect((result.result as { reindexed: number }).reindexed).toBe(42); }); }); describe('system.restart handler', () => { it('returns restarting:true and calls restart callback', async () => { const restartFn = vi.fn(async () => {}); const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, restart: restartFn, }); const req: GatewayRequest = { id: 1, method: 'system.restart' }; const result = await handlers['system.restart'](req) as GatewayResponse; expect(result.id).toBe(1); expect(getPath(result.result, 'restarting')).toBe(true); // Restart is called asynchronously via queueMicrotask await new Promise((resolve) => queueMicrotask(resolve)); expect(restartFn).toHaveBeenCalledOnce(); }); it('returns error when restart is not available', async () => { const handlers = createSystemHandlers({ startTime: Date.now(), version: '0.1.0', getSessionCount: () => 0, getToolCount: () => 0, getConnectionCount: () => 0, }); const req: GatewayRequest = { id: 2, method: 'system.restart' }; const result = await handlers['system.restart'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InternalError); expect(result.error.message).toContain('not available'); }); }); describe('config handlers', () => { function makeConfig() { return { telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [12345] }, server: { tailscale: {}, localhost: true, port: 18800, queue: { mode: 'collect' as const, cap: 50, overflow: 'drop_old' as const, debounce_ms: 0, summarize_overflow: true, }, nodes: { enabled: false, allowed_roles: ['companion'], feature_gates: {}, location: { enabled: false, }, }, }, models: { default: { provider: 'anthropic' as const, model: 'claude-3-haiku', api_key: 'sk-secret-key' }, fallback_chain: ['anthropic'], }, backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } }, hooks: { confirm: ['shell.exec'], log: [], silent: [] }, mcp: { servers: [] }, }; } it('config.get returns redacted config', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 1, method: 'config.get' }; const result = await handlers['config.get'](req) as GatewayResponse; expect(getPath(result.result, 'telegram', 'bot_token')).toBe('***'); expect(getPath(result.result, 'models', 'default', 'api_key')).toBe('***'); // Non-secret values are preserved expect(getPath(result.result, 'server', 'port')).toBe(18800); expect(getPath(result.result, 'hooks', 'confirm')).toEqual(['shell.exec']); }); it('config.patch applies valid patches', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 2, method: 'config.patch', params: { patches: { 'hooks.confirm': ['shell.exec', 'file.write'], 'hooks.log': ['file.read'], 'server.queue.mode': 'followup', 'server.queue.debounce_ms': 100, 'server.nodes.location.enabled': true, }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual(['hooks.confirm', 'hooks.log', 'server.queue.mode', 'server.queue.debounce_ms', 'server.nodes.location.enabled']); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(false); // Verify the config was actually mutated expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']); expect(config.hooks.log).toEqual(['file.read']); expect(config.server.queue.mode).toBe('followup'); expect(config.server.queue.debounce_ms).toBe(100); expect(config.server.nodes.location.enabled).toBe(true); }); it('config.patch rejects unknown keys', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 3, method: 'config.patch', params: { patches: { 'telegram.bot_token': 'hacked', 'hooks.confirm': [], }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual(['hooks.confirm']); expect(r.rejected).toEqual(['telegram.bot_token']); expect(r.persisted).toBe(false); }); it('config.patch rejects invalid value types', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 4, method: 'config.patch', params: { patches: { 'hooks.confirm': 'not-an-array', 'server.queue.cap': 0, }, }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual([]); expect(r.rejected).toEqual(['hooks.confirm', 'server.queue.cap']); expect(r.persisted).toBe(false); }); it('config.patch persists changes when persistence callback is provided', async () => { const config = makeConfig(); const persist = vi.fn(); const handlers = createConfigHandlers({ config: asConfigValue(config), persistConfig: persist as () => Promise, }); const req: GatewayRequest = { id: 6, method: 'config.patch', params: { patches: { 'hooks.confirm': ['shell.exec', 'file.write'] } }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; expect(r.applied).toEqual(['hooks.confirm']); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(true); expect(persist).toHaveBeenCalledTimes(1); expect(config.hooks.confirm).toEqual(['shell.exec', 'file.write']); }); it('config.patch does not mutate runtime config when persistence fails', async () => { const config = makeConfig(); const before = [...config.hooks.confirm]; const persist = vi.fn().mockRejectedValue(new Error('disk full')); const handlers = createConfigHandlers({ config: asConfigValue(config), persistConfig: persist as () => Promise, }); const req: GatewayRequest = { id: 7, method: 'config.patch', params: { patches: { 'hooks.confirm': ['file.write'] } }, }; const result = await handlers['config.patch'](req) as GatewayResponse; const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean; persistError?: string }; expect(r.applied).toEqual([]); expect(r.rejected).toEqual([]); expect(r.persisted).toBe(false); expect(r.persistError).toContain('disk full'); expect(config.hooks.confirm).toEqual(before); }); it('config.patch requires patches object', async () => { const config = makeConfig(); const handlers = createConfigHandlers({ config: asConfigValue(config) }); const req: GatewayRequest = { id: 5, method: 'config.patch', params: {} }; const result = await handlers['config.patch'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); }); }); describe('redactConfig – comprehensive credential redaction', () => { /** * Build a full config object with secrets in every possible location. * Optional sections (discord, slack, etc.) are included to test redaction. */ function makeFullConfig() { return { telegram: { bot_token: 'tg-secret', allowed_chat_ids: [1], require_mention: true }, discord: { bot_token: 'dc-secret', allowed_guild_ids: ['g1'], allowed_channel_ids: [], require_mention: true }, slack: { bot_token: 'sl-bot', app_token: 'sl-app', signing_secret: 'sl-sign', allowed_channel_ids: [], require_mention: false }, matrix: { homeserver_url: 'https://matrix.example.org', access_token: 'mx-secret', allowed_room_ids: ['!room1:example.org'], require_mention: true }, mattermost: { server_url: 'https://mattermost.example.org', bot_token: 'mm-secret', allowed_channel_ids: [], require_mention: true, mention_name: 'flynn', poll_interval_ms: 3000 }, server: { tailscale: {}, localhost: true, port: 18800, token: 'bearer-secret', tailscale_identity: false, auth_http: true }, models: { default: { provider: 'anthropic' as const, model: 'claude', api_key: 'sk-def', auth_token: 'at-def', fallback: { provider: 'openai' as const, model: 'gpt-4', api_key: 'sk-def-fb', auth_token: 'at-def-fb' }, }, fast: { provider: 'openai' as const, model: 'gpt-4o-mini', api_key: 'sk-fast', fallback: { provider: 'gemini' as const, model: 'gemini-flash', api_key: 'sk-fast-fb' }, }, complex: { provider: 'anthropic' as const, model: 'claude-opus', auth_token: 'at-complex' }, local: { provider: 'ollama' as const, model: 'llama3' }, fallback_chain: ['anthropic'], local_providers: { ollama: { provider: 'ollama' as const, model: 'llama3', api_key: 'lp-key', auth_token: 'lp-token', fallback: { provider: 'llamacpp' as const, model: 'llama', api_key: 'lp-fb-key' }, }, }, thinking: { anthropic: { budgetTokens: 4096 }, openai: { reasoningEffort: 'medium' as const }, gemini: { budgetTokens: 4096 } }, }, web_search: { provider: 'brave' as const, api_key: 'brave-key', endpoint: 'https://api.brave.com', max_results: 5 }, audio: { transcription_endpoint: 'https://api.openai.com', transcription_api_key: 'audio-key', transcription_model: 'whisper-1' }, memory: { enabled: true, auto_extract: true, max_context_tokens: 2000, embedding: { enabled: true, provider: 'openai' as const, model: 'text-embedding-3-small', api_key: 'embed-key', dimensions: 1536, chunk_size: 512, chunk_overlap: 50, top_k: 5, hybrid_weight: 0.7 }, }, automation: { cron: [], webhooks: [ { name: 'github', secret: 'wh-secret-1', message: '{{body}}', output: { channel: 'telegram', peer: '123' }, enabled: true }, { name: 'gitlab', secret: 'wh-secret-2', message: '{{body}}', output: { channel: 'telegram', peer: '456' }, enabled: true }, { name: 'no-secret', message: '{{body}}', output: { channel: 'telegram', peer: '789' }, enabled: true }, ], gmail: { enabled: true, credentials_file: '/path/to/creds.json', token_file: '/path/to/token.json', watch_labels: ['INBOX'], poll_interval: '300s', output: { channel: 'telegram', peer: '123' }, message: 'new email' }, heartbeat: { enabled: false, interval: '5m', checks: ['gateway'], failure_threshold: 2, disk_threshold_mb: 100 }, }, mcp: { servers: [ { name: 'my-server', command: 'node', args: ['server.js'], env: { API_KEY: 'mcp-api-key', DATABASE_URL: 'postgres://secret@host/db' } }, { name: 'no-env', command: 'python', args: ['app.py'] }, ], }, hooks: { confirm: ['shell.exec'], log: [], silent: [] }, backends: { claude_code: { enabled: false }, opencode: { enabled: false }, native: { enabled: true } }, }; } it('redacts telegram.bot_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'telegram', 'bot_token')).toBe('***'); }); it('redacts discord.bot_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'discord', 'bot_token')).toBe('***'); }); it('redacts slack.bot_token, app_token, and signing_secret', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'slack', 'bot_token')).toBe('***'); expect(getPath(result, 'slack', 'app_token')).toBe('***'); expect(getPath(result, 'slack', 'signing_secret')).toBe('***'); }); it('redacts matrix.access_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'matrix', 'access_token')).toBe('***'); }); it('redacts mattermost.bot_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'mattermost', 'bot_token')).toBe('***'); }); it('redacts server.token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'server', 'token')).toBe('***'); }); it('redacts model api_key and auth_token for all tiers', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'models', 'default', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'default', 'auth_token')).toBe('***'); expect(getPath(result, 'models', 'fast', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'complex', 'auth_token')).toBe('***'); // local has no keys — should remain unchanged expect(getPath(result, 'models', 'local', 'api_key')).toBeUndefined(); }); it('redacts model fallback api_key and auth_token', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'models', 'default', 'fallback', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'default', 'fallback', 'auth_token')).toBe('***'); expect(getPath(result, 'models', 'fast', 'fallback', 'api_key')).toBe('***'); }); it('redacts local_providers api_key, auth_token, and their fallbacks', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'models', 'local_providers', 'ollama', 'api_key')).toBe('***'); expect(getPath(result, 'models', 'local_providers', 'ollama', 'auth_token')).toBe('***'); expect(getPath(result, 'models', 'local_providers', 'ollama', 'fallback', 'api_key')).toBe('***'); }); it('redacts web_search.api_key', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'web_search', 'api_key')).toBe('***'); }); it('redacts audio.transcription_api_key', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'audio', 'transcription_api_key')).toBe('***'); }); it('redacts memory.embedding.api_key', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'memory', 'embedding', 'api_key')).toBe('***'); }); it('redacts automation webhook secrets', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'automation', 'webhooks', '0', 'secret')).toBe('***'); expect(getPath(result, 'automation', 'webhooks', '1', 'secret')).toBe('***'); // Webhook without a secret should remain unaffected expect(getPath(result, 'automation', 'webhooks', '2', 'secret')).toBeUndefined(); }); it('redacts automation gmail credentials_file and token_file', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'automation', 'gmail', 'credentials_file')).toBe('***'); expect(getPath(result, 'automation', 'gmail', 'token_file')).toBe('***'); }); it('redacts all MCP server env vars', () => { const result = redactConfig(asRedactInput(makeFullConfig())); expect(getPath(result, 'mcp', 'servers', '0', 'env', 'API_KEY')).toBe('***'); expect(getPath(result, 'mcp', 'servers', '0', 'env', 'DATABASE_URL')).toBe('***'); // Server without env should be unaffected expect(getPath(result, 'mcp', 'servers', '1', 'env')).toBeUndefined(); }); it('preserves non-secret fields', () => { const result = redactConfig(asRedactInput(makeFullConfig())); // telegram expect(getPath(result, 'telegram', 'allowed_chat_ids')).toEqual([1]); expect(getPath(result, 'telegram', 'require_mention')).toBe(true); // discord expect(getPath(result, 'discord', 'allowed_guild_ids')).toEqual(['g1']); // slack expect(getPath(result, 'slack', 'allowed_channel_ids')).toEqual([]); // server expect(getPath(result, 'server', 'port')).toBe(18800); expect(getPath(result, 'server', 'tailscale')).toBeDefined(); // models expect(getPath(result, 'models', 'default', 'provider')).toBe('anthropic'); expect(getPath(result, 'models', 'default', 'model')).toBe('claude'); expect(getPath(result, 'models', 'fallback_chain')).toEqual(['anthropic']); // web_search expect(getPath(result, 'web_search', 'provider')).toBe('brave'); expect(getPath(result, 'web_search', 'max_results')).toBe(5); // audio expect(getPath(result, 'audio', 'transcription_model')).toBe('whisper-1'); // memory expect(getPath(result, 'memory', 'embedding', 'model')).toBe('text-embedding-3-small'); // hooks expect(getPath(result, 'hooks', 'confirm')).toEqual(['shell.exec']); // mcp expect(getPath(result, 'mcp', 'servers', '0', 'name')).toBe('my-server'); expect(getPath(result, 'mcp', 'servers', '0', 'command')).toBe('node'); }); it('handles missing optional sections gracefully', () => { const minimal = { telegram: { bot_token: 'tok', allowed_chat_ids: [1] }, models: { default: { provider: 'anthropic' as const, model: 'claude' }, fallback_chain: [] }, server: { port: 18800 }, hooks: { confirm: [], log: [], silent: [] }, }; // Should not throw even when discord, slack, automation, mcp, etc. are absent const result = redactConfig(asRedactInput(minimal)); expect(getPath(result, 'telegram', 'bot_token')).toBe('***'); expect(result.discord).toBeUndefined(); expect(result.slack).toBeUndefined(); expect(result.automation).toBeUndefined(); }); it('does not mutate the original config object', () => { const config = makeFullConfig(); redactConfig(asRedactInput(config)); // Original secrets should still be intact expect(config.telegram.bot_token).toBe('tg-secret'); expect(config.models.default.api_key).toBe('sk-def'); expect(config.server.token).toBe('bearer-secret'); }); }); describe('pairing handlers', () => { let pm: PairingManager; let handlers: ReturnType; beforeEach(() => { pm = new PairingManager({ enabled: true, codeTtl: 300_000, codeLength: 6 }); handlers = createPairingHandlers({ pairingManager: pm }); }); it('pairing.generate returns a code and expiry', async () => { const req: GatewayRequest = { id: 1, method: 'pairing.generate', params: { label: 'for alice' } }; const result = await handlers['pairing.generate'](req) as GatewayResponse; const r = result.result as { code: string; expiresAt: number }; expect(r.code).toHaveLength(6); expect(r.expiresAt).toBeGreaterThan(Date.now()); }); it('pairing.generate works without label', async () => { const req: GatewayRequest = { id: 2, method: 'pairing.generate' }; const result = await handlers['pairing.generate'](req) as GatewayResponse; const r = result.result as { code: string; expiresAt: number }; expect(r.code).toHaveLength(6); }); it('pairing.list returns pending codes and approved senders', async () => { // Generate a code first pm.generateCode('test'); // Approve a sender const code = pm.generateCode('for bob'); pm.validateCode('telegram', '12345', code); const req: GatewayRequest = { id: 3, method: 'pairing.list' }; const result = await handlers['pairing.list'](req) as GatewayResponse; const r = result.result as { pending: unknown[]; approved: unknown[] }; expect(r.pending).toHaveLength(1); // one code remaining (the other was consumed) expect(r.approved).toHaveLength(1); }); it('pairing.revoke removes an approved sender', async () => { // Approve a sender const code = pm.generateCode(); pm.validateCode('discord', 'chan-1', code); expect(pm.isApproved('discord', 'chan-1')).toBe(true); const req: GatewayRequest = { id: 4, method: 'pairing.revoke', params: { channel: 'discord', senderId: 'chan-1' } }; const result = await handlers['pairing.revoke'](req) as GatewayResponse; const r = result.result as { revoked: boolean }; expect(r.revoked).toBe(true); expect(pm.isApproved('discord', 'chan-1')).toBe(false); }); it('pairing.revoke returns false for unknown sender', async () => { const req: GatewayRequest = { id: 5, method: 'pairing.revoke', params: { channel: 'telegram', senderId: 'unknown' } }; const result = await handlers['pairing.revoke'](req) as GatewayResponse; const r = result.result as { revoked: boolean }; expect(r.revoked).toBe(false); }); it('pairing.revoke requires channel and senderId', async () => { const req: GatewayRequest = { id: 6, method: 'pairing.revoke', params: {} }; const result = await handlers['pairing.revoke'](req) as GatewayError; expect(result.error.code).toBe(ErrorCode.InvalidRequest); expect(result.error.message).toContain('channel'); }); });