/** * TerminalService * * Manages PTY (pseudo-terminal) sessions for workspace users. * Spawns real shell processes via node-pty, streams I/O to connected sockets, * and enforces per-workspace session limits. * * Session lifecycle: * - createSession: spawn a new PTY, wire onData/onExit, return sessionId * - writeToSession: send input data to PTY stdin * - resizeSession: resize PTY dimensions (cols x rows) * - closeSession: kill PTY process, emit terminal:exit, cleanup * - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect) */ import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; import type { IPty } from "node-pty"; import type { Socket } from "socket.io"; import { randomUUID } from "node:crypto"; // Lazy-loaded in onModuleInit via dynamic import() to prevent crash // if the native binary is missing. node-pty requires a compiled .node // binary which may not be available in all Docker environments. interface NodePtyModule { spawn: (file: string, args: string[], options: Record) => IPty; } let pty: NodePtyModule | null = null; /** Maximum concurrent PTY sessions per workspace */ export const MAX_SESSIONS_PER_WORKSPACE = parseInt( process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10", 10 ); /** Default PTY dimensions */ const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; export interface TerminalSession { sessionId: string; workspaceId: string; pty: IPty; name?: string; createdAt: Date; } export interface CreateSessionOptions { name?: string; cols?: number; rows?: number; cwd?: string; workspaceId: string; socketId: string; } export interface SessionCreatedResult { sessionId: string; name?: string; cols: number; rows: number; } @Injectable() export class TerminalService implements OnModuleInit { private readonly logger = new Logger(TerminalService.name); /** * Map of sessionId -> TerminalSession */ private readonly sessions = new Map(); /** * Map of workspaceId -> Set for fast per-workspace lookups */ private readonly workspaceSessions = new Map>(); async onModuleInit(): Promise { if (!pty) { try { pty = await import("node-pty"); this.logger.log("node-pty loaded successfully — terminal sessions available"); } catch { this.logger.warn( "node-pty native module not available — terminal sessions will be disabled. " + "Install build tools (python3, make, g++) and rebuild node-pty to enable." ); } } } /** * Create a new PTY session for the given workspace and socket. * Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit. * * @throws Error if workspace session limit is exceeded or node-pty is unavailable */ createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult { if (!pty) { throw new Error("Terminal sessions are unavailable: node-pty native module failed to load"); } const { workspaceId, name, cwd, socketId } = options; const cols = options.cols ?? DEFAULT_COLS; const rows = options.rows ?? DEFAULT_ROWS; // Enforce per-workspace session limit const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set(); if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) { throw new Error( `Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions` ); } const sessionId = randomUUID(); const shell = process.env.SHELL ?? "/bin/bash"; this.logger.log( `Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})` ); const ptyProcess = pty.spawn(shell, [], { name: "xterm-256color", cols, rows, cwd: cwd ?? process.cwd(), env: process.env as Record, }); const session: TerminalSession = { sessionId, workspaceId, pty: ptyProcess, ...(name !== undefined ? { name } : {}), createdAt: new Date(), }; this.sessions.set(sessionId, session); // Track by workspace if (!this.workspaceSessions.has(workspaceId)) { this.workspaceSessions.set(workspaceId, new Set()); } const wsSet = this.workspaceSessions.get(workspaceId); if (wsSet) { wsSet.add(sessionId); } // Wire PTY stdout/stderr -> terminal:output ptyProcess.onData((data: string) => { socket.emit("terminal:output", { sessionId, data }); }); // Wire PTY exit -> terminal:exit, cleanup ptyProcess.onExit(({ exitCode, signal }) => { this.logger.log( `PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})` ); socket.emit("terminal:exit", { sessionId, exitCode, signal }); this.cleanupSession(sessionId, workspaceId); }); return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows }; } /** * Write input data to a PTY session's stdin. * * @throws Error if session not found */ writeToSession(sessionId: string, data: string): void { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Terminal session ${sessionId} not found`); } session.pty.write(data); } /** * Resize a PTY session's terminal dimensions. * * @throws Error if session not found */ resizeSession(sessionId: string, cols: number, rows: number): void { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`Terminal session ${sessionId} not found`); } session.pty.resize(cols, rows); this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`); } /** * Kill and clean up a specific PTY session. * Returns true if the session existed, false if it was already gone. */ closeSession(sessionId: string): boolean { const session = this.sessions.get(sessionId); if (!session) { return false; } this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`); try { session.pty.kill(); } catch (error) { this.logger.warn( `Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}` ); } this.cleanupSession(sessionId, session.workspaceId); return true; } /** * Close all PTY sessions for a workspace (called on client disconnect). */ closeWorkspaceSessions(workspaceId: string): void { const sessionIds = this.workspaceSessions.get(workspaceId); if (!sessionIds || sessionIds.size === 0) { return; } this.logger.log( `Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)` ); // Copy to array to avoid mutation during iteration const ids = Array.from(sessionIds); for (const sessionId of ids) { const session = this.sessions.get(sessionId); if (session) { try { session.pty.kill(); } catch (error) { this.logger.warn( `Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}` ); } this.cleanupSession(sessionId, workspaceId); } } } /** * Get the number of active sessions for a workspace. */ getWorkspaceSessionCount(workspaceId: string): number { return this.workspaceSessions.get(workspaceId)?.size ?? 0; } /** * Check if a session belongs to a given workspace. * Used for access control in the gateway. */ sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean { const session = this.sessions.get(sessionId); return session?.workspaceId === workspaceId; } /** * Internal cleanup: remove session from tracking maps. * Does NOT kill the PTY (caller is responsible). */ private cleanupSession(sessionId: string, workspaceId: string): void { this.sessions.delete(sessionId); const workspaceSessionIds = this.workspaceSessions.get(workspaceId); if (workspaceSessionIds) { workspaceSessionIds.delete(sessionId); if (workspaceSessionIds.size === 0) { this.workspaceSessions.delete(workspaceId); } } } }