refactor: restructure web-server module with security and memory leak fixes

- Move WebServer class to dedicated file and add module index
- Extract shared types to centralized types.ts
- Fix XSS vulnerability by sanitizing sessionId/tabId in URL parameters
- Fix IPC listener memory leak with proper cleanup on timeout
- Add autoRunStates cleanup when sessions go offline
- Refactor message handlers with send() and sendError() helpers
- Add XSS sanitization tests and e2e test configuration
This commit is contained in:
Raza Rauf
2026-01-29 01:19:56 +05:00
committed by Pedram Amini
parent 96357d210e
commit 68945cb946
14 changed files with 1809 additions and 665 deletions

View File

@@ -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<string, { handler: Function; config?: any }> = 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<typeof createMockFastify>;
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);
});
});
});

View File

@@ -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<string, { handler: Function }> = 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<typeof createMockFastify>;
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('<script>alert(1)</script>')).toBeNull();
expect(sanitizeId('session<script>')).toBeNull();
expect(sanitizeId('tab</script>')).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('&lt;script&gt;')).toBeNull();
expect(sanitizeId('session&#x3C;')).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();
});
});
});

View File

@@ -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<string, Function[]> = 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<string, { handler: Function; options?: any }> = 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<typeof createMockFastify>;
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']);
});
});
});

View File

@@ -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';

View File

@@ -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<boolean>;
// 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<boolean>;
// 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<boolean>;
// 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<boolean>;
// Tab operation callbacks for multi-tab support
export type SelectTabCallback = (sessionId: string, tabId: string) => Promise<boolean>;
export type NewTabCallback = (sessionId: string) => Promise<{ tabId: string } | null>;
export type CloseTabCallback = (sessionId: string, tabId: string) => Promise<boolean>;
export type RenameTabCallback = (
sessionId: string,
tabId: string,
newName: string
) => Promise<boolean>;
// 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;

View File

@@ -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<string, unknown>): 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<string, unknown>): 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 });
}
}

View File

@@ -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';

View File

@@ -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
*/

View File

@@ -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 = `<script>
window.__MAESTRO_CONFIG__ = {
securityToken: "${this.securityToken}",
sessionId: ${sessionId ? `"${sessionId}"` : 'null'},
tabId: ${tabId ? `"${tabId}"` : 'null'},
sessionId: ${safeSessionId ? `"${safeSessionId}"` : 'null'},
tabId: ${safeTabId ? `"${safeTabId}"` : 'null'},
apiBase: "/${this.securityToken}/api",
wsUrl: "/${this.securityToken}/ws"
};

View File

@@ -17,51 +17,33 @@
import { FastifyInstance } from 'fastify';
import { logger } from '../../utils/logger';
import { WebClient, WebClientMessage } from '../handlers';
import { AutoRunState } from '../services/broadcastService';
import type { Theme } from '../../../shared/theme-types';
import type {
Theme,
WebClient,
WebClientMessage,
LiveSessionInfo,
CustomAICommand,
AutoRunState,
SessionData,
} from '../types';
// Re-export types for backwards compatibility
export type { LiveSessionInfo, CustomAICommand } from '../types';
// Logger context for all WebSocket route logs
const LOG_CONTEXT = 'WebServer:WS';
/**
* Live session info for enriching sessions
* Session data for WebSocket initial sync.
* Uses SessionData as the base type.
*/
export interface LiveSessionInfo {
sessionId: string;
agentSessionId?: string;
enabledAt: number;
}
/**
* Custom AI command definition
*/
export interface CustomAICommand {
id: string;
command: string;
description: string;
prompt: string;
}
/**
* Session data for WebSocket initial sync
*/
export interface WsSessionData {
id: string;
name: string;
toolType: string;
state: string;
inputMode: string;
cwd: string;
agentSessionId?: string | null;
[key: string]: unknown;
}
export type WsSessionData = SessionData;
/**
* Callbacks required by WebSocket route
*/
export interface WsRouteCallbacks {
getSessions: () => WsSessionData[];
getSessions: () => SessionData[];
getTheme: () => Theme | null;
getCustomCommands: () => CustomAICommand[];
getAutoRunStates: () => Map<string, AutoRunState>;

View File

@@ -20,96 +20,33 @@
*/
import { WebSocket } from 'ws';
import type { Theme } from '../../../shared/theme-types';
import { logger } from '../../utils/logger';
import type {
Theme,
WebClient,
CustomAICommand,
AITabData,
SessionBroadcastData,
AutoRunState,
CliActivity,
} from '../types';
// Re-export types for backwards compatibility
export type {
CustomAICommand,
AITabData,
SessionBroadcastData,
AutoRunState,
CliActivity,
} from '../types';
// Logger context for broadcast service logs
const LOG_CONTEXT = 'BroadcastService';
/**
* Web client connection info (shared with messageHandlers)
* Web client connection info (alias for backwards compatibility)
*/
export interface WebClientInfo {
socket: WebSocket;
id: string;
connectedAt: number;
subscribedSessionId?: string;
}
/**
* Custom AI command definition (matches renderer's CustomAICommand)
*/
export interface CustomAICommand {
id: string;
command: string;
description: string;
prompt: string;
}
/**
* AI Tab data for multi-tab support within a Maestro session
*/
export interface AITabData {
id: string;
agentSessionId: string | null;
name: string | null;
starred: boolean;
inputValue: string;
usageStats?: {
inputTokens?: number;
outputTokens?: number;
cacheReadInputTokens?: number;
cacheCreationInputTokens?: number;
totalCostUsd?: number;
contextWindow?: number;
} | null;
createdAt: number;
state: 'idle' | 'busy';
thinkingStartTime?: number | null;
}
/**
* Session data for broadcast messages
*/
export interface SessionBroadcastData {
id: string;
name: string;
toolType: string;
state: string;
inputMode: string;
cwd: string;
groupId?: string | null;
groupName?: string | null;
groupEmoji?: string | null;
// Worktree subagent support
parentSessionId?: string | null;
worktreeBranch?: string | null;
}
/**
* Auto Run state for broadcast messages
*/
export interface AutoRunState {
isRunning: boolean;
totalTasks: number;
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
// Multi-document progress fields
totalDocuments?: number; // Total number of documents in the run
currentDocumentIndex?: number; // Current document being processed (0-based)
totalTasksAcrossAllDocs?: number; // Total tasks across all documents
completedTasksAcrossAllDocs?: number; // Completed tasks across all documents
}
/**
* CLI activity data for session state broadcasts
*/
export interface CliActivity {
playbookId: string;
playbookName: string;
startedAt: number;
}
export type WebClientInfo = WebClient;
/**
* Callback to get all connected web clients

View File

@@ -0,0 +1,302 @@
/**
* Shared type definitions for the web server module.
* All web server components should import types from this file to avoid duplication.
*/
import type { WebSocket } from 'ws';
import type { Theme } from '../../shared/theme-types';
// Re-export Theme for convenience
export type { Theme } from '../../shared/theme-types';
// =============================================================================
// Core Types
// =============================================================================
/**
* 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 {
/** First 3 lines or ~500 chars of the last AI response */
text: string;
timestamp: number;
source: 'stdout' | 'stderr' | 'system';
/** Total length of the original response */
fullLength: number;
}
/**
* 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;
}
/**
* Live session info for tracking live-enabled sessions.
*/
export interface LiveSessionInfo {
sessionId: string;
agentSessionId?: string;
enabledAt: number;
}
/**
* Custom AI command definition.
*/
export interface CustomAICommand {
id: string;
command: string;
description: string;
prompt: string;
}
/**
* Rate limiting configuration for web server endpoints.
*/
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;
}
// =============================================================================
// Session Types
// =============================================================================
/**
* 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;
/** Timestamp when AI started thinking (for elapsed time display) */
thinkingStartTime?: number | null;
aiTabs?: AITabData[];
activeTabId?: string;
/** Whether session is bookmarked (shows in Bookmarks group) */
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;
}
/**
* Session data for broadcast messages.
*/
export interface SessionBroadcastData {
id: string;
name: string;
toolType: string;
state: string;
inputMode: string;
cwd: string;
groupId?: string | null;
groupName?: string | null;
groupEmoji?: string | null;
/** Worktree subagent support */
parentSessionId?: string | null;
worktreeBranch?: string | null;
}
// =============================================================================
// AutoRun Types
// =============================================================================
/**
* Auto Run state for broadcast messages.
*/
export interface AutoRunState {
isRunning: boolean;
totalTasks: number;
completedTasks: number;
currentTaskIndex: number;
isStopping?: boolean;
/** Total number of documents in the run (multi-document progress) */
totalDocuments?: number;
/** Current document being processed (0-based, multi-document progress) */
currentDocumentIndex?: number;
/** Total tasks across all documents (multi-document progress) */
totalTasksAcrossAllDocs?: number;
/** Completed tasks across all documents (multi-document progress) */
completedTasksAcrossAllDocs?: number;
}
/**
* CLI activity data for session state broadcasts.
*/
export interface CliActivity {
playbookId: string;
playbookName: string;
startedAt: number;
}
// =============================================================================
// WebSocket Client Types
// =============================================================================
/**
* Web client connection info.
*/
export interface WebClient {
socket: WebSocket;
id: string;
connectedAt: number;
subscribedSessionId?: string;
}
/**
* Web client message interface.
*/
export interface WebClientMessage {
type: string;
sessionId?: string;
tabId?: string;
command?: string;
mode?: 'ai' | 'terminal';
inputMode?: 'ai' | 'terminal';
newName?: string;
[key: string]: unknown;
}
// =============================================================================
// Callback Types
// =============================================================================
/**
* Callback type for fetching sessions data.
*/
export type GetSessionsCallback = () => SessionData[];
/**
* 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<boolean>;
/**
* 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<boolean>;
/**
* 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<boolean>;
/**
* 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<boolean>;
/**
* Tab operation callbacks for multi-tab support.
*/
export type SelectTabCallback = (sessionId: string, tabId: string) => Promise<boolean>;
export type NewTabCallback = (sessionId: string) => Promise<{ tabId: string } | null>;
export type CloseTabCallback = (sessionId: string, tabId: string) => Promise<boolean>;
export type RenameTabCallback = (
sessionId: string,
tabId: string,
newName: string
) => Promise<boolean>;
/**
* Callback type for fetching current theme.
*/
export type GetThemeCallback = () => Theme | null;
/**
* 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
) => import('../../shared/types').HistoryEntry[];
/**
* Callback to get all connected web clients.
*/
export type GetWebClientsCallback = () => Map<string, WebClient>;

View File

@@ -4,7 +4,7 @@
*/
import { BrowserWindow, ipcMain } from 'electron';
import { WebServer } from '../web-server';
import { WebServer } from './WebServer';
import { getThemeById } from '../themes';
import { getHistoryManager } from '../history-manager';
import { logger } from '../utils/logger';
@@ -348,12 +348,26 @@ export function createWebServerFactory(deps: WebServerFactoryDependencies) {
// Use invoke for synchronous response with tab ID
return new Promise((resolve) => {
const responseChannel = `remote:newTab:response:${Date.now()}`;
ipcMain.once(responseChannel, (_event, result) => {
let resolved = false;
const handleResponse = (_event: Electron.IpcMainEvent, result: any) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
resolve(result);
});
};
ipcMain.once(responseChannel, handleResponse);
mainWindow.webContents.send('remote:newTab', sessionId, responseChannel);
// Timeout after 5 seconds
setTimeout(() => resolve(null), 5000);
// Timeout after 5 seconds - clean up the listener to prevent memory leak
const timeoutId = setTimeout(() => {
if (resolved) return;
resolved = true;
ipcMain.removeListener(responseChannel, handleResponse);
logger.warn(`newTab callback timed out for session ${sessionId}`, 'WebServer');
resolve(null);
}, 5000);
});
});

31
vitest.e2e.config.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* @file vitest.e2e.config.ts
* @description Vitest configuration for e2e tests.
*
* E2E tests use real WebSocket connections and actual server instances.
* These tests are meant to be run manually or in dedicated CI jobs.
*
* Run with: npx vitest run --config vitest.e2e.config.ts
*/
import { defineConfig } from 'vitest/config';
import path from 'path';
export default defineConfig({
test: {
include: ['src/__tests__/e2e/**/*.test.ts'],
environment: 'node',
testTimeout: 30000,
hookTimeout: 10000,
globals: true,
reporters: ['verbose'],
sequence: {
shuffle: false, // Run tests in order
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});