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 +12 -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 +185 -73
- package/site/app/vendor/anentrypoint-design/247420.js +13 -13
- 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,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.
|
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, {
|
package/site/app/js/app.js
CHANGED
|
@@ -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:
|
|
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 ? (
|
|
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:
|
|
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 composer
|
|
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: [
|