agentgui 1.0.970 → 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 +18 -0
- package/PUNCHLIST-DESIGN-14.md +43 -0
- package/PUNCHLIST-DESIGN-15.md +37 -0
- package/lib/asset-server.js +17 -5
- package/lib/http-handler.js +100 -27
- package/lib/ws-handlers-util.js +24 -11
- package/lib/ws-setup.js +15 -2
- package/package.json +2 -2
- package/server.js +2 -2
- package/site/app/js/app.js +10 -9
- package/site/app/vendor/anentrypoint-design/247420.css +316 -103
- package/site/app/vendor/anentrypoint-design/247420.js +13 -13
- package/site/theme.mjs +19 -15
- package/scripts/build-rippleui.mjs +0 -84
- package/scripts/copy-vendor.js +0 -50
- package/static/lib/webjsx.js +0 -700
- package/static/lib/xstate.umd.min.js +0 -2
package/AGENTS.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
15
|
+
## Design-maturity sweep + marketing-site consolidation (2026-06-18) — fourteenth run
|
|
16
|
+
|
|
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.**
|
|
18
|
+
|
|
19
|
+
**Workflow `gui-design-14` (via the Workflow tool, run `wf_50751bd9-a43`, 7 lenses: chat-thread/files/sessions/shell-chrome/tokens-theme/a11y-motion/marketing-residue): 46 agents -> 39 findings -> 28 adversarially confirmed** (`PUNCHLIST-DESIGN-14.md`; 2 lens connection-failures: tokens-theme hunt + marketing-residue verify). ALL landed in the kit. **chat:** ThinkingNode base CSS (`.chat-thinking`/`-text`/`-dots` had ZERO base rules outside `.agentchat-working`), markdown `table`/`th`/`td`/`hr` (were unstyled -> bare cells), dead `.chat-tick` inline-code class given a rule, composer toolbar unified on 32px/`--r-1` (was 40px-round vs 36px-square), flat `.chat-md` rhythm+inset, streaming `.chat-stream-pre` persistent chrome, tool/code `:has()` measure-breakout. **files:** FileSkeleton container was a single 48px shimmer bar collapsing all rows (now inner `.ds-skel`-only), filter folded into one `.ds-file-controls` toolbar row, per-type cell icon tints, dead `data-columns` card-mode removed (+ `FILE_GRID_MIN_COL`/`columns` param), `.ds-file-check` dead font/color decls dropped + `.ds-check-box` rest border -> `--fg-3`. **sessions:** stale disc own tone (was sharing `--amber` with connecting) + connecting now a hollow ring, dead duplicate `.ds-dash-status.is-running` removed + running unified on `--accent` across disc/breakdown/card, `.seg.is-idle` weight 600, reduced-motion live-disc static-ring fallback, mixed-tick thickened 1.5px->2px, card cost as emphasized `.ds-dash-stat-cost`, conv-list unread -> hollow ring (shape-distinct from running). **shell:** resizers hidden past their staging breakpoints (`@media` 1480/1100/900 — were dead handles dragging vars into a fixed drawer), coarse-pointer 16px hit target, `WS_RESIZE_CLAMP` reconciled to the CSS `clamp()` floors/ceilings, localStorage committed on pointerup (not per-move), separator `aria-valuemin/max/now`. **a11y:** voice `vx-*` added to `community.css` reduced-motion guard, status discs given non-colour SHAPE channels, `.ds-input-bare` `:focus`->`:focus-visible`+ring. **Status-disc shape rule:** live=solid+pulse(or static ring under reduced-motion), error=solid+halo, connecting=hollow ring, stale=muted solid — four channels, not colour-only. Witnessed live (localhost:3009/gm/?token, PASSWORD=`123,slam,123,slam`): 0 console/page errors, dark body surface painted by kit, 3 resizers, no h-scroll, migrated classes resolve. Kit pushed `3704876`; rebased onto a concurrent CI `v0.0.211` bump.
|
|
20
|
+
|
|
3
21
|
## Design-content consolidation — ALL design lives in the kit now (2026-06-18) — thirteenth run
|
|
4
22
|
|
|
5
23
|
The mandate: every design decision must live in the kit (`../design` = `/config/workspace/design`), none in the agentgui app. **`site/app/index.html` no longer carries an inline `<style>` block** — the ~240-line block (28 classes: `.pill`/`.cwd-bar`/`.resume-banner`/`.health-chip`/`.settings-grid`/`.history-empty`/`.boot-splash`/`.chat-controls`/`.status-dot`/`.ds-event-list` overrides + `event-flash` keyframe + scrollbar theming + focus rings + responsive + print) moved to a NEW kit file **`../design/app-surfaces.css`** (wired into `scripts/build.mjs` CSS_FILES, scoped `.ds-247420`, reads kit tokens directly with NO `--agentgui-*` fallbacks — those local color vars are deleted). `app.js` carries **zero inline `style=` props** (the 6 margin literals -> `.agentgui-field-mb`/`.agentgui-field-my` kit utilities; the `mainStyle` layout string -> `.agentgui-main`/`.agentgui-main-chat` kit rules). **Rule going forward: no design content in agentgui — new surface styling is a kit CSS rule, never an inline `<style>` or `style=`.** `app-surfaces.css` selectors are written pre-scoped `.ds-247420 ...` so the build's selector-prefixer (handles `:root`/`html`/`body`, compounds `[attr]`/`*`) leaves them untouched. Because `247420.css` is a render-blocking `<link>` in head and `<html class="ds-247420">` is static, there is NO FOUC from moving the base/boot-splash styling into the kit.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# PUNCHLIST-DESIGN-14 (14th run — design-maturity sweep)
|
|
2
|
+
|
|
3
|
+
Workflow `gui-design-14` (run wf_50751bd9-a43): 46 agents, 39 findings, 28 adversarially confirmed. All fixes land in the kit (`/config/workspace/design`), never the agentgui app. ASCII-only; kept typography (middot/ellipsis/dash) preserved.
|
|
4
|
+
|
|
5
|
+
## chat (chat.css / app-shell.css / src/components/chat.js)
|
|
6
|
+
1. [high] ThinkingNode `.chat-thinking/.chat-thinking-text/.chat-thinking-dots` have ZERO base CSS (only `.agentchat-working .chat-thinking-dots` exists) -> add base thinking rules to chat.css reusing `agentchat-dot-bounce` + reduced-motion guard.
|
|
7
|
+
2. [high] `.chat-md table/th/td/hr` unstyled -> markdown tables collapse to bare cells, `---` vanishes. Add to the `.chat-bubble.chat-md` block in app-shell.css.
|
|
8
|
+
3. [med] `.chat-tick` (inline backtick code, chat.js:47) is a dead class -> add a real rule in chat.css.
|
|
9
|
+
4. [med] Composer toolbar geometry mismatch: 40px round `.composer-btn` vs 36px square `.send` -> unify on 32px/`--r-1` in chat.css (later source order wins).
|
|
10
|
+
5. [med] Flat-layout `.chat-msg-flat .chat-md` cramped against role label + flush `.them` tint -> add flat rhythm/inset in chat.css.
|
|
11
|
+
6. [low] Streaming `.chat-stream-pre` drops the lang tab + copy chrome the settled block gets -> add minimal persistent chrome.
|
|
12
|
+
7. [low] Tool-card pre overflows inside the centered `--measure` column -> let tool/code break out wider in flat layout.
|
|
13
|
+
|
|
14
|
+
## files (app-shell.css / src/components/files.js)
|
|
15
|
+
8. [high] `FileSkeleton` markup/CSS disagree: `.ds-file-skeleton` container styled as a single 48px shimmer bar collapsing all rows + double-shimmer -> drop the container height/gradient/anim block + dead `.ds-file-grid-loading`.
|
|
16
|
+
9. [med] filter + sort/select-all/density are two stacked strips with conflicting alignment -> fold filter into one `.ds-file-controls` row.
|
|
17
|
+
10. [med] thumbnail tiles: no per-type icon tint on cells + 4:3 box wastes space for non-image -> add `.ds-file-cell[data-file-type]` tints, denser non-image media.
|
|
18
|
+
11. [med] `data-columns` card-mode is a dead half-wired third layout (flex rows in a grid) not exposed by density -> remove the dead path.
|
|
19
|
+
12. [low] `.ds-file-check` carries dead font/color decls (bracket text gone) + at-rest box too faint -> drop dead decls, strengthen `.ds-check-box` rest border.
|
|
20
|
+
|
|
21
|
+
## sessions (chat.css / src/components/sessions.js)
|
|
22
|
+
13. [med] stale and connecting discs share `var(--amber)` + neither animates -> give stale its own tone/ring.
|
|
23
|
+
14. [med] duplicate `.ds-dash-status.is-running` (green@400 then accent@475) -> delete dead rule, unify running tone across disc/breakdown/card.
|
|
24
|
+
15. [low] `.seg.is-idle` lighter weight than running/error siblings -> add `font-weight:600`.
|
|
25
|
+
16. [low] heartbeat live disc loses its cue under reduced-motion -> static concentric ring fallback.
|
|
26
|
+
17. [low] select-all mixed tick is a 1.5px sub-pixel bar -> thicken to ~2px crisp.
|
|
27
|
+
18. [med] card `.ds-dash-stat` crams elapsed/counter/tok/cost into one mono middot run -> emphasize cost, dim unit suffixes.
|
|
28
|
+
19. [low] conv-list running vs unread are same-color same-shape discs -> differentiate by shape/tone.
|
|
29
|
+
|
|
30
|
+
## shell (app-shell.css / src/components/shell.js)
|
|
31
|
+
20. [high] resizers render past their breakpoints (dead handles dragging vars that no longer affect a fixed drawer) -> media `display:none` matching the 1480/1100/900 staging.
|
|
32
|
+
21. [med] JS `WS_RESIZE_CLAMP` bounds fall below/above the CSS `clamp()` floors/ceilings -> derive from CSS clamp min/max.
|
|
33
|
+
22. [med] resize writes localStorage every pointermove + separator lacks `aria-valuenow/min/max` -> commit on pointerup, add aria-value*.
|
|
34
|
+
23. [low] resizer 8px hit target, no coarse-pointer floor -> `@media (pointer:coarse)` widen/hide.
|
|
35
|
+
|
|
36
|
+
## a11y / motion
|
|
37
|
+
24. [low] voice `vx-*` surfaces omitted from community.css reduced-motion block (infinite `vx-pulse`) -> add to the guard list.
|
|
38
|
+
25. [med] status discs differentiate by COLOR only (no shape channel like the rail tones) -> add non-color channel + disambiguate stale/connecting (overlaps 13).
|
|
39
|
+
26. [low] `.ds-input-bare:focus` (not `:focus-visible`) kills outline -> switch + `--focus-ring-inset`.
|
|
40
|
+
27. [low] conv-list indicators color-only for sighted users (overlaps 19).
|
|
41
|
+
|
|
42
|
+
## marketing site (site/theme.mjs -> kit)
|
|
43
|
+
28. [high] theme.mjs saturated with design content: inline `<style>`+hex, ~12 inline `style=`, deprecated `Btn({primary})`, `↗` glyphs -> new kit `marketing.css` `.site-*` family + base-surface rule + render-blocking CSS link; theme.mjs carries zero design CSS.
|
|
@@ -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.
|
package/lib/asset-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/http-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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'
|
|
115
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
147
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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','
|
|
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);
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -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
|
|
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
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 &&
|
|
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, {
|