ssh_session_start
Start a persistent SSH session to maintain state and context for remote server management, enabling command execution and file transfers across configured servers.
Instructions
Start a persistent SSH session that maintains state and context
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| server | Yes | Server name from configuration | |
| name | No | Optional session name for identification |
Implementation Reference
- src/tool-registry.js:22-28 (registration)'ssh_session_start' is listed in the TOOL_GROUPS.sessions array, used for conditional tool registration based on user configuration.// Sessions group (4 tools) - Persistent SSH session management sessions: [ 'ssh_session_start', 'ssh_session_send', 'ssh_session_list', 'ssh_session_close' ],
- src/session-manager.js:291-309 (handler)The createSession function creates, initializes, and returns a new persistent SSH session instance. This is the primary implementation logic for the 'ssh_session_start' tool.const sessionId = `ssh_${Date.now()}_${uuidv4().substring(0, 8)}`; const session = new SSHSession(sessionId, serverName, ssh); sessions.set(sessionId, session); try { await session.initialize(); logger.info('SSH session created', { id: sessionId, server: serverName }); return session; } catch (error) { sessions.delete(sessionId); throw error; } }
- src/session-manager.js:21-285 (helper)SSHSession class provides the stateful session management, shell handling, command execution, context tracking (CWD, env, history), and lifecycle methods used by ssh_session_start.class SSHSession { constructor(id, serverName, ssh) { this.id = id; this.serverName = serverName; this.ssh = ssh; this.state = SESSION_STATES.INITIALIZING; this.context = { cwd: null, env: {}, history: [], variables: {} }; this.createdAt = new Date(); this.lastActivity = new Date(); this.shell = null; this.outputBuffer = ''; this.errorBuffer = ''; } /** * Initialize the session with a shell */ async initialize() { try { logger.info(`Initializing SSH session ${this.id}`, { server: this.serverName }); // Start an interactive shell this.shell = await this.ssh.requestShell({ term: 'xterm-256color', cols: 80, rows: 24 }); // Setup event handlers this.shell.on('data', (data) => { this.outputBuffer += data.toString(); this.lastActivity = new Date(); // Log output in verbose mode if (logger.verbose) { logger.debug(`Session ${this.id} output`, { data: data.toString().substring(0, 200) }); } }); this.shell.on('close', () => { logger.info(`Session ${this.id} shell closed`); this.state = SESSION_STATES.CLOSED; this.cleanup(); }); this.shell.stderr.on('data', (data) => { this.errorBuffer += data.toString(); logger.warn(`Session ${this.id} stderr`, { error: data.toString() }); }); // Wait for shell prompt await this.waitForPrompt(); // Allow context queries through standard execute flow this.state = SESSION_STATES.READY; // Get initial working directory await this.updateContext(); logger.info(`Session ${this.id} initialized`, { server: this.serverName, cwd: this.context.cwd }); } catch (error) { this.state = SESSION_STATES.ERROR; logger.error(`Failed to initialize session ${this.id}`, { error: error.message }); throw error; } } /** * Wait for shell prompt */ async waitForPrompt(timeout = 5000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { // Check if we have a prompt (ends with $ or # typically) if (this.outputBuffer.match(/[$#>]\s*$/)) { return true; } // Wait a bit await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Timeout waiting for shell prompt'); } /** * Update session context (pwd, env) */ async updateContext() { try { // Get current directory const pwdResult = await this.execute('pwd', { silent: true }); if (pwdResult.success) { this.context.cwd = pwdResult.output.trim(); } // Get environment variables (selective) const envResult = await this.execute('echo $PATH:$USER:$HOME', { silent: true }); if (envResult.success) { const [path, user, home] = envResult.output.trim().split(':'); this.context.env = { PATH: path, USER: user, HOME: home }; } } catch (error) { logger.warn(`Failed to update context for session ${this.id}`, { error: error.message }); } } /** * Execute a command in the session */ async execute(command, options = {}) { if (this.state !== SESSION_STATES.READY) { throw new Error(`Session ${this.id} is not ready (state: ${this.state})`); } this.state = SESSION_STATES.BUSY; this.lastActivity = new Date(); try { // Clear buffers this.outputBuffer = ''; this.errorBuffer = ''; // Add to history unless silent if (!options.silent) { this.context.history.push({ command, timestamp: new Date(), cwd: this.context.cwd }); logger.info(`Session ${this.id} executing`, { command: command.substring(0, 100), server: this.serverName }); } // Send command this.shell.write(command + '\n'); // Wait for command to complete await this.waitForPrompt(options.timeout || 30000); // Parse output (remove command echo and prompt) let output = this.outputBuffer; // Remove the command echo (first line) const lines = output.split('\n'); if (lines[0].includes(command)) { lines.shift(); } // Remove the prompt (last line) const lastLine = lines[lines.length - 1]; if (lastLine.match(/[$#>]\s*$/)) { lines.pop(); } output = lines.join('\n').trim(); // Check for command success (basic heuristic) const success = !this.errorBuffer && !output.includes('command not found'); // Update context if command might have changed it if (command.startsWith('cd ') || command.startsWith('export ')) { await this.updateContext(); } this.state = SESSION_STATES.READY; return { success, output, error: this.errorBuffer, session: this.id }; } catch (error) { this.state = SESSION_STATES.ERROR; logger.error(`Session ${this.id} execution failed`, { command, error: error.message }); throw error; } } /** * Set session variable */ setVariable(name, value) { this.context.variables[name] = value; this.lastActivity = new Date(); } /** * Get session variable */ getVariable(name) { return this.context.variables[name]; } /** * Get session info */ getInfo() { return { id: this.id, server: this.serverName, state: this.state, cwd: this.context.cwd, env: this.context.env, created: this.createdAt, lastActivity: this.lastActivity, historyCount: this.context.history.length, variables: Object.keys(this.context.variables) }; } /** * Close the session */ close() { logger.info(`Closing session ${this.id}`); if (this.shell) { this.shell.write('exit\n'); this.shell.end(); this.shell = null; } this.state = SESSION_STATES.CLOSED; this.cleanup(); } /** * Cleanup resources */ cleanup() { sessions.delete(this.id); this.outputBuffer = ''; this.errorBuffer = ''; this.context.history = []; } }
- src/ssh-manager.js:9-454 (helper)SSHManager class handles SSH connections, authentication, command execution, SFTP, which is required to obtain the connected 'ssh' client passed to createSession.class SSHManager { constructor(config) { this.config = config; this.client = new Client(); this.connected = false; this.sftp = null; this.cachedHomeDir = null; this.autoAcceptHostKey = config.autoAcceptHostKey || false; this.hostKeyVerification = config.hostKeyVerification !== false; // Default true } async connect() { return new Promise((resolve, reject) => { this.client.on('ready', () => { this.connected = true; resolve(); }); this.client.on('error', (err) => { this.connected = false; reject(err); }); this.client.on('end', () => { this.connected = false; }); // Build connection config const connConfig = { host: this.config.host, port: this.config.port || 22, username: this.config.user, readyTimeout: 60000, // Increased from 20000 to 60000 for slow connections keepaliveInterval: 10000, // Add compatibility options for problematic servers algorithms: { kex: ['ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521', 'diffie-hellman-group-exchange-sha256', 'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha1'], cipher: ['aes128-ctr', 'aes192-ctr', 'aes256-ctr', 'aes128-gcm', 'aes256-gcm', 'aes128-cbc', 'aes192-cbc', 'aes256-cbc'], serverHostKey: ['ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519'], hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1'] }, debug: (info) => { if (info.includes('Handshake') || info.includes('error')) { logger.debug('SSH2 Debug', { info }); } } }; // Add host key verification callback if enabled if (this.hostKeyVerification) { connConfig.hostVerifier = (hashedKey) => { const port = this.config.port || 22; const host = this.config.host; // Check if host is already known if (isHostKnown(host, port)) { // For now, accept all known hosts // TODO: Implement proper fingerprint comparison once we understand SSH2's hash format logger.info('Host key verified', { host, port }); return true; } // Host is not known logger.info('New host detected', { host, port }); // If autoAcceptHostKey is enabled, accept and add the key if (this.autoAcceptHostKey) { logger.info('Auto-accept host key', { host, port }); // Schedule key addition after connection setImmediate(async () => { try { await addHostKey(host, port); logger.info('Host key added', { host, port }); } catch (err) { logger.warn('Failed to add host key', { host, port, error: err.message }); } }); return true; } // For backward compatibility, accept new hosts by default // In production, you might want to prompt the user or check a whitelist logger.warn('Auto-accepting new host', { host, port }); return true; }; } // Add authentication (support both keyPath and keypath for compatibility) const keyPath = this.config.keyPath || this.config.keypath; if (keyPath) { const resolvedKeyPath = keyPath.replace('~', process.env.HOME); connConfig.privateKey = fs.readFileSync(resolvedKeyPath); } else if (this.config.password) { connConfig.password = this.config.password; } this.client.connect(connConfig); }); } async execCommand(command, options = {}) { if (!this.connected) { throw new Error('Not connected to SSH server'); } const { timeout = 30000, cwd, rawCommand = false } = options; const fullCommand = (cwd && !rawCommand) ? `cd ${cwd} && ${command}` : command; return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; let completed = false; let stream = null; let timeoutId = null; // Setup timeout first if (timeout > 0) { timeoutId = setTimeout(() => { if (!completed) { completed = true; // Try multiple ways to kill the stream if (stream) { try { stream.write('\x03'); // Send Ctrl+C stream.end(); stream.destroy(); } catch (e) { // Ignore errors } } // Kill the entire client connection as last resort try { this.client.end(); this.connected = false; } catch (e) { // Ignore errors } reject(new Error(`Command timeout after ${timeout}ms: ${command.substring(0, 100)}...`)); } }, timeout); } this.client.exec(fullCommand, (err, streamObj) => { if (err) { completed = true; if (timeoutId) clearTimeout(timeoutId); reject(err); return; } stream = streamObj; stream.on('close', (code, signal) => { if (!completed) { completed = true; if (timeoutId) clearTimeout(timeoutId); resolve({ stdout, stderr, code: code || 0, signal }); } }); stream.on('data', (data) => { stdout += data.toString(); }); stream.stderr.on('data', (data) => { stderr += data.toString(); }); stream.on('error', (err) => { if (!completed) { completed = true; if (timeoutId) clearTimeout(timeoutId); reject(err); } }); }); }); } async execCommandStream(command, options = {}) { if (!this.connected) { throw new Error('Not connected to SSH server'); } const { cwd, onStdout, onStderr } = options; const fullCommand = cwd ? `cd ${cwd} && ${command}` : command; return new Promise((resolve, reject) => { this.client.exec(fullCommand, (err, stream) => { if (err) { reject(err); return; } let stdout = ''; let stderr = ''; stream.on('close', (code, signal) => { resolve({ stdout, stderr, code: code || 0, signal, stream }); }); stream.on('data', (data) => { const chunk = data.toString(); stdout += chunk; if (onStdout) onStdout(chunk); }); stream.stderr.on('data', (data) => { const chunk = data.toString(); stderr += chunk; if (onStderr) onStderr(chunk); }); stream.on('error', reject); }); }); } async requestShell(options = {}) { if (!this.connected) { throw new Error('Not connected to SSH server'); } return new Promise((resolve, reject) => { this.client.shell(options, (err, stream) => { if (err) { reject(err); return; } resolve(stream); }); }); } async getSFTP() { if (this.sftp) return this.sftp; return new Promise((resolve, reject) => { this.client.sftp((err, sftp) => { if (err) { reject(err); return; } this.sftp = sftp; resolve(sftp); }); }); } async resolveHomePath() { if (this.cachedHomeDir) { return this.cachedHomeDir; } let homeDir = null; // Method 1: Try getent (most reliable) try { const result = await this.execCommand('getent passwd $USER | cut -d: -f6', { timeout: 5000, rawCommand: true }); homeDir = result.stdout.trim(); if (homeDir && homeDir.startsWith('/')) { this.cachedHomeDir = homeDir; return homeDir; } } catch (err) { // getent might not be available, try next method } // Method 2: Try env -i to get clean HOME try { const result = await this.execCommand('env -i HOME=$HOME bash -c "echo $HOME"', { timeout: 5000, rawCommand: true }); homeDir = result.stdout.trim(); if (homeDir && homeDir.startsWith('/')) { this.cachedHomeDir = homeDir; return homeDir; } } catch (err) { // env method failed, try next } // Method 3: Parse /etc/passwd directly try { const result = await this.execCommand('grep "^$USER:" /etc/passwd | cut -d: -f6', { timeout: 5000, rawCommand: true }); homeDir = result.stdout.trim(); if (homeDir && homeDir.startsWith('/')) { this.cachedHomeDir = homeDir; return homeDir; } } catch (err) { // /etc/passwd parsing failed, try last resort } // Method 4: Last resort - try cd ~ && pwd try { const result = await this.execCommand('cd ~ && pwd', { timeout: 5000, rawCommand: true }); homeDir = result.stdout.trim(); if (homeDir && homeDir.startsWith('/')) { this.cachedHomeDir = homeDir; return homeDir; } } catch (err) { // All methods failed } throw new Error('Unable to determine home directory on remote server'); } async putFile(localPath, remotePath) { // SFTP doesn't resolve ~ automatically, we need to get the real path let resolvedRemotePath = remotePath; if (remotePath.includes('~')) { try { const homeDir = await this.resolveHomePath(); // Replace ~ with the actual home directory // Handle both ~/path and ~ alone if (remotePath === '~') { resolvedRemotePath = homeDir; } else if (remotePath.startsWith('~/')) { resolvedRemotePath = homeDir + remotePath.substring(1); } else { // If ~ is not at the beginning, don't replace it resolvedRemotePath = remotePath; } } catch (err) { // If we can't resolve home, throw a more descriptive error throw new Error(`Failed to resolve home directory for path: ${remotePath}. ${err.message}`); } } const sftp = await this.getSFTP(); return new Promise((resolve, reject) => { // Check if local file exists and is readable if (!fs.existsSync(localPath)) { reject(new Error(`Local file does not exist: ${localPath}`)); return; } sftp.fastPut(localPath, resolvedRemotePath, (err) => { if (err) reject(err); else resolve(); }); }); } async getFile(localPath, remotePath) { // SFTP doesn't resolve ~ automatically, we need to get the real path let resolvedRemotePath = remotePath; if (remotePath.includes('~')) { try { const homeDir = await this.resolveHomePath(); // Replace ~ with the actual home directory // Handle both ~/path and ~ alone if (remotePath === '~') { resolvedRemotePath = homeDir; } else if (remotePath.startsWith('~/')) { resolvedRemotePath = homeDir + remotePath.substring(1); } else { // If ~ is not at the beginning, don't replace it resolvedRemotePath = remotePath; } } catch (err) { // If we can't resolve home, throw a more descriptive error throw new Error(`Failed to resolve home directory for path: ${remotePath}. ${err.message}`); } } const sftp = await this.getSFTP(); return new Promise((resolve, reject) => { sftp.fastGet(resolvedRemotePath, localPath, (err) => { if (err) reject(err); else resolve(); }); }); } async putFiles(files, options = {}) { const sftp = await this.getSFTP(); const results = []; for (const file of files) { try { await this.putFile(file.local, file.remote); results.push({ ...file, success: true }); } catch (error) { results.push({ ...file, success: false, error: error.message }); if (options.stopOnError) break; } } return results; } isConnected() { return this.connected && this.client && !this.client.destroyed; } dispose() { if (this.sftp) { this.sftp.end(); this.sftp = null; } if (this.client) { this.client.end(); this.connected = false; } } async ping() { try { const result = await this.execCommand('echo "ping"', { timeout: 5000 }); return result.stdout.trim() === 'ping'; } catch (error) { return false; } } }