diff --git a/src/__tests__/main/web-server/routes/apiRoutes.test.ts b/src/__tests__/main/web-server/routes/apiRoutes.test.ts new file mode 100644 index 00000000..82bc6a9a --- /dev/null +++ b/src/__tests__/main/web-server/routes/apiRoutes.test.ts @@ -0,0 +1,457 @@ +/** + * Tests for ApiRoutes + * + * API Routes handle REST API requests from web clients. + * Routes are protected by a security token prefix. + * + * Endpoints tested: + * - GET /api/sessions - List all sessions with live info + * - GET /api/session/:id - Get single session detail + * - POST /api/session/:id/send - Send command to session + * - GET /api/theme - Get current theme + * - POST /api/session/:id/interrupt - Interrupt session + * - GET /api/history - Get history entries + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + ApiRoutes, + type ApiRouteCallbacks, + type RateLimitConfig, +} from '../../../../main/web-server/routes/apiRoutes'; + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +/** + * Create mock callbacks with all methods as vi.fn() + */ +function createMockCallbacks(): ApiRouteCallbacks { + return { + getSessions: vi.fn().mockReturnValue([ + { + id: 'session-1', + name: 'Session 1', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/test/project', + groupId: null, + }, + { + id: 'session-2', + name: 'Session 2', + toolType: 'codex', + state: 'busy', + inputMode: 'terminal', + cwd: '/test/project2', + groupId: 'group-1', + }, + ]), + getSessionDetail: vi.fn().mockReturnValue({ + id: 'session-1', + name: 'Session 1', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/test/project', + aiTabs: [{ id: 'tab-1', name: 'Tab 1', logs: [] }], + activeAITabId: 'tab-1', + }), + getTheme: vi.fn().mockReturnValue({ + name: 'dark', + background: '#1a1a1a', + foreground: '#ffffff', + }), + writeToSession: vi.fn().mockReturnValue(true), + interruptSession: vi.fn().mockResolvedValue(true), + getHistory: vi + .fn() + .mockReturnValue([{ id: '1', command: 'test command', timestamp: Date.now() }]), + getLiveSessionInfo: vi.fn().mockReturnValue({ + sessionId: 'session-1', + agentSessionId: 'claude-agent-123', + enabledAt: Date.now(), + }), + isSessionLive: vi.fn().mockReturnValue(true), + }; +} + +/** + * Mock Fastify instance with route registration tracking + */ +function createMockFastify() { + const routes: Map = new Map(); + + return { + get: vi.fn((path: string, options: any, handler?: Function) => { + const h = handler || options; + const config = handler ? options?.config : undefined; + routes.set(`GET:${path}`, { handler: h, config }); + }), + post: vi.fn((path: string, options: any, handler?: Function) => { + const h = handler || options; + const config = handler ? options?.config : undefined; + routes.set(`POST:${path}`, { handler: h, config }); + }), + getRoute: (method: string, path: string) => routes.get(`${method}:${path}`), + routes, + }; +} + +/** + * Mock reply object + */ +function createMockReply() { + const reply: any = { + code: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + }; + return reply; +} + +describe('ApiRoutes', () => { + const securityToken = 'test-token-123'; + const rateLimitConfig: RateLimitConfig = { + max: 100, + maxPost: 30, + timeWindow: 60000, // 1 minute in milliseconds + enabled: true, + }; + + let apiRoutes: ApiRoutes; + let callbacks: ApiRouteCallbacks; + let mockFastify: ReturnType; + + beforeEach(() => { + apiRoutes = new ApiRoutes(securityToken, rateLimitConfig); + callbacks = createMockCallbacks(); + apiRoutes.setCallbacks(callbacks); + mockFastify = createMockFastify(); + apiRoutes.registerRoutes(mockFastify as any); + }); + + describe('Route Registration', () => { + it('should register all API routes', () => { + expect(mockFastify.get).toHaveBeenCalledTimes(4); // sessions, session/:id, theme, history + expect(mockFastify.post).toHaveBeenCalledTimes(2); // send, interrupt + }); + + it('should register routes with correct token prefix', () => { + expect(mockFastify.routes.has(`GET:/${securityToken}/api/sessions`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/api/session/:id`)).toBe(true); + expect(mockFastify.routes.has(`POST:/${securityToken}/api/session/:id/send`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/api/theme`)).toBe(true); + expect(mockFastify.routes.has(`POST:/${securityToken}/api/session/:id/interrupt`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/api/history`)).toBe(true); + }); + + it('should configure rate limiting for GET routes', () => { + const sessionsRoute = mockFastify.getRoute('GET', `/${securityToken}/api/sessions`); + expect(sessionsRoute?.config?.rateLimit?.max).toBe(rateLimitConfig.max); + expect(sessionsRoute?.config?.rateLimit?.timeWindow).toBe(rateLimitConfig.timeWindow); + }); + + it('should configure stricter rate limiting for POST routes', () => { + const sendRoute = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/send`); + expect(sendRoute?.config?.rateLimit?.max).toBe(rateLimitConfig.maxPost); + }); + }); + + describe('GET /api/sessions', () => { + it('should return all sessions with live info', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/sessions`); + const result = await route!.handler(); + + expect(result.sessions).toHaveLength(2); + expect(result.count).toBe(2); + expect(result.timestamp).toBeDefined(); + expect(callbacks.getSessions).toHaveBeenCalled(); + }); + + it('should enrich sessions with live info', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/sessions`); + const result = await route!.handler(); + + expect(result.sessions[0].agentSessionId).toBe('claude-agent-123'); + expect(result.sessions[0].isLive).toBe(true); + expect(result.sessions[0].liveEnabledAt).toBeDefined(); + }); + + it('should return empty array when no callbacks configured', async () => { + const emptyRoutes = new ApiRoutes(securityToken, rateLimitConfig); + const emptyFastify = createMockFastify(); + emptyRoutes.registerRoutes(emptyFastify as any); + + const route = emptyFastify.getRoute('GET', `/${securityToken}/api/sessions`); + const result = await route!.handler(); + + expect(result.sessions).toEqual([]); + expect(result.count).toBe(0); + }); + }); + + describe('GET /api/session/:id', () => { + it('should return session detail', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/session/:id`); + const reply = createMockReply(); + const result = await route!.handler({ params: { id: 'session-1' }, query: {} }, reply); + + expect(result.session.id).toBe('session-1'); + expect(result.session.agentSessionId).toBe('claude-agent-123'); + expect(result.session.isLive).toBe(true); + expect(callbacks.getSessionDetail).toHaveBeenCalledWith('session-1', undefined); + }); + + it('should pass tabId query param to callback', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/session/:id`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' }, query: { tabId: 'tab-5' } }, reply); + + expect(callbacks.getSessionDetail).toHaveBeenCalledWith('session-1', 'tab-5'); + }); + + it('should return 404 for non-existent session', async () => { + (callbacks.getSessionDetail as any).mockReturnValue(null); + + const route = mockFastify.getRoute('GET', `/${securityToken}/api/session/:id`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'nonexistent' }, query: {} }, reply); + + expect(reply.code).toHaveBeenCalledWith(404); + expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ error: 'Not Found' })); + }); + + it('should return 503 when getSessionDetail callback not configured', async () => { + const emptyRoutes = new ApiRoutes(securityToken, rateLimitConfig); + const emptyFastify = createMockFastify(); + emptyRoutes.registerRoutes(emptyFastify as any); + + const route = emptyFastify.getRoute('GET', `/${securityToken}/api/session/:id`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' }, query: {} }, reply); + + expect(reply.code).toHaveBeenCalledWith(503); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Service Unavailable' }) + ); + }); + }); + + describe('POST /api/session/:id/send', () => { + it('should send command to session', async () => { + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/send`); + const reply = createMockReply(); + const result = await route!.handler( + { params: { id: 'session-1' }, body: { command: 'ls -la' } }, + reply + ); + + expect(result.success).toBe(true); + expect(result.sessionId).toBe('session-1'); + expect(callbacks.writeToSession).toHaveBeenCalledWith('session-1', 'ls -la\n'); + }); + + it('should return 400 for missing command', async () => { + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/send`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' }, body: {} }, reply); + + expect(reply.code).toHaveBeenCalledWith(400); + expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ error: 'Bad Request' })); + }); + + it('should return 400 for non-string command', async () => { + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/send`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' }, body: { command: 123 } }, reply); + + expect(reply.code).toHaveBeenCalledWith(400); + }); + + it('should return 500 when writeToSession fails', async () => { + (callbacks.writeToSession as any).mockReturnValue(false); + + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/send`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' }, body: { command: 'test' } }, reply); + + expect(reply.code).toHaveBeenCalledWith(500); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Internal Server Error' }) + ); + }); + + it('should return 503 when writeToSession callback not configured', async () => { + const emptyRoutes = new ApiRoutes(securityToken, rateLimitConfig); + const emptyFastify = createMockFastify(); + emptyRoutes.registerRoutes(emptyFastify as any); + + const route = emptyFastify.getRoute('POST', `/${securityToken}/api/session/:id/send`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' }, body: { command: 'test' } }, reply); + + expect(reply.code).toHaveBeenCalledWith(503); + }); + }); + + describe('GET /api/theme', () => { + it('should return current theme', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/theme`); + const reply = createMockReply(); + const result = await route!.handler({}, reply); + + expect(result.theme.name).toBe('dark'); + expect(result.timestamp).toBeDefined(); + expect(callbacks.getTheme).toHaveBeenCalled(); + }); + + it('should return 404 when no theme configured', async () => { + (callbacks.getTheme as any).mockReturnValue(null); + + const route = mockFastify.getRoute('GET', `/${securityToken}/api/theme`); + const reply = createMockReply(); + await route!.handler({}, reply); + + expect(reply.code).toHaveBeenCalledWith(404); + expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ error: 'Not Found' })); + }); + + it('should return 503 when getTheme callback not configured', async () => { + const emptyRoutes = new ApiRoutes(securityToken, rateLimitConfig); + const emptyFastify = createMockFastify(); + emptyRoutes.registerRoutes(emptyFastify as any); + + const route = emptyFastify.getRoute('GET', `/${securityToken}/api/theme`); + const reply = createMockReply(); + await route!.handler({}, reply); + + expect(reply.code).toHaveBeenCalledWith(503); + }); + }); + + describe('POST /api/session/:id/interrupt', () => { + it('should interrupt session successfully', async () => { + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/interrupt`); + const reply = createMockReply(); + const result = await route!.handler({ params: { id: 'session-1' } }, reply); + + expect(result.success).toBe(true); + expect(result.sessionId).toBe('session-1'); + expect(callbacks.interruptSession).toHaveBeenCalledWith('session-1'); + }); + + it('should return 500 when interrupt fails', async () => { + (callbacks.interruptSession as any).mockResolvedValue(false); + + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/interrupt`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' } }, reply); + + expect(reply.code).toHaveBeenCalledWith(500); + }); + + it('should return 500 when interrupt throws error', async () => { + (callbacks.interruptSession as any).mockRejectedValue(new Error('Session not found')); + + const route = mockFastify.getRoute('POST', `/${securityToken}/api/session/:id/interrupt`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' } }, reply); + + expect(reply.code).toHaveBeenCalledWith(500); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Session not found'), + }) + ); + }); + + it('should return 503 when interruptSession callback not configured', async () => { + const emptyRoutes = new ApiRoutes(securityToken, rateLimitConfig); + const emptyFastify = createMockFastify(); + emptyRoutes.registerRoutes(emptyFastify as any); + + const route = emptyFastify.getRoute('POST', `/${securityToken}/api/session/:id/interrupt`); + const reply = createMockReply(); + await route!.handler({ params: { id: 'session-1' } }, reply); + + expect(reply.code).toHaveBeenCalledWith(503); + }); + }); + + describe('GET /api/history', () => { + it('should return history entries', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/history`); + const reply = createMockReply(); + const result = await route!.handler({ query: {} }, reply); + + expect(result.entries).toHaveLength(1); + expect(result.count).toBe(1); + expect(callbacks.getHistory).toHaveBeenCalledWith(undefined, undefined); + }); + + it('should pass projectPath and sessionId to callback', async () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/api/history`); + const reply = createMockReply(); + await route!.handler({ query: { projectPath: '/test', sessionId: 'session-1' } }, reply); + + expect(callbacks.getHistory).toHaveBeenCalledWith('/test', 'session-1'); + }); + + it('should return 500 when getHistory throws error', async () => { + (callbacks.getHistory as any).mockImplementation(() => { + throw new Error('Database error'); + }); + + const route = mockFastify.getRoute('GET', `/${securityToken}/api/history`); + const reply = createMockReply(); + await route!.handler({ query: {} }, reply); + + expect(reply.code).toHaveBeenCalledWith(500); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Database error'), + }) + ); + }); + + it('should return 503 when getHistory callback not configured', async () => { + const emptyRoutes = new ApiRoutes(securityToken, rateLimitConfig); + const emptyFastify = createMockFastify(); + emptyRoutes.registerRoutes(emptyFastify as any); + + const route = emptyFastify.getRoute('GET', `/${securityToken}/api/history`); + const reply = createMockReply(); + await route!.handler({ query: {} }, reply); + + expect(reply.code).toHaveBeenCalledWith(503); + }); + }); + + describe('Rate Limit Configuration', () => { + it('should update rate limit config', () => { + const newConfig: RateLimitConfig = { + max: 200, + maxPost: 50, + timeWindow: 120000, // 2 minutes in milliseconds + enabled: true, + }; + apiRoutes.updateRateLimitConfig(newConfig); + + // Re-register routes to see new config + const newFastify = createMockFastify(); + apiRoutes.registerRoutes(newFastify as any); + + const sessionsRoute = newFastify.getRoute('GET', `/${securityToken}/api/sessions`); + expect(sessionsRoute?.config?.rateLimit?.max).toBe(200); + }); + }); +}); diff --git a/src/__tests__/main/web-server/routes/staticRoutes.test.ts b/src/__tests__/main/web-server/routes/staticRoutes.test.ts new file mode 100644 index 00000000..642f2c79 --- /dev/null +++ b/src/__tests__/main/web-server/routes/staticRoutes.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for StaticRoutes + * + * Static Routes handle dashboard views, PWA files, and security redirects. + * Routes are protected by a security token prefix. + * + * Note: Tests that require fs mocking are skipped due to ESM module limitations. + * The fs-dependent functionality is tested via integration tests. + * + * Routes tested: + * - / - Redirect to website (no access without token) + * - /health - Health check endpoint + * - /:token - Invalid token catch-all, redirect to website + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { StaticRoutes } from '../../../../main/web-server/routes/staticRoutes'; + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +/** + * Mock Fastify instance with route registration tracking + */ +function createMockFastify() { + const routes: Map = new Map(); + + return { + get: vi.fn((path: string, handler: Function) => { + routes.set(`GET:${path}`, { handler }); + }), + getRoute: (method: string, path: string) => routes.get(`${method}:${path}`), + routes, + }; +} + +/** + * Mock reply object + */ +function createMockReply() { + const reply: any = { + code: vi.fn().mockReturnThis(), + send: vi.fn().mockReturnThis(), + type: vi.fn().mockReturnThis(), + redirect: vi.fn().mockReturnThis(), + }; + return reply; +} + +describe('StaticRoutes', () => { + const securityToken = 'test-token-123'; + const webAssetsPath = '/path/to/web/assets'; + + let staticRoutes: StaticRoutes; + let mockFastify: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + staticRoutes = new StaticRoutes(securityToken, webAssetsPath); + mockFastify = createMockFastify(); + staticRoutes.registerRoutes(mockFastify as any); + }); + + describe('Route Registration', () => { + it('should register all static routes', () => { + // 8 routes: /, /health, manifest.json, sw.js, dashboard, dashboard/, session/:id, /:token + expect(mockFastify.get).toHaveBeenCalledTimes(8); + }); + + it('should register routes with correct paths', () => { + expect(mockFastify.routes.has('GET:/')).toBe(true); + expect(mockFastify.routes.has('GET:/health')).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/manifest.json`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/sw.js`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/`)).toBe(true); + expect(mockFastify.routes.has(`GET:/${securityToken}/session/:sessionId`)).toBe(true); + expect(mockFastify.routes.has('GET:/:token')).toBe(true); + }); + }); + + describe('GET / (Root Redirect)', () => { + it('should redirect to website', async () => { + const route = mockFastify.getRoute('GET', '/'); + const reply = createMockReply(); + await route!.handler({}, reply); + + expect(reply.redirect).toHaveBeenCalledWith(302, 'https://runmaestro.ai'); + }); + }); + + describe('GET /health', () => { + it('should return health status', async () => { + const route = mockFastify.getRoute('GET', '/health'); + const result = await route!.handler(); + + expect(result.status).toBe('ok'); + expect(result.timestamp).toBeDefined(); + }); + }); + + describe('Null webAssetsPath handling', () => { + it('should return 404 for manifest.json when webAssetsPath is null', async () => { + const noAssetsRoutes = new StaticRoutes(securityToken, null); + const noAssetsFastify = createMockFastify(); + noAssetsRoutes.registerRoutes(noAssetsFastify as any); + + const route = noAssetsFastify.getRoute('GET', `/${securityToken}/manifest.json`); + const reply = createMockReply(); + await route!.handler({}, reply); + + expect(reply.code).toHaveBeenCalledWith(404); + }); + + it('should return 404 for sw.js when webAssetsPath is null', async () => { + const noAssetsRoutes = new StaticRoutes(securityToken, null); + const noAssetsFastify = createMockFastify(); + noAssetsRoutes.registerRoutes(noAssetsFastify as any); + + const route = noAssetsFastify.getRoute('GET', `/${securityToken}/sw.js`); + const reply = createMockReply(); + await route!.handler({}, reply); + + expect(reply.code).toHaveBeenCalledWith(404); + }); + + it('should return 503 for dashboard when webAssetsPath is null', async () => { + const noAssetsRoutes = new StaticRoutes(securityToken, null); + const noAssetsFastify = createMockFastify(); + noAssetsRoutes.registerRoutes(noAssetsFastify as any); + + const route = noAssetsFastify.getRoute('GET', `/${securityToken}`); + const reply = createMockReply(); + await route!.handler({}, reply); + + expect(reply.code).toHaveBeenCalledWith(503); + expect(reply.send).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Service Unavailable' }) + ); + }); + }); + + describe('GET /:token (Invalid Token Catch-all)', () => { + it('should redirect to website for invalid token', async () => { + const route = mockFastify.getRoute('GET', '/:token'); + const reply = createMockReply(); + await route!.handler({ params: { token: 'invalid-token' } }, reply); + + expect(reply.redirect).toHaveBeenCalledWith(302, 'https://runmaestro.ai'); + }); + }); + + describe('Security Token Validation', () => { + it('should use provided security token in routes', () => { + const customToken = 'custom-secure-token-456'; + const customRoutes = new StaticRoutes(customToken, webAssetsPath); + const customFastify = createMockFastify(); + customRoutes.registerRoutes(customFastify as any); + + expect(customFastify.routes.has(`GET:/${customToken}`)).toBe(true); + expect(customFastify.routes.has(`GET:/${customToken}/manifest.json`)).toBe(true); + expect(customFastify.routes.has(`GET:/${customToken}/sw.js`)).toBe(true); + expect(customFastify.routes.has(`GET:/${customToken}/session/:sessionId`)).toBe(true); + }); + }); + + describe('XSS Sanitization (sanitizeId)', () => { + // Access private method via type casting for testing + const getSanitizeId = (routes: StaticRoutes) => { + return (routes as any).sanitizeId.bind(routes); + }; + + it('should allow valid UUID-style IDs', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('abc123')).toBe('abc123'); + expect(sanitizeId('session-1')).toBe('session-1'); + expect(sanitizeId('tab_abc_123')).toBe('tab_abc_123'); + expect(sanitizeId('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + ); + }); + + it('should return null for null/undefined input', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId(null)).toBeNull(); + expect(sanitizeId(undefined)).toBeNull(); + expect(sanitizeId('')).toBeNull(); + }); + + it('should reject XSS payloads with script tags', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('')).toBeNull(); + expect(sanitizeId('session')).toBeNull(); + }); + + it('should reject XSS payloads with JavaScript URLs', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('javascript:alert(1)')).toBeNull(); + expect(sanitizeId('session:javascript')).toBeNull(); + }); + + it('should reject XSS payloads with HTML entities', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('<script>')).toBeNull(); + expect(sanitizeId('session<')).toBeNull(); + }); + + it('should reject special characters that could break HTML/JS', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('"onload="alert(1)')).toBeNull(); + expect(sanitizeId("'onclick='alert(1)")).toBeNull(); + expect(sanitizeId('session;alert(1)')).toBeNull(); + expect(sanitizeId('session&alert=1')).toBeNull(); + expect(sanitizeId('session?alert=1')).toBeNull(); + expect(sanitizeId('session#alert')).toBeNull(); + }); + + it('should reject whitespace', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('session 1')).toBeNull(); + expect(sanitizeId('tab\t1')).toBeNull(); + expect(sanitizeId('tab\n1')).toBeNull(); + }); + + it('should reject path traversal attempts', () => { + const sanitizeId = getSanitizeId(staticRoutes); + expect(sanitizeId('../../../etc/passwd')).toBeNull(); + expect(sanitizeId('..%2F..%2F')).toBeNull(); + }); + }); +}); diff --git a/src/__tests__/main/web-server/routes/wsRoute.test.ts b/src/__tests__/main/web-server/routes/wsRoute.test.ts new file mode 100644 index 00000000..b17eb34b --- /dev/null +++ b/src/__tests__/main/web-server/routes/wsRoute.test.ts @@ -0,0 +1,497 @@ +/** + * Tests for WsRoute + * + * WebSocket Route handles WebSocket connections, initial state sync, and message delegation. + * Route: /$TOKEN/ws + * + * Connection Flow: + * 1. Client connects with optional ?sessionId= query param + * 2. Server sends 'connected' message with client ID + * 3. Server sends 'sessions_list' with all sessions (enriched with live info) + * 4. Server sends 'theme' with current theme + * 5. Server sends 'custom_commands' with available commands + * 6. Server sends 'autorun_state' for active AutoRun sessions + * 7. Client can send messages which are delegated to WebSocketMessageHandler + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebSocket } from 'ws'; +import { WsRoute, type WsRouteCallbacks } from '../../../../main/web-server/routes/wsRoute'; + +// Mock the logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +/** + * Create mock callbacks with all methods as vi.fn() + */ +function createMockCallbacks(): WsRouteCallbacks { + return { + getSessions: vi.fn().mockReturnValue([ + { + id: 'session-1', + name: 'Session 1', + toolType: 'claude-code', + state: 'idle', + inputMode: 'ai', + cwd: '/test/project', + groupId: null, + }, + { + id: 'session-2', + name: 'Session 2', + toolType: 'codex', + state: 'busy', + inputMode: 'terminal', + cwd: '/test/project2', + groupId: 'group-1', + }, + ]), + getTheme: vi.fn().mockReturnValue({ + name: 'dark', + background: '#1a1a1a', + foreground: '#ffffff', + }), + getCustomCommands: vi + .fn() + .mockReturnValue([{ id: 'cmd-1', name: 'Test Command', prompt: 'Do something' }]), + getAutoRunStates: vi.fn().mockReturnValue( + new Map([ + [ + 'session-1', + { + isRunning: true, + totalTasks: 5, + completedTasks: 2, + currentTask: 'Task 3', + }, + ], + ]) + ), + getLiveSessionInfo: vi.fn().mockReturnValue({ + sessionId: 'session-1', + agentSessionId: 'claude-agent-123', + enabledAt: Date.now(), + }), + isSessionLive: vi.fn().mockReturnValue(true), + onClientConnect: vi.fn(), + onClientDisconnect: vi.fn(), + onClientError: vi.fn(), + handleMessage: vi.fn(), + }; +} + +/** + * Create mock WebSocket + */ +function createMockSocket() { + const eventHandlers: Map = new Map(); + return { + readyState: WebSocket.OPEN, + send: vi.fn(), + on: vi.fn((event: string, handler: Function) => { + if (!eventHandlers.has(event)) { + eventHandlers.set(event, []); + } + eventHandlers.get(event)!.push(handler); + }), + emit: (event: string, ...args: any[]) => { + const handlers = eventHandlers.get(event) || []; + handlers.forEach((h) => h(...args)); + }, + eventHandlers, + }; +} + +/** + * Create mock Fastify connection + */ +function createMockConnection() { + return { + socket: createMockSocket(), + }; +} + +/** + * Create mock Fastify request + */ +function createMockRequest(sessionId?: string) { + const queryString = sessionId ? `?sessionId=${sessionId}` : ''; + return { + url: `/test-token/ws${queryString}`, + headers: { + host: 'localhost:3000', + }, + }; +} + +/** + * Mock Fastify instance with route registration tracking + */ +function createMockFastify() { + const routes: Map = new Map(); + + return { + get: vi.fn((path: string, options: any, handler?: Function) => { + const h = handler || options; + const opts = handler ? options : undefined; + routes.set(`GET:${path}`, { handler: h, options: opts }); + }), + getRoute: (method: string, path: string) => routes.get(`${method}:${path}`), + routes, + }; +} + +describe('WsRoute', () => { + const securityToken = 'test-token-123'; + + let wsRoute: WsRoute; + let callbacks: WsRouteCallbacks; + let mockFastify: ReturnType; + + beforeEach(() => { + wsRoute = new WsRoute(securityToken); + callbacks = createMockCallbacks(); + wsRoute.setCallbacks(callbacks); + mockFastify = createMockFastify(); + wsRoute.registerRoute(mockFastify as any); + }); + + describe('Route Registration', () => { + it('should register WebSocket route with correct path', () => { + expect(mockFastify.get).toHaveBeenCalledTimes(1); + expect(mockFastify.routes.has(`GET:/${securityToken}/ws`)).toBe(true); + }); + + it('should register route with websocket option', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + expect(route?.options?.websocket).toBe(true); + }); + }); + + describe('Connection Handling', () => { + it('should generate unique client IDs', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + + // Connect first client + const conn1 = createMockConnection(); + route!.handler(conn1, createMockRequest()); + + // Connect second client + const conn2 = createMockConnection(); + route!.handler(conn2, createMockRequest()); + + // Verify unique IDs + expect(callbacks.onClientConnect).toHaveBeenCalledTimes(2); + const client1 = (callbacks.onClientConnect as any).mock.calls[0][0]; + const client2 = (callbacks.onClientConnect as any).mock.calls[1][0]; + expect(client1.id).not.toBe(client2.id); + expect(client1.id).toMatch(/^web-client-\d+$/); + expect(client2.id).toMatch(/^web-client-\d+$/); + }); + + it('should notify parent on client connect', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + expect(callbacks.onClientConnect).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(/^web-client-/), + socket: connection.socket, + connectedAt: expect.any(Number), + }) + ); + }); + + it('should extract sessionId from query string', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest('session-123')); + + expect(callbacks.onClientConnect).toHaveBeenCalledWith( + expect.objectContaining({ + subscribedSessionId: 'session-123', + }) + ); + }); + + it('should set subscribedSessionId to undefined when not in query', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + expect(callbacks.onClientConnect).toHaveBeenCalledWith( + expect.objectContaining({ + subscribedSessionId: undefined, + }) + ); + }); + }); + + describe('Initial Sync Messages', () => { + it('should send connected message', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest('session-123')); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const connectedMsg = sentMessages.find((m: any) => m.type === 'connected'); + expect(connectedMsg).toBeDefined(); + expect(connectedMsg.clientId).toMatch(/^web-client-/); + expect(connectedMsg.subscribedSessionId).toBe('session-123'); + expect(connectedMsg.timestamp).toBeDefined(); + }); + + it('should send sessions_list with enriched live info', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const sessionsMsg = sentMessages.find((m: any) => m.type === 'sessions_list'); + expect(sessionsMsg).toBeDefined(); + expect(sessionsMsg.sessions).toHaveLength(2); + expect(sessionsMsg.sessions[0].agentSessionId).toBe('claude-agent-123'); + expect(sessionsMsg.sessions[0].isLive).toBe(true); + expect(sessionsMsg.sessions[0].liveEnabledAt).toBeDefined(); + }); + + it('should send theme', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const themeMsg = sentMessages.find((m: any) => m.type === 'theme'); + expect(themeMsg).toBeDefined(); + expect(themeMsg.theme.name).toBe('dark'); + }); + + it('should not send theme when null', () => { + (callbacks.getTheme as any).mockReturnValue(null); + + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const themeMsg = sentMessages.find((m: any) => m.type === 'theme'); + expect(themeMsg).toBeUndefined(); + }); + + it('should send custom_commands', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const commandsMsg = sentMessages.find((m: any) => m.type === 'custom_commands'); + expect(commandsMsg).toBeDefined(); + expect(commandsMsg.commands).toHaveLength(1); + expect(commandsMsg.commands[0].name).toBe('Test Command'); + }); + + it('should send autorun_state for running sessions', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const autoRunMsg = sentMessages.find((m: any) => m.type === 'autorun_state'); + expect(autoRunMsg).toBeDefined(); + expect(autoRunMsg.sessionId).toBe('session-1'); + expect(autoRunMsg.state.isRunning).toBe(true); + expect(autoRunMsg.state.completedTasks).toBe(2); + expect(autoRunMsg.state.totalTasks).toBe(5); + }); + + it('should not send autorun_state for non-running sessions', () => { + (callbacks.getAutoRunStates as any).mockReturnValue( + new Map([ + [ + 'session-1', + { + isRunning: false, + totalTasks: 5, + completedTasks: 5, + }, + ], + ]) + ); + + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const autoRunMsg = sentMessages.find((m: any) => m.type === 'autorun_state'); + expect(autoRunMsg).toBeUndefined(); + }); + }); + + describe('Message Handling', () => { + it('should delegate messages to handleMessage callback', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + // Simulate incoming message + const message = JSON.stringify({ type: 'ping' }); + connection.socket.emit('message', message); + + expect(callbacks.handleMessage).toHaveBeenCalledWith(expect.stringMatching(/^web-client-/), { + type: 'ping', + }); + }); + + it('should send error for invalid JSON messages', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + // Clear previous sends + (connection.socket.send as any).mockClear(); + + // Simulate invalid message + connection.socket.emit('message', 'not valid json'); + + const lastSend = (connection.socket.send as any).mock.calls[0]; + const errorMsg = JSON.parse(lastSend[0]); + expect(errorMsg.type).toBe('error'); + expect(errorMsg.message).toBe('Invalid message format'); + }); + }); + + describe('Disconnection Handling', () => { + it('should notify parent on client disconnect', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const clientId = (callbacks.onClientConnect as any).mock.calls[0][0].id; + + // Simulate close event + connection.socket.emit('close'); + + expect(callbacks.onClientDisconnect).toHaveBeenCalledWith(clientId); + }); + }); + + describe('Error Handling', () => { + it('should notify parent on client error', () => { + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const clientId = (callbacks.onClientConnect as any).mock.calls[0][0].id; + const error = new Error('Connection lost'); + + // Simulate error event + connection.socket.emit('error', error); + + expect(callbacks.onClientError).toHaveBeenCalledWith(clientId, error); + }); + }); + + describe('Callback Resilience', () => { + it('should handle missing callbacks gracefully', () => { + const emptyWsRoute = new WsRoute(securityToken); + // Don't set any callbacks + const emptyFastify = createMockFastify(); + emptyWsRoute.registerRoute(emptyFastify as any); + + const route = emptyFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + + // Should not throw + expect(() => { + route!.handler(connection, createMockRequest()); + }).not.toThrow(); + + // Should still send connected message + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + const connectedMsg = sentMessages.find((m: any) => m.type === 'connected'); + expect(connectedMsg).toBeDefined(); + }); + + it('should handle partial callbacks', () => { + const partialWsRoute = new WsRoute(securityToken); + partialWsRoute.setCallbacks({ + getSessions: vi.fn().mockReturnValue([]), + getTheme: vi.fn().mockReturnValue(null), + getCustomCommands: vi.fn().mockReturnValue([]), + getAutoRunStates: vi.fn().mockReturnValue(new Map()), + getLiveSessionInfo: vi.fn().mockReturnValue(undefined), + isSessionLive: vi.fn().mockReturnValue(false), + onClientConnect: vi.fn(), + onClientDisconnect: vi.fn(), + onClientError: vi.fn(), + handleMessage: vi.fn(), + }); + const partialFastify = createMockFastify(); + partialWsRoute.registerRoute(partialFastify as any); + + const route = partialFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + + // Should not throw + expect(() => { + route!.handler(connection, createMockRequest()); + }).not.toThrow(); + }); + }); + + describe('Multiple AutoRun States', () => { + it('should send autorun_state for all running sessions', () => { + (callbacks.getAutoRunStates as any).mockReturnValue( + new Map([ + ['session-1', { isRunning: true, totalTasks: 5, completedTasks: 2 }], + ['session-2', { isRunning: true, totalTasks: 3, completedTasks: 1 }], + ['session-3', { isRunning: false, totalTasks: 2, completedTasks: 2 }], + ]) + ); + + const route = mockFastify.getRoute('GET', `/${securityToken}/ws`); + const connection = createMockConnection(); + route!.handler(connection, createMockRequest()); + + const sentMessages = (connection.socket.send as any).mock.calls.map((call: any[]) => + JSON.parse(call[0]) + ); + + const autoRunMsgs = sentMessages.filter((m: any) => m.type === 'autorun_state'); + expect(autoRunMsgs).toHaveLength(2); // Only running sessions + expect(autoRunMsgs.map((m: any) => m.sessionId)).toEqual(['session-1', 'session-2']); + }); + }); +}); diff --git a/src/__tests__/main/web-server/web-server-factory.test.ts b/src/__tests__/main/web-server/web-server-factory.test.ts index 619f56d6..2bb09a76 100644 --- a/src/__tests__/main/web-server/web-server-factory.test.ts +++ b/src/__tests__/main/web-server/web-server-factory.test.ts @@ -14,7 +14,8 @@ vi.mock('electron', () => ({ })); // Mock WebServer - use class syntax to make it a proper constructor -vi.mock('../../../main/web-server', () => { +// Note: Mock the specific file path that web-server-factory.ts imports from +vi.mock('../../../main/web-server/WebServer', () => { return { WebServer: class MockWebServer { port: number; @@ -68,7 +69,7 @@ import { createWebServerFactory, type WebServerFactoryDependencies, } from '../../../main/web-server/web-server-factory'; -import { WebServer } from '../../../main/web-server'; +import { WebServer } from '../../../main/web-server/WebServer'; import { getThemeById } from '../../../main/themes'; import { getHistoryManager } from '../../../main/history-manager'; import { logger } from '../../../main/utils/logger'; diff --git a/src/main/web-server.ts b/src/main/web-server/WebServer.ts similarity index 79% rename from src/main/web-server.ts rename to src/main/web-server/WebServer.ts index c03b26eb..a067b7c4 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server/WebServer.ts @@ -1,49 +1,3 @@ -import Fastify from 'fastify'; -import cors from '@fastify/cors'; -import websocket from '@fastify/websocket'; -import rateLimit from '@fastify/rate-limit'; -import fastifyStatic from '@fastify/static'; -import { FastifyInstance, FastifyRequest } from 'fastify'; -import { randomUUID } from 'crypto'; -import path from 'path'; -import { existsSync } from 'fs'; -import type { Theme } from '../shared/theme-types'; -import { HistoryEntry } from '../shared/types'; -import { getLocalIpAddressSync } from './utils/networkUtils'; -import { logger } from './utils/logger'; -import { WebSocketMessageHandler, WebClient, WebClientMessage } from './web-server/handlers'; -import { - BroadcastService, - AITabData as BroadcastAITabData, - CustomAICommand as BroadcastCustomAICommand, - AutoRunState, - CliActivity, - SessionBroadcastData, -} from './web-server/services'; -import { ApiRoutes, StaticRoutes, WsRoute } from './web-server/routes'; - -// Logger context for all web server logs -const LOG_CONTEXT = 'WebServer'; - -// Live session info -interface LiveSessionInfo { - sessionId: string; - agentSessionId?: string; - enabledAt: number; -} - -// Rate limiting configuration -export interface RateLimitConfig { - // Maximum requests per time window - max: number; - // Time window in milliseconds - timeWindow: number; - // Maximum requests for POST endpoints (typically lower) - maxPost: number; - // Enable/disable rate limiting - enabled: boolean; -} - /** * WebServer - HTTP and WebSocket server for remote access * @@ -62,141 +16,55 @@ export interface RateLimitConfig { * * Security: * - Token regenerated on each app restart - * - Invalid/missing token redirects to GitHub + * - Invalid/missing token redirects to website * - No access without knowing the token */ -// Usage stats type for session cost/token tracking -export interface SessionUsageStats { - inputTokens?: number; - outputTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - totalCostUsd?: number; - contextWindow?: number; -} -// Last response type for mobile preview (truncated to save bandwidth) -export interface LastResponsePreview { - text: string; // First 3 lines or ~500 chars of the last AI response - timestamp: number; - source: 'stdout' | 'stderr' | 'system'; - fullLength: number; // Total length of the original response -} +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import websocket from '@fastify/websocket'; +import rateLimit from '@fastify/rate-limit'; +import fastifyStatic from '@fastify/static'; +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { randomUUID } from 'crypto'; +import path from 'path'; +import { existsSync } from 'fs'; +import { getLocalIpAddressSync } from '../utils/networkUtils'; +import { logger } from '../utils/logger'; +import { WebSocketMessageHandler } from './handlers'; +import { BroadcastService } from './services'; +import { ApiRoutes, StaticRoutes, WsRoute } from './routes'; -// AI Tab type for multi-tab support within a Maestro session -export interface AITabData { - id: string; - agentSessionId: string | null; - name: string | null; - starred: boolean; - inputValue: string; - usageStats?: SessionUsageStats | null; - createdAt: number; - state: 'idle' | 'busy'; - thinkingStartTime?: number | null; -} +// Import shared types from canonical location +import type { + Theme, + LiveSessionInfo, + RateLimitConfig, + AITabData, + CustomAICommand, + AutoRunState, + CliActivity, + SessionBroadcastData, + WebClient, + WebClientMessage, + GetSessionsCallback, + GetSessionDetailCallback, + WriteToSessionCallback, + ExecuteCommandCallback, + InterruptSessionCallback, + SwitchModeCallback, + SelectSessionCallback, + SelectTabCallback, + NewTabCallback, + CloseTabCallback, + RenameTabCallback, + GetThemeCallback, + GetCustomCommandsCallback, + GetHistoryCallback, +} from './types'; -// Callback type for fetching sessions data -export type GetSessionsCallback = () => Array<{ - id: string; - name: string; - toolType: string; - state: string; - inputMode: string; - cwd: string; - groupId: string | null; - groupName: string | null; - groupEmoji: string | null; - usageStats?: SessionUsageStats | null; - lastResponse?: LastResponsePreview | null; - agentSessionId?: string | null; - thinkingStartTime?: number | null; // Timestamp when AI started thinking (for elapsed time display) - aiTabs?: AITabData[]; - activeTabId?: string; - bookmarked?: boolean; // Whether session is bookmarked (shows in Bookmarks group) -}>; - -// Session detail type for single session endpoint -export interface SessionDetail { - id: string; - name: string; - toolType: string; - state: string; - inputMode: string; - cwd: string; - aiLogs?: Array<{ timestamp: number; content: string; type?: string }>; - shellLogs?: Array<{ timestamp: number; content: string; type?: string }>; - usageStats?: { - inputTokens?: number; - outputTokens?: number; - totalCost?: number; - }; - agentSessionId?: string; - isGitRepo?: boolean; - activeTabId?: string; -} - -// Callback type for fetching single session details -// Optional tabId allows fetching logs for a specific tab (avoids race conditions) -export type GetSessionDetailCallback = (sessionId: string, tabId?: string) => SessionDetail | null; - -// Callback type for sending commands to a session -// Returns true if successful, false if session not found or write failed -export type WriteToSessionCallback = (sessionId: string, data: string) => boolean; - -// Callback type for executing a command through the desktop's existing logic -// This forwards the command to the renderer which handles spawn, state, and broadcasts -// Returns true if command was accepted (session not busy) -// inputMode is optional - if provided, the renderer will use it instead of querying session state -export type ExecuteCommandCallback = ( - sessionId: string, - command: string, - inputMode?: 'ai' | 'terminal' -) => Promise; - -// Callback type for interrupting a session through the desktop's existing logic -// This forwards to the renderer which handles state updates and broadcasts -export type InterruptSessionCallback = (sessionId: string) => Promise; - -// Callback type for switching session input mode through the desktop's existing logic -// This forwards to the renderer which handles state updates and broadcasts -export type SwitchModeCallback = (sessionId: string, mode: 'ai' | 'terminal') => Promise; - -// Callback type for selecting/switching to a session in the desktop app -// This forwards to the renderer which handles state updates and broadcasts -// Optional tabId to also switch to a specific tab within the session -export type SelectSessionCallback = (sessionId: string, tabId?: string) => Promise; - -// Tab operation callbacks for multi-tab support -export type SelectTabCallback = (sessionId: string, tabId: string) => Promise; -export type NewTabCallback = (sessionId: string) => Promise<{ tabId: string } | null>; -export type CloseTabCallback = (sessionId: string, tabId: string) => Promise; -export type RenameTabCallback = ( - sessionId: string, - tabId: string, - newName: string -) => Promise; - -// Re-export Theme type from shared for backwards compatibility -export type { Theme } from '../shared/theme-types'; - -// Callback type for fetching current theme -export type GetThemeCallback = () => Theme | null; - -// Custom AI command definition (matches renderer's CustomAICommand) -export interface CustomAICommand { - id: string; - command: string; - description: string; - prompt: string; -} - -// Callback type for fetching custom AI commands -export type GetCustomCommandsCallback = () => CustomAICommand[]; - -// Callback type for fetching history entries -// Uses HistoryEntry from shared/types.ts as the canonical type -export type GetHistoryCallback = (projectPath?: string, sessionId?: string) => HistoryEntry[]; +// Logger context for all web server logs +const LOG_CONTEXT = 'WebServer'; // Default rate limit configuration const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = { @@ -291,11 +159,11 @@ export class WebServer { // Try multiple locations for the web assets const possiblePaths = [ // Production: relative to the compiled main process - path.join(__dirname, '..', 'web'), + path.join(__dirname, '..', '..', 'web'), // Development: from project root path.join(process.cwd(), 'dist', 'web'), // Alternative: relative to __dirname going up to dist - path.join(__dirname, 'web'), + path.join(__dirname, '..', 'web'), ]; for (const p of possiblePaths) { @@ -343,6 +211,12 @@ export class WebServer { LOG_CONTEXT ); + // Clean up any associated AutoRun state to prevent memory leaks + if (this.autoRunStates.has(sessionId)) { + this.autoRunStates.delete(sessionId); + logger.debug(`Cleaned up AutoRun state for offline session ${sessionId}`, LOG_CONTEXT); + } + // Broadcast to all connected clients this.broadcastService.broadcastSessionOffline(sessionId); } @@ -705,7 +579,7 @@ export class WebServer { * Broadcast tab change to all connected web clients * Called when the tabs array or active tab changes in a session */ - broadcastTabsChange(sessionId: string, aiTabs: BroadcastAITabData[], activeTabId: string): void { + broadcastTabsChange(sessionId: string, aiTabs: AITabData[], activeTabId: string): void { this.broadcastService.broadcastTabsChange(sessionId, aiTabs, activeTabId); } @@ -721,7 +595,7 @@ export class WebServer { * Broadcast custom commands update to all connected web clients * Called when the user modifies custom AI commands in the desktop app */ - broadcastCustomCommands(commands: BroadcastCustomAICommand[]): void { + broadcastCustomCommands(commands: CustomAICommand[]): void { this.broadcastService.broadcastCustomCommands(commands); } @@ -862,11 +736,20 @@ export class WebServer { return; } - // Mark all live sessions as offline + // Mark all live sessions as offline (this also cleans up autoRunStates) for (const sessionId of this.liveSessions.keys()) { this.setSessionOffline(sessionId); } + // Clear any remaining autoRunStates as a safety measure + if (this.autoRunStates.size > 0) { + logger.debug( + `Clearing ${this.autoRunStates.size} remaining AutoRun states on server stop`, + LOG_CONTEXT + ); + this.autoRunStates.clear(); + } + try { await this.server.close(); this.isRunning = false; diff --git a/src/main/web-server/handlers/messageHandlers.ts b/src/main/web-server/handlers/messageHandlers.ts index 84029af8..6bb32ecf 100644 --- a/src/main/web-server/handlers/messageHandlers.ts +++ b/src/main/web-server/handlers/messageHandlers.ts @@ -90,7 +90,6 @@ export interface MessageHandlerCallbacks { inputMode: string; cwd: string; agentSessionId?: string | null; - [key: string]: unknown; }>; getLiveSessionInfo: (sessionId: string) => LiveSessionInfo | undefined; isSessionLive: (sessionId: string) => boolean; @@ -112,6 +111,20 @@ export class WebSocketMessageHandler { this.callbacks = { ...this.callbacks, ...callbacks }; } + /** + * Helper to send a JSON message to a client with timestamp + */ + private send(client: WebClient, data: Record): void { + client.socket.send(JSON.stringify({ ...data, timestamp: Date.now() })); + } + + /** + * Helper to send an error message to a client + */ + private sendError(client: WebClient, message: string, extra?: Record): void { + this.send(client, { type: 'error', message, ...extra }); + } + /** * Handle incoming WebSocket message from a web client * @@ -175,12 +188,7 @@ export class WebSocketMessageHandler { * Handle ping message - respond with pong */ private handlePing(client: WebClient): void { - client.socket.send( - JSON.stringify({ - type: 'pong', - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'pong' }); } /** @@ -190,13 +198,7 @@ export class WebSocketMessageHandler { if (message.sessionId) { client.subscribedSessionId = message.sessionId as string; } - client.socket.send( - JSON.stringify({ - type: 'subscribed', - sessionId: message.sessionId, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'subscribed', sessionId: message.sessionId }); } /** @@ -218,38 +220,25 @@ export class WebSocketMessageHandler { `[Web Command] Missing sessionId or command: sessionId=${sessionId}, commandLen=${command?.length}`, LOG_CONTEXT ); - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId or command', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId or command'); return; } // Get session details to check state and determine how to handle const sessionDetail = this.callbacks.getSessionDetail?.(sessionId); if (!sessionDetail) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Session not found', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Session not found'); return; } // Check if session is busy - prevent race conditions between desktop and web if (sessionDetail.state === 'busy') { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Session is busy - please wait for the current operation to complete', + this.sendError( + client, + 'Session is busy - please wait for the current operation to complete', + { sessionId, - timestamp: Date.now(), - }) + } ); logger.debug(`Command rejected - session ${sessionId} is busy`, LOG_CONTEXT); return; @@ -274,14 +263,7 @@ export class WebSocketMessageHandler { this.callbacks .executeCommand(sessionId, command, clientInputMode) .then((success) => { - client.socket.send( - JSON.stringify({ - type: 'command_result', - success, - sessionId, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'command_result', success, sessionId }); if (!success) { logger.warn( `[Web Command] ${mode} command rejected for session ${sessionId}`, @@ -294,22 +276,10 @@ export class WebSocketMessageHandler { `[Web Command] ${mode} command failed for session ${sessionId}: ${error.message}`, LOG_CONTEXT ); - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to execute command: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to execute command: ${error.message}`); }); } else { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Command execution not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Command execution not configured'); } } @@ -325,25 +295,13 @@ export class WebSocketMessageHandler { ); if (!sessionId || !mode) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId or mode', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId or mode'); return; } if (!this.callbacks.switchMode) { logger.warn(`[Web] switchModeCallback is not set!`, LOG_CONTEXT); - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Mode switching not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Mode switching not configured'); return; } @@ -353,28 +311,14 @@ export class WebSocketMessageHandler { this.callbacks .switchMode(sessionId, mode) .then((success) => { - client.socket.send( - JSON.stringify({ - type: 'mode_switch_result', - success, - sessionId, - mode, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'mode_switch_result', success, sessionId, mode }); logger.debug( `Mode switch for session ${sessionId} to ${mode}: ${success ? 'success' : 'failed'}`, LOG_CONTEXT ); }) .catch((error) => { - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to switch mode: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to switch mode: ${error.message}`); }); } @@ -390,25 +334,13 @@ export class WebSocketMessageHandler { ); if (!sessionId) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId'); return; } if (!this.callbacks.selectSession) { logger.warn(`[Web] selectSessionCallback is not set!`, LOG_CONTEXT); - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Session selection not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Session selection not configured'); return; } @@ -427,23 +359,10 @@ export class WebSocketMessageHandler { } else { logger.warn(`Failed to select session ${sessionId} in desktop`, LOG_CONTEXT); } - client.socket.send( - JSON.stringify({ - type: 'select_session_result', - success, - sessionId, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'select_session_result', success, sessionId }); }) .catch((error) => { - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to select session: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to select session: ${error.message}`); }); } @@ -467,13 +386,7 @@ export class WebSocketMessageHandler { isLive: this.callbacks.isSessionLive!(s.id), }; }); - client.socket.send( - JSON.stringify({ - type: 'sessions_list', - sessions: sessionsWithLiveInfo, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'sessions_list', sessions: sessionsWithLiveInfo }); } } @@ -489,48 +402,22 @@ export class WebSocketMessageHandler { ); if (!sessionId || !tabId) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId or tabId', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId or tabId'); return; } if (!this.callbacks.selectTab) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Tab selection not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Tab selection not configured'); return; } this.callbacks .selectTab(sessionId, tabId) .then((success) => { - client.socket.send( - JSON.stringify({ - type: 'select_tab_result', - success, - sessionId, - tabId, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'select_tab_result', success, sessionId, tabId }); }) .catch((error) => { - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to select tab: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to select tab: ${error.message}`); }); } @@ -542,48 +429,27 @@ export class WebSocketMessageHandler { logger.info(`[Web] Received new_tab message: session=${sessionId}`, LOG_CONTEXT); if (!sessionId) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId'); return; } if (!this.callbacks.newTab) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Tab creation not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Tab creation not configured'); return; } this.callbacks .newTab(sessionId) .then((result) => { - client.socket.send( - JSON.stringify({ - type: 'new_tab_result', - success: !!result, - sessionId, - tabId: result?.tabId, - timestamp: Date.now(), - }) - ); + this.send(client, { + type: 'new_tab_result', + success: !!result, + sessionId, + tabId: result?.tabId, + }); }) .catch((error) => { - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to create tab: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to create tab: ${error.message}`); }); } @@ -599,48 +465,22 @@ export class WebSocketMessageHandler { ); if (!sessionId || !tabId) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId or tabId', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId or tabId'); return; } if (!this.callbacks.closeTab) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Tab closing not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Tab closing not configured'); return; } this.callbacks .closeTab(sessionId, tabId) .then((success) => { - client.socket.send( - JSON.stringify({ - type: 'close_tab_result', - success, - sessionId, - tabId, - timestamp: Date.now(), - }) - ); + this.send(client, { type: 'close_tab_result', success, sessionId, tabId }); }) .catch((error) => { - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to close tab: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to close tab: ${error.message}`); }); } @@ -657,24 +497,12 @@ export class WebSocketMessageHandler { ); if (!sessionId || !tabId) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Missing sessionId or tabId', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Missing sessionId or tabId'); return; } if (!this.callbacks.renameTab) { - client.socket.send( - JSON.stringify({ - type: 'error', - message: 'Tab renaming not configured', - timestamp: Date.now(), - }) - ); + this.sendError(client, 'Tab renaming not configured'); return; } @@ -682,25 +510,16 @@ export class WebSocketMessageHandler { this.callbacks .renameTab(sessionId, tabId, newName || '') .then((success) => { - client.socket.send( - JSON.stringify({ - type: 'rename_tab_result', - success, - sessionId, - tabId, - newName: newName || '', - timestamp: Date.now(), - }) - ); + this.send(client, { + type: 'rename_tab_result', + success, + sessionId, + tabId, + newName: newName || '', + }); }) .catch((error) => { - client.socket.send( - JSON.stringify({ - type: 'error', - message: `Failed to rename tab: ${error.message}`, - timestamp: Date.now(), - }) - ); + this.sendError(client, `Failed to rename tab: ${error.message}`); }); } @@ -709,12 +528,6 @@ export class WebSocketMessageHandler { */ private handleUnknown(client: WebClient, message: WebClientMessage): void { logger.debug(`Unknown message type: ${message.type}`, LOG_CONTEXT); - client.socket.send( - JSON.stringify({ - type: 'echo', - originalType: message.type, - data: message, - }) - ); + this.send(client, { type: 'echo', originalType: message.type, data: message }); } } diff --git a/src/main/web-server/index.ts b/src/main/web-server/index.ts new file mode 100644 index 00000000..b00d2c51 --- /dev/null +++ b/src/main/web-server/index.ts @@ -0,0 +1,62 @@ +/** + * Web Server Module Index + * + * Main entry point for the web server module. + * Re-exports all public types, classes, and utilities. + * + * Import from this module: + * import { WebServer } from './web-server'; + * import type { Theme, LiveSessionInfo } from './web-server'; + */ + +// ============ Main Export ============ +// Export the WebServer class +export { WebServer } from './WebServer'; + +// ============ Shared Types ============ +// Export all shared types (canonical location for all web server types) +export type { + // Core types + Theme, + LiveSessionInfo, + RateLimitConfig, + SessionUsageStats, + LastResponsePreview, + AITabData, + SessionDetail, + CustomAICommand, + AutoRunState, + CliActivity, + SessionBroadcastData, + SessionData, + WebClient, + WebClientMessage, + // Callback types + GetSessionsCallback, + GetSessionDetailCallback, + WriteToSessionCallback, + ExecuteCommandCallback, + InterruptSessionCallback, + SwitchModeCallback, + SelectSessionCallback, + SelectTabCallback, + NewTabCallback, + CloseTabCallback, + RenameTabCallback, + GetThemeCallback, + GetCustomCommandsCallback, + GetHistoryCallback, + GetWebClientsCallback, +} from './types'; + +// ============ Handlers ============ +export { WebSocketMessageHandler } from './handlers'; +export type { SessionDetailForHandler, MessageHandlerCallbacks } from './handlers'; + +// ============ Services ============ +export { BroadcastService } from './services'; +export type { WebClientInfo } from './services'; + +// ============ Routes ============ +export { ApiRoutes, StaticRoutes, WsRoute } from './routes'; +export type { ApiRouteCallbacks, WsRouteCallbacks, WsSessionData } from './routes'; diff --git a/src/main/web-server/routes/apiRoutes.ts b/src/main/web-server/routes/apiRoutes.ts index 5f03b5c3..ee720781 100644 --- a/src/main/web-server/routes/apiRoutes.ts +++ b/src/main/web-server/routes/apiRoutes.ts @@ -16,118 +16,23 @@ import { FastifyInstance } from 'fastify'; import { HistoryEntry } from '../../../shared/types'; import { logger } from '../../utils/logger'; +import type { Theme, SessionData, SessionDetail, LiveSessionInfo, RateLimitConfig } from '../types'; + +// Re-export types for backwards compatibility +export type { + Theme, + SessionUsageStats, + LastResponsePreview, + AITabData, + SessionData, + SessionDetail, + LiveSessionInfo, + RateLimitConfig, +} from '../types'; // Logger context for all API route logs const LOG_CONTEXT = 'WebServer:API'; -/** - * Usage stats type for session cost/token tracking - */ -export interface SessionUsageStats { - inputTokens?: number; - outputTokens?: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - totalCostUsd?: number; - contextWindow?: number; -} - -/** - * Last response type for mobile preview (truncated to save bandwidth) - */ -export interface LastResponsePreview { - text: string; // First 3 lines or ~500 chars of the last AI response - timestamp: number; - source: 'stdout' | 'stderr' | 'system'; - fullLength: number; // Total length of the original response -} - -/** - * AI Tab type for multi-tab support within a Maestro session - */ -export interface AITabData { - id: string; - agentSessionId: string | null; - name: string | null; - starred: boolean; - inputValue: string; - usageStats?: SessionUsageStats | null; - createdAt: number; - state: 'idle' | 'busy'; - thinkingStartTime?: number | null; -} - -/** - * Session data returned by getSessions callback - */ -export interface SessionData { - id: string; - name: string; - toolType: string; - state: string; - inputMode: string; - cwd: string; - groupId: string | null; - groupName: string | null; - groupEmoji: string | null; - usageStats?: SessionUsageStats | null; - lastResponse?: LastResponsePreview | null; - agentSessionId?: string | null; - thinkingStartTime?: number | null; - aiTabs?: AITabData[]; - activeTabId?: string; - bookmarked?: boolean; -} - -/** - * Session detail type for single session endpoint - */ -export interface SessionDetail { - id: string; - name: string; - toolType: string; - state: string; - inputMode: string; - cwd: string; - aiLogs?: Array<{ timestamp: number; content: string; type?: string }>; - shellLogs?: Array<{ timestamp: number; content: string; type?: string }>; - usageStats?: { - inputTokens?: number; - outputTokens?: number; - totalCost?: number; - }; - agentSessionId?: string; - isGitRepo?: boolean; - activeTabId?: string; -} - -// HistoryEntry is imported from shared/types.ts as the canonical type - -/** - * Live session info for enriching sessions - */ -export interface LiveSessionInfo { - sessionId: string; - agentSessionId?: string; - enabledAt: number; -} - -/** - * Rate limit configuration - */ -export interface RateLimitConfig { - max: number; - timeWindow: number; - maxPost: number; - enabled: boolean; -} - -/** - * Theme type (imported from shared, re-exported for convenience) - */ -export type { Theme } from '../../../shared/theme-types'; -import type { Theme } from '../../../shared/theme-types'; - /** * Callbacks required by API routes */ diff --git a/src/main/web-server/routes/staticRoutes.ts b/src/main/web-server/routes/staticRoutes.ts index b50957fb..135be5b7 100644 --- a/src/main/web-server/routes/staticRoutes.ts +++ b/src/main/web-server/routes/staticRoutes.ts @@ -47,6 +47,22 @@ export class StaticRoutes { return token === this.securityToken; } + /** + * Sanitize a string for safe injection into HTML/JavaScript + * Only allows alphanumeric characters, hyphens, and underscores (valid for UUIDs and IDs) + * Returns null if the input contains invalid characters + */ + private sanitizeId(input: string | undefined | null): string | null { + if (!input) return null; + // Only allow characters that are safe for UUID-style IDs + // This prevents XSS attacks via malicious sessionId/tabId parameters + if (!/^[a-zA-Z0-9_-]+$/.test(input)) { + logger.warn(`Rejected potentially unsafe ID: ${input.substring(0, 50)}`, LOG_CONTEXT); + return null; + } + return input; + } + /** * Serve the index.html file for SPA routes * Rewrites asset paths to include the security token @@ -79,12 +95,17 @@ export class StaticRoutes { html = html.replace(/\.\/icons\//g, `/${this.securityToken}/icons/`); html = html.replace(/\.\/sw\.js/g, `/${this.securityToken}/sw.js`); + // Sanitize sessionId and tabId to prevent XSS attacks + // Only allow safe characters (alphanumeric, hyphens, underscores) + const safeSessionId = this.sanitizeId(sessionId); + const safeTabId = this.sanitizeId(tabId); + // Inject config for the React app to know the token and session context const configScript = `