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.
@@ -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 PASSWORD is set the server holds credentials a cross-origin
105
- // page must not be able to spend, so we do NOT advertise a wildcard origin
106
- // (a wildcard + a leaked Bearer/token would let any site the user visits
107
- // drive this server). Reflect an explicitly-allowed origin (CORS_ORIGIN)
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
- } else if (!process.env.PASSWORD) {
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', '.svg': 'image/svg+xml' };
688
- // Only serve known image types - never an octet-stream fallback, which
689
- // would turn this into a generic file reader for any extension.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.974",
3
+ "version": "1.0.975",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -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.' }));
@@ -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 content = block?.content ?? block?.output ?? block;
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 ?? state.events.filter(e => e.role === 'user').length) },
2491
- { label: 'tools', value: String(state.events.filter(e => e.type === 'tool_use').length) },
2492
- { label: 'errors', value: String(state.events.filter(e => e.isError).length) },
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 absIdx = total - shown.length + i;
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
- 'aria-describedby': !isValid ? 'backend-url-error' : undefined,
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 backend from this browser. This cannot be undone. '),
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 > 1) bits.push('restarted ' + acp.restartCount + ' times');
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: auto;
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,