agentgui 1.0.974 → 1.0.975
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/lib/http-handler.js
CHANGED
|
@@ -101,19 +101,15 @@ function isAllowRoot(realPath, allowRoots) {
|
|
|
101
101
|
|
|
102
102
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
103
103
|
return async function httpHandler(req, res) {
|
|
104
|
-
// CORS: when
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// when configured, else same-origin only. With no PASSWORD the server is
|
|
109
|
-
// already open, so the permissive wildcard is harmless and kept for tools.
|
|
104
|
+
// CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
|
|
105
|
+
// let any webpage the user visits make credentialless fetches to /api/list,
|
|
106
|
+
// /api/file/*, etc. and read ~/.claude/projects content — even on a no-
|
|
107
|
+
// PASSWORD localhost deploy. Set CORS_ORIGIN=<origin> for cross-origin tools.
|
|
110
108
|
const _corsOrigin = process.env.CORS_ORIGIN;
|
|
111
109
|
if (_corsOrigin) {
|
|
112
110
|
res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
|
|
113
111
|
res.setHeader('Vary', 'Origin');
|
|
114
|
-
}
|
|
115
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
116
|
-
} // else: no ACAO header -> browsers block cross-origin reads (same-origin still works)
|
|
112
|
+
} // no ACAO header -> browsers enforce same-origin by default
|
|
117
113
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
118
114
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
119
115
|
// The password can ride in a ?token= query param (EventSource/deep-links
|
|
@@ -684,12 +680,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
684
680
|
const normalizedPath = conf.realPath;
|
|
685
681
|
try {
|
|
686
682
|
const ext = path.extname(normalizedPath).toLowerCase();
|
|
687
|
-
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp'
|
|
688
|
-
//
|
|
689
|
-
//
|
|
683
|
+
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
684
|
+
// SVG is intentionally excluded: browsers render SVG as a live document
|
|
685
|
+
// in the app's origin, so an agent-written SVG with a <script src=CDN>
|
|
686
|
+
// would execute in the agentgui origin (CSP allows unpkg/jsdelivr).
|
|
687
|
+
// Files preview uses /api/file/download (attachment) for SVG.
|
|
690
688
|
const contentType = mimeTypes[ext];
|
|
691
689
|
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
692
|
-
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
|
|
690
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
|
|
693
691
|
res.end(fs.readFileSync(normalizedPath));
|
|
694
692
|
} catch (err) { sendJSON(req, res, 400, { error: err.message }); }
|
|
695
693
|
return;
|
package/package.json
CHANGED
|
@@ -28,6 +28,9 @@ const TRAVERSAL = join(ROOT, '..', '..', ...(WIN ? ['Windows', 'win.ini'] : ['et
|
|
|
28
28
|
await check('rename-traversal', 403, await post('/api/rename', { path: TRAVERSAL, newName: 'x.txt' }));
|
|
29
29
|
await check('delete-out-of-roots', 403, await post('/api/delete', { path: OUTSIDE }));
|
|
30
30
|
await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
|
|
31
|
+
await check('rename-to-secret-403', 403, await post('/api/rename', { path: join(ROOT, 'package.json'), newName: '.env' }));
|
|
32
|
+
await check('upload-secret-403', 403, await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT) + '&name=.env', { method: 'PUT', headers: AUTH, body: 'secret' }).then(async r => ({ status: r.status, body: await r.text() })));
|
|
33
|
+
await check('mkdir-secret-403', 403, await post('/api/mkdir', { dir: ROOT, name: '.env' }));
|
|
31
34
|
await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
|
|
32
35
|
await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
|
|
33
36
|
await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
|
package/site/app/js/app.js
CHANGED
|
@@ -415,6 +415,7 @@ function openLiveStream() {
|
|
|
415
415
|
// Dedupe against the snapshot/prior pushes by event index - a
|
|
416
416
|
// reconnect or overlap would otherwise double-append the same event.
|
|
417
417
|
if (ev.i == null || !state.events.some(e => e.i === ev.i)) {
|
|
418
|
+
ev._idx = state.events.length;
|
|
418
419
|
state.events.push(ev);
|
|
419
420
|
// Cap retained events so a long live session can't grow unbounded.
|
|
420
421
|
if (state.events.length > 2000) state.events.splice(0, state.events.length - 2000);
|
|
@@ -1699,7 +1700,9 @@ function appendText(parts, text) {
|
|
|
1699
1700
|
// standalone tool_result part. Sets the card's result + done/error status.
|
|
1700
1701
|
function applyToolResult(parts, block) {
|
|
1701
1702
|
const id = block.tool_use_id || block.id || null;
|
|
1702
|
-
const
|
|
1703
|
+
const raw = block?.content ?? block?.output ?? block;
|
|
1704
|
+
// Claude API delivers content as an array of {type,text} objects; flatten to plain text.
|
|
1705
|
+
const content = Array.isArray(raw) ? (raw.filter(b => b.type === 'text').map(b => b.text).join('\n') || JSON.stringify(raw, null, 2)) : raw;
|
|
1703
1706
|
const isError = !!(block?.is_error);
|
|
1704
1707
|
const byId = id ? [...parts].reverse().find(p => p && p.kind === 'tool' && p._id === id) : null;
|
|
1705
1708
|
const target = byId || [...parts].reverse().find(p => p && p.kind === 'tool' && p.status === 'running');
|
|
@@ -2323,6 +2326,9 @@ async function sendChat(textArg) {
|
|
|
2323
2326
|
} finally {
|
|
2324
2327
|
state.chat.busy = false;
|
|
2325
2328
|
state.chat.abort = null;
|
|
2329
|
+
// Prune an empty assistant shell (WS-drop before any content arrived).
|
|
2330
|
+
const msgs = state.chat.messages;
|
|
2331
|
+
if (msgs.length && isEmptyTurn(msgs[msgs.length - 1])) msgs.pop();
|
|
2326
2332
|
persistChat();
|
|
2327
2333
|
refreshActive(); // settle the running panel/dashboard now, not at the next poll
|
|
2328
2334
|
render();
|
|
@@ -2479,6 +2485,13 @@ function historyMain() {
|
|
|
2479
2485
|
onSelect: (id) => { state.eventFilter = id && id.id ? id.id : id; render(); },
|
|
2480
2486
|
label: 'Filter events by type',
|
|
2481
2487
|
});
|
|
2488
|
+
// Single pass over state.events for all three counters (replaces three separate .filter() calls).
|
|
2489
|
+
const evCounters = state.events.reduce((c, e) => {
|
|
2490
|
+
if (e.role === 'user') c.turns++;
|
|
2491
|
+
if (e.type === 'tool_use') c.tools++;
|
|
2492
|
+
if (e.isError) c.errors++;
|
|
2493
|
+
return c;
|
|
2494
|
+
}, { turns: 0, tools: 0, errors: 0 });
|
|
2482
2495
|
const meta = SessionMeta({
|
|
2483
2496
|
items: [
|
|
2484
2497
|
sess && sess.cwd ? { label: 'cwd', value: sess.cwd, title: sess.cwd } : null,
|
|
@@ -2487,9 +2500,9 @@ function historyMain() {
|
|
|
2487
2500
|
// Spelled counter vocabulary in the detail strip (events/turns/tools/
|
|
2488
2501
|
// errors); the abbreviated 'ev/tools/err' triple stays compact-row-only.
|
|
2489
2502
|
{ label: 'events', value: String(state.events.length) },
|
|
2490
|
-
{ label: 'turns', value: String(sess?.userTurns ??
|
|
2491
|
-
{ label: 'tools', value: String(
|
|
2492
|
-
{ label: 'errors', value: String(
|
|
2503
|
+
{ label: 'turns', value: String(sess?.userTurns ?? evCounters.turns) },
|
|
2504
|
+
{ label: 'tools', value: String(evCounters.tools) },
|
|
2505
|
+
{ label: 'errors', value: String(evCounters.errors) },
|
|
2493
2506
|
].filter(Boolean),
|
|
2494
2507
|
});
|
|
2495
2508
|
if (filteredEvents.length === 0) {
|
|
@@ -2506,10 +2519,7 @@ function historyMain() {
|
|
|
2506
2519
|
const shown = filteredEvents.slice(-limit);
|
|
2507
2520
|
const hiddenCount = total - shown.length;
|
|
2508
2521
|
// Keys of the currently-shown rows, so expand-all toggles only what's rendered.
|
|
2509
|
-
const shownKeys = shown.map((e, i) =>
|
|
2510
|
-
const absIdx = total - shown.length + i;
|
|
2511
|
-
return e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
|
|
2512
|
-
});
|
|
2522
|
+
const shownKeys = shown.map((e, i) => e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i)));
|
|
2513
2523
|
const allExpanded = shownKeys.length > 0 && shownKeys.every(k => state.expandedEvents.has(k));
|
|
2514
2524
|
const eventControls = h('div', { key: 'evctrl', class: 'history-actions', role: 'group', 'aria-label': 'event controls' },
|
|
2515
2525
|
Btn({ key: 'expall', onClick: () => {
|
|
@@ -2535,8 +2545,7 @@ function historyMain() {
|
|
|
2535
2545
|
// Stable key: server event index when present, else ts + the event's
|
|
2536
2546
|
// ABSOLUTE position in state.events (not the sliced-view index, which
|
|
2537
2547
|
// shifts when live events append and would collide loaded vs live rows).
|
|
2538
|
-
const
|
|
2539
|
-
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + absIdx;
|
|
2548
|
+
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (e.type || '') + '-' + (e._idx ?? (total - shown.length + i));
|
|
2540
2549
|
const role = e.role || '?';
|
|
2541
2550
|
const type = e.type || '?';
|
|
2542
2551
|
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
@@ -2890,8 +2899,7 @@ function settingsMain() {
|
|
|
2890
2899
|
label: 'backend url',
|
|
2891
2900
|
value: state.backendDraft,
|
|
2892
2901
|
placeholder: '(blank = same origin)',
|
|
2893
|
-
|
|
2894
|
-
'aria-invalid': !isValid ? 'true' : 'false',
|
|
2902
|
+
error: !isValid ? 'Invalid URL format' : undefined,
|
|
2895
2903
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
2896
2904
|
onInput: (v) => {
|
|
2897
2905
|
state.backendDraft = v;
|
|
@@ -2900,7 +2908,6 @@ function settingsMain() {
|
|
|
2900
2908
|
render();
|
|
2901
2909
|
},
|
|
2902
2910
|
}),
|
|
2903
|
-
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
2904
2911
|
state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
2905
2912
|
state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 'lede', role: 'status' }, 'connected') : null,
|
|
2906
2913
|
state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 'lede field-error', role: 'alert' }, 'connection failed - check the URL') : null,
|
|
@@ -2977,6 +2984,8 @@ function clearLocalData() {
|
|
|
2977
2984
|
// defaults with the keys gone.
|
|
2978
2985
|
state.chat.abort?.abort(); // stop any in-flight stream before we drop the page
|
|
2979
2986
|
for (const k of ['agentgui.chat', 'agentgui.agent', 'agentgui.model', 'agentgui.cwd', 'agentgui.backend', 'agentgui.live', 'agentgui.files']) lsRemove(k);
|
|
2987
|
+
// Also wipe kit WorkspaceShell layout keys (collapse state + resizer widths).
|
|
2988
|
+
try { for (let i = localStorage.length - 1; i >= 0; i--) { const k = localStorage.key(i); if (k && k.startsWith('ds.ws.')) localStorage.removeItem(k); } } catch {}
|
|
2980
2989
|
state.chat = { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 };
|
|
2981
2990
|
location.reload();
|
|
2982
2991
|
}
|
|
@@ -3013,7 +3022,7 @@ function preferencesPanel() {
|
|
|
3013
3022
|
state.confirmingClearData
|
|
3014
3023
|
? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
|
|
3015
3024
|
children: [
|
|
3016
|
-
h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, and
|
|
3025
|
+
h('span', { key: 'cldtxt' }, 'Removes saved chat, agent/model/cwd, backend, and layout preferences from this browser. This cannot be undone. '),
|
|
3017
3026
|
Btn({ key: 'cldno', onClick: () => { state.confirmingClearData = false; render(); }, children: 'cancel' }),
|
|
3018
3027
|
Btn({ key: 'cldyes', danger: true, onClick: clearLocalData, children: 'clear' })] })
|
|
3019
3028
|
: Btn({ key: 'cldbtn', onClick: clearLocalData, children: 'clear local data' }),
|
|
@@ -3046,7 +3055,9 @@ function agentsPanel() {
|
|
|
3046
3055
|
const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
|
|
3047
3056
|
if (!avail) bits.push(a.npxInstallable ? 'runs via npx' : 'not installed');
|
|
3048
3057
|
if (acp) bits.push(acp.healthy ? 'running healthy' : (acp.running ? 'running' : 'stopped'));
|
|
3049
|
-
if (acp && acp.restartCount
|
|
3058
|
+
if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
|
|
3059
|
+
if (acp && !acp.healthy && acp.providerInfo?.error) bits.push(acp.providerInfo.error);
|
|
3060
|
+
if (acp && acp.idleMs > 3_600_000) bits.push(fmtDuration(acp.idleMs) + ' idle');
|
|
3050
3061
|
return Row({
|
|
3051
3062
|
key: 'ag' + a.id,
|
|
3052
3063
|
rank: String(i + 1).padStart(3, '0'),
|
|
@@ -3192,6 +3203,8 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
|
|
|
3192
3203
|
// whole session) - cap in-memory state at the most-recent 5000 so a
|
|
3193
3204
|
// monster session can't pin the tab; the render window stays 300+load-older.
|
|
3194
3205
|
if (state.events.length > 5000) state.events = state.events.slice(-5000);
|
|
3206
|
+
// Stamp stable _idx so EventList keys are stable regardless of slice/cap.
|
|
3207
|
+
state.events.forEach((e, i) => { if (e._idx == null) e._idx = i; });
|
|
3195
3208
|
clearTimeout(slowTimer);
|
|
3196
3209
|
state.eventsSlow = false;
|
|
3197
3210
|
state.eventsLoaded = true;
|
|
@@ -2675,9 +2675,10 @@
|
|
|
2675
2675
|
re-declare it; the sizing block now states the truth). */
|
|
2676
2676
|
line-height: 1.5; resize: none;
|
|
2677
2677
|
min-height: 28px; max-height: 200px;
|
|
2678
|
-
box-sizing: border-box; overflow-y:
|
|
2678
|
+
box-sizing: border-box; overflow-y: hidden;
|
|
2679
2679
|
scrollbar-width: thin;
|
|
2680
2680
|
}
|
|
2681
|
+
.ds-247420 .chat-composer textarea:focus { overflow-y: auto; }
|
|
2681
2682
|
.ds-247420 .chat-composer textarea::placeholder { color: var(--fg-3); }
|
|
2682
2683
|
.ds-247420 .chat-composer textarea:focus { background: none; border: none; box-shadow: none; outline: none; }
|
|
2683
2684
|
.ds-247420 .chat-composer .send {
|
|
@@ -3709,6 +3710,7 @@
|
|
|
3709
3710
|
@media (pointer: coarse) {
|
|
3710
3711
|
.ds-247420 .ws-rail-toggle { width: 44px; height: 44px; }
|
|
3711
3712
|
.ds-247420 .ws-drawer-toggle { width: 44px; height: 44px; }
|
|
3713
|
+
.ds-247420 .ws-desktop-toggle { width: 44px; height: 44px; }
|
|
3712
3714
|
}
|
|
3713
3715
|
|
|
3714
3716
|
/* Drawer toggles and the scrim are hidden by default and revealed by the staged
|
|
@@ -3726,6 +3728,7 @@
|
|
|
3726
3728
|
pane becomes a mobile overlay drawer at <=1480px, reached via its own
|
|
3727
3729
|
drawer-toggle), so hide this crumb control past that breakpoint. */
|
|
3728
3730
|
@media (max-width: 1480px) { .ds-247420 .ws-pane-toggle { display: none; } }
|
|
3731
|
+
@media (max-width: 480px) { .ds-247420 .ws-crumb { padding-left: var(--space-2); padding-right: var(--space-2); } }
|
|
3729
3732
|
.ds-247420 .ws-scrim { display: none; }
|
|
3730
3733
|
|
|
3731
3734
|
/* Responsive: the columns yield to the CONTENT in stages - the main column is
|
|
@@ -7902,9 +7905,6 @@
|
|
|
7902
7905
|
.ds-247420 .ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg) 5%, transparent); }
|
|
7903
7906
|
.ds-247420 .ds-event-list .row.event-flash { animation: agentgui-event-flash 2s ease-out; }
|
|
7904
7907
|
|
|
7905
|
-
/* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
|
|
7906
|
-
.ds-247420 .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
|
|
7907
|
-
.ds-247420 .chat-composer textarea:not(:focus) { overflow-y: hidden; }
|
|
7908
7908
|
|
|
7909
7909
|
/* Generic interactive focus ring for app-emitted controls. */
|
|
7910
7910
|
.ds-247420 button:focus-visible,
|