import "server-only"; import os from "node:os"; import path from "node:path"; import { spawn } from "node:child_process"; import { access, stat } from "node:fs/promises"; import { z } from "zod"; import { withServerTiming } from "@/lib/server/logging/logger"; import type { TerminalExecuteRequest, TerminalExecuteResult, TerminalOutputLine, TerminalServiceErrorCode, } from "@/lib/shared/contracts/terminal"; const COMMAND_TIMEOUT_MS = 12_000; const MAX_OUTPUT_BYTES = 128_000; const executeSchema = z.object({ command: z.string().trim().min(1).max(1024), cwd: z.string().trim().max(1024).optional(), history: z.array(z.string().max(1024)).max(200).optional(), }); const ALLOWED_COMMANDS = new Set([ "ls", "cd", "pwd", "cat", "echo", "whoami", "hostname", "uname", "uptime", "df", "free", "docker", "ping", "ip", "date", "history", "help", "clear", "exit", ]); type RequestContext = { requestId?: string; }; type ExecutionResult = { stdout: string; stderr: string; exitCode: number | null; timedOut: boolean; }; export class TerminalServiceError extends Error { readonly code: TerminalServiceErrorCode; readonly statusCode: number; constructor( message: string, options?: { code?: TerminalServiceErrorCode; statusCode?: number; cause?: unknown; }, ) { super(message, { cause: options?.cause, }); this.name = "TerminalServiceError"; this.code = options?.code ?? "execution_failed"; this.statusCode = options?.statusCode ?? 500; } } function tokenizeCommand(command: string) { const tokens = command.match(/"([^"\\]|\\.)*"|'([^'\\]|\\.)*'|\S+/g); if (!tokens) return []; return tokens.map((token) => { if ( (token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'")) ) { return token.slice(1, -1); } return token; }); } function validateInput(input: unknown) { const parsed = executeSchema.safeParse(input); if (!parsed.success) { throw new TerminalServiceError("Invalid terminal execute payload", { code: "invalid_request", statusCode: 400, cause: parsed.error, }); } return parsed.data satisfies TerminalExecuteRequest; } function toOutputLine(type: TerminalOutputLine["type"], content: string): TerminalOutputLine { return { type, content, }; } /** * Matches ANSI / VT100 escape sequences that programs emit for colours, * cursor movement, window titles, etc. Stripping them keeps the HTTP * terminal output readable as plain text. * * Covers: * CSI sequences ESC [ … final-byte (colours, erase, cursor, …) * OSC sequences ESC ] … BEL (window title, hyperlinks, …) */ const ANSI_ESCAPE_RE = /\u001b(?:\[\??[0-9;]*[A-Za-z]|\][^\u0007]*\u0007|[()][AB012]|[<=>])/g; function trimOutput(content: string) { return content .replace(ANSI_ESCAPE_RE, "") // strip ANSI escape sequences .replace(/\r\n/g, "\n") // normalise Windows CRLF .replace(/\r/g, "") // strip bare CR (progress-bar overwrites) .trimEnd(); } function toHomePath(rawPath: string) { const home = os.homedir(); if (rawPath === "~") return home; if (rawPath.startsWith("~/")) return path.join(home, rawPath.slice(2)); return rawPath; } async function resolveWorkingDirectory(candidate: string | undefined) { const fallback = os.homedir(); if (!candidate) return fallback; const resolved = path.resolve(toHomePath(candidate)); try { const info = await stat(resolved); if (!info.isDirectory()) { return fallback; } await access(resolved); return resolved; } catch { return fallback; } } async function handleCd(currentDirectory: string, target: string | undefined) { const nextPath = target ? path.resolve(currentDirectory, toHomePath(target)) : os.homedir(); try { const info = await stat(nextPath); if (!info.isDirectory()) { return { cwd: currentDirectory, lines: [ toOutputLine("error", `cd: ${target ?? ""}: Not a directory`), ], }; } await access(nextPath); return { cwd: nextPath, lines: [] satisfies TerminalOutputLine[], }; } catch { return { cwd: currentDirectory, lines: [ toOutputLine( "error", `cd: ${target ?? ""}: No such file or directory`, ), ], }; } } function renderHistory(history: string[] | undefined) { const items = history ?? []; if (items.length === 0) { return [toOutputLine("info", "No command history yet.")]; } const rendered = items .slice(-200) .map((command, index) => `${String(index + 1).padStart(4, " ")} ${command}`) .join("\n"); return [toOutputLine("output", rendered)]; } function renderHelp() { return [ toOutputLine( "info", `Supported commands: help Show this help message ls [path] List directory contents cd [path] Change directory pwd Print working directory cat Display file contents echo Print text whoami Print current user hostname Print hostname uname -a Print system information uptime Show system uptime df -h Show disk usage free -h Show memory usage docker ... Run docker commands ping Ping a host ip addr Show network interfaces date Print current date history Show command history clear Clear the terminal Note: commands run in non-interactive mode with a timeout.`, ), ]; } function executeExternalCommand( command: string, args: string[], cwd: string, ): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { cwd, shell: false, env: { ...process.env, TERM: "xterm-256color", }, }); let stdout = ""; let stderr = ""; let timedOut = false; const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); setTimeout(() => child.kill("SIGKILL"), 250).unref(); }, COMMAND_TIMEOUT_MS); child.stdout.on("data", (chunk) => { stdout += chunk.toString(); if (stdout.length > MAX_OUTPUT_BYTES) { stdout = `${stdout.slice(0, MAX_OUTPUT_BYTES)}\n\n[output truncated]`; } }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); if (stderr.length > MAX_OUTPUT_BYTES) { stderr = `${stderr.slice(0, MAX_OUTPUT_BYTES)}\n\n[output truncated]`; } }); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); child.on("close", (exitCode) => { clearTimeout(timeout); resolve({ stdout: trimOutput(stdout), stderr: trimOutput(stderr), exitCode, timedOut, }); }); }); } export async function executeTerminalCommand( input: unknown, context?: RequestContext, ): Promise { const validated = validateInput(input); return withServerTiming( { layer: "service", action: "terminal.execute", requestId: context?.requestId, }, async () => { const startedAt = performance.now(); const cwd = await resolveWorkingDirectory(validated.cwd); const tokens = tokenizeCommand(validated.command); const command = tokens[0]; const args = tokens.slice(1); if (!command) { throw new TerminalServiceError("Command cannot be empty", { code: "invalid_request", statusCode: 400, }); } if (!ALLOWED_COMMANDS.has(command)) { throw new TerminalServiceError( `Command '${command}' is not allowed in this terminal`, { code: "forbidden_command", statusCode: 403, }, ); } if (command === "help") { return { cwd, lines: renderHelp(), exitCode: 0, durationMs: Number((performance.now() - startedAt).toFixed(2)), }; } if (command === "history") { return { cwd, lines: renderHistory(validated.history), exitCode: 0, durationMs: Number((performance.now() - startedAt).toFixed(2)), }; } if (command === "clear") { return { cwd, lines: [toOutputLine("info", "__CLEAR__")], exitCode: 0, durationMs: Number((performance.now() - startedAt).toFixed(2)), }; } if (command === "cd") { const next = await handleCd(cwd, args[0]); return { cwd: next.cwd, lines: next.lines, exitCode: next.lines.length === 0 ? 0 : 1, durationMs: Number((performance.now() - startedAt).toFixed(2)), }; } try { const result = await executeExternalCommand(command, args, cwd); const lines: TerminalOutputLine[] = []; if (result.stdout) { lines.push(toOutputLine("output", result.stdout)); } if (result.stderr) { lines.push(toOutputLine("error", result.stderr)); } if (result.timedOut) { lines.push( toOutputLine( "error", `Command timed out after ${COMMAND_TIMEOUT_MS}ms`, ), ); throw new TerminalServiceError("Terminal command timed out", { code: "timeout", statusCode: 408, }); } if (result.exitCode !== 0 && !result.stderr) { lines.push( toOutputLine( "error", `Command exited with code ${result.exitCode}`, ), ); } return { cwd, lines, exitCode: result.exitCode, durationMs: Number((performance.now() - startedAt).toFixed(2)), }; } catch (error) { if (error instanceof TerminalServiceError) { throw error; } if (error instanceof Error && "code" in error && error.code === "ENOENT") { throw new TerminalServiceError(`Command not found: ${command}`, { code: "command_not_found", statusCode: 404, cause: error, }); } throw new TerminalServiceError("Failed to execute terminal command", { code: "execution_failed", statusCode: 500, cause: error, }); } }, ); }