agentgui 1.0.963 → 1.0.964

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.
@@ -471,6 +471,48 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
471
471
  return;
472
472
  }
473
473
 
474
+ // POST /api/move {path, destDir, overwrite?} -> {ok, path}. Moves an
475
+ // entry into another directory; BOTH endpoints re-confine via realpath.
476
+ // Refuses: a root as the source, a directory moved into itself or its
477
+ // own subtree, and an existing target unless overwrite:true (and never
478
+ // overwrites a directory).
479
+ if (routePath.split('?')[0] === '/api/move' && req.method === 'POST') {
480
+ let body;
481
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
482
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
483
+ const allowRoots = fsAllowRoots();
484
+ const conf = confineToRoots(String(body.path || ''), allowRoots);
485
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
486
+ if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot move an allowed root' }); return; }
487
+ const dConf = confineToRoots(String(body.destDir || ''), allowRoots);
488
+ if (!dConf.ok) { sendJSON(req, res, dConf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + dConf.reason }); return; }
489
+ let destIsDir = false;
490
+ try { destIsDir = fs.statSync(dConf.realPath).isDirectory(); } catch {}
491
+ if (!destIsDir) { sendJSON(req, res, 400, { error: 'destination is not a directory' }); return; }
492
+ const name = sanitizeEntryName(path.basename(conf.realPath));
493
+ if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
494
+ // A directory must never move into itself or its own subtree.
495
+ const srcPrefix = conf.realPath + path.sep;
496
+ if (dConf.realPath === conf.realPath || dConf.realPath.startsWith(srcPrefix)) {
497
+ sendJSON(req, res, 400, { error: 'cannot move a folder into itself' }); return;
498
+ }
499
+ const target = path.join(dConf.realPath, name);
500
+ if (target === conf.realPath) { sendJSON(req, res, 200, { ok: true, path: target }); return; }
501
+ if (fs.existsSync(target)) {
502
+ let targetIsDir = false;
503
+ try { targetIsDir = fs.lstatSync(target).isDirectory(); } catch {}
504
+ if (targetIsDir || body.overwrite !== true) {
505
+ sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return;
506
+ }
507
+ }
508
+ try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
509
+ catch (err) {
510
+ const code = err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400;
511
+ sendJSON(req, res, code, { error: err.code === 'EXDEV' ? 'cannot move across drives' : err.message });
512
+ }
513
+ return;
514
+ }
515
+
474
516
  // POST /api/delete {path, recursive?} -> {ok}. Deleting a non-empty dir
475
517
  // requires recursive:true (the client confirms first).
476
518
  if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.963",
3
+ "version": "1.0.964",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -44,6 +44,17 @@ const up2 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(join
44
44
  results.push({ name: 'upload-conflict-409', expected: 409, got: up2.status, ok: up2.status === 409 });
45
45
  await check('rename-ok', 200, await post('/api/rename', { path: join(ROOT, '_wtest', 'up.txt'), newName: 'up2.txt' }));
46
46
  await check('rename-conflict-409', 409, await post('/api/rename', { path: join(ROOT, '_wtest', 'up2.txt'), newName: 'up2.txt' }));
47
+ // move: security cases + round trip (subdir -> back), then self-nesting refusal
48
+ await check('move-traversal-src-403', 403, await post('/api/move', { path: TRAVERSAL, destDir: ROOT }));
49
+ await check('move-outside-dest-403', 403, await post('/api/move', { path: join(ROOT, '_wtest', 'up2.txt'), destDir: OUTSIDE_DIR }));
50
+ await check('move-root-refused-403', 403, await post('/api/move', { path: ROOT, destDir: join(ROOT, '_wtest') }));
51
+ await check('mkdir-move-sub-ok', 200, await post('/api/mkdir', { dir: join(ROOT, '_wtest'), name: 'sub' }));
52
+ await check('move-ok', 200, await post('/api/move', { path: join(ROOT, '_wtest', 'up2.txt'), destDir: join(ROOT, '_wtest', 'sub') }));
53
+ const up3 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(join(ROOT, '_wtest')) + '&name=up2.txt', { method: 'PUT', headers: AUTH, body: 'collide' });
54
+ results.push({ name: 'upload-for-move-conflict', expected: 200, got: up3.status, ok: up3.status === 200 });
55
+ await check('move-conflict-409', 409, await post('/api/move', { path: join(ROOT, '_wtest', 'sub', 'up2.txt'), destDir: join(ROOT, '_wtest') }));
56
+ await check('move-overwrite-ok', 200, await post('/api/move', { path: join(ROOT, '_wtest', 'sub', 'up2.txt'), destDir: join(ROOT, '_wtest'), overwrite: true }));
57
+ await check('move-dir-into-itself-400', 400, await post('/api/move', { path: join(ROOT, '_wtest', 'sub'), destDir: join(ROOT, '_wtest', 'sub') }));
47
58
  await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: join(ROOT, '_wtest') }));
48
59
  await check('delete-recursive-ok', 200, await post('/api/delete', { path: join(ROOT, '_wtest'), recursive: true }));
49
60
  const st = await fetch(BASE + '/api/stat/' + encodeURIComponent(ROOT), { headers: AUTH });
@@ -16,6 +16,13 @@
16
16
  <script type="importmap">
17
17
  { "imports": { "anentrypoint-design": "./vendor/anentrypoint-design/247420.js" } }
18
18
  </script>
19
+ <!-- Preload the whole module graph so the 300KB kit fetch starts in parallel
20
+ with app.js instead of after its parse; preconnect warms the jsdelivr
21
+ DNS+TLS the markdown stack needs on first chat render. -->
22
+ <link rel="modulepreload" href="./vendor/anentrypoint-design/247420.js">
23
+ <link rel="modulepreload" href="./js/backend.js">
24
+ <link rel="modulepreload" href="./js/codec.js">
25
+ <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
19
26
  <style>
20
27
  :root {
21
28
  --agentgui-bg: #16161a;
@@ -114,12 +121,15 @@
114
121
  }
115
122
  .pill:focus-visible { outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px; }
116
123
 
117
- /* subagent toggle */
118
- .subagent-toggle { display: flex; gap: .5em; align-items: center; padding: .4em 0; cursor: pointer; min-height: 32px; }
119
- .subagent-toggle input { width: 16px; height: 16px; cursor: pointer; }
124
+ /* subagent toggle (the control itself is the kit Checkbox) */
125
+ .subagent-toggle { padding: .4em 0; min-height: 32px; }
120
126
 
121
127
  .field-error { color: var(--warn, var(--agentgui-warn)); }
122
128
 
129
+ /* Boot splash: static text painted with the HTML, replaced by the first
130
+ mount render - the cold module waterfall no longer reads as a blank page. */
131
+ .boot-splash { height: 100%; min-height: 60vh; display: flex; align-items: center; justify-content: center; color: var(--agentgui-muted); font-size: .9rem; }
132
+
123
133
  /* chat control cluster in the crumb bar: model picker, +new, status.
124
134
  Allowed to wrap so it never overflows the crumb on tablet widths. */
125
135
  .chat-controls { display: flex; align-items: center; gap: .6em; flex-wrap: wrap; }
@@ -255,7 +265,7 @@
255
265
  </style>
256
266
  </head>
257
267
  <body>
258
- <div id="app"></div>
268
+ <div id="app"><div class="boot-splash" role="status">loading agentgui…</div></div>
259
269
  <script type="module" src="./js/app.js"></script>
260
270
  </body>
261
271
  </html>