/**
* Complete fix for SSH interactive mode
* This bypasses the command queue entirely for interactive sessions
*/
import { ClientChannel } from 'ssh2';
import { Logger } from '../utils/logger.js';
export class SSHInteractiveFix {
private static logger = new Logger('SSHInteractiveFix');
private static interactiveSessions = new Set<string>();
/**
* Mark a session as interactive
*/
static markInteractive(sessionId: string): void {
this.interactiveSessions.add(sessionId);
this.logger.info(`Session ${sessionId} marked as interactive`);
}
/**
* Check if a session is interactive
*/
static isInteractive(sessionId: string): boolean {
return this.interactiveSessions.has(sessionId);
}
/**
* Clear interactive state
*/
static clearInteractive(sessionId: string): void {
this.interactiveSessions.delete(sessionId);
this.logger.info(`Session ${sessionId} cleared from interactive mode`);
}
/**
* Send input directly to SSH channel, bypassing all queues
*/
static async sendDirectToSSH(
channel: ClientChannel,
input: string
): Promise<void> {
return new Promise((resolve, reject) => {
this.logger.debug(`Sending direct to SSH: ${input.substring(0, 50)}...`);
channel.write(input, (error) => {
if (error) {
this.logger.error('Failed to send to SSH channel:', error);
reject(error);
} else {
resolve();
}
});
});
}
/**
* Send a key sequence directly to SSH channel
*/
static async sendKeyDirect(
channel: ClientChannel,
key: string
): Promise<void> {
const keyMap: Record<string, string> = {
enter: '\r',
tab: '\t',
escape: '\x1b',
backspace: '\x7f',
delete: '\x1b[3~',
'ctrl+c': '\x03',
'ctrl+d': '\x04',
'ctrl+z': '\x1a',
'ctrl+l': '\x0c',
'ctrl+x': '\x18',
'ctrl+o': '\x0f',
'ctrl+s': '\x13',
'ctrl+q': '\x11',
'ctrl+a': '\x01',
'ctrl+e': '\x05',
'ctrl+k': '\x0b',
'ctrl+u': '\x15',
'ctrl+w': '\x17',
'ctrl+break': '\x03',
up: '\x1b[A',
down: '\x1b[B',
right: '\x1b[C',
left: '\x1b[D',
home: '\x1b[H',
end: '\x1b[F',
pageup: '\x1b[5~',
pagedown: '\x1b[6~',
f1: '\x1bOP',
f2: '\x1bOQ',
f3: '\x1bOR',
f4: '\x1bOS',
f5: '\x1b[15~',
f6: '\x1b[17~',
f7: '\x1b[18~',
f8: '\x1b[19~',
f9: '\x1b[20~',
f10: '\x1b[21~',
};
const sequence = keyMap[key.toLowerCase()] || key;
return this.sendDirectToSSH(channel, sequence);
}
/**
* Detect interactive programs from command
*/
static isInteractiveCommand(command: string): boolean {
const interactivePrograms = [
'nano',
'vim',
'vi',
'emacs',
'pico',
'joe',
'less',
'more',
'man',
'top',
'htop',
'iotop',
'iftop',
'mysql',
'psql',
'sqlite3',
'mongo',
'redis-cli',
'python',
'python3',
'node',
'irb',
'php -a',
'julia',
'ssh',
'telnet',
'ftp',
'sftp',
'screen',
'tmux',
'byobu',
'gdb',
'pdb',
'lldb',
'crontab -e',
'visudo',
'passwd',
'su -',
'sudo -i',
'mc',
'ranger',
'nnn', // file managers
'tig',
'gitui', // git interfaces
'ncdu',
'btop',
'glances', // system monitors
];
const cmd = command.toLowerCase().trim();
return interactivePrograms.some(
(prog) =>
cmd === prog ||
cmd.startsWith(prog + ' ') ||
cmd.includes('/' + prog + ' ') ||
cmd.endsWith('/' + prog)
);
}
/**
* Patch ConsoleManager to handle interactive SSH properly
*/
static patchConsoleManager(consoleManager: any): void {
const originalSendInput = consoleManager.sendInput.bind(consoleManager);
const originalSendKey = consoleManager.sendKey.bind(consoleManager);
const originalExecuteCommand =
consoleManager.executeCommand.bind(consoleManager);
// Track when interactive commands are executed
consoleManager.executeCommand = async function (
sessionId: string,
command: string,
args?: string[]
): Promise<void> {
const fullCommand =
args && args.length > 0 ? `${command} ${args.join(' ')}` : command;
// Check if this is an interactive command
if (SSHInteractiveFix.isInteractiveCommand(fullCommand)) {
SSHInteractiveFix.markInteractive(sessionId);
SSHInteractiveFix.logger.info(
`Executing interactive command in session ${sessionId}: ${fullCommand}`
);
}
// For SSH sessions with interactive commands, send directly
const sshChannel = this.sshChannels?.get(sessionId);
if (sshChannel && SSHInteractiveFix.isInteractive(sessionId)) {
SSHInteractiveFix.logger.info(
`Sending interactive command directly to SSH channel: ${fullCommand}`
);
await SSHInteractiveFix.sendDirectToSSH(sshChannel, fullCommand + '\n');
return;
}
return originalExecuteCommand.call(this, sessionId, command, args);
};
// Override sendInput for interactive sessions
consoleManager.sendInput = async function (
sessionId: string,
input: string
): Promise<void> {
const sshChannel = this.sshChannels?.get(sessionId);
// If it's an SSH session and it's interactive, bypass everything
if (sshChannel && SSHInteractiveFix.isInteractive(sessionId)) {
SSHInteractiveFix.logger.info(
`Sending input directly to interactive SSH session ${sessionId}`
);
await SSHInteractiveFix.sendDirectToSSH(sshChannel, input);
return;
}
return originalSendInput.call(this, sessionId, input);
};
// Override sendKey for interactive sessions
consoleManager.sendKey = async function (
sessionId: string,
key: string
): Promise<void> {
const sshChannel = this.sshChannels?.get(sessionId);
// If it's an SSH session and it's interactive, send key directly
if (sshChannel && SSHInteractiveFix.isInteractive(sessionId)) {
SSHInteractiveFix.logger.info(
`Sending key '${key}' directly to interactive SSH session ${sessionId}`
);
await SSHInteractiveFix.sendKeyDirect(sshChannel, key);
return;
}
return originalSendKey.call(this, sessionId, key);
};
// Clean up on session close
const originalCleanupSession =
consoleManager.cleanupSession?.bind(consoleManager);
if (originalCleanupSession) {
consoleManager.cleanupSession = function (sessionId: string) {
SSHInteractiveFix.clearInteractive(sessionId);
return originalCleanupSession.call(this, sessionId);
};
}
this.logger.info('ConsoleManager patched with SSHInteractiveFix');
}
}