@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/commands.js
ADDED
|
@@ -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
|
+
};
|