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.
@@ -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 = isWindows ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
31
+ const isAbsolute = IS_WINDOWS ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
30
32
  const within = (p) => {
31
- const np = isWindows ? p.toLowerCase() : p;
33
+ const np = IS_WINDOWS ? p.toLowerCase() : p;
32
34
  return norms.some(root => {
33
- const r = isWindows ? root.toLowerCase() : root;
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 isWindows = os.platform() === 'win32';
105
- const p = isWindows ? realPath.toLowerCase() : realPath;
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 === '*' ? _corsOrigin : _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
- _ok = _checkToken(_auth.slice(7));
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.message });
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
- const s = term.createSession({ cwd: p.cwd, cols: p.cols, rows: p.rows });
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.message });
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.message);
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).pipe(res);
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.message);
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.message }); }
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
- let buf;
666
- try { buf = await readBody(req, 50 * 1024 * 1024); }
667
- catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: e.code === 'TOO_LARGE' ? 'file too large (50MB cap)' : 'upload failed' }); return; }
668
- try { fs.writeFileSync(target, buf); sendJSON(req, res, 200, { ok: true, path: target, size: buf.length }); }
669
- catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
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
- res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
702
- res.end(fs.readFileSync(normalizedPath));
703
- } catch (err) { sendJSON(req, res, 400, { error: err.message }); }
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: e.message });
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, cmd: 'opencode acp --port 18100' },
20
- { name: 'gemini', port: 18101, cmd: 'gemini acp --port 18101' },
21
- { name: 'kilo', port: 18102, cmd: 'kilo acp --port 18102' },
22
- { name: 'codex', port: 18103, cmd: 'codex acp --port 18103' },
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 proc = spawn('bash', ['-c', spec.cmd]);
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(dir);
20
+ const entries = fs.readdirSync(conf.realPath);
18
21
  return entries.map(entry => {
19
- const fullPath = path.join(dir, entry);
20
- const stat = fs.statSync(fullPath);
21
- return {
22
- name: entry,
23
- path: fullPath,
24
- isDirectory: stat.isDirectory(),
25
- size: stat.size,
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 entries = browseDirectory(dir || process.cwd());
42
- res.json({ entries, currentDir: dir || process.cwd() });
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
- const content = fs.readFileSync(filePath, 'utf8');
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(filePath), content };
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
  },
@@ -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.985",
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
 
@@ -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>