agentgui 1.0.985 → 1.0.986

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 CHANGED
@@ -1,5 +1,9 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## GUI quality sweep (2026-06-19) — nineteenth run
4
+
5
+ 132-agent workflow wf_8183560b-e3d (12 lenses, 93 confirmed). Kit: thinking settled state, retry on non-last message, AT aria fixes (followup chips live guard, cwd focus restore, skip-link target, sessions toggle chevron, shell print styles, a11y-01 index.html), composer disabled visual state, files-modals focus trap re-query + stable aria-labelledby, sessions.js listbox+aria-selected. App: resume-transcript-load (loadResumeTranscript historical messages + spinner), ACP force-restart (unhealthy agent restart btn + WS handler), history tool_use rail=purple, shortcuts overlay role=dialog, streaming-active badge on chat rail tab, sortedAgents memoized, files filter 150ms debounce, _seen Set capped 5000, humanizeMs->fmtDuration, dead historySide() removed, pathBasename util, ARM_RESET_MS constant, refreshHistory guard, perf-002/006/007/008 memoization+cleanup, live-tokens accumulated, backend-change-mid-chat guard, transcript loading state, session-expiry onSessionExpired hook, server-500 stream error sanitized. Server: IPv4-mapped IPv6 normalization in rate-limit, image route streaming (no sync read), isWindows module-level, getAvailableAgents export removed, files-plugin+workflow-plugin confinement, acp-plugin shell injection fix, plugin-routes CSRF fix, asset-server JS injection fix. Full detail in rs-learn (recall "agentgui 19th run").
6
+
3
7
  ## GUI quality sweep (2026-06-19) — eighteenth run
4
8
 
5
9
  53-agent audit wf_7cd243d2-753 (29 confirmed). Kit: showWorkingTail status=running (was backwards), focus ring 2px+offset, aria-expanded dynamic on sessions toggle, DOMPurify version-pinned, FORCE_BODY, 360px button fill, cwd-btn 44px coarse. App: thinking branch in sendChat loop, saveBackend deep health probe, aria-live guard, O(1) SSE dedupe Set, sessionDuration reduce, tool_use rail flame (purple=subagent rule), ACP vocabulary connected/connecting/disconnected + unhealthy rail flame, skip-link. Server: CORS credentials guard, token masking in logs. Also: history (result)/(tool call) labels, multi-turn preamble for non-resume agents. Deferred: resume-transcript (#3), ACP force-restart (#4). Full detail in rs-learn (recall "agentgui 18th run").
@@ -95,7 +95,7 @@ export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding
95
95
  if (err2) { res.writeHead(500); res.end('Server error'); return; }
96
96
  let content = data.toString();
97
97
  const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
98
- const wsToken = process.env.PASSWORD ? `window.__WS_TOKEN='${process.env.PASSWORD.replace(/'/g, "\\'")}';` : '';
98
+ const wsToken = process.env.PASSWORD ? 'window.__WS_TOKEN=' + JSON.stringify(process.env.PASSWORD) + ';' : '';
99
99
  const baseTag = `<script${nonceAttr}>window.__BASE_URL='${BASE_URL}';window.__SERVER_VERSION='${PKG_VERSION}';${wsToken}</script>`;
100
100
  content = content.replace('<head>', `<head>\n <base href="${BASE_URL}/">\n ` + baseTag);
101
101
  content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
@@ -44,6 +44,5 @@ export async function runClaudeWithStreaming(prompt, cwd, agentId = 'claude-code
44
44
  }
45
45
 
46
46
  export function getRegisteredAgents() { return registry.list(); }
47
- export function getAvailableAgents() { return registry.listACPAvailable(); }
48
47
  export function isAgentRegistered(agentId) { return registry.has(agentId); }
49
48
  export default runClaudeWithStreaming;
@@ -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);
@@ -338,7 +383,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
338
383
  const st = fs.statSync(conf.realPath);
339
384
  sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
340
385
  } catch (err) {
341
- sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: err.message });
386
+ sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: safeErrMsg(err) });
342
387
  }
343
388
  return;
344
389
  }
@@ -353,7 +398,13 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
353
398
  let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
354
399
  // Never honour a client-supplied shell or env - that would be a direct
355
400
  // command/arg injection into the spawned process. Only geometry + cwd.
356
- const s = term.createSession({ cwd: p.cwd, cols: p.cols, rows: p.rows });
401
+ // Confine cwd to allowed roots; fall back to STARTUP_CWD on failure.
402
+ let termCwd = process.env.STARTUP_CWD || process.cwd();
403
+ if (p.cwd) {
404
+ const cwdConf = confineToRoots(p.cwd, fsAllowRoots());
405
+ if (cwdConf.ok) termCwd = cwdConf.realPath;
406
+ }
407
+ const s = term.createSession({ cwd: termCwd, cols: p.cols, rows: p.rows });
357
408
  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
409
  return;
359
410
  }
@@ -440,7 +491,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
440
491
  sendJSON(req, res, 200, { path: normalizedPath, segments, entries, roots: allowRoots });
441
492
  } catch (err) {
442
493
  const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
443
- sendJSON(req, res, code, { error: err.message });
494
+ sendJSON(req, res, code, { error: safeErrMsg(err) });
444
495
  }
445
496
  return;
446
497
  }
@@ -490,7 +541,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
490
541
  res.end(buf);
491
542
  } catch (err) {
492
543
  const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
493
- res.writeHead(code); res.end(err.message);
544
+ res.writeHead(code); res.end(safeErrMsg(err));
494
545
  }
495
546
  return;
496
547
  }
@@ -521,10 +572,12 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
521
572
  'Content-Length': String(st.size),
522
573
  'Cache-Control': 'no-cache',
523
574
  });
524
- fs.createReadStream(normalizedPath).pipe(res);
575
+ const rs = fs.createReadStream(normalizedPath);
576
+ rs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
577
+ rs.pipe(res);
525
578
  } catch (err) {
526
579
  const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
527
- res.writeHead(code); res.end(err.message);
580
+ res.writeHead(code); res.end(safeErrMsg(err));
528
581
  }
529
582
  return;
530
583
  }
@@ -557,7 +610,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
557
610
  if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
558
611
  if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
559
612
  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 }); }
613
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: safeErrMsg(err) }); }
561
614
  return;
562
615
  }
563
616
 
@@ -662,11 +715,37 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
662
715
  if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
663
716
  const target = path.join(conf.realPath, name);
664
717
  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 }); }
718
+ // Stream the upload body to a temp file to keep memory constant and
719
+ // avoid blocking the event loop with a large synchronous writeFileSync.
720
+ const tmpPath = target + '.tmp.' + crypto.randomBytes(6).toString('hex');
721
+ try {
722
+ await new Promise((resolve, reject) => {
723
+ const ws = fs.createWriteStream(tmpPath);
724
+ let total = 0;
725
+ const MAX_UPLOAD = 50 * 1024 * 1024;
726
+ ws.on('error', reject);
727
+ req.on('error', reject);
728
+ req.on('data', (chunk) => {
729
+ total += chunk.length;
730
+ if (total > MAX_UPLOAD) {
731
+ ws.destroy();
732
+ req.destroy();
733
+ const e = new Error('file too large (50MB cap)'); e.code = 'TOO_LARGE';
734
+ reject(e); return;
735
+ }
736
+ ws.write(chunk);
737
+ });
738
+ req.on('end', () => ws.end());
739
+ ws.on('finish', () => resolve(total));
740
+ });
741
+ fs.renameSync(tmpPath, target);
742
+ const uploadedSize = fs.statSync(target).size;
743
+ sendJSON(req, res, 200, { ok: true, path: target, size: uploadedSize });
744
+ } catch (err) {
745
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
746
+ if (err.code === 'TOO_LARGE') { sendJSON(req, res, 413, { error: 'file too large (50MB cap)' }); return; }
747
+ sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: 'upload failed' });
748
+ }
670
749
  return;
671
750
  }
672
751
 
@@ -698,9 +777,15 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
698
777
  // Files preview uses /api/file/download (attachment) for SVG.
699
778
  const contentType = mimeTypes[ext];
700
779
  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 }); }
780
+ const imgSt = fs.statSync(normalizedPath);
781
+ const IMG_MAX = 20 * 1024 * 1024; // 20MB hard cap
782
+ if (imgSt.size > IMG_MAX) { res.writeHead(413); res.end('Image too large'); return; }
783
+ // Always stream to avoid blocking the event loop on large synchronous reads.
784
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'Content-Length': String(imgSt.size) });
785
+ const imgRs = fs.createReadStream(normalizedPath);
786
+ imgRs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
787
+ imgRs.pipe(res);
788
+ } catch (err) { sendJSON(req, res, 400, { error: 'cannot read image' }); }
704
789
  return;
705
790
  }
706
791
 
@@ -720,8 +805,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
720
805
  } else { serveFile(filePath, res, req, cspNonce); }
721
806
  });
722
807
  } catch (e) {
723
- console.error('Server error:', maskToken(e.message), '| path:', maskToken(req.url.split('?')[0]));
724
- sendJSON(req, res, 500, { error: e.message });
808
+ console.error('Server error:', maskToken(e.message), e.stack, '| path:', maskToken(req.url.split('?')[0]));
809
+ sendJSON(req, res, 500, { error: 'internal server error' });
725
810
  }
726
811
  };
727
812
  }
@@ -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.986",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -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>