Skip to main content
Glama
index.ts15.2 kB
/** * Terminal Tools - SSH, Telnet, and Local Shell support * Provides web-based terminal functionality */ import { spawn, ChildProcess } from 'child_process'; import { Client as SSHClient, type ConnectConfig } from 'ssh2'; import { Socket } from 'net'; import { EventEmitter } from 'events'; import { logger } from '../../logging/logger.js'; // Types export interface TerminalSession { id: string; type: 'local' | 'ssh' | 'telnet'; createdAt: Date; lastActivity: Date; connected: boolean; host?: string; port?: number; username?: string; } export interface SSHConnectionOptions { host: string; port?: number; username: string; password?: string; privateKey?: string; passphrase?: string; } export interface TelnetConnectionOptions { host: string; port?: number; } export interface CommandResult { stdout: string; stderr: string; exitCode: number | null; } // Session management class TerminalSessionManager extends EventEmitter { private sessions: Map<string, { session: TerminalSession; process?: ChildProcess; sshClient?: SSHClient; sshStream?: NodeJS.ReadWriteStream; telnetSocket?: Socket; outputBuffer: string[]; }> = new Map(); private generateSessionId(): string { return `term-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; } /** * Create a local shell session */ async createLocalSession(): Promise<TerminalSession> { const sessionId = this.generateSessionId(); const session: TerminalSession = { id: sessionId, type: 'local', createdAt: new Date(), lastActivity: new Date(), connected: true, }; this.sessions.set(sessionId, { session, outputBuffer: [], }); logger.info(`Local terminal session created: ${sessionId}`); return session; } /** * Execute command in local session */ async executeLocalCommand(sessionId: string, command: string): Promise<CommandResult> { const sessionData = this.sessions.get(sessionId); if (!sessionData || sessionData.session.type !== 'local') { throw new Error('Invalid local session'); } sessionData.session.lastActivity = new Date(); return new Promise((resolve, reject) => { const isWindows = process.platform === 'win32'; const shell = isWindows ? 'powershell.exe' : '/bin/sh'; const args = isWindows ? ['-Command', command] : ['-c', command]; let stdout = ''; let stderr = ''; const proc = spawn(shell, args, { cwd: process.cwd(), env: process.env, }); proc.stdout?.on('data', (data) => { stdout += data.toString(); sessionData.outputBuffer.push(data.toString()); }); proc.stderr?.on('data', (data) => { stderr += data.toString(); sessionData.outputBuffer.push(data.toString()); }); proc.on('close', (code) => { resolve({ stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code, }); }); proc.on('error', (error) => { reject(error); }); // Timeout after 30 seconds setTimeout(() => { proc.kill(); reject(new Error('Command timeout')); }, 30000); }); } /** * Create SSH session with interactive shell (PTY) */ async createSSHSession(options: SSHConnectionOptions): Promise<TerminalSession> { const sessionId = this.generateSessionId(); return new Promise((resolve, reject) => { const client = new SSHClient(); const session: TerminalSession = { id: sessionId, type: 'ssh', createdAt: new Date(), lastActivity: new Date(), connected: false, host: options.host, port: options.port || 22, username: options.username, }; client.on('ready', () => { // Request a PTY (pseudo-terminal) for interactive shell client.shell({ term: 'xterm-256color', cols: 120, rows: 30 }, (err, stream) => { if (err) { logger.error(`SSH shell error: ${err.message}`); reject(err); return; } session.connected = true; const outputBuffer: string[] = []; // Handle incoming data from shell stream.on('data', (data: Buffer) => { const text = data.toString(); logger.info(`[SSH ${sessionId}] Received data:`, { length: text.length, preview: text.substring(0, 100), bufferSizeBefore: outputBuffer.length }); outputBuffer.push(text); logger.info(`[SSH ${sessionId}] Buffer after push:`, { bufferSize: outputBuffer.length }); this.emit(`data:${sessionId}`, text); }); stream.stderr?.on('data', (data: Buffer) => { const text = data.toString(); logger.info(`[SSH ${sessionId}] Received stderr:`, { length: text.length, preview: text.substring(0, 100) }); outputBuffer.push(text); this.emit(`data:${sessionId}`, text); }); stream.on('close', () => { const sessionData = this.sessions.get(sessionId); if (sessionData) { sessionData.session.connected = false; } this.emit(`close:${sessionId}`); }); this.sessions.set(sessionId, { session, sshClient: client, sshStream: stream, outputBuffer, }); logger.info(`SSH interactive session created: ${sessionId} -> ${options.host}`); resolve(session); }); }); client.on('error', (err) => { logger.error(`SSH connection error: ${err.message}`); reject(err); }); client.on('close', () => { const sessionData = this.sessions.get(sessionId); if (sessionData) { sessionData.session.connected = false; } }); // Connect const connectConfig: ConnectConfig = { host: options.host, port: options.port || 22, username: options.username, }; if (options.password) { connectConfig.password = options.password; } if (options.privateKey) { connectConfig.privateKey = options.privateKey; if (options.passphrase) { connectConfig.passphrase = options.passphrase; } } client.connect(connectConfig); }); } /** * Execute command in SSH session (send to interactive shell) */ async executeSSHCommand(sessionId: string, command: string): Promise<CommandResult> { const sessionData = this.sessions.get(sessionId); if (!sessionData || sessionData.session.type !== 'ssh') { throw new Error('Invalid SSH session'); } if (!sessionData.session.connected) { throw new Error('SSH session disconnected'); } sessionData.session.lastActivity = new Date(); // For interactive shell, write the command directly if (sessionData.sshStream) { sessionData.sshStream.write(command + '\n'); // Wait a bit for output and return what we have await new Promise(resolve => setTimeout(resolve, 500)); const output = sessionData.outputBuffer.slice(-50).join(''); return { stdout: output, stderr: '', exitCode: null, }; } throw new Error('SSH stream not available'); } /** * Send raw data to SSH session (for password input, etc.) */ async sendSSHData(sessionId: string, data: string): Promise<void> { const sessionData = this.sessions.get(sessionId); if (!sessionData || sessionData.session.type !== 'ssh' || !sessionData.sshStream) { throw new Error('Invalid SSH session'); } if (!sessionData.session.connected) { throw new Error('SSH session disconnected'); } sessionData.session.lastActivity = new Date(); sessionData.sshStream.write(data); } /** * Create Telnet session */ async createTelnetSession(options: TelnetConnectionOptions): Promise<TerminalSession> { const sessionId = this.generateSessionId(); const port = options.port || 23; return new Promise((resolve, reject) => { const socket = new Socket(); const session: TerminalSession = { id: sessionId, type: 'telnet', createdAt: new Date(), lastActivity: new Date(), connected: false, host: options.host, port, }; const outputBuffer: string[] = []; socket.on('connect', () => { session.connected = true; this.sessions.set(sessionId, { session, telnetSocket: socket, outputBuffer, }); logger.info(`Telnet session created: ${sessionId} -> ${options.host}:${port}`); resolve(session); }); socket.on('data', (data) => { outputBuffer.push(data.toString()); this.emit(`data:${sessionId}`, data.toString()); }); socket.on('error', (err) => { logger.error(`Telnet connection error: ${err.message}`); reject(err); }); socket.on('close', () => { const sessionData = this.sessions.get(sessionId); if (sessionData) { sessionData.session.connected = false; } }); // Connect with timeout socket.setTimeout(10000); socket.on('timeout', () => { socket.destroy(); reject(new Error('Connection timeout')); }); socket.connect(port, options.host); }); } /** * Send data to Telnet session */ async sendTelnetData(sessionId: string, data: string): Promise<void> { const sessionData = this.sessions.get(sessionId); if (!sessionData || sessionData.session.type !== 'telnet' || !sessionData.telnetSocket) { throw new Error('Invalid Telnet session'); } if (!sessionData.session.connected) { throw new Error('Telnet session disconnected'); } sessionData.session.lastActivity = new Date(); sessionData.telnetSocket.write(data); } /** * Send data to SSH session (for interactive PTY) */ async sendToSSH(sessionId: string, data: string): Promise<void> { const sessionData = this.sessions.get(sessionId); if (!sessionData || sessionData.session.type !== 'ssh' || !sessionData.sshStream) { throw new Error('Invalid SSH session'); } if (!sessionData.session.connected) { throw new Error('SSH session disconnected'); } sessionData.session.lastActivity = new Date(); sessionData.sshStream.write(data); } /** * Get session output buffer */ getSessionOutput(sessionId: string): string[] { const sessionData = this.sessions.get(sessionId); if (!sessionData) { return []; } const output = [...sessionData.outputBuffer]; if (output.length > 0) { logger.info(`[Terminal] getSessionOutput ${sessionId}:`, { count: output.length, totalLength: output.join('').length, bufferRef: sessionData.outputBuffer.length // Check if same reference }); } return output; } /** * Clear session output buffer */ clearSessionOutput(sessionId: string): void { const sessionData = this.sessions.get(sessionId); if (sessionData) { const beforeSize = sessionData.outputBuffer.length; // Clear array in-place to preserve reference for stream.on('data') callback sessionData.outputBuffer.length = 0; if (beforeSize > 0) { logger.info(`[Terminal] clearSessionOutput ${sessionId}: cleared ${beforeSize} items`); } } } /** * Get session info */ getSession(sessionId: string): TerminalSession | undefined { return this.sessions.get(sessionId)?.session; } /** * Get all sessions */ getAllSessions(): TerminalSession[] { return Array.from(this.sessions.values()).map(s => s.session); } /** * Close session */ async closeSession(sessionId: string): Promise<void> { const sessionData = this.sessions.get(sessionId); if (!sessionData) return; try { if (sessionData.process) { sessionData.process.kill(); } if (sessionData.sshClient) { sessionData.sshClient.end(); } if (sessionData.telnetSocket) { sessionData.telnetSocket.destroy(); } } catch (error) { logger.error(`Error closing session ${sessionId}:`, { error }); } this.sessions.delete(sessionId); logger.info(`Terminal session closed: ${sessionId}`); } /** * Cleanup inactive sessions (older than 30 minutes) */ cleanupInactiveSessions(): void { const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000); for (const [sessionId, sessionData] of this.sessions) { if (sessionData.session.lastActivity < thirtyMinutesAgo) { this.closeSession(sessionId); } } } } export const terminalManager = new TerminalSessionManager(); // Cleanup inactive sessions every 5 minutes setInterval(() => { terminalManager.cleanupInactiveSessions(); }, 5 * 60 * 1000);

Latest Blog Posts

MCP directory API

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

curl -X GET 'https://glama.ai/api/mcp/v1/servers/babasida246/ai-mcp-gateway'

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