@swarmai/local-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/index.js +11 -0
- package/package.json +42 -0
- package/src/agenticChatClaudeMd.js +92 -0
- package/src/agenticChatHandler.js +566 -0
- package/src/aiProviderScanner.js +115 -0
- package/src/auth.js +180 -0
- package/src/cli.js +334 -0
- package/src/commands.js +1853 -0
- package/src/config.js +98 -0
- package/src/connection.js +470 -0
- package/src/mcpManager.js +276 -0
- package/src/startup.js +297 -0
- package/src/toolScanner.js +221 -0
- package/src/workspace.js +201 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management
|
|
3
|
+
* Reads/writes ~/.swarmai/config.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.swarmai');
|
|
11
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SERVER = 'https://agents.northpeak.app';
|
|
14
|
+
|
|
15
|
+
function ensureConfigDir() {
|
|
16
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
17
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadConfig() {
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
24
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
} catch (e) {
|
|
28
|
+
// Corrupt config, start fresh
|
|
29
|
+
}
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function saveConfig(config) {
|
|
34
|
+
ensureConfigDir();
|
|
35
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
mode: 0o600,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// On Windows, mode 0o600 is ignored. Try to restrict ACLs via icacls.
|
|
41
|
+
if (os.platform() === 'win32') {
|
|
42
|
+
try {
|
|
43
|
+
const { execSync } = require('child_process');
|
|
44
|
+
const username = os.userInfo().username;
|
|
45
|
+
execSync(`icacls "${CONFIG_FILE}" /inheritance:r /grant:r "${username}:(R,W)" /deny "Everyone:(R)"`, {
|
|
46
|
+
stdio: 'pipe', timeout: 5000,
|
|
47
|
+
});
|
|
48
|
+
} catch { /* non-critical — icacls might fail on some Windows editions */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getServer(opts) {
|
|
53
|
+
const config = loadConfig();
|
|
54
|
+
return opts?.server || config.server || DEFAULT_SERVER;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isConfigured() {
|
|
58
|
+
const config = loadConfig();
|
|
59
|
+
return !!(config.apiKey && config.server);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get security config with defaults
|
|
64
|
+
*/
|
|
65
|
+
function getSecurityDefaults() {
|
|
66
|
+
return {
|
|
67
|
+
shellBlocklist: [], // Additional patterns beyond built-in defaults
|
|
68
|
+
fileRootPaths: [], // Empty = allow all. Set paths to restrict file access.
|
|
69
|
+
requireApprovalFor: ['cliSession', 'clipboard'], // Phase 5.4: sensitive commands need approval. capture is handled by allowCapture gate instead (type-aware).
|
|
70
|
+
allowCapture: false, // Phase 5.4: camera/mic disabled by default, must opt-in
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get workspace config with OS-aware defaults.
|
|
76
|
+
* Users can override in ~/.swarmai/config.json under "workspace" key.
|
|
77
|
+
*/
|
|
78
|
+
function getWorkspaceDefaults() {
|
|
79
|
+
const platform = os.platform();
|
|
80
|
+
return {
|
|
81
|
+
rootPath: platform === 'win32' ? 'C:/SwarmAI' : path.join(os.homedir(), 'SwarmAI'),
|
|
82
|
+
cleanupIntervalMs: 60 * 60 * 1000, // 1 hour
|
|
83
|
+
tempMaxAgeMs: 24 * 60 * 60 * 1000, // 24 hours
|
|
84
|
+
downloadsMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
CONFIG_DIR,
|
|
90
|
+
CONFIG_FILE,
|
|
91
|
+
DEFAULT_SERVER,
|
|
92
|
+
loadConfig,
|
|
93
|
+
saveConfig,
|
|
94
|
+
getServer,
|
|
95
|
+
isConfigured,
|
|
96
|
+
getSecurityDefaults,
|
|
97
|
+
getWorkspaceDefaults,
|
|
98
|
+
};
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket connection management for Local Agent
|
|
3
|
+
*
|
|
4
|
+
* Connects to SwarmAI server via Socket.io /local-agent namespace.
|
|
5
|
+
* Handles heartbeat, command dispatch, and reconnection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const { executeCommand, getCapabilities, setConnectionContext, setSocket } = require('./commands');
|
|
10
|
+
const { scanTools, scanToolsAsync } = require('./toolScanner');
|
|
11
|
+
const { loadConfig, getWorkspaceDefaults } = require('./config');
|
|
12
|
+
const { initWorkspaceManager, getWorkspaceManager } = require('./workspace');
|
|
13
|
+
|
|
14
|
+
const HEARTBEAT_INTERVAL_MS = 15 * 1000; // Send heartbeat every 15s
|
|
15
|
+
|
|
16
|
+
// Rate limiting: max commands per window to prevent DoS
|
|
17
|
+
const RATE_LIMIT_WINDOW_MS = 10000; // 10-second window
|
|
18
|
+
const RATE_LIMIT_MAX_COMMANDS = 20; // max 20 commands per window (2/sec avg)
|
|
19
|
+
|
|
20
|
+
class AgentConnection {
|
|
21
|
+
constructor(serverUrl, apiKey, options = {}) {
|
|
22
|
+
this._serverUrl = serverUrl;
|
|
23
|
+
this._apiKey = apiKey;
|
|
24
|
+
this._socket = null;
|
|
25
|
+
this._heartbeatInterval = null;
|
|
26
|
+
this._onStatusChange = options.onStatusChange || (() => {});
|
|
27
|
+
this._onLog = options.onLog || console.log;
|
|
28
|
+
this._connected = false;
|
|
29
|
+
this._prevCpuTimes = null; // For CPU usage delta calculation
|
|
30
|
+
this._mcpStarting = false; // Prevent double MCP init
|
|
31
|
+
this._activeProcesses = new Map(); // Track running processes for cleanup
|
|
32
|
+
this._commandTimestamps = []; // Rate limiting
|
|
33
|
+
this._auditLog = []; // Command audit log (last 1000 entries)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Connect to server
|
|
38
|
+
*/
|
|
39
|
+
async connect() {
|
|
40
|
+
if (this._socket) {
|
|
41
|
+
throw new Error('Already connected. Call disconnect() first.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { io } = require('socket.io-client');
|
|
45
|
+
|
|
46
|
+
this._onLog('Connecting to server...');
|
|
47
|
+
|
|
48
|
+
this._socket = io(`${this._serverUrl}/local-agent`, {
|
|
49
|
+
auth: { apiKey: this._apiKey },
|
|
50
|
+
transports: ['websocket', 'polling'],
|
|
51
|
+
reconnection: true,
|
|
52
|
+
reconnectionDelay: 2000,
|
|
53
|
+
reconnectionDelayMax: 30000,
|
|
54
|
+
reconnectionAttempts: Infinity,
|
|
55
|
+
maxHttpBufferSize: 20 * 1024 * 1024, // 20MB — match server config for large payloads (screenshots, file transfers)
|
|
56
|
+
pingTimeout: 60000, // 60s — allow large payloads to finish transmitting
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Connection events
|
|
60
|
+
this._socket.on('connect', async () => {
|
|
61
|
+
this._connected = true;
|
|
62
|
+
this._onStatusChange('connected');
|
|
63
|
+
this._onLog('Connected to SwarmAI server');
|
|
64
|
+
|
|
65
|
+
// Pass server context to commands module for HTTP file uploads
|
|
66
|
+
setConnectionContext(this._serverUrl, this._apiKey);
|
|
67
|
+
|
|
68
|
+
// Pass socket reference for streaming output
|
|
69
|
+
setSocket(this._socket);
|
|
70
|
+
|
|
71
|
+
// Send system info on connect (includes tool registry + MCP tools)
|
|
72
|
+
// Uses async parallel scanning for faster startup
|
|
73
|
+
let toolRegistry = {};
|
|
74
|
+
try {
|
|
75
|
+
this._onLog('Scanning installed tools...');
|
|
76
|
+
toolRegistry = await scanToolsAsync().catch(() => scanTools()); // async with sync fallback
|
|
77
|
+
const installed = Object.entries(toolRegistry).filter(([k, v]) => k !== '_docker' && v.installed).map(([k]) => k);
|
|
78
|
+
this._onLog(`Found tools: ${installed.join(', ') || 'none'}`);
|
|
79
|
+
if (toolRegistry._docker?.available) {
|
|
80
|
+
this._onLog(`Docker: ${toolRegistry._docker.containers.length} running containers, ${toolRegistry._docker.images} images`);
|
|
81
|
+
}
|
|
82
|
+
} catch (e) {
|
|
83
|
+
this._onLog(`Tool scan failed: ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Start MCP servers (async, non-blocking for initial connect)
|
|
87
|
+
// Guard with _mcpStarting flag to prevent double-init on rapid reconnect
|
|
88
|
+
let mcpTools = [];
|
|
89
|
+
const startMcp = async () => {
|
|
90
|
+
if (this._mcpStarting) {
|
|
91
|
+
this._onLog('MCP startup already in progress, skipping');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this._mcpStarting = true;
|
|
95
|
+
try {
|
|
96
|
+
const { loadConfig } = require('./config');
|
|
97
|
+
const config = loadConfig();
|
|
98
|
+
if (config.mcpServers && Object.keys(config.mcpServers).length > 0) {
|
|
99
|
+
this._onLog('Starting MCP servers...');
|
|
100
|
+
const { getMcpManager } = require('./mcpManager');
|
|
101
|
+
const mcpManager = getMcpManager();
|
|
102
|
+
mcpManager.setLogger(this._onLog);
|
|
103
|
+
await mcpManager.startAll(config.mcpServers);
|
|
104
|
+
mcpTools = mcpManager.getAllTools();
|
|
105
|
+
const servers = mcpManager.getConnectedServers();
|
|
106
|
+
this._onLog(`MCP: ${servers.length} server(s) active, ${mcpTools.length} tool(s) available`);
|
|
107
|
+
|
|
108
|
+
// Send updated system-info with MCP tools
|
|
109
|
+
if (this._socket && this._connected) {
|
|
110
|
+
this._socket.emit('system-info', {
|
|
111
|
+
hostname: os.hostname(),
|
|
112
|
+
os: os.platform(),
|
|
113
|
+
osVersion: os.release(),
|
|
114
|
+
capabilities: getCapabilities(),
|
|
115
|
+
toolRegistry,
|
|
116
|
+
mcpTools,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (e) {
|
|
121
|
+
this._onLog(`MCP startup failed: ${e.message}`);
|
|
122
|
+
} finally {
|
|
123
|
+
this._mcpStarting = false;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Emit initial system-info immediately (without MCP tools)
|
|
128
|
+
this._socket.emit('system-info', {
|
|
129
|
+
hostname: os.hostname(),
|
|
130
|
+
os: os.platform(),
|
|
131
|
+
osVersion: os.release(),
|
|
132
|
+
capabilities: getCapabilities(),
|
|
133
|
+
toolRegistry,
|
|
134
|
+
mcpTools: [],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Start MCP servers in background (will re-emit system-info when ready)
|
|
138
|
+
startMcp();
|
|
139
|
+
|
|
140
|
+
// Scan for local AI providers (Ollama, LM Studio) — async, non-blocking
|
|
141
|
+
const scanAiServices = async () => {
|
|
142
|
+
try {
|
|
143
|
+
const { scanAiProviders } = require('./aiProviderScanner');
|
|
144
|
+
const aiProviders = await scanAiProviders();
|
|
145
|
+
if (aiProviders.length > 0 && this._socket && this._connected) {
|
|
146
|
+
this._socket.emit('ai-providers', { providers: aiProviders });
|
|
147
|
+
const summary = aiProviders.map(p => `${p.type}(${p.models.length} models)`).join(', ');
|
|
148
|
+
this._onLog(`AI providers: ${summary}`);
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
this._onLog(`AI provider scan failed: ${e.message}`);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
scanAiServices();
|
|
155
|
+
|
|
156
|
+
// Initialize workspace shared dirs (temp, downloads)
|
|
157
|
+
// Use getWorkspaceManager() to avoid re-creating instance on reconnect
|
|
158
|
+
try {
|
|
159
|
+
const config = loadConfig();
|
|
160
|
+
const wsConfig = { ...getWorkspaceDefaults(), ...(config.workspace || {}) };
|
|
161
|
+
const wm = getWorkspaceManager() || initWorkspaceManager(wsConfig);
|
|
162
|
+
const shared = wm.initSharedDirs();
|
|
163
|
+
this._onLog(`Workspace root: ${shared.rootPath}`);
|
|
164
|
+
// Report workspace paths to server
|
|
165
|
+
this._socket.emit('workspace:info', shared);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
this._onLog(`Workspace init failed: ${e.message}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Start periodic cleanup of temp/downloads (clear previous interval on reconnect)
|
|
171
|
+
if (this._cleanupInterval) {
|
|
172
|
+
clearInterval(this._cleanupInterval);
|
|
173
|
+
this._cleanupInterval = null;
|
|
174
|
+
}
|
|
175
|
+
const cleanupMs = getWorkspaceDefaults().cleanupIntervalMs;
|
|
176
|
+
this._cleanupInterval = setInterval(() => {
|
|
177
|
+
const wm = getWorkspaceManager();
|
|
178
|
+
if (wm) {
|
|
179
|
+
const tempDeleted = wm.cleanupTemp();
|
|
180
|
+
const dlDeleted = wm.cleanupDownloads();
|
|
181
|
+
if (tempDeleted || dlDeleted) {
|
|
182
|
+
this._onLog(`Workspace cleanup: ${tempDeleted} temp, ${dlDeleted} downloads removed`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}, cleanupMs);
|
|
186
|
+
|
|
187
|
+
// Start heartbeat
|
|
188
|
+
this._startHeartbeat();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
this._socket.on('disconnect', (reason) => {
|
|
192
|
+
this._connected = false;
|
|
193
|
+
this._onStatusChange('disconnected');
|
|
194
|
+
this._onLog(`Disconnected: ${reason}`);
|
|
195
|
+
this._stopHeartbeat();
|
|
196
|
+
|
|
197
|
+
// Kill any running processes spawned by commands (cliSession, shell, etc.)
|
|
198
|
+
if (this._activeProcesses.size > 0) {
|
|
199
|
+
this._onLog(`Cleaning up ${this._activeProcesses.size} running process(es)...`);
|
|
200
|
+
for (const [pid, info] of this._activeProcesses) {
|
|
201
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
202
|
+
this._onLog(`Killed process ${pid} (${info.command})`);
|
|
203
|
+
}
|
|
204
|
+
this._activeProcesses.clear();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
this._socket.on('connect_error', (error) => {
|
|
209
|
+
this._onStatusChange('error');
|
|
210
|
+
this._onLog(`Connection error: ${error.message}`);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Listen for profile workspace requests from server (lazy per-profile init)
|
|
214
|
+
// Registered outside 'connect' handler to avoid duplicate listeners on reconnect
|
|
215
|
+
this._socket.on('workspace:init', (data) => {
|
|
216
|
+
try {
|
|
217
|
+
const { profileName, systemPrompt } = data || {};
|
|
218
|
+
const wm = getWorkspaceManager();
|
|
219
|
+
if (!wm || !profileName) return;
|
|
220
|
+
const wsPath = wm.ensureProfileWorkspace(profileName, { systemPrompt });
|
|
221
|
+
this._socket.emit('workspace:ready', { profileName, workspacePath: wsPath });
|
|
222
|
+
this._onLog(`Profile workspace ready: ${wsPath}`);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
this._onLog(`Workspace init for profile failed: ${e.message}`);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Command handling with rate limiting and audit logging
|
|
229
|
+
this._socket.on('command', async (data) => {
|
|
230
|
+
const { commandId, command, params } = data;
|
|
231
|
+
const startTime = Date.now();
|
|
232
|
+
|
|
233
|
+
// Rate limiting
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
this._commandTimestamps = this._commandTimestamps.filter(t => now - t < RATE_LIMIT_WINDOW_MS);
|
|
236
|
+
if (this._commandTimestamps.length >= RATE_LIMIT_MAX_COMMANDS) {
|
|
237
|
+
this._onLog(`Rate limit exceeded: ${command} (${this._commandTimestamps.length} commands in ${RATE_LIMIT_WINDOW_MS / 1000}s)`);
|
|
238
|
+
this._socket.emit('command:result', {
|
|
239
|
+
commandId,
|
|
240
|
+
error: `Rate limit exceeded: max ${RATE_LIMIT_MAX_COMMANDS} commands per ${RATE_LIMIT_WINDOW_MS / 1000}s. Retry later.`,
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this._commandTimestamps.push(now);
|
|
245
|
+
|
|
246
|
+
this._onLog(`Received command: ${command}`);
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const result = await executeCommand(command, params, commandId);
|
|
250
|
+
this._socket.emit('command:result', { commandId, result });
|
|
251
|
+
const duration = Date.now() - startTime;
|
|
252
|
+
this._onLog(`Command ${command} completed (${duration}ms)`);
|
|
253
|
+
this._appendAuditEntry(command, params, 'success', duration);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
this._socket.emit('command:result', {
|
|
256
|
+
commandId,
|
|
257
|
+
error: error.message,
|
|
258
|
+
});
|
|
259
|
+
const duration = Date.now() - startTime;
|
|
260
|
+
this._onLog(`Command ${command} failed: ${error.message}`);
|
|
261
|
+
this._appendAuditEntry(command, params, 'error', duration, error.message);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Heartbeat ack
|
|
266
|
+
this._socket.on('heartbeat:ack', () => {
|
|
267
|
+
// Server acknowledged heartbeat
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Config update from server (e.g., security config changed via frontend)
|
|
271
|
+
this._socket.on('config:update', (data) => {
|
|
272
|
+
try {
|
|
273
|
+
const { security } = data || {};
|
|
274
|
+
if (security) {
|
|
275
|
+
const config = loadConfig();
|
|
276
|
+
config.security = { ...(config.security || {}), ...security };
|
|
277
|
+
const { saveConfig } = require('./config');
|
|
278
|
+
saveConfig(config);
|
|
279
|
+
this._onLog(`Security config updated from server: ${JSON.stringify(security)}`);
|
|
280
|
+
}
|
|
281
|
+
} catch (e) {
|
|
282
|
+
this._onLog(`Config update failed: ${e.message}`);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Revocation
|
|
287
|
+
this._socket.on('revoked', (data) => {
|
|
288
|
+
this._onLog(`Agent revoked: ${data?.reason || 'Access revoked'}`);
|
|
289
|
+
this.disconnect();
|
|
290
|
+
process.exit(1);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Wait for initial connection with retry tolerance.
|
|
294
|
+
// Don't reject on the first connect_error — let Socket.io retry.
|
|
295
|
+
// Only give up after MAX_INITIAL_RETRIES consecutive failures.
|
|
296
|
+
const MAX_INITIAL_RETRIES = 10;
|
|
297
|
+
const INITIAL_TIMEOUT_MS = 60000; // 60s total window for initial connect
|
|
298
|
+
|
|
299
|
+
return new Promise((resolve, reject) => {
|
|
300
|
+
let retryCount = 0;
|
|
301
|
+
|
|
302
|
+
const timeout = setTimeout(() => {
|
|
303
|
+
cleanup();
|
|
304
|
+
reject(new Error(`Connection timeout after ${INITIAL_TIMEOUT_MS / 1000}s (${retryCount} retries)`));
|
|
305
|
+
}, INITIAL_TIMEOUT_MS);
|
|
306
|
+
|
|
307
|
+
const onConnect = () => {
|
|
308
|
+
cleanup();
|
|
309
|
+
resolve();
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const onError = (err) => {
|
|
313
|
+
retryCount++;
|
|
314
|
+
this._onLog(`Connection attempt ${retryCount}/${MAX_INITIAL_RETRIES} failed: ${err.message}`);
|
|
315
|
+
|
|
316
|
+
if (retryCount >= MAX_INITIAL_RETRIES) {
|
|
317
|
+
cleanup();
|
|
318
|
+
reject(new Error(`${err.message} (after ${retryCount} retries)`));
|
|
319
|
+
}
|
|
320
|
+
// Otherwise let Socket.io reconnect automatically
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const cleanup = () => {
|
|
324
|
+
clearTimeout(timeout);
|
|
325
|
+
this._socket.off('connect', onConnect);
|
|
326
|
+
this._socket.off('connect_error', onError);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
this._socket.on('connect', onConnect);
|
|
330
|
+
this._socket.on('connect_error', onError);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Disconnect from server
|
|
336
|
+
*/
|
|
337
|
+
disconnect() {
|
|
338
|
+
this._stopHeartbeat();
|
|
339
|
+
|
|
340
|
+
// Stop workspace cleanup interval
|
|
341
|
+
if (this._cleanupInterval) {
|
|
342
|
+
clearInterval(this._cleanupInterval);
|
|
343
|
+
this._cleanupInterval = null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Shutdown MCP servers
|
|
347
|
+
try {
|
|
348
|
+
const { getMcpManager } = require('./mcpManager');
|
|
349
|
+
getMcpManager().disconnectAll();
|
|
350
|
+
} catch { /* ignore if MCP not loaded */ }
|
|
351
|
+
|
|
352
|
+
if (this._socket) {
|
|
353
|
+
this._socket.disconnect();
|
|
354
|
+
this._socket = null;
|
|
355
|
+
}
|
|
356
|
+
this._connected = false;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Check if connected
|
|
361
|
+
*/
|
|
362
|
+
isConnected() {
|
|
363
|
+
return this._connected;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
_startHeartbeat() {
|
|
367
|
+
this._stopHeartbeat();
|
|
368
|
+
this._heartbeatInterval = setInterval(() => {
|
|
369
|
+
if (this._socket && this._connected) {
|
|
370
|
+
// Enrich heartbeat with system metrics (#9 Health Dashboard)
|
|
371
|
+
const totalMem = os.totalmem();
|
|
372
|
+
const freeMem = os.freemem();
|
|
373
|
+
const cpus = os.cpus();
|
|
374
|
+
|
|
375
|
+
// Calculate CPU usage from delta between consecutive heartbeats
|
|
376
|
+
let cpuUsage = 0;
|
|
377
|
+
if (cpus.length > 0) {
|
|
378
|
+
const current = {};
|
|
379
|
+
for (const cpu of cpus) {
|
|
380
|
+
for (const type in cpu.times) {
|
|
381
|
+
current[type] = (current[type] || 0) + cpu.times[type];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (this._prevCpuTimes) {
|
|
386
|
+
const idleDelta = current.idle - this._prevCpuTimes.idle;
|
|
387
|
+
let totalDelta = 0;
|
|
388
|
+
for (const type in current) {
|
|
389
|
+
totalDelta += (current[type] - (this._prevCpuTimes[type] || 0));
|
|
390
|
+
}
|
|
391
|
+
if (totalDelta > 0) {
|
|
392
|
+
cpuUsage = Math.round(100 - (idleDelta / totalDelta * 100));
|
|
393
|
+
cpuUsage = Math.max(0, Math.min(100, cpuUsage));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
this._prevCpuTimes = current;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this._socket.emit('heartbeat', {
|
|
400
|
+
timestamp: Date.now(),
|
|
401
|
+
metrics: {
|
|
402
|
+
cpu: { usage: cpuUsage, cores: cpus.length },
|
|
403
|
+
memory: {
|
|
404
|
+
used: Math.round((totalMem - freeMem) / (1024 * 1024)),
|
|
405
|
+
total: Math.round(totalMem / (1024 * 1024)),
|
|
406
|
+
unit: 'MB',
|
|
407
|
+
},
|
|
408
|
+
uptime: Math.round(os.uptime()),
|
|
409
|
+
loadAvg: os.loadavg().map(v => Math.round(v * 100) / 100),
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
_stopHeartbeat() {
|
|
417
|
+
if (this._heartbeatInterval) {
|
|
418
|
+
clearInterval(this._heartbeatInterval);
|
|
419
|
+
this._heartbeatInterval = null;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Append command audit entry to in-memory log and persist to file.
|
|
425
|
+
* Keeps last 1000 entries in memory, writes all to disk for forensics.
|
|
426
|
+
*/
|
|
427
|
+
_appendAuditEntry(command, params, status, durationMs, error) {
|
|
428
|
+
const entry = {
|
|
429
|
+
ts: new Date().toISOString(),
|
|
430
|
+
command,
|
|
431
|
+
params: _sanitizeAuditParams(params),
|
|
432
|
+
status,
|
|
433
|
+
durationMs,
|
|
434
|
+
...(error ? { error: error.substring(0, 200) } : {}),
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// In-memory ring buffer (last 1000)
|
|
438
|
+
this._auditLog.push(entry);
|
|
439
|
+
if (this._auditLog.length > 1000) this._auditLog.shift();
|
|
440
|
+
|
|
441
|
+
// Persist to disk (append, non-blocking)
|
|
442
|
+
try {
|
|
443
|
+
const { CONFIG_DIR } = require('./config');
|
|
444
|
+
const fs = require('fs');
|
|
445
|
+
const logPath = require('path').join(CONFIG_DIR, 'command-audit.log');
|
|
446
|
+
const line = JSON.stringify(entry) + '\n';
|
|
447
|
+
fs.appendFile(logPath, line, () => {}); // fire-and-forget
|
|
448
|
+
} catch { /* non-critical */ }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Sanitize params for audit log — redact sensitive fields, truncate large values.
|
|
454
|
+
*/
|
|
455
|
+
function _sanitizeAuditParams(params) {
|
|
456
|
+
if (!params || typeof params !== 'object') return params;
|
|
457
|
+
const sanitized = {};
|
|
458
|
+
for (const [key, value] of Object.entries(params)) {
|
|
459
|
+
if (/password|secret|token|apiKey|key/i.test(key)) {
|
|
460
|
+
sanitized[key] = '[REDACTED]';
|
|
461
|
+
} else if (typeof value === 'string' && value.length > 200) {
|
|
462
|
+
sanitized[key] = value.substring(0, 200) + '...(truncated)';
|
|
463
|
+
} else {
|
|
464
|
+
sanitized[key] = value;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return sanitized;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
module.exports = { AgentConnection };
|