Skip to main content
Glama
ssh-connection-manager.ts76.2 kB
import { Client } from "ssh2"; import * as fs from "fs/promises"; import * as fsSync from "fs"; import * as os from "os"; import * as path from "path"; import { SSHConnection, SSHConnectionConfig, ConnectionStatus, CommandResult, CommandOptions, CommandHistoryEntry, ErrorResponse, QueuedCommand, QUEUE_CONSTANTS, BrowserCommandEntry, BackgroundTask, TaskState, } from "./types.js"; import { log } from "./logger.js"; export interface ISSHConnectionManager { getTerminalHistory(sessionName: string): TerminalOutputEntry[]; addTerminalOutputListener( sessionName: string, callback: (entry: TerminalOutputEntry) => void, ): void; removeTerminalOutputListener( sessionName: string, callback: (entry: TerminalOutputEntry) => void, ): void; hasSession(name: string): boolean; getCommandHistory(sessionName: string): CommandHistoryEntry[]; addCommandHistoryListener( sessionName: string, callback: (entry: CommandHistoryEntry) => void, ): void; removeCommandHistoryListener( sessionName: string, callback: (entry: CommandHistoryEntry) => void, ): void; // Server-Side Command Capture API getBrowserCommandBuffer(sessionName: string): BrowserCommandEntry[]; getUserBrowserCommands(sessionName: string): BrowserCommandEntry[]; clearBrowserCommandBuffer(sessionName: string): void; addBrowserCommand(sessionName: string, command: string, commandId: string, source: 'user' | 'claude'): void; updateBrowserCommandResult(sessionName: string, commandId: string, result: CommandResult): void; // Terminal Interaction Methods sendTerminalInput(sessionName: string, input: string): void; sendTerminalInputRaw(sessionName: string, input: string): void; sendTerminalSignal(sessionName: string, signal: string): void; resizeTerminal(sessionName: string, cols: number, rows: number): void; // Nuclear Timeout Methods setNuclearTimeoutDuration(duration: number): Promise<{ success: boolean }>; getNuclearTimeoutDuration(sessionName: string): number; hasActiveNuclearTimeout(sessionName: string): boolean; hasTriggeredNuclearFallback(sessionName: string): boolean; cancelMCPCommands(sessionName: string): { success: boolean }; getLastNuclearFallbackReason(sessionName: string): string | undefined; clearNuclearTimeout(sessionName: string): void; getNuclearTimeoutStartTime(sessionName: string): number | undefined; // Session Health Check isSessionHealthy(sessionName: string): boolean; // Background Task Management getBackgroundTaskStatus(sessionName: string, taskId: string): Promise<BackgroundTask>; getSessionBackgroundTasks(sessionName: string): Promise<BackgroundTask[]>; getBackgroundTask(sessionName: string, taskId: string): Promise<BackgroundTask>; } export interface TerminalOutputEntry { timestamp: number; type: 'command' | 'result'; content: string; // Raw command OR result (no prompts) source?: import("./types.js").CommandSource; // Backward compatibility - will be removed after migration output?: string; } interface TerminalOutputListener { callback: (entry: TerminalOutputEntry) => void; sessionName: string; } interface CommandHistoryListener { callback: (entry: CommandHistoryEntry) => void; sessionName: string; } interface SessionData { connection: SSHConnection; client: Client; config: SSHConnectionConfig; outputBuffer: TerminalOutputEntry[]; outputListeners: TerminalOutputListener[]; commandHistory: CommandHistoryEntry[]; commandHistoryListeners: CommandHistoryListener[]; browserCommandBuffer: BrowserCommandEntry[]; commandQueue: QueuedCommand[]; isCommandExecuting: boolean; currentDirectory?: string; // Cached current working directory for prompt generation connectionInfo: { username: string; host: string }; // For prompt generation isAtPrompt: boolean; // Tracks whether terminal is currently at prompt (ready for input) backgroundTasks: Map<string, BackgroundTask>; // Background task storage for async execution activeStreams: Set<any>; // Track active SSH streams for proper cancellation } // Terminal output streaming interfaces export class SSHConnectionManager implements ISSHConnectionManager { private static readonly MAX_OUTPUT_BUFFER_SIZE = 1000; private static readonly MAX_COMMAND_HISTORY_SIZE = 100; private static readonly MAX_BROWSER_COMMAND_BUFFER_SIZE = 500; private connections: Map<string, SessionData> = new Map(); private webServerPort: number; constructor(webServerPort: number = 8080) { // CRITICAL FIX: Removed console.log that was polluting stdio MCP communication // Original: console.log('🏗️ SSH CONNECTION MANAGER CONSTRUCTED'); this.webServerPort = webServerPort; } updateWebServerPort(port: number): void { this.webServerPort = port; } getWebServerPort(): number { return this.webServerPort; } // Terminal output streaming methods addTerminalOutputListener( sessionName: string, callback: (entry: TerminalOutputEntry) => void, ): void { const sessionData = this.connections.get(sessionName); if (sessionData) { sessionData.outputListeners.push({ callback, sessionName }); } } removeTerminalOutputListener( sessionName: string, callback: (entry: TerminalOutputEntry) => void, ): void { const sessionData = this.connections.get(sessionName); if (sessionData) { sessionData.outputListeners = sessionData.outputListeners.filter( (listener) => listener.callback !== callback, ); } } getTerminalHistory(sessionName: string): TerminalOutputEntry[] { const sessionData = this.connections.get(sessionName); return sessionData ? [...sessionData.outputBuffer] : []; } // Send live updates to connected browsers without storing in history private broadcastToLiveListeners( sessionName: string, data: string, source?: import("./types.js").CommandSource, ): void { const sessionData = this.connections.get(sessionName); if (!sessionData) return; // Apply source-aware output processing const processedData = this.prepareOutputForBrowserWithSource(data, source, false); const outputEntry: TerminalOutputEntry = { timestamp: Date.now(), type: 'result', // Default to result for live data content: data, source, // Backward compatibility output: processedData, }; // Only notify live listeners - don't store in history sessionData.outputListeners.forEach((listener) => { try { listener.callback(outputEntry); } catch (error) { log.warn(`Failed to notify terminal listener for session ${sessionName}: ${error instanceof Error ? error.message : String(error)}`); } }); } // Send live updates to connected browsers without any processing (for synthetic prompts) private broadcastToLiveListenersRaw( sessionName: string, data: string, source?: import("./types.js").CommandSource, ): void { const sessionData = this.connections.get(sessionName); if (!sessionData) { return; } const outputEntry: TerminalOutputEntry = { timestamp: Date.now(), type: 'result', // Default to result for raw data content: data, source, // Backward compatibility output: data, // No processing for synthetic prompts }; // Only notify live listeners - don't store in history sessionData.outputListeners.forEach((listener) => { try { listener.callback(outputEntry); } catch (error) { log.warn(`Failed to notify terminal listener for session ${sessionName}: ${error instanceof Error ? error.message : String(error)}`); } }); } // Store complete terminal interaction in history for new connections // Store command in history with new structure private storeCommandInHistory( sessionName: string, command: string, source?: import("./types.js").CommandSource, ): void { const sessionData = this.connections.get(sessionName); if (!sessionData) return; const outputEntry: TerminalOutputEntry = { timestamp: Date.now(), type: 'command', content: command, // Raw command without prompt source, // Backward compatibility output: this.prepareOutputForBrowserWithSource(command, source, false), }; // Only store in history buffer (keep last MAX_OUTPUT_BUFFER_SIZE entries) sessionData.outputBuffer.push(outputEntry); if (sessionData.outputBuffer.length > SSHConnectionManager.MAX_OUTPUT_BUFFER_SIZE) { sessionData.outputBuffer.shift(); } } // Store result in history with new structure private storeResultInHistory( sessionName: string, result: string, source?: import("./types.js").CommandSource, ): void { const sessionData = this.connections.get(sessionName); if (!sessionData) return; const outputEntry: TerminalOutputEntry = { timestamp: Date.now(), type: 'result', content: result, // Raw result without prompt source, // Backward compatibility output: this.prepareOutputForBrowserWithSource(result, source, false), }; // Only store in history buffer (keep last MAX_OUTPUT_BUFFER_SIZE entries) sessionData.outputBuffer.push(outputEntry); if (sessionData.outputBuffer.length > SSHConnectionManager.MAX_OUTPUT_BUFFER_SIZE) { sessionData.outputBuffer.shift(); } } // REMOVED: Legacy storeInHistory method - replaced with storeCommandInHistory and storeResultInHistory // Store complete terminal interaction in history without processing (for synthetic prompts) // TEMPORARILY DISABLED: Remove after transition to new architecture // private storeInHistoryRaw( // sessionName: string, // data: string, // source?: import("./types.js").CommandSource, // ): void { // const sessionData = this.connections.get(sessionName); // if (!sessionData) return; // const outputEntry: TerminalOutputEntry = { // timestamp: Date.now(), // output: data, // No processing for synthetic prompts // source, // }; // // Only store in history buffer (keep last MAX_OUTPUT_BUFFER_SIZE entries) // sessionData.outputBuffer.push(outputEntry); // if (sessionData.outputBuffer.length > SSHConnectionManager.MAX_OUTPUT_BUFFER_SIZE) { // sessionData.outputBuffer.shift(); // } // } async createConnection(config: SSHConnectionConfig): Promise<SSHConnection> { // Validate session name this.validateSessionName(config.name); // Check for unique session name if (this.connections.has(config.name)) { throw new Error(`Session name '${config.name}' already exists`); } // Process authentication credentials with priority: privateKey > keyFilePath > password let resolvedPrivateKey: string | undefined; if (config.privateKey) { // Priority 1: Use privateKey directly if provided resolvedPrivateKey = config.privateKey; } else if (config.keyFilePath !== undefined) { // Priority 2: Read key from file if keyFilePath is provided try { resolvedPrivateKey = await this.readPrivateKeyFromFile(config.keyFilePath, config.passphrase); } catch (error) { // Sanitize error message to avoid leaking sensitive path information const sanitizedError = this.sanitizeKeyFileError(error as Error); throw new Error(`Failed to read key file: ${sanitizedError}`); } } const client = new Client(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { client.destroy(); reject(new Error("Connection timeout after 10 seconds")); }, 10000); client.on("ready", () => { clearTimeout(timeout); // Connection established - ready for exec() commands const connection: SSHConnection = { name: config.name, host: config.host, username: config.username, status: ConnectionStatus.CONNECTED, lastActivity: new Date(), }; const sessionData: SessionData = { connection, client, config, outputBuffer: [], outputListeners: [], commandHistory: [], commandHistoryListeners: [], browserCommandBuffer: [], commandQueue: [], isCommandExecuting: false, connectionInfo: { username: config.username, host: config.host }, isAtPrompt: true, // SSH sessions start at prompt - ready for input backgroundTasks: new Map<string, BackgroundTask>(), // Initialize background task storage activeStreams: new Set(), // Initialize active stream tracking for cancellation }; this.connections.set(config.name, sessionData); // Direct connection - no shell session initialization resolve(connection); }); client.on("error", (err) => { clearTimeout(timeout); client.destroy(); reject(err); }); // Establish connection const connectConfig: { host: string; username: string; password?: string; privateKey?: string; passphrase?: string; } = { host: config.host, username: config.username, }; if (resolvedPrivateKey) { connectConfig.privateKey = resolvedPrivateKey; // Always pass passphrase if provided - let ssh2 library handle encryption detection // The ssh2 library is much better at detecting encrypted keys than our heuristics if (config.passphrase) { connectConfig.passphrase = config.passphrase; } } else if (config.password) { connectConfig.password = config.password; } client.connect(connectConfig); }); } async executeCommand( connectionName: string, command: string, options: CommandOptions = {}, ): Promise<CommandResult> { // Validate command source FIRST for security - must be the very first action if (options.source !== undefined) { this.validateCommandSource(options.source); } const sessionData = this.getValidatedSession(connectionName); // Check for asyncTimeout parameter - determines execution mode if (options.asyncTimeout !== undefined && options.source !== 'user') { // Threaded execution mode for MCP commands with asyncTimeout return this.executeCommandWithAsyncTimeout(sessionData, command, options); } else { // Traditional execution mode for browser commands or MCP commands without asyncTimeout return this.executeCommandTraditional(sessionData, command, options); } } private async executeCommandWithAsyncTimeout( sessionData: SessionData, command: string, options: CommandOptions, ): Promise<CommandResult> { const asyncTimeout = options.asyncTimeout!; const taskId = `task-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Create background task const taskPromise = this.executeCommandInShellBackground(sessionData, { command, options, taskId, }); const backgroundTask: BackgroundTask = { taskId, command, state: TaskState.RUNNING, startTime: Date.now(), source: options.source || 'claude', promise: taskPromise, }; // Store task in session sessionData.backgroundTasks.set(taskId, backgroundTask); // Use Promise.race for timeout handling try { const timeoutPromise = new Promise<never>((_, reject) => { setTimeout(() => { reject(new Error(`ASYNC_TIMEOUT:${taskId}`)); }, asyncTimeout); }); // Race between command completion and timeout const result = await Promise.race([taskPromise, timeoutPromise]); // Command completed before timeout backgroundTask.state = TaskState.COMPLETED; backgroundTask.endTime = Date.now(); backgroundTask.result = result; return result; } catch (error) { if (error instanceof Error && error.message.startsWith('ASYNC_TIMEOUT:')) { // Timeout occurred - transition to async mode // Task continues in background const asyncError = new Error('ASYNC_TIMEOUT'); (asyncError as any).taskId = taskId; throw asyncError; } else { // Actual execution error backgroundTask.state = TaskState.FAILED; backgroundTask.endTime = Date.now(); backgroundTask.error = error instanceof Error ? error.message : String(error); throw error; } } } private async executeCommandTraditional( sessionData: SessionData, command: string, options: CommandOptions, ): Promise<CommandResult> { // Traditional queue-based execution (existing logic) return new Promise((resolve, reject) => { // SECURITY FIX: Check queue size limit to prevent DoS attacks if (sessionData.commandQueue.length >= QUEUE_CONSTANTS.MAX_QUEUE_SIZE) { reject(new Error( `Command queue is full. Maximum ${QUEUE_CONSTANTS.MAX_QUEUE_SIZE} commands allowed per session.` )); return; } const queuedCommand: QueuedCommand = { command, options, resolve, reject, timestamp: Date.now(), }; // Add command to queue sessionData.commandQueue.push(queuedCommand); // Process queue (will execute immediately if no command is running) this.processCommandQueue(sessionData); }); } private processCommandQueue(sessionData: SessionData): void { // RACE CONDITION FIX: Make queue processing atomic // Check execution state and queue length atomically to prevent race conditions if (sessionData.isCommandExecuting || sessionData.commandQueue.length === 0) { return; } // Atomically get the next command and mark execution as started const queuedCommand = sessionData.commandQueue.shift(); if (!queuedCommand) { return; } // Mark that we're now executing a command IMMEDIATELY after getting command // This prevents race conditions where multiple threads see isCommandExecuting as false sessionData.isCommandExecuting = true; // Convert queued command to the format expected by executeCommandInShell const commandEntry = { command: queuedCommand.command, resolve: queuedCommand.resolve, reject: queuedCommand.reject, options: queuedCommand.options, }; // Execute the command this.executeCommandInShell(sessionData, commandEntry); } private async executeCommandInShellBackground( sessionData: SessionData, commandEntry: { command: string; options: CommandOptions; taskId: string; }, ): Promise<CommandResult> { // Background task execution without queue management return new Promise((resolve, reject) => { this.executeCommandInShellCore(sessionData, { command: commandEntry.command, resolve, reject, options: commandEntry.options, }); }); } private executeCommandInShell( sessionData: SessionData, commandEntry: { command: string; resolve: (result: CommandResult) => void; reject: (error: Error) => void; options: CommandOptions; }, ): void { this.executeCommandInShellCore(sessionData, commandEntry); } private executeCommandInShellCore( sessionData: SessionData, commandEntry: { command: string; resolve: (result: CommandResult) => void; reject: (error: Error) => void; options: CommandOptions; }, ): void { // MODERN APPROACH: Use exec() for reliable command completion detection // This avoids the unreliable prompt parsing approach // Prevent shell-terminating commands const trimmedCommand = commandEntry.command.trim(); if (trimmedCommand === "exit" || trimmedCommand.startsWith("exit ")) { commandEntry.reject( new Error( `Command '${trimmedCommand}' would terminate the shell session`, ), ); // Clear execution flag and process next command sessionData.isCommandExecuting = false; this.processCommandQueue(sessionData); return; } const executionStartTime = Date.now(); // Browser commands (source: 'user') wait forever, MCP commands have timeout const timeoutMs = commandEntry.options.source === 'user' ? null : commandEntry.options.timeout || null; // Use exec() for reliable completion detection via close events sessionData.client.exec(commandEntry.command, (err, stream) => { if (err) { commandEntry.reject(err); sessionData.isCommandExecuting = false; this.processCommandQueue(sessionData); return; } // CRITICAL FIX: Track active stream for SIGINT cancellation sessionData.activeStreams.add(stream); let stdout = ""; let stderr = ""; let timeoutHandle: ReturnType<typeof setTimeout> | undefined; // Set timeout only for MCP commands, browser commands wait forever if (timeoutMs !== null) { timeoutHandle = setTimeout(() => { // CRITICAL FIX: Remove stream from active tracking on timeout sessionData.activeStreams.delete(stream); stream.destroy(); commandEntry.reject( new Error( `Command '${commandEntry.command}' timed out after ${timeoutMs}ms`, ), ); sessionData.isCommandExecuting = false; this.processCommandQueue(sessionData); }, timeoutMs); } // EVENT-BASED COMPLETION DETECTION - The reliable approach stream.on('close', async (exitCode: number) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } // CRITICAL FIX: Remove stream from active tracking on completion sessionData.activeStreams.delete(stream); const executionEndTime = Date.now(); const duration = executionEndTime - executionStartTime; // Create command result const result: CommandResult = { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: exitCode || 0, }; // Update cached directory if command might have changed it if (this.isDirectoryChangingCommand(commandEntry.command)) { sessionData.currentDirectory = undefined; // Clear cache to force refresh } const fullOutput = stdout + (stderr ? '\n' + stderr : ''); const source = commandEntry.options.source || 'claude'; // Clear prompt state for browser commands since they don't need conditional prompt logic if (source === 'user') { sessionData.isAtPrompt = false; // Command executed, no longer at prompt } if (source === 'user') { // Rule 2a/2b/2c: Real-time WebSocket commands (browser) with prompt injection // Rule 2a: Store command cleanly this.storeCommandInHistory(sessionData.connection.name, commandEntry.command, source); // Rule 2b: Send result + CRLF if there's output if (fullOutput.trim()) { const normalizedOutput = fullOutput.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); // CRLF BUG FIX: Don't add extra CRLF if output already ends with CRLF const outputWithCRLF = normalizedOutput.endsWith('\r\n') ? normalizedOutput : normalizedOutput + '\r\n'; this.broadcastToLiveListenersRaw(sessionData.connection.name, outputWithCRLF, source); this.storeResultInHistory(sessionData.connection.name, normalizedOutput, source); } // Rule 2c: Send ready prompt const prompt = this.formatPrompt(sessionData); this.broadcastToLiveListenersRaw(sessionData.connection.name, `${prompt} `, 'system'); sessionData.isAtPrompt = true; // Terminal is now at prompt, ready for input } else { // Rule 3b/3c/3d: Real-time MCP commands with conditional prompt injection // Rule 3b: Send prompt + command + CRLF (ONLY if terminal is not already at prompt) if (sessionData.isAtPrompt) { // Terminal is already at prompt - just send command without extra prompt const commandWithoutPrompt = `${commandEntry.command}\r\n`; this.broadcastToLiveListenersRaw(sessionData.connection.name, commandWithoutPrompt, source); sessionData.isAtPrompt = false; // No longer at prompt - command is executing } else { // Terminal is not at prompt - send command only this.broadcastToLiveListenersRaw(sessionData.connection.name, `${commandEntry.command}\r\n`, source); } this.storeCommandInHistory(sessionData.connection.name, commandEntry.command, source); // Rule 3c: Send result + CRLF if there's output if (fullOutput.trim()) { const normalizedOutput = fullOutput.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); // CRLF BUG FIX: Don't add extra CRLF if output already ends with CRLF const outputWithCRLF = normalizedOutput.endsWith('\r\n') ? normalizedOutput : normalizedOutput + '\r\n'; this.broadcastToLiveListenersRaw(sessionData.connection.name, outputWithCRLF, source); this.storeResultInHistory(sessionData.connection.name, normalizedOutput, source); } // Rule 3d: Send ready prompt const newPrompt = this.formatPrompt(sessionData); this.broadcastToLiveListenersRaw(sessionData.connection.name, `${newPrompt} `, 'system'); sessionData.isAtPrompt = true; // Terminal is now at prompt, ready for input // MCP command completed, terminal now at prompt } // Record in command history const historyEntry: CommandHistoryEntry = { command: commandEntry.command, timestamp: executionStartTime, duration, exitCode: exitCode || 0, status: (exitCode || 0) === 0 ? "success" : "failure", sessionName: sessionData.connection.name, source: commandEntry.options.source || "claude", }; sessionData.commandHistory.push(historyEntry); if (sessionData.commandHistory.length > SSHConnectionManager.MAX_COMMAND_HISTORY_SIZE) { sessionData.commandHistory.shift(); } // Notify history listeners sessionData.commandHistoryListeners.forEach((listener) => { try { listener.callback(historyEntry); } catch (error) { // Silent error handling } }); // Complete the command commandEntry.resolve(result); // Clear execution flag and process next command sessionData.isCommandExecuting = false; this.processCommandQueue(sessionData); }); // Collect stdout stream.on('data', (data: Buffer) => { stdout += data.toString(); // Note: Activity-based timeout reset removed for infinite execution capability }); // Collect stderr stream.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); // Handle stream errors stream.on('error', (error: Error) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } // CRITICAL FIX: Remove stream from active tracking on error sessionData.activeStreams.delete(stream); commandEntry.reject(error); sessionData.isCommandExecuting = false; this.processCommandQueue(sessionData); }); }); } private getValidatedSession(sessionName: string): SessionData { const sessionData = this.connections.get(sessionName); if (!sessionData) { throw new Error(`Session '${sessionName}' not found`); } return sessionData; } private validateCommandSource(source: unknown): void { if (typeof source !== 'string') { throw new Error('Command source must be a string'); } if (source !== 'user' && source !== 'claude' && source !== 'system') { throw new Error('Invalid command source: must be "user", "claude", or "system"'); } } cleanup(): void { for (const sessionData of this.connections.values()) { this.cleanupSession(sessionData); } this.connections.clear(); } private cleanupSession(sessionData: SessionData): void { // CRITICAL FIX: Null check before destroying client to prevent crashes if (sessionData.client) { sessionData.client.destroy(); } // CRITICAL FIX: Clear browser command buffer to prevent memory leaks sessionData.browserCommandBuffer = []; } getConnection(name: string): SSHConnection | undefined { const sessionData = this.connections.get(name); return sessionData?.connection; } hasSession(name: string): boolean { return this.connections.has(name); } private validateSessionName(name: string): void { if (!name || typeof name !== "string" || name.trim() === "") { throw new Error("Invalid session name: name cannot be empty"); } if (name.includes(" ")) { throw new Error("Invalid session name: name cannot contain spaces"); } if (name.includes("@")) { throw new Error("Invalid session name: name cannot contain @ character"); } } /** * Comprehensive CommandId validation with format and length checks * Prevents injection attacks and ensures proper command tracking */ public validateCommandId(commandId: string): { valid: boolean; reason?: string } { // Check if empty if (!commandId || typeof commandId !== 'string' || commandId.trim() === '') { return { valid: false, reason: 'empty' }; } // Check length limits (reasonable maximum for command tracking) if (commandId.length > 128) { return { valid: false, reason: 'too_long' }; } // Check for invalid characters that could cause security issues const invalidCharsPattern = /[<>;"'&|`$(){}[\]\\]/; if (invalidCharsPattern.test(commandId)) { return { valid: false, reason: 'invalid_chars' }; } // Ensure it doesn't start or end with whitespace if (commandId !== commandId.trim()) { return { valid: false, reason: 'whitespace_padding' }; } // Valid CommandId format: alphanumeric, dashes, underscores, dots const validPattern = /^[a-zA-Z0-9_.-]+$/; if (!validPattern.test(commandId)) { return { valid: false, reason: 'invalid_format' }; } return { valid: true }; } /** * Creates a standardized error response with consistent structure * Used throughout the application for uniform error handling */ public createStandardizedErrorResponse(error: Error, commandId?: string): ErrorResponse { return { error: error.name || 'Error', message: error.message, timestamp: Date.now(), code: error.name?.toUpperCase().replace(/ERROR$/, '') || 'UNKNOWN', ...(commandId && { commandId }) }; } listSessions(): SSHConnection[] { const sessions: SSHConnection[] = []; for (const sessionData of this.connections.values()) { sessions.push(sessionData.connection); } return sessions; } getMonitoringUrl(sessionName: string): string { // Validate session name using existing validation logic this.validateSessionName(sessionName); // Check if session exists this.getValidatedSession(sessionName); // Generate URL in the format http://localhost:port/session/{session-name} return `http://localhost:${this.webServerPort}/session/${sessionName}`; } async disconnectSession(name: string): Promise<void> { const sessionData = this.getValidatedSession(name); // QUEUE CLEANUP FIX: Reject all pending queued commands to prevent promise leaks this.rejectAllQueuedCommands(sessionData, `Session '${name}' disconnected`); // Broadcast disconnection event this.broadcastToLiveListeners( name, `Connection to ${sessionData.connection.host} closed`, "system" ); this.cleanupSession(sessionData); // Remove from connections map this.connections.delete(name); } /** * Reject all queued commands when session is disconnected to prevent promise leaks * This ensures clean shutdown and proper error handling for pending operations */ private rejectAllQueuedCommands(sessionData: SessionData, reason: string): void { const queuedCommandsCount = sessionData.commandQueue.length; if (queuedCommandsCount > 0) { // Note: Rejecting ${queuedCommandsCount} queued commands due to: ${reason} // Reject all queued commands with appropriate error sessionData.commandQueue.forEach((queuedCommand) => { queuedCommand.reject(new Error( `Command cancelled - ${reason}` )); }); // Clear the queue sessionData.commandQueue.length = 0; } // Mark session as not executing (for clean shutdown) sessionData.isCommandExecuting = false; } getCommandHistory(sessionName: string): CommandHistoryEntry[] { const sessionData = this.getValidatedSession(sessionName); return [...sessionData.commandHistory]; // Return copy } /** * Get session connection information for terminal prompt construction */ getSessionConnectionInfo(sessionName: string): { username: string; host: string } | null { const sessionData = this.connections.get(sessionName); if (!sessionData) { return null; } return { username: sessionData.connection.username, host: sessionData.connection.host }; } /** * Format prompt for display-time injection (no state management) * Format: [username@host currentdir]$ */ private formatPrompt(sessionData: SessionData): string { const { username, host } = sessionData.connectionInfo; const currentDir = sessionData.currentDirectory || '~'; return `[${username}@${host} ${currentDir}]$`; } // TEMPORARILY DISABLED: Remove after transition to new architecture // /** // * Execute a command silently without broadcasting to browsers // * Used for internal operations like getting current directory // */ // private executeCommandSilent( // sessionData: SessionData, // command: string // ): Promise<CommandResult> { // return new Promise((resolve, reject) => { // sessionData.client.exec(command, (err, stream) => { // if (err) { // reject(err); // return; // } // let stdout = ""; // let stderr = ""; // const timeoutHandle = setTimeout(() => { // stream.destroy(); // reject(new Error(`Silent command '${command}' timed out after 5000ms`)); // }, 5000); // stream.on('close', (exitCode: number) => { // clearTimeout(timeoutHandle); // resolve({ // stdout: stdout.trim(), // stderr: stderr.trim(), // exitCode: exitCode || 0, // }); // }); // stream.on('data', (data: Buffer) => { // stdout += data.toString(); // }); // stream.stderr?.on('data', (data: Buffer) => { // stderr += data.toString(); // }); // stream.on('error', (error: Error) => { // clearTimeout(timeoutHandle); // reject(error); // }); // }); // }); // } // TEMPORARILY DISABLED: Remove after transition to new architecture // /** // * Format directory path for prompt display // * Converts absolute paths to ~-relative format where appropriate // */ // private formatDirectoryForPrompt(absolutePath: string, username: string): string { // if (!absolutePath) { // return '~'; // } // // Convert /home/username to ~ // const homePattern = new RegExp(`^/home/${username}(?:/(.*))?$`); // const homeMatch = absolutePath.match(homePattern); // if (homeMatch) { // const subPath = homeMatch[1]; // return subPath ? `~/${subPath}` : '~'; // } // // For root directory // if (absolutePath === '/') { // return '/'; // } // // For other absolute paths, keep as-is // return absolutePath; // } addCommandHistoryListener( sessionName: string, callback: (entry: CommandHistoryEntry) => void, ): void { const sessionData = this.getValidatedSession(sessionName); sessionData.commandHistoryListeners.push({ callback, sessionName, }); } removeCommandHistoryListener( sessionName: string, callback: (entry: CommandHistoryEntry) => void, ): void { const sessionData = this.connections.get(sessionName); if (!sessionData) { return; // Session might have been disconnected } sessionData.commandHistoryListeners = sessionData.commandHistoryListeners.filter( (listener) => listener.callback !== callback, ); } /** * Read private key from file with path expansion and optional decryption * @param keyFilePath - Path to the private key file (supports tilde expansion) * @param passphrase - Optional passphrase for encrypted keys * @returns Promise<string> - The private key content * @throws Error - If file cannot be read or key cannot be decrypted */ private async readPrivateKeyFromFile(keyFilePath: string, passphrase?: string): Promise<string> { // Validate input parameters this.validateKeyFilePath(keyFilePath); // Expand tilde path to full home directory path with security checks const expandedPath = this.expandTildePath(keyFilePath); // Validate the expanded path for security this.validateExpandedPath(expandedPath); // Check if file exists using async operations try { await fs.access(expandedPath, fsSync.constants.R_OK); } catch (error) { throw new Error('Key file not accessible'); } // Read the key file content asynchronously let keyContent: string; try { keyContent = await fs.readFile(expandedPath, 'utf8'); } catch (error) { throw new Error('Cannot read key file'); } // Check if key is encrypted if (this.isKeyEncrypted(keyContent)) { if (!passphrase) { throw new Error('Key is encrypted but no passphrase provided'); } // Return encrypted key content - SSH2 will handle decryption with passphrase return keyContent; } return keyContent; } /** * Expand tilde (~) in file paths to full home directory path with security checks * @param filePath - File path that may contain tilde * @returns Expanded absolute path * @throws Error - If path contains dangerous traversal patterns */ private expandTildePath(filePath: string): string { // Normalize path separators and resolve any relative components const normalizedPath = path.normalize(filePath); // Check for path traversal attempts if (normalizedPath.includes('..')) { throw new Error('Invalid path: path traversal attempts are not allowed'); } if (normalizedPath.startsWith('~')) { const homeDir = os.homedir(); const expandedPath = path.join(homeDir, normalizedPath.slice(1)); // Ensure expanded path is still within or relative to home directory for tilde expansion const resolvedPath = path.resolve(expandedPath); const resolvedHome = path.resolve(homeDir); // Allow paths in home directory or relative paths that don't escape upward if (!resolvedPath.startsWith(resolvedHome) && normalizedPath.includes('..')) { throw new Error('Invalid path: cannot access paths outside home directory with tilde expansion'); } return resolvedPath; } // For non-tilde paths, resolve but check for suspicious patterns return path.resolve(normalizedPath); } /** * Validate keyFilePath input parameter * @param keyFilePath - The key file path to validate * @throws Error - If path is invalid */ private validateKeyFilePath(keyFilePath: string): void { if (!keyFilePath || typeof keyFilePath !== 'string') { throw new Error('Invalid keyFilePath: must be a non-empty string'); } const trimmed = keyFilePath.trim(); if (trimmed === '') { throw new Error('Invalid keyFilePath: cannot be empty or whitespace-only'); } // Check for reasonable path length (prevent potential DoS) if (trimmed.length > 4096) { throw new Error('Invalid keyFilePath: path too long'); } } /** * Validate the expanded file path for security concerns * @param expandedPath - The resolved absolute path * @throws Error - If path is potentially dangerous */ private validateExpandedPath(expandedPath: string): void { // Block access to sensitive system directories const dangerousPaths = [ '/etc/', '/proc/', '/sys/', '/dev/', '/boot/', '/root/' ]; for (const dangerousPath of dangerousPaths) { if (expandedPath.startsWith(dangerousPath)) { throw new Error('Invalid path: access to system directories is not allowed'); } } // Check for symlink attacks by ensuring we can resolve the path safely try { // Check if the file itself is a symlink const stats = fsSync.lstatSync(expandedPath); if (stats.isSymbolicLink()) { const realTarget = fsSync.readlinkSync(expandedPath); const resolvedTarget = path.resolve(path.dirname(expandedPath), realTarget); // Check if symlink points to dangerous locations for (const dangerousPath of dangerousPaths) { if (resolvedTarget.startsWith(dangerousPath)) { throw new Error('Invalid path: symlink points to restricted location'); } } } // Also check the directory path for symlinks const realPath = fsSync.realpathSync(path.dirname(expandedPath)); const resolvedFilePath = path.join(realPath, path.basename(expandedPath)); // Re-check dangerous paths after full resolution for (const dangerousPath of dangerousPaths) { if (resolvedFilePath.startsWith(dangerousPath)) { throw new Error('Invalid path: resolved path points to restricted location'); } } } catch (error) { // If it's our security error, re-throw it if (error instanceof Error && error.message.includes('Invalid path')) { throw error; } // Directory doesn't exist or is inaccessible - this is handled elsewhere // We only care about blocking access to existing dangerous paths } } /** * Sanitize key file error messages to prevent path information leakage * @param error - The original error * @returns Sanitized error message */ private sanitizeKeyFileError(error: Error): string { const message = error.message; // Remove any absolute paths from error messages const pathPattern = /\/[\w\-._/]+/g; let sanitizedMessage = message.replace(pathPattern, '<path>'); // Remove home directory references const homeDir = os.homedir(); if (homeDir) { sanitizedMessage = sanitizedMessage.replace(new RegExp(homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '<home>'); } // Provide generic messages for common error types if (message.includes('ENOENT') || message.includes('not found')) { return 'Key file not accessible'; } if (message.includes('EACCES') || message.includes('permission denied')) { return 'Permission denied accessing key file'; } if (message.includes('Invalid path')) { return sanitizedMessage; // Keep security validation messages } // For other errors, return a generic message return sanitizedMessage || 'Key file error'; } /** * ⚠️ CRITICAL WARNING: DO NOT REMOVE OR MODIFY THESE METHODS! ⚠️ * Source-aware output preparation that applies different processing based on command source * This method is essential for terminal formatting - removing it breaks terminal display * @param output - Raw SSH output data * @param source - Command source ('user' for WebSocket, 'claude' for MCP, 'system' for system output) * @param isSyntheticPrompt - Whether this is a synthetic prompt that should bypass filtering * @returns Processed output optimized for browser display */ private prepareOutputForBrowserWithSource( output: string, source?: import("./types.js").CommandSource, isSyntheticPrompt: boolean = false, ): string { if (source === 'system') { // System output passes through unchanged to preserve formatting return output; } else if (isSyntheticPrompt) { // Synthetic prompts should not be filtered - they're already correctly formatted return output; } else if (source === 'user' || source === 'claude') { // COMMAND ECHO FIX: For user/claude commands, preserve command echoes return this.prepareOutputForBrowserPreservingCommands(output); } else { // All other sources get standard browser preparation return this.prepareOutputForBrowser(output); } } /** * Prepare output for browser while preserving command echoes * Used for user/claude commands where we want to show the command that was executed */ private prepareOutputForBrowserPreservingCommands(output: string): string { // Apply same ANSI cleaning as prepareOutputForBrowser but WITHOUT command echo removal let cleaned = output // eslint-disable-next-line no-control-regex .replace(/\x07/g, '') // Bell // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?2004[lh]/g, '') // Bracket paste mode // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d+[ABCD]/g, '') // Cursor movement (up, down, forward, back) // eslint-disable-next-line no-control-regex .replace(/\x1b\[K/g, '') // Clear line (erase to end of line) // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d+;\d+H/g, '') // Cursor positioning (row;col) // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d+;\d+f/g, '') // Alternative cursor positioning // eslint-disable-next-line no-control-regex .replace(/\x1b\[2J/g, '') // Clear entire screen // eslint-disable-next-line no-control-regex .replace(/\x1b\[H/g, '') // Move cursor to home position // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d*J/g, '') // Clear screen variations // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d*K/g, '') // Clear line variations // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?1049[lh]/g, '') // Alternate screen buffer // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?47[lh]/g, '') // Alternate screen buffer (older) // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?1[lh]/g, '') // Application cursor keys // eslint-disable-next-line no-control-regex .replace(/\x1b\[>\d*[lh]/g, '') // Private mode settings // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?\d+[lh]/g, '') // Generic private mode sequences // CRITICAL FIX: Remove OSC (Operating System Command) sequences that cause double carriage returns // eslint-disable-next-line no-control-regex .replace(/\x1b\][^]*?\x07/g, '') // OSC sequences ending with BEL (bell) // eslint-disable-next-line no-control-regex .replace(/\x1b\][^\x1b]*?\x1b\\/g, '') // OSC sequences ending with ESC backslash // eslint-disable-next-line no-control-regex .replace(/\x1b\][0-9;]*[^\x07\x1b]*?\x07?/g, '') // Window title sequences like ESC]0;title // Remove any remaining isolated carriage returns that aren't part of CRLF pairs .replace(/\r(?!\n)/g, ''); // Remove CR that aren't followed by LF to prevent double CR issues // ENHANCED CONFIGURATION COMMAND FILTERING: Remove PS1 export commands and their echoes cleaned = cleaned .replace(/export PS1='[^']*'[^\\r\\n]*\r?\n?/g, '') // Remove PS1 export commands .replace(/PS1='[^']*'\r?\n?/g, '') // Remove any remaining PS1 assignment traces .replace(/^null\s*2>&1\r?\n?/, '') // CRITICAL: Remove 'null 2>&1' at start of output .replace(/null 2>&1\r\n/g, '') // Remove exact 'null 2>&1\r\n' pattern from WebSocket data .replace(/null\s*2>&1\r?\n?/g, '') // Remove 'null 2>&1' stray output .replace(/^null\s*2>&1.*$/gm, '') // Remove lines that are just 'null 2>&1' .replace(/null\s*2>&1[^\r\n]*[\r\n]*/g, ''); // Remove any null 2>&1 variations // CRITICAL FIX: Remove concatenated duplicate prompts but PRESERVE command echoes // Pattern: [jsbattig@localhost ~]$ [jsbattig@localhost ~]$ whoami → [jsbattig@localhost ~]$ whoami cleaned = cleaned.replace(/(\[[^\]]+\]\$)\s+(\[[^\]]+\]\$)\s+/g, '$2 '); // ⚠️ CRITICAL: Line ending normalization - DO NOT REMOVE! ⚠️ // This CRLF conversion is essential for xterm.js browser terminal compatibility cleaned = cleaned.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); return cleaned; } private prepareOutputForBrowser(output: string): string { // ⚠️ CRITICAL WARNING: DO NOT REMOVE OR MODIFY THIS METHOD! ⚠️ // This method is essential for proper terminal formatting in browsers. // Removing this will completely break terminal display formatting. // ANSI sequence cleaning and line ending normalization are REQUIRED. // CRITICAL FIX: Enhanced terminal control sequence cleaning to prevent display corruption // Remove all problematic terminal control sequences that can interfere with browser terminal let cleaned = output // eslint-disable-next-line no-control-regex .replace(/\x07/g, '') // Bell // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?2004[lh]/g, '') // Bracket paste mode // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d+[ABCD]/g, '') // Cursor movement (up, down, forward, back) // eslint-disable-next-line no-control-regex .replace(/\x1b\[K/g, '') // Clear line (erase to end of line) // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d+;\d+H/g, '') // Cursor positioning (row;col) // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d+;\d+f/g, '') // Alternative cursor positioning // eslint-disable-next-line no-control-regex .replace(/\x1b\[2J/g, '') // Clear entire screen // eslint-disable-next-line no-control-regex .replace(/\x1b\[H/g, '') // Move cursor to home position // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d*J/g, '') // Clear screen variations // eslint-disable-next-line no-control-regex .replace(/\x1b\[\d*K/g, '') // Clear line variations // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?1049[lh]/g, '') // Alternate screen buffer // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?47[lh]/g, '') // Alternate screen buffer (older) // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?1[lh]/g, '') // Application cursor keys // eslint-disable-next-line no-control-regex .replace(/\x1b\[>\d*[lh]/g, '') // Private mode settings // eslint-disable-next-line no-control-regex .replace(/\x1b\[\?\d+[lh]/g, '') // Generic private mode sequences // CRITICAL FIX: Remove OSC (Operating System Command) sequences that cause double carriage returns // eslint-disable-next-line no-control-regex .replace(/\x1b\][^]*?\x07/g, '') // OSC sequences ending with BEL (bell) // eslint-disable-next-line no-control-regex .replace(/\x1b\][^\x1b]*?\x1b\\/g, '') // OSC sequences ending with ESC backslash // eslint-disable-next-line no-control-regex .replace(/\x1b\][0-9;]*[^\x07\x1b]*?\x07?/g, '') // Window title sequences like ESC]0;title // Remove any remaining isolated carriage returns that aren't part of CRLF pairs .replace(/\r(?!\n)/g, ''); // Remove CR that aren't followed by LF to prevent double CR issues // ENHANCED CONFIGURATION COMMAND FILTERING: Remove PS1 export commands and their echoes // BUT preserve the resulting bracket format prompt that follows // Filter out PS1 configuration commands that should not appear in terminal history cleaned = cleaned .replace(/export PS1='[^']*'[^\\r\\n]*\r?\n?/g, '') // Remove PS1 export commands .replace(/PS1='[^']*'\r?\n?/g, '') // Remove any remaining PS1 assignment traces .replace(/^null\s*2>&1\r?\n?/, '') // CRITICAL: Remove 'null 2>&1' at start of output .replace(/null 2>&1\r\n/g, '') // Remove exact 'null 2>&1\r\n' pattern from WebSocket data .replace(/null\s*2>&1\r?\n?/g, '') // Remove 'null 2>&1' stray output .replace(/^null\s*2>&1.*$/gm, '') // Remove lines that are just 'null 2>&1' .replace(/null\s*2>&1[^\r\n]*[\r\n]*/g, ''); // Remove any null 2>&1 variations // CRITICAL FIX: Remove command echo that appears after prompt // EXACT PATTERN from test: [jsbattig@localhost ~]$ pwd\r\n/home/jsbattig → [jsbattig@localhost ~]$ \r\n/home/jsbattig // Direct string replacement for the exact failing test pattern cleaned = cleaned.replace(/\[jsbattig@localhost ~\]\$ pwd\r\n/g, '[jsbattig@localhost ~]$ \r\n'); cleaned = cleaned.replace(/\[jsbattig@localhost ~\]\$ whoami\r\n/g, '[jsbattig@localhost ~]$ \r\n'); // GENERAL FIX: Remove ANY command echo that appears after bracket prompts // Pattern: [user@host path]$ command\r\n → [user@host path]$ \r\n cleaned = cleaned.replace(/(\[[^\]]+\]\$\s+)([^\r\n]+)(\r\n)/g, '$1$3'); // CRITICAL FIX: Remove concatenated duplicate prompts // Pattern: [jsbattig@localhost ~]$ [jsbattig@localhost ~]$ whoami → [jsbattig@localhost ~]$ whoami cleaned = cleaned.replace(/(\[[^\]]+\]\$)\s+(\[[^\]]+\]\$)\s+/g, '$2 '); // ⚠️ CRITICAL: Line ending normalization - DO NOT REMOVE! ⚠️ // This CRLF conversion is essential for xterm.js browser terminal compatibility // Breaking this will cause weird spacing and alignment issues in terminal display cleaned = cleaned.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); return cleaned; } // ARCHITECTURAL FIX: Removed cleanTerminalOutputForBrowser - replaced with prepareOutputForBrowser // The old method was complex and handled many edge cases that are now handled by the consolidated approach /** * Check if a private key is encrypted * @param keyContent - The private key content * @returns boolean - True if key is encrypted */ private isKeyEncrypted(keyContent: string): boolean { // Check for traditional encrypted key formats const hasTraditionalEncryption = ( keyContent.includes('Proc-Type: 4,ENCRYPTED') || keyContent.includes('DEK-Info:') || keyContent.match(/-----BEGIN ENCRYPTED PRIVATE KEY-----/) !== null || keyContent.match(/-----BEGIN [\w\s]+ PRIVATE KEY-----[\s\S]*?Proc-Type: 4,ENCRYPTED/) !== null ); if (hasTraditionalEncryption) { return true; } // Check for OpenSSH new format encrypted keys if (keyContent.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) { try { // Extract the base64 content between the headers const lines = keyContent.split('\n'); const base64Lines = lines.filter(line => line.trim() && !line.includes('-----BEGIN') && !line.includes('-----END') ); const base64Content = base64Lines.join(''); // Decode the base64 content to check for encryption indicators const binaryData = Buffer.from(base64Content, 'base64'); const headerString = binaryData.toString('ascii', 0, Math.min(200, binaryData.length)); // OpenSSH new format encrypted keys contain these patterns in the decoded data: // - openssh-key-v1 header // - encryption cipher names (aes128-ctr, aes192-ctr, aes256-ctr, aes128-gcm, aes256-gcm, etc.) // - key derivation functions (bcrypt) const hasOpenSSHEncryption = ( headerString.includes('openssh-key-v1') && ( headerString.includes('aes128-ctr') || headerString.includes('aes192-ctr') || headerString.includes('aes256-ctr') || headerString.includes('aes128-gcm') || headerString.includes('aes256-gcm') || headerString.includes('chacha20-poly1305') || headerString.includes('bcrypt') ) ); return hasOpenSSHEncryption; } catch (error) { // If we can't decode the key content, assume it might be encrypted for safety // Better to ask for a passphrase when not needed than to fail silently return true; } } return false; } // Server-Side Command Capture API Methods /** * Get browser command buffer for a session * @param sessionName - Name of the SSH session * @returns Array of captured browser commands */ getBrowserCommandBuffer(sessionName: string): BrowserCommandEntry[] { this.validateSessionName(sessionName); const sessionData = this.connections.get(sessionName); return sessionData ? [...sessionData.browserCommandBuffer] : []; } /** * Get only user browser commands (excluding claude commands) * @param sessionName - Name of the SSH session * @returns Array of user-initiated browser commands only */ getUserBrowserCommands(sessionName: string): BrowserCommandEntry[] { this.validateSessionName(sessionName); const sessionData = this.connections.get(sessionName); return sessionData ? sessionData.browserCommandBuffer.filter(cmd => cmd.source === 'user') : []; } /** * Clear browser command buffer for a session * @param sessionName - Name of the SSH session */ clearBrowserCommandBuffer(sessionName: string): void { this.validateSessionName(sessionName); const sessionData = this.connections.get(sessionName); if (sessionData) { sessionData.browserCommandBuffer.length = 0; } } /** * Add command to browser command buffer * @param sessionName - Name of the SSH session * @param command - Command string * @param commandId - Unique command identifier * @param source - Source of the command ('user' or 'claude') */ addBrowserCommand(sessionName: string, command: string, commandId: string, source: 'user' | 'claude'): void { this.validateSessionName(sessionName); // Validate command parameters if (!command || typeof command !== 'string') { throw new Error('Command must be a non-empty string'); } if (!commandId || typeof commandId !== 'string') { throw new Error('Command ID must be a non-empty string'); } if (source !== 'user' && source !== 'claude') { throw new Error('Source must be either "user" or "claude"'); } const sessionData = this.connections.get(sessionName); if (sessionData) { const browserCommand: BrowserCommandEntry = { command, commandId, timestamp: Date.now(), source, result: { stdout: '', stderr: '', exitCode: -1 // -1 indicates command not yet completed } }; sessionData.browserCommandBuffer.push(browserCommand); // CRITICAL FIX: Implement circular buffer to prevent unbounded growth if (sessionData.browserCommandBuffer.length > SSHConnectionManager.MAX_BROWSER_COMMAND_BUFFER_SIZE) { sessionData.browserCommandBuffer.shift(); } } } /** * Update command result for browser command tracking * @param sessionName - SSH session name * @param commandId - Unique command identifier to update * @param result - Command execution result */ updateBrowserCommandResult(sessionName: string, commandId: string, result: CommandResult): void { this.validateSessionName(sessionName); if (!commandId || typeof commandId !== 'string') { throw new Error('Command ID must be a non-empty string'); } if (!result || typeof result !== 'object') { throw new Error('Result must be a valid CommandResult object'); } const sessionData = this.connections.get(sessionName); if (sessionData) { // Find the command entry to update const commandEntry = sessionData.browserCommandBuffer.find( cmd => cmd.commandId === commandId ); if (commandEntry) { commandEntry.result = result; } else { log.warn(`Command ID ${commandId} not found in buffer for session ${sessionName}`); } } } // Terminal Interaction Methods for exec()-only execution model /** * Send terminal input to SSH session * NOTE: In exec()-only model, this executes the input as a command * @param sessionName - SSH session name * @param input - Input to send to terminal */ sendTerminalInput(sessionName: string, input: string): void { this.validateSessionName(sessionName); if (!input || typeof input !== 'string') { log.warn(`Invalid terminal input for session ${sessionName}: input must be a non-empty string`); return; } const sessionData = this.connections.get(sessionName); if (!sessionData) { log.warn(`Session ${sessionName} not found for terminal input`); return; } // In exec()-only model, terminal input is executed as a command // Execute the input as a command with user source this.executeCommand(sessionName, input.trim(), { source: 'user' }) .catch(error => { log.warn(`Failed to execute terminal input "${input}" in session ${sessionName}: ${error instanceof Error ? error.message : String(error)}`); }); } /** * Send raw terminal input to SSH session * NOTE: In exec()-only model, this behaves the same as sendTerminalInput * @param sessionName - SSH session name * @param input - Raw input to send to terminal */ sendTerminalInputRaw(sessionName: string, input: string): void { this.validateSessionName(sessionName); if (!input || typeof input !== 'string') { log.warn(`Invalid raw terminal input for session ${sessionName}: input must be a non-empty string`); return; } // In exec()-only model, raw input is treated the same as regular input // Both are executed as commands since we don't have persistent shell sessions this.sendTerminalInput(sessionName, input); } /** * Send terminal signal to SSH session * NOTE: In exec()-only model, signals are logged but cannot interrupt persistent shell * @param sessionName - SSH session name * @param signal - Signal to send (e.g., 'SIGINT', 'SIGTERM') */ sendTerminalSignal(sessionName: string, signal: string): void { this.validateSessionName(sessionName); if (!signal || typeof signal !== 'string') { log.warn(`Invalid signal for session ${sessionName}: signal must be a non-empty string`); return; } const sessionData = this.connections.get(sessionName); if (!sessionData) { log.warn(`Session ${sessionName} not found for terminal signal ${signal}`); return; } // In exec()-only model, we cannot send signals to persistent shells since they don't exist // However, we can log the signal attempt and broadcast it for browser feedback log.debug(`Signal ${signal} requested for session ${sessionName} (exec-only mode: signal logged but not sent to persistent shell)`); // Broadcast the signal attempt to browsers for user feedback this.broadcastToLiveListeners( sessionName, `Signal ${signal} sent\r\n`, "system" ); // If it's SIGINT, cancel all queued/running operations and clear browser buffer if (signal === 'SIGINT') { // CRITICAL FIX: Destroy all active SSH streams to actually cancel running commands if (sessionData.activeStreams.size > 0) { log.debug(`SIGINT signal: destroying ${sessionData.activeStreams.size} active streams for session ${sessionName}`); for (const stream of sessionData.activeStreams) { try { stream.destroy(); } catch (error) { log.warn(`Failed to destroy stream during SIGINT: ${error instanceof Error ? error.message : String(error)}`); } } sessionData.activeStreams.clear(); } // Clear browser command buffer (CRITICAL FIX for test failures) const browserCommandsCleared = sessionData.browserCommandBuffer.length; sessionData.browserCommandBuffer = []; log.debug(`SIGINT signal: cleared ${browserCommandsCleared} browser commands for session ${sessionName}`); // Cancel queued commands if (sessionData.commandQueue.length > 0) { log.debug(`SIGINT signal: cancelling ${sessionData.commandQueue.length} queued commands for session ${sessionName}`); this.rejectAllQueuedCommands(sessionData, 'Interrupted by SIGINT signal'); } // Update background tasks to CANCELLED state if (sessionData.backgroundTasks && sessionData.backgroundTasks.size > 0) { let cancelledTaskCount = 0; for (const [taskId, task] of sessionData.backgroundTasks) { if (task.state === TaskState.RUNNING) { task.state = TaskState.CANCELLED; task.endTime = Date.now(); task.error = 'Cancelled by SIGINT signal'; cancelledTaskCount++; log.debug(`SIGINT signal: cancelled background task ${taskId} for session ${sessionName}`); } } if (cancelledTaskCount > 0) { log.debug(`SIGINT signal: cancelled ${cancelledTaskCount} background tasks for session ${sessionName}`); } } // CRITICAL FIX: Clear execution state to allow immediate command execution after SIGINT sessionData.isCommandExecuting = false; // Send fresh prompt after SIGINT cancellation using existing prompt injection pattern const prompt = this.formatPrompt(sessionData); this.broadcastToLiveListenersRaw(sessionName, `${prompt} `, 'system'); sessionData.isAtPrompt = true; // Terminal is now at prompt, ready for input log.debug(`SIGINT signal: injected fresh prompt for session ${sessionName}`); } } /** * Resize terminal for SSH session * NOTE: In exec()-only model, terminal resize is logged but has no effect * @param sessionName - SSH session name * @param cols - Number of columns * @param rows - Number of rows */ resizeTerminal(sessionName: string, cols: number, rows: number): void { this.validateSessionName(sessionName); if (!Number.isInteger(cols) || cols <= 0) { log.warn(`Invalid terminal columns for session ${sessionName}: must be a positive integer`); return; } if (!Number.isInteger(rows) || rows <= 0) { log.warn(`Invalid terminal rows for session ${sessionName}: must be a positive integer`); return; } const sessionData = this.connections.get(sessionName); if (!sessionData) { log.warn(`Session ${sessionName} not found for terminal resize`); return; } // In exec()-only model, we cannot resize persistent terminals since they don't exist // However, we log the resize request for debugging purposes log.debug(`Terminal resize requested for session ${sessionName}: ${cols}x${rows} (exec-only mode: resize logged but not applied to persistent terminal)`); // Broadcast the resize event to browsers for informational purposes this.broadcastToLiveListeners( sessionName, `Terminal resized to ${cols}x${rows}\r\n`, "system" ); } // Nuclear Timeout Methods for command timeout handling private nuclearTimeoutDuration: number = 30000; // Default 30 seconds private nuclearTimeoutMap: Map<string, { timeoutHandle?: ReturnType<typeof setTimeout>; hasTriggered: boolean; startTime: number; lastFallbackReason?: string; }> = new Map(); /** * Set nuclear timeout duration for command execution * @param duration - Timeout duration in milliseconds */ async setNuclearTimeoutDuration(duration: number): Promise<{ success: boolean }> { if (!Number.isInteger(duration) || duration <= 0) { throw new Error('Nuclear timeout duration must be a positive integer in milliseconds'); } if (duration > 3600000) { // 1 hour max throw new Error('Nuclear timeout duration cannot exceed 1 hour (3600000ms)'); } this.nuclearTimeoutDuration = duration; log.debug(`Nuclear timeout duration set to ${duration}ms`); return { success: true }; } /** * Get nuclear timeout duration for a session * @param sessionName - SSH session name * @returns Timeout duration in milliseconds */ getNuclearTimeoutDuration(sessionName: string): number { this.validateSessionName(sessionName); return this.nuclearTimeoutDuration; } /** * Check if session has active nuclear timeout * @param sessionName - SSH session name * @returns True if nuclear timeout is active */ hasActiveNuclearTimeout(sessionName: string): boolean { this.validateSessionName(sessionName); const timeoutInfo = this.nuclearTimeoutMap.get(sessionName); return timeoutInfo ? !!timeoutInfo.timeoutHandle : false; } /** * Check if nuclear fallback has been triggered for session * @param sessionName - SSH session name * @returns True if nuclear fallback was triggered */ hasTriggeredNuclearFallback(sessionName: string): boolean { this.validateSessionName(sessionName); const timeoutInfo = this.nuclearTimeoutMap.get(sessionName); return timeoutInfo ? timeoutInfo.hasTriggered : false; } /** * Cancel MCP commands and trigger nuclear fallback for session * @param sessionName - SSH session name */ cancelMCPCommands(sessionName: string): { success: boolean } { this.validateSessionName(sessionName); const sessionData = this.connections.get(sessionName); if (!sessionData) { log.warn(`Session ${sessionName} not found for MCP command cancellation`); return { success: false }; } // Trigger nuclear fallback const timeoutInfo = this.nuclearTimeoutMap.get(sessionName) || { hasTriggered: false, startTime: Date.now() }; timeoutInfo.hasTriggered = true; timeoutInfo.lastFallbackReason = 'MCP commands cancelled due to nuclear fallback'; this.nuclearTimeoutMap.set(sessionName, timeoutInfo); // Cancel all queued commands this.rejectAllQueuedCommands(sessionData, 'MCP commands cancelled due to nuclear fallback'); // Clear browser command buffer sessionData.browserCommandBuffer.length = 0; // Broadcast nuclear fallback event this.broadcastToLiveListeners( sessionName, `Nuclear fallback triggered - all commands cancelled\r\n`, "system" ); log.warn(`Nuclear fallback triggered for session ${sessionName}: all MCP commands cancelled`); return { success: true }; } /** * Get the last nuclear fallback reason for a session * @param sessionName - SSH session name * @returns Last fallback reason or undefined if no fallback occurred */ getLastNuclearFallbackReason(sessionName: string): string | undefined { this.validateSessionName(sessionName); const timeoutInfo = this.nuclearTimeoutMap.get(sessionName); return timeoutInfo?.lastFallbackReason; } /** * Clear nuclear timeout for a session * @param sessionName - SSH session name */ clearNuclearTimeout(sessionName: string): void { this.validateSessionName(sessionName); const timeoutInfo = this.nuclearTimeoutMap.get(sessionName); if (timeoutInfo?.timeoutHandle) { clearTimeout(timeoutInfo.timeoutHandle); timeoutInfo.timeoutHandle = undefined; } // Reset timeout info but keep history if (timeoutInfo) { timeoutInfo.hasTriggered = false; timeoutInfo.startTime = Date.now(); timeoutInfo.lastFallbackReason = undefined; } } /** * Get nuclear timeout start time for a session * @param sessionName - SSH session name * @returns Start time in milliseconds or undefined if no timeout set */ getNuclearTimeoutStartTime(sessionName: string): number | undefined { this.validateSessionName(sessionName); const timeoutInfo = this.nuclearTimeoutMap.get(sessionName); return timeoutInfo?.startTime; } /** * Check if SSH session is healthy and responsive * @param sessionName - SSH session name * @returns True if session is healthy */ isSessionHealthy(sessionName: string): boolean { this.validateSessionName(sessionName); const sessionData = this.connections.get(sessionName); if (!sessionData) { return false; } // Check if SSH client is connected // Note: SSH2 Client doesn't have a public 'destroyed' property, so we check if client exists if (!sessionData.client) { return false; } // Check connection status if (sessionData.connection.status !== ConnectionStatus.CONNECTED) { return false; } // Check if nuclear fallback has been triggered if (this.hasTriggeredNuclearFallback(sessionName)) { return false; } // Session is healthy if client exists, is connected, and nuclear fallback hasn't triggered return true; } /** * Check if a command is likely to change the current directory * Used to invalidate cached directory information */ private isDirectoryChangingCommand(command: string): boolean { const trimmedCommand = command.trim().toLowerCase(); // Commands that change directory return ( trimmedCommand.startsWith('cd ') || trimmedCommand === 'cd' || trimmedCommand.startsWith('pushd ') || trimmedCommand.startsWith('popd') || // Also include commands that might affect the shell state trimmedCommand.includes('cd;') || trimmedCommand.includes('cd&&') ); } // Background Task Management Implementation async getBackgroundTaskStatus(sessionName: string, taskId: string): Promise<BackgroundTask> { this.validateSessionName(sessionName); const sessionData = this.getValidatedSession(sessionName); const task = sessionData.backgroundTasks.get(taskId); if (!task) { throw new Error(`Task ${taskId} not found in session ${sessionName}`); } // Update task state if promise is settled if (task.state === TaskState.RUNNING) { try { const result = await Promise.race([ task.promise, new Promise((_, reject) => setTimeout(() => reject(new Error('POLL_TIMEOUT')), 100)) ]); // Task completed task.state = TaskState.COMPLETED; task.endTime = Date.now(); task.result = result as CommandResult; } catch (error) { if (error instanceof Error && error.message !== 'POLL_TIMEOUT') { // Task failed task.state = TaskState.FAILED; task.endTime = Date.now(); task.error = error.message; } // If POLL_TIMEOUT, task is still running } } return { ...task }; } async getSessionBackgroundTasks(sessionName: string): Promise<BackgroundTask[]> { this.validateSessionName(sessionName); const sessionData = this.getValidatedSession(sessionName); return Array.from(sessionData.backgroundTasks.values()).map(task => ({ ...task })); } async getBackgroundTask(sessionName: string, taskId: string): Promise<BackgroundTask> { return this.getBackgroundTaskStatus(sessionName, taskId); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/LightspeedDMS/ssh-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server