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
JavaScript
/**
* 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);
});