@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/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 };