/**
* Fix for SSH PTY allocation to support interactive programs
*/
import { Client as SSHClient, ClientChannel } from 'ssh2';
import { Logger } from '../utils/logger.js';
export interface PTYOptions {
term?: string;
cols?: number;
rows?: number;
height?: number;
width?: number;
modes?: any;
}
/**
* Create an SSH channel with proper PTY allocation
*/
export async function createSSHChannelWithPTY(
client: SSHClient,
sessionId: string,
ptyOptions?: PTYOptions
): Promise<ClientChannel> {
const logger = new Logger('SSHPtyFix');
return new Promise((resolve, reject) => {
// Default PTY options for interactive terminal
const defaultPtyOptions = {
term: process.env.TERM || 'xterm-256color',
cols: 80,
rows: 24,
height: 480,
width: 640,
modes: {
ECHO: 1,
ICANON: 1,
IEXTEN: 1,
ISIG: 1,
IUTF8: 1,
},
};
const finalOptions = { ...defaultPtyOptions, ...ptyOptions };
logger.debug(
`Creating SSH channel with PTY for session ${sessionId}`,
finalOptions
);
// Request a shell with PTY
client.shell(
{
term: finalOptions.term,
cols: finalOptions.cols,
rows: finalOptions.rows,
height: finalOptions.height,
width: finalOptions.width,
modes: finalOptions.modes,
},
(error, channel) => {
if (error) {
logger.error(
`Failed to create SSH channel with PTY for session ${sessionId}:`,
error
);
reject(error);
return;
}
logger.debug(
`SSH channel with PTY created successfully for session ${sessionId}`
);
// Set encoding
channel.setEncoding('utf8');
// Handle window resize if needed
channel.on('request', (accept, reject, info) => {
if (info === 'window-change') {
accept();
}
});
resolve(channel);
}
);
});
}
/**
* Send input to an SSH channel handling special keys properly
*/
export async function sendToSSHChannel(
channel: ClientChannel,
input: string,
isInteractive: boolean = false
): Promise<void> {
const logger = new Logger('SSHPtyFix');
return new Promise((resolve, reject) => {
try {
// Special key mappings for interactive programs
const keyMappings: Record<string, string> = {
'ctrl+c': '\x03',
'ctrl+d': '\x04',
'ctrl+z': '\x1a',
'ctrl+l': '\x0c',
escape: '\x1b',
tab: '\t',
enter: '\r\n',
backspace: '\x7f',
delete: '\x1b[3~',
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',
};
// Check if input is a special key
let dataToSend = input;
const lowerInput = input.toLowerCase().trim();
if (keyMappings[lowerInput]) {
dataToSend = keyMappings[lowerInput];
logger.debug(
`Sending special key: ${lowerInput} -> ${dataToSend.charCodeAt(0)}`
);
} else if (isInteractive) {
// For interactive mode, send character by character if it's a single char
// This helps with programs like vim that process individual keystrokes
if (input.length === 1) {
dataToSend = input;
} else if (!input.endsWith('\n') && !input.endsWith('\r')) {
// Add newline for multi-character input in interactive mode
dataToSend = input + '\r\n';
}
} else {
// Non-interactive mode - ensure commands end with newline
if (!input.endsWith('\n') && !input.endsWith('\r')) {
dataToSend = input + '\n';
}
}
// Write to channel
channel.write(dataToSend, (error) => {
if (error) {
logger.error(`Failed to send input to SSH channel:`, error);
reject(error);
} else {
logger.debug(`Input sent successfully: ${input.substring(0, 20)}...`);
resolve();
}
});
} catch (error) {
logger.error(`Error sending to SSH channel:`, error);
reject(error);
}
});
}
/**
* Patch the ConsoleManager's createSSHChannel method
*/
export function patchCreateSSHChannel(consoleManager: any): void {
const logger = new Logger('SSHPtyFix');
// Replace the createSSHChannel method
consoleManager.createSSHChannel = async function (
client: SSHClient,
sessionId: string
): Promise<ClientChannel> {
logger.info(`Patched createSSHChannel called for session ${sessionId}`);
return createSSHChannelWithPTY(client, sessionId);
};
logger.info('ConsoleManager.createSSHChannel patched for PTY support');
}
/**
* Check if a command requires interactive mode
*/
export function isInteractiveCommand(command: string): boolean {
const interactiveCommands = [
'nano',
'vim',
'vi',
'emacs',
'pico',
'less',
'more',
'man',
'top',
'htop',
'iotop',
'ssh',
'telnet',
'ftp',
'python',
'node',
'irb',
'mysql',
'psql',
'mongo',
];
const cmdLower = command.toLowerCase().trim();
return interactiveCommands.some(
(cmd) =>
cmdLower === cmd ||
cmdLower.startsWith(cmd + ' ') ||
cmdLower.includes('/' + cmd + ' ') ||
cmdLower.endsWith('/' + cmd)
);
}