mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
* feat: add auto-login for dev mode and fix log box formatting Add AUTOMAKER_AUTO_LOGIN environment variable that, when set to 'true', automatically creates a session for web mode users without requiring them to enter the API key. Useful for development environments. Also fix formatting issues in console log boxes: - API Key box: add right border, show auto-login status and tips - Claude auth warning: add separator line, fix emoji spacing - Server info box: use consistent 71-char width, proper padding - Port conflict error: use same width, proper dynamic padding Environment variables: - AUTOMAKER_AUTO_LOGIN=true: Skip login prompt, auto-create session - AUTOMAKER_API_KEY: Use a fixed API key (existing) - AUTOMAKER_HIDE_API_KEY=true: Hide the API key banner (existing) * fix: add production safeguard to auto-login and extract log box constant - Add NODE_ENV !== 'production' check to prevent auto-login in production - Extract magic number 67 to BOX_CONTENT_WIDTH constant in auth.ts and index.ts - Document AUTOMAKER_AUTO_LOGIN env var in CLAUDE.md and README.md
782 lines
30 KiB
TypeScript
782 lines
30 KiB
TypeScript
/**
|
|
* Automaker Backend Server
|
|
*
|
|
* Provides HTTP/WebSocket API for both web and Electron modes.
|
|
* In Electron mode, this server runs locally.
|
|
* In web mode, this server runs on a remote host.
|
|
*/
|
|
|
|
import express from 'express';
|
|
import cors from 'cors';
|
|
import morgan from 'morgan';
|
|
import cookieParser from 'cookie-parser';
|
|
import cookie from 'cookie';
|
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
import { createServer } from 'http';
|
|
import dotenv from 'dotenv';
|
|
|
|
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
|
import { initAllowedPaths } from '@automaker/platform';
|
|
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
|
|
|
|
const logger = createLogger('Server');
|
|
|
|
/**
|
|
* Map server log level string to LogLevel enum
|
|
*/
|
|
const LOG_LEVEL_MAP: Record<string, LogLevel> = {
|
|
error: LogLevel.ERROR,
|
|
warn: LogLevel.WARN,
|
|
info: LogLevel.INFO,
|
|
debug: LogLevel.DEBUG,
|
|
};
|
|
import { authMiddleware, validateWsConnectionToken, checkRawAuthentication } from './lib/auth.js';
|
|
import { requireJsonContentType } from './middleware/require-json-content-type.js';
|
|
import { createAuthRoutes } from './routes/auth/index.js';
|
|
import { createFsRoutes } from './routes/fs/index.js';
|
|
import { createHealthRoutes, createDetailedHandler } from './routes/health/index.js';
|
|
import { createAgentRoutes } from './routes/agent/index.js';
|
|
import { createSessionsRoutes } from './routes/sessions/index.js';
|
|
import { createFeaturesRoutes } from './routes/features/index.js';
|
|
import { createAutoModeRoutes } from './routes/auto-mode/index.js';
|
|
import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
|
|
import { createWorktreeRoutes } from './routes/worktree/index.js';
|
|
import { createGitRoutes } from './routes/git/index.js';
|
|
import { createSetupRoutes } from './routes/setup/index.js';
|
|
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
|
|
import { createModelsRoutes } from './routes/models/index.js';
|
|
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
|
import { createWorkspaceRoutes } from './routes/workspace/index.js';
|
|
import { createTemplatesRoutes } from './routes/templates/index.js';
|
|
import {
|
|
createTerminalRoutes,
|
|
validateTerminalToken,
|
|
isTerminalEnabled,
|
|
isTerminalPasswordRequired,
|
|
} from './routes/terminal/index.js';
|
|
import { createSettingsRoutes } from './routes/settings/index.js';
|
|
import { AgentService } from './services/agent-service.js';
|
|
import { FeatureLoader } from './services/feature-loader.js';
|
|
import { AutoModeService } from './services/auto-mode-service.js';
|
|
import { getTerminalService } from './services/terminal-service.js';
|
|
import { SettingsService } from './services/settings-service.js';
|
|
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
|
import { createClaudeRoutes } from './routes/claude/index.js';
|
|
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
|
import { createCodexRoutes } from './routes/codex/index.js';
|
|
import { CodexUsageService } from './services/codex-usage-service.js';
|
|
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
|
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
|
import { createGitHubRoutes } from './routes/github/index.js';
|
|
import { createContextRoutes } from './routes/context/index.js';
|
|
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
|
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
|
import { createMCPRoutes } from './routes/mcp/index.js';
|
|
import { MCPTestService } from './services/mcp-test-service.js';
|
|
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
|
import { pipelineService } from './services/pipeline-service.js';
|
|
import { createIdeationRoutes } from './routes/ideation/index.js';
|
|
import { IdeationService } from './services/ideation-service.js';
|
|
import { getDevServerService } from './services/dev-server-service.js';
|
|
import { eventHookService } from './services/event-hook-service.js';
|
|
import { createNotificationsRoutes } from './routes/notifications/index.js';
|
|
import { getNotificationService } from './services/notification-service.js';
|
|
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
|
import { getEventHistoryService } from './services/event-history-service.js';
|
|
|
|
// Load environment variables
|
|
dotenv.config();
|
|
|
|
const PORT = parseInt(process.env.PORT || '3008', 10);
|
|
const HOST = process.env.HOST || '0.0.0.0';
|
|
const HOSTNAME = process.env.HOSTNAME || 'localhost';
|
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
|
logger.info('[SERVER_STARTUP] process.env.DATA_DIR:', process.env.DATA_DIR);
|
|
logger.info('[SERVER_STARTUP] Resolved DATA_DIR:', DATA_DIR);
|
|
logger.info('[SERVER_STARTUP] process.cwd():', process.cwd());
|
|
const ENABLE_REQUEST_LOGGING_DEFAULT = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
|
|
|
// Runtime-configurable request logging flag (can be changed via settings)
|
|
let requestLoggingEnabled = ENABLE_REQUEST_LOGGING_DEFAULT;
|
|
|
|
/**
|
|
* Enable or disable HTTP request logging at runtime
|
|
*/
|
|
export function setRequestLoggingEnabled(enabled: boolean): void {
|
|
requestLoggingEnabled = enabled;
|
|
}
|
|
|
|
/**
|
|
* Get current request logging state
|
|
*/
|
|
export function isRequestLoggingEnabled(): boolean {
|
|
return requestLoggingEnabled;
|
|
}
|
|
|
|
// Width for log box content (excluding borders)
|
|
const BOX_CONTENT_WIDTH = 67;
|
|
|
|
// Check for required environment variables
|
|
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
|
|
|
if (!hasAnthropicKey) {
|
|
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
|
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
|
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
|
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
|
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
|
|
BOX_CONTENT_WIDTH
|
|
);
|
|
|
|
logger.warn(`
|
|
╔═════════════════════════════════════════════════════════════════════╗
|
|
║ ${wHeader}║
|
|
╠═════════════════════════════════════════════════════════════════════╣
|
|
║ ║
|
|
║ ${w1}║
|
|
║ ║
|
|
║ ${w2}║
|
|
║ ${w3}║
|
|
║ ║
|
|
║ ${w4}║
|
|
║ ║
|
|
╚═════════════════════════════════════════════════════════════════════╝
|
|
`);
|
|
} else {
|
|
logger.info('✓ ANTHROPIC_API_KEY detected');
|
|
}
|
|
|
|
// Initialize security
|
|
initAllowedPaths();
|
|
|
|
// Create Express app
|
|
const app = express();
|
|
|
|
// Middleware
|
|
// Custom colored logger showing only endpoint and status code (dynamically configurable)
|
|
morgan.token('status-colored', (_req, res) => {
|
|
const status = res.statusCode;
|
|
if (status >= 500) return `\x1b[31m${status}\x1b[0m`; // Red for server errors
|
|
if (status >= 400) return `\x1b[33m${status}\x1b[0m`; // Yellow for client errors
|
|
if (status >= 300) return `\x1b[36m${status}\x1b[0m`; // Cyan for redirects
|
|
return `\x1b[32m${status}\x1b[0m`; // Green for success
|
|
});
|
|
|
|
app.use(
|
|
morgan(':method :url :status-colored', {
|
|
// Skip when request logging is disabled or for health check endpoints
|
|
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
|
|
})
|
|
);
|
|
// CORS configuration
|
|
// When using credentials (cookies), origin cannot be '*'
|
|
// We dynamically allow the requesting origin for local development
|
|
app.use(
|
|
cors({
|
|
origin: (origin, callback) => {
|
|
// Allow requests with no origin (like mobile apps, curl, Electron)
|
|
if (!origin) {
|
|
callback(null, true);
|
|
return;
|
|
}
|
|
|
|
// If CORS_ORIGIN is set, use it (can be comma-separated list)
|
|
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim());
|
|
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') {
|
|
if (allowedOrigins.includes(origin)) {
|
|
callback(null, origin);
|
|
} else {
|
|
callback(new Error('Not allowed by CORS'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// For local development, allow all localhost/loopback origins (any port)
|
|
try {
|
|
const url = new URL(origin);
|
|
const hostname = url.hostname;
|
|
|
|
if (
|
|
hostname === 'localhost' ||
|
|
hostname === '127.0.0.1' ||
|
|
hostname === '::1' ||
|
|
hostname === '0.0.0.0' ||
|
|
hostname.startsWith('192.168.') ||
|
|
hostname.startsWith('10.') ||
|
|
hostname.startsWith('172.')
|
|
) {
|
|
callback(null, origin);
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
// Ignore URL parsing errors
|
|
}
|
|
|
|
// Reject other origins by default for security
|
|
callback(new Error('Not allowed by CORS'));
|
|
},
|
|
credentials: true,
|
|
})
|
|
);
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(cookieParser());
|
|
|
|
// Create shared event emitter for streaming
|
|
const events: EventEmitter = createEventEmitter();
|
|
|
|
// Create services
|
|
// Note: settingsService is created first so it can be injected into other services
|
|
const settingsService = new SettingsService(DATA_DIR);
|
|
const agentService = new AgentService(DATA_DIR, events, settingsService);
|
|
const featureLoader = new FeatureLoader();
|
|
const autoModeService = new AutoModeService(events, settingsService);
|
|
const claudeUsageService = new ClaudeUsageService();
|
|
const codexAppServerService = new CodexAppServerService();
|
|
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
|
const codexUsageService = new CodexUsageService(codexAppServerService);
|
|
const mcpTestService = new MCPTestService(settingsService);
|
|
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
|
|
|
// Initialize DevServerService with event emitter for real-time log streaming
|
|
const devServerService = getDevServerService();
|
|
devServerService.setEventEmitter(events);
|
|
|
|
// Initialize Notification Service with event emitter for real-time updates
|
|
const notificationService = getNotificationService();
|
|
notificationService.setEventEmitter(events);
|
|
|
|
// Initialize Event History Service
|
|
const eventHistoryService = getEventHistoryService();
|
|
|
|
// Initialize Event Hook Service for custom event triggers (with history storage)
|
|
eventHookService.initialize(events, settingsService, eventHistoryService);
|
|
|
|
// Initialize services
|
|
(async () => {
|
|
// Migrate settings from legacy Electron userData location if needed
|
|
// This handles users upgrading from versions that stored settings in ~/.config/Automaker (Linux),
|
|
// ~/Library/Application Support/Automaker (macOS), or %APPDATA%\Automaker (Windows)
|
|
// to the new shared ./data directory
|
|
try {
|
|
const migrationResult = await settingsService.migrateFromLegacyElectronPath();
|
|
if (migrationResult.migrated) {
|
|
logger.info(`Settings migrated from legacy location: ${migrationResult.legacyPath}`);
|
|
logger.info(`Migrated files: ${migrationResult.migratedFiles.join(', ')}`);
|
|
}
|
|
if (migrationResult.errors.length > 0) {
|
|
logger.warn('Migration errors:', migrationResult.errors);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('Failed to check for legacy settings migration:', err);
|
|
}
|
|
|
|
// Apply logging settings from saved settings
|
|
try {
|
|
const settings = await settingsService.getGlobalSettings();
|
|
if (settings.serverLogLevel && LOG_LEVEL_MAP[settings.serverLogLevel] !== undefined) {
|
|
setLogLevel(LOG_LEVEL_MAP[settings.serverLogLevel]);
|
|
logger.info(`Server log level set to: ${settings.serverLogLevel}`);
|
|
}
|
|
// Apply request logging setting (default true if not set)
|
|
const enableRequestLog = settings.enableRequestLogging ?? true;
|
|
setRequestLoggingEnabled(enableRequestLog);
|
|
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
|
|
} catch (err) {
|
|
logger.warn('Failed to load logging settings, using defaults');
|
|
}
|
|
|
|
await agentService.initialize();
|
|
logger.info('Agent service initialized');
|
|
|
|
// Bootstrap Codex model cache in background (don't block server startup)
|
|
void codexModelCacheService.getModels().catch((err) => {
|
|
logger.error('Failed to bootstrap Codex model cache:', err);
|
|
});
|
|
})();
|
|
|
|
// Run stale validation cleanup every hour to prevent memory leaks from crashed validations
|
|
const VALIDATION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
|
setInterval(() => {
|
|
const cleaned = cleanupStaleValidations();
|
|
if (cleaned > 0) {
|
|
logger.info(`Cleaned up ${cleaned} stale validation entries`);
|
|
}
|
|
}, VALIDATION_CLEANUP_INTERVAL_MS);
|
|
|
|
// Require Content-Type: application/json for all API POST/PUT/PATCH requests
|
|
// This helps prevent CSRF and content-type confusion attacks
|
|
app.use('/api', requireJsonContentType);
|
|
|
|
// Mount API routes - health, auth, and setup are unauthenticated
|
|
app.use('/api/health', createHealthRoutes());
|
|
app.use('/api/auth', createAuthRoutes());
|
|
app.use('/api/setup', createSetupRoutes());
|
|
|
|
// Apply authentication to all other routes
|
|
app.use('/api', authMiddleware);
|
|
|
|
// Protected health endpoint with detailed info
|
|
app.get('/api/health/detailed', createDetailedHandler());
|
|
|
|
app.use('/api/fs', createFsRoutes(events));
|
|
app.use('/api/agent', createAgentRoutes(agentService, events));
|
|
app.use('/api/sessions', createSessionsRoutes(agentService));
|
|
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
|
|
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
|
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
|
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
|
app.use('/api/git', createGitRoutes());
|
|
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
|
app.use('/api/models', createModelsRoutes());
|
|
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
|
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
|
app.use('/api/workspace', createWorkspaceRoutes());
|
|
app.use('/api/templates', createTemplatesRoutes());
|
|
app.use('/api/terminal', createTerminalRoutes());
|
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
|
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
|
app.use('/api/context', createContextRoutes(settingsService));
|
|
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
|
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
|
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
|
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
|
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
|
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
|
|
|
// Create HTTP server
|
|
const server = createServer(app);
|
|
|
|
// WebSocket servers using noServer mode for proper multi-path support
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
const terminalWss = new WebSocketServer({ noServer: true });
|
|
const terminalService = getTerminalService();
|
|
|
|
/**
|
|
* Authenticate WebSocket upgrade requests
|
|
* Checks for API key in header/query, session token in header/query, OR valid session cookie
|
|
*/
|
|
function authenticateWebSocket(request: import('http').IncomingMessage): boolean {
|
|
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
|
|
|
// Convert URL search params to query object
|
|
const query: Record<string, string | undefined> = {};
|
|
url.searchParams.forEach((value, key) => {
|
|
query[key] = value;
|
|
});
|
|
|
|
// Parse cookies from header
|
|
const cookieHeader = request.headers.cookie;
|
|
const cookies = cookieHeader ? cookie.parse(cookieHeader) : {};
|
|
|
|
// Use shared authentication logic for standard auth methods
|
|
if (
|
|
checkRawAuthentication(
|
|
request.headers as Record<string, string | string[] | undefined>,
|
|
query,
|
|
cookies
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Additionally check for short-lived WebSocket connection token (WebSocket-specific)
|
|
const wsToken = url.searchParams.get('wsToken');
|
|
if (wsToken && validateWsConnectionToken(wsToken)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Handle HTTP upgrade requests manually to route to correct WebSocket server
|
|
server.on('upgrade', (request, socket, head) => {
|
|
const { pathname } = new URL(request.url || '', `http://${request.headers.host}`);
|
|
|
|
// Authenticate all WebSocket connections
|
|
if (!authenticateWebSocket(request)) {
|
|
logger.info('Authentication failed, rejecting connection');
|
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
if (pathname === '/api/events') {
|
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
wss.emit('connection', ws, request);
|
|
});
|
|
} else if (pathname === '/api/terminal/ws') {
|
|
terminalWss.handleUpgrade(request, socket, head, (ws) => {
|
|
terminalWss.emit('connection', ws, request);
|
|
});
|
|
} else {
|
|
socket.destroy();
|
|
}
|
|
});
|
|
|
|
// Events WebSocket connection handler
|
|
wss.on('connection', (ws: WebSocket) => {
|
|
logger.info('Client connected, ready state:', ws.readyState);
|
|
|
|
// Subscribe to all events and forward to this client
|
|
const unsubscribe = events.subscribe((type, payload) => {
|
|
logger.info('Event received:', {
|
|
type,
|
|
hasPayload: !!payload,
|
|
payloadKeys: payload ? Object.keys(payload) : [],
|
|
wsReadyState: ws.readyState,
|
|
wsOpen: ws.readyState === WebSocket.OPEN,
|
|
});
|
|
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
const message = JSON.stringify({ type, payload });
|
|
logger.info('Sending event to client:', {
|
|
type,
|
|
messageLength: message.length,
|
|
sessionId: (payload as any)?.sessionId,
|
|
});
|
|
ws.send(message);
|
|
} else {
|
|
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
logger.info('Client disconnected');
|
|
unsubscribe();
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
logger.error('ERROR:', error);
|
|
unsubscribe();
|
|
});
|
|
});
|
|
|
|
// Track WebSocket connections per session
|
|
const terminalConnections: Map<string, Set<WebSocket>> = new Map();
|
|
// Track last resize dimensions per session to deduplicate resize messages
|
|
const lastResizeDimensions: Map<string, { cols: number; rows: number }> = new Map();
|
|
// Track last resize timestamp to rate-limit resize operations (prevents resize storm)
|
|
const lastResizeTime: Map<string, number> = new Map();
|
|
const RESIZE_MIN_INTERVAL_MS = 100; // Minimum 100ms between resize operations
|
|
|
|
// Clean up resize tracking when sessions actually exit (not just when connections close)
|
|
terminalService.onExit((sessionId) => {
|
|
lastResizeDimensions.delete(sessionId);
|
|
lastResizeTime.delete(sessionId);
|
|
terminalConnections.delete(sessionId);
|
|
});
|
|
|
|
// Terminal WebSocket connection handler
|
|
terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage) => {
|
|
// Parse URL to get session ID and token
|
|
const url = new URL(req.url || '', `http://${req.headers.host}`);
|
|
const sessionId = url.searchParams.get('sessionId');
|
|
const token = url.searchParams.get('token');
|
|
|
|
logger.info(`Connection attempt for session: ${sessionId}`);
|
|
|
|
// Check if terminal is enabled
|
|
if (!isTerminalEnabled()) {
|
|
logger.info('Terminal is disabled');
|
|
ws.close(4003, 'Terminal access is disabled');
|
|
return;
|
|
}
|
|
|
|
// Validate token if password is required
|
|
if (isTerminalPasswordRequired() && !validateTerminalToken(token || undefined)) {
|
|
logger.info('Invalid or missing token');
|
|
ws.close(4001, 'Authentication required');
|
|
return;
|
|
}
|
|
|
|
if (!sessionId) {
|
|
logger.info('No session ID provided');
|
|
ws.close(4002, 'Session ID required');
|
|
return;
|
|
}
|
|
|
|
// Check if session exists
|
|
const session = terminalService.getSession(sessionId);
|
|
if (!session) {
|
|
logger.info(`Session ${sessionId} not found`);
|
|
ws.close(4004, 'Session not found');
|
|
return;
|
|
}
|
|
|
|
logger.info(`Client connected to session ${sessionId}`);
|
|
|
|
// Track this connection
|
|
if (!terminalConnections.has(sessionId)) {
|
|
terminalConnections.set(sessionId, new Set());
|
|
}
|
|
terminalConnections.get(sessionId)!.add(ws);
|
|
|
|
// Send initial connection success FIRST
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'connected',
|
|
sessionId,
|
|
shell: session.shell,
|
|
cwd: session.cwd,
|
|
})
|
|
);
|
|
|
|
// Send scrollback buffer BEFORE subscribing to prevent race condition
|
|
// Also clear pending output buffer to prevent duplicates from throttled flush
|
|
const scrollback = terminalService.getScrollbackAndClearPending(sessionId);
|
|
if (scrollback && scrollback.length > 0) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'scrollback',
|
|
data: scrollback,
|
|
})
|
|
);
|
|
}
|
|
|
|
// NOW subscribe to terminal data (after scrollback is sent)
|
|
const unsubscribeData = terminalService.onData((sid, data) => {
|
|
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'data', data }));
|
|
}
|
|
});
|
|
|
|
// Subscribe to terminal exit
|
|
const unsubscribeExit = terminalService.onExit((sid, exitCode) => {
|
|
if (sid === sessionId && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'exit', exitCode }));
|
|
ws.close(1000, 'Session ended');
|
|
}
|
|
});
|
|
|
|
// Handle incoming messages
|
|
ws.on('message', (message) => {
|
|
try {
|
|
const msg = JSON.parse(message.toString());
|
|
|
|
switch (msg.type) {
|
|
case 'input':
|
|
// Validate input data type and length
|
|
if (typeof msg.data !== 'string') {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid input type' }));
|
|
break;
|
|
}
|
|
// Limit input size to 1MB to prevent memory issues
|
|
if (msg.data.length > 1024 * 1024) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Input too large' }));
|
|
break;
|
|
}
|
|
// Write user input to terminal
|
|
terminalService.write(sessionId, msg.data);
|
|
break;
|
|
|
|
case 'resize':
|
|
// Validate resize dimensions are positive integers within reasonable bounds
|
|
if (
|
|
typeof msg.cols !== 'number' ||
|
|
typeof msg.rows !== 'number' ||
|
|
!Number.isInteger(msg.cols) ||
|
|
!Number.isInteger(msg.rows) ||
|
|
msg.cols < 1 ||
|
|
msg.cols > 1000 ||
|
|
msg.rows < 1 ||
|
|
msg.rows > 500
|
|
) {
|
|
break; // Silently ignore invalid resize requests
|
|
}
|
|
// Resize terminal with deduplication and rate limiting
|
|
if (msg.cols && msg.rows) {
|
|
const now = Date.now();
|
|
const lastTime = lastResizeTime.get(sessionId) || 0;
|
|
const lastDimensions = lastResizeDimensions.get(sessionId);
|
|
|
|
// Skip if resized too recently (prevents resize storm during splits)
|
|
if (now - lastTime < RESIZE_MIN_INTERVAL_MS) {
|
|
break;
|
|
}
|
|
|
|
// Check if dimensions are different from last resize
|
|
if (
|
|
!lastDimensions ||
|
|
lastDimensions.cols !== msg.cols ||
|
|
lastDimensions.rows !== msg.rows
|
|
) {
|
|
// Only suppress output on subsequent resizes, not the first one
|
|
// The first resize happens on terminal open and we don't want to drop the initial prompt
|
|
const isFirstResize = !lastDimensions;
|
|
terminalService.resize(sessionId, msg.cols, msg.rows, !isFirstResize);
|
|
lastResizeDimensions.set(sessionId, {
|
|
cols: msg.cols,
|
|
rows: msg.rows,
|
|
});
|
|
lastResizeTime.set(sessionId, now);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'ping':
|
|
// Respond to ping
|
|
ws.send(JSON.stringify({ type: 'pong' }));
|
|
break;
|
|
|
|
default:
|
|
logger.warn(`Unknown message type: ${msg.type}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error processing message:', error);
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
logger.info(`Client disconnected from session ${sessionId}`);
|
|
unsubscribeData();
|
|
unsubscribeExit();
|
|
|
|
// Remove from connections tracking
|
|
const connections = terminalConnections.get(sessionId);
|
|
if (connections) {
|
|
connections.delete(ws);
|
|
if (connections.size === 0) {
|
|
terminalConnections.delete(sessionId);
|
|
// DON'T delete lastResizeDimensions/lastResizeTime here!
|
|
// The session still exists, and reconnecting clients need to know
|
|
// this isn't the "first resize" to prevent duplicate prompts.
|
|
// These get cleaned up when the session actually exits.
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('error', (error) => {
|
|
logger.error(`Error on session ${sessionId}:`, error);
|
|
unsubscribeData();
|
|
unsubscribeExit();
|
|
});
|
|
});
|
|
|
|
// Start server with error handling for port conflicts
|
|
const startServer = (port: number, host: string) => {
|
|
server.listen(port, host, () => {
|
|
const terminalStatus = isTerminalEnabled()
|
|
? isTerminalPasswordRequired()
|
|
? 'enabled (password protected)'
|
|
: 'enabled'
|
|
: 'disabled';
|
|
|
|
// Build URLs for display
|
|
const listenAddr = `${host}:${port}`;
|
|
const httpUrl = `http://${HOSTNAME}:${port}`;
|
|
const wsEventsUrl = `ws://${HOSTNAME}:${port}/api/events`;
|
|
const wsTerminalUrl = `ws://${HOSTNAME}:${port}/api/terminal/ws`;
|
|
const healthUrl = `http://${HOSTNAME}:${port}/api/health`;
|
|
|
|
const sHeader = '🚀 Automaker Backend Server'.padEnd(BOX_CONTENT_WIDTH);
|
|
const s1 = `Listening: ${listenAddr}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const s2 = `HTTP API: ${httpUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const s3 = `WebSocket: ${wsEventsUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const s4 = `Terminal WS: ${wsTerminalUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const s5 = `Health: ${healthUrl}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const s6 = `Terminal: ${terminalStatus}`.padEnd(BOX_CONTENT_WIDTH);
|
|
|
|
logger.info(`
|
|
╔═════════════════════════════════════════════════════════════════════╗
|
|
║ ${sHeader}║
|
|
╠═════════════════════════════════════════════════════════════════════╣
|
|
║ ║
|
|
║ ${s1}║
|
|
║ ${s2}║
|
|
║ ${s3}║
|
|
║ ${s4}║
|
|
║ ${s5}║
|
|
║ ${s6}║
|
|
║ ║
|
|
╚═════════════════════════════════════════════════════════════════════╝
|
|
`);
|
|
});
|
|
|
|
server.on('error', (error: NodeJS.ErrnoException) => {
|
|
if (error.code === 'EADDRINUSE') {
|
|
const portStr = port.toString();
|
|
const nextPortStr = (port + 1).toString();
|
|
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
|
|
const altCmd = `PORT=${nextPortStr} npm run dev:server`;
|
|
|
|
const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
|
|
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
|
|
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
|
|
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
|
|
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
|
|
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
|
|
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
|
|
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);
|
|
|
|
logger.error(`
|
|
╔═════════════════════════════════════════════════════════════════════╗
|
|
║ ${eHeader}║
|
|
╠═════════════════════════════════════════════════════════════════════╣
|
|
║ ║
|
|
║ ${e1}║
|
|
║ ║
|
|
║ ${e2}║
|
|
║ ║
|
|
║ ${e3}║
|
|
║ ${e4}║
|
|
║ ║
|
|
║ ${e5}║
|
|
║ ${e6}║
|
|
║ ║
|
|
║ ${e7}║
|
|
║ ${e8}║
|
|
║ ║
|
|
╚═════════════════════════════════════════════════════════════════════╝
|
|
`);
|
|
process.exit(1);
|
|
} else {
|
|
logger.error('Error starting server:', error);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
};
|
|
|
|
startServer(PORT, HOST);
|
|
|
|
// Global error handlers to prevent crashes from uncaught errors
|
|
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
|
logger.error('Unhandled Promise Rejection:', {
|
|
reason: reason instanceof Error ? reason.message : String(reason),
|
|
stack: reason instanceof Error ? reason.stack : undefined,
|
|
});
|
|
// Don't exit - log the error and continue running
|
|
// This prevents the server from crashing due to unhandled rejections
|
|
});
|
|
|
|
process.on('uncaughtException', (error: Error) => {
|
|
logger.error('Uncaught Exception:', {
|
|
message: error.message,
|
|
stack: error.stack,
|
|
});
|
|
// Exit on uncaught exceptions to prevent undefined behavior
|
|
// The process is in an unknown state after an uncaught exception
|
|
process.exit(1);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', () => {
|
|
logger.info('SIGTERM received, shutting down...');
|
|
terminalService.cleanup();
|
|
server.close(() => {
|
|
logger.info('Server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
process.on('SIGINT', () => {
|
|
logger.info('SIGINT received, shutting down...');
|
|
terminalService.cleanup();
|
|
server.close(() => {
|
|
logger.info('Server closed');
|
|
process.exit(0);
|
|
});
|
|
});
|