agentgui 1.0.984 → 1.0.986
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 +5 -9
- package/lib/asset-server.js +1 -1
- package/lib/claude-runner-run.js +0 -1
- package/lib/http-handler.js +112 -27
- package/lib/plugins/acp-plugin.js +27 -6
- package/lib/plugins/files-plugin.js +43 -12
- package/lib/plugins/workflow-plugin.js +20 -2
- package/lib/ws-handlers-util.js +7 -0
- package/package.json +1 -1
- package/site/app/index.html +0 -1
- package/site/app/js/app.js +191 -142
- package/site/app/js/backend.js +52 -6
- package/site/app/vendor/anentrypoint-design/247420.css +19 -0
- package/site/app/vendor/anentrypoint-design/247420.js +14 -14
package/AGENTS.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# AgentGUI — Agent Notes
|
|
2
2
|
|
|
3
|
+
## GUI quality sweep (2026-06-19) — nineteenth run
|
|
4
|
+
|
|
5
|
+
132-agent workflow wf_8183560b-e3d (12 lenses, 93 confirmed). Kit: thinking settled state, retry on non-last message, AT aria fixes (followup chips live guard, cwd focus restore, skip-link target, sessions toggle chevron, shell print styles, a11y-01 index.html), composer disabled visual state, files-modals focus trap re-query + stable aria-labelledby, sessions.js listbox+aria-selected. App: resume-transcript-load (loadResumeTranscript historical messages + spinner), ACP force-restart (unhealthy agent restart btn + WS handler), history tool_use rail=purple, shortcuts overlay role=dialog, streaming-active badge on chat rail tab, sortedAgents memoized, files filter 150ms debounce, _seen Set capped 5000, humanizeMs->fmtDuration, dead historySide() removed, pathBasename util, ARM_RESET_MS constant, refreshHistory guard, perf-002/006/007/008 memoization+cleanup, live-tokens accumulated, backend-change-mid-chat guard, transcript loading state, session-expiry onSessionExpired hook, server-500 stream error sanitized. Server: IPv4-mapped IPv6 normalization in rate-limit, image route streaming (no sync read), isWindows module-level, getAvailableAgents export removed, files-plugin+workflow-plugin confinement, acp-plugin shell injection fix, plugin-routes CSRF fix, asset-server JS injection fix. Full detail in rs-learn (recall "agentgui 19th run").
|
|
6
|
+
|
|
3
7
|
## GUI quality sweep (2026-06-19) — eighteenth run
|
|
4
8
|
|
|
5
9
|
53-agent audit wf_7cd243d2-753 (29 confirmed). Kit: showWorkingTail status=running (was backwards), focus ring 2px+offset, aria-expanded dynamic on sessions toggle, DOMPurify version-pinned, FORCE_BODY, 360px button fill, cwd-btn 44px coarse. App: thinking branch in sendChat loop, saveBackend deep health probe, aria-live guard, O(1) SSE dedupe Set, sessionDuration reduce, tool_use rail flame (purple=subagent rule), ACP vocabulary connected/connecting/disconnected + unhealthy rail flame, skip-link. Server: CORS credentials guard, token masking in logs. Also: history (result)/(tool call) labels, multi-turn preamble for non-resume agents. Deferred: resume-transcript (#3), ACP force-restart (#4). Full detail in rs-learn (recall "agentgui 18th run").
|
|
@@ -14,15 +18,7 @@ Two-pass 48-agent audit (wf_bcac251f-d54, 25 confirmed). Critical: absIdx Refere
|
|
|
14
18
|
|
|
15
19
|
## Design-maturity sweep + dead-code + server-hardening (2026-06-18) — fifteenth run
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
**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`.
|
|
20
|
-
|
|
21
|
-
**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.
|
|
22
|
-
|
|
23
|
-
**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.
|
|
24
|
-
|
|
25
|
-
**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/`.
|
|
21
|
+
Two Workflows: gui-design-15 (wf_7ba315a1-9a9, 8 lenses, ~95 agents total) + gui-design-15b (wf_5b2861b2-717, 3 lenses). ShortcutList + Row.detail kit exports; static/ dead-code removed; terminal RCE fix; CSP nonce; constant-time token compare. Full detail in rs-learn (recall "agentgui 15th run").
|
|
26
22
|
|
|
27
23
|
## Design-maturity sweep + marketing-site consolidation (2026-06-18) — fourteenth run
|
|
28
24
|
|
package/lib/asset-server.js
CHANGED
|
@@ -95,7 +95,7 @@ export function serveFile(filePath, res, req, { compressAndSend, acceptsEncoding
|
|
|
95
95
|
if (err2) { res.writeHead(500); res.end('Server error'); return; }
|
|
96
96
|
let content = data.toString();
|
|
97
97
|
const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
|
|
98
|
-
const wsToken = process.env.PASSWORD ?
|
|
98
|
+
const wsToken = process.env.PASSWORD ? 'window.__WS_TOKEN=' + JSON.stringify(process.env.PASSWORD) + ';' : '';
|
|
99
99
|
const baseTag = `<script${nonceAttr}>window.__BASE_URL='${BASE_URL}';window.__SERVER_VERSION='${PKG_VERSION}';${wsToken}</script>`;
|
|
100
100
|
content = content.replace('<head>', `<head>\n <base href="${BASE_URL}/">\n ` + baseTag);
|
|
101
101
|
content = content.replace(/(href|src)="vendor\//g, `$1="${BASE_URL}/vendor/`);
|
package/lib/claude-runner-run.js
CHANGED
|
@@ -44,6 +44,5 @@ export async function runClaudeWithStreaming(prompt, cwd, agentId = 'claude-code
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function getRegisteredAgents() { return registry.list(); }
|
|
47
|
-
export function getAvailableAgents() { return registry.listACPAvailable(); }
|
|
48
47
|
export function isAgentRegistered(agentId) { return registry.has(agentId); }
|
|
49
48
|
export default runClaudeWithStreaming;
|
package/lib/http-handler.js
CHANGED
|
@@ -21,16 +21,18 @@ export function maskToken(url) {
|
|
|
21
21
|
return url.replace(/([?&]token=)[^&]*/gi, '$1***');
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Module-level platform constant — never changes at runtime.
|
|
25
|
+
const IS_WINDOWS = os.platform() === 'win32';
|
|
26
|
+
|
|
24
27
|
export function confineToRoots(inputPath, allowRoots) {
|
|
25
|
-
const isWindows = os.platform() === 'win32';
|
|
26
28
|
const norms = allowRoots.map(r => path.normalize(r));
|
|
27
29
|
const expanded = inputPath && inputPath.startsWith('~') ? inputPath.replace('~', os.homedir()) : inputPath;
|
|
28
30
|
const normalizedPath = path.normalize(expanded || '');
|
|
29
|
-
const isAbsolute =
|
|
31
|
+
const isAbsolute = IS_WINDOWS ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
|
|
30
32
|
const within = (p) => {
|
|
31
|
-
const np =
|
|
33
|
+
const np = IS_WINDOWS ? p.toLowerCase() : p;
|
|
32
34
|
return norms.some(root => {
|
|
33
|
-
const r =
|
|
35
|
+
const r = IS_WINDOWS ? root.toLowerCase() : root;
|
|
34
36
|
return np === r || np.startsWith(r + path.sep);
|
|
35
37
|
});
|
|
36
38
|
};
|
|
@@ -82,6 +84,26 @@ function sanitizeEntryName(name) {
|
|
|
82
84
|
return n;
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// Map a Node.js filesystem error code to a safe human-readable string that
|
|
88
|
+
// does not disclose host paths or internal stack context. Used everywhere an
|
|
89
|
+
// err.message would otherwise be returned to the client.
|
|
90
|
+
function safeErrMsg(err) {
|
|
91
|
+
if (!err) return 'unknown error';
|
|
92
|
+
switch (err.code) {
|
|
93
|
+
case 'ENOENT': return 'file not found';
|
|
94
|
+
case 'EACCES': return 'permission denied';
|
|
95
|
+
case 'EPERM': return 'operation not permitted';
|
|
96
|
+
case 'EISDIR': return 'path is a directory';
|
|
97
|
+
case 'ENOTDIR': return 'path is not a directory';
|
|
98
|
+
case 'ENOTEMPTY': return 'directory is not empty';
|
|
99
|
+
case 'EEXIST': return 'file already exists';
|
|
100
|
+
case 'EXDEV': return 'cannot move across drives';
|
|
101
|
+
case 'EMFILE': return 'too many open files';
|
|
102
|
+
case 'ENOSPC': return 'no space left on device';
|
|
103
|
+
default: return 'operation failed';
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
85
107
|
// Read a request body with a hard size cap; resolves a Buffer or rejects with
|
|
86
108
|
// .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
|
|
87
109
|
function readBody(req, maxBytes) {
|
|
@@ -101,12 +123,20 @@ function readBody(req, maxBytes) {
|
|
|
101
123
|
// themselves are never mutation targets (rename/delete of a root would orphan
|
|
102
124
|
// the whole surface).
|
|
103
125
|
function isAllowRoot(realPath, allowRoots) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
return allowRoots.some(r => (isWindows ? r.toLowerCase() : r) === p);
|
|
126
|
+
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
127
|
+
return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
131
|
+
// Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
|
|
132
|
+
// cross-origin page can make credentialless fetch() calls to all /api/*
|
|
133
|
+
// endpoints (list, file, download, rename, delete, mkdir) and read or
|
|
134
|
+
// modify the filesystem. The existing code is structurally correct (no
|
|
135
|
+
// Access-Control-Allow-Credentials with wildcard), but the exposure is
|
|
136
|
+
// easy to miss.
|
|
137
|
+
if (process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD) {
|
|
138
|
+
console.warn('[agentgui] WARNING: CORS_ORIGIN=* is set without PASSWORD. Any cross-origin page can make credentialless requests to all /api/* endpoints and read/modify the filesystem. Set PASSWORD or restrict CORS_ORIGIN to a specific origin.');
|
|
139
|
+
}
|
|
110
140
|
return async function httpHandler(req, res) {
|
|
111
141
|
// CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
|
|
112
142
|
// let any webpage the user visits make credentialless fetches to /api/list,
|
|
@@ -117,17 +147,29 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
117
147
|
// Never set a wildcard when credentials (cookies) may be in play — a
|
|
118
148
|
// wildcard + credentials is rejected by browsers and leaks session tokens.
|
|
119
149
|
// A specific origin allows credentialed cross-origin requests safely.
|
|
120
|
-
res.setHeader('Access-Control-Allow-Origin', _corsOrigin
|
|
150
|
+
res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
|
|
121
151
|
if (_corsOrigin !== '*') res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
122
152
|
res.setHeader('Vary', 'Origin');
|
|
153
|
+
// Allow-Methods and Allow-Headers are only meaningful on CORS preflights
|
|
154
|
+
// or CORS requests; emitting them unconditionally leaks the method surface
|
|
155
|
+
// to same-origin (and non-CORS) responses unnecessarily.
|
|
156
|
+
if (req.method === 'OPTIONS' || req.headers['origin']) {
|
|
157
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
158
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
159
|
+
}
|
|
123
160
|
} // no ACAO header -> browsers enforce same-origin by default
|
|
124
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
125
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
126
161
|
// The password can ride in a ?token= query param (EventSource/deep-links
|
|
127
162
|
// can't set headers), so a navigation away from the app would leak it in
|
|
128
163
|
// the Referer header to the destination. Strip the referrer on every
|
|
129
164
|
// response so the credential never crosses an origin boundary that way.
|
|
130
165
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
166
|
+
// Prevent MIME-sniffing attacks on all responses including file downloads.
|
|
167
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
168
|
+
// Emit HSTS when the connection is known-TLS so clients pin HTTPS and
|
|
169
|
+
// refuse future downgrade attempts (protects cookie + ?token= in transit).
|
|
170
|
+
if (req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https') {
|
|
171
|
+
res.setHeader('Strict-Transport-Security', 'max-age=31536000');
|
|
172
|
+
}
|
|
131
173
|
// CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
|
|
132
174
|
// from unpkg/jsdelivr; everything else is self. connect-src also allows
|
|
133
175
|
// self for the same-origin WS/SSE. script-src drops 'unsafe-inline' in
|
|
@@ -161,6 +203,9 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
161
203
|
} else {
|
|
162
204
|
clientIp = req.socket.remoteAddress;
|
|
163
205
|
}
|
|
206
|
+
// Normalize IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 -> 1.2.3.4) so the
|
|
207
|
+
// rate-limit bucket is the same regardless of how the OS presents the peer.
|
|
208
|
+
if (clientIp && clientIp.startsWith('::ffff:')) clientIp = clientIp.slice(7);
|
|
164
209
|
const hits = (rateLimitMap.get(clientIp) || 0) + 1;
|
|
165
210
|
rateLimitMap.set(clientIp, hits);
|
|
166
211
|
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
|
|
@@ -338,7 +383,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
338
383
|
const st = fs.statSync(conf.realPath);
|
|
339
384
|
sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
|
|
340
385
|
} catch (err) {
|
|
341
|
-
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: err
|
|
386
|
+
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: safeErrMsg(err) });
|
|
342
387
|
}
|
|
343
388
|
return;
|
|
344
389
|
}
|
|
@@ -353,7 +398,13 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
353
398
|
let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
|
|
354
399
|
// Never honour a client-supplied shell or env - that would be a direct
|
|
355
400
|
// command/arg injection into the spawned process. Only geometry + cwd.
|
|
356
|
-
|
|
401
|
+
// Confine cwd to allowed roots; fall back to STARTUP_CWD on failure.
|
|
402
|
+
let termCwd = process.env.STARTUP_CWD || process.cwd();
|
|
403
|
+
if (p.cwd) {
|
|
404
|
+
const cwdConf = confineToRoots(p.cwd, fsAllowRoots());
|
|
405
|
+
if (cwdConf.ok) termCwd = cwdConf.realPath;
|
|
406
|
+
}
|
|
407
|
+
const s = term.createSession({ cwd: termCwd, cols: p.cols, rows: p.rows });
|
|
357
408
|
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 });
|
|
358
409
|
return;
|
|
359
410
|
}
|
|
@@ -440,7 +491,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
440
491
|
sendJSON(req, res, 200, { path: normalizedPath, segments, entries, roots: allowRoots });
|
|
441
492
|
} catch (err) {
|
|
442
493
|
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
443
|
-
sendJSON(req, res, code, { error: err
|
|
494
|
+
sendJSON(req, res, code, { error: safeErrMsg(err) });
|
|
444
495
|
}
|
|
445
496
|
return;
|
|
446
497
|
}
|
|
@@ -490,7 +541,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
490
541
|
res.end(buf);
|
|
491
542
|
} catch (err) {
|
|
492
543
|
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
493
|
-
res.writeHead(code); res.end(err
|
|
544
|
+
res.writeHead(code); res.end(safeErrMsg(err));
|
|
494
545
|
}
|
|
495
546
|
return;
|
|
496
547
|
}
|
|
@@ -521,10 +572,12 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
521
572
|
'Content-Length': String(st.size),
|
|
522
573
|
'Cache-Control': 'no-cache',
|
|
523
574
|
});
|
|
524
|
-
fs.createReadStream(normalizedPath)
|
|
575
|
+
const rs = fs.createReadStream(normalizedPath);
|
|
576
|
+
rs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
577
|
+
rs.pipe(res);
|
|
525
578
|
} catch (err) {
|
|
526
579
|
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
527
|
-
res.writeHead(code); res.end(err
|
|
580
|
+
res.writeHead(code); res.end(safeErrMsg(err));
|
|
528
581
|
}
|
|
529
582
|
return;
|
|
530
583
|
}
|
|
@@ -557,7 +610,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
557
610
|
if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
|
|
558
611
|
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
559
612
|
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
560
|
-
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err
|
|
613
|
+
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: safeErrMsg(err) }); }
|
|
561
614
|
return;
|
|
562
615
|
}
|
|
563
616
|
|
|
@@ -662,11 +715,37 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
662
715
|
if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
663
716
|
const target = path.join(conf.realPath, name);
|
|
664
717
|
if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
try {
|
|
669
|
-
|
|
718
|
+
// Stream the upload body to a temp file to keep memory constant and
|
|
719
|
+
// avoid blocking the event loop with a large synchronous writeFileSync.
|
|
720
|
+
const tmpPath = target + '.tmp.' + crypto.randomBytes(6).toString('hex');
|
|
721
|
+
try {
|
|
722
|
+
await new Promise((resolve, reject) => {
|
|
723
|
+
const ws = fs.createWriteStream(tmpPath);
|
|
724
|
+
let total = 0;
|
|
725
|
+
const MAX_UPLOAD = 50 * 1024 * 1024;
|
|
726
|
+
ws.on('error', reject);
|
|
727
|
+
req.on('error', reject);
|
|
728
|
+
req.on('data', (chunk) => {
|
|
729
|
+
total += chunk.length;
|
|
730
|
+
if (total > MAX_UPLOAD) {
|
|
731
|
+
ws.destroy();
|
|
732
|
+
req.destroy();
|
|
733
|
+
const e = new Error('file too large (50MB cap)'); e.code = 'TOO_LARGE';
|
|
734
|
+
reject(e); return;
|
|
735
|
+
}
|
|
736
|
+
ws.write(chunk);
|
|
737
|
+
});
|
|
738
|
+
req.on('end', () => ws.end());
|
|
739
|
+
ws.on('finish', () => resolve(total));
|
|
740
|
+
});
|
|
741
|
+
fs.renameSync(tmpPath, target);
|
|
742
|
+
const uploadedSize = fs.statSync(target).size;
|
|
743
|
+
sendJSON(req, res, 200, { ok: true, path: target, size: uploadedSize });
|
|
744
|
+
} catch (err) {
|
|
745
|
+
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
746
|
+
if (err.code === 'TOO_LARGE') { sendJSON(req, res, 413, { error: 'file too large (50MB cap)' }); return; }
|
|
747
|
+
sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: 'upload failed' });
|
|
748
|
+
}
|
|
670
749
|
return;
|
|
671
750
|
}
|
|
672
751
|
|
|
@@ -698,9 +777,15 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
698
777
|
// Files preview uses /api/file/download (attachment) for SVG.
|
|
699
778
|
const contentType = mimeTypes[ext];
|
|
700
779
|
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
780
|
+
const imgSt = fs.statSync(normalizedPath);
|
|
781
|
+
const IMG_MAX = 20 * 1024 * 1024; // 20MB hard cap
|
|
782
|
+
if (imgSt.size > IMG_MAX) { res.writeHead(413); res.end('Image too large'); return; }
|
|
783
|
+
// Always stream to avoid blocking the event loop on large synchronous reads.
|
|
784
|
+
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'Content-Length': String(imgSt.size) });
|
|
785
|
+
const imgRs = fs.createReadStream(normalizedPath);
|
|
786
|
+
imgRs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
787
|
+
imgRs.pipe(res);
|
|
788
|
+
} catch (err) { sendJSON(req, res, 400, { error: 'cannot read image' }); }
|
|
704
789
|
return;
|
|
705
790
|
}
|
|
706
791
|
|
|
@@ -720,8 +805,8 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
720
805
|
} else { serveFile(filePath, res, req, cspNonce); }
|
|
721
806
|
});
|
|
722
807
|
} catch (e) {
|
|
723
|
-
console.error('Server error:', maskToken(e.message), '| path:', maskToken(req.url.split('?')[0]));
|
|
724
|
-
sendJSON(req, res, 500, { error:
|
|
808
|
+
console.error('Server error:', maskToken(e.message), e.stack, '| path:', maskToken(req.url.split('?')[0]));
|
|
809
|
+
sendJSON(req, res, 500, { error: 'internal server error' });
|
|
725
810
|
}
|
|
726
811
|
};
|
|
727
812
|
}
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
// ACP plugin - OpenCode, Gemini, Kilo, Codex startup and health checks
|
|
2
2
|
|
|
3
|
-
import { spawn } from 'child_process';
|
|
3
|
+
import { spawn, execFileSync } from 'child_process';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
|
|
7
|
+
// Resolve a binary name to an absolute path (mirrors lib/claude-runner-direct.js pattern).
|
|
8
|
+
// Returns the resolved path, or null if not found.
|
|
9
|
+
function resolveBinaryPath(name) {
|
|
10
|
+
try {
|
|
11
|
+
const which = process.platform === 'win32' ? 'where' : 'which';
|
|
12
|
+
const result = execFileSync(which, [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
13
|
+
const first = result.trim().split(/\r?\n/)[0];
|
|
14
|
+
return first || null;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
export default {
|
|
8
21
|
name: 'acp',
|
|
9
22
|
version: '1.0.0',
|
|
@@ -15,16 +28,24 @@ export default {
|
|
|
15
28
|
const restartCounts = new Map();
|
|
16
29
|
const acpPorts = new Map();
|
|
17
30
|
|
|
31
|
+
// Each spec uses argv (binary + args) rather than a shell command string so
|
|
32
|
+
// spawn runs with shell:false — no shell injection risk even if port/name
|
|
33
|
+
// values are later sourced from config. Mirrors lib/claude-runner-direct.js.
|
|
18
34
|
const toolSpecs = [
|
|
19
|
-
{ name: 'opencode', port: 18100,
|
|
20
|
-
{ name: 'gemini',
|
|
21
|
-
{ name: 'kilo',
|
|
22
|
-
{ name: 'codex',
|
|
35
|
+
{ name: 'opencode', port: 18100, argv: ['opencode', 'acp', '--port', '18100'] },
|
|
36
|
+
{ name: 'gemini', port: 18101, argv: ['gemini', 'acp', '--port', '18101'] },
|
|
37
|
+
{ name: 'kilo', port: 18102, argv: ['kilo', 'acp', '--port', '18102'] },
|
|
38
|
+
{ name: 'codex', port: 18103, argv: ['codex', 'acp', '--port', '18103'] },
|
|
23
39
|
];
|
|
24
40
|
|
|
25
41
|
const startTool = async (spec) => {
|
|
26
42
|
try {
|
|
27
|
-
const
|
|
43
|
+
const [bin, ...args] = spec.argv;
|
|
44
|
+
// Resolve to an absolute path so spawn never relies on PATH expansion
|
|
45
|
+
// inside a shell; shell:false is the default for spawn() when called
|
|
46
|
+
// this way, matching the project-wide rule in AGENTS.md.
|
|
47
|
+
const resolvedBin = resolveBinaryPath(bin) || bin;
|
|
48
|
+
const proc = spawn(resolvedBin, args, { shell: false });
|
|
28
49
|
toolProcesses.set(spec.name, proc);
|
|
29
50
|
acpPorts.set(spec.name, spec.port);
|
|
30
51
|
restartCounts.set(spec.name, 0);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
+
import { confineToRoots, fsAllowRoots } from '../http-handler.js';
|
|
5
6
|
|
|
6
7
|
export default {
|
|
7
8
|
name: 'files',
|
|
@@ -13,18 +14,24 @@ export default {
|
|
|
13
14
|
const uploadedFiles = new Map();
|
|
14
15
|
|
|
15
16
|
const browseDirectory = (dir) => {
|
|
17
|
+
const conf = confineToRoots(dir, fsAllowRoots());
|
|
18
|
+
if (!conf.ok) return [];
|
|
16
19
|
try {
|
|
17
|
-
const entries = fs.readdirSync(
|
|
20
|
+
const entries = fs.readdirSync(conf.realPath);
|
|
18
21
|
return entries.map(entry => {
|
|
19
|
-
const fullPath = path.join(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
const fullPath = path.join(conf.realPath, entry);
|
|
23
|
+
try {
|
|
24
|
+
const stat = fs.statSync(fullPath);
|
|
25
|
+
return {
|
|
26
|
+
name: entry,
|
|
27
|
+
path: fullPath,
|
|
28
|
+
isDirectory: stat.isDirectory(),
|
|
29
|
+
size: stat.size,
|
|
30
|
+
};
|
|
31
|
+
} catch (_) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}).filter(Boolean);
|
|
28
35
|
} catch (e) {
|
|
29
36
|
return [];
|
|
30
37
|
}
|
|
@@ -38,8 +45,13 @@ export default {
|
|
|
38
45
|
handler: (req, res) => {
|
|
39
46
|
const { conversationId } = req.params;
|
|
40
47
|
const { dir } = req.query;
|
|
41
|
-
const
|
|
42
|
-
|
|
48
|
+
const requestedDir = dir || process.cwd();
|
|
49
|
+
const conf = confineToRoots(requestedDir, fsAllowRoots());
|
|
50
|
+
if (!conf.ok) {
|
|
51
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
52
|
+
}
|
|
53
|
+
const entries = browseDirectory(conf.realPath);
|
|
54
|
+
res.json({ entries, currentDir: conf.realPath });
|
|
43
55
|
},
|
|
44
56
|
},
|
|
45
57
|
{
|
|
@@ -56,6 +68,25 @@ export default {
|
|
|
56
68
|
path: '/api/folders',
|
|
57
69
|
handler: async (req, res) => {
|
|
58
70
|
const { path: folderPath } = req.body;
|
|
71
|
+
if (!folderPath || typeof folderPath !== 'string') {
|
|
72
|
+
return res.status(400).json({ error: 'path is required' });
|
|
73
|
+
}
|
|
74
|
+
// Sanitize each path component - reject traversal attempts
|
|
75
|
+
const parts = folderPath.split(/[/\\]/);
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
if (part === '..' || part === '.' || /[<>:"|?*\x00-\x1f]/.test(part)) {
|
|
78
|
+
return res.status(400).json({ error: 'Invalid path component' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const conf = confineToRoots(folderPath, fsAllowRoots());
|
|
82
|
+
if (!conf.ok && conf.reason !== 'not found') {
|
|
83
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
84
|
+
}
|
|
85
|
+
// For mkdir, path may not exist yet; re-check parent is confined
|
|
86
|
+
const parentConf = confineToRoots(path.dirname(folderPath), fsAllowRoots());
|
|
87
|
+
if (!parentConf.ok) {
|
|
88
|
+
return res.status(403).json({ error: 'Path not allowed' });
|
|
89
|
+
}
|
|
59
90
|
try {
|
|
60
91
|
fs.mkdirSync(folderPath, { recursive: true });
|
|
61
92
|
res.json({ success: true, path: folderPath });
|
|
@@ -19,11 +19,20 @@ export default {
|
|
|
19
19
|
.map(name => ({ name, path: path.join(workflowDir, name) }));
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
const resolvedWorkflowDir = path.resolve(process.cwd(), '.github', 'workflows');
|
|
23
|
+
|
|
24
|
+
const isNameSafe = (name) => !/[/\\]/.test(name);
|
|
25
|
+
|
|
22
26
|
const parseWorkflow = (filePath) => {
|
|
23
27
|
try {
|
|
24
|
-
|
|
28
|
+
// Confine to workflowDir: resolve and verify the path stays within the allowed directory
|
|
29
|
+
const resolved = path.resolve(filePath);
|
|
30
|
+
if (!resolved.startsWith(resolvedWorkflowDir + path.sep) && resolved !== resolvedWorkflowDir) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
25
34
|
// Parse YAML manually or return raw content
|
|
26
|
-
return { name: path.basename(
|
|
35
|
+
return { name: path.basename(resolved), content };
|
|
27
36
|
} catch {
|
|
28
37
|
return null;
|
|
29
38
|
}
|
|
@@ -43,6 +52,9 @@ export default {
|
|
|
43
52
|
method: 'GET',
|
|
44
53
|
path: '/api/workflows/:name/history',
|
|
45
54
|
handler: (req, res) => {
|
|
55
|
+
if (!isNameSafe(req.params.name)) {
|
|
56
|
+
return res.status(400).json({ error: 'Invalid workflow name' });
|
|
57
|
+
}
|
|
46
58
|
res.json({ history: [] });
|
|
47
59
|
},
|
|
48
60
|
},
|
|
@@ -50,6 +62,9 @@ export default {
|
|
|
50
62
|
method: 'POST',
|
|
51
63
|
path: '/api/workflows/:name/trigger',
|
|
52
64
|
handler: async (req, res) => {
|
|
65
|
+
if (!isNameSafe(req.params.name)) {
|
|
66
|
+
return res.status(400).json({ error: 'Invalid workflow name' });
|
|
67
|
+
}
|
|
53
68
|
res.json({ success: true, message: 'Workflow trigger requires GitHub API' });
|
|
54
69
|
},
|
|
55
70
|
},
|
|
@@ -57,6 +72,9 @@ export default {
|
|
|
57
72
|
method: 'GET',
|
|
58
73
|
path: '/api/workflows/:name/status',
|
|
59
74
|
handler: (req, res) => {
|
|
75
|
+
if (!isNameSafe(req.params.name)) {
|
|
76
|
+
return res.status(400).json({ error: 'Invalid workflow name' });
|
|
77
|
+
}
|
|
60
78
|
res.json({ status: 'unknown' });
|
|
61
79
|
},
|
|
62
80
|
},
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
8
|
import { confineToRoots, fsAllowRoots } from './http-handler.js';
|
|
9
|
+
import { restart as restartAcpAgent } from './acp-sdk-manager.js';
|
|
9
10
|
|
|
10
11
|
function err(code, message) { const e = new Error(message); e.code = code; throw e; }
|
|
11
12
|
|
|
@@ -297,6 +298,12 @@ export function register(router, deps) {
|
|
|
297
298
|
|
|
298
299
|
router.handle('ws.stats', () => wsOptimizer.getStats());
|
|
299
300
|
|
|
301
|
+
router.handle('acp.restart', async (p) => {
|
|
302
|
+
if (!p.id) err(400, 'Missing agent id');
|
|
303
|
+
const ok = await restartAcpAgent(p.id);
|
|
304
|
+
return { ok: !!ok };
|
|
305
|
+
});
|
|
306
|
+
|
|
300
307
|
router.handle('agent.subagents', async (p) => {
|
|
301
308
|
if (!p.id) err(400, 'Missing agent id');
|
|
302
309
|
if (p.id === 'claude-code' || p.id === 'cli-claude') {
|
package/package.json
CHANGED
package/site/app/index.html
CHANGED
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
247420.css bundle, scoped under .ds-247420. The kit owns all design. -->
|
|
31
31
|
</head>
|
|
32
32
|
<body>
|
|
33
|
-
<a href="#app" class="skip-link">Skip to main content</a>
|
|
34
33
|
<div id="app"><div class="boot-splash" role="status">loading agentgui…</div></div>
|
|
35
34
|
<script type="module" src="./js/app.js"></script>
|
|
36
35
|
</body>
|