import * as net from 'net'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { BrowserManager } from './browser.js'; import { parseCommand, serializeResponse, errorResponse } from './protocol.js'; import { executeCommand } from './actions.js'; import { StreamServer } from './stream-server.js'; // Platform detection const isWindows = process.platform === 'win32'; // Session support - each session gets its own socket/pid let currentSession = process.env.AGENT_BROWSER_SESSION || 'default'; // Stream server for browser preview let streamServer: StreamServer | null = null; // Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT) const DEFAULT_STREAM_PORT = 9223; /** * Set the current session */ export function setSession(session: string): void { currentSession = session; } /** * Get the current session */ export function getSession(): string { return currentSession; } /** * Get port number for TCP mode (Windows) * Uses a hash of the session name to get a consistent port */ function getPortForSession(session: string): number { let hash = 0; for (let i = 0; i < session.length; i++) { hash = (hash << 5) - hash + session.charCodeAt(i); hash |= 0; } // Port range 49152-65535 (dynamic/private ports) return 49152 + (Math.abs(hash) % 16383); } /** * Get the base directory for socket/pid files. * Priority: AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR > ~/.agent-browser > tmpdir */ export function getSocketDir(): string { // 1. Explicit override if (process.env.AGENT_BROWSER_SOCKET_DIR) { return process.env.AGENT_BROWSER_SOCKET_DIR; } // 2. XDG_RUNTIME_DIR (Linux standard) if (process.env.XDG_RUNTIME_DIR) { return path.join(process.env.XDG_RUNTIME_DIR, 'agent-browser'); } // 3. Home directory fallback (like Docker Desktop's ~/.docker/run/) const homeDir = os.homedir(); if (homeDir) { return path.join(homeDir, '.agent-browser'); } // 4. Last resort: temp dir return path.join(os.tmpdir(), 'agent-browser'); } /** * Get the socket path for the current session (Unix) or port (Windows) */ export function getSocketPath(session?: string): string { const sess = session ?? currentSession; if (isWindows) { return String(getPortForSession(sess)); } return path.join(getSocketDir(), `${sess}.sock`); } /** * Get the port file path for Windows (stores the port number) */ export function getPortFile(session?: string): string { const sess = session ?? currentSession; return path.join(getSocketDir(), `${sess}.port`); } /** * Get the PID file path for the current session */ export function getPidFile(session?: string): string { const sess = session ?? currentSession; return path.join(getSocketDir(), `${sess}.pid`); } /** * Check if daemon is running for the current session */ export function isDaemonRunning(session?: string): boolean { const pidFile = getPidFile(session); if (!fs.existsSync(pidFile)) return false; try { const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10); // Check if process exists (works on both Unix and Windows) process.kill(pid, 0); return true; } catch { // Process doesn't exist, clean up stale files cleanupSocket(session); return false; } } /** * Get connection info for the current session * Returns { type: 'unix', path: string } or { type: 'tcp', port: number } */ export function getConnectionInfo( session?: string ): { type: 'unix'; path: string } | { type: 'tcp'; port: number } { const sess = session ?? currentSession; if (isWindows) { return { type: 'tcp', port: getPortForSession(sess) }; } return { type: 'unix', path: path.join(getSocketDir(), `${sess}.sock`) }; } /** * Clean up socket and PID file for the current session */ export function cleanupSocket(session?: string): void { const pidFile = getPidFile(session); const streamPortFile = getStreamPortFile(session); try { if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile); if (isWindows) { const portFile = getPortFile(session); if (fs.existsSync(portFile)) fs.unlinkSync(portFile); } else { const socketPath = getSocketPath(session); if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath); } } catch { // Ignore cleanup errors } } /** * Get the stream port file path */ export function getStreamPortFile(session?: string): string { const sess = session ?? currentSession; return path.join(getSocketDir(), `${sess}.stream`); } /** * Start the daemon server * @param options.streamPort Port for WebSocket stream server (0 to disable) */ export async function startDaemon(options?: { streamPort?: number }): Promise { // Ensure socket directory exists const socketDir = getSocketDir(); if (!fs.existsSync(socketDir)) { fs.mkdirSync(socketDir, { recursive: true }); } // Clean up any stale socket cleanupSocket(); const browser = new BrowserManager(); let shuttingDown = false; // Start stream server if port is specified (or use default if env var is set) const streamPort = options?.streamPort ?? (process.env.AGENT_BROWSER_STREAM_PORT ? parseInt(process.env.AGENT_BROWSER_STREAM_PORT, 10) : 0); if (streamPort > 0) { streamServer = new StreamServer(browser, streamPort); await streamServer.start(); // Write stream port to file for clients to discover const streamPortFile = getStreamPortFile(); fs.writeFileSync(streamPortFile, streamPort.toString()); } const server = net.createServer((socket) => { let buffer = ''; socket.on('data', async (data) => { buffer += data.toString(); // Process complete lines while (buffer.includes('\n')) { const newlineIdx = buffer.indexOf('\n'); const line = buffer.substring(0, newlineIdx); buffer = buffer.substring(newlineIdx + 1); if (!line.trim()) continue; try { const parseResult = parseCommand(line); if (!parseResult.success) { const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error); socket.write(serializeResponse(resp) + '\n'); continue; } // Auto-launch browser if not already launched and this isn't a launch command if ( !browser.isLaunched() && parseResult.command.action !== 'launch' && parseResult.command.action !== 'close' ) { const extensions = process.env.AGENT_BROWSER_EXTENSIONS ? process.env.AGENT_BROWSER_EXTENSIONS.split(',') .map((p) => p.trim()) .filter(Boolean) : undefined; // Parse args from env (comma or newline separated) const argsEnv = process.env.AGENT_BROWSER_ARGS; const args = argsEnv ? argsEnv .split(/[,\n]/) .map((a) => a.trim()) .filter((a) => a.length > 0) : undefined; // Parse proxy from env const proxyServer = process.env.AGENT_BROWSER_PROXY; const proxyBypass = process.env.AGENT_BROWSER_PROXY_BYPASS; const proxy = proxyServer ? { server: proxyServer, ...(proxyBypass && { bypass: proxyBypass }), } : undefined; await browser.launch({ id: 'auto', action: 'launch' as const, headless: process.env.AGENT_BROWSER_HEADED !== '1', executablePath: process.env.AGENT_BROWSER_EXECUTABLE_PATH, extensions: extensions, args, userAgent: process.env.AGENT_BROWSER_USER_AGENT, proxy, }); } // Handle close command specially if (parseResult.command.action === 'close') { const response = await executeCommand(parseResult.command, browser); socket.write(serializeResponse(response) + '\n'); if (!shuttingDown) { shuttingDown = true; setTimeout(() => { server.close(); cleanupSocket(); process.exit(0); }, 100); } return; } const response = await executeCommand(parseResult.command, browser); socket.write(serializeResponse(response) + '\n'); } catch (err) { const message = err instanceof Error ? err.message : String(err); socket.write(serializeResponse(errorResponse('error', message)) + '\n'); } } }); socket.on('error', () => { // Client disconnected, ignore }); }); const pidFile = getPidFile(); // Write PID file before listening fs.writeFileSync(pidFile, process.pid.toString()); if (isWindows) { // Windows: use TCP socket on localhost const port = getPortForSession(currentSession); const portFile = getPortFile(); fs.writeFileSync(portFile, port.toString()); server.listen(port, '127.0.0.1', () => { // Daemon is ready on TCP port }); } else { // Unix: use Unix domain socket const socketPath = getSocketPath(); server.listen(socketPath, () => { // Daemon is ready }); } server.on('error', (err) => { console.error('Server error:', err); cleanupSocket(); process.exit(1); }); // Handle shutdown signals const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; // Stop stream server if running if (streamServer) { await streamServer.stop(); streamServer = null; // Clean up stream port file const streamPortFile = getStreamPortFile(); try { if (fs.existsSync(streamPortFile)) fs.unlinkSync(streamPortFile); } catch { // Ignore cleanup errors } } await browser.close(); server.close(); cleanupSocket(); process.exit(0); }; process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); process.on('SIGHUP', shutdown); // Handle unexpected errors - always cleanup process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); cleanupSocket(); process.exit(1); }); process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); cleanupSocket(); process.exit(1); }); // Cleanup on normal exit process.on('exit', () => { cleanupSocket(); }); // Keep process alive process.stdin.resume(); } // Run daemon if this is the entry point if (process.argv[1]?.endsWith('daemon.js') || process.env.AGENT_BROWSER_DAEMON === '1') { startDaemon().catch((err) => { console.error('Daemon error:', err); cleanupSocket(); process.exit(1); }); }