mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
457
src/__tests__/main/web-server/routes/apiRoutes.test.ts
Normal file
457
src/__tests__/main/web-server/routes/apiRoutes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
239
src/__tests__/main/web-server/routes/staticRoutes.test.ts
Normal file
239
src/__tests__/main/web-server/routes/staticRoutes.test.ts
Normal 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('<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();
|
||||
});
|
||||
});
|
||||
});
|
||||
497
src/__tests__/main/web-server/routes/wsRoute.test.ts
Normal file
497
src/__tests__/main/web-server/routes/wsRoute.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
62
src/main/web-server/index.ts
Normal file
62
src/main/web-server/index.ts
Normal 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';
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
302
src/main/web-server/types.ts
Normal file
302
src/main/web-server/types.ts
Normal 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>;
|
||||
@@ -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
31
vitest.e2e.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user