UNPKG

shell-mirror

Version:

Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.

675 lines (590 loc) • 24.6 kB
#!/usr/bin/env node /** * Terminal Mirror Mac Agent - HTTP Polling Version * * Polls the web server for commands and executes them locally * Validates Google OAuth tokens and executes commands for authorized users */ const { spawn } = require('child_process'); const fs = require('fs'); const path = require('path'); const axios = require('axios'); const winston = require('winston'); require('dotenv').config(); class TerminalMirrorHttpAgent { constructor() { this.config = { webServerHttp: process.env.WEB_SERVER_HTTP || 'https://shellmirror.app', agentSecret: process.env.AGENT_SECRET || 'mac-agent-secret-2024', agentId: process.env.AGENT_ID || this.generateAgentId(), pollInterval: parseInt(process.env.POLL_INTERVAL) || 500, commandTimeout: parseInt(process.env.COMMAND_TIMEOUT) || 30000, maxConcurrentCommands: parseInt(process.env.MAX_CONCURRENT_COMMANDS) || 3, ownerEmail: process.env.OWNER_EMAIL, ownerToken: process.env.OWNER_TOKEN }; this.isRunning = false; this.isRegistered = false; this.activeCommands = new Map(); this.lastHeartbeat = 0; this.sessionStates = new Map(); // Store session working directories this.setupLogger(); this.setupGracefulShutdown(); this.logger.info('Terminal Mirror HTTP Agent starting...', { agentId: this.config.agentId, webServer: this.config.webServerHttp }); this.showStartupInfo(); } generateAgentId() { const os = require('os'); const hostname = os.hostname(); const username = os.userInfo().username; // No timestamp - consistent ID per machine to avoid duplicate agent registrations return `mac-${username}-${hostname}`.replace(/[^a-zA-Z0-9-]/g, '-'); } showStartupInfo() { const packageInfo = require('../package.json'); let buildInfo; try { buildInfo = require('../build-info.json'); } catch (e) { buildInfo = { version: packageInfo.version, buildTime: 'Unknown', nodeVersion: process.version }; } console.log('\nšŸ”— Terminal Mirror - Ready'); console.log(`Account: ${this.config.ownerEmail || 'Not configured'}`); console.log(`Version: ${buildInfo.version}`); console.log(`Build Time: ${buildInfo.buildTime}`); console.log(''); } setupLogger() { const logDir = path.join(__dirname, 'logs'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } this.logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ new winston.transports.File({ filename: path.join(logDir, 'http-agent.log'), maxsize: 10485760, // 10MB maxFiles: 5 }) // Removed console transport to eliminate log spam in terminal ] }); } async registerWithServer() { if (!this.config.ownerEmail || !this.config.ownerToken) { throw new Error('Owner email and token required for registration. Please run setup first.'); } try { const os = require('os'); const registrationData = { agentId: this.config.agentId, ownerEmail: this.config.ownerEmail, ownerName: os.userInfo().username, ownerToken: this.config.ownerToken, machineName: os.hostname(), agentVersion: '1.0.0', capabilities: ['terminal', 'file_access'] }; const response = await axios.post(`${this.config.webServerHttp}/php-backend/api/agent-register.php`, registrationData, { headers: { 'Content-Type': 'application/json', 'User-Agent': 'TerminalMirror-HttpAgent/1.0.0' }, timeout: 10000 }); if (response.data && response.data.success) { this.isRegistered = true; this.logger.info('Successfully registered with server', { agentId: this.config.agentId, ownerEmail: this.config.ownerEmail }); return true; } else { throw new Error('Registration failed: ' + (response.data?.message || 'Unknown error')); } } catch (error) { this.logger.error('Failed to register with server', { error: error.message, status: error.response?.status, data: error.response?.data }); throw error; } } async sendHeartbeat() { try { const response = await axios.post(`${this.config.webServerHttp}/php-backend/api/agent-heartbeat.php`, { agentId: this.config.agentId, timestamp: Date.now(), activeSessions: this.activeCommands.size }, { headers: { 'X-Agent-Secret': this.config.agentSecret, 'X-Agent-ID': this.config.agentId, 'Content-Type': 'application/json' }, timeout: 5000 }); if (response.data && response.data.success) { this.lastHeartbeat = Date.now(); return true; } else { throw new Error('Heartbeat failed: ' + (response.data?.message || 'Unknown error')); } } catch (error) { this.logger.warn('Heartbeat failed', { error: error.message, status: error.response?.status }); return false; } } async validateGoogleToken(token) { try { const response = await axios.get(`https://oauth2.googleapis.com/tokeninfo?access_token=${token}`, { timeout: 5000 }); if (response.data && response.data.email) { return { valid: true, email: response.data.email, name: response.data.name, picture: response.data.picture }; } } catch (error) { this.logger.warn('Google token validation failed', { error: error.message }); } return { valid: false }; } async checkUserAuthorization(email) { try { // For now, use simple validation - server will do the real authorization check // The server-side registry system handles all permissions return { authorized: true, // Server will validate this properly role: 'user', // Default role allowedCommands: '*' // Allow all commands for authorized users }; } catch (error) { this.logger.error('Failed to check user authorization', { email, error: error.message }); return { authorized: false, role: null, allowedCommands: [] }; } } isCommandAllowed(command, userPermissions) { const allowedCommands = userPermissions.allowedCommands; if (allowedCommands === '*') return true; if (Array.isArray(allowedCommands)) { const baseCommand = command.split(' ')[0]; return allowedCommands.includes(baseCommand); } return false; } async pollForCommands() { try { const response = await axios.get(`${this.config.webServerHttp}/php-backend/api/mac-agent-poll.php`, { headers: { 'X-Agent-Secret': this.config.agentSecret, 'X-Agent-ID': this.config.agentId, 'User-Agent': 'TerminalMirror-HttpAgent/1.0.0' }, timeout: 10000 }); if (response.data && response.data.success) { const commands = response.data.data.commands || []; if (commands.length > 0) { this.logger.info(`Received ${commands.length} command(s) to process`); for (const command of commands) { if (this.activeCommands.size < this.config.maxConcurrentCommands) { this.processCommand(command); } else { this.logger.warn('Max concurrent commands reached, skipping', { commandId: command.id }); } } } } } catch (error) { this.logger.error('Failed to poll for commands', { error: error.message, status: error.response?.status, statusText: error.response?.statusText }); } } async processCommand(commandData) { const { id: commandId, command, userEmail, userToken, sessionId } = commandData; this.logger.info('Processing command', { commandId, command, userEmail, sessionId }); // Add to active commands this.activeCommands.set(commandId, { command, userEmail, startTime: Date.now() }); try { // Handle placeholder token for development/initial setup let tokenValidation; if (userToken === 'temp-token-placeholder') { tokenValidation = { valid: true, email: userEmail, name: 'Local User' }; } else { // Validate real Google OAuth token tokenValidation = await this.validateGoogleToken(userToken); if (!tokenValidation.valid) { await this.submitResponse(commandId, { success: false, error: 'Invalid authentication token', output: '' }); return; } } // Check user authorization (server-side validation) const userAuth = await this.checkUserAuthorization(tokenValidation.email); if (!userAuth.authorized) { await this.submitResponse(commandId, { success: false, error: 'User not authorized for Mac terminal access', output: '' }); return; } // Check command permissions if (!this.isCommandAllowed(command, userAuth)) { await this.submitResponse(commandId, { success: false, error: `Command not allowed: ${command.split(' ')[0]}`, output: '' }); return; } // Execute the command const result = await this.executeCommand(command, userEmail, sessionId); await this.submitResponse(commandId, { success: true, output: result.output, exitCode: result.exitCode, duration: result.duration }); } catch (error) { this.logger.error('Command processing error', { commandId, command, error: error.message }); await this.submitResponse(commandId, { success: false, error: 'Command execution failed', output: error.message }); } finally { // Remove from active commands this.activeCommands.delete(commandId); } } async getShellPrompt(sessionId = 'default') { const currentDir = this.sessionStates.get(sessionId) || process.env.HOME; try { // Get the actual shell prompt by executing PS1 evaluation const pty = require('node-pty'); const enhancedEnv = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1', COLUMNS: '120', LINES: '30', PWD: currentDir }; return new Promise((resolve) => { // Create a shell session to get the prompt const shell = pty.spawn(process.env.SHELL || 'bash', [], { cwd: currentDir, env: enhancedEnv, cols: 120, rows: 30 }); let promptOutput = ''; let promptCaptured = false; // Listen for initial prompt shell.on('data', (data) => { promptOutput += data; // Look for prompt pattern (ends with $ or % or # followed by space) if (!promptCaptured && (data.includes('$ ') || data.includes('% ') || data.includes('# '))) { // Extract the prompt from the output const lines = promptOutput.split('\n'); const lastLine = lines[lines.length - 1]; // Clean up the prompt (preserve color codes but remove problematic ones) let cleanPrompt = lastLine.replace(/\r/g, ''); // Remove carriage returns cleanPrompt = cleanPrompt.replace(/\x1b\[2J/g, ''); // Remove clear screen cleanPrompt = cleanPrompt.replace(/\x1b\[H/g, ''); // Remove cursor home // Ensure prompt includes directory info or fallback to current dir if (cleanPrompt.trim() && cleanPrompt.length > 2) { promptCaptured = true; shell.kill(); resolve(cleanPrompt.trim()); } else { // Fallback to directory-based prompt const path = require('path'); const dirName = path.basename(currentDir); resolve(`${dirName} $`); } } }); // Timeout after 2 seconds setTimeout(() => { if (!promptCaptured) { shell.kill(); // Fallback to directory-based prompt const path = require('path'); const dirName = path.basename(currentDir); resolve(`${dirName} $`); } }, 2000); }); } catch (error) { this.logger.warn('Failed to get shell prompt', { error: error.message }); // Fallback to directory-based prompt const path = require('path'); const dirName = path.basename(currentDir); return `${dirName} $`; } } executeCommand(command, userEmail, sessionId = 'default') { return new Promise((resolve, reject) => { const startTime = Date.now(); let output = ''; this.logger.info('Executing command on Mac', { command, userEmail, sessionId }); // Get current working directory for this session let currentDir = this.sessionStates.get(sessionId) || process.env.HOME; // Handle cd command specially if (command.trim().startsWith('cd ')) { const path = require('path'); const newPath = command.trim().substring(3).trim(); if (newPath === '') { currentDir = process.env.HOME; } else if (newPath.startsWith('/')) { currentDir = newPath; } else { currentDir = path.resolve(currentDir, newPath); } // Verify directory exists const fs = require('fs'); if (fs.existsSync(currentDir) && fs.statSync(currentDir).isDirectory()) { this.sessionStates.set(sessionId, currentDir); // Get the new prompt after directory change this.getShellPrompt(sessionId).then(prompt => { resolve({ exitCode: 0, output: `Changed directory to: ${currentDir}`, duration: Date.now() - startTime, prompt: prompt }); }).catch(() => { resolve({ exitCode: 0, output: `Changed directory to: ${currentDir}`, duration: Date.now() - startTime, prompt: '$ ' }); }); } else { resolve({ exitCode: 1, output: `cd: no such file or directory: ${newPath}`, duration: Date.now() - startTime, prompt: '$ ' }); } return; } // Handle pwd command if (command.trim() === 'pwd') { this.getShellPrompt(sessionId).then(prompt => { resolve({ exitCode: 0, output: currentDir, duration: Date.now() - startTime, prompt: prompt }); }).catch(() => { resolve({ exitCode: 0, output: currentDir, duration: Date.now() - startTime, prompt: '$ ' }); }); return; } // Execute other commands with PTY for proper TTY support const pty = require('node-pty'); // Enhanced environment variables for CLI tools const enhancedEnv = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '1', COLUMNS: '120', LINES: '30', PWD: currentDir }; const child = pty.spawn('bash', ['-c', command], { cwd: currentDir, env: enhancedEnv, cols: 120, rows: 30 }); child.on('data', (data) => { output += data.toString(); }); // Add timeout handling const timeout = setTimeout(() => { child.kill(); resolve({ exitCode: 124, output: output + '\n[Command timed out]', duration: Date.now() - startTime, prompt: '$ ' }); }, this.config.commandTimeout); child.on('exit', (code) => { clearTimeout(timeout); const duration = Date.now() - startTime; let finalOutput = output; if (!finalOutput.trim()) { finalOutput = code === 0 ? '[Command completed successfully]' : `[Command failed with exit code ${code}]`; } // Get the current prompt after command execution this.getShellPrompt(sessionId).then(prompt => { resolve({ exitCode: code, output: finalOutput, duration, prompt: prompt }); }).catch(() => { resolve({ exitCode: code, output: finalOutput, duration, prompt: '$ ' }); }); }); child.on('error', (error) => { reject(new Error(`Failed to execute command: ${error.message}`)); }); // Set command timeout setTimeout(() => { if (!child.killed) { child.kill('SIGTERM'); setTimeout(() => { if (!child.killed) { child.kill('SIGKILL'); } }, 5000); reject(new Error('Command timed out')); } }, this.config.commandTimeout); }); } async submitResponse(commandId, response) { try { await axios.post(`${this.config.webServerHttp}/php-backend/api/mac-agent-response.php`, { commandId, response }, { headers: { 'X-Agent-Secret': this.config.agentSecret, 'X-Agent-ID': this.config.agentId, 'Content-Type': 'application/json' }, timeout: 10000 }); this.logger.info('Response submitted successfully', { commandId }); } catch (error) { this.logger.error('Failed to submit response', { commandId, error: error.message, status: error.response?.status }); } } async start() { this.logger.info('Starting HTTP polling agent', { agentId: this.config.agentId, pollInterval: this.config.pollInterval }); try { // Register with server console.log('Checking for Mac connection...'); await this.registerWithServer(); console.log(`Connected to Mac: ${require('os').hostname()}.local`); console.log('Loading terminal environment...\n'); this.isRunning = true; let lastHeartbeat = 0; while (this.isRunning) { // Send heartbeat every 60 seconds if (Date.now() - lastHeartbeat > 60000) { await this.sendHeartbeat(); lastHeartbeat = Date.now(); } // Poll for commands await this.pollForCommands(); await this.sleep(this.config.pollInterval); } } catch (error) { this.logger.error('Agent startup failed', { error: error.message }); throw error; } } stop() { this.logger.info('Stopping HTTP polling agent'); this.isRunning = false; } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } setupGracefulShutdown() { const shutdown = (signal) => { this.logger.info(`Received ${signal}, shutting down gracefully...`); this.stop(); setTimeout(() => { process.exit(0); }, 2000); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); } } // Start the agent const agent = new TerminalMirrorHttpAgent(); agent.start().catch(error => { console.error('Agent startup failed:', error); process.exit(1); });