import { randomUUID } from 'node:crypto'; import { WebSocketServer } from 'ws'; import { TERMINAL_WS_MAX_PAYLOAD_BYTES, TERMINAL_WS_PATH, createTerminalWsControlFrame, parseRequestPathname, readTerminalWsControlFrame, } from './terminal-ws-protocol.js'; import { sanitizeTerminalHistoryChunk } from './history.js'; import { consumeTerminalThemeQueries, terminalThemeModeReport } from './theme-response.js'; import { createTerminalShellResolver, getTerminalShellLoginArgs, normalizeTerminalShell } from './shells.js'; import { stripAppImageArgv0Leak, resolveLinuxPtyLaunch } from '../inherited-env.js'; const MAX_SESSIONS = 20; const MAX_HISTORY_BYTES = 512 * 1024; const MAX_INPUT_CHARS = 65_536; const IDLE_TIMEOUT_MS = 30 * 60 * 1000; const TERMINATION_GRACE_MS = 1000; const validateSize = (value, max) => Number.isInteger(value) && value >= 1 && value <= max; const trimHistory = (history) => { const bytes = Buffer.from(history); if (bytes.byteLength <= MAX_HISTORY_BYTES) return history; let start = bytes.byteLength - MAX_HISTORY_BYTES; while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1; return bytes.subarray(start).toString('utf8'); }; export function createTerminalRuntime({ app, server, fs, path, uiAuthController, buildAugmentedPath, searchPathFor, isExecutable, isRequestOriginAllowed, rejectWebSocketUpgrade, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS, loadPtyProvider, terminalTerminationGraceMs = TERMINATION_GRACE_MS, }) { const sessions = new Map(); const pendingSessionCreates = new Map(); const pendingSessionRestarts = new Map(); const connections = new Set(); const pendingTerminations = new Set(); const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun'; let ptyProviderPromise = null; let wsServer = new WebSocketServer({ noServer: true, maxPayload: TERMINAL_WS_MAX_PAYLOAD_BYTES }); const shellResolver = createTerminalShellResolver({ fs, path, searchPathFor, isExecutable, buildAugmentedPath }); const getPtyProvider = async () => { if (!ptyProviderPromise) { ptyProviderPromise = loadPtyProvider ? loadPtyProvider() : (async () => { if (typeof globalThis.Bun !== 'undefined') { try { const pty = await import('bun-pty'); return { spawn: pty.spawn, backend: 'bun-pty' }; } catch { /* fall through */ } } const pty = await import('node-pty'); return { spawn: pty.spawn, backend: 'node-pty' }; })(); } return ptyProviderPromise; }; const spawnPty = async ({ cwd, cols, rows, themeMode, shell, loginShell }) => { const provider = await getPtyProvider(); const resolvedShell = await shellResolver.resolve(shell); let lastError = null; for (const executable of resolvedShell.executables) { const args = loginShell ? getTerminalShellLoginArgs(executable) : []; if (!args) throw new Error(`Terminal shell "${resolvedShell.id}" does not support login mode`); try { const env = { ...process.env, PATH: buildAugmentedPath(), TERM: 'xterm-256color', COLORTERM: 'truecolor', COLORFGBG: themeMode === 'light' ? '0;15' : '15;0' }; // The daemon's IPC fd is closed inside the PTY. An explicit override is // required because bun-pty also inherits Bun's native process environment. env.NODE_CHANNEL_FD = ''; delete env.BASH_XTRACEFD; delete env.BASH_ENV; delete env.ENV; delete env.ELECTRON_RUN_AS_NODE; // AppImage exports ARGV0; zsh would otherwise rewrite argv[0] for every command (#2588). // bun-pty also merges the native OS environ, so wrap with `env -u ARGV0` on Linux. stripAppImageArgv0Leak(env); const launch = resolveLinuxPtyLaunch(executable, args); const options = { name: 'xterm-256color', cwd, cols, rows, env, ...(process.platform === 'win32' ? { useConpty: true } : {}) }; return { process: provider.spawn(launch.executable, launch.args, options), backend: provider.backend, shell: resolvedShell.id, loginShell }; } catch (error) { lastError = error; } } throw lastError ?? new Error('No executable shell found'); }; const killProcess = (ptyProcess, force = false) => { if (!ptyProcess) return; if (process.platform !== 'win32' && Number.isInteger(ptyProcess.pid) && ptyProcess.pid > 0) { try { process.kill(-ptyProcess.pid, force ? 'SIGKILL' : 'SIGTERM'); } catch { /* already gone */ } } try { ptyProcess.kill(force ? 'SIGKILL' : undefined); } catch { /* already gone */ } }; const terminateProcess = (ptyProcess, force = false) => { if (!ptyProcess) return Promise.resolve(); if (force) { killProcess(ptyProcess, true); return Promise.resolve(); } let termination; termination = new Promise((resolve) => { let settled = false; let disposable = null; const finish = () => { if (settled) return; settled = true; clearTimeout(timeout); disposable?.dispose?.(); resolve(); }; const timeout = setTimeout(() => { killProcess(ptyProcess, true); finish(); }, terminalTerminationGraceMs); try { disposable = ptyProcess.onExit(() => finish()); } catch { /* backend is already gone */ } killProcess(ptyProcess, false); }).finally(() => pendingTerminations.delete(termination)); pendingTerminations.add(termination); return termination; }; const send = (socket, message) => { if (socket?.readyState !== 1) return false; try { socket.send(createTerminalWsControlFrame(message), { binary: true }); return true; } catch { return false; } }; const closeAttachments = (sessionId, code, message) => { for (const connection of connections) { if (!connection.attachments.delete(sessionId)) continue; send(connection.socket, { t: 'error', v: 3, s: sessionId, code, message, fatal: true }); } }; const snapshot = (session) => ({ t: 'snapshot', v: 3, s: session.id, q: session.sequence, history: session.history, status: session.status, exitCode: session.exitCode, signal: session.signal, runtime, ptyBackend: session.backend, }); const publish = (session, event) => { session.sequence += 1; const message = { ...event, v: 3, s: session.id, q: session.sequence }; for (const connection of connections) { const attachment = connection.attachments.get(session.id); if (!attachment) continue; if (attachment.initializing) attachment.pending.push(message); else send(connection.socket, message); } }; const drainEvents = (session) => { if (session.draining) return; session.draining = true; try { while (session.eventQueue.length > 0) { const event = session.eventQueue.shift(); if (event.process !== session.process) continue; if (event.type === 'output') { const theme = consumeTerminalThemeQueries(session.pendingThemeControlSequence, event.data, { themeMode: session.themeMode, background: session.terminalBackground, foreground: session.terminalForeground, modeEnabled: session.themeModeEnabled, }, { respondToPrimaryDeviceAttributes: true }); session.pendingThemeControlSequence = theme.pending; session.themeModeEnabled = theme.modeEnabled; for (const response of theme.responses) session.process?.write(response); const sanitized = sanitizeTerminalHistoryChunk(session.pendingHistoryControlSequence, event.data); session.pendingHistoryControlSequence = sanitized.pending; session.history = trimHistory(session.history + sanitized.visible); session.lastActivity = Date.now(); publish(session, { t: 'output', d: event.data, ...(sanitized.visible !== event.data ? { r: sanitized.visible } : {}) }); } else { session.status = 'exited'; session.exitCode = Number.isInteger(event.exitCode) ? event.exitCode : null; session.signal = Number.isInteger(event.signal) ? event.signal : null; session.process = null; publish(session, { t: 'exit', exitCode: session.exitCode, signal: session.signal }); } } } finally { session.draining = false; } }; const wire = (session, ptyProcess) => { ptyProcess.onData((data) => { session.eventQueue.push({ type: 'output', process: ptyProcess, data }); drainEvents(session); }); ptyProcess.onExit(({ exitCode, signal }) => { session.eventQueue.push({ type: 'exit', process: ptyProcess, exitCode, signal }); drainEvents(session); }); }; const validateCwd = async (cwd) => { if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('cwd is required'); const stats = await fs.promises.stat(cwd).catch(() => null); if (!stats?.isDirectory()) throw new Error('Invalid working directory'); }; const applyAppearance = (session, { themeMode, terminalBackground, terminalForeground }) => { const previous = [session.themeMode, session.terminalBackground, session.terminalForeground]; if (themeMode === 'light' || themeMode === 'dark') session.themeMode = themeMode; if (typeof terminalBackground === 'string') session.terminalBackground = terminalBackground; if (typeof terminalForeground === 'string') session.terminalForeground = terminalForeground; const changed = previous[0] !== session.themeMode || previous[1] !== session.terminalBackground || previous[2] !== session.terminalForeground; if (changed && session.themeModeEnabled) { try { session.process?.write(terminalThemeModeReport(session.themeMode)); } catch { /* process exited */ } } }; const startSession = async (session, { cwd, cols, rows, themeMode = 'dark', terminalBackground, terminalForeground, shell, loginShell }, clear = true) => { await validateCwd(cwd); const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell }); if (clear) { session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; } session.cwd = cwd; session.cols = cols; session.rows = rows; session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.status = 'running'; session.exitCode = null; session.signal = null; session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground; session.lastActivity = Date.now(); session.eventQueue.length = 0; wire(session, spawned.process); }; const createSession = async ({ sessionId, cwd, cols = 80, rows = 24, themeMode, terminalBackground, terminalForeground, shell = 'auto', loginShell = false }) => { if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions'); if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode'); const normalizedShell = normalizeTerminalShell(shell); if (!normalizedShell) throw new Error('Invalid terminal shell'); const id = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : randomUUID(); if (id.length > 128) throw new Error('Invalid terminal session id'); const existing = sessions.get(id); const resolvedCwd = path.resolve(cwd); if (existing?.status === 'running') { if (path.resolve(existing.cwd) !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory'); applyAppearance(existing, { themeMode, terminalBackground, terminalForeground }); return existing; } const pending = pendingSessionCreates.get(id); if (pending) { if (pending.cwd !== resolvedCwd) throw new Error('Terminal session belongs to a different working directory'); if (pending.shell !== normalizedShell) throw new Error('Terminal session is already being created with a different shell'); if (pending.loginShell !== loginShell) throw new Error('Terminal session is already being created with a different login mode'); const session = await pending.promise; applyAppearance(session, { themeMode, terminalBackground, terminalForeground }); return session; } if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached'); const creation = (async () => { const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false }; await startSession(session, { cwd, cols, rows, themeMode, terminalBackground, terminalForeground, shell: normalizedShell, loginShell }); sessions.set(id, session); return session; })(); const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, promise: creation }; pendingSessionCreates.set(id, pendingEntry); try { return await creation; } finally { if (pendingSessionCreates.get(id) === pendingEntry) pendingSessionCreates.delete(id); } }; wsServer.on('connection', (socket) => { const connection = { socket, attachments: new Map() }; connections.add(connection); send(socket, { t: 'hello', v: 3 }); const heartbeat = setInterval(() => { try { socket.ping(); } catch { /* closed */ } }, TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS); socket.on('message', (raw, isBinary) => { if (!isBinary) { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Binary control frame required', fatal: false }); return; } const message = readTerminalWsControlFrame(raw); if (!message || message.v !== 3 || typeof message.t !== 'string') { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Invalid terminal frame', fatal: false }); return; } if (message.t === 'ping') { send(socket, { t: 'pong', v: 3 }); return; } if (message.t === 'hello') return; const id = typeof message.s === 'string' ? message.s : ''; if (!id) { send(socket, { t: 'error', v: 3, code: 'BAD_FRAME', message: 'Session id required', fatal: false }); return; } if (message.t === 'detach') { connection.attachments.delete(id); return; } const session = sessions.get(id); if (!session) { send(socket, { t: 'error', v: 3, s: id, code: 'SESSION_NOT_FOUND', message: 'Terminal session not found', fatal: true }); return; } if (message.t === 'attach') { const attachment = { initializing: true, pending: [] }; connection.attachments.set(id, attachment); const initial = snapshot(session); send(socket, initial); for (const event of attachment.pending) if (event.q > initial.q) send(socket, event); attachment.pending.length = 0; attachment.initializing = false; return; } if (message.t === 'write') { if (typeof message.d !== 'string' || !message.d || message.d.length > MAX_INPUT_CHARS) { send(socket, { t: 'error', v: 3, s: id, code: 'BAD_INPUT', message: 'Invalid terminal input', fatal: false }); return; } if (session.status !== 'running' || !session.process) { send(socket, { t: 'error', v: 3, s: id, code: 'NOT_RUNNING', message: 'Terminal is not running', fatal: false }); return; } try { session.process.write(message.d); session.lastActivity = Date.now(); } catch { send(socket, { t: 'error', v: 3, s: id, code: 'WRITE_FAILED', message: 'Failed to write to terminal', fatal: false }); } } }); const cleanup = () => { clearInterval(heartbeat); connection.attachments.clear(); connections.delete(connection); }; socket.on('close', cleanup); socket.on('error', () => {}); }); const upgradeHandler = (req, socket, head) => { if (parseRequestPathname(req.url) !== TERMINAL_WS_PATH) return; void (async () => { try { if (uiAuthController?.enabled) { if (!await uiAuthController.ensureSessionToken(req, null)) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; } if (!await isRequestOriginAllowed(req)) { rejectWebSocketUpgrade(socket, 403, 'Invalid origin'); return; } } if (!wsServer) { rejectWebSocketUpgrade(socket, 500, 'Terminal WebSocket unavailable'); return; } wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req)); } catch { rejectWebSocketUpgrade(socket, 500, 'Upgrade failed'); } })(); }; server.on('upgrade', upgradeHandler); app.get('/api/terminal/shells', async (_req, res) => { try { const shells = await shellResolver.list(); res.json(shells.map(({ id, name, supportsLogin }) => ({ id, name, supportsLogin }))); } catch (error) { res.status(500).json({ error: error?.message || 'Failed to list terminal shells' }); } }); app.post('/api/terminal/create', async (req, res) => { try { const session = await createSession(req.body ?? {}); res.json({ sessionId: session.id, cols: session.cols, rows: session.rows, status: session.status }); } catch (error) { res.status(error?.message === 'Maximum terminal sessions reached' ? 429 : 400).json({ error: error?.message || 'Failed to create terminal session' }); } }); app.post('/api/terminal/:sessionId/resize', (req, res) => { const session = sessions.get(req.params.sessionId); if (!session) return res.status(404).json({ error: 'Terminal session not found' }); const { cols, rows } = req.body ?? {}; if (!validateSize(cols, 1000) || !validateSize(rows, 500)) return res.status(400).json({ error: 'Invalid terminal dimensions' }); try { if (session.status === 'running') session.process?.resize(cols, rows); session.cols = cols; session.rows = rows; res.json({ success: true, cols, rows }); } catch (error) { res.status(500).json({ error: error?.message || 'Failed to resize terminal' }); } }); app.post('/api/terminal/:sessionId/appearance', (req, res) => { const session = sessions.get(req.params.sessionId); if (!session) return res.status(404).json({ error: 'Terminal session not found' }); applyAppearance(session, req.body ?? {}); res.json({ success: true }); }); app.post('/api/terminal/:sessionId/restart', async (req, res) => { const session = sessions.get(req.params.sessionId); if (!session) return res.status(404).json({ error: 'Terminal session not found' }); const cwd = req.body?.cwd ?? session.cwd; const cols = req.body?.cols ?? session.cols; const rows = req.body?.rows ?? session.rows; const themeMode = req.body?.themeMode ?? session.themeMode; const terminalBackground = req.body?.terminalBackground ?? session.terminalBackground; const terminalForeground = req.body?.terminalForeground ?? session.terminalForeground; const shell = req.body?.shell ?? 'auto'; const loginShell = req.body?.loginShell ?? false; const previousRestart = pendingSessionRestarts.get(session.id) ?? Promise.resolve(); const restart = previousRestart.catch(() => {}).then(async () => { await validateCwd(cwd); if (!validateSize(cols, 1000) || !validateSize(rows, 500)) throw new Error('Invalid terminal dimensions'); if (typeof loginShell !== 'boolean') throw new Error('Invalid terminal login mode'); const oldProcess = session.process; const spawned = await spawnPty({ cwd, cols, rows, themeMode, shell, loginShell }); session.process = spawned.process; session.backend = spawned.backend; session.shell = spawned.shell; session.loginShell = spawned.loginShell; session.cwd = cwd; session.cols = cols; session.rows = rows; session.history = ''; session.pendingHistoryControlSequence = ''; session.pendingThemeControlSequence = ''; session.themeModeEnabled = false; session.status = 'running'; session.exitCode = null; session.signal = null; session.eventQueue.length = 0; session.themeMode = themeMode === 'light' ? 'light' : 'dark'; session.terminalBackground = terminalBackground; session.terminalForeground = terminalForeground; wire(session, spawned.process); void terminateProcess(oldProcess); publish(session, { t: 'restarted', history: '' }); }); pendingSessionRestarts.set(session.id, restart); try { await restart; res.json({ sessionId: session.id, cols, rows, status: session.status }); } catch (error) { res.status(400).json({ error: error?.message || 'Failed to restart terminal' }); } finally { if (pendingSessionRestarts.get(session.id) === restart) pendingSessionRestarts.delete(session.id); } }); app.delete('/api/terminal/:sessionId', async (req, res) => { const session = sessions.get(req.params.sessionId); if (!session) return res.status(404).json({ error: 'Terminal session not found' }); sessions.delete(session.id); closeAttachments(session.id, 'CLOSED', 'Terminal closed'); await terminateProcess(session.process); res.json({ success: true }); }); app.post('/api/terminal/force-kill', (req, res) => { const { sessionId, cwd } = req.body ?? {}; let killedCount = 0; const killedSessionIds = []; for (const [id, session] of sessions) { if ((sessionId && id !== sessionId) || (!sessionId && cwd && session.cwd !== cwd)) continue; sessions.delete(id); closeAttachments(id, 'KILLED', 'Terminal was killed'); void terminateProcess(session.process, true); killedSessionIds.push(id); killedCount += 1; } res.json({ success: true, killedCount, killedSessionIds }); }); const idleSweep = setInterval(() => { const now = Date.now(); for (const [id, session] of sessions) { const attached = [...connections].some((connection) => connection.attachments.has(id)); if (!attached && now - session.lastActivity > IDLE_TIMEOUT_MS) { sessions.delete(id); closeAttachments(id, 'IDLE_TIMEOUT', 'Terminal expired after being idle'); void terminateProcess(session.process, true); } } }, 5 * 60 * 1000); const shutdown = async () => { server.off('upgrade', upgradeHandler); clearInterval(idleSweep); await Promise.allSettled([...pendingSessionRestarts.values()]); for (const session of sessions.values()) void terminateProcess(session.process, true); sessions.clear(); await Promise.allSettled([...pendingTerminations]); if (!wsServer) return; for (const client of wsServer.clients) client.terminate(); await Promise.race([ new Promise((resolve) => wsServer.close(resolve)), new Promise((resolve) => setTimeout(resolve, 1000)), ]); wsServer = null; }; return { shutdown }; }