agentgui 1.0.971 → 1.0.972

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/AGENTS.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # AgentGUI — Agent Notes
2
2
 
3
+ ## Design-maturity sweep + dead-code + server-hardening (2026-06-18) — fifteenth run
4
+
5
+ Mandate: "fix every aspect, update ../design, all design content lives there, fan out subagents, track with a workflow." Two tracking Workflows ran. **`gui-design-15` (run `wf_7ba315a1-9a9`, 8 lenses composer-input/chat-thread/files/sessions/shell/tokens-theme/a11y-motion/glyph-residue): 59 agents -> 42 findings -> 35 confirmed -> 33 applied across 9 kit files** + 2 deferred fixes implemented after (shell pane-toggle relocated into `.ws-crumb` as a `ws-desktop-toggle`; files density picker made icon-led with 3 NEW shell.js icons `rows`/`rows-tight`/`grid` + `DENSITY_ICONS` + `.ds-density-btn` 28px square). **`gui-design-15b` (run `wf_5b2861b2-717`, 3 lenses history-settings/server-boundary/security): 25 agents -> 22 findings -> 18 confirmed**, all landed. Total ~95 agents, ~4M tokens.
6
+
7
+ **Kit design fixes (ALL in `/config/workspace/design`):** chat.css(10) cwd-input focus/invalid + `.send.cancel` neutral tone + code-card + flat-user inline code + structured-turn band suppression + shape-distinct live/stale status discs + breakdown disc shape-channel + canonical `--stale` token + is-new.is-error compound; app-shell.css send-button size consolidation + composer error state + `--on-color` saturated-fill foregrounds + collapsed-track resizer hide guards + coarse 44px toggle floor + 4 reduced-motion opt-in guards + `.ds-seg-btn:focus-visible` + `.ds-row-detail` + `.empty-state--inline` + `.panel-body` owl-rhythm; colors_and_type.css auto-dark `--danger` parity + `--ink-3-dark`/`--paper-3-dark` named anchors + paper-island `--warn`/`--sky` restore; app-surfaces.css print `--paper`/`--ink` + health-chip/cwd-bar `--r-1`/`--bw-hair` tokens; editor-primitives.css `:focus`->`:focus-visible`+ring; chat.js ToolCallNode copy buttons; files.js filtered-empty-keeps-controls + perm-tag chip + folder-open empty icon + `emptyAction` CTA + skeleton 12 rows + roving-radiogroup kbd nav; sessions.js cost-separator emphasis; shell.js `WS_RESIZE_CLAMP` ceilings raised above the fluid clamp(); **NEW kit export `ShortcutList`** (interaction-primitives.js, consumes the orphaned `.ds-shortcuts-hint`/`.ds-kbd` legend CSS); **Row `detail` prop** (content.js, renders a `.ds-row-detail` monospace pre below the title — expanded history events no longer dump JSON into the bold title). App wiring (app.js): `ShortcutList` in the ?-overlay + settings keyboard panel, search-result `highlight`, `empty-state--inline` on the 3 transient search status nodes, expanded-event `detail`.
8
+
9
+ **Dead-code removal (architecture-pliable):** `static/` (webjsx.js+xstate.umd.min.js — referenced NOWHERE; the SPA gets `h`/`mount`/webjsx from the kit dist), `scripts/build-rippleui.mjs` (self-ref only), `scripts/copy-vendor.js` (only built into the dead static/ tree) all removed; `copy-vendor` dropped from package.json `postinstall` (now `patch-fsbrowse` + better-sqlite3 only). AGENTS.md's "legacy static/ tree is gone" claim is now TRUE. One smart-quote `'` (U+2019) at app.js:3011 -> ASCII; full-codebase glyph sweep otherwise clean.
10
+
11
+ **Server hardening (agentgui lib/, all preserve the localhost-PASSWORD witness):** terminal `/api/terminal/*` + WS-attach now fail-closed unless `PASSWORD && ENABLE_TERMINAL=1` (was an open RCE when PASSWORD unset) + drops client-supplied shell/env; `/api/file`+`/api/list` `fsAllowRoots()` includes the server cwd only under `PASSWORD`/`FS_ALLOW_CWD=1` (was exposing the whole server tree+secrets) + secret-basename block (`.env`/`.pem`/`.key`/etc) + `env`/`conf`/`cfg`/`ini` dropped from TEXT_EXTS; CSRF guard now JSON-only + Origin-host==Host (dropped the octet-stream/empty-CT bypass); constant-time SHA-256 token compare in BOTH http-handler `_checkToken` and ws-setup (was `!==`/length-leaking); `chat.sendMessage` confines the client cwd via `confineToRoots` realPath (was spawning the agent in any host dir); cookie `Secure` (conditional) + `Max-Age`; rate-limiter honors `X-Forwarded-For` only under `TRUST_PROXY=1` (right-most hop) + failed-auth bucket penalty; health endpoint omits memory/projectsDir/allowRoots under the health-exemption bypass; **CSP `script-src` drops `unsafe-inline` for a per-request nonce** threaded through asset-server.js onto the bootstrap/hot-reload scripts AND the static importmap (style-src keeps unsafe-inline for DS runtime `<style>`); `agents.models` wired through `getModelsForAgent` (was `[]` for every non-claude-code agent). `confineToRoots`/`fsAllowRoots` exported from http-handler.js for reuse.
12
+
13
+ **Witnessed** (localhost:3009/gm/?token, PASSWORD=`123,slam,123,slam`, fresh server + fresh re-vendored dist): browser-4 ready=complete, dark body `rgb(19,19,24)` kit-painted, 3 resizers, no h-scroll, `ShortcutList` 11 rows/11 keycaps in BOTH overlay and settings, **0 console errors -> the CSP-nonce change did not break the importmap/boot**. `validate-mutations.mjs` 26/26 PASS on the live hardened server. Kit build all 4 lints pass. `test.js` 10 pass/0 fail. Re-vendored `dist/247420.{css,js}` into `site/app/vendor/anentrypoint-design/`.
14
+
3
15
  ## Design-maturity sweep + marketing-site consolidation (2026-06-18) — fourteenth run
4
16
 
5
17
  Continues the 13th run's "all design lives in the kit" mandate to the LAST residue surface + a kit design-maturity sweep. **`site/theme.mjs` (the flatspace marketing/landing renderer, NOT the SPA) was the remaining residue**: it carried an inline `<style>` with hardcoded hex (`#FBF6EB`/`#1F1B16`), ~12 inline `style=` props, deprecated `C.Btn({primary})`, and two `↗` arrow glyphs. ALL migrated: a new kit **`marketing.css`** `.site-*` family (`.site-panel`/`.site-hero`/`.site-hero-h`/`.site-hero-body`/`.site-chip-row`/`.site-cta-row`/`.site-cli`+`.cli`/`.prompt`/`.cmd`/`.site-embed`, pre-scoped `.ds-247420`, wired into `build.mjs` cssParts + `lint-glyphs` `SCAN_ROOT_FILES`); kit `Panel`/`Heading` gained a **`class` prop** (they only accepted `style`) so consumers attach classes instead of inline styles; `Btn({primary})` -> `Btn({variant:'primary'})`; `↗` -> `->`. theme.mjs head now render-blocking-`<link>`s the kit `247420.css` (it loaded the kit via JS importmap only, so the inline `<style>` was its pre-JS FOUC guard) — the kit already owns the base surface (`app-surfaces.css:27` `.ds-247420 body { background:var(--bg); color:var(--fg); font-family:var(--ff-display) }`). flatspace is NOT a dep here; theme.mjs is a pure render fn witnessed by loading its `renderHtml()` output. **The marketing site tracks unpkg@latest (importmap+CSS link); the SPA ships the pinned vendored dist — two intentional load strategies.**
@@ -0,0 +1,37 @@
1
+ # PUNCHLIST — 15th design-maturity + hardening sweep (2026-06-18)
2
+
3
+ Two tracking Workflows (Workflow tool):
4
+ - `gui-design-15` (`wf_7ba315a1-9a9`): 8 lenses, 59 agents, 42 findings -> 35 confirmed -> 33 applied in kit + 2 deferred-then-implemented.
5
+ - `gui-design-15b` (`wf_5b2861b2-717`): 3 lenses (history-settings/server-boundary/security), 25 agents, 22 findings -> 18 confirmed, all landed.
6
+
7
+ ## Kit design (../design) — all confirmed findings landed
8
+ - composer-input: cwd-input focus-visible/aria-invalid, `.send.cancel` neutral tone, radius/border tokens, error state, composer-btn size de-dup.
9
+ - chat-thread: code-card chrome, flat-user inline code, structured-turn band suppression, ToolCallNode copy buttons.
10
+ - files: filtered-empty keeps controls mounted, perm-tag chip, folder-open empty icon, emptyAction CTA, skeleton 12 rows, roving-radiogroup keyboard nav, icon-led density picker (3 new icons).
11
+ - sessions: shape-distinct status discs (live/error/connecting/stale), breakdown disc channel, canonical `--stale`, cost-separator emphasis, is-new.is-error compound.
12
+ - shell: collapsed-track resizer hide, coarse 44px toggle floor, WS_RESIZE_CLAMP ceilings raised, pane-toggle relocated into crumb.
13
+ - tokens-theme: auto-dark `--danger` parity, `--ink-3-dark`/`--paper-3-dark` anchors, paper-island `--warn`/`--sky`, print `--paper`/`--ink`, `--on-color` fills, health-chip/cwd-bar tokens.
14
+ - a11y-motion: `.ds-seg-btn` + editor-primitives `:focus-visible`, 4 reduced-motion opt-in guards.
15
+ - history-settings: NEW `ShortcutList` export, Row `detail` prop + `.ds-row-detail`, `.empty-state--inline`, `.panel-body` owl rhythm.
16
+
17
+ ## App (agentgui) wiring
18
+ - app.js: ShortcutList in overlay + keyboard panel, search highlight, empty-state--inline x3, expanded-event detail prop, smart-quote -> ASCII.
19
+
20
+ ## Dead code removed
21
+ - static/ (webjsx.js, xstate.umd.min.js), scripts/build-rippleui.mjs, scripts/copy-vendor.js; copy-vendor dropped from postinstall.
22
+
23
+ ## Server hardening (lib/)
24
+ - terminal RCE fail-closed guard (HTTP + WS) + drop client shell/env.
25
+ - /api/file+/api/list root confinement gated on PASSWORD/FS_ALLOW_CWD + secret-file block + TEXT_EXTS trim.
26
+ - CSRF: JSON-only + Origin==Host (dropped octet-stream/empty-CT bypass).
27
+ - constant-time SHA-256 token compare (http-handler + ws-setup).
28
+ - chat.sendMessage cwd confined via confineToRoots realPath.
29
+ - cookie Secure (conditional) + Max-Age; rate-limit TRUST_PROXY + failed-auth penalty.
30
+ - health endpoint omits fs paths under health-exemption.
31
+ - CSP script-src nonce (unsafe-inline dropped; importmap nonced).
32
+ - agents.models wired through getModelsForAgent.
33
+
34
+ ## Witness
35
+ - browser-4 (localhost:3009/gm/?token): 0 console errors, dark kit surface, 3 resizers, no h-scroll, ShortcutList 11 rows in overlay+settings, CSP nonce did not break boot.
36
+ - validate-mutations.mjs 26/26 PASS on live hardened server.
37
+ - kit build 4 lints pass; test.js 10 pass/0 fail.
@@ -46,7 +46,7 @@ export function warmAssetCache(staticDir) {
46
46
  if (count > 0) console.log(`[CACHE] Pre-warmed ${count} static assets`);
47
47
  }
48
48
 
49
- export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding, watch, BASE_URL, PKG_VERSION }) {
49
+ export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding, watch, BASE_URL, PKG_VERSION, cspNonce }) {
50
50
  const ext = path.extname(filePath).toLowerCase();
51
51
  const contentType = MIME_TYPES[ext] || 'application/octet-stream';
52
52
 
@@ -82,7 +82,11 @@ export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding
82
82
  fs.stat(filePath, (err, stats) => {
83
83
  if (err) { res.writeHead(500); res.end('Server error'); return; }
84
84
  const etag = generateETag(stats);
85
- if (!watch && htmlState.cache && htmlState.etag === etag) {
85
+ // The HTML carries a per-request CSP nonce, so the response body differs on
86
+ // every request - the shared htmlState cache cannot be reused (it would
87
+ // serve a stale nonce that fails the CSP). Only cache when there is no
88
+ // nonce.
89
+ if (!cspNonce && !watch && htmlState.cache && htmlState.etag === etag) {
86
90
  res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-store', 'Content-Encoding': 'gzip', 'Content-Length': htmlState.cache.length });
87
91
  res.end(htmlState.cache);
88
92
  return;
@@ -90,16 +94,24 @@ export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding
90
94
  fs.readFile(filePath, (err2, data) => {
91
95
  if (err2) { res.writeHead(500); res.end('Server error'); return; }
92
96
  let content = data.toString();
97
+ const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
93
98
  const wsToken = process.env.PASSWORD ? `window.__WS_TOKEN='${process.env.PASSWORD.replace(/'/g, "\\'")}';` : '';
94
- const baseTag = `<script>window.__BASE_URL='${BASE_URL}';window.__SERVER_VERSION='${PKG_VERSION}';${wsToken}</script>`;
99
+ const baseTag = `<script${nonceAttr}>window.__BASE_URL='${BASE_URL}';window.__SERVER_VERSION='${PKG_VERSION}';${wsToken}</script>`;
95
100
  content = content.replace('<head>', `<head>\n <base href="${BASE_URL}/">\n ` + baseTag);
96
101
  content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
97
102
  content = content.replace(/(src)="\/gm\/js\//g, `$1="${BASE_URL}/js/`);
103
+ // The static inline <script type="importmap"> and the module entry script
104
+ // are inline/self scripts that MUST carry the nonce or the app never boots
105
+ // under the nonce'd CSP. Add nonce to every inline <script> that lacks one.
106
+ if (cspNonce) {
107
+ content = content.replace(/<script type="importmap">/g, `<script type="importmap"${nonceAttr}>`);
108
+ content = content.replace(/<script type="module"/g, `<script type="module"${nonceAttr}`);
109
+ }
98
110
  if (watch) {
99
- content += `\n<script>(function(){const tok=window.__WS_TOKEN?'?token='+encodeURIComponent(window.__WS_TOKEN):'';const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'${BASE_URL}/hot-reload'+tok);ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
111
+ content += `\n<script${nonceAttr}>(function(){const tok=window.__WS_TOKEN?'?token='+encodeURIComponent(window.__WS_TOKEN):'';const ws=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'${BASE_URL}/hot-reload'+tok);ws.onmessage=e=>{if(JSON.parse(e.data).type==='reload')location.reload()};})();</script>`;
100
112
  }
101
113
  compressAndSend(req, res, 200, contentType, content);
102
- if (!watch && acceptsEncoding(req, 'gzip')) {
114
+ if (!cspNonce && !watch && acceptsEncoding(req, 'gzip')) {
103
115
  htmlState.cache = zlib.gzipSync(Buffer.from(content), { level: 6 });
104
116
  htmlState.etag = etag;
105
117
  }
@@ -14,7 +14,7 @@ import * as term from './terminal.js';
14
14
  // Returns { ok, realPath, reason }. realPath is the symlink-resolved absolute
15
15
  // path to stat/read; callers use it, never the raw input. A non-existent path
16
16
  // has no realpath yet, so it fails closed with reason 'not found'.
17
- function confineToRoots(inputPath, allowRoots) {
17
+ export function confineToRoots(inputPath, allowRoots) {
18
18
  const isWindows = os.platform() === 'win32';
19
19
  const norms = allowRoots.map(r => path.normalize(r));
20
20
  const expanded = inputPath && inputPath.startsWith('~') ? inputPath.replace('~', os.homedir()) : inputPath;
@@ -40,12 +40,18 @@ function confineToRoots(inputPath, allowRoots) {
40
40
  // The allowlist the Files surface operates within: server cwd + Claude
41
41
  // projects dir, widened via FS_ROOTS (path-separated). One construction so
42
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(),
43
+ export function fsAllowRoots() {
44
+ const roots = [
46
45
  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));
46
+ ];
47
+ // The server cwd is only exposed when PASSWORD is set (the witnessed
48
+ // localhost-PASSWORD deploy lists the repo tree) or FS_ALLOW_CWD=1 is opted
49
+ // in. An open no-PASSWORD deploy must NOT expose the whole server tree.
50
+ if (process.env.PASSWORD || process.env.FS_ALLOW_CWD === '1') {
51
+ roots.push(process.env.STARTUP_CWD || process.cwd());
52
+ }
53
+ if (process.env.FS_ROOTS) roots.push(...process.env.FS_ROOTS.split(path.delimiter));
54
+ return roots.map(r => path.normalize(r));
49
55
  }
50
56
 
51
57
  // A new file/dir name must be a single path component: no separators, no
@@ -111,11 +117,15 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
111
117
  res.setHeader('Referrer-Policy', 'no-referrer');
112
118
  // CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
113
119
  // from unpkg/jsdelivr; everything else is self. connect-src also allows
114
- // self for the same-origin WS/SSE. 'unsafe-inline' on style/script is
115
- // required by the inlined head bootstrap + DS runtime <style> injection.
120
+ // self for the same-origin WS/SSE. script-src drops 'unsafe-inline' in
121
+ // favour of a per-request nonce threaded onto every server-injected and
122
+ // static inline <script> (bootstrap, hot-reload, importmap) by the asset
123
+ // server. style-src keeps 'unsafe-inline' because the DS injects a runtime
124
+ // <style> with no hook to nonce.
125
+ const cspNonce = crypto.randomBytes(16).toString('base64');
116
126
  res.setHeader('Content-Security-Policy', [
117
127
  "default-src 'self'",
118
- "script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net",
128
+ `script-src 'self' 'nonce-${cspNonce}' https://unpkg.com https://cdn.jsdelivr.net`,
119
129
  "style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://fonts.googleapis.com",
120
130
  "img-src 'self' data: blob:",
121
131
  "font-src 'self' data: https://unpkg.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
@@ -127,7 +137,17 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
127
137
  if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
128
138
  if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
129
139
 
130
- const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
140
+ // Only honour X-Forwarded-For behind a trusted proxy (TRUST_PROXY=1), and
141
+ // then take the RIGHT-MOST hop (the one the trusted proxy itself observed,
142
+ // not a client-spoofed left-most value). Otherwise the socket peer is the
143
+ // only trustworthy source - a client can forge the header freely.
144
+ let clientIp;
145
+ if (process.env.TRUST_PROXY === '1' && req.headers['x-forwarded-for']) {
146
+ const hops = req.headers['x-forwarded-for'].split(',').map(s => s.trim()).filter(Boolean);
147
+ clientIp = hops[hops.length - 1] || req.socket.remoteAddress;
148
+ } else {
149
+ clientIp = req.socket.remoteAddress;
150
+ }
131
151
  const hits = (rateLimitMap.get(clientIp) || 0) + 1;
132
152
  rateLimitMap.set(clientIp, hits);
133
153
  res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
@@ -142,9 +162,14 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
142
162
  if (_pwd && !_healthExempt) {
143
163
  const _auth = req.headers['authorization'] || '';
144
164
  let _ok = false;
165
+ // Constant-time compare over fixed-width SHA-256 digests so neither the
166
+ // password length nor a byte-position mismatch leaks via timing.
145
167
  const _checkToken = (tok) => {
146
- try { return tok.length === _pwd.length && crypto.timingSafeEqual(Buffer.from(tok), Buffer.from(_pwd)); }
147
- catch { return false; }
168
+ try {
169
+ const a = crypto.createHash('sha256').update(String(tok)).digest();
170
+ const b = crypto.createHash('sha256').update(String(_pwd)).digest();
171
+ return crypto.timingSafeEqual(a, b);
172
+ } catch { return false; }
148
173
  };
149
174
  if (_auth.startsWith('Basic ')) {
150
175
  try {
@@ -178,14 +203,35 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
178
203
  } catch (_) {}
179
204
  } else if (_viaQuery || _auth) {
180
205
  try {
181
- res.setHeader('Set-Cookie', 'agentgui_token=' + encodeURIComponent(_pwd) + '; Path=/; HttpOnly; SameSite=Lax');
206
+ // Secure only over a real TLS connection (or behind an https proxy /
207
+ // explicit opt-in) so the localhost-http witness still gets a usable
208
+ // cookie. Max-Age bounds the credential's lifetime to a day.
209
+ const _https = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' || process.env.COOKIE_SECURE === '1';
210
+ res.setHeader('Set-Cookie', 'agentgui_token=' + encodeURIComponent(_pwd) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400' + (_https ? '; Secure' : ''));
182
211
  } catch (_) {}
183
212
  }
184
- if (!_ok) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return; }
213
+ if (!_ok) {
214
+ // Penalize failed auth heavily in the rate bucket so a credential
215
+ // brute-force trips the 429 limiter long before it can guess.
216
+ rateLimitMap.set(clientIp, (rateLimitMap.get(clientIp) || 0) + 100);
217
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return;
218
+ }
185
219
  }
186
220
 
187
221
  const pathOnly = req.url.split('?')[0];
188
222
 
223
+ // Terminal RCE guard: the terminal surface spawns an interactive shell, so
224
+ // it is fail-closed by default. It is reachable ONLY when PASSWORD is set
225
+ // AND ENABLE_TERMINAL=1 is explicitly opted in. (Under the localhost-PASSWORD
226
+ // witness ENABLE_TERMINAL is unset, so the terminal is correctly disabled -
227
+ // it is not part of the witness.)
228
+ if (pathOnly.startsWith('/api/terminal/') || pathOnly.startsWith(BASE_URL + '/api/terminal/')) {
229
+ if (!process.env.PASSWORD || process.env.ENABLE_TERMINAL !== '1') {
230
+ sendJSON(req, res, 403, { error: 'terminal disabled (requires PASSWORD and ENABLE_TERMINAL=1)' });
231
+ return;
232
+ }
233
+ }
234
+
189
235
  // CSRF guard on every state-changing method. Without PASSWORD the server
190
236
  // is open and advertises a wildcard ACAO, so a cross-site page could POST
191
237
  // a form at the mutation routes (rename/delete/mkdir/upload) on localhost.
@@ -198,9 +244,22 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
198
244
  const sfs = req.headers['sec-fetch-site'];
199
245
  const ct = (req.headers['content-type'] || '').toLowerCase();
200
246
  const sameSite = !sfs || sfs === 'same-origin' || sfs === 'none';
201
- const jsonBody = ct.startsWith('application/json') || ct.startsWith('application/octet-stream') || ct === '';
247
+ // Only application/json counts as a non-cross-site body now: a simple
248
+ // cross-site <form> can send only urlencoded/multipart/text-plain, and
249
+ // octet-stream / empty CT were too broad an escape (a no-CORS fetch can
250
+ // send octet-stream). The binary upload PUT still rides the same-origin
251
+ // SPA fetch (Sec-Fetch-Site: same-origin), so it passes via sameSite.
252
+ const jsonBody = ct.startsWith('application/json');
202
253
  const authed = !!req.headers['authorization'];
203
- if (!sameSite && !jsonBody && !authed) {
254
+ // If an Origin header is present, its host MUST match the Host header -
255
+ // a cross-origin page's Origin will not, regardless of Sec-Fetch-Site.
256
+ let originOk = true;
257
+ const origin = req.headers['origin'];
258
+ if (origin) {
259
+ try { originOk = new URL(origin).host === req.headers['host']; }
260
+ catch { originOk = false; }
261
+ }
262
+ if (!originOk || (!sameSite && !jsonBody && !authed)) {
204
263
  res.writeHead(403, { 'Content-Type': 'application/json' });
205
264
  res.end(JSON.stringify({ error: 'cross-site request rejected' }));
206
265
  return;
@@ -238,14 +297,19 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
238
297
  try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
239
298
  const queueSizes = {};
240
299
  for (const [k, v] of messageQueues) queueSizes[k] = v.length;
241
- sendJSON(req, res, 200, {
300
+ const _body = {
242
301
  status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
243
302
  activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
244
- memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes,
245
- // Read-only server facts for the settings server-info panel.
246
- projectsDir: process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
247
- allowRoots: fsAllowRoots(),
248
- });
303
+ acp: getACPStatus(), db: dbStatus, queueSizes,
304
+ };
305
+ // Host-internal facts (memory, filesystem paths) are exposed only to an
306
+ // authenticated caller, never on the unauthenticated health-probe bypass.
307
+ if (!_healthExempt) {
308
+ _body.memory = process.memoryUsage();
309
+ _body.projectsDir = process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
310
+ _body.allowRoots = fsAllowRoots();
311
+ }
312
+ sendJSON(req, res, 200, _body);
249
313
  return;
250
314
  }
251
315
 
@@ -274,7 +338,9 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
274
338
  if (pathOnly === '/api/terminal/sessions' && req.method === 'POST') {
275
339
  let body = ''; for await (const c of req) body += c;
276
340
  let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
277
- const s = term.createSession({ shell: p.shell, cwd: p.cwd, cols: p.cols, rows: p.rows, env: p.env });
341
+ // Never honour a client-supplied shell or env - that would be a direct
342
+ // command/arg injection into the spawned process. Only geometry + cwd.
343
+ const s = term.createSession({ cwd: p.cwd, cols: p.cols, rows: p.rows });
278
344
  sendJSON(req, res, 200, { sid: s.sid, kind: s.kind, shell: s.shell, cwd: s.cwd, cols: s.cols, rows: s.rows, pid: s.proc.pid });
279
345
  return;
280
346
  }
@@ -379,11 +445,18 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
379
445
  const conf = confineToRoots(decodedPath, allowRoots);
380
446
  if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
381
447
  const normalizedPath = conf.realPath;
448
+ // Block secret-bearing files regardless of root: dotfiles, env/key/cert
449
+ // material, and credential stores must never be readable through the
450
+ // Files preview even when they sit inside an allowed root.
451
+ const base = path.basename(normalizedPath);
452
+ const SECRET_RE = /(^\.|\.(env|pem|key|crt|p12|pfx)$|secret|credential|\.npmrc$|\.netrc$)/i;
453
+ if (SECRET_RE.test(base)) { res.writeHead(403); res.end('Forbidden'); return; }
382
454
  // Only known text/code extensions (images go through /api/image). An
383
455
  // unknown/binary extension is rejected, never served as octet-stream.
456
+ // env/conf/cfg/ini are dropped - they commonly carry secrets.
384
457
  const TEXT_EXTS = new Set([
385
458
  'js','mjs','cjs','ts','tsx','jsx','rs','go','py','rb','java','c','cpp','h','hpp','cs','php','sh','css','html','json','yml','yaml','toml','sql',
386
- 'txt','md','log','csv','env','xml','ini','conf','cfg','gitignore','dockerfile','svg',
459
+ 'txt','md','log','csv','xml','gitignore','dockerfile','svg',
387
460
  ]);
388
461
  const ext = path.extname(normalizedPath).slice(1).toLowerCase()
389
462
  || path.basename(normalizedPath).toLowerCase();
@@ -610,7 +683,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
610
683
  return;
611
684
  }
612
685
 
613
- if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req); return; }
686
+ if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req, cspNonce); return; }
614
687
 
615
688
  const routePathBare = routePath.split('?')[0];
616
689
  let filePath = routePathBare === '/' ? '/index.html' : routePathBare;
@@ -622,8 +695,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
622
695
  if (err) { res.writeHead(404); res.end('Not found'); return; }
623
696
  if (stats.isDirectory()) {
624
697
  filePath = path.join(filePath, 'index.html');
625
- fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req); });
626
- } else { serveFile(filePath, res, req); }
698
+ fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req, cspNonce); });
699
+ } else { serveFile(filePath, res, req, cspNonce); }
627
700
  });
628
701
  } catch (e) {
629
702
  console.error('Server error:', e.message);
@@ -5,6 +5,7 @@ import crypto from 'crypto';
5
5
  import { execSync, spawnSync } from 'child_process';
6
6
  import { runClaudeWithStreaming } from './claude-runner-run.js';
7
7
  import { registry } from './claude-runner-agents.js';
8
+ import { confineToRoots, fsAllowRoots } from './http-handler.js';
8
9
 
9
10
  function err(code, message) { const e = new Error(message); e.code = code; throw e; }
10
11
 
@@ -16,7 +17,7 @@ const SUB_AGENT_MAP = {
16
17
  };
17
18
 
18
19
  export function register(router, deps) {
19
- const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats } = deps;
20
+ const { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats, getModelsForAgent } = deps;
20
21
 
21
22
  // Short-lived per-session terminal-event buffer (finding 35): a turn that
22
23
  // completes/errors/cancels while a client ws is down would otherwise be a
@@ -47,7 +48,7 @@ export function register(router, deps) {
47
48
  });
48
49
 
49
50
  // --- agents.models: model choices for a given agent ---
50
- router.handle('agents.models', (p) => {
51
+ router.handle('agents.models', async (p) => {
51
52
  const id = p?.id || p?.agentId;
52
53
  if (!id) err(400, 'agent id required');
53
54
  if (id === 'claude-code') {
@@ -57,7 +58,16 @@ export function register(router, deps) {
57
58
  { id: 'haiku', name: 'Claude Haiku (latest)' },
58
59
  ] };
59
60
  }
60
- // Other agents pick their model via their own config/login; none surfaced here.
61
+ // Other agents: discover their models via getModelsForAgent (queries the
62
+ // running ACP server). Fail closed to an empty list on any error or when
63
+ // the dep isn't a function.
64
+ if (typeof getModelsForAgent === 'function') {
65
+ try {
66
+ const raw = await getModelsForAgent(id);
67
+ const list = Array.isArray(raw) ? raw : (raw?.models || []);
68
+ return { models: list.map(m => ({ id: m.id, name: m.name || m.label || m.id })) };
69
+ } catch { return { models: [] }; }
70
+ }
61
71
  return { models: [] };
62
72
  });
63
73
 
@@ -88,13 +98,16 @@ export function register(router, deps) {
88
98
  const cwd = p?.cwd || STARTUP_CWD;
89
99
  const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
90
100
  if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
91
- // A client-supplied cwd that doesn't exist would have the agent CLI spawn
92
- // fail opaquely (or inherit STARTUP_CWD on some runners). Validate up front
93
- // so the caller gets a clear 400 instead of a confusing mid-stream error.
101
+ // A client-supplied cwd must be confined to the SAME allowlist as the Files
102
+ // routes (fsAllowRoots) - an unconfined cwd would let a client spawn the
103
+ // agent CLI anywhere on disk. Use the realpath-resolved value as the spawn
104
+ // cwd (defeats symlink escape, same as the HTTP file routes). No p.cwd ->
105
+ // STARTUP_CWD, which is itself an allowed root, so it needs no check.
106
+ let spawnCwd = STARTUP_CWD;
94
107
  if (p?.cwd) {
95
- let st = null;
96
- try { st = fs.statSync(cwd); } catch (_) {}
97
- if (!st || !st.isDirectory()) err(400, `cwd is not an existing directory: ${cwd}`);
108
+ const conf = confineToRoots(cwd, fsAllowRoots());
109
+ if (!conf.ok) err(conf.reason === 'not found' ? 400 : 403, `cwd outside allowed roots: ${cwd}`);
110
+ spawnCwd = conf.realPath;
98
111
  }
99
112
 
100
113
  const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
@@ -104,7 +117,7 @@ export function register(router, deps) {
104
117
  ws.subscriptions = ws.subscriptions || new Set();
105
118
  ws.subscriptions.add(sessionId);
106
119
 
107
- const ctrl = { aborted: false, proc: null, agentId, model, cwd, startedAt: Date.now() };
120
+ const ctrl = { aborted: false, proc: null, agentId, model, cwd: spawnCwd, startedAt: Date.now() };
108
121
  activeChats.set(sessionId, ctrl);
109
122
  // Push-driven hint so clients refresh active-session state without
110
123
  // waiting for the 3s chat.active poll (finding 48).
@@ -147,7 +160,7 @@ export function register(router, deps) {
147
160
  model, subAgent, onEvent, resumeSessionId,
148
161
  onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
149
162
  };
150
- await runClaudeWithStreaming(content, cwd, agentId, config);
163
+ await runClaudeWithStreaming(content, spawnCwd, agentId, config);
151
164
  if (!ctrl.aborted) {
152
165
  const ev = { type: 'streaming_complete', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, eventCount, timestamp: Date.now() };
153
166
  recordTerminal(sessionId, ev);
package/lib/ws-setup.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import crypto from 'crypto';
3
4
  import { WebSocketServer } from 'ws';
4
5
  import * as term from './terminal.js';
5
6
  // Legacy WS handlers removed; no-op shim kept for callsite compatibility.
@@ -16,7 +17,15 @@ export function createWsSetup(server, { BASE_URL, watch, staticDir, _assetCache,
16
17
  if (_pwd) {
17
18
  const url = new URL(req.url, 'http://localhost');
18
19
  const token = url.searchParams.get('token');
19
- if (token !== _pwd) { ws.close(4001, 'Unauthorized'); return; }
20
+ // Constant-time compare (fixed-width SHA-256 digests) - matches the HTTP
21
+ // auth path; a raw `!==` leaks the token length/prefix via timing.
22
+ let _ok = false;
23
+ try {
24
+ const a = crypto.createHash('sha256').update(String(token)).digest();
25
+ const b = crypto.createHash('sha256').update(String(_pwd)).digest();
26
+ _ok = crypto.timingSafeEqual(a, b);
27
+ } catch { _ok = false; }
28
+ if (!_ok) { ws.close(4001, 'Unauthorized'); return; }
20
29
  }
21
30
  const wsPath = req.url.split('?')[0];
22
31
  const wsRoute = wsPath.startsWith(BASE_URL) ? wsPath.slice(BASE_URL.length) : wsPath;
@@ -25,7 +34,11 @@ export function createWsSetup(server, { BASE_URL, watch, staticDir, _assetCache,
25
34
  ws.on('close', () => { const i = hotReloadClients.indexOf(ws); if (i > -1) hotReloadClients.splice(i, 1); });
26
35
  } else if (wsRoute.startsWith('/api/terminal/sessions/')) {
27
36
  // Terminal session WS - auth was already enforced by wss-level PASSWORD
28
- // check at the top of this connection callback. attach to existing session.
37
+ // check at the top of this connection callback. The terminal surface
38
+ // spawns an interactive shell, so gate it fail-closed with the SAME guard
39
+ // as the HTTP terminal routes (http-handler.js): refuse unless PASSWORD is
40
+ // set AND ENABLE_TERMINAL=1 is explicitly opted in.
41
+ if (!process.env.PASSWORD || process.env.ENABLE_TERMINAL !== '1') { ws.close(4403, 'terminal-disabled'); return; }
29
42
  const m = wsRoute.match(/^\/api\/terminal\/sessions\/([0-9a-f]+)$/);
30
43
  if (!m) { ws.close(4400, 'bad-terminal-path'); return; }
31
44
  term.attachWs(m[1], ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.971",
3
+ "version": "1.0.972",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -18,7 +18,7 @@
18
18
  "scripts": {
19
19
  "start": "bun server.js || node server.js",
20
20
  "dev": "node server.js --watch",
21
- "postinstall": "node scripts/patch-fsbrowse.js && node scripts/copy-vendor.js && (cd node_modules/better-sqlite3 && node-gyp rebuild 2>/dev/null) || true",
21
+ "postinstall": "node scripts/patch-fsbrowse.js && (cd node_modules/better-sqlite3 && node-gyp rebuild 2>/dev/null) || true",
22
22
  "electron": "electron electron/main.js",
23
23
  "electron:dev": "PORT=3000 electron electron/main.js"
24
24
  },
package/server.js CHANGED
@@ -120,7 +120,7 @@ const _rateLimitMap = new LRUCache({ max: 1000, ttl: 60000 });
120
120
  const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '3000', 10);
121
121
 
122
122
  const _assetDeps = { compressAndSend, acceptsEncoding, watch, BASE_URL, PKG_VERSION };
123
- function serveFile(filePath, res, req) { return _serveFile(filePath, res, req, _assetDeps); }
123
+ function serveFile(filePath, res, req, cspNonce) { return _serveFile(filePath, res, req, { ..._assetDeps, cspNonce }); }
124
124
 
125
125
  const _routes = {};
126
126
  const server = http.createServer(createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss: () => wss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap: _rateLimitMap, routes: _routes, PORT }));
@@ -155,7 +155,7 @@ const { processMessageWithStreaming } = createProcessMessage({
155
155
  const activeChats = new Map();
156
156
  const wsRouter = new WsRouter();
157
157
  createRegistry(wsRouter, { queries, sendJSON, parseBody, broadcastSync, debugLog, PORT, BASE_URL, rootDir, STARTUP_CWD, PKG_VERSION, processMessageWithStreaming, activeExecutions, activeProcessesByRunId, activeScripts, messageQueues, rateLimitState, cleanupExecution, discoveredAgents, getACPStatus, modelCache, getModelsForAgent, logError, syncClients, wsOptimizer, errLogPath, getJsonlWatcher: () => getJsonlWatcher(), routes: _routes });
158
- registerWsHandlers(wsRouter, { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats });
158
+ registerWsHandlers(wsRouter, { queries, wsOptimizer, broadcastSync, getProviderConfigs, saveProviderConfig, STARTUP_CWD, discoveredAgents, subscriptionIndex, activeChats, getModelsForAgent });
159
159
 
160
160
 
161
161
  const { wss, hotReloadClients } = createWsSetup(server, {
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar, Checkbox } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, SearchInput, TextField, Select, Btn, Icon, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar, Checkbox, ShortcutList } = C;
7
7
 
8
8
  // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
9
  // formatters (exported alongside the components), fall back to the local
@@ -564,7 +564,7 @@ function view() {
564
564
 
565
565
  const shortcutsHint = state.showShortcuts
566
566
  ? Alert({ key: 'sc', kind: 'info', title: 'Keyboard shortcuts',
567
- children: SHORTCUTS.map(s => s.keys + ' - ' + s.desc).join(' · ') })
567
+ children: ShortcutList({ shortcuts: SHORTCUTS }) })
568
568
  : null;
569
569
  const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, ...mainContent()].filter(Boolean));
570
570
 
@@ -2570,7 +2570,8 @@ function historyMain() {
2570
2570
  label: 'copy', title: 'copy event',
2571
2571
  onClick: () => copyText(full || raw || ('(' + type + ')'), 'event copied'),
2572
2572
  }] : undefined,
2573
- title: expanded ? (full || '(' + type + ')') : (collapsedTitle || '(' + type + ')'),
2573
+ title: expanded ? (text || '(' + type + ')') : (collapsedTitle || '(' + type + ')'),
2574
+ detail: expanded && e.toolInput ? JSON.stringify(e.toolInput, null, 2) : undefined,
2574
2575
  // Guard ts: a missing/zero timestamp renders "Invalid Date" otherwise.
2575
2576
  // Every row is click-to-expand, so always show the affordance word
2576
2577
  // (not only when text overflows 220 chars).
@@ -2710,6 +2711,7 @@ function historySide() {
2710
2711
  key: 'sr-' + (r.sid || '?') + '-' + i,
2711
2712
  rank: String(i + 1).padStart(3, '0'),
2712
2713
  title: r.snippet || '(no snippet)',
2714
+ highlight: state.searchQ || undefined,
2713
2715
  sub: (projectLabel(r.project) || '?') + ' · ' + (r.role || '?') + (r.tool ? ' · ' + r.tool : '') + (r.ts ? ' · ' + fmtRelTime(r.ts) : ''),
2714
2716
  // Rail carries the same semantics as session rows: error > subagent > normal.
2715
2717
  rail: r.isError ? 'flame' : (r.isSubagent ? 'purple' : 'green'),
@@ -2752,16 +2754,16 @@ function historySide() {
2752
2754
  onInput: (v) => { state.searchQ = v; debouncedSearch(); },
2753
2755
  }),
2754
2756
  state.searchBusy
2755
- ? h('div', { key: 'searchbusy', class: 'lede empty-state', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
2757
+ ? h('div', { key: 'searchbusy', class: 'lede empty-state empty-state--inline', role: 'status' }, Spinner({ key: 'ss', size: 'sm' }), 'searching…')
2756
2758
  : null,
2757
2759
  searching && state.searchHits.error
2758
2760
  ? Alert({ key: 'searcherr', kind: 'error', title: 'Search failed', children: state.searchHits.error })
2759
2761
  : null,
2760
2762
  searching && !state.searchBusy && !state.searchHits.error && (state.searchHits.results || []).length === 0
2761
- ? h('p', { key: 'nomatch', class: 'lede empty-state' }, 'no matches for "' + state.searchQ + '"')
2763
+ ? h('p', { key: 'nomatch', class: 'lede empty-state empty-state--inline' }, 'no matches for "' + state.searchQ + '"')
2762
2764
  : null,
2763
2765
  state.searchQ.trim().length === 1
2764
- ? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
2766
+ ? h('p', { key: 'min2', class: 'lede empty-state empty-state--inline' }, 'type at least 2 characters to search')
2765
2767
  : null,
2766
2768
  state.searchQ
2767
2769
  ? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; writeHash(); render(); }, children: 'clear search' })
@@ -2961,8 +2963,7 @@ function keyboardPanel() {
2961
2963
  return Panel({
2962
2964
  id: 'keyboard',
2963
2965
  title: 'keyboard',
2964
- children: SHORTCUTS.map((s, i) =>
2965
- h('div', { key: 'kb' + i, class: 'lede' }, s.keys + ' - ' + s.desc)),
2966
+ children: ShortcutList({ shortcuts: SHORTCUTS }),
2966
2967
  });
2967
2968
  }
2968
2969
 
@@ -3008,7 +3009,7 @@ function preferencesPanel() {
3008
3009
  onClick: savedChat ? () => downloadBlob(savedChat, 'agentgui-chat-' + dateStamp() + '.json', 'application/json') : undefined,
3009
3010
  children: savedChat ? 'export chat' : 'no saved chat' })),
3010
3011
  h('div', { key: 'cwdnote', class: 'lede agentgui-field-my' },
3011
- 'working directory: set per-chat in the chat composers cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
3012
+ 'working directory: set per-chat in the chat composer\'s cwd bar' + (state.chatCwd ? ' (current: ' + state.chatCwd + ')' : ' (currently: server default)')),
3012
3013
  state.confirmingClearData
3013
3014
  ? Alert({ key: 'cld', kind: 'warn', title: 'Clear all local data?',
3014
3015
  children: [