@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.
@@ -0,0 +1,1853 @@
1
+ /**
2
+ * Command handlers for Local Agent
3
+ *
4
+ * These are executed when the server sends a command via WebSocket.
5
+ * Phase 5.1: systemInfo, notification
6
+ * Phase 5.2: screenshot, shell, fileRead, fileList
7
+ */
8
+
9
+ const os = require('os');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execSync, spawn } = require('child_process');
13
+ const { loadConfig, getSecurityDefaults } = require('./config');
14
+
15
+ const http = require('http');
16
+ const https = require('https');
17
+ const { createAgenticChatHandler } = require('./agenticChatHandler');
18
+
19
+ const MAX_SHELL_OUTPUT = 100 * 1024; // 100KB
20
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB
21
+ const MAX_CLI_OUTPUT = 500 * 1024; // 500KB
22
+ const MAX_TRANSFER_SIZE = 10 * 1024 * 1024; // 10MB
23
+ const MAX_CLIPBOARD_SIZE = 64 * 1024; // 64KB
24
+
25
+ // Connection context (set by connection.js before command dispatch)
26
+ let _serverUrl = null;
27
+ let _apiKey = null;
28
+
29
+ // Socket reference for streaming output (set by connection.js)
30
+ let _socket = null;
31
+
32
+ // Active child processes for kill support
33
+ const _activeProcesses = new Map(); // commandId → child process
34
+
35
+ /**
36
+ * Set the socket reference for streaming output.
37
+ * Called by connection.js on connect.
38
+ */
39
+ function setSocket(socket) {
40
+ _socket = socket;
41
+ }
42
+
43
+ /**
44
+ * Emit streaming output chunk to server
45
+ */
46
+ function emitChunk(commandId, chunk, stream = 'stdout') {
47
+ if (_socket && chunk) {
48
+ _socket.emit('command:output', { commandId, chunk, stream });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Set the server connection context for HTTP uploads.
54
+ * Called by connection.js on connect.
55
+ */
56
+ function setConnectionContext(serverUrl, apiKey) {
57
+ _serverUrl = serverUrl;
58
+ _apiKey = apiKey;
59
+ }
60
+
61
+ /**
62
+ * Upload a buffer to the server via HTTP POST (multipart/form-data).
63
+ * Returns { downloadUrl, id, token, ... } on success, null on failure.
64
+ */
65
+ async function uploadToServer(buffer, fileName, mimeType) {
66
+ if (!_serverUrl || !_apiKey) return null;
67
+
68
+ const boundary = `----SwarmaiBoundary${Date.now()}`;
69
+ const crlf = '\r\n';
70
+
71
+ // Build multipart body manually (no dependency needed)
72
+ const parts = [];
73
+ parts.push(`--${boundary}${crlf}`);
74
+ parts.push(`Content-Disposition: form-data; name="file"; filename="${fileName}"${crlf}`);
75
+ parts.push(`Content-Type: ${mimeType}${crlf}${crlf}`);
76
+ const header = Buffer.from(parts.join(''));
77
+ const footer = Buffer.from(`${crlf}--${boundary}--${crlf}`);
78
+ const body = Buffer.concat([header, buffer, footer]);
79
+
80
+ const url = new URL('/api/temp-files/agent-upload', _serverUrl);
81
+ const isHttps = url.protocol === 'https:';
82
+ const lib = isHttps ? https : http;
83
+
84
+ return new Promise((resolve) => {
85
+ const req = lib.request({
86
+ hostname: url.hostname,
87
+ port: url.port || (isHttps ? 443 : 80),
88
+ path: url.pathname,
89
+ method: 'POST',
90
+ headers: {
91
+ 'Authorization': `Bearer ${_apiKey}`,
92
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
93
+ 'Content-Length': body.length,
94
+ },
95
+ timeout: 30000,
96
+ }, (res) => {
97
+ let data = '';
98
+ res.on('data', (chunk) => { data += chunk; });
99
+ res.on('end', () => {
100
+ if (res.statusCode >= 200 && res.statusCode < 300) {
101
+ try { resolve(JSON.parse(data)); } catch { resolve(null); }
102
+ } else {
103
+ resolve(null);
104
+ }
105
+ });
106
+ });
107
+
108
+ req.on('error', () => resolve(null));
109
+ req.on('timeout', () => { req.destroy(); resolve(null); });
110
+ req.write(body);
111
+ req.end();
112
+ });
113
+ }
114
+
115
+ // Default dangerous shell patterns (merged with user config)
116
+ const DEFAULT_SHELL_BLOCKLIST = [
117
+ // Destructive filesystem ops
118
+ 'rm -rf /',
119
+ 'rm -rf ~',
120
+ 'rm -rf *',
121
+ 'format c:',
122
+ 'format d:',
123
+ 'del /s /q c:\\',
124
+ 'del /s /q d:\\',
125
+ 'mkfs',
126
+ 'dd if=',
127
+ // Fork bomb
128
+ ':(){ :|:& };:',
129
+ // System control
130
+ 'shutdown',
131
+ 'reboot',
132
+ 'halt',
133
+ 'init 0',
134
+ 'init 6',
135
+ ];
136
+
137
+ // Regex-based dangerous patterns (harder to evade than substring matching)
138
+ const DANGEROUS_SHELL_PATTERNS = [
139
+ /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?\/($|\s)/, // rm -rf / (any flag combo with -f)
140
+ /\bdd\b.*\bof\s*=\s*\/dev\/[sh]d/, // dd to disk device
141
+ /\bmkfs\b/, // format filesystem
142
+ /\bcurl\b.*\|\s*(ba)?sh/, // curl | bash (RCE)
143
+ /\bwget\b.*\|\s*(ba)?sh/, // wget | bash
144
+ /\bnc\b.*-[a-zA-Z]*e\b/, // netcat reverse shell
145
+ /\bpython[23]?\b.*\bsocket\b/, // python reverse shell
146
+ /\bchmod\b.*[+]s\b/, // setuid
147
+ /\bchown\b.*root/, // chown to root
148
+ />\s*\/etc\/(passwd|shadow|sudoers)/, // overwrite auth files
149
+ /\biptables\b.*-F/, // flush firewall
150
+ /\bcrontab\b.*-r/, // delete all crontabs
151
+ /\bkill\s+-9\s+-1\b/, // kill all processes
152
+ /\b(env|export)\b.*[A-Z_]+=.*&&/, // env manipulation + chain
153
+ ];
154
+
155
+ /**
156
+ * Get security config (merged defaults + user overrides)
157
+ */
158
+ function getSecurityConfig() {
159
+ const config = loadConfig();
160
+ const security = config.security || {};
161
+ const defaults = getSecurityDefaults();
162
+ return {
163
+ shellBlocklist: [...DEFAULT_SHELL_BLOCKLIST, ...(security.shellBlocklist || [])],
164
+ fileRootPaths: security.fileRootPaths || [], // empty = allow all
165
+ requireApprovalFor: security.requireApprovalFor ?? defaults.requireApprovalFor,
166
+ allowCapture: security.allowCapture ?? defaults.allowCapture,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Check if a shell command is blocked
172
+ * Normalizes whitespace and strips common shell escape chars before checking.
173
+ * Uses both substring blocklist AND regex patterns for defense-in-depth.
174
+ */
175
+ function isShellBlocked(command, blocklist) {
176
+ // Normalize: collapse whitespace, strip shell escape tricks, lowercase
177
+ const normalized = command
178
+ .toLowerCase()
179
+ .replace(/\\\n/g, '') // strip line continuations
180
+ .replace(/\\/g, '') // strip backslash escapes
181
+ .replace(/['"`]/g, '') // strip quotes and backticks
182
+ .replace(/\$\{?ifs\}?/gi, ' ') // replace $IFS, ${IFS}, ${IFS} variants
183
+ .replace(/\$\{?[a-z_]*\}?/gi, ' ') // neutralize other shell variables used for evasion
184
+ .replace(/\s+/g, ' ') // collapse whitespace
185
+ .trim();
186
+
187
+ // 1. Check substring blocklist
188
+ for (const pattern of blocklist) {
189
+ const normalizedPattern = pattern.toLowerCase().replace(/\s+/g, ' ').trim();
190
+ if (normalized.includes(normalizedPattern)) {
191
+ return pattern;
192
+ }
193
+ }
194
+
195
+ // 2. Check regex patterns against ORIGINAL command (before normalization)
196
+ // This catches encoded/obfuscated variants
197
+ for (const regex of DANGEROUS_SHELL_PATTERNS) {
198
+ if (regex.test(command) || regex.test(normalized)) {
199
+ return `regex:${regex.source}`;
200
+ }
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Validate file path against allowed root paths.
208
+ * Resolves symlinks to prevent traversal via symlink chains.
209
+ */
210
+ function validateFilePath(filePath, rootPaths) {
211
+ if (!rootPaths || rootPaths.length === 0) return true; // empty = allow all
212
+
213
+ // Resolve the logical path first (handles ../ etc.)
214
+ const resolved = path.resolve(filePath);
215
+
216
+ // Also resolve symlinks to get the real filesystem path
217
+ let realResolved = resolved;
218
+ try {
219
+ realResolved = fs.realpathSync(resolved);
220
+ } catch {
221
+ // File might not exist yet (e.g., for write operations) — check parent dir
222
+ try {
223
+ const parentReal = fs.realpathSync(path.dirname(resolved));
224
+ realResolved = path.join(parentReal, path.basename(resolved));
225
+ } catch {
226
+ // Parent doesn't exist either — use logical path
227
+ }
228
+ }
229
+
230
+ for (const root of rootPaths) {
231
+ const resolvedRoot = path.resolve(root);
232
+ let realRoot = resolvedRoot;
233
+ try { realRoot = fs.realpathSync(resolvedRoot); } catch { /* use logical */ }
234
+
235
+ const boundary = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
236
+
237
+ // Check both logical and real paths must be within bounds
238
+ if ((realResolved === realRoot || realResolved.startsWith(boundary)) &&
239
+ (resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep))) {
240
+ return true;
241
+ }
242
+ }
243
+ return false;
244
+ }
245
+
246
+ // =====================================================
247
+ // COMMAND HANDLERS
248
+ // =====================================================
249
+
250
+ /**
251
+ * Get system information
252
+ */
253
+ function handleSystemInfo() {
254
+ const cpus = os.cpus();
255
+ const totalMem = os.totalmem();
256
+ const freeMem = os.freemem();
257
+ const platform = os.platform();
258
+
259
+ // Suggest install commands for missing tools
260
+ const missingToolHints = {};
261
+ const installHints = {
262
+ ffmpeg: { darwin: 'brew install ffmpeg', linux: 'sudo apt install ffmpeg', win32: 'winget install ffmpeg' },
263
+ docker: { darwin: 'brew install --cask docker', linux: 'sudo apt install docker.io', win32: 'winget install Docker.DockerDesktop' },
264
+ git: { darwin: 'brew install git', linux: 'sudo apt install git', win32: 'winget install Git.Git' },
265
+ curl: { darwin: 'brew install curl', linux: 'sudo apt install curl', win32: 'winget install cURL.cURL' },
266
+ ollama: { darwin: 'brew install ollama', linux: 'curl -fsSL https://ollama.ai/install.sh | sh' },
267
+ };
268
+
269
+ for (const [tool, hints] of Object.entries(installHints)) {
270
+ try {
271
+ execSync(`${platform === 'win32' ? 'where' : 'which'} ${tool}`, { stdio: 'pipe', timeout: 2000 });
272
+ } catch {
273
+ if (hints[platform]) missingToolHints[tool] = hints[platform];
274
+ }
275
+ }
276
+
277
+ return {
278
+ hostname: os.hostname(),
279
+ os: platform,
280
+ osVersion: os.release(),
281
+ arch: os.arch(),
282
+ cpuModel: cpus.length > 0 ? cpus[0].model : 'Unknown',
283
+ cpuCores: cpus.length,
284
+ totalMemoryMB: Math.round(totalMem / (1024 * 1024)),
285
+ freeMemoryMB: Math.round(freeMem / (1024 * 1024)),
286
+ usedMemoryPct: Math.round(((totalMem - freeMem) / totalMem) * 100),
287
+ uptimeHours: Math.round(os.uptime() / 3600 * 10) / 10,
288
+ nodeVersion: process.version,
289
+ username: os.userInfo().username,
290
+ diskFreeGB: _getDiskFree(),
291
+ ...(Object.keys(missingToolHints).length > 0 ? { missingToolHints } : {}),
292
+ };
293
+ }
294
+
295
+ /** Get free disk space on the primary drive (best-effort) */
296
+ function _getDiskFree() {
297
+ try {
298
+ if (os.platform() === 'win32') {
299
+ const out = execSync('wmic logicaldisk where "DeviceID=\'C:\'" get FreeSpace /value', {
300
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
301
+ });
302
+ const match = out.match(/FreeSpace=(\d+)/);
303
+ return match ? Math.round(parseInt(match[1]) / (1024 * 1024 * 1024)) : null;
304
+ } else {
305
+ const out = execSync("df -k / | tail -1 | awk '{print $4}'", {
306
+ encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
307
+ });
308
+ return Math.round(parseInt(out.trim()) / (1024 * 1024)); // KB to GB
309
+ }
310
+ } catch { return null; }
311
+ }
312
+
313
+ /**
314
+ * Show a desktop notification (best effort)
315
+ */
316
+ function handleNotification(params) {
317
+ const { title = 'SwarmAI', message = 'Notification from SwarmAI' } = params || {};
318
+
319
+ try {
320
+ const notifier = require('node-notifier');
321
+ notifier.notify({ title, message });
322
+ return { sent: true, method: 'node-notifier' };
323
+ } catch {
324
+ console.log(`[Notification] ${title}: ${message}`);
325
+ return { sent: true, method: 'console' };
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Take a screenshot of the desktop (async — screenshot-desktop is promise-based)
331
+ */
332
+ async function handleScreenshot(params) {
333
+ // Default to jpeg for smaller payload size (PNG can be 5MB+, JPEG is typically 200-500KB)
334
+ const { format = 'jpeg', quality = 70 } = params || {};
335
+ const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; // 10MB hard cap
336
+
337
+ try {
338
+ const screenshot = require('screenshot-desktop');
339
+ const useFormat = format === 'png' ? 'png' : 'jpg';
340
+ const imgBuffer = await screenshot({ format: useFormat });
341
+
342
+ if (imgBuffer.length > MAX_SCREENSHOT_SIZE) {
343
+ throw new Error(`Screenshot too large: ${(imgBuffer.length / (1024 * 1024)).toFixed(1)}MB (max: ${MAX_SCREENSHOT_SIZE / (1024 * 1024)}MB). Try format: "jpeg" for smaller size.`);
344
+ }
345
+
346
+ const actualFormat = useFormat === 'jpg' ? 'jpeg' : 'png';
347
+ const sizeHuman = `${(imgBuffer.length / 1024).toFixed(0)}KB`;
348
+ const fileName = `screenshot_${Date.now()}.${actualFormat === 'jpeg' ? 'jpg' : 'png'}`;
349
+
350
+ // Upload via HTTP (lightweight metadata returned via WebSocket)
351
+ const uploaded = await uploadToServer(imgBuffer, fileName, `image/${actualFormat}`);
352
+ if (uploaded) {
353
+ return {
354
+ downloadUrl: uploaded.downloadUrl,
355
+ fileName,
356
+ format: actualFormat,
357
+ size: imgBuffer.length,
358
+ sizeHuman,
359
+ timestamp: new Date().toISOString(),
360
+ note: `Screenshot captured and uploaded. Download URL: ${uploaded.downloadUrl}`,
361
+ };
362
+ }
363
+
364
+ // Fallback: return base64 if HTTP upload fails (old behavior)
365
+ return {
366
+ imageData: imgBuffer.toString('base64'),
367
+ format: actualFormat,
368
+ mimeType: `image/${actualFormat}`,
369
+ size: imgBuffer.length,
370
+ sizeHuman,
371
+ timestamp: new Date().toISOString(),
372
+ };
373
+ } catch (error) {
374
+ throw new Error(`Screenshot failed: ${error.message}. Install screenshot-desktop: npm i screenshot-desktop`);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Execute a shell command with streaming output
380
+ * Streams stdout/stderr chunks in real-time via WebSocket
381
+ */
382
+ function handleShell(params, commandId) {
383
+ const { command, cwd, timeout = 30000, workspaceProfile, workspaceSystemPrompt } = params || {};
384
+
385
+ if (!command || typeof command !== 'string') {
386
+ throw new Error('shell command is required');
387
+ }
388
+
389
+ // Security check
390
+ const security = getSecurityConfig();
391
+ const blocked = isShellBlocked(command, security.shellBlocklist);
392
+ if (blocked) {
393
+ throw new Error(`Command blocked by security policy: matches "${blocked}"`);
394
+ }
395
+
396
+ // Check if this shell command matches a restricted pattern
397
+ if (security.requireApprovalFor.length > 0) {
398
+ const needsApproval = security.requireApprovalFor.some(pattern =>
399
+ command.toLowerCase().includes(pattern.toLowerCase())
400
+ );
401
+ if (needsApproval) {
402
+ return {
403
+ status: 'restricted',
404
+ command,
405
+ reason: 'This shell command is restricted by the local agent security config. The user can adjust security.requireApprovalFor in their config file to allow it.',
406
+ };
407
+ }
408
+ }
409
+
410
+ // Resolve effective cwd: explicit cwd > workspace profile > workspace root > process.cwd()
411
+ let effectiveCwd = cwd;
412
+ if (!effectiveCwd && workspaceProfile) {
413
+ try {
414
+ const { getWorkspaceManager } = require('./workspace');
415
+ const wm = getWorkspaceManager();
416
+ if (wm) {
417
+ effectiveCwd = wm.ensureProfileWorkspace(workspaceProfile, {
418
+ systemPrompt: workspaceSystemPrompt || '',
419
+ });
420
+ }
421
+ } catch { /* fall through */ }
422
+ }
423
+ if (!effectiveCwd) {
424
+ try {
425
+ const { getWorkspaceManager } = require('./workspace');
426
+ const wm = getWorkspaceManager();
427
+ if (wm) effectiveCwd = wm.getRootPath();
428
+ } catch { /* fall through */ }
429
+ }
430
+ if (!effectiveCwd) effectiveCwd = process.cwd();
431
+
432
+ // Validate resolved cwd against security file root paths
433
+ if (effectiveCwd !== process.cwd()) {
434
+ if (!validateFilePath(effectiveCwd, security.fileRootPaths)) {
435
+ throw new Error(`Access denied: cwd "${effectiveCwd}" is outside allowed directories`);
436
+ }
437
+ }
438
+
439
+ const effectiveTimeout = Math.min(timeout, 60000); // Cap at 60s
440
+
441
+ return new Promise((resolve, reject) => {
442
+ const startTime = Date.now();
443
+ let stdout = '';
444
+ let stderr = '';
445
+ let killed = false;
446
+
447
+ const isWindows = os.platform() === 'win32';
448
+ const shellCmd = isWindows ? 'cmd.exe' : '/bin/sh';
449
+ const shellArgs = isWindows ? ['/c', command] : ['-c', command];
450
+
451
+ const child = spawn(shellCmd, shellArgs, {
452
+ cwd: effectiveCwd,
453
+ stdio: ['pipe', 'pipe', 'pipe'],
454
+ windowsHide: true,
455
+ });
456
+
457
+ // Track for kill support
458
+ if (commandId) _activeProcesses.set(commandId, child);
459
+
460
+ child.stdout.on('data', (data) => {
461
+ const chunk = data.toString();
462
+ stdout += chunk;
463
+ // Stream to dashboard
464
+ if (commandId) emitChunk(commandId, chunk, 'stdout');
465
+ if (stdout.length > MAX_SHELL_OUTPUT && !killed) {
466
+ killed = true;
467
+ child.kill('SIGTERM');
468
+ }
469
+ });
470
+
471
+ child.stderr.on('data', (data) => {
472
+ const chunk = data.toString();
473
+ stderr += chunk;
474
+ if (commandId) emitChunk(commandId, chunk, 'stderr');
475
+ if (stderr.length > MAX_SHELL_OUTPUT) {
476
+ stderr = stderr.substring(0, MAX_SHELL_OUTPUT);
477
+ }
478
+ });
479
+
480
+ const timeoutTimer = setTimeout(() => {
481
+ if (!killed) {
482
+ killed = true;
483
+ child.kill('SIGTERM');
484
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* done */ } }, 5000);
485
+ }
486
+ }, effectiveTimeout);
487
+
488
+ child.on('close', (code) => {
489
+ clearTimeout(timeoutTimer);
490
+ if (commandId) _activeProcesses.delete(commandId);
491
+ const duration = Date.now() - startTime;
492
+ const truncated = stdout.length >= MAX_SHELL_OUTPUT;
493
+
494
+ if (stdout.length > MAX_SHELL_OUTPUT) {
495
+ stdout = stdout.substring(0, MAX_SHELL_OUTPUT) + '\n... [output truncated at 100KB]';
496
+ }
497
+
498
+ resolve({
499
+ stdout,
500
+ stderr,
501
+ exitCode: code,
502
+ duration,
503
+ truncated,
504
+ error: killed && code !== 0 ? 'Command timed out or killed' : undefined,
505
+ });
506
+ });
507
+
508
+ child.on('error', (err) => {
509
+ clearTimeout(timeoutTimer);
510
+ if (commandId) _activeProcesses.delete(commandId);
511
+ reject(new Error(`Shell command failed: ${err.message}`));
512
+ });
513
+ });
514
+ }
515
+
516
+ /**
517
+ * Read a file's contents
518
+ */
519
+ function handleFileRead(params) {
520
+ const { path: filePath, encoding = 'utf-8', maxBytes = MAX_FILE_SIZE } = params || {};
521
+
522
+ if (!filePath) {
523
+ throw new Error('fileRead path is required');
524
+ }
525
+
526
+ // Security check
527
+ const security = getSecurityConfig();
528
+ if (!validateFilePath(filePath, security.fileRootPaths)) {
529
+ throw new Error(`Access denied: path "${filePath}" is outside allowed directories`);
530
+ }
531
+
532
+ const resolvedPath = path.resolve(filePath);
533
+
534
+ if (!fs.existsSync(resolvedPath)) {
535
+ throw new Error(`File not found: ${filePath}`);
536
+ }
537
+
538
+ const stat = fs.statSync(resolvedPath);
539
+
540
+ if (stat.isDirectory()) {
541
+ throw new Error(`Path is a directory, not a file. Use fileList instead.`);
542
+ }
543
+
544
+ if (stat.size > maxBytes) {
545
+ throw new Error(`File too large: ${stat.size} bytes (max: ${maxBytes}). Use shell "head" or "tail" instead.`);
546
+ }
547
+
548
+ // Detect binary files
549
+ const ext = path.extname(resolvedPath).toLowerCase();
550
+ const binaryExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.pdf',
551
+ '.zip', '.gz', '.tar', '.exe', '.dll', '.so', '.dylib', '.wasm',
552
+ '.mp3', '.mp4', '.avi', '.mov', '.wav'];
553
+
554
+ if (binaryExts.includes(ext)) {
555
+ const data = fs.readFileSync(resolvedPath);
556
+ return {
557
+ content: data.toString('base64'),
558
+ encoding: 'base64',
559
+ size: stat.size,
560
+ mimeType: getMimeType(ext),
561
+ path: resolvedPath,
562
+ };
563
+ }
564
+
565
+ const content = fs.readFileSync(resolvedPath, encoding);
566
+ return {
567
+ content,
568
+ encoding,
569
+ size: stat.size,
570
+ path: resolvedPath,
571
+ };
572
+ }
573
+
574
+ /**
575
+ * List directory contents
576
+ */
577
+ function handleFileList(params) {
578
+ const { path: dirPath, recursive = false, filter, offset = 0, limit = 500 } = params || {};
579
+
580
+ if (!dirPath) {
581
+ throw new Error('fileList path is required');
582
+ }
583
+
584
+ // Security check
585
+ const security = getSecurityConfig();
586
+ if (!validateFilePath(dirPath, security.fileRootPaths)) {
587
+ throw new Error(`Access denied: path "${dirPath}" is outside allowed directories`);
588
+ }
589
+
590
+ const resolvedPath = path.resolve(dirPath);
591
+
592
+ if (!fs.existsSync(resolvedPath)) {
593
+ throw new Error(`Directory not found: ${dirPath}`);
594
+ }
595
+
596
+ const stat = fs.statSync(resolvedPath);
597
+ if (!stat.isDirectory()) {
598
+ throw new Error(`Path is a file, not a directory. Use fileRead instead.`);
599
+ }
600
+
601
+ const effectiveLimit = Math.min(limit, 1000); // hard cap at 1000
602
+ const allFiles = listDir(resolvedPath, recursive, filter, offset + effectiveLimit);
603
+
604
+ // Apply pagination
605
+ const paginated = allFiles.slice(offset, offset + effectiveLimit);
606
+
607
+ return {
608
+ files: paginated,
609
+ total: allFiles.length,
610
+ offset,
611
+ limit: effectiveLimit,
612
+ hasMore: allFiles.length > offset + effectiveLimit,
613
+ path: resolvedPath,
614
+ };
615
+ }
616
+
617
+ /**
618
+ * List directory contents (helper)
619
+ */
620
+ function listDir(dirPath, recursive, filter, maxItems) {
621
+ const results = [];
622
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
623
+
624
+ for (const entry of entries) {
625
+ if (results.length >= maxItems) break;
626
+
627
+ // Skip hidden files by default (including .env for security)
628
+ if (entry.name.startsWith('.')) continue;
629
+
630
+ const fullPath = path.join(dirPath, entry.name);
631
+
632
+ // Apply filter
633
+ if (filter && !entry.name.includes(filter)) {
634
+ if (!entry.isDirectory() || !recursive) continue;
635
+ }
636
+
637
+ try {
638
+ const stat = fs.statSync(fullPath);
639
+ results.push({
640
+ name: entry.name,
641
+ path: fullPath,
642
+ size: stat.size,
643
+ mtime: stat.mtime.toISOString(),
644
+ isDirectory: entry.isDirectory(),
645
+ });
646
+
647
+ if (recursive && entry.isDirectory() && results.length < maxItems) {
648
+ const children = listDir(fullPath, true, filter, maxItems - results.length);
649
+ results.push(...children);
650
+ }
651
+ } catch {
652
+ // Skip files we can't stat (permission issues)
653
+ }
654
+ }
655
+
656
+ return results;
657
+ }
658
+
659
+ /**
660
+ * Get MIME type from extension
661
+ */
662
+ function getMimeType(ext) {
663
+ const types = {
664
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
665
+ '.gif': 'image/gif', '.pdf': 'application/pdf', '.zip': 'application/zip',
666
+ '.gz': 'application/gzip', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
667
+ };
668
+ return types[ext] || 'application/octet-stream';
669
+ }
670
+
671
+ /**
672
+ * Handle MCP tool call from server
673
+ * params: { action, server, tool, args }
674
+ */
675
+ async function handleMcp(params) {
676
+ const { getMcpManager } = require('./mcpManager');
677
+ const mcpManager = getMcpManager();
678
+
679
+ const action = params?.action || 'call';
680
+
681
+ if (action === 'list') {
682
+ return {
683
+ servers: mcpManager.getConnectedServers(),
684
+ tools: mcpManager.getAllTools(),
685
+ };
686
+ }
687
+
688
+ if (action === 'status') {
689
+ return {
690
+ servers: mcpManager.getConnectedServers(),
691
+ toolCount: mcpManager.getAllTools().length,
692
+ };
693
+ }
694
+
695
+ // Default: call a tool
696
+ const { server, tool, args = {} } = params || {};
697
+ if (!server) throw new Error('mcp: server name is required');
698
+ if (!tool) throw new Error('mcp: tool name is required');
699
+
700
+ const result = await mcpManager.callTool(server, tool, args);
701
+ return {
702
+ server,
703
+ tool,
704
+ result,
705
+ executedAt: new Date().toISOString(),
706
+ };
707
+ }
708
+
709
+ // =====================================================
710
+ // PHASE 5.4 COMMAND HANDLERS
711
+ // =====================================================
712
+
713
+ /**
714
+ * Kill a running command by its commandId
715
+ */
716
+ function handleKill(params) {
717
+ const { commandId } = params || {};
718
+ if (!commandId) throw new Error('kill requires commandId parameter');
719
+
720
+ const child = _activeProcesses.get(commandId);
721
+ if (!child) {
722
+ return { killed: false, reason: 'No active process found for this command' };
723
+ }
724
+
725
+ try {
726
+ child.kill('SIGTERM');
727
+ // Force kill after 3 seconds
728
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* done */ } }, 3000);
729
+ return { killed: true, commandId };
730
+ } catch (err) {
731
+ return { killed: false, reason: err.message };
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Run a CLI AI session (claude, gemini, opencode)
737
+ * Non-interactive, spawns process with prompt argument.
738
+ * Streams output in real-time via WebSocket.
739
+ */
740
+ async function handleCliSession(params, commandId) {
741
+ const { cliType, prompt, cwd, timeout, workspaceProfile, workspaceSystemPrompt } = params || {};
742
+
743
+ const CLI_WHITELIST = ['claude', 'gemini', 'opencode'];
744
+ if (!CLI_WHITELIST.includes(cliType)) {
745
+ throw new Error(`Unsupported CLI type: "${cliType}". Allowed: ${CLI_WHITELIST.join(', ')}`);
746
+ }
747
+
748
+ if (!prompt || typeof prompt !== 'string') {
749
+ throw new Error('cliSession prompt is required and must be a non-empty string');
750
+ }
751
+
752
+ // Resolve effective cwd: explicit cwd > workspace profile > workspace root > process.cwd()
753
+ let effectiveCwd = cwd;
754
+ if (!effectiveCwd && workspaceProfile) {
755
+ try {
756
+ const { getWorkspaceManager } = require('./workspace');
757
+ const wm = getWorkspaceManager();
758
+ if (wm) {
759
+ effectiveCwd = wm.ensureProfileWorkspace(workspaceProfile, {
760
+ systemPrompt: workspaceSystemPrompt || '',
761
+ });
762
+ }
763
+ } catch { /* fall through to next fallback */ }
764
+ }
765
+ if (!effectiveCwd) {
766
+ try {
767
+ const { getWorkspaceManager } = require('./workspace');
768
+ const wm = getWorkspaceManager();
769
+ if (wm) effectiveCwd = wm.getRootPath();
770
+ } catch { /* fall through */ }
771
+ }
772
+ if (!effectiveCwd) effectiveCwd = process.cwd();
773
+
774
+ // Validate working directory
775
+ if (effectiveCwd && effectiveCwd !== process.cwd()) {
776
+ const security = getSecurityConfig();
777
+ if (!validateFilePath(effectiveCwd, security.fileRootPaths)) {
778
+ throw new Error(`Access denied: cwd "${effectiveCwd}" is outside allowed directories`);
779
+ }
780
+ if (!fs.existsSync(effectiveCwd) || !fs.statSync(effectiveCwd).isDirectory()) {
781
+ throw new Error(`cwd does not exist or is not a directory: ${effectiveCwd}`);
782
+ }
783
+ }
784
+
785
+ // Build command and args based on CLI type
786
+ const isWindows = os.platform() === 'win32';
787
+ const suffix = isWindows ? '.cmd' : '';
788
+ let executable;
789
+ let args;
790
+
791
+ switch (cliType) {
792
+ case 'claude':
793
+ executable = `claude${suffix}`;
794
+ args = ['--print', prompt];
795
+ break;
796
+ case 'gemini':
797
+ executable = `gemini${suffix}`;
798
+ args = [prompt];
799
+ break;
800
+ case 'opencode':
801
+ executable = `opencode${suffix}`;
802
+ args = ['run', prompt];
803
+ break;
804
+ }
805
+
806
+ // ── Async mode: extended timeout + stale detection for long-running tasks ──
807
+ // When asyncMode is true (set by server for long CLI tasks), we:
808
+ // 1. Allow up to 60 minutes instead of 5 minutes
809
+ // 2. Add stale detection (kill if no output for staleThresholdMs)
810
+ // 3. Report result via 'command:async-result' WebSocket event
811
+ const isAsync = params.asyncMode === true;
812
+ const effectiveTimeout = isAsync
813
+ ? Math.min(timeout || 3600000, 3600000) // Async: up to 60 min
814
+ : Math.min(timeout || 120000, 300000); // Sync: default 2min, cap 5min
815
+ const staleThresholdMs = params.staleThresholdMs || (5 * 60 * 1000); // 5 min default
816
+
817
+ return new Promise((resolve, reject) => {
818
+ const startTime = Date.now();
819
+ let stdout = '';
820
+ let stderr = '';
821
+ let killed = false;
822
+ let killTimer = null;
823
+ let lastOutputTime = Date.now();
824
+ let staleCheckTimer = null;
825
+
826
+ const child = spawn(executable, args, {
827
+ cwd: effectiveCwd,
828
+ stdio: ['pipe', 'pipe', 'pipe'],
829
+ windowsHide: true,
830
+ });
831
+
832
+ // Track for kill support
833
+ if (commandId) _activeProcesses.set(commandId, child);
834
+
835
+ child.stdout.on('data', (data) => {
836
+ const chunk = data.toString();
837
+ stdout += chunk;
838
+ lastOutputTime = Date.now();
839
+ // Stream to dashboard (always, for both sync and async)
840
+ if (commandId) emitChunk(commandId, chunk, 'stdout');
841
+ if (stdout.length > MAX_CLI_OUTPUT) {
842
+ stdout = stdout.substring(0, MAX_CLI_OUTPUT);
843
+ if (!killed) {
844
+ killed = true;
845
+ child.kill('SIGTERM');
846
+ }
847
+ }
848
+ });
849
+
850
+ child.stderr.on('data', (data) => {
851
+ const chunk = data.toString();
852
+ stderr += chunk;
853
+ lastOutputTime = Date.now();
854
+ if (commandId) emitChunk(commandId, chunk, 'stderr');
855
+ if (stderr.length > MAX_CLI_OUTPUT) {
856
+ stderr = stderr.substring(0, MAX_CLI_OUTPUT);
857
+ }
858
+ });
859
+
860
+ // Stale detection for async mode: kill process if no output for staleThresholdMs
861
+ if (isAsync) {
862
+ staleCheckTimer = setInterval(() => {
863
+ const silentMs = Date.now() - lastOutputTime;
864
+ if (silentMs > staleThresholdMs && !killed) {
865
+ killed = true;
866
+ child.kill('SIGTERM');
867
+ setTimeout(() => {
868
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
869
+ }, 5000);
870
+ if (staleCheckTimer) clearInterval(staleCheckTimer);
871
+ }
872
+ }, 30000); // Check every 30s
873
+ }
874
+
875
+ // Timeout handler
876
+ const timeoutTimer = setTimeout(() => {
877
+ if (!killed) {
878
+ killed = true;
879
+ child.kill('SIGTERM');
880
+ // Force kill after 5 seconds if still alive
881
+ killTimer = setTimeout(() => {
882
+ try { child.kill('SIGKILL'); } catch { /* already dead */ }
883
+ }, 5000);
884
+ }
885
+ }, effectiveTimeout);
886
+
887
+ child.on('close', (code) => {
888
+ clearTimeout(timeoutTimer);
889
+ if (killTimer) clearTimeout(killTimer);
890
+ if (staleCheckTimer) clearInterval(staleCheckTimer);
891
+ if (commandId) _activeProcesses.delete(commandId);
892
+ const duration = Date.now() - startTime;
893
+ const truncated = stdout.length >= MAX_CLI_OUTPUT;
894
+
895
+ const result = {
896
+ cliType,
897
+ output: stdout,
898
+ stderr,
899
+ exitCode: code,
900
+ duration,
901
+ truncated,
902
+ };
903
+
904
+ // In async mode, also report result back via command:async-result event
905
+ // so the server can deliver to the user even if the sync promise already resolved
906
+ if (isAsync && _socket && commandId) {
907
+ _socket.emit('command:async-result', { commandId, result, error: code !== 0 ? `CLI exited with code ${code}` : null });
908
+ }
909
+
910
+ resolve(result);
911
+ });
912
+
913
+ child.on('error', (err) => {
914
+ clearTimeout(timeoutTimer);
915
+ if (killTimer) clearTimeout(killTimer);
916
+ if (staleCheckTimer) clearInterval(staleCheckTimer);
917
+ if (commandId) _activeProcesses.delete(commandId);
918
+ reject(new Error(`Failed to start ${cliType}: ${err.message}. Is ${cliType} installed and in PATH?`));
919
+ });
920
+ });
921
+ }
922
+
923
+ /**
924
+ * Transfer a file as base64 (up to 10MB)
925
+ */
926
+ async function handleFileTransfer(params) {
927
+ const { path: filePath } = params || {};
928
+
929
+ if (!filePath) {
930
+ throw new Error('fileTransfer path is required');
931
+ }
932
+
933
+ // Security check
934
+ const security = getSecurityConfig();
935
+ if (!validateFilePath(filePath, security.fileRootPaths)) {
936
+ throw new Error(`Access denied: path "${filePath}" is outside allowed directories`);
937
+ }
938
+
939
+ const resolvedPath = path.resolve(filePath);
940
+
941
+ if (!fs.existsSync(resolvedPath)) {
942
+ throw new Error(`File not found: ${filePath}`);
943
+ }
944
+
945
+ const stat = fs.statSync(resolvedPath);
946
+
947
+ if (stat.isDirectory()) {
948
+ throw new Error('Path is a directory, not a file. Cannot transfer directories.');
949
+ }
950
+
951
+ if (stat.size > MAX_TRANSFER_SIZE) {
952
+ throw new Error(`File too large: ${stat.size} bytes (${(stat.size / (1024 * 1024)).toFixed(1)}MB). Max: ${MAX_TRANSFER_SIZE / (1024 * 1024)}MB`);
953
+ }
954
+
955
+ const buffer = fs.readFileSync(resolvedPath);
956
+ const originalName = path.basename(resolvedPath);
957
+ const ext = path.extname(resolvedPath).toLowerCase();
958
+ const mimeType = getMimeType(ext);
959
+
960
+ // Upload via HTTP (lightweight metadata returned via WebSocket)
961
+ const uploaded = await uploadToServer(buffer, originalName, mimeType);
962
+ if (uploaded) {
963
+ return {
964
+ downloadUrl: uploaded.downloadUrl,
965
+ originalName,
966
+ mimeType,
967
+ size: stat.size,
968
+ sizeHuman: `${(stat.size / 1024).toFixed(0)}KB`,
969
+ note: `File uploaded. Download URL: ${uploaded.downloadUrl}`,
970
+ };
971
+ }
972
+
973
+ // Fallback: return base64 if HTTP upload fails (old behavior)
974
+ return {
975
+ content: buffer.toString('base64'),
976
+ encoding: 'base64',
977
+ originalName,
978
+ mimeType,
979
+ size: stat.size,
980
+ };
981
+ }
982
+
983
+ /**
984
+ * Read or write system clipboard (async, non-blocking)
985
+ */
986
+ async function handleClipboard(params) {
987
+ const { action, text } = params || {};
988
+
989
+ if (action !== 'read' && action !== 'write') {
990
+ throw new Error('clipboard action must be "read" or "write"');
991
+ }
992
+
993
+ const platform = os.platform();
994
+
995
+ if (action === 'read') {
996
+ let cmd, args;
997
+ if (platform === 'win32') {
998
+ cmd = 'powershell'; args = ['-NoProfile', '-Command', 'Get-Clipboard'];
999
+ } else if (platform === 'darwin') {
1000
+ cmd = 'pbpaste'; args = [];
1001
+ } else {
1002
+ // Linux: try wl-paste (Wayland), then xclip, then xsel
1003
+ cmd = await _findLinuxClipboardCmd(['wl-paste', 'xclip', 'xsel']);
1004
+ if (cmd === 'xclip') args = ['-selection', 'clipboard', '-o'];
1005
+ else if (cmd === 'xsel') args = ['--clipboard', '--output'];
1006
+ else args = []; // wl-paste needs no args
1007
+ }
1008
+
1009
+ const content = await _spawnAsync(cmd, args, null, 5000);
1010
+ return { action: 'read', content: content.replace(/\r\n$/, ''), length: content.length };
1011
+ }
1012
+
1013
+ // action === 'write'
1014
+ if (typeof text !== 'string') {
1015
+ throw new Error('clipboard write requires "text" parameter as a string');
1016
+ }
1017
+ if (Buffer.byteLength(text) > MAX_CLIPBOARD_SIZE) {
1018
+ throw new Error(`Text too large for clipboard: ${Buffer.byteLength(text)} bytes (max: ${MAX_CLIPBOARD_SIZE})`);
1019
+ }
1020
+
1021
+ let cmd, args;
1022
+ if (platform === 'win32') {
1023
+ cmd = 'powershell'; args = ['-NoProfile', '-Command', 'Set-Clipboard -Value $input'];
1024
+ } else if (platform === 'darwin') {
1025
+ cmd = 'pbcopy'; args = [];
1026
+ } else {
1027
+ cmd = await _findLinuxClipboardCmd(['wl-copy', 'xclip', 'xsel']);
1028
+ if (cmd === 'xclip') args = ['-selection', 'clipboard'];
1029
+ else if (cmd === 'xsel') args = ['--clipboard', '--input'];
1030
+ else args = []; // wl-copy needs no args
1031
+ }
1032
+
1033
+ await _spawnAsync(cmd, args, text, 5000);
1034
+ return { action: 'write', success: true, length: text.length };
1035
+ }
1036
+
1037
+ /**
1038
+ * Find first available clipboard command on Linux (Wayland + X11 fallback)
1039
+ */
1040
+ async function _findLinuxClipboardCmd(candidates) {
1041
+ for (const cmd of candidates) {
1042
+ try {
1043
+ execSync(`which ${cmd}`, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] });
1044
+ return cmd;
1045
+ } catch { continue; }
1046
+ }
1047
+ throw new Error('No clipboard tool found. Install wl-clipboard (Wayland), xclip, or xsel.');
1048
+ }
1049
+
1050
+ /**
1051
+ * Non-blocking spawn helper with timeout and optional stdin
1052
+ */
1053
+ function _spawnAsync(cmd, args, stdin, timeoutMs) {
1054
+ return new Promise((resolve, reject) => {
1055
+ const proc = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs });
1056
+ let stdout = '';
1057
+ let stderr = '';
1058
+
1059
+ proc.stdout.on('data', (d) => { stdout += d; });
1060
+ proc.stderr.on('data', (d) => { stderr += d; });
1061
+
1062
+ const timer = setTimeout(() => {
1063
+ proc.kill('SIGTERM');
1064
+ reject(new Error(`${cmd} timed out after ${timeoutMs}ms`));
1065
+ }, timeoutMs);
1066
+
1067
+ proc.on('close', (code) => {
1068
+ clearTimeout(timer);
1069
+ if (code === 0) resolve(stdout);
1070
+ else reject(new Error(`${cmd} failed (exit ${code}): ${stderr.substring(0, 200)}`));
1071
+ });
1072
+
1073
+ proc.on('error', (err) => {
1074
+ clearTimeout(timer);
1075
+ reject(new Error(`${cmd} failed: ${err.message}`));
1076
+ });
1077
+
1078
+ if (stdin != null) {
1079
+ proc.stdin.write(stdin);
1080
+ proc.stdin.end();
1081
+ }
1082
+ });
1083
+ }
1084
+
1085
+ /**
1086
+ * Capture from camera or microphone using ffmpeg
1087
+ */
1088
+ async function handleCapture(params) {
1089
+ const { type, device, duration, format } = params || {};
1090
+
1091
+ const ALLOWED_TYPES = ['camera', 'microphone', 'list_devices'];
1092
+ if (!ALLOWED_TYPES.includes(type)) {
1093
+ throw new Error(`capture type must be one of: ${ALLOWED_TYPES.join(', ')}`);
1094
+ }
1095
+
1096
+ // Check security config - capture must be explicitly allowed
1097
+ const security = getSecurityConfig();
1098
+ if (!security.allowCapture && type !== 'list_devices') {
1099
+ throw new Error('Capture (camera/microphone) is disabled by default. Set security.allowCapture = true in config to enable.');
1100
+ }
1101
+
1102
+ // Validate device name to prevent argument injection
1103
+ if (device && !/^[\w\s:./\\()-]+$/.test(device)) {
1104
+ throw new Error(`Invalid device name: "${device}". Only alphanumeric, spaces, colons, dots, slashes, and parentheses are allowed.`);
1105
+ }
1106
+
1107
+ // Check ffmpeg is installed
1108
+ try {
1109
+ execSync('ffmpeg -version', { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
1110
+ } catch {
1111
+ throw new Error('ffmpeg is not installed. Install it: https://ffmpeg.org/download.html');
1112
+ }
1113
+
1114
+ const platform = os.platform();
1115
+
1116
+ // --- LIST DEVICES ---
1117
+ if (type === 'list_devices') {
1118
+ try {
1119
+ let output = '';
1120
+ if (platform === 'win32') {
1121
+ try {
1122
+ execSync('ffmpeg -list_devices true -f dshow -i dummy', {
1123
+ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
1124
+ });
1125
+ } catch (e) {
1126
+ // ffmpeg outputs device list to stderr and exits with error code
1127
+ output = (e.stderr || '').toString();
1128
+ }
1129
+ } else if (platform === 'darwin') {
1130
+ try {
1131
+ execSync('ffmpeg -f avfoundation -list_devices true -i ""', {
1132
+ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
1133
+ });
1134
+ } catch (e) {
1135
+ output = (e.stderr || '').toString();
1136
+ }
1137
+ } else {
1138
+ // Linux: list video and audio devices
1139
+ const videoDevices = [];
1140
+ const audioDevices = [];
1141
+ try {
1142
+ const videoFiles = fs.readdirSync('/dev').filter(f => f.startsWith('video'));
1143
+ videoDevices.push(...videoFiles.map(f => `/dev/${f}`));
1144
+ } catch { /* no video devices */ }
1145
+ try {
1146
+ const audioOut = execSync('arecord -l', {
1147
+ encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
1148
+ });
1149
+ audioDevices.push(audioOut.trim());
1150
+ } catch { /* no audio devices */ }
1151
+ output = `Video devices: ${videoDevices.join(', ') || 'none found'}\nAudio devices: ${audioDevices.length ? audioDevices.join('\n') : 'none found'}`;
1152
+ }
1153
+ return { type: 'list_devices', output, platform };
1154
+ } catch (error) {
1155
+ throw new Error(`Failed to list devices: ${error.message}`);
1156
+ }
1157
+ }
1158
+
1159
+ // Create temp directory for captures
1160
+ const captureDir = path.join(os.homedir(), '.swarmai', 'capture');
1161
+ if (!fs.existsSync(captureDir)) {
1162
+ fs.mkdirSync(captureDir, { recursive: true });
1163
+ }
1164
+
1165
+ const timestamp = Date.now();
1166
+
1167
+ // --- CAMERA CAPTURE ---
1168
+ if (type === 'camera') {
1169
+ const outputPath = path.join(captureDir, `capture_${timestamp}.jpg`);
1170
+
1171
+ let ffmpegArgs;
1172
+ if (platform === 'win32') {
1173
+ ffmpegArgs = ['-f', 'dshow', '-i', 'video=' + (device || 'Integrated Camera'), '-frames:v', '1', '-y', outputPath];
1174
+ } else if (platform === 'darwin') {
1175
+ ffmpegArgs = ['-f', 'avfoundation', '-i', device || '0', '-frames:v', '1', '-y', outputPath];
1176
+ } else {
1177
+ ffmpegArgs = ['-f', 'v4l2', '-i', device || '/dev/video0', '-frames:v', '1', '-y', outputPath];
1178
+ }
1179
+
1180
+ await runFfmpeg(ffmpegArgs, 30000);
1181
+
1182
+ // Read result and clean up
1183
+ if (!fs.existsSync(outputPath)) {
1184
+ throw new Error('Camera capture failed: output file was not created');
1185
+ }
1186
+ const buffer = fs.readFileSync(outputPath);
1187
+ try { fs.unlinkSync(outputPath); } catch { /* cleanup best effort */ }
1188
+
1189
+ return {
1190
+ type: 'camera',
1191
+ imageData: buffer.toString('base64'),
1192
+ format: 'jpeg',
1193
+ mimeType: 'image/jpeg',
1194
+ size: buffer.length,
1195
+ timestamp: new Date().toISOString(),
1196
+ };
1197
+ }
1198
+
1199
+ // --- MICROPHONE CAPTURE ---
1200
+ if (type === 'microphone') {
1201
+ const effectiveDuration = Math.min(duration || 5, 30);
1202
+ const outputPath = path.join(captureDir, `capture_${timestamp}.wav`);
1203
+
1204
+ let ffmpegArgs;
1205
+ if (platform === 'win32') {
1206
+ ffmpegArgs = ['-f', 'dshow', '-i', 'audio=' + (device || 'Microphone'), '-t', String(effectiveDuration), '-y', outputPath];
1207
+ } else if (platform === 'darwin') {
1208
+ ffmpegArgs = ['-f', 'avfoundation', '-i', ':' + (device || '0'), '-t', String(effectiveDuration), '-y', outputPath];
1209
+ } else {
1210
+ ffmpegArgs = ['-f', 'alsa', '-i', device || 'default', '-t', String(effectiveDuration), '-y', outputPath];
1211
+ }
1212
+
1213
+ // Timeout = duration + 10s buffer
1214
+ await runFfmpeg(ffmpegArgs, (effectiveDuration + 10) * 1000);
1215
+
1216
+ if (!fs.existsSync(outputPath)) {
1217
+ throw new Error('Microphone capture failed: output file was not created');
1218
+ }
1219
+ const buffer = fs.readFileSync(outputPath);
1220
+ try { fs.unlinkSync(outputPath); } catch { /* cleanup best effort */ }
1221
+
1222
+ return {
1223
+ type: 'microphone',
1224
+ audioData: buffer.toString('base64'),
1225
+ format: 'wav',
1226
+ mimeType: 'audio/wav',
1227
+ duration: effectiveDuration,
1228
+ size: buffer.length,
1229
+ timestamp: new Date().toISOString(),
1230
+ };
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * Helper: Run ffmpeg as a spawned process with timeout
1236
+ */
1237
+ function runFfmpeg(args, timeoutMs) {
1238
+ return new Promise((resolve, reject) => {
1239
+ let stderr = '';
1240
+ const child = spawn('ffmpeg', args, {
1241
+ stdio: ['pipe', 'pipe', 'pipe'],
1242
+ windowsHide: true,
1243
+ });
1244
+
1245
+ child.stderr.on('data', (data) => {
1246
+ stderr += data.toString();
1247
+ });
1248
+
1249
+ const timer = setTimeout(() => {
1250
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
1251
+ reject(new Error(`ffmpeg timed out after ${timeoutMs}ms`));
1252
+ }, timeoutMs);
1253
+
1254
+ child.on('close', (code) => {
1255
+ clearTimeout(timer);
1256
+ if (code === 0) {
1257
+ resolve();
1258
+ } else {
1259
+ reject(new Error(`ffmpeg exited with code ${code}: ${stderr.substring(0, 500)}`));
1260
+ }
1261
+ });
1262
+
1263
+ child.on('error', (err) => {
1264
+ clearTimeout(timer);
1265
+ reject(new Error(`Failed to run ffmpeg: ${err.message}`));
1266
+ });
1267
+ });
1268
+ }
1269
+
1270
+ // =====================================================
1271
+ // AI CHAT — Proxy AI requests to local Ollama / LM Studio
1272
+ // =====================================================
1273
+
1274
+ const AI_CHAT_TIMEOUT_MS = 120000; // 120s — local inference can be slow
1275
+
1276
+ // Track last successfully used model per provider for fallback
1277
+ const _lastWorkingModel = {}; // { ollama: 'modelName', lmstudio: 'modelName' }
1278
+
1279
+ /**
1280
+ * Auto-detect an available model from the local provider.
1281
+ * Ollama: GET /api/tags → { models: [{ name }] }
1282
+ * LM Studio: GET /v1/models → { data: [{ id }] }
1283
+ */
1284
+ function _fetchFirstAvailableModel(provider, baseUrl) {
1285
+ const endpoint = provider === 'ollama' ? `${baseUrl}/api/tags` : `${baseUrl}/v1/models`;
1286
+ return new Promise((resolve) => {
1287
+ const url = new URL(endpoint);
1288
+ const lib = url.protocol === 'https:' ? https : http;
1289
+ const req = lib.get(endpoint, { timeout: 5000 }, (res) => {
1290
+ let data = '';
1291
+ res.on('data', (chunk) => { data += chunk; });
1292
+ res.on('end', () => {
1293
+ try {
1294
+ const parsed = JSON.parse(data);
1295
+ if (provider === 'ollama' && parsed.models && parsed.models.length > 0) {
1296
+ resolve(parsed.models[0].name);
1297
+ } else if (provider === 'lmstudio' && parsed.data && parsed.data.length > 0) {
1298
+ resolve(parsed.data[0].id);
1299
+ } else {
1300
+ resolve(null);
1301
+ }
1302
+ } catch { resolve(null); }
1303
+ });
1304
+ });
1305
+ req.on('error', () => resolve(null));
1306
+ req.on('timeout', () => { req.destroy(); resolve(null); });
1307
+ });
1308
+ }
1309
+
1310
+ /**
1311
+ * Proxy an AI chat request to a local provider (Ollama or LM Studio).
1312
+ * Called by SwarmAI server via WebSocket when routing through this Local Agent.
1313
+ *
1314
+ * @param {object} params - { provider, baseUrl, model, messages, options }
1315
+ * @returns {{ content, model, usage, metadata }}
1316
+ */
1317
+ async function handleAiChat(params) {
1318
+ const { provider, baseUrl, messages, options = {} } = params || {};
1319
+ let { model } = params || {};
1320
+
1321
+ if (!provider) throw new Error('aiChat: provider is required (ollama or lmstudio)');
1322
+ if (!messages || !Array.isArray(messages)) throw new Error('aiChat: messages array is required');
1323
+
1324
+ const effectiveBaseUrl = baseUrl || (provider === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234');
1325
+
1326
+ // Fallback chain: explicit model → last working model → auto-detect from provider
1327
+ if (!model) {
1328
+ model = _lastWorkingModel[provider];
1329
+ if (model) {
1330
+ console.log(`[aiChat] No model specified, using last working model: ${model}`);
1331
+ }
1332
+ }
1333
+ if (!model) {
1334
+ console.log(`[aiChat] No model specified and no last working model — auto-detecting from ${provider}...`);
1335
+ model = await _fetchFirstAvailableModel(provider, effectiveBaseUrl);
1336
+ if (model) {
1337
+ console.log(`[aiChat] Auto-detected model: ${model}`);
1338
+ } else {
1339
+ throw new Error(`aiChat: no model specified and could not auto-detect any model from ${provider} at ${effectiveBaseUrl}`);
1340
+ }
1341
+ }
1342
+
1343
+ let result;
1344
+ if (provider === 'ollama') {
1345
+ result = await _chatOllama(effectiveBaseUrl, model, messages, options);
1346
+ } else if (provider === 'lmstudio') {
1347
+ result = await _chatLmStudio(effectiveBaseUrl, model, messages, options);
1348
+ } else {
1349
+ throw new Error(`aiChat: unsupported provider "${provider}". Use "ollama" or "lmstudio".`);
1350
+ }
1351
+
1352
+ // Remember this model as last working for this provider
1353
+ _lastWorkingModel[provider] = model;
1354
+ return result;
1355
+ }
1356
+
1357
+ /**
1358
+ * Call Ollama's /api/chat endpoint
1359
+ */
1360
+ async function _chatOllama(baseUrl, model, messages, options) {
1361
+ // Inject system prompt as first message if provided
1362
+ const fullMessages = [...messages];
1363
+ if (options.systemPrompt && !fullMessages.find(m => m.role === 'system')) {
1364
+ fullMessages.unshift({ role: 'system', content: options.systemPrompt });
1365
+ }
1366
+
1367
+ const payload = JSON.stringify({
1368
+ model,
1369
+ messages: fullMessages,
1370
+ stream: false,
1371
+ options: {
1372
+ temperature: options.temperature ?? 0.7,
1373
+ top_p: options.topP ?? 0.9,
1374
+ num_predict: options.maxTokens || 2048,
1375
+ },
1376
+ });
1377
+
1378
+ const data = await _httpPost(`${baseUrl}/api/chat`, payload, AI_CHAT_TIMEOUT_MS);
1379
+
1380
+ if (!data || !data.message) {
1381
+ throw new Error(`Ollama returned empty response for model "${model}"`);
1382
+ }
1383
+
1384
+ return {
1385
+ content: data.message.content,
1386
+ model: data.model || model,
1387
+ usage: {
1388
+ promptTokens: data.prompt_eval_count || 0,
1389
+ completionTokens: data.eval_count || 0,
1390
+ totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
1391
+ },
1392
+ metadata: {
1393
+ provider: 'ollama',
1394
+ totalDuration: data.total_duration,
1395
+ evalDuration: data.eval_duration,
1396
+ loadDuration: data.load_duration,
1397
+ },
1398
+ };
1399
+ }
1400
+
1401
+ /**
1402
+ * Call LM Studio's OpenAI-compatible /v1/chat/completions endpoint
1403
+ */
1404
+ async function _chatLmStudio(baseUrl, model, messages, options) {
1405
+ const fullMessages = [...messages];
1406
+ if (options.systemPrompt && !fullMessages.find(m => m.role === 'system')) {
1407
+ fullMessages.unshift({ role: 'system', content: options.systemPrompt });
1408
+ }
1409
+
1410
+ const payload = JSON.stringify({
1411
+ model,
1412
+ messages: fullMessages,
1413
+ temperature: options.temperature ?? 0.7,
1414
+ max_tokens: options.maxTokens || 2048,
1415
+ stream: false,
1416
+ });
1417
+
1418
+ const data = await _httpPost(`${baseUrl}/v1/chat/completions`, payload, AI_CHAT_TIMEOUT_MS);
1419
+
1420
+ if (!data || !data.choices || !data.choices[0]) {
1421
+ throw new Error(`LM Studio returned empty response for model "${model}"`);
1422
+ }
1423
+
1424
+ return {
1425
+ content: data.choices[0].message?.content || '',
1426
+ model: data.model || model,
1427
+ usage: {
1428
+ promptTokens: data.usage?.prompt_tokens || 0,
1429
+ completionTokens: data.usage?.completion_tokens || 0,
1430
+ totalTokens: data.usage?.total_tokens || 0,
1431
+ },
1432
+ metadata: {
1433
+ provider: 'lmstudio',
1434
+ finishReason: data.choices[0].finish_reason,
1435
+ },
1436
+ };
1437
+ }
1438
+
1439
+ /**
1440
+ * Simple HTTP POST helper (JSON body, JSON response)
1441
+ */
1442
+ function _httpPost(urlStr, body, timeoutMs) {
1443
+ return new Promise((resolve, reject) => {
1444
+ const url = new URL(urlStr);
1445
+ const req = http.request({
1446
+ hostname: url.hostname,
1447
+ port: url.port,
1448
+ path: url.pathname,
1449
+ method: 'POST',
1450
+ headers: {
1451
+ 'Content-Type': 'application/json',
1452
+ 'Content-Length': Buffer.byteLength(body),
1453
+ },
1454
+ timeout: timeoutMs,
1455
+ }, (res) => {
1456
+ let data = '';
1457
+ res.on('data', (chunk) => { data += chunk; });
1458
+ res.on('end', () => {
1459
+ if (res.statusCode >= 200 && res.statusCode < 300) {
1460
+ try { resolve(JSON.parse(data)); } catch { reject(new Error(`Invalid JSON from ${urlStr}`)); }
1461
+ } else {
1462
+ reject(new Error(`${urlStr} returned HTTP ${res.statusCode}: ${data.substring(0, 200)}`));
1463
+ }
1464
+ });
1465
+ });
1466
+
1467
+ req.on('error', (err) => reject(new Error(`Failed to connect to ${urlStr}: ${err.message}`)));
1468
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${urlStr} timed out after ${timeoutMs}ms`)); });
1469
+ req.write(body);
1470
+ req.end();
1471
+ });
1472
+ }
1473
+
1474
+ // =====================================================
1475
+ // DOCKER MANAGEMENT
1476
+ // =====================================================
1477
+
1478
+ const DOCKER_TIMEOUT_MS = 30000; // 30s default per docker command
1479
+ const DOCKER_TIMEOUTS = {
1480
+ ps: 15000, images: 15000, info: 10000, version: 10000,
1481
+ networks: 10000, volumes: 10000, top: 10000,
1482
+ logs: 60000, stats: 30000, inspect: 15000,
1483
+ start: 30000, stop: 30000, restart: 60000, rm: 15000,
1484
+ pull: 300000, prune: 120000, exec: 60000,
1485
+ 'compose-ps': 30000, 'compose-logs': 60000,
1486
+ 'compose-up': 120000, 'compose-down': 60000,
1487
+ 'compose-restart': 60000, 'compose-build': 600000, // 10 min for builds
1488
+ };
1489
+
1490
+ // Safe actions (read-only, no state change)
1491
+ const DOCKER_SAFE_ACTIONS = new Set(['ps', 'images', 'logs', 'stats', 'inspect', 'info', 'version', 'networks', 'volumes', 'compose-ps', 'compose-logs', 'top']);
1492
+ // Dangerous actions (modify state — local agent security gate may require approval)
1493
+ const DOCKER_DANGEROUS_ACTIONS = new Set(['start', 'stop', 'restart', 'rm', 'pull', 'prune', 'compose-up', 'compose-down', 'compose-restart', 'compose-build', 'exec']);
1494
+
1495
+ /**
1496
+ * Docker management command handler.
1497
+ * Provides structured Docker operations with safety guards.
1498
+ *
1499
+ * @param {object} params - { action, container, image, service, options }
1500
+ * @returns {object} Structured result
1501
+ */
1502
+ async function handleDocker(params) {
1503
+ const { action, container, image, service, options = {} } = params || {};
1504
+
1505
+ if (!action) throw new Error('docker: action is required');
1506
+
1507
+ const allActions = new Set([...DOCKER_SAFE_ACTIONS, ...DOCKER_DANGEROUS_ACTIONS]);
1508
+ if (!allActions.has(action)) {
1509
+ throw new Error(`docker: unknown action "${action}". Valid actions: ${[...allActions].join(', ')}`);
1510
+ }
1511
+
1512
+ // Check if docker is available
1513
+ try {
1514
+ execSync('docker --version', { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], shell: true });
1515
+ } catch {
1516
+ throw new Error('Docker is not installed or not in PATH on this machine');
1517
+ }
1518
+
1519
+ switch (action) {
1520
+ case 'ps': return _dockerPs(options);
1521
+ case 'images': return _dockerImages(options);
1522
+ case 'logs': return _dockerLogs(container, service, options);
1523
+ case 'stats': return _dockerStats(container, options);
1524
+ case 'inspect': return _dockerInspect(container, options);
1525
+ case 'info': return _dockerInfo();
1526
+ case 'version': return _dockerVersion();
1527
+ case 'networks': return _dockerNetworks(options);
1528
+ case 'volumes': return _dockerVolumes(options);
1529
+ case 'top': return _dockerTop(container);
1530
+ case 'start': return _dockerLifecycle('start', container);
1531
+ case 'stop': return _dockerLifecycle('stop', container);
1532
+ case 'restart': return _dockerLifecycle('restart', container);
1533
+ case 'rm': return _dockerRm(container, options);
1534
+ case 'pull': return _dockerPull(image);
1535
+ case 'prune': return _dockerPrune(options);
1536
+ case 'exec': return _dockerExec(container, options);
1537
+ case 'compose-ps': return _dockerCompose('ps', options);
1538
+ case 'compose-logs': return _dockerCompose('logs', options);
1539
+ case 'compose-up': return _dockerCompose('up -d', options);
1540
+ case 'compose-down': return _dockerCompose('down', options);
1541
+ case 'compose-restart': return _dockerCompose('restart', options);
1542
+ case 'compose-build': return _dockerCompose('build', options);
1543
+ default: throw new Error(`docker: unhandled action "${action}"`);
1544
+ }
1545
+ }
1546
+
1547
+ function _dockerExecCmd(cmd, timeoutMs = DOCKER_TIMEOUT_MS) {
1548
+ return execSync(cmd, {
1549
+ encoding: 'utf-8',
1550
+ timeout: timeoutMs,
1551
+ maxBuffer: MAX_SHELL_OUTPUT,
1552
+ stdio: ['pipe', 'pipe', 'pipe'],
1553
+ shell: true,
1554
+ env: { ...process.env, DOCKER_CLI_HINTS: 'false' },
1555
+ }).trim();
1556
+ }
1557
+
1558
+ /** Validate container/service name to prevent injection */
1559
+ function _validateDockerName(name, label) {
1560
+ if (!name) throw new Error(`docker: ${label} is required`);
1561
+ if (!/^[\w][\w./-]*$/.test(name)) {
1562
+ throw new Error(`docker: invalid ${label} "${name}" — only alphanumeric, dash, dot, slash allowed`);
1563
+ }
1564
+ return name;
1565
+ }
1566
+
1567
+ function _dockerPs(options) {
1568
+ const flags = options.all ? '-a' : '';
1569
+ const format = '--format "{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}\\t{{.State}}"';
1570
+ const raw = _dockerExecCmd(`docker ps ${flags} ${format}`);
1571
+
1572
+ const containers = raw.split('\n').filter(Boolean).map(line => {
1573
+ const [id, name, image, status, ports, state] = line.split('\t');
1574
+ return { id, name, image, status, ports, state };
1575
+ });
1576
+
1577
+ return { action: 'ps', count: containers.length, containers };
1578
+ }
1579
+
1580
+ function _dockerImages(options) {
1581
+ const flags = options.all ? '-a' : '';
1582
+ const format = '--format "{{.ID}}\\t{{.Repository}}\\t{{.Tag}}\\t{{.Size}}\\t{{.CreatedSince}}"';
1583
+ const raw = _dockerExecCmd(`docker images ${flags} ${format}`);
1584
+
1585
+ const images = raw.split('\n').filter(Boolean).map(line => {
1586
+ const [id, repository, tag, size, created] = line.split('\t');
1587
+ return { id, repository, tag, size, created };
1588
+ });
1589
+
1590
+ return { action: 'images', count: images.length, images };
1591
+ }
1592
+
1593
+ function _dockerLogs(container, service, options) {
1594
+ const target = container || service;
1595
+ if (!target) throw new Error('docker logs: container or service name is required');
1596
+ _validateDockerName(target, 'container/service');
1597
+
1598
+ const tail = parseInt(options.tail, 10) || 100;
1599
+ const since = options.since && /^[\w.:+-]+$/.test(options.since) ? `--since ${options.since}` : '';
1600
+ const isCompose = !container && service;
1601
+
1602
+ const cmd = isCompose
1603
+ ? `docker compose logs --tail ${tail} ${since} ${service}`
1604
+ : `docker logs --tail ${tail} ${since} ${target}`;
1605
+
1606
+ const output = _dockerExecCmd(cmd, DOCKER_TIMEOUTS.logs);
1607
+ return { action: 'logs', target, lines: output.split('\n').length, output: output.substring(0, MAX_SHELL_OUTPUT) };
1608
+ }
1609
+
1610
+ function _dockerStats(container, options) {
1611
+ const target = container || '';
1612
+ const raw = _dockerExecCmd(`docker stats --no-stream ${target} --format "{{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\\t{{.MemPerc}}\\t{{.NetIO}}\\t{{.BlockIO}}\\t{{.PIDs}}"`);
1613
+
1614
+ const stats = raw.split('\n').filter(Boolean).map(line => {
1615
+ const [name, cpu, memUsage, memPerc, netIO, blockIO, pids] = line.split('\t');
1616
+ return { name, cpu, memUsage, memPerc, netIO, blockIO, pids };
1617
+ });
1618
+
1619
+ return { action: 'stats', containers: stats };
1620
+ }
1621
+
1622
+ function _dockerInspect(container, options) {
1623
+ _validateDockerName(container, 'container');
1624
+ const format = options.format || '';
1625
+ const formatFlag = format && /^[{\w.,":\s[\]|]+$/.test(format) ? `--format '${format}'` : '';
1626
+ const raw = _dockerExecCmd(`docker inspect ${formatFlag} ${container}`, DOCKER_TIMEOUTS.inspect);
1627
+
1628
+ try {
1629
+ return { action: 'inspect', container, data: JSON.parse(raw) };
1630
+ } catch {
1631
+ return { action: 'inspect', container, data: raw };
1632
+ }
1633
+ }
1634
+
1635
+ function _dockerInfo() {
1636
+ const raw = _dockerExecCmd('docker info --format "{{json .}}"');
1637
+ try {
1638
+ const info = JSON.parse(raw);
1639
+ return {
1640
+ action: 'info',
1641
+ serverVersion: info.ServerVersion,
1642
+ os: info.OperatingSystem,
1643
+ arch: info.Architecture,
1644
+ cpus: info.NCPU,
1645
+ memory: `${Math.round((info.MemTotal || 0) / 1024 / 1024 / 1024)}GB`,
1646
+ containers: { total: info.Containers, running: info.ContainersRunning, paused: info.ContainersPaused, stopped: info.ContainersStopped },
1647
+ images: info.Images,
1648
+ storageDriver: info.Driver,
1649
+ };
1650
+ } catch {
1651
+ return { action: 'info', raw: _dockerExecCmd('docker info') };
1652
+ }
1653
+ }
1654
+
1655
+ function _dockerVersion() {
1656
+ const raw = _dockerExecCmd('docker version --format "{{json .}}"');
1657
+ try { return { action: 'version', data: JSON.parse(raw) }; }
1658
+ catch { return { action: 'version', raw: _dockerExecCmd('docker version') }; }
1659
+ }
1660
+
1661
+ function _dockerNetworks(options) {
1662
+ const format = '--format "{{.ID}}\\t{{.Name}}\\t{{.Driver}}\\t{{.Scope}}"';
1663
+ const raw = _dockerExecCmd(`docker network ls ${format}`);
1664
+
1665
+ const networks = raw.split('\n').filter(Boolean).map(line => {
1666
+ const [id, name, driver, scope] = line.split('\t');
1667
+ return { id, name, driver, scope };
1668
+ });
1669
+
1670
+ return { action: 'networks', count: networks.length, networks };
1671
+ }
1672
+
1673
+ function _dockerVolumes(options) {
1674
+ const format = '--format "{{.Name}}\\t{{.Driver}}\\t{{.Mountpoint}}"';
1675
+ const raw = _dockerExecCmd(`docker volume ls ${format}`);
1676
+
1677
+ const volumes = raw.split('\n').filter(Boolean).map(line => {
1678
+ const [name, driver, mountpoint] = line.split('\t');
1679
+ return { name, driver, mountpoint };
1680
+ });
1681
+
1682
+ return { action: 'volumes', count: volumes.length, volumes };
1683
+ }
1684
+
1685
+ function _dockerTop(container) {
1686
+ _validateDockerName(container, 'container');
1687
+ const raw = _dockerExecCmd(`docker top ${container}`, DOCKER_TIMEOUTS.top);
1688
+ return { action: 'top', container, output: raw };
1689
+ }
1690
+
1691
+ function _dockerLifecycle(verb, container) {
1692
+ _validateDockerName(container, 'container');
1693
+ const output = _dockerExecCmd(`docker ${verb} ${container}`, DOCKER_TIMEOUTS[verb] || DOCKER_TIMEOUT_MS);
1694
+ return { action: verb, container, success: true, output };
1695
+ }
1696
+
1697
+ function _dockerRm(container, options) {
1698
+ _validateDockerName(container, 'container');
1699
+ const force = options.force ? '-f' : '';
1700
+ const output = _dockerExecCmd(`docker rm ${force} ${container}`, DOCKER_TIMEOUTS.rm);
1701
+ return { action: 'rm', container, success: true, output };
1702
+ }
1703
+
1704
+ function _dockerPull(image) {
1705
+ if (!image) throw new Error('docker pull: image name is required');
1706
+ _validateDockerName(image, 'image');
1707
+ const output = _dockerExecCmd(`docker pull ${image}`, DOCKER_TIMEOUTS.pull);
1708
+ return { action: 'pull', image, success: true, output };
1709
+ }
1710
+
1711
+ function _dockerPrune(options) {
1712
+ const target = options.target || 'system'; // system, containers, images, volumes, networks
1713
+ const force = '-f'; // always force to avoid interactive prompt
1714
+ let cmd;
1715
+ switch (target) {
1716
+ case 'system': cmd = `docker system prune ${force}`; break;
1717
+ case 'containers': cmd = `docker container prune ${force}`; break;
1718
+ case 'images': cmd = `docker image prune ${force}`; break;
1719
+ case 'volumes': cmd = `docker volume prune ${force}`; break;
1720
+ case 'networks': cmd = `docker network prune ${force}`; break;
1721
+ default: throw new Error(`docker prune: unknown target "${target}". Use: system, containers, images, volumes, networks`);
1722
+ }
1723
+ const output = _dockerExecCmd(cmd, 60000);
1724
+ return { action: 'prune', target, success: true, output };
1725
+ }
1726
+
1727
+ function _dockerExec(container, options) {
1728
+ if (!container) throw new Error('docker exec: container name/ID is required');
1729
+ if (!options.command) throw new Error('docker exec: options.command is required');
1730
+
1731
+ // Validate container name (alphanumeric, dash, underscore, dot — no shell metacharacters)
1732
+ if (!/^[\w][\w.-]*$/.test(container)) {
1733
+ throw new Error(`docker exec: invalid container name "${container}"`);
1734
+ }
1735
+
1736
+ // Use spawn with explicit args to prevent shell injection
1737
+ const args = ['exec', container, ...options.command.split(/\s+/)];
1738
+ const result = require('child_process').spawnSync('docker', args, {
1739
+ encoding: 'utf-8',
1740
+ timeout: 60000,
1741
+ maxBuffer: MAX_SHELL_OUTPUT,
1742
+ stdio: ['pipe', 'pipe', 'pipe'],
1743
+ env: { ...process.env, DOCKER_CLI_HINTS: 'false' }, // suppress hints, don't leak agent env into container
1744
+ });
1745
+
1746
+ if (result.error) throw new Error(`docker exec failed: ${result.error.message}`);
1747
+ if (result.status !== 0 && result.stderr) {
1748
+ throw new Error(`docker exec failed (exit ${result.status}): ${result.stderr.substring(0, 500)}`);
1749
+ }
1750
+
1751
+ return { action: 'exec', container, command: options.command, output: (result.stdout || '').trim() };
1752
+ }
1753
+
1754
+ function _dockerCompose(subcommand, options) {
1755
+ const cwd = options.cwd || process.cwd();
1756
+ const service = options.service || '';
1757
+ if (service) _validateDockerName(service, 'service');
1758
+
1759
+ // Validate compose file path if provided
1760
+ const file = options.file && /^[\w./-]+$/.test(options.file) ? `-f ${options.file}` : '';
1761
+ const tail = options.tail ? `--tail ${parseInt(options.tail, 10) || 100}` : '';
1762
+
1763
+ let extraFlags = '';
1764
+ if (subcommand === 'logs') extraFlags = tail || '--tail 100';
1765
+ if (subcommand === 'build') extraFlags = options.noCache ? '--no-cache' : '';
1766
+
1767
+ const cmd = `cd "${cwd}" && docker compose ${file} ${subcommand} ${extraFlags} ${service}`.trim();
1768
+ const actionKey = `compose-${subcommand.split(' ')[0]}`;
1769
+ const timeout = DOCKER_TIMEOUTS[actionKey] || 60000;
1770
+ const output = _dockerExecCmd(cmd, timeout);
1771
+ return { action: actionKey, cwd, service: service || '(all)', output };
1772
+ }
1773
+
1774
+ // =====================================================
1775
+ // REGISTRY
1776
+ // =====================================================
1777
+
1778
+ // Commands that receive commandId for streaming/kill support
1779
+ const STREAMING_COMMANDS = new Set(['shell', 'cliSession', 'agenticChat']);
1780
+
1781
+ // Create agenticChat handler with injected dependencies
1782
+ const handleAgenticChat = createAgenticChatHandler({
1783
+ activeProcesses: _activeProcesses,
1784
+ emitChunk,
1785
+ uploadToServer,
1786
+ getSecurityConfig,
1787
+ validateFilePath,
1788
+ get _socket() { return _socket; },
1789
+ });
1790
+
1791
+ const commandHandlers = {
1792
+ systemInfo: handleSystemInfo,
1793
+ notification: handleNotification,
1794
+ screenshot: handleScreenshot,
1795
+ shell: handleShell,
1796
+ fileRead: handleFileRead,
1797
+ fileList: handleFileList,
1798
+ mcp: handleMcp,
1799
+ cliSession: handleCliSession, // Phase 5.4
1800
+ fileTransfer: handleFileTransfer, // Phase 5.4
1801
+ clipboard: handleClipboard, // Phase 5.4
1802
+ capture: handleCapture, // Phase 5.4
1803
+ aiChat: handleAiChat, // AI proxy to local Ollama/LM Studio
1804
+ agenticChat: handleAgenticChat, // Claude CLI + Ollama agentic execution
1805
+ docker: handleDocker, // Docker management
1806
+ kill: handleKill, // Phase 5.5 — kill running command
1807
+ };
1808
+
1809
+ /**
1810
+ * Execute a command by name
1811
+ * Pre-dispatch: checks requireApprovalFor list (Phase 5.4 security gate)
1812
+ * @param {string} command - Command name
1813
+ * @param {object} params - Command parameters
1814
+ * @param {string} [commandId] - For streaming commands, the unique command ID
1815
+ */
1816
+ function executeCommand(command, params, commandId) {
1817
+ const handler = commandHandlers[command];
1818
+ if (!handler) {
1819
+ throw new Error(`Unknown command: ${command}`);
1820
+ }
1821
+
1822
+ // Phase 5.4: Check if command is restricted by local agent security policy
1823
+ const security = getSecurityConfig();
1824
+ if (security.requireApprovalFor.includes(command)) {
1825
+ return {
1826
+ status: 'restricted',
1827
+ command,
1828
+ reason: `Command "${command}" is restricted by the local agent's security config. The user can enable it by removing "${command}" from security.requireApprovalFor in their local agent config file.`,
1829
+ };
1830
+ }
1831
+
1832
+ // Pass commandId to streaming-capable commands (shell, cliSession)
1833
+ if (STREAMING_COMMANDS.has(command) && commandId) {
1834
+ return handler(params, commandId);
1835
+ }
1836
+
1837
+ return handler(params);
1838
+ }
1839
+
1840
+ /**
1841
+ * Get list of supported commands
1842
+ */
1843
+ function getCapabilities() {
1844
+ return Object.keys(commandHandlers);
1845
+ }
1846
+
1847
+ module.exports = {
1848
+ executeCommand,
1849
+ getCapabilities,
1850
+ commandHandlers,
1851
+ setConnectionContext,
1852
+ setSocket,
1853
+ };