agentgui 1.0.963 → 1.0.965
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/.claude/workflows/gui-ux-craft.js +101 -0
- package/AGENTS.md +25 -23
- package/PUNCHLIST-UX.md +222 -0
- package/lib/http-handler.js +42 -0
- package/package.json +1 -1
- package/scripts/validate-mutations.mjs +11 -0
- package/site/app/index.html +14 -4
- package/site/app/js/app.js +186 -76
- package/site/app/js/backend.js +12 -3
- package/site/app/vendor/anentrypoint-design/247420.css +366 -111
- package/site/app/vendor/anentrypoint-design/247420.js +14 -14
package/lib/http-handler.js
CHANGED
|
@@ -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
|
@@ -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 });
|
package/site/app/index.html
CHANGED
|
@@ -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 {
|
|
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>
|