agentgui 1.0.958 → 1.0.960

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.
@@ -37,6 +37,56 @@ function confineToRoots(inputPath, allowRoots) {
37
37
  return { ok: true, realPath };
38
38
  }
39
39
 
40
+ // The allowlist the Files surface operates within: server cwd + Claude
41
+ // projects dir, widened via FS_ROOTS (path-separated). One construction so
42
+ // /api/list,file,download and the mutation routes can never drift apart.
43
+ function fsAllowRoots() {
44
+ return [
45
+ process.env.STARTUP_CWD || process.cwd(),
46
+ process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
47
+ ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
48
+ ].map(r => path.normalize(r));
49
+ }
50
+
51
+ // A new file/dir name must be a single path component: no separators, no
52
+ // traversal, no NTFS alternate-data-stream colon, no reserved Windows device
53
+ // names, no trailing dot/space (Windows silently strips them, aliasing two
54
+ // names onto one entry). Returns the trimmed name or null when inadmissible.
55
+ function sanitizeEntryName(name) {
56
+ if (typeof name !== 'string') return null;
57
+ const n = name.trim();
58
+ if (!n || n.length > 255) return null;
59
+ if (/[\\/:*?"<>|\x00-\x1f]/.test(n)) return null;
60
+ if (n === '.' || n === '..') return null;
61
+ if (/[. ]$/.test(n)) return null;
62
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(n)) return null;
63
+ return n;
64
+ }
65
+
66
+ // Read a request body with a hard size cap; resolves a Buffer or rejects with
67
+ // .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
68
+ function readBody(req, maxBytes) {
69
+ return new Promise((resolve, reject) => {
70
+ const chunks = []; let total = 0;
71
+ req.on('data', (c) => {
72
+ total += c.length;
73
+ if (total > maxBytes) { const e = new Error('body too large'); e.code = 'TOO_LARGE'; req.destroy(); reject(e); return; }
74
+ chunks.push(c);
75
+ });
76
+ req.on('end', () => resolve(Buffer.concat(chunks)));
77
+ req.on('error', reject);
78
+ });
79
+ }
80
+
81
+ // True when the resolved path IS one of the allowlist roots - the roots
82
+ // themselves are never mutation targets (rename/delete of a root would orphan
83
+ // the whole surface).
84
+ function isAllowRoot(realPath, allowRoots) {
85
+ const isWindows = os.platform() === 'win32';
86
+ const p = isWindows ? realPath.toLowerCase() : realPath;
87
+ return allowRoots.some(r => (isWindows ? r.toLowerCase() : r) === p);
88
+ }
89
+
40
90
  export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
41
91
  return async function httpHandler(req, res) {
42
92
  // CORS: when PASSWORD is set the server holds credentials a cross-origin
@@ -116,6 +166,28 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
116
166
  }
117
167
 
118
168
  const pathOnly = req.url.split('?')[0];
169
+
170
+ // CSRF guard on every state-changing method. Without PASSWORD the server
171
+ // is open and advertises a wildcard ACAO, so a cross-site page could POST
172
+ // a form at the mutation routes (rename/delete/mkdir/upload) on localhost.
173
+ // A simple cross-site form cannot set Sec-Fetch-Site:same-origin, send
174
+ // application/json, or attach custom headers - so: accept when the
175
+ // browser says same-origin/none (direct nav, same-origin fetch), when the
176
+ // body is declared JSON, or when an Authorization header rode along (a
177
+ // credentialed programmatic client). Reject the rest with 403.
178
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
179
+ const sfs = req.headers['sec-fetch-site'];
180
+ const ct = (req.headers['content-type'] || '').toLowerCase();
181
+ const sameSite = !sfs || sfs === 'same-origin' || sfs === 'none';
182
+ const jsonBody = ct.startsWith('application/json') || ct.startsWith('application/octet-stream') || ct === '';
183
+ const authed = !!req.headers['authorization'];
184
+ if (!sameSite && !jsonBody && !authed) {
185
+ res.writeHead(403, { 'Content-Type': 'application/json' });
186
+ res.end(JSON.stringify({ error: 'cross-site request rejected' }));
187
+ return;
188
+ }
189
+ }
190
+
119
191
  if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
120
192
 
121
193
  if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
@@ -147,7 +219,31 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
147
219
  try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
148
220
  const queueSizes = {};
149
221
  for (const [k, v] of messageQueues) queueSizes[k] = v.length;
150
- sendJSON(req, res, 200, { status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length, activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0, memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes });
222
+ sendJSON(req, res, 200, {
223
+ status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
224
+ activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
225
+ memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes,
226
+ // Read-only server facts for the settings server-info panel.
227
+ projectsDir: process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
228
+ allowRoots: fsAllowRoots(),
229
+ });
230
+ return;
231
+ }
232
+
233
+ // Existence/shape probe for a proposed chat working directory, confined
234
+ // to the same allowlist as the Files surface (an unconfined stat would be
235
+ // a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
236
+ if (routePath.startsWith('/api/stat') && req.method === 'GET') {
237
+ const rawS = routePath.split('?')[0].slice('/api/stat'.length).replace(/^\//, '');
238
+ const decodedPath = rawS ? decodeURIComponent(rawS) : '';
239
+ const conf = confineToRoots(decodedPath, fsAllowRoots());
240
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: conf.reason }); return; }
241
+ try {
242
+ const st = fs.statSync(conf.realPath);
243
+ sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
244
+ } catch (err) {
245
+ sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: err.message });
246
+ }
151
247
  return;
152
248
  }
153
249
 
@@ -191,11 +287,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
191
287
  if (routePath.startsWith('/api/list')) {
192
288
  const rawQ = routePath.split('?')[0].slice('/api/list'.length).replace(/^\//, '');
193
289
  const decodedPath = rawQ ? decodeURIComponent(rawQ) : '';
194
- const allowRoots = [
195
- process.env.STARTUP_CWD || process.cwd(),
196
- process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
197
- ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
198
- ].map(r => path.normalize(r));
290
+ const allowRoots = fsAllowRoots();
199
291
  // Empty path = the first allow-root (a sane default landing dir).
200
292
  const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
201
293
  const conf = confineToRoots(reqPath, allowRoots);
@@ -264,11 +356,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
264
356
  if (routePath.startsWith('/api/file/')) {
265
357
  const rawF = routePath.split('?')[0].slice('/api/file/'.length);
266
358
  const decodedPath = decodeURIComponent(rawF);
267
- const allowRoots = [
268
- process.env.STARTUP_CWD || process.cwd(),
269
- process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
270
- ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
271
- ].map(r => path.normalize(r));
359
+ const allowRoots = fsAllowRoots();
272
360
  const conf = confineToRoots(decodedPath, allowRoots);
273
361
  if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
274
362
  const normalizedPath = conf.realPath;
@@ -309,11 +397,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
309
397
  if (routePath.startsWith('/api/download/')) {
310
398
  const rawD = routePath.split('?')[0].slice('/api/download/'.length);
311
399
  const decodedPath = decodeURIComponent(rawD);
312
- const allowRoots = [
313
- process.env.STARTUP_CWD || process.cwd(),
314
- process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
315
- ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
316
- ].map(r => path.normalize(r));
400
+ const allowRoots = fsAllowRoots();
317
401
  const conf = confineToRoots(decodedPath, allowRoots);
318
402
  if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
319
403
  const normalizedPath = conf.realPath;
@@ -337,6 +421,102 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
337
421
  return;
338
422
  }
339
423
 
424
+ // --- File mutations ----------------------------------------------------
425
+ // The Files surface is a real manager (fsbrowse-grade): rename, delete,
426
+ // mkdir, upload. Every route re-confines via confineToRoots (realpath, so
427
+ // a symlink inside a root cannot point a mutation outside it), refuses
428
+ // the roots themselves as targets, and sanitizes any NEW name to a single
429
+ // path component (sanitizeEntryName). All are POST/PUT-only with JSON or
430
+ // raw-byte bodies; errors map to plain machine codes the client renders
431
+ // as human copy.
432
+
433
+ // POST /api/rename {path, newName} -> {ok, path}
434
+ if (routePath.split('?')[0] === '/api/rename' && req.method === 'POST') {
435
+ let body;
436
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
437
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
438
+ const allowRoots = fsAllowRoots();
439
+ const conf = confineToRoots(String(body.path || ''), allowRoots);
440
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
441
+ if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot rename an allowed root' }); return; }
442
+ const newName = sanitizeEntryName(body.newName);
443
+ if (!newName) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
444
+ const target = path.join(path.dirname(conf.realPath), newName);
445
+ // The target stays in the same (already-confined) directory by
446
+ // construction, but re-check anyway so the invariant is local.
447
+ const tConf = confineToRoots(path.dirname(target), allowRoots);
448
+ if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
449
+ if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
450
+ try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
451
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
452
+ return;
453
+ }
454
+
455
+ // POST /api/delete {path, recursive?} -> {ok}. Deleting a non-empty dir
456
+ // requires recursive:true (the client confirms first).
457
+ if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
458
+ let body;
459
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
460
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
461
+ const allowRoots = fsAllowRoots();
462
+ const conf = confineToRoots(String(body.path || ''), allowRoots);
463
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
464
+ if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
465
+ try {
466
+ const st = fs.lstatSync(conf.realPath);
467
+ if (st.isDirectory()) {
468
+ if (body.recursive === true) fs.rmSync(conf.realPath, { recursive: true });
469
+ else fs.rmdirSync(conf.realPath); // throws ENOTEMPTY unless empty
470
+ } else {
471
+ fs.unlinkSync(conf.realPath);
472
+ }
473
+ sendJSON(req, res, 200, { ok: true });
474
+ } catch (err) {
475
+ const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
476
+ sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
477
+ }
478
+ return;
479
+ }
480
+
481
+ // POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
482
+ if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
483
+ let body;
484
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
485
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
486
+ const allowRoots = fsAllowRoots();
487
+ const conf = confineToRoots(String(body.dir || ''), allowRoots);
488
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
489
+ const name = sanitizeEntryName(body.name);
490
+ if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
491
+ const target = path.join(conf.realPath, name);
492
+ if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
493
+ try { fs.mkdirSync(target); sendJSON(req, res, 200, { ok: true, path: target }); }
494
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
495
+ return;
496
+ }
497
+
498
+ // PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the
499
+ // body (no multipart dependency; the client sends fetch(file)). 50MB cap,
500
+ // never overwrites unless ?overwrite=1. Distinct path from the legacy
501
+ // express-mounted /api/upload/:conversationId.
502
+ if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
503
+ let qs;
504
+ try { qs = new URL(req.url, 'http://localhost').searchParams; } catch { qs = new URLSearchParams(); }
505
+ const allowRoots = fsAllowRoots();
506
+ const conf = confineToRoots(qs.get('dir') || '', allowRoots);
507
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
508
+ const name = sanitizeEntryName(qs.get('name'));
509
+ if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
510
+ const target = path.join(conf.realPath, name);
511
+ if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
512
+ let buf;
513
+ try { buf = await readBody(req, 50 * 1024 * 1024); }
514
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: e.code === 'TOO_LARGE' ? 'file too large (50MB cap)' : 'upload failed' }); return; }
515
+ try { fs.writeFileSync(target, buf); sendJSON(req, res, 200, { ok: true, path: target, size: buf.length }); }
516
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
517
+ return;
518
+ }
519
+
340
520
  if (routePath.startsWith('/api/image/')) {
341
521
  const imagePath = routePath.slice('/api/image/'.length);
342
522
  const decodedPath = decodeURIComponent(imagePath);
@@ -18,6 +18,20 @@ const SUB_AGENT_MAP = {
18
18
  export function register(router, deps) {
19
19
  const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats } = deps;
20
20
 
21
+ // Short-lived per-session terminal-event buffer (finding 35): a turn that
22
+ // completes/errors/cancels while a client ws is down would otherwise be a
23
+ // fire-and-forget broadcast the client never sees, hanging it busy forever.
24
+ // A re-subscribing client gets the buffered terminal frame replayed.
25
+ const TERMINAL_TTL_MS = 60000;
26
+ const terminalEvents = new Map(); // sessionId -> terminal event
27
+ function recordTerminal(sessionId, event) {
28
+ terminalEvents.set(sessionId, event);
29
+ const t = setTimeout(() => {
30
+ if (terminalEvents.get(sessionId) === event) terminalEvents.delete(sessionId);
31
+ }, TERMINAL_TTL_MS);
32
+ if (typeof t.unref === 'function') t.unref();
33
+ }
34
+
21
35
  // --- agents.list: enumerate registered ACP agents + claude-code ---
22
36
  router.handle('agents.list', () => {
23
37
  const agents = registry.list().map(a => ({
@@ -55,7 +69,11 @@ export function register(router, deps) {
55
69
  subscriptionIndex.get(sid).add(ws);
56
70
  ws.subscriptions = ws.subscriptions || new Set();
57
71
  ws.subscriptions.add(sid);
58
- return { subscribed: true, sessionId: sid };
72
+ // Replay a buffered terminal frame (complete/error/cancelled) so a client
73
+ // that re-subscribes after a ws drop learns the turn already ended.
74
+ const term = terminalEvents.get(sid);
75
+ if (term) { try { wsOptimizer.sendToClient(ws, { ...term, replayed: true }); } catch {} }
76
+ return { subscribed: true, sessionId: sid, replayedTerminal: !!term };
59
77
  });
60
78
 
61
79
  // --- chat.sendMessage: start a one-shot streaming chat with an agent.
@@ -88,6 +106,9 @@ export function register(router, deps) {
88
106
 
89
107
  const ctrl = { aborted: false, proc: null, agentId, model, cwd, startedAt: Date.now() };
90
108
  activeChats.set(sessionId, ctrl);
109
+ // Push-driven hint so clients refresh active-session state without
110
+ // waiting for the 3s chat.active poll (finding 48).
111
+ broadcastSync({ type: 'chat_active_changed', reason: 'started', sessionId, agentId, timestamp: Date.now() });
91
112
 
92
113
  // Fire-and-forget. Errors broadcast as streaming_error.
93
114
  (async () => {
@@ -127,11 +148,20 @@ export function register(router, deps) {
127
148
  onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
128
149
  };
129
150
  await runClaudeWithStreaming(content, cwd, agentId, config);
130
- broadcastSync({ type: 'streaming_complete', sessionId, agentId, eventCount, timestamp: Date.now() });
151
+ if (!ctrl.aborted) {
152
+ const ev = { type: 'streaming_complete', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, eventCount, timestamp: Date.now() };
153
+ recordTerminal(sessionId, ev);
154
+ broadcastSync(ev);
155
+ }
131
156
  } catch (e) {
132
- broadcastSync({ type: 'streaming_error', sessionId, agentId, error: e.message || String(e), recoverable: false, timestamp: Date.now() });
157
+ if (!ctrl.aborted) {
158
+ const ev = { type: 'streaming_error', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, error: e.message || String(e), recoverable: false, timestamp: Date.now() };
159
+ recordTerminal(sessionId, ev);
160
+ broadcastSync(ev);
161
+ }
133
162
  } finally {
134
163
  activeChats.delete(sessionId);
164
+ broadcastSync({ type: 'chat_active_changed', reason: 'ended', sessionId, agentId, timestamp: Date.now() });
135
165
  }
136
166
  })();
137
167
 
@@ -142,7 +172,7 @@ export function register(router, deps) {
142
172
  router.handle('chat.active', () => {
143
173
  const sessions = [];
144
174
  for (const [sid, c] of activeChats) {
145
- sessions.push({ sessionId: sid, agentId: c.agentId || null, model: c.model || null, cwd: c.cwd || null, startedAt: c.startedAt || null, pid: c.proc?.pid || null });
175
+ sessions.push({ sessionId: sid, claudeSessionId: c.claudeSessionId || null, agentId: c.agentId || null, model: c.model || null, cwd: c.cwd || null, startedAt: c.startedAt || null, pid: c.proc?.pid || null });
146
176
  }
147
177
  return { sessions };
148
178
  });
@@ -154,8 +184,16 @@ export function register(router, deps) {
154
184
  const ctrl = activeChats.get(sid);
155
185
  if (!ctrl) return { cancelled: false, reason: 'not-found' };
156
186
  ctrl.aborted = true;
187
+ // Broadcast the terminal 'cancelled' frame BEFORE killing the proc so a
188
+ // remote cancellation (other tab, dashboard stop-all) does not read as a
189
+ // normal completion (finding 44). streaming_cancelled is in BROADCAST_TYPES
190
+ // so every connected client sees it; it is also buffered for re-subscribers.
191
+ const ev = { type: 'streaming_cancelled', sessionId: sid, claudeSessionId: ctrl.claudeSessionId || null, agentId: ctrl.agentId || null, cancelled: true, timestamp: Date.now() };
192
+ recordTerminal(sid, ev);
193
+ broadcastSync(ev);
157
194
  try { ctrl.proc?.kill?.(); } catch {}
158
195
  activeChats.delete(sid);
196
+ broadcastSync({ type: 'chat_active_changed', reason: 'cancelled', sessionId: sid, agentId: ctrl.agentId || null, timestamp: Date.now() });
159
197
  return { cancelled: true };
160
198
  });
161
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.958",
3
+ "version": "1.0.960",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -0,0 +1,40 @@
1
+ // Live security validation of the confined file-mutation endpoints.
2
+ // Run with the server up: node scripts/validate-mutations.mjs
3
+ const BASE = process.env.BASE || 'http://localhost:3000';
4
+ const J = { 'Content-Type': 'application/json' };
5
+ const results = [];
6
+ async function post(route, body, headers = J) {
7
+ const r = await fetch(BASE + route, { method: 'POST', headers, body: typeof body === 'string' ? body : JSON.stringify(body) });
8
+ return { status: r.status, body: await r.text() };
9
+ }
10
+ async function check(name, expected, actual) {
11
+ const ok = actual.status === expected;
12
+ results.push({ name, expected, got: actual.status, ok, body: actual.body.slice(0, 80) });
13
+ }
14
+ const ROOT = 'C:\\dev\\agentgui';
15
+ // security cases
16
+ await check('rename-traversal', 403, await post('/api/rename', { path: ROOT + '\\..\\..\\Windows\\win.ini', newName: 'x.txt' }));
17
+ await check('delete-out-of-roots', 403, await post('/api/delete', { path: 'C:\\Windows\\Temp\\x.txt' }));
18
+ await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
19
+ await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
20
+ await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
21
+ await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
22
+ await check('mkdir-ads-colon', 400, await post('/api/mkdir', { dir: ROOT, name: 'a:b' }));
23
+ await check('csrf-cross-site-form', 403, await post('/api/delete', 'path=x', { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'cross-site' }));
24
+ // round trip
25
+ await check('mkdir-ok', 200, await post('/api/mkdir', { dir: ROOT, name: '_wtest' }));
26
+ const up = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'hello upload' });
27
+ results.push({ name: 'upload-ok', expected: 200, got: up.status, ok: up.status === 200 });
28
+ const up2 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'x' });
29
+ results.push({ name: 'upload-conflict-409', expected: 409, got: up2.status, ok: up2.status === 409 });
30
+ await check('rename-ok', 200, await post('/api/rename', { path: ROOT + '\\_wtest\\up.txt', newName: 'up2.txt' }));
31
+ await check('rename-conflict-409', 409, await post('/api/rename', { path: ROOT + '\\_wtest\\up2.txt', newName: 'up2.txt' }));
32
+ await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: ROOT + '\\_wtest' }));
33
+ await check('delete-recursive-ok', 200, await post('/api/delete', { path: ROOT + '\\_wtest', recursive: true }));
34
+ const st = await fetch(BASE + '/api/stat/' + encodeURIComponent(ROOT));
35
+ results.push({ name: 'stat-ok', expected: 200, got: st.status, ok: st.status === 200 });
36
+ const st2 = await fetch(BASE + '/api/stat/' + encodeURIComponent('C:\\Windows'));
37
+ results.push({ name: 'stat-outside-403', expected: 403, got: st2.status, ok: st2.status === 403 });
38
+ let fail = 0;
39
+ for (const r of results) { if (!r.ok) fail++; console.log((r.ok ? 'PASS' : 'FAIL') + ' ' + r.name + ' expected=' + r.expected + ' got=' + r.got + (r.ok ? '' : ' body=' + (r.body || ''))); }
40
+ process.exit(fail ? 1 : 0);
package/server.js CHANGED
@@ -70,6 +70,33 @@ const staticDir = path.join(rootDir, 'site', 'app');
70
70
  if (!fs.existsSync(staticDir)) fs.mkdirSync(staticDir, { recursive: true });
71
71
 
72
72
  const expressApp = createExpressApp({ queries, BASE_URL });
73
+
74
+ // ccsniff's /v1/history/sessions/:sid/events returns ALL events with no
75
+ // paging. Shim (mounted BEFORE the ccsniff router; node_modules untouched):
76
+ // when ?limit= is present, slice the most-recent window server-side and
77
+ // report { total, offset } so the client can page older on demand via
78
+ // ?before= (slice end index, exclusive; defaults to total).
79
+ function historyEventsLimitShim(req, res, next) {
80
+ if (!/\/v1\/history\/sessions\/[^/]+\/events$/.test(req.path)) return next();
81
+ const limit = parseInt(req.query.limit, 10);
82
+ if (!Number.isFinite(limit) || limit <= 0) return next();
83
+ const origJson = res.json.bind(res);
84
+ res.json = (body) => {
85
+ try {
86
+ if (body && Array.isArray(body.events)) {
87
+ const total = body.events.length;
88
+ const before = parseInt(req.query.before, 10);
89
+ const end = (Number.isFinite(before) && before >= 0) ? Math.min(before, total) : total;
90
+ const start = Math.max(0, end - limit);
91
+ body = { ...body, events: body.events.slice(start, end), total, offset: start };
92
+ }
93
+ } catch {}
94
+ return origJson(body);
95
+ };
96
+ next();
97
+ }
98
+ expressApp.use(historyEventsLimitShim);
99
+
73
100
  try {
74
101
  const historyRouter = await createHistoryRouter({ projectsDir: process.env.CLAUDE_PROJECTS_DIR });
75
102
  expressApp.use('/', historyRouter);