import { spawn, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; /** * Command validation patterns for the allowlist. * These are the base commands that users are allowed to execute. */ export const ALLOWED_COMMANDS = new Set([ // Version control "git", // Package managers "npm", "pnpm", "yarn", "bun", // File operations "ls", "cat", "echo", "pwd", "cd", "mkdir", "touch", "cp", "mv", "rm", "head", "tail", "less", "more", "find", "grep", // System info "clear", "which", "whoami", "uname", "date", // Node/JS "node", "npx", "tsx", // Python "python", "python3", "pip", "pip3", // Network "curl", "wget", // Build tools "make", "cmake", // Process management "ps", "top", "htop", "kill", "pkill", // Shell builtins that are safe "source", ".", "export", "env", "printenv", "alias", // Editors (for viewing) "code", // VS Code CLI "vim", "vi", "nano", ]); /** * Blocked dangerous patterns - additional defense in depth beyond the allowlist. * These patterns are explicitly forbidden even if the base command is allowed. */ const BLOCKED_PATTERNS = [ // System destruction /rm\s+(-[rf]+\s+)?\/\s*$/i, // rm -rf / or rm / /rm\s+(-[rf]+\s+)?\/\*/i, // rm -rf /* />\s*\/dev\/sda/i, // Direct disk write /:\(\)\s*\{\s*:\s*\|:\s*&\s*\};\s*:/i, // Fork bomb /mkfs\.\w+\s+/i, // Filesystem formatting /dd\s+if=/i, // dd with input file /\.\s*\/dev\/null/i, // Sourcing /dev/null tricks ]; /** * Command-substitution patterns that would cause the shell to evaluate an * arbitrary sub-command BEFORE the allowlist check runs (e.g. `git $(curl ... | sh)` * evaluates to whatever the curl prints). These bypass the allowlist entirely * regardless of which base command is used, so they must be rejected outright. */ const SUBSTITUTION_PATTERNS = [ /\$\(/, // $(...) — command substitution /`/, // `...` — backtick command substitution /<\(/, // <(...) — process substitution (bash) />\(/, // >(...) — process substitution (bash) ]; /** Chaining operators that let users combine commands in one request. */ const CHAIN_OPERATORS = /&&|\|\||;|(?(); private readonly defaultTimeout = 30_000; // 30 seconds /** * Creates a new terminal session and spawns the command. * Returns the session ID for tracking. */ createSession(command: string, cwd: string): { sessionId: string; error?: string } { // Validate command before execution const validation = validateCommand(command); if (!validation.valid) { return { sessionId: "", error: validation.error }; } const sessionId = randomUUID(); // Spawn the process in a shell to support pipes, redirects, etc. const childProcess = spawn(command, [], { cwd, shell: true, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, FORCE_COLOR: "1", TERM: "xterm-256color" }, }); const session: TerminalSession = { id: sessionId, command, process: childProcess, startTime: new Date(), output: [], exitCode: null, killed: false, }; this.sessions.set(sessionId, session); // Handle stdout childProcess.stdout?.on("data", (data: Buffer) => { const chunk = data.toString("utf-8"); session.output.push(chunk); this.emit("output", { sessionId, type: "stdout", data: chunk } as TerminalOutputEvent); }); // Handle stderr childProcess.stderr?.on("data", (data: Buffer) => { const chunk = data.toString("utf-8"); session.output.push(chunk); this.emit("output", { sessionId, type: "stderr", data: chunk } as TerminalOutputEvent); }); // Handle process exit childProcess.on("exit", (code: number | null) => { session.exitCode = code ?? 0; this.emit("output", { sessionId, type: "exit", data: `Process exited with code ${code ?? 0}`, exitCode: code ?? 0, } as TerminalOutputEvent); // Schedule cleanup after a delay to allow final output consumption setTimeout(() => this.cleanupSession(sessionId), 5_000); }); // Handle errors (e.g., command not found) childProcess.on("error", (err: Error) => { const errorMsg = err.message; session.output.push(errorMsg); session.exitCode = 127; // Command not found exit code this.emit("output", { sessionId, type: "stderr", data: errorMsg }); this.emit("output", { sessionId, type: "exit", data: `Process failed: ${errorMsg}`, exitCode: 127, }); setTimeout(() => this.cleanupSession(sessionId), 5_000); }); // Set up timeout const timeout = setTimeout(() => { if (!session.exitCode && !session.killed) { this.killSession(sessionId, "SIGTERM"); this.emit("output", { sessionId, type: "stderr", data: "\n[Command timed out after 30 seconds]\n" }); } }, this.defaultTimeout); // Clear timeout when process exits childProcess.on("exit", () => clearTimeout(timeout)); return { sessionId }; } /** * Gets a session by ID. */ getSession(sessionId: string): TerminalSession | undefined { return this.sessions.get(sessionId); } /** * Kills a running session's process. * Returns true if killed, false if not found or already exited. */ killSession(sessionId: string, signal: NodeJS.Signals = "SIGTERM"): boolean { const session = this.sessions.get(sessionId); if (!session || session.exitCode !== null || session.killed) { return false; } session.killed = true; // Kill the process group to handle child processes try { if (session.process.pid) { process.kill(-session.process.pid, signal); } } catch { // Fallback to killing just the main process session.process.kill(signal); } return true; } /** * Cleans up a session from memory. */ cleanupSession(sessionId: string): boolean { const session = this.sessions.get(sessionId); if (!session) return false; // Ensure process is killed if still running if (session.exitCode === null && !session.killed) { this.killSession(sessionId, "SIGKILL"); } this.sessions.delete(sessionId); return true; } /** * Lists all active sessions. */ listSessions(): Array<{ id: string; command: string; running: boolean; startTime: Date }> { return Array.from(this.sessions.values()).map(s => ({ id: s.id, command: s.command, running: s.exitCode === null && !s.killed, startTime: s.startTime, })); } /** * Cleans up all sessions (useful for shutdown). */ cleanupAll(): void { for (const [sessionId] of this.sessions) { this.cleanupSession(sessionId); } } } /** * Singleton instance of the terminal session manager. */ export const terminalSessionManager = new TerminalSessionManager();