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);
}
}