agentgui 1.0.985 → 1.0.987
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/AGENTS.md +4 -0
- package/TEST-COVERAGE.md +393 -0
- package/database-schema.js +0 -2
- package/lib/asset-server.js +7 -1
- package/lib/claude-runner-run.js +0 -1
- package/lib/http-handler.js +126 -28
- package/lib/plugins/acp-plugin.js +27 -6
- package/lib/plugins/files-plugin.js +43 -12
- package/lib/plugins/workflow-plugin.js +20 -2
- package/lib/ws-handlers-util.js +7 -0
- package/package.json +2 -1
- package/server.js +0 -2
- package/site/app/index.html +0 -1
- package/site/app/js/app.js +174 -147
- package/site/app/js/backend.js +52 -6
- package/site/app/vendor/anentrypoint-design/247420.css +19 -0
- package/site/app/vendor/anentrypoint-design/247420.js +14 -14
- package/test-integration.js +491 -0
- package/test.js +218 -0
- package/acp-queries.js +0 -182
- package/lib/routes-agents.js +0 -108
- package/lib/routes-registry.js +0 -6
package/lib/http-handler.js
CHANGED
|
@@ -21,16 +21,18 @@ export function maskToken(url) {
|
|
|
21
21
|
return url.replace(/([?&]token=)[^&]*/gi, '$1***');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Module-level platform constant — never changes at runtime.
|
|
25
|
+
const IS_WINDOWS = os.platform() === 'win32';
|
|
26
|
+
|
|
24
27
|
export function confineToRoots(inputPath, allowRoots) {
|
|
25
|
-
const isWindows = os.platform() === 'win32';
|
|
26
28
|
const norms = allowRoots.map(r => path.normalize(r));
|
|
27
29
|
const expanded = inputPath && inputPath.startsWith('~') ? inputPath.replace('~', os.homedir()) : inputPath;
|
|
28
30
|
const normalizedPath = path.normalize(expanded || '');
|
|
29
|
-
const isAbsolute =
|
|
31
|
+
const isAbsolute = IS_WINDOWS ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
|
|
30
32
|
const within = (p) => {
|
|
31
|
-
const np =
|
|
33
|
+
const np = IS_WINDOWS ? p.toLowerCase() : p;
|
|
32
34
|
return norms.some(root => {
|
|
33
|
-
const r =
|
|
35
|
+
const r = IS_WINDOWS ? root.toLowerCase() : root;
|
|
34
36
|
return np === r || np.startsWith(r + path.sep);
|
|
35
37
|
});
|
|
36
38
|
};
|
|
@@ -82,6 +84,26 @@ function sanitizeEntryName(name) {
|
|
|
82
84
|
return n;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Map a Node.js filesystem error code to a safe human-readable string that
|
|
88
|
+
// does not disclose host paths or internal stack context. Used everywhere an
|
|
89
|
+
// err.message would otherwise be returned to the client.
|
|
90
|
+
function safeErrMsg(err) {
|
|
91
|
+
if (!err) return 'unknown error';
|
|
92
|
+
switch (err.code) {
|
|
93
|
+
case 'ENOENT': return 'file not found';
|
|
94
|
+
case 'EACCES': return 'permission denied';
|
|
95
|
+
case 'EPERM': return 'operation not permitted';
|
|
96
|
+
case 'EISDIR': return 'path is a directory';
|
|
97
|
+
case 'ENOTDIR': return 'path is not a directory';
|
|
98
|
+
case 'ENOTEMPTY': return 'directory is not empty';
|
|
99
|
+
case 'EEXIST': return 'file already exists';
|
|
100
|
+
case 'EXDEV': return 'cannot move across drives';
|
|
101
|
+
case 'EMFILE': return 'too many open files';
|
|
102
|
+
case 'ENOSPC': return 'no space left on device';
|
|
103
|
+
default: return 'operation failed';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
// Read a request body with a hard size cap; resolves a Buffer or rejects with
|
|
86
108
|
// .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
|
|
87
109
|
function readBody(req, maxBytes) {
|
|
@@ -101,12 +123,20 @@ function readBody(req, maxBytes) {
|
|
|
101
123
|
// themselves are never mutation targets (rename/delete of a root would orphan
|
|
102
124
|
// the whole surface).
|
|
103
125
|
function isAllowRoot(realPath, allowRoots) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
return allowRoots.some(r => (isWindows ? r.toLowerCase() : r) === p);
|
|
126
|
+
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
127
|
+
return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
131
|
+
// Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
|
|
132
|
+
// cross-origin page can make credentialless fetch() calls to all /api/*
|
|
133
|
+
// endpoints (list, file, download, rename, delete, mkdir) and read or
|
|
134
|
+
// modify the filesystem. The existing code is structurally correct (no
|
|
135
|
+
// Access-Control-Allow-Credentials with wildcard), but the exposure is
|
|
136
|
+
// easy to miss.
|
|
137
|
+
if (process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD) {
|
|
138
|
+
console.warn('[agentgui] WARNING: CORS_ORIGIN=* is set without PASSWORD. Any cross-origin page can make credentialless requests to all /api/* endpoints and read/modify the filesystem. Set PASSWORD or restrict CORS_ORIGIN to a specific origin.');
|
|
139
|
+
}
|
|
110
140
|
return async function httpHandler(req, res) {
|
|
111
141
|
// CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
|
|
112
142
|
// let any webpage the user visits make credentialless fetches to /api/list,
|
|
@@ -117,17 +147,29 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
117
147
|
// Never set a wildcard when credentials (cookies) may be in play — a
|
|
118
148
|
// wildcard + credentials is rejected by browsers and leaks session tokens.
|
|
119
149
|
// A specific origin allows credentialed cross-origin requests safely.
|
|
120
|
-
res.setHeader('Access-Control-Allow-Origin', _corsOrigin
|
|
150
|
+
res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
|
|
121
151
|
if (_corsOrigin !== '*') res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
122
152
|
res.setHeader('Vary', 'Origin');
|
|
153
|
+
// Allow-Methods and Allow-Headers are only meaningful on CORS preflights
|
|
154
|
+
// or CORS requests; emitting them unconditionally leaks the method surface
|
|
155
|
+
// to same-origin (and non-CORS) responses unnecessarily.
|
|
156
|
+
if (req.method === 'OPTIONS' || req.headers['origin']) {
|
|
157
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
158
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
159
|
+
}
|
|
123
160
|
} // no ACAO header -> browsers enforce same-origin by default
|
|
124
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
125
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
126
161
|
// The password can ride in a ?token= query param (EventSource/deep-links
|
|
127
162
|
// can't set headers), so a navigation away from the app would leak it in
|
|
128
163
|
// the Referer header to the destination. Strip the referrer on every
|
|
129
164
|
// response so the credential never crosses an origin boundary that way.
|
|
130
165
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
166
|
+
// Prevent MIME-sniffing attacks on all responses including file downloads.
|
|
167
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
168
|
+
// Emit HSTS when the connection is known-TLS so clients pin HTTPS and
|
|
169
|
+
// refuse future downgrade attempts (protects cookie + ?token= in transit).
|
|
170
|
+
if (req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https') {
|
|
171
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
|
|
172
|
+
}
|
|
131
173
|
// CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
|
|
132
174
|
// from unpkg/jsdelivr; everything else is self. connect-src also allows
|
|
133
175
|
// self for the same-origin WS/SSE. script-src drops 'unsafe-inline' in
|
|
@@ -161,6 +203,9 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
161
203
|
} else {
|
|
162
204
|
clientIp = req.socket.remoteAddress;
|
|
163
205
|
}
|
|
206
|
+
// Normalize IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 -> 1.2.3.4) so the
|
|
207
|
+
// rate-limit bucket is the same regardless of how the OS presents the peer.
|
|
208
|
+
if (clientIp && clientIp.startsWith('::ffff:')) clientIp = clientIp.slice(7);
|
|
164
209
|
const hits = (rateLimitMap.get(clientIp) || 0) + 1;
|
|
165
210
|
rateLimitMap.set(clientIp, hits);
|
|
166
211
|
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
|
|
@@ -191,7 +236,10 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
191
236
|
if (_ci !== -1) _ok = _checkToken(_decoded.slice(_ci + 1));
|
|
192
237
|
} catch (_) {}
|
|
193
238
|
} else if (_auth.startsWith('Bearer ')) {
|
|
194
|
-
|
|
239
|
+
const bearerToken = _auth.slice(7);
|
|
240
|
+
// Validate Bearer token format: non-empty, no whitespace. Prevents
|
|
241
|
+
// timing-attack length inference if PASSWORD contains spaces.
|
|
242
|
+
if (/^[\S]+$/.test(bearerToken)) _ok = _checkToken(bearerToken);
|
|
195
243
|
}
|
|
196
244
|
// EventSource and same-origin links can't set headers - accept ?token= as fallback.
|
|
197
245
|
let _viaQuery = false;
|
|
@@ -338,7 +386,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
338
386
|
const st = fs.statSync(conf.realPath);
|
|
339
387
|
sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
|
|
340
388
|
} catch (err) {
|
|
341
|
-
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: err
|
|
389
|
+
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: safeErrMsg(err) });
|
|
342
390
|
}
|
|
343
391
|
return;
|
|
344
392
|
}
|
|
@@ -353,7 +401,13 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
353
401
|
let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
|
|
354
402
|
// Never honour a client-supplied shell or env - that would be a direct
|
|
355
403
|
// command/arg injection into the spawned process. Only geometry + cwd.
|
|
356
|
-
|
|
404
|
+
// Confine cwd to allowed roots; fall back to STARTUP_CWD on failure.
|
|
405
|
+
let termCwd = process.env.STARTUP_CWD || process.cwd();
|
|
406
|
+
if (p.cwd) {
|
|
407
|
+
const cwdConf = confineToRoots(p.cwd, fsAllowRoots());
|
|
408
|
+
if (cwdConf.ok) termCwd = cwdConf.realPath;
|
|
409
|
+
}
|
|
410
|
+
const s = term.createSession({ cwd: termCwd, cols: p.cols, rows: p.rows });
|
|
357
411
|
sendJSON(req, res, 200, { sid: s.sid, kind: s.kind, shell: s.shell, cwd: s.cwd, cols: s.cols, rows: s.rows, pid: s.proc.pid });
|
|
358
412
|
return;
|
|
359
413
|
}
|
|
@@ -440,7 +494,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
440
494
|
sendJSON(req, res, 200, { path: normalizedPath, segments, entries, roots: allowRoots });
|
|
441
495
|
} catch (err) {
|
|
442
496
|
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
443
|
-
sendJSON(req, res, code, { error: err
|
|
497
|
+
sendJSON(req, res, code, { error: safeErrMsg(err) });
|
|
444
498
|
}
|
|
445
499
|
return;
|
|
446
500
|
}
|
|
@@ -490,7 +544,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
490
544
|
res.end(buf);
|
|
491
545
|
} catch (err) {
|
|
492
546
|
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
493
|
-
res.writeHead(code); res.end(err
|
|
547
|
+
res.writeHead(code); res.end(safeErrMsg(err));
|
|
494
548
|
}
|
|
495
549
|
return;
|
|
496
550
|
}
|
|
@@ -521,10 +575,12 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
521
575
|
'Content-Length': String(st.size),
|
|
522
576
|
'Cache-Control': 'no-cache',
|
|
523
577
|
});
|
|
524
|
-
fs.createReadStream(normalizedPath)
|
|
578
|
+
const rs = fs.createReadStream(normalizedPath);
|
|
579
|
+
rs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
580
|
+
rs.pipe(res);
|
|
525
581
|
} catch (err) {
|
|
526
582
|
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
527
|
-
res.writeHead(code); res.end(err
|
|
583
|
+
res.writeHead(code); res.end(safeErrMsg(err));
|
|
528
584
|
}
|
|
529
585
|
return;
|
|
530
586
|
}
|
|
@@ -557,7 +613,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
557
613
|
if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
|
|
558
614
|
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
559
615
|
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
560
|
-
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err
|
|
616
|
+
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: safeErrMsg(err) }); }
|
|
561
617
|
return;
|
|
562
618
|
}
|
|
563
619
|
|
|
@@ -654,6 +710,17 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
654
710
|
if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
|
|
655
711
|
let qs;
|
|
656
712
|
try { qs = new URL(req.url, 'http://localhost').searchParams; } catch { qs = new URLSearchParams(); }
|
|
713
|
+
// Require Content-Length header: rejects chunked or missing-length requests
|
|
714
|
+
// that could claim any size. Pre-validates the announced size before streaming.
|
|
715
|
+
const contentLength = req.headers['content-length'];
|
|
716
|
+
if (!contentLength) {
|
|
717
|
+
sendJSON(req, res, 411, { error: 'length required' }); return;
|
|
718
|
+
}
|
|
719
|
+
const MAX_UPLOAD = 50 * 1024 * 1024;
|
|
720
|
+
const len = parseInt(contentLength, 10);
|
|
721
|
+
if (isNaN(len) || len < 0 || len > MAX_UPLOAD) {
|
|
722
|
+
sendJSON(req, res, 413, { error: `file too large (max ${MAX_UPLOAD} bytes)` }); return;
|
|
723
|
+
}
|
|
657
724
|
const allowRoots = fsAllowRoots();
|
|
658
725
|
const conf = confineToRoots(qs.get('dir') || '', allowRoots);
|
|
659
726
|
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
@@ -662,11 +729,36 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
662
729
|
if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
663
730
|
const target = path.join(conf.realPath, name);
|
|
664
731
|
if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
try {
|
|
669
|
-
|
|
732
|
+
// Stream the upload body to a temp file to keep memory constant and
|
|
733
|
+
// avoid blocking the event loop with a large synchronous writeFileSync.
|
|
734
|
+
const tmpPath = target + '.tmp.' + crypto.randomBytes(6).toString('hex');
|
|
735
|
+
try {
|
|
736
|
+
await new Promise((resolve, reject) => {
|
|
737
|
+
const ws = fs.createWriteStream(tmpPath);
|
|
738
|
+
let total = 0;
|
|
739
|
+
ws.on('error', reject);
|
|
740
|
+
req.on('error', reject);
|
|
741
|
+
req.on('data', (chunk) => {
|
|
742
|
+
total += chunk.length;
|
|
743
|
+
if (total > MAX_UPLOAD) {
|
|
744
|
+
ws.destroy();
|
|
745
|
+
req.destroy();
|
|
746
|
+
const e = new Error('file too large (50MB cap)'); e.code = 'TOO_LARGE';
|
|
747
|
+
reject(e); return;
|
|
748
|
+
}
|
|
749
|
+
ws.write(chunk);
|
|
750
|
+
});
|
|
751
|
+
req.on('end', () => ws.end());
|
|
752
|
+
ws.on('finish', () => resolve(total));
|
|
753
|
+
});
|
|
754
|
+
fs.renameSync(tmpPath, target);
|
|
755
|
+
const uploadedSize = fs.statSync(target).size;
|
|
756
|
+
sendJSON(req, res, 200, { ok: true, path: target, size: uploadedSize });
|
|
757
|
+
} catch (err) {
|
|
758
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
759
|
+
if (err.code === 'TOO_LARGE') { sendJSON(req, res, 413, { error: 'file too large (50MB cap)' }); return; }
|
|
760
|
+
sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: 'upload failed' });
|
|
761
|
+
}
|
|
670
762
|
return;
|
|
671
763
|
}
|
|
672
764
|
|
|
@@ -698,9 +790,15 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
698
790
|
// Files preview uses /api/file/download (attachment) for SVG.
|
|
699
791
|
const contentType = mimeTypes[ext];
|
|
700
792
|
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
793
|
+
const imgSt = fs.statSync(normalizedPath);
|
|
794
|
+
const IMG_MAX = 20 * 1024 * 1024; // 20MB hard cap
|
|
795
|
+
if (imgSt.size > IMG_MAX) { res.writeHead(413); res.end('Image too large'); return; }
|
|
796
|
+
// Always stream to avoid blocking the event loop on large synchronous reads.
|
|
797
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'Content-Length': String(imgSt.size) });
|
|
798
|
+
const imgRs = fs.createReadStream(normalizedPath);
|
|
799
|
+
imgRs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
800
|
+
imgRs.pipe(res);
|
|
801
|
+
} catch (err) { sendJSON(req, res, 400, { error: 'cannot read image' }); }
|
|
704
802
|
return;
|
|
705
803
|
}
|
|
706
804
|
|
|
@@ -720,8 +818,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
720
818
|
} else { serveFile(filePath, res, req, cspNonce); }
|
|
721
819
|
});
|
|
722
820
|
} catch (e) {
|
|
723
|
-
console.error('Server error:', maskToken(e.message), '| path:', maskToken(req.url.split('?')[0]));
|
|
724
|
-
sendJSON(req, res, 500, { error:
|
|
821
|
+
console.error('Server error:', maskToken(e.message), e.stack, '| path:', maskToken(req.url.split('?')[0]));
|
|
822
|
+
sendJSON(req, res, 500, { error: 'internal server error' });
|
|
725
823
|
}
|
|
726
824
|
};
|
|
727
825
|
}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
// ACP plugin - OpenCode, Gemini, Kilo, Codex startup and health checks
|
|
2
2
|
|
|
3
|
-
import { spawn } from 'child_process';
|
|
3
|
+
import { spawn, execFileSync } from 'child_process';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
|
|
7
|
+
// Resolve a binary name to an absolute path (mirrors lib/claude-runner-direct.js pattern).
|
|
8
|
+
// Returns the resolved path, or null if not found.
|
|
9
|
+
function resolveBinaryPath(name) {
|
|
10
|
+
try {
|
|
11
|
+
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
12
|
+
const result = execFileSync(which, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
13
|
+
const first = result.trim().split(/\r?\n/)[0];
|
|
14
|
+
return first || null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export default {
|
|
8
21
|
name: 'acp',
|
|
9
22
|
version: '1.0.0',
|
|
@@ -15,16 +28,24 @@ export default {
|
|
|
15
28
|
const restartCounts = new Map();
|
|
16
29
|
const acpPorts = new Map();
|
|
17
30
|
|
|
31
|
+
// Each spec uses argv (binary + args) rather than a shell command string so
|
|
32
|
+
// spawn runs with shell:false — no shell injection risk even if port/name
|
|
33
|
+
// values are later sourced from config. Mirrors lib/claude-runner-direct.js.
|
|
18
34
|
const toolSpecs = [
|
|
19
|
-
{ name: 'opencode', port: 18100,
|
|
20
|
-
{ name: 'gemini',
|
|
21
|
-
{ name: 'kilo',
|
|
22
|
-
{ name: 'codex',
|
|
35
|
+
{ name: 'opencode', port: 18100, argv: ['opencode', 'acp', '--port', '18100'] },
|
|
36
|
+
{ name: 'gemini', port: 18101, argv: ['gemini', 'acp', '--port', '18101'] },
|
|
37
|
+
{ name: 'kilo', port: 18102, argv: ['kilo', 'acp', '--port', '18102'] },
|
|
38
|
+
{ name: 'codex', port: 18103, argv: ['codex', 'acp', '--port', '18103'] },
|
|
23
39
|
];
|
|
24
40
|
|
|
25
41
|
const startTool = async (spec) => {
|
|
26
42
|
try {
|
|
27
|
-
const
|
|
43
|
+
const [bin, ...args] = spec.argv;
|
|
44
|
+
// Resolve to an absolute path so spawn never relies on PATH expansion
|
|
45
|
+
// inside a shell; shell:false is the default for spawn() when called
|
|
46
|
+
// this way, matching the project-wide rule in AGENTS.md.
|
|
47
|
+
const resolvedBin = resolveBinaryPath(bin) || bin;
|
|
48
|
+
const proc = spawn(resolvedBin, args, { shell: false });
|
|
28
49
|
toolProcesses.set(spec.name, proc);
|
|
29
50
|
acpPorts.set(spec.name, spec.port);
|
|
30
51
|
restartCounts.set(spec.name, 0);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
+
import { confineToRoots, fsAllowRoots } from '../http-handler.js';
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
name: 'files',
|
|
@@ -13,18 +14,24 @@ export default {
|
|
|
13
14
|
const uploadedFiles = new Map();
|
|
14
15
|
|
|
15
16
|
const browseDirectory = (dir) => {
|
|
17
|
+
const conf = confineToRoots(dir, fsAllowRoots());
|
|
18
|
+
if (!conf.ok) return [];
|
|
16
19
|
try {
|
|
17
|
-
const entries = fs.readdirSync(
|
|
20
|
+
const entries = fs.readdirSync(conf.realPath);
|
|
18
21
|
return entries.map(entry => {
|
|
19
|
-
const fullPath = path.join(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const fullPath = path.join(conf.realPath, entry);
|
|
23
|
+
try {
|
|
24
|
+
const stat = fs.statSync(fullPath);
|
|
25
|
+
return {
|
|
26
|
+
name: entry,
|
|
27
|
+
path: fullPath,
|
|
28
|
+
isDirectory: stat.isDirectory(),
|
|
29
|
+
size: stat.size,
|
|
30
|
+
};
|
|
31
|
+
} catch (_) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}).filter(Boolean);
|
|
28
35
|
} catch (e) {
|
|
29
36
|
return [];
|
|
30
37
|
}
|
|
@@ -38,8 +45,13 @@ export default {
|
|
|
38
45
|
handler: (req, res) => {
|
|
39
46
|
const { conversationId } = req.params;
|
|
40
47
|
const { dir } = req.query;
|
|
41
|
-
const
|
|
42
|
-
|
|
48
|
+
const requestedDir = dir || process.cwd();
|
|
49
|
+
const conf = confineToRoots(requestedDir, fsAllowRoots());
|
|
50
|
+
if (!conf.ok) {
|
|
51
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
52
|
+
}
|
|
53
|
+
const entries = browseDirectory(conf.realPath);
|
|
54
|
+
res.json({ entries, currentDir: conf.realPath });
|
|
43
55
|
},
|
|
44
56
|
},
|
|
45
57
|
{
|
|
@@ -56,6 +68,25 @@ export default {
|
|
|
56
68
|
path: '/api/folders',
|
|
57
69
|
handler: async (req, res) => {
|
|
58
70
|
const { path: folderPath } = req.body;
|
|
71
|
+
if (!folderPath || typeof folderPath !== 'string') {
|
|
72
|
+
return res.status(400).json({ error: 'path is required' });
|
|
73
|
+
}
|
|
74
|
+
// Sanitize each path component - reject traversal attempts
|
|
75
|
+
const parts = folderPath.split(/[/\\]/);
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (part === '..' || part === '.' || /[<>:"|?*\x00-\x1f]/.test(part)) {
|
|
78
|
+
return res.status(400).json({ error: 'Invalid path component' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const conf = confineToRoots(folderPath, fsAllowRoots());
|
|
82
|
+
if (!conf.ok && conf.reason !== 'not found') {
|
|
83
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
84
|
+
}
|
|
85
|
+
// For mkdir, path may not exist yet; re-check parent is confined
|
|
86
|
+
const parentConf = confineToRoots(path.dirname(folderPath), fsAllowRoots());
|
|
87
|
+
if (!parentConf.ok) {
|
|
88
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
89
|
+
}
|
|
59
90
|
try {
|
|
60
91
|
fs.mkdirSync(folderPath, { recursive: true });
|
|
61
92
|
res.json({ success: true, path: folderPath });
|
|
@@ -19,11 +19,20 @@ export default {
|
|
|
19
19
|
.map(name => ({ name, path: path.join(workflowDir, name) }));
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
const resolvedWorkflowDir = path.resolve(process.cwd(), '.github', 'workflows');
|
|
23
|
+
|
|
24
|
+
const isNameSafe = (name) => !/[/\\]/.test(name);
|
|
25
|
+
|
|
22
26
|
const parseWorkflow = (filePath) => {
|
|
23
27
|
try {
|
|
24
|
-
|
|
28
|
+
// Confine to workflowDir: resolve and verify the path stays within the allowed directory
|
|
29
|
+
const resolved = path.resolve(filePath);
|
|
30
|
+
if (!resolved.startsWith(resolvedWorkflowDir + path.sep) && resolved !== resolvedWorkflowDir) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
25
34
|
// Parse YAML manually or return raw content
|
|
26
|
-
return { name: path.basename(
|
|
35
|
+
return { name: path.basename(resolved), content };
|
|
27
36
|
} catch {
|
|
28
37
|
return null;
|
|
29
38
|
}
|
|
@@ -43,6 +52,9 @@ export default {
|
|
|
43
52
|
method: 'GET',
|
|
44
53
|
path: '/api/workflows/:name/history',
|
|
45
54
|
handler: (req, res) => {
|
|
55
|
+
if (!isNameSafe(req.params.name)) {
|
|
56
|
+
return res.status(400).json({ error: 'Invalid workflow name' });
|
|
57
|
+
}
|
|
46
58
|
res.json({ history: [] });
|
|
47
59
|
},
|
|
48
60
|
},
|
|
@@ -50,6 +62,9 @@ export default {
|
|
|
50
62
|
method: 'POST',
|
|
51
63
|
path: '/api/workflows/:name/trigger',
|
|
52
64
|
handler: async (req, res) => {
|
|
65
|
+
if (!isNameSafe(req.params.name)) {
|
|
66
|
+
return res.status(400).json({ error: 'Invalid workflow name' });
|
|
67
|
+
}
|
|
53
68
|
res.json({ success: true, message: 'Workflow trigger requires GitHub API' });
|
|
54
69
|
},
|
|
55
70
|
},
|
|
@@ -57,6 +72,9 @@ export default {
|
|
|
57
72
|
method: 'GET',
|
|
58
73
|
path: '/api/workflows/:name/status',
|
|
59
74
|
handler: (req, res) => {
|
|
75
|
+
if (!isNameSafe(req.params.name)) {
|
|
76
|
+
return res.status(400).json({ error: 'Invalid workflow name' });
|
|
77
|
+
}
|
|
60
78
|
res.json({ status: 'unknown' });
|
|
61
79
|
},
|
|
62
80
|
},
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -6,6 +6,7 @@ import { execSync, spawnSync } from 'child_process';
|
|
|
6
6
|
import { runClaudeWithStreaming } from './claude-runner-run.js';
|
|
7
7
|
import { registry } from './claude-runner-agents.js';
|
|
8
8
|
import { confineToRoots, fsAllowRoots } from './http-handler.js';
|
|
9
|
+
import { restart as restartAcpAgent } from './acp-sdk-manager.js';
|
|
9
10
|
|
|
10
11
|
function err(code, message) { const e = new Error(message); e.code = code; throw e; }
|
|
11
12
|
|
|
@@ -297,6 +298,12 @@ export function register(router, deps) {
|
|
|
297
298
|
|
|
298
299
|
router.handle('ws.stats', () => wsOptimizer.getStats());
|
|
299
300
|
|
|
301
|
+
router.handle('acp.restart', async (p) => {
|
|
302
|
+
if (!p.id) err(400, 'Missing agent id');
|
|
303
|
+
const ok = await restartAcpAgent(p.id);
|
|
304
|
+
return { ok: !!ok };
|
|
305
|
+
});
|
|
306
|
+
|
|
300
307
|
router.handle('agent.subagents', async (p) => {
|
|
301
308
|
if (!p.id) err(400, 'Missing agent id');
|
|
302
309
|
if (p.id === 'claude-code' || p.id === 'cli-claude') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.987",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "electron/main.js",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"start": "bun server.js || node server.js",
|
|
20
20
|
"dev": "node server.js --watch",
|
|
21
|
+
"test": "bun test.js && bun test-integration.js",
|
|
21
22
|
"postinstall": "node scripts/patch-fsbrowse.js && (cd node_modules/better-sqlite3 && node-gyp rebuild 2>/dev/null) || true",
|
|
22
23
|
"electron": "electron electron/main.js",
|
|
23
24
|
"electron:dev": "PORT=3000 electron electron/main.js"
|
package/server.js
CHANGED
|
@@ -10,7 +10,6 @@ import { queries } from './database.js';
|
|
|
10
10
|
import { runClaudeWithStreaming } from './lib/claude-runner-run.js';
|
|
11
11
|
import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descriptors.js';
|
|
12
12
|
import { discoverExternalACPServers, initializeAgentDiscovery } from './lib/agent-discovery.js';
|
|
13
|
-
import { createRegistry } from './lib/routes-registry.js';
|
|
14
13
|
import { register as registerWsHandlers } from './lib/ws-handlers-util.js';
|
|
15
14
|
import { BROADCAST_TYPES } from './lib/broadcast.js';
|
|
16
15
|
import { WSOptimizer } from './lib/ws-optimizer.js';
|
|
@@ -154,7 +153,6 @@ const { processMessageWithStreaming } = createProcessMessage({
|
|
|
154
153
|
|
|
155
154
|
const activeChats = new Map();
|
|
156
155
|
const wsRouter = new WsRouter();
|
|
157
|
-
createRegistry(wsRouter, { queries, sendJSON, parseBody, broadcastSync, debugLog, PORT, BASE_URL, rootDir, STARTUP_CWD, PKG_VERSION, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, activeScripts, messageQueues, rateLimitState, cleanupExecution, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, logError, syncClients, wsOptimizer, errLogPath, getJsonlWatcher: () => getJsonlWatcher(), routes: _routes });
|
|
158
156
|
registerWsHandlers(wsRouter, { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats, getModelsForAgent });
|
|
159
157
|
|
|
160
158
|
|
package/site/app/index.html
CHANGED
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
247420.css bundle, scoped under .ds-247420. The kit owns all design. -->
|
|
31
31
|
</head>
|
|
32
32
|
<body>
|
|
33
|
-
<a href="#app" class="skip-link">Skip to main content</a>
|
|
34
33
|
<div id="app"><div class="boot-splash" role="status">loading agentgui…</div></div>
|
|
35
34
|
<script type="module" src="./js/app.js"></script>
|
|
36
35
|
</body>
|