feat(KB-058): complete Step 2 — create TerminalService with node-pty

This commit is contained in:
gsxdsm
2026-03-29 23:25:21 -07:00
parent 4a2315934f
commit 99bfd4fb8f

View File

@@ -0,0 +1,587 @@
/**
* Terminal Service
*
* Manages PTY (pseudo-terminal) sessions using node-pty.
* Supports cross-platform shell detection and secure session management.
*/
import * as pty from "node-pty";
import { EventEmitter } from "events";
import * as os from "os";
import * as path from "path";
import { existsSync } from "node:fs";
// Maximum scrollback buffer size (characters)
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per terminal
// Session limit constants
const MIN_MAX_SESSIONS = 1;
const MAX_MAX_SESSIONS = 100;
const DEFAULT_MAX_SESSIONS = 10;
// Throttle output to prevent overwhelming WebSocket under heavy load
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive input
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
// Valid session ID pattern (alphanumeric and dashes only)
const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/;
// Allowed shell paths for security
const ALLOWED_SHELL_PATHS: Record<string, string[]> = {
darwin: ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/local/bin/bash", "/usr/local/bin/zsh"],
linux: ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh", "/usr/bin/sh"],
win32: [
"C:\\Windows\\System32\\cmd.exe",
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
"powershell.exe",
"cmd.exe",
],
};
// Environment variables to strip from user shells
const STRIP_ENV_VARS = [
"PORT",
"DATA_DIR",
"AUTOMAKER_API_KEY",
"NODE_PATH",
"GITHUB_TOKEN",
"KB_API_KEY",
];
export interface TerminalSession {
id: string;
pty: pty.IPty;
cwd: string;
createdAt: Date;
shell: string;
scrollbackBuffer: string;
outputBuffer: string;
flushTimeout: NodeJS.Timeout | null;
resizeInProgress: boolean;
resizeDebounceTimeout: NodeJS.Timeout | null;
}
export interface TerminalOptions {
cwd?: string;
shell?: string;
cols?: number;
rows?: number;
env?: Record<string, string>;
}
type DataCallback = (sessionId: string, data: string) => void;
type ExitCallback = (sessionId: string, exitCode: number) => void;
export class TerminalService extends EventEmitter {
private sessions: Map<string, TerminalSession> = new Map();
private dataCallbacks: Set<DataCallback> = new Set();
private exitCallbacks: Set<ExitCallback> = new Set();
private isWindows = os.platform() === "win32";
private projectRoot: string;
private maxSessions: number;
constructor(projectRoot: string, maxSessions: number = DEFAULT_MAX_SESSIONS) {
super();
this.projectRoot = path.resolve(projectRoot);
this.maxSessions = Math.max(MIN_MAX_SESSIONS, Math.min(maxSessions, MAX_MAX_SESSIONS));
}
/**
* Kill a PTY process with platform-specific handling.
* Windows doesn't support Unix signals like SIGTERM/SIGKILL.
*/
private killPtyProcess(ptyProcess: pty.IPty, signal: string = "SIGTERM"): void {
if (this.isWindows) {
ptyProcess.kill();
} else {
ptyProcess.kill(signal);
}
}
/**
* Get the default allowed shells for the current platform
*/
private getAllowedShells(): string[] {
const platform = os.platform();
return ALLOWED_SHELL_PATHS[platform] || ALLOWED_SHELL_PATHS.linux;
}
/**
* Validate that a shell path is allowed
*/
private isAllowedShell(shellPath: string): boolean {
const allowed = this.getAllowedShells();
const normalized = this.isWindows ? shellPath.toLowerCase() : shellPath;
return allowed.some((s) => (this.isWindows ? s.toLowerCase() : s) === normalized);
}
/**
* Detect the best shell for the current platform
*/
detectShell(): { shell: string; args: string[] } {
const platform = os.platform();
const allowedShells = this.getAllowedShells();
// Helper to get basename handling both path separators
const getBasename = (shellPath: string): string => {
const lastSep = Math.max(shellPath.lastIndexOf("/"), shellPath.lastIndexOf("\\"));
return lastSep >= 0 ? shellPath.slice(lastSep + 1) : shellPath;
};
// Helper to get shell args based on shell name
const getShellArgs = (shell: string): string[] => {
const shellName = getBasename(shell).toLowerCase().replace(".exe", "");
// PowerShell and cmd don't need --login
if (shellName === "powershell" || shellName === "pwsh" || shellName === "cmd") {
return [];
}
// sh doesn't support --login in all implementations
if (shellName === "sh") {
return [];
}
// bash, zsh, and other POSIX shells support --login
return ["--login"];
};
// First try user's shell from env if it's allowed
const userShell = process.env.SHELL;
if (userShell && platform !== "win32") {
const normalizedUserShell = this.isWindows ? userShell.toLowerCase() : userShell;
for (const allowed of allowedShells) {
const normalizedAllowed = this.isWindows ? allowed.toLowerCase() : allowed;
if (normalizedAllowed === normalizedUserShell && existsSync(allowed)) {
return { shell: allowed, args: getShellArgs(allowed) };
}
}
}
// Iterate through allowed shell paths and return first existing one
for (const shell of allowedShells) {
if (existsSync(shell)) {
return { shell, args: getShellArgs(shell) };
}
}
// Ultimate fallbacks based on platform
if (platform === "win32") {
return { shell: "cmd.exe", args: [] };
}
return { shell: "/bin/sh", args: [] };
}
/**
* Validate and resolve a working directory path
*/
private async resolveWorkingDirectory(requestedCwd?: string): Promise<string> {
// If no cwd requested, use project root
if (!requestedCwd) {
return this.projectRoot;
}
// Clean up the path
let cwd = requestedCwd.trim();
// Reject paths with null bytes (could bypass path checks)
if (cwd.includes("\0")) {
console.warn(`Rejecting path with null byte: ${cwd.replace(/\0/g, "\\0")}`);
return this.projectRoot;
}
// Normalize the path to resolve . and .. segments
cwd = path.resolve(this.projectRoot, cwd);
// Ensure path is within project root (path traversal protection)
if (!cwd.startsWith(this.projectRoot)) {
console.warn(`Path traversal attempt blocked: ${requestedCwd}`);
return this.projectRoot;
}
// Check if path exists and is a directory
try {
const stat = await import("node:fs/promises").then((fs) => fs.stat(cwd));
if (stat.isDirectory()) {
return cwd;
}
} catch {
// Path doesn't exist, fall back to project root
console.warn(`Working directory does not exist: ${cwd}`);
}
return this.projectRoot;
}
/**
* Validate session ID format
*/
private isValidSessionId(sessionId: string): boolean {
return SESSION_ID_PATTERN.test(sessionId);
}
/**
* Get current session count
*/
getSessionCount(): number {
return this.sessions.size;
}
/**
* Get maximum allowed sessions
*/
getMaxSessions(): number {
return this.maxSessions;
}
/**
* Set maximum allowed sessions
*/
setMaxSessions(limit: number): void {
if (limit >= MIN_MAX_SESSIONS && limit <= MAX_MAX_SESSIONS) {
this.maxSessions = limit;
}
}
/**
* Create a new terminal session
*/
async createSession(options: TerminalOptions = {}): Promise<TerminalSession | null> {
// Check session limit
if (this.sessions.size >= this.maxSessions) {
console.error(`Max sessions (${this.maxSessions}) reached, refusing new session`);
return null;
}
const id = `term-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const { shell: detectedShell, args: shellArgs } = this.detectShell();
const shell = options.shell || detectedShell;
// Validate shell is allowed
if (!this.isAllowedShell(shell)) {
console.error(`Shell not allowed: ${shell}`);
return null;
}
// Validate and resolve working directory
const cwd = await this.resolveWorkingDirectory(options.cwd);
// Build environment with stripped sensitive vars
const cleanEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !STRIP_ENV_VARS.includes(key)) {
cleanEnv[key] = value;
}
}
const env: Record<string, string> = {
...cleanEnv,
TERM: "xterm-256color",
COLORTERM: "truecolor",
TERM_PROGRAM: "kb-terminal",
LANG: process.env.LANG || "en_US.UTF-8",
LC_ALL: process.env.LC_ALL || process.env.LANG || "en_US.UTF-8",
...options.env,
};
console.info(`Creating session ${id} with shell: ${shell} in ${cwd}`);
// Build PTY spawn options
const ptyOptions: pty.IPtyForkOptions = {
name: "xterm-256color",
cols: options.cols || 80,
rows: options.rows || 24,
cwd,
env,
};
// On Windows, use winpty instead of ConPTY for compatibility
if (this.isWindows) {
(ptyOptions as pty.IWindowsPtyForkOptions).useConpty = false;
}
let ptyProcess: pty.IPty;
try {
ptyProcess = pty.spawn(shell, shellArgs, ptyOptions);
} catch (spawnError) {
console.error(`[createSession] PTY spawn failed:`, spawnError);
return null;
}
const session: TerminalSession = {
id,
pty: ptyProcess,
cwd,
createdAt: new Date(),
shell,
scrollbackBuffer: "",
outputBuffer: "",
flushTimeout: null,
resizeInProgress: false,
resizeDebounceTimeout: null,
};
this.sessions.set(id, session);
// Flush buffered output to clients (throttled)
const flushOutput = () => {
if (session.outputBuffer.length === 0) return;
let dataToSend = session.outputBuffer;
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
} else {
session.outputBuffer = "";
session.flushTimeout = null;
}
this.dataCallbacks.forEach((cb) => cb(id, dataToSend));
this.emit("data", id, dataToSend);
};
// Forward data events with throttling
ptyProcess.onData((data) => {
if (session.resizeInProgress) {
return;
}
// Append to scrollback buffer
session.scrollbackBuffer += data;
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
}
// Buffer output for throttled delivery
session.outputBuffer += data;
if (!session.flushTimeout) {
session.flushTimeout = setTimeout(flushOutput, OUTPUT_THROTTLE_MS);
}
});
// Handle exit
ptyProcess.onExit(({ exitCode }) => {
console.info(`Session exited with code ${exitCode ?? 0} (${id})`);
this.sessions.delete(id);
this.exitCallbacks.forEach((cb) => cb(id, exitCode ?? 0));
this.emit("exit", id, exitCode ?? 0);
});
console.info(`Session ${id} created successfully`);
return session;
}
/**
* Write data to a terminal session
*/
write(sessionId: string, data: string): boolean {
if (!this.isValidSessionId(sessionId)) {
return false;
}
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`Session ${sessionId} not found`);
return false;
}
// Reject data with null bytes
if (data.includes("\0")) {
console.warn(`Rejecting input with null byte to session ${sessionId}`);
return false;
}
session.pty.write(data);
return true;
}
/**
* Resize a terminal session
*/
resize(sessionId: string, cols: number, rows: number, suppressOutput: boolean = true): boolean {
if (!this.isValidSessionId(sessionId)) {
return false;
}
const session = this.sessions.get(sessionId);
if (!session) {
console.warn(`Session ${sessionId} not found for resize`);
return false;
}
try {
if (suppressOutput) {
session.resizeInProgress = true;
if (session.resizeDebounceTimeout) {
clearTimeout(session.resizeDebounceTimeout);
}
}
session.pty.resize(cols, rows);
if (suppressOutput) {
session.resizeDebounceTimeout = setTimeout(() => {
session.resizeInProgress = false;
session.resizeDebounceTimeout = null;
}, 150);
}
return true;
} catch (error) {
console.error(`Error resizing session ${sessionId}:`, error);
session.resizeInProgress = false;
return false;
}
}
/**
* Kill a terminal session
*/
killSession(sessionId: string): boolean {
if (!this.isValidSessionId(sessionId)) {
return false;
}
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
try {
// Clean up flush timeout
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
if (session.resizeDebounceTimeout) {
clearTimeout(session.resizeDebounceTimeout);
session.resizeDebounceTimeout = null;
}
// First try graceful SIGTERM
console.info(`Session ${sessionId} sending SIGTERM`);
this.killPtyProcess(session.pty, "SIGTERM");
// Schedule SIGKILL fallback
setTimeout(() => {
if (this.sessions.has(sessionId)) {
console.info(`Session ${sessionId} still alive after SIGTERM, sending SIGKILL`);
try {
this.killPtyProcess(session.pty, "SIGKILL");
} catch {
// Process may have already exited
}
this.sessions.delete(sessionId);
}
}, 1000);
return true;
} catch (error) {
console.error(`Error killing session ${sessionId}:`, error);
this.sessions.delete(sessionId);
return false;
}
}
/**
* Get a session by ID
*/
getSession(sessionId: string): TerminalSession | undefined {
if (!this.isValidSessionId(sessionId)) {
return undefined;
}
return this.sessions.get(sessionId);
}
/**
* Get scrollback buffer for a session
*/
getScrollback(sessionId: string): string | null {
if (!this.isValidSessionId(sessionId)) {
return null;
}
const session = this.sessions.get(sessionId);
return session?.scrollbackBuffer || null;
}
/**
* Get scrollback and clear pending output buffer
*/
getScrollbackAndClearPending(sessionId: string): string | null {
if (!this.isValidSessionId(sessionId)) {
return null;
}
const session = this.sessions.get(sessionId);
if (!session) return null;
session.outputBuffer = "";
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
session.flushTimeout = null;
}
return session.scrollbackBuffer || null;
}
/**
* Get all active sessions
*/
getAllSessions(): Array<{ id: string; cwd: string; createdAt: Date; shell: string }> {
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
cwd: s.cwd,
createdAt: s.createdAt,
shell: s.shell,
}));
}
/**
* Subscribe to data events
*/
onData(callback: DataCallback): () => void {
this.dataCallbacks.add(callback);
return () => this.dataCallbacks.delete(callback);
}
/**
* Subscribe to exit events
*/
onExit(callback: ExitCallback): () => void {
this.exitCallbacks.add(callback);
return () => this.exitCallbacks.delete(callback);
}
/**
* Clean up all sessions
*/
cleanup(): void {
console.info(`Cleaning up ${this.sessions.size} sessions`);
this.sessions.forEach((session, id) => {
try {
if (session.flushTimeout) {
clearTimeout(session.flushTimeout);
}
this.killPtyProcess(session.pty);
} catch {
// Ignore errors during cleanup
}
this.sessions.delete(id);
});
}
}
// Singleton instance (initialized lazily with project root)
let terminalService: TerminalService | null = null;
let initializedRoot: string | null = null;
export function getTerminalService(projectRoot?: string, maxSessions?: number): TerminalService {
if (!terminalService || (projectRoot && projectRoot !== initializedRoot)) {
if (!projectRoot) {
throw new Error("TerminalService requires projectRoot for initialization");
}
terminalService = new TerminalService(projectRoot, maxSessions);
initializedRoot = projectRoot;
}
return terminalService;
}