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.
1,238 lines (1,071 loc) âĸ 42.4 kB
JavaScript
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const WebSocket = require('ws');
const pty = require('node-pty');
const os = require('os');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const https = require('https');
// Enhanced logging to file
const LOG_FILE = path.join(__dirname, 'agent-debug.log');
function logToFile(message) {
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] ${message}\n`;
console.log(logEntry.trim()); // Also log to console
fs.appendFileSync(LOG_FILE, logEntry);
}
// Clear previous log
fs.writeFileSync(LOG_FILE, `=== Mac Agent Debug Log Started ${new Date().toISOString()} ===\n`);
// Use @koush/wrtc package for Node.js WebRTC support
let wrtc;
try {
wrtc = require('@koush/wrtc');
logToFile('â
@koush/wrtc package loaded successfully');
} catch (err) {
logToFile('â Failed to load @koush/wrtc package. Install with: npm install @koush/wrtc');
process.exit(1);
}
// Generate stable agent ID from machine identity (prevents duplicate registrations)
function generateStableAgentId() {
const hostname = os.hostname();
const username = os.userInfo().username;
return `mac-${username}-${hostname}`.replace(/[^a-zA-Z0-9-]/g, '-');
}
const AGENT_ID = process.env.AGENT_ID || generateStableAgentId();
logToFile(`đ Agent ID: ${AGENT_ID}`);
// Determine signaling server URL - prefer WEBSOCKET_URL for cloud connections
let SIGNALING_SERVER_URL;
if (process.env.WEBSOCKET_URL) {
SIGNALING_SERVER_URL = process.env.WEBSOCKET_URL;
logToFile(`đ Using cloud WebSocket URL: ${SIGNALING_SERVER_URL}`);
} else {
// Fallback to local server configuration
let connectHost = process.env.HOST || 'localhost';
if (connectHost === '0.0.0.0') {
logToFile('[AGENT] Host is 0.0.0.0, connecting to localhost instead.');
connectHost = 'localhost';
}
const PORT = process.env.PORT || 3000;
SIGNALING_SERVER_URL = `ws://${connectHost}:${PORT}`;
logToFile(`đ Using local WebSocket URL: ${SIGNALING_SERVER_URL}`);
}
const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';
logToFile(`đ Shell: ${shell}`);
// Enable case-insensitive tab completion for shells
function enableCaseInsensitiveCompletion(terminal, shellType) {
setTimeout(() => {
if (shellType === '/bin/zsh' || shellType.includes('zsh')) {
// For zsh: configure case-insensitive completion matcher
terminal.write('autoload -Uz compinit 2>/dev/null; compinit -i 2>/dev/null; zstyle \':completion:*\' matcher-list \'m:{a-zA-Z}={A-Za-z}\'; clear\n');
} else if (shellType.includes('bash')) {
// For bash: set completion-ignore-case
terminal.write('bind \'set completion-ignore-case on\' 2>/dev/null; clear\n');
}
}, 600); // After login scripts have run
}
// Circular buffer for session output persistence
class CircularBuffer {
constructor(size = 10000, maxTotalSize = 512 * 1024) { // 512KB max total size
this.size = size;
this.maxTotalSize = maxTotalSize;
this.buffer = [];
this.index = 0;
this.full = false;
this.totalSize = 0;
}
add(data) {
const dataSize = Buffer.byteLength(data, 'utf8');
// Add new data
const oldData = this.buffer[this.index];
if (oldData) {
this.totalSize -= Buffer.byteLength(oldData, 'utf8');
}
this.buffer[this.index] = data;
this.totalSize += dataSize;
this.index = (this.index + 1) % this.size;
if (this.index === 0) this.full = true;
// If total size exceeds limit, remove older data
this.enforceMaxSize();
}
enforceMaxSize() {
if (this.totalSize <= this.maxTotalSize) return;
// Remove data from the oldest end until under limit
let removed = 0;
while (this.totalSize > this.maxTotalSize && this.getTotalItems() > 0) {
let oldestIndex;
if (this.full) {
oldestIndex = this.index; // Oldest item when buffer is full
} else {
oldestIndex = 0; // Start from beginning when not full
}
const oldestData = this.buffer[oldestIndex];
if (oldestData) {
this.totalSize -= Buffer.byteLength(oldestData, 'utf8');
this.buffer[oldestIndex] = '';
removed++;
if (this.full) {
this.index = (this.index + 1) % this.size;
if (this.getTotalItems() === 0) {
this.full = false;
this.index = 0;
}
} else {
// Shift array to remove empty spot
this.buffer.splice(oldestIndex, 1);
this.buffer.push('');
this.index = Math.max(0, this.index - 1);
}
} else {
break; // Prevent infinite loop
}
}
if (removed > 0) {
logToFile(`[BUFFER] Enforced max size limit: removed ${removed} old entries, total size now ${this.totalSize} bytes`);
}
}
getTotalItems() {
if (!this.full) {
return this.buffer.slice(0, this.index).filter(item => item).length;
}
return this.buffer.filter(item => item).length;
}
getAll() {
if (!this.full) {
return this.buffer.slice(0, this.index).join('');
}
return this.buffer.slice(this.index).concat(this.buffer.slice(0, this.index)).join('');
}
clear() {
this.buffer = [];
this.index = 0;
this.full = false;
this.totalSize = 0;
}
getStats() {
return {
items: this.getTotalItems(),
totalSize: this.totalSize,
maxSize: this.maxTotalSize,
utilizationPercent: Math.round((this.totalSize / this.maxTotalSize) * 100)
};
}
}
// Session Manager for multiple persistent terminal sessions
class SessionManager {
constructor() {
this.sessions = {};
this.maxSessions = 10;
this.defaultSessionTimeout = 24 * 60 * 60 * 1000; // 24 hours
this.clientSessions = {}; // Maps clientId to sessionId
this.sessionCounter = 0; // Incrementing counter for unique session names (never resets)
}
createSession(sessionName = null, clientId = null) {
const sessionId = `ses_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Use incrementing counter for unique names (doesn't reuse after deletion)
this.sessionCounter++;
const name = sessionName || `Session ${this.sessionCounter}`;
logToFile(`[SESSION] Creating new session: ${sessionId} (${name})`);
// Check session limit
if (Object.keys(this.sessions).length >= this.maxSessions) {
logToFile(`[SESSION] â Maximum sessions (${this.maxSessions}) reached`);
return null;
}
const macShell = os.platform() === 'darwin' ? '/bin/zsh' : shell;
const terminalEnv = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
SHELL: macShell,
TERM_PROGRAM: 'Terminal',
TERM_PROGRAM_VERSION: '2.12.7'
};
const terminal = pty.spawn(macShell, ['--login'], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: process.env.HOME,
env: terminalEnv,
encoding: 'utf8'
});
const session = {
id: sessionId,
name: name,
terminal: terminal,
buffer: new CircularBuffer(10000),
connectedClients: [],
createdAt: Date.now(),
lastActivity: Date.now(),
status: 'active'
};
// Set up terminal event handlers
terminal.on('data', (data) => {
session.buffer.add(data);
session.lastActivity = Date.now();
// Send to all connected clients for this session
session.connectedClients.forEach(clientId => {
this.sendToClient(clientId, { type: 'output', data: data });
});
});
// Send initial prompt after terminal is ready
setTimeout(() => {
// Send a newline to trigger the shell prompt
terminal.write('\n');
}, 500);
// Enable case-insensitive tab completion
enableCaseInsensitiveCompletion(terminal, macShell);
terminal.on('exit', (code) => {
logToFile(`[SESSION] Terminal process exited for session ${sessionId} with code ${code}`);
session.status = 'crashed';
// Notify connected clients
session.connectedClients.forEach(clientId => {
this.sendToClient(clientId, {
type: 'session-ended',
sessionId: sessionId,
reason: 'terminal-exit',
code: code
});
});
});
this.sessions[sessionId] = session;
// Associate with client if provided
if (clientId) {
this.clientSessions[clientId] = sessionId;
session.connectedClients.push(clientId);
}
logToFile(`[SESSION] â
Session created: ${sessionId} (PID: ${terminal.pid})`);
return sessionId;
}
getSession(sessionId) {
return this.sessions[sessionId] || null;
}
connectClientToSession(clientId, sessionId) {
const session = this.sessions[sessionId];
if (!session) {
logToFile(`[SESSION] â Cannot connect client ${clientId} - session ${sessionId} not found`);
return false;
}
// Disconnect client from any existing session
this.disconnectClient(clientId);
// Connect to new session
this.clientSessions[clientId] = sessionId;
if (!session.connectedClients.includes(clientId)) {
session.connectedClients.push(clientId);
}
session.lastActivity = Date.now();
logToFile(`[SESSION] â
Client ${clientId} connected to session ${sessionId}`);
logToFile(`[SESSION] âšī¸ Buffered output will be sent when WebRTC data channel opens`);
return true;
}
disconnectClient(clientId) {
const sessionId = this.clientSessions[clientId];
if (sessionId && this.sessions[sessionId]) {
const session = this.sessions[sessionId];
session.connectedClients = session.connectedClients.filter(id => id !== clientId);
logToFile(`[SESSION] Client ${clientId} disconnected from session ${sessionId}`);
}
delete this.clientSessions[clientId];
}
getClientSession(clientId) {
const sessionId = this.clientSessions[clientId];
return sessionId ? this.sessions[sessionId] : null;
}
getAllSessions() {
return Object.values(this.sessions).map(session => ({
id: session.id,
name: session.name,
lastActivity: session.lastActivity,
createdAt: session.createdAt,
status: session.status,
connectedClients: session.connectedClients.length
}));
}
terminateSession(sessionId) {
const session = this.sessions[sessionId];
if (!session) return false;
logToFile(`[SESSION] Terminating session: ${sessionId}`);
// Notify connected clients
session.connectedClients.forEach(clientId => {
this.sendToClient(clientId, {
type: 'session-terminated',
sessionId: sessionId
});
delete this.clientSessions[clientId];
});
// Kill terminal process
if (session.terminal) {
session.terminal.kill();
}
delete this.sessions[sessionId];
logToFile(`[SESSION] â
Session terminated: ${sessionId}`);
return true;
}
sendToClient(clientId, message) {
// This will be connected to the WebRTC data channel sending logic
// For now, we'll use a global dataChannel reference
// In a full implementation, this would use a clientId-to-dataChannel mapping
if (typeof dataChannel !== 'undefined' && dataChannel && dataChannel.readyState === 'open') {
const success = sendLargeMessage(dataChannel, message, '[SESSION]');
if (!success) {
logToFile(`[SESSION] â Failed to send message to client ${clientId}`);
}
} else {
logToFile(`[SESSION] â ī¸ Cannot send to client ${clientId} - data channel not available`);
}
}
writeToSession(sessionId, data) {
const session = this.sessions[sessionId];
if (session && session.terminal) {
session.terminal.write(data);
session.lastActivity = Date.now();
return true;
}
return false;
}
resizeSession(sessionId, cols, rows) {
const session = this.sessions[sessionId];
if (session && session.terminal) {
session.terminal.resize(cols, rows);
session.lastActivity = Date.now();
return true;
}
return false;
}
cleanupIdleSessions() {
const now = Date.now();
Object.keys(this.sessions).forEach(sessionId => {
const session = this.sessions[sessionId];
const idleTime = now - session.lastActivity;
if (idleTime > this.defaultSessionTimeout && session.connectedClients.length === 0) {
logToFile(`[SESSION] Auto-cleanup idle session: ${sessionId} (idle for ${Math.floor(idleTime / 60000)} minutes)`);
this.terminateSession(sessionId);
}
});
}
}
// Initialize session manager
const sessionManager = new SessionManager();
// Cleanup idle sessions every 30 minutes
setInterval(() => {
sessionManager.cleanupIdleSessions();
}, 30 * 60 * 1000);
let ws;
let peerConnection;
let dataChannel;
const iceServers = [
// Google STUN servers (primary)
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
// Cloudflare STUN servers (backup)
{ urls: 'stun:stun.cloudflare.com:3478' },
// Mozilla STUN servers (backup)
{ urls: 'stun:stun.services.mozilla.com:3478' },
// OpenRelay free TURN server (for NAT traversal)
{
urls: 'turn:openrelay.metered.ca:80',
username: 'openrelayproject',
credential: 'openrelayproject'
},
// Alternative TURN server
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject'
}
];
// --- Heartbeat System ---
let heartbeatInterval;
async function sendHeartbeat() {
try {
// Get full session list for dashboard display
const sessionList = sessionManager.getAllSessions().map(session => ({
id: session.id,
name: session.name,
lastActivity: session.lastActivity,
createdAt: session.createdAt,
status: session.status
}));
const heartbeatData = JSON.stringify({
agentId: AGENT_ID,
timestamp: Date.now(),
activeSessions: sessionList.length,
sessions: sessionList, // Full session list for dashboard
localPort: process.env.LOCAL_PORT || 8080,
capabilities: ['webrtc', 'direct_websocket']
});
const options = {
hostname: 'shellmirror.app',
port: 443,
path: '/php-backend/api/agent-heartbeat.php',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(heartbeatData),
'X-Agent-Secret': 'mac-agent-secret-2024',
'X-Agent-ID': AGENT_ID
}
};
const req = https.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
try {
const result = JSON.parse(responseData);
if (result.success) {
logToFile(`đ Heartbeat sent successfully`);
} else {
logToFile(`â ī¸ Heartbeat failed: ${result.message}`);
}
} catch (error) {
logToFile(`â ī¸ Heartbeat response parse error: ${error.message}`);
}
} else {
logToFile(`â ī¸ Heartbeat HTTP error: ${res.statusCode}`);
}
});
});
req.on('error', (error) => {
logToFile(`â Heartbeat request failed: ${error.message}`);
});
req.write(heartbeatData);
req.end();
} catch (error) {
logToFile(`â Heartbeat error: ${error.message}`);
}
}
function startHeartbeatSystem() {
logToFile('đ Starting heartbeat system (60 second interval)');
// Send initial heartbeat immediately
sendHeartbeat();
// Set up recurring heartbeat
heartbeatInterval = setInterval(sendHeartbeat, 60000); // 60 seconds
}
function stopHeartbeatSystem() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
logToFile('đ Heartbeat system stopped');
}
}
function connectToSignalingServer() {
logToFile(`đ Connecting to signaling server at ${SIGNALING_SERVER_URL}?role=agent&agentId=${AGENT_ID}`);
ws = new WebSocket(`${SIGNALING_SERVER_URL}?role=agent&agentId=${AGENT_ID}`);
ws.on('open', () => {
logToFile('â
Connected to signaling server.');
// Start heartbeat system to maintain agent status
startHeartbeatSystem();
});
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
logToFile(`đ¨ Received message of type: ${data.type} from: ${data.from} to: ${data.to}`);
switch (data.type) {
case 'client-hello':
logToFile(`đ Received client-hello from ${data.from}. Processing session request.`);
try {
let sessionId;
let isNewSession = false;
// Handle session request from client
if (data.sessionRequest) {
if (data.sessionRequest.sessionId) {
// Connect to existing session
sessionId = data.sessionRequest.sessionId;
logToFile(`[SESSION] Client requesting existing session: ${sessionId}`);
if (!sessionManager.getSession(sessionId)) {
logToFile(`[SESSION] â ī¸ Requested session ${sessionId} not found, creating new session`);
sessionId = sessionManager.createSession(data.sessionRequest.sessionName, data.from);
isNewSession = true;
}
} else if (data.sessionRequest.newSession) {
// Create new session
sessionId = sessionManager.createSession(data.sessionRequest.sessionName, data.from);
isNewSession = true;
logToFile(`[SESSION] Client requesting new session: ${sessionId}`);
} else {
// Default: create new session if no specific request
sessionId = sessionManager.createSession(null, data.from);
isNewSession = true;
}
} else {
// Backward compatibility: no session request means create default session
sessionId = sessionManager.createSession(null, data.from);
isNewSession = true;
}
if (!sessionId) {
logToFile(`[SESSION] â Failed to create/connect to session`);
sendMessage({
type: 'error',
message: 'Failed to create session - maximum sessions reached',
to: data.from,
from: AGENT_ID
});
break;
}
// Connect client to session
sessionManager.connectClientToSession(data.from, sessionId);
await createPeerConnection(data.from);
logToFile('đĄ PeerConnection created, generating offer...');
const offer = await peerConnection.createOffer();
logToFile(`đ Offer created: ${offer.type}`);
await peerConnection.setLocalDescription(offer);
// Send WebRTC offer with session assignment
// Get availableSessions AFTER session creation so new session is included
sendMessage({
type: 'offer',
sdp: offer.sdp,
to: data.from,
from: AGENT_ID,
sessionId: sessionId,
sessionName: sessionManager.getSession(sessionId).name,
isNewSession: isNewSession,
availableSessions: sessionManager.getAllSessions()
});
logToFile('â
WebRTC offer sent with session assignment');
// Force ICE gathering if it hasn't started within 2 seconds
logToFile('[AGENT] đ§ Setting up ICE gathering fallback timer...');
setTimeout(() => {
if (!peerConnection) {
logToFile('[AGENT] â ī¸ ICE gathering timer fired but peerConnection is null (connection already closed)');
return;
}
if (peerConnection.iceGatheringState === 'new') {
logToFile('[AGENT] â ī¸ ICE gathering hasn\'t started - checking peer connection state');
logToFile(`[AGENT] Current ICE gathering state: ${peerConnection.iceGatheringState}`);
logToFile(`[AGENT] Current ICE connection state: ${peerConnection.iceConnectionState}`);
try {
peerConnection.restartIce();
logToFile('[AGENT] đ ICE restart triggered');
} catch (error) {
logToFile(`[AGENT] â Failed to restart ICE: ${error.message}`);
}
} else {
logToFile(`[AGENT] â
ICE gathering is active: ${peerConnection.iceGatheringState}`);
}
}, 2000);
} catch (error) {
logToFile(`â Error handling client-hello: ${error.message} Stack: ${error.stack}`);
}
break;
case 'answer':
logToFile('[AGENT] đĨ Received WebRTC answer from client.');
try {
await peerConnection.setRemoteDescription(new wrtc.RTCSessionDescription({ type: 'answer', sdp: data.sdp }));
logToFile('[AGENT] â
WebRTC answer processed successfully');
} catch (error) {
logToFile(`[AGENT] â Error processing answer: ${error.message}`);
}
break;
case 'candidate':
logToFile('[AGENT] đ§ Received ICE candidate from client.');
try {
if (data.candidate) {
await peerConnection.addIceCandidate(new wrtc.RTCIceCandidate(data.candidate));
logToFile('[AGENT] â
ICE candidate added successfully');
}
} catch (error) {
logToFile(`[AGENT] â Error adding ICE candidate: ${error.message}`);
}
break;
default:
logToFile(`[AGENT] â Unknown message type: ${data.type}`);
}
} catch (error) {
console.error('[AGENT] â Error parsing message:', error, 'Raw message:', message);
}
});
ws.on('close', () => {
console.log('[AGENT] Disconnected from signaling server. Reconnecting...');
setTimeout(connectToSignalingServer, 5000);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err.message);
});
}
async function createPeerConnection(clientId) {
logToFile('Creating new PeerConnection');
logToFile(`đ Configuring ICE servers: ${iceServers.map(server => server.urls).join(', ')}`);
// Enhanced WebRTC configuration for better ICE candidate generation
const rtcConfig = {
iceServers: iceServers,
iceCandidatePoolSize: 10, // Generate more ICE candidates
iceTransportPolicy: 'all', // Use both STUN and TURN
bundlePolicy: 'balanced' // Optimize for connection establishment
};
logToFile(`âī¸ WebRTC config: ${JSON.stringify(rtcConfig)}`);
peerConnection = new wrtc.RTCPeerConnection(rtcConfig);
// Debug: Verify event handler is being attached
logToFile('[AGENT] đ§ Attaching ICE candidate event handler...');
peerConnection.onicecandidate = (event) => {
logToFile(`[AGENT] đ§ ICE candidate event fired: ${event.candidate ? 'candidate found' : 'gathering complete'}`);
if (event.candidate) {
logToFile(`[AGENT] đ¤ ICE candidate details: ${JSON.stringify({
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
})}`);
logToFile('[AGENT] đ¤ Sending ICE candidate to client...');
sendMessage({ type: 'candidate', candidate: event.candidate, to: clientId, from: AGENT_ID });
logToFile('[AGENT] â
ICE candidate sent successfully');
} else {
logToFile('[AGENT] đ ICE candidate gathering complete.');
}
};
// Agent creates the data channel (not client)
logToFile('[AGENT] Creating data channel...');
dataChannel = peerConnection.createDataChannel('terminal', {
ordered: true
});
setupDataChannel(clientId);
peerConnection.ondatachannel = (event) => {
logToFile('[AGENT] Additional data channel received (this should not happen)');
};
peerConnection.oniceconnectionstatechange = () => {
if (!peerConnection) {
logToFile('[AGENT] â ī¸ ICE connection state change after peerConnection was closed');
return;
}
logToFile(`[AGENT] đ ICE connection state changed: ${peerConnection.iceConnectionState}`);
logToFile(`[AGENT] đ ICE gathering state: ${peerConnection.iceGatheringState}`);
switch (peerConnection.iceConnectionState) {
case 'new':
logToFile('[AGENT] đ ICE connection starting...');
break;
case 'checking':
logToFile('[AGENT] đ ICE connection checking candidates...');
break;
case 'connected':
logToFile('[AGENT] â
WebRTC connection established!');
break;
case 'completed':
logToFile('[AGENT] â
ICE connection completed successfully!');
break;
case 'failed':
logToFile('[AGENT] â ICE connection failed - no viable candidates');
cleanup(clientId);
break;
case 'disconnected':
logToFile('[AGENT] â ī¸ ICE connection disconnected');
cleanup(clientId);
break;
case 'closed':
logToFile('[AGENT] đ ICE connection closed');
break;
}
};
peerConnection.onconnectionstatechange = () => {
if (!peerConnection) {
logToFile('[AGENT] â ī¸ Connection state change after peerConnection was closed');
return;
}
logToFile(`[AGENT] đĄ Connection state changed: ${peerConnection.connectionState}`);
switch (peerConnection.connectionState) {
case 'new':
logToFile('[AGENT] đ Connection starting...');
break;
case 'connecting':
logToFile('[AGENT] đ Connection in progress...');
break;
case 'connected':
logToFile('[AGENT] â
Peer connection fully established!');
break;
case 'disconnected':
logToFile('[AGENT] â ī¸ Peer connection disconnected');
break;
case 'failed':
logToFile('[AGENT] â Peer connection failed completely');
break;
case 'closed':
logToFile('[AGENT] đ Peer connection closed');
break;
}
};
peerConnection.onicegatheringstatechange = () => {
if (!peerConnection) {
logToFile('[AGENT] â ī¸ ICE gathering state change after peerConnection was closed');
return;
}
logToFile(`[AGENT] đ ICE gathering state changed: ${peerConnection.iceGatheringState}`);
switch (peerConnection.iceGatheringState) {
case 'new':
logToFile('[AGENT] đ ICE gathering not started');
break;
case 'gathering':
logToFile('[AGENT] đ ICE gathering in progress...');
break;
case 'complete':
logToFile('[AGENT] â
ICE gathering completed');
break;
}
};
}
function cleanup(clientId = null) {
// Disconnect client from session manager
if (clientId) {
sessionManager.disconnectClient(clientId);
} else {
// Full agent shutdown - stop heartbeat system
stopHeartbeatSystem();
}
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
if (peerConnection) {
peerConnection.close();
peerConnection = null;
}
}
// WebRTC data channel message size limits and chunking
const MAX_WEBRTC_MESSAGE_SIZE = 32 * 1024; // 32KB - conservative limit for compatibility
const CHUNK_TYPE_START = 'chunk_start';
const CHUNK_TYPE_DATA = 'chunk_data';
const CHUNK_TYPE_END = 'chunk_end';
function sendLargeMessage(dataChannel, message, logPrefix = '[AGENT]') {
try {
const messageStr = JSON.stringify(message);
const messageBytes = Buffer.byteLength(messageStr, 'utf8');
if (messageBytes <= MAX_WEBRTC_MESSAGE_SIZE) {
// Small message, send directly
dataChannel.send(messageStr);
logToFile(`${logPrefix} â
Sent small message (${messageBytes} bytes)`);
return true;
}
// Large message, chunk it
logToFile(`${logPrefix} đĻ Chunking large message (${messageBytes} bytes) into ${MAX_WEBRTC_MESSAGE_SIZE} byte chunks`);
const chunkId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
const chunks = [];
// Split message string into chunks
for (let i = 0; i < messageStr.length; i += MAX_WEBRTC_MESSAGE_SIZE - 200) { // Reserve 200 bytes for chunk metadata
chunks.push(messageStr.slice(i, i + MAX_WEBRTC_MESSAGE_SIZE - 200));
}
logToFile(`${logPrefix} đĻ Split into ${chunks.length} chunks`);
// Send chunk start notification
dataChannel.send(JSON.stringify({
type: CHUNK_TYPE_START,
chunkId: chunkId,
totalChunks: chunks.length,
totalSize: messageBytes,
originalType: message.type
}));
// Send each chunk with a small delay to prevent overwhelming
chunks.forEach((chunk, index) => {
setTimeout(() => {
try {
dataChannel.send(JSON.stringify({
type: CHUNK_TYPE_DATA,
chunkId: chunkId,
chunkIndex: index,
data: chunk
}));
// Send end notification after last chunk
if (index === chunks.length - 1) {
setTimeout(() => {
dataChannel.send(JSON.stringify({
type: CHUNK_TYPE_END,
chunkId: chunkId
}));
logToFile(`${logPrefix} â
Large message sent successfully (${chunks.length} chunks)`);
}, 10);
}
} catch (err) {
logToFile(`${logPrefix} â Error sending chunk ${index}: ${err.message}`);
}
}, index * 10); // 10ms delay between chunks
});
return true;
} catch (err) {
logToFile(`${logPrefix} â Error in sendLargeMessage: ${err.message}`);
return false;
}
}
function setupDataChannel(clientId) {
dataChannel.onopen = () => {
logToFile('[AGENT] â
Data channel is open!');
// Send buffered output for existing session when data channel opens
const session = sessionManager.getClientSession(clientId);
if (session) {
const bufferedOutput = session.buffer.getAll();
if (bufferedOutput) {
logToFile(`[AGENT] đ¤ Sending buffered output to client (${bufferedOutput.length} chars)`);
const success = sendLargeMessage(dataChannel, { type: 'output', data: bufferedOutput }, '[AGENT]');
if (!success) {
logToFile('[AGENT] â Failed to send buffered output');
}
} else {
logToFile('[AGENT] âšī¸ No buffered output to send for this session');
}
}
};
dataChannel.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
const session = sessionManager.getClientSession(clientId);
if (!session) {
logToFile(`[AGENT] â ī¸ No session found for client ${clientId}`);
return;
}
if (message.type === 'input') {
sessionManager.writeToSession(session.id, message.data);
} else if (message.type === 'resize') {
logToFile(`[AGENT] Resizing session ${session.id} to ${message.cols}x${message.rows}`);
sessionManager.resizeSession(session.id, message.cols, message.rows);
} else if (message.type === 'session-switch') {
// Handle session switching
logToFile(`[AGENT] Client ${clientId} switching to session ${message.sessionId}`);
if (sessionManager.connectClientToSession(clientId, message.sessionId)) {
// Send confirmation and buffered output
const newSession = sessionManager.getSession(message.sessionId);
dataChannel.send(JSON.stringify({
type: 'session-switched',
sessionId: message.sessionId,
sessionName: newSession.name
}));
// Send buffered output for this session
const bufferedOutput = newSession.buffer.getAll();
if (bufferedOutput) {
logToFile(`[AGENT] đ¤ Sending ${bufferedOutput.length} chars of buffered output for session switch`);
const success = sendLargeMessage(dataChannel, {
type: 'output',
data: bufferedOutput
}, '[AGENT]');
if (!success) {
logToFile('[AGENT] â Failed to send buffered output');
}
} else {
logToFile('[AGENT] âšī¸ No buffered output for switched session');
}
} else {
dataChannel.send(JSON.stringify({
type: 'error',
message: `Session ${message.sessionId} not found`
}));
}
} else if (message.type === 'session-create') {
// Handle new session creation via data channel
logToFile(`[AGENT] Client ${clientId} creating new session`);
const newSessionId = sessionManager.createSession(null, clientId);
if (newSessionId) {
const newSession = sessionManager.getSession(newSessionId);
// Send confirmation with updated session list
dataChannel.send(JSON.stringify({
type: 'session-created',
sessionId: newSessionId,
sessionName: newSession.name,
availableSessions: sessionManager.getAllSessions()
}));
logToFile(`[AGENT] â
New session created: ${newSessionId}`);
} else {
dataChannel.send(JSON.stringify({
type: 'error',
message: 'Failed to create session - maximum sessions reached'
}));
logToFile(`[AGENT] â Failed to create session for client ${clientId}`);
}
} else if (message.type === 'close_session') {
// Handle session closure request from client
logToFile(`[AGENT] Client ${clientId} closing session ${message.sessionId}`);
// Get remaining sessions BEFORE termination
const closingSessionId = message.sessionId;
sessionManager.terminateSession(closingSessionId);
const remainingSessions = sessionManager.getAllSessions();
// Send confirmation with updated session list
dataChannel.send(JSON.stringify({
type: 'session-closed',
sessionId: closingSessionId,
availableSessions: remainingSessions
}));
// If client requested auto-switch to next session, do it atomically
if (message.switchToSessionId && remainingSessions.find(s => s.id === message.switchToSessionId)) {
logToFile(`[AGENT] Auto-switching client to session ${message.switchToSessionId}`);
if (sessionManager.connectClientToSession(clientId, message.switchToSessionId)) {
const newSession = sessionManager.getSession(message.switchToSessionId);
dataChannel.send(JSON.stringify({
type: 'session-switched',
sessionId: message.switchToSessionId,
sessionName: newSession.name
}));
// Send buffered output
const bufferedOutput = newSession.buffer.getAll();
if (bufferedOutput) {
sendLargeMessage(dataChannel, { type: 'output', data: bufferedOutput }, '[AGENT]');
}
}
}
// Send immediate heartbeat to update dashboard
sendHeartbeat();
logToFile(`[AGENT] â
Session closed: ${closingSessionId}`);
}
} catch (err) {
logToFile(`[AGENT] Error parsing data channel message: ${err.message}`);
}
};
dataChannel.onclose = () => {
logToFile('[AGENT] Data channel closed.');
cleanup(clientId);
};
dataChannel.onerror = (error) => {
logToFile(`[AGENT] Data channel error: ${error.message}`);
};
}
function sendMessage(message) {
if (ws && ws.readyState === WebSocket.OPEN) {
logToFile(`[AGENT] Sending message: ${message.type}`);
ws.send(JSON.stringify(message));
} else {
logToFile('[AGENT] Cannot send message - WebSocket not connected');
}
}
// Graceful shutdown handling
process.on('SIGINT', () => {
console.log('\n[AGENT] Shutting down gracefully...');
cleanup();
if (ws) ws.close();
if (localServer) localServer.close();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\n[AGENT] Received SIGTERM, shutting down...');
cleanup();
if (ws) ws.close();
if (localServer) localServer.close();
process.exit(0);
});
// --- Local WebSocket Server for Direct Connections ---
// Sessions storage for direct WebSocket connections
const directSessions = {};
function startLocalServer() {
const localPort = process.env.LOCAL_PORT || 8080;
const localServer = require('ws').Server;
const wss = new localServer({ port: localPort });
logToFile(`đ Starting local WebSocket server on port ${localPort}`);
wss.on('connection', (localWs, request) => {
const clientIp = request.socket.remoteAddress;
logToFile(`đ Direct connection from ${clientIp}`);
// Handle direct browser connections
localWs.on('message', (data) => {
try {
const message = JSON.parse(data);
logToFile(`[LOCAL] Received direct message: ${message.type}`);
switch (message.type) {
case 'ping':
localWs.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
case 'authenticate':
// For direct connections, we can implement simpler auth
localWs.send(JSON.stringify({
type: 'authenticated',
agentId: AGENT_ID,
timestamp: Date.now()
}));
break;
case 'create_session':
// Check if client requested an existing session
let sessionId;
let isNewSession = false;
if (message.sessionId && directSessions[message.sessionId]) {
// Reconnect to existing session
sessionId = message.sessionId;
logToFile(`[LOCAL] Reconnecting to existing session: ${sessionId}`);
// Update activity timestamp
directSessions[sessionId].lastActivity = Date.now();
// Re-attach output handler for this connection
directSessions[sessionId].pty.onData((data) => {
if (localWs.readyState === WebSocket.OPEN) {
localWs.send(JSON.stringify({
type: 'output',
sessionId,
data
}));
}
});
// Send buffered output if available
const bufferedOutput = directSessions[sessionId].buffer.getAll();
if (bufferedOutput.length > 0) {
localWs.send(JSON.stringify({
type: 'output',
sessionId,
data: bufferedOutput.join('')
}));
}
} else {
// Create new terminal session
sessionId = uuidv4();
isNewSession = true;
const ptyProcess = pty.spawn(shell, [], {
name: 'xterm-color',
cols: message.cols || 120,
rows: message.rows || 30,
cwd: process.env.HOME,
env: process.env
});
// Store session
directSessions[sessionId] = {
pty: ptyProcess,
buffer: new CircularBuffer(),
lastActivity: Date.now()
};
// Send session output to direct connection
ptyProcess.onData((data) => {
if (localWs.readyState === WebSocket.OPEN) {
localWs.send(JSON.stringify({
type: 'output',
sessionId,
data
}));
}
// Store in buffer for reconnection
directSessions[sessionId].buffer.add(data);
});
logToFile(`[LOCAL] Created new direct session: ${sessionId}`);
// Enable case-insensitive tab completion for direct sessions
enableCaseInsensitiveCompletion(ptyProcess, shell);
}
localWs.send(JSON.stringify({
type: 'session_created',
sessionId,
sessionName: `Session ${sessionId.slice(0, 8)}`,
isNewSession: isNewSession,
cols: message.cols || 120,
rows: message.rows || 30
}));
break;
case 'input':
// Handle terminal input for direct connection
if (directSessions[message.sessionId]) {
directSessions[message.sessionId].pty.write(message.data);
directSessions[message.sessionId].lastActivity = Date.now();
}
break;
case 'resize':
// Handle terminal resize for direct connection
if (directSessions[message.sessionId]) {
directSessions[message.sessionId].pty.resize(message.cols, message.rows);
}
break;
default:
logToFile(`[LOCAL] Unknown message type: ${message.type}`);
}
} catch (err) {
logToFile(`[LOCAL] Error parsing message: ${err.message}`);
}
});
localWs.on('close', () => {
logToFile(`[LOCAL] Direct connection from ${clientIp} closed`);
});
localWs.on('error', (error) => {
logToFile(`[LOCAL] Direct connection error: ${error.message}`);
});
});
logToFile(`â
Local WebSocket server started on port ${localPort}`);
return wss;
}
// --- Start the agent ---
console.log(`[AGENT] Starting Mac Agent with ID: ${AGENT_ID}`);
// Start local server for direct connections
const localServer = startLocalServer();
console.log(`[AGENT] Connecting to signaling server at: ${SIGNALING_SERVER_URL}`);
connectToSignalingServer();