agentgui 1.0.1117 → 1.0.1118
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/.gm/.embed-generation.code_chunks +1 -0
- package/.gm/.embed-generation.git_commit_vectors +1 -0
- package/.gm/.embed-generation.memories +1 -0
- package/.gm/.embed-generation.rssearch_vectors +1 -0
- package/.gm/prd.yml +15 -0
- package/AGENTS.md +1 -1
- package/lib/acp-sdk-manager.js +15 -2
- package/lib/claude-runner-agents.js +8 -1
- package/lib/http-handler.js +35 -566
- package/lib/http-routes/mutations.js +199 -0
- package/lib/http-routes/reads.js +212 -0
- package/lib/http-routes/shared.js +213 -0
- package/lib/ws-handlers/agents.js +137 -0
- package/lib/ws-handlers/chat.js +157 -0
- package/lib/ws-handlers/git.js +159 -0
- package/lib/ws-handlers/misc.js +36 -0
- package/lib/ws-handlers/shared.js +47 -0
- package/lib/ws-handlers/terminal-state.js +16 -0
- package/lib/ws-handlers-util.js +8 -526
- package/package.json +1 -1
- package/site/app/js/app.js +8 -103
- package/site/app/js/chat-persistence.js +123 -0
- package/site/app/vendor/anentrypoint-design/247420.css +1614 -488
- package/site/app/vendor/anentrypoint-design/247420.js +363 -77
- package/UX_OPTIMIZATION_SUMMARY.md +0 -238
- package/agentgui-after.png +0 -0
- package/agentgui-current.png +0 -0
- package/agentgui-final.png +0 -0
- package/agentgui-nobrand.png +0 -0
- package/agentgui-now.png +0 -0
- package/agentgui-v2.png +0 -0
- package/agentgui-v4.png +0 -0
- package/bash.exe.stackdump +0 -28
- package/design-reference.png +0 -0
package/lib/http-handler.js
CHANGED
|
@@ -3,215 +3,11 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import crypto from 'crypto';
|
|
5
5
|
import * as term from './terminal.js';
|
|
6
|
+
import { confineToRoots, fsAllowRoots, maskToken, SECRET_RE } from './http-routes/shared.js';
|
|
7
|
+
import { handleStat, handleList, handleFile, handleDownload, handleImage } from './http-routes/reads.js';
|
|
8
|
+
import { handleRename, handleMove, handleDelete, handleRestore, handleMkdir, handleUploadFile } from './http-routes/mutations.js';
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
// 1. normalize + resolved-prefix check on the LEXICAL path (blocks `../`
|
|
9
|
-
// traversal, which path.normalize collapses so a literal `..` test is a
|
|
10
|
-
// no-op);
|
|
11
|
-
// 2. fs.realpathSync + the SAME prefix check on the REAL path, so a symlink
|
|
12
|
-
// that lives inside an allowed root but points outside it cannot escape
|
|
13
|
-
// (the lexical path passes layer 1, the resolved target fails layer 2).
|
|
14
|
-
// Returns { ok, realPath, reason }. realPath is the symlink-resolved absolute
|
|
15
|
-
// path to stat/read; callers use it, never the raw input. A non-existent path
|
|
16
|
-
// has no realpath yet, so it fails closed with reason 'not found'.
|
|
17
|
-
// Mask ?token=VALUE in a URL string before logging so credentials never
|
|
18
|
-
// appear in server logs or error messages.
|
|
19
|
-
export function maskToken(url) {
|
|
20
|
-
if (typeof url !== 'string') return url;
|
|
21
|
-
return url.replace(/([?&]token=)[^&]*/gi, '$1***');
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// Module-level platform constant — never changes at runtime.
|
|
25
|
-
const IS_WINDOWS = os.platform() === 'win32';
|
|
26
|
-
|
|
27
|
-
export function confineToRoots(inputPath, allowRoots) {
|
|
28
|
-
const norms = allowRoots.map(r => path.normalize(r));
|
|
29
|
-
const expanded = inputPath && inputPath.startsWith('~') ? inputPath.replace('~', os.homedir()) : inputPath;
|
|
30
|
-
const normalizedPath = path.normalize(expanded || '');
|
|
31
|
-
const isAbsolute = IS_WINDOWS ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
|
|
32
|
-
const within = (p) => {
|
|
33
|
-
const np = IS_WINDOWS ? p.toLowerCase() : p;
|
|
34
|
-
return norms.some(root => {
|
|
35
|
-
const r = IS_WINDOWS ? root.toLowerCase() : root;
|
|
36
|
-
return np === r || np.startsWith(r + path.sep);
|
|
37
|
-
});
|
|
38
|
-
};
|
|
39
|
-
if (!isAbsolute || !within(normalizedPath)) return { ok: false, reason: 'path outside allowed roots' };
|
|
40
|
-
// realpath the target and re-confine, defeating symlink escape (TOCTOU/link
|
|
41
|
-
// traversal). If realpath throws (missing path / broken link) fail closed.
|
|
42
|
-
let realPath;
|
|
43
|
-
try { realPath = fs.realpathSync(normalizedPath); }
|
|
44
|
-
catch (e) { return { ok: false, reason: e && e.code === 'ENOENT' ? 'not found' : 'cannot resolve path', code: e && e.code }; }
|
|
45
|
-
if (!within(realPath)) return { ok: false, reason: 'symlink target outside allowed roots' };
|
|
46
|
-
return { ok: true, realPath };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Secret-bearing basenames that must never be readable through ANY confined
|
|
50
|
-
// raw-bytes route (preview or download), even when they sit inside an allowed
|
|
51
|
-
// root: dotfiles, env/key/cert material, and credential stores. One const so
|
|
52
|
-
// /api/file and /api/download can never drift apart.
|
|
53
|
-
export const SECRET_RE = /(^\.|\.(env|pem|key|crt|p12|pfx)$|secret|credential|\.npmrc$|\.netrc$)/i;
|
|
54
|
-
|
|
55
|
-
// The allowlist the Files surface operates within: server cwd + Claude
|
|
56
|
-
// projects dir, widened via FS_ROOTS (path-separated). One construction so
|
|
57
|
-
// /api/list,file,download and the mutation routes can never drift apart.
|
|
58
|
-
export function fsAllowRoots() {
|
|
59
|
-
const roots = [
|
|
60
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
61
|
-
];
|
|
62
|
-
// The server cwd is only exposed when PASSWORD is set (the witnessed
|
|
63
|
-
// localhost-PASSWORD deploy lists the repo tree) or FS_ALLOW_CWD=1 is opted
|
|
64
|
-
// in. An open no-PASSWORD deploy must NOT expose the whole server tree.
|
|
65
|
-
if (process.env.PASSWORD || process.env.FS_ALLOW_CWD === '1') {
|
|
66
|
-
roots.push(process.env.STARTUP_CWD || process.cwd());
|
|
67
|
-
}
|
|
68
|
-
if (process.env.FS_ROOTS) roots.push(...process.env.FS_ROOTS.split(path.delimiter));
|
|
69
|
-
return roots.map(r => path.normalize(r));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// A new file/dir name must be a single path component: no separators, no
|
|
73
|
-
// traversal, no NTFS alternate-data-stream colon, no reserved Windows device
|
|
74
|
-
// names, no trailing dot/space (Windows silently strips them, aliasing two
|
|
75
|
-
// names onto one entry). Returns the trimmed name or null when inadmissible.
|
|
76
|
-
function sanitizeEntryName(name) {
|
|
77
|
-
if (typeof name !== 'string') return null;
|
|
78
|
-
const n = name.trim();
|
|
79
|
-
if (!n || n.length > 255) return null;
|
|
80
|
-
if (/[\\/:*?"<>|\x00-\x1f]/.test(n)) return null;
|
|
81
|
-
if (n === '.' || n === '..') return null;
|
|
82
|
-
if (/[. ]$/.test(n)) return null;
|
|
83
|
-
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(n)) return null;
|
|
84
|
-
return n;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Every confined filesystem route accepts its target path via a `?path=`/
|
|
88
|
-
// `?dir=` query param, preferred over a path SEGMENT. A reverse proxy's
|
|
89
|
-
// proxy_pass URI normalization can decode-then-reencode the request path,
|
|
90
|
-
// collapsing an encoded %2F segment slash back into a literal '/' before
|
|
91
|
-
// forwarding - the app then sees extra path segments instead of one opaque
|
|
92
|
-
// one, strips what it thinks is a leading '/', and silently turns an absolute
|
|
93
|
-
// path into a relative one that fails confinement even for genuinely
|
|
94
|
-
// accessible directories/files. A query param is untouched by that
|
|
95
|
-
// normalization on any proxy in front of this app. `legacyPrefix` is the old
|
|
96
|
-
// `/api/xxx/<path>` route prefix, still accepted for any caller not yet
|
|
97
|
-
// updated to the query-param form.
|
|
98
|
-
function resolveConfinedPath(req, routePath, queryKey, legacyPrefix) {
|
|
99
|
-
const url = new URL(req.url, 'http://x');
|
|
100
|
-
const qVal = url.searchParams.get(queryKey);
|
|
101
|
-
if (qVal != null) return qVal;
|
|
102
|
-
const raw = routePath.split('?')[0].slice(legacyPrefix.length).replace(/^\//, '');
|
|
103
|
-
return raw ? decodeURIComponent(raw) : '';
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Map a Node.js filesystem error code to a safe human-readable string that
|
|
107
|
-
// does not disclose host paths or internal stack context. Used everywhere an
|
|
108
|
-
// err.message would otherwise be returned to the client.
|
|
109
|
-
function safeErrMsg(err) {
|
|
110
|
-
if (!err) return 'unknown error';
|
|
111
|
-
switch (err.code) {
|
|
112
|
-
case 'ENOENT': return 'file not found';
|
|
113
|
-
case 'EACCES': return 'permission denied';
|
|
114
|
-
case 'EPERM': return 'operation not permitted';
|
|
115
|
-
case 'EISDIR': return 'path is a directory';
|
|
116
|
-
case 'ENOTDIR': return 'path is not a directory';
|
|
117
|
-
case 'ENOTEMPTY': return 'directory is not empty';
|
|
118
|
-
case 'EEXIST': return 'file already exists';
|
|
119
|
-
case 'EXDEV': return 'cannot move across drives';
|
|
120
|
-
case 'EMFILE': return 'too many open files';
|
|
121
|
-
case 'ENOSPC': return 'no space left on device';
|
|
122
|
-
default: return 'operation failed';
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Read a request body with a hard size cap; resolves a Buffer or rejects with
|
|
127
|
-
// .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
|
|
128
|
-
function readBody(req, maxBytes) {
|
|
129
|
-
return new Promise((resolve, reject) => {
|
|
130
|
-
const chunks = []; let total = 0;
|
|
131
|
-
req.on('data', (c) => {
|
|
132
|
-
total += c.length;
|
|
133
|
-
if (total > maxBytes) { const e = new Error('body too large'); e.code = 'TOO_LARGE'; req.destroy(); reject(e); return; }
|
|
134
|
-
chunks.push(c);
|
|
135
|
-
});
|
|
136
|
-
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
137
|
-
req.on('error', reject);
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// True when the resolved path IS one of the allowlist roots - the roots
|
|
142
|
-
// themselves are never mutation targets (rename/delete of a root would orphan
|
|
143
|
-
// the whole surface).
|
|
144
|
-
function isAllowRoot(realPath, allowRoots) {
|
|
145
|
-
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
146
|
-
return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// --- Soft-delete (trash) -----------------------------------------------
|
|
150
|
-
// /api/delete moves entries into <root>/.agentgui-trash/<trashId>__<name>
|
|
151
|
-
// instead of unlinking, giving a short undo window. In-memory index only
|
|
152
|
-
// (server restart forfeits the undo window - acceptable since the retention
|
|
153
|
-
// window itself is short and this is a convenience net on top of, not a
|
|
154
|
-
// replacement for, the pre-delete ConfirmDialog). Purged after
|
|
155
|
-
// TRASH_RETENTION_MS or when trashIndex grows past TRASH_MAX_ENTRIES
|
|
156
|
-
// (oldest-first), so a long-running server's trash dir can't grow unbounded.
|
|
157
|
-
const TRASH_DIR_NAME = '.agentgui-trash';
|
|
158
|
-
const TRASH_RETENTION_MS = parseInt(process.env.AGENTGUI_TRASH_RETENTION_MS || '', 10) || 10 * 60 * 1000;
|
|
159
|
-
const TRASH_MAX_ENTRIES = 200;
|
|
160
|
-
const trashIndex = new Map(); // trashId -> { trashPath, originalPath, root, deletedAt }
|
|
161
|
-
|
|
162
|
-
function purgeExpiredTrash() {
|
|
163
|
-
const now = Date.now();
|
|
164
|
-
for (const [id, info] of trashIndex) {
|
|
165
|
-
if (now - info.deletedAt > TRASH_RETENTION_MS) {
|
|
166
|
-
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
167
|
-
trashIndex.delete(id);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (trashIndex.size > TRASH_MAX_ENTRIES) {
|
|
171
|
-
const sorted = [...trashIndex.entries()].sort((a, b) => a[1].deletedAt - b[1].deletedAt);
|
|
172
|
-
for (const [id, info] of sorted.slice(0, trashIndex.size - TRASH_MAX_ENTRIES)) {
|
|
173
|
-
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch {}
|
|
174
|
-
trashIndex.delete(id);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Which allowed root a confined realPath lives under - the trash dir sits
|
|
180
|
-
// alongside it (still inside the SAME root, so confineToRoots covers the
|
|
181
|
-
// trash path too - no new unconfined surface).
|
|
182
|
-
function rootFor(realPath, allowRoots) {
|
|
183
|
-
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
184
|
-
return allowRoots.find(r => { const rr = IS_WINDOWS ? r.toLowerCase() : r; return p === rr || p.startsWith(rr + path.sep); });
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function moveToTrash(realPath, allowRoots) {
|
|
188
|
-
purgeExpiredTrash();
|
|
189
|
-
const root = rootFor(realPath, allowRoots);
|
|
190
|
-
if (!root) { const e = new Error('not confined to an allowed root'); e.code = 'EACCES'; throw e; }
|
|
191
|
-
const trashDir = path.join(root, TRASH_DIR_NAME);
|
|
192
|
-
fs.mkdirSync(trashDir, { recursive: true });
|
|
193
|
-
const trashId = crypto.randomBytes(8).toString('hex');
|
|
194
|
-
const base = path.basename(realPath);
|
|
195
|
-
const trashPath = path.join(trashDir, trashId + '__' + base);
|
|
196
|
-
fs.renameSync(realPath, trashPath);
|
|
197
|
-
trashIndex.set(trashId, { trashPath, originalPath: realPath, root, deletedAt: Date.now() });
|
|
198
|
-
return { trashId };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function restoreFromTrash(trashId, allowRoots) {
|
|
202
|
-
const info = trashIndex.get(trashId);
|
|
203
|
-
if (!info) { const e = new Error('nothing to restore - the undo window has expired or this was already restored'); e.code = 'NOT_FOUND'; throw e; }
|
|
204
|
-
// Re-confine the ORIGINAL path at restore time (not trust the cached one
|
|
205
|
-
// blindly) - the allowlist itself doesn't change at runtime, but this keeps
|
|
206
|
-
// restore honoring the exact same confinement contract every other route does.
|
|
207
|
-
const conf = confineToRoots(info.originalPath, allowRoots);
|
|
208
|
-
if (!conf.ok && conf.reason !== 'not found') { const e = new Error('restore target is no longer inside an accessible folder'); e.code = 'CONFLICT'; throw e; }
|
|
209
|
-
if (fs.existsSync(info.originalPath)) { const e = new Error('a file already exists at the original location'); e.code = 'CONFLICT'; throw e; }
|
|
210
|
-
fs.mkdirSync(path.dirname(info.originalPath), { recursive: true });
|
|
211
|
-
fs.renameSync(info.trashPath, info.originalPath);
|
|
212
|
-
trashIndex.delete(trashId);
|
|
213
|
-
return { path: info.originalPath };
|
|
214
|
-
}
|
|
10
|
+
export { confineToRoots, fsAllowRoots, maskToken, SECRET_RE };
|
|
215
11
|
|
|
216
12
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
217
13
|
// Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
|
|
@@ -390,7 +186,12 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
390
186
|
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
|
|
391
187
|
const sfs = req.headers['sec-fetch-site'];
|
|
392
188
|
const ct = (req.headers['content-type'] || '').toLowerCase();
|
|
393
|
-
|
|
189
|
+
// A MISSING Sec-Fetch-Site header must NOT be treated as same-site -
|
|
190
|
+
// older browsers/non-browser clients omitting it would otherwise pass
|
|
191
|
+
// unconditionally regardless of true origin. Only an explicit
|
|
192
|
+
// same-origin/none value counts; a present-but-absent header falls
|
|
193
|
+
// through to the Origin-host check below.
|
|
194
|
+
const sameSite = sfs === 'same-origin' || sfs === 'none';
|
|
394
195
|
// Only application/json counts as a non-cross-site body now: a simple
|
|
395
196
|
// cross-site <form> can send only urlencoded/multipart/text-plain, and
|
|
396
197
|
// octet-stream / empty CT were too broad an escape (a no-CORS fetch can
|
|
@@ -464,19 +265,10 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
464
265
|
return;
|
|
465
266
|
}
|
|
466
267
|
|
|
467
|
-
// Existence/shape probe for a proposed chat working directory
|
|
468
|
-
//
|
|
469
|
-
// a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
|
|
268
|
+
// Existence/shape probe for a proposed chat working directory - see
|
|
269
|
+
// lib/http-routes/reads.js for the confinement contract.
|
|
470
270
|
if (routePath.startsWith('/api/stat') && req.method === 'GET') {
|
|
471
|
-
|
|
472
|
-
const conf = confineToRoots(decodedPath, fsAllowRoots());
|
|
473
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: conf.reason }); return; }
|
|
474
|
-
try {
|
|
475
|
-
const st = fs.statSync(conf.realPath);
|
|
476
|
-
sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
|
|
477
|
-
} catch (err) {
|
|
478
|
-
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: safeErrMsg(err) });
|
|
479
|
-
}
|
|
271
|
+
handleStat(req, res, routePath, sendJSON);
|
|
480
272
|
return;
|
|
481
273
|
}
|
|
482
274
|
|
|
@@ -519,392 +311,69 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
519
311
|
if (h) { await h(req, res); return; }
|
|
520
312
|
} catch (_) {}
|
|
521
313
|
}
|
|
522
|
-
// Directory listing for the Files / folder-browser view
|
|
523
|
-
// /
|
|
524
|
-
// normalize-then-prefix-check is the real guard (a `..` test after
|
|
525
|
-
// path.normalize is a no-op). Returns {path, segments, entries} so the kit
|
|
526
|
-
// FileGrid + BreadcrumbPath render directly. Allowed roots default to the
|
|
527
|
-
// server cwd + Claude projects dir; widen via FS_ROOTS (path-separated).
|
|
314
|
+
// Directory listing for the Files / folder-browser view - see
|
|
315
|
+
// lib/http-routes/reads.js for the confinement contract.
|
|
528
316
|
if (routePath.startsWith('/api/list')) {
|
|
529
|
-
|
|
530
|
-
const allowRoots = fsAllowRoots();
|
|
531
|
-
// Empty path = the first allow-root (a sane default landing dir).
|
|
532
|
-
const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
|
|
533
|
-
const conf = confineToRoots(reqPath, allowRoots);
|
|
534
|
-
if (!conf.ok) {
|
|
535
|
-
const code = conf.reason === 'not found' ? 404 : 403;
|
|
536
|
-
sendJSON(req, res, code, { error: 'forbidden: ' + conf.reason }); return;
|
|
537
|
-
}
|
|
538
|
-
// Use the symlink-resolved real path for all reads.
|
|
539
|
-
const normalizedPath = conf.realPath;
|
|
540
|
-
try {
|
|
541
|
-
const st = fs.statSync(normalizedPath);
|
|
542
|
-
if (!st.isDirectory()) { sendJSON(req, res, 400, { error: 'not a directory' }); return; }
|
|
543
|
-
// Classify by extension into the kit's data-file-type buckets so
|
|
544
|
-
// FileGrid renders the right rail/icon. dir/symlink come from stat.
|
|
545
|
-
const EXT_TYPE = {
|
|
546
|
-
image: ['png','jpg','jpeg','gif','webp','svg','bmp','ico','avif'],
|
|
547
|
-
video: ['mp4','webm','mov','mkv','avi','m4v'],
|
|
548
|
-
audio: ['mp3','wav','ogg','flac','m4a','aac'],
|
|
549
|
-
code: ['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'],
|
|
550
|
-
text: ['txt','md','log','csv','env'],
|
|
551
|
-
archive: ['zip','tar','gz','tgz','rar','7z','bz2','xz'],
|
|
552
|
-
document: ['pdf','doc','docx','xls','xlsx','ppt','pptx','odt'],
|
|
553
|
-
};
|
|
554
|
-
const typeFor = (name, dirent) => {
|
|
555
|
-
if (dirent.isSymbolicLink()) return 'symlink';
|
|
556
|
-
if (dirent.isDirectory()) return 'dir';
|
|
557
|
-
const ext = path.extname(name).slice(1).toLowerCase();
|
|
558
|
-
for (const [t, exts] of Object.entries(EXT_TYPE)) if (exts.includes(ext)) return t;
|
|
559
|
-
return 'other';
|
|
560
|
-
};
|
|
561
|
-
const dirents = fs.readdirSync(normalizedPath, { withFileTypes: true }).filter((d) => !SECRET_RE.test(d.name));
|
|
562
|
-
const entries = dirents.map((d) => {
|
|
563
|
-
const full = path.join(normalizedPath, d.name);
|
|
564
|
-
let size = null, modified = null, permissions;
|
|
565
|
-
try {
|
|
566
|
-
const s = fs.statSync(full); size = s.isDirectory() ? null : s.size; modified = s.mtime.toISOString();
|
|
567
|
-
// Per-entry permission probe (mirrors fsbrowse checkPermissions) so
|
|
568
|
-
// the row reads honestly (read-only / no access) instead of a silent
|
|
569
|
-
// size:null on a stat failure. A stat success means at least read.
|
|
570
|
-
const perms = ['read'];
|
|
571
|
-
try { fs.accessSync(full, fs.constants.W_OK); perms.push('write'); } catch (_) {}
|
|
572
|
-
permissions = perms;
|
|
573
|
-
} catch (e) {
|
|
574
|
-
// Could not stat (commonly EACCES): mark no-access so the client
|
|
575
|
-
// disables open + shows the tag, rather than failing silently.
|
|
576
|
-
permissions = e && e.code === 'EACCES' ? 'EACCES' : [];
|
|
577
|
-
}
|
|
578
|
-
return { name: d.name, type: typeFor(d.name, d), size, modified, path: full, permissions };
|
|
579
|
-
}).sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
|
|
580
|
-
// Breadcrumb segments from the absolute path (drive/root aware).
|
|
581
|
-
const segments = normalizedPath.split(/[\\\/]+/).filter(Boolean);
|
|
582
|
-
sendJSON(req, res, 200, { path: normalizedPath, segments, entries, roots: allowRoots });
|
|
583
|
-
} catch (err) {
|
|
584
|
-
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
585
|
-
sendJSON(req, res, code, { error: safeErrMsg(err) });
|
|
586
|
-
}
|
|
317
|
+
handleList(req, res, routePath, sendJSON);
|
|
587
318
|
return;
|
|
588
319
|
}
|
|
589
320
|
|
|
590
|
-
// Raw file bytes for the Files preview pane
|
|
591
|
-
// allowlist roots as /api/list (server cwd + Claude projects dir, widened
|
|
592
|
-
// via FS_ROOTS). Same normalize-then-prefix-check guard. Capped at 512KB
|
|
593
|
-
// and limited to reasonable text/code/image types so this is never a
|
|
594
|
-
// generic arbitrary-file reader. Images are served via /api/image; this
|
|
595
|
-
// returns text/* with utf-8.
|
|
321
|
+
// Raw file bytes for the Files preview pane - see lib/http-routes/reads.js.
|
|
596
322
|
if (routePath.startsWith('/api/file/') || routePath.startsWith('/api/file?')) {
|
|
597
|
-
|
|
598
|
-
const allowRoots = fsAllowRoots();
|
|
599
|
-
const conf = confineToRoots(decodedPath, allowRoots);
|
|
600
|
-
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
601
|
-
const normalizedPath = conf.realPath;
|
|
602
|
-
// Block secret-bearing files regardless of root: dotfiles, env/key/cert
|
|
603
|
-
// material, and credential stores must never be readable through the
|
|
604
|
-
// Files preview even when they sit inside an allowed root.
|
|
605
|
-
const base = path.basename(normalizedPath);
|
|
606
|
-
if (SECRET_RE.test(base)) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
607
|
-
// Only known text/code extensions (images go through /api/image). An
|
|
608
|
-
// unknown/binary extension is rejected, never served as octet-stream.
|
|
609
|
-
// env/conf/cfg/ini are dropped - they commonly carry secrets.
|
|
610
|
-
const TEXT_EXTS = new Set([
|
|
611
|
-
'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',
|
|
612
|
-
'txt','md','log','csv','xml','gitignore','dockerfile','svg',
|
|
613
|
-
]);
|
|
614
|
-
const ext = path.extname(normalizedPath).slice(1).toLowerCase()
|
|
615
|
-
|| path.basename(normalizedPath).toLowerCase();
|
|
616
|
-
if (!TEXT_EXTS.has(ext)) { res.writeHead(403); res.end('Forbidden: unsupported type'); return; }
|
|
617
|
-
try {
|
|
618
|
-
const st = fs.statSync(normalizedPath);
|
|
619
|
-
if (!st.isFile()) { res.writeHead(400); res.end('Not a file'); return; }
|
|
620
|
-
const MAX = 512 * 1024;
|
|
621
|
-
const fd = fs.openSync(normalizedPath, 'r');
|
|
622
|
-
const len = Math.min(st.size, MAX);
|
|
623
|
-
const buf = Buffer.alloc(len);
|
|
624
|
-
fs.readSync(fd, buf, 0, len, 0);
|
|
625
|
-
fs.closeSync(fd);
|
|
626
|
-
res.writeHead(200, {
|
|
627
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
628
|
-
'Cache-Control': 'no-cache',
|
|
629
|
-
'X-File-Truncated': st.size > MAX ? '1' : '0',
|
|
630
|
-
});
|
|
631
|
-
res.end(buf);
|
|
632
|
-
} catch (err) {
|
|
633
|
-
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
634
|
-
res.writeHead(code); res.end(safeErrMsg(err));
|
|
635
|
-
}
|
|
323
|
+
handleFile(req, res, routePath);
|
|
636
324
|
return;
|
|
637
325
|
}
|
|
638
326
|
|
|
639
|
-
// Confined raw-bytes download
|
|
640
|
-
// so the Files view can offer download on a row. Same allowlist + realpath
|
|
641
|
-
// confinement as /api/file and /api/image - never a generic file reader.
|
|
327
|
+
// Confined raw-bytes download - see lib/http-routes/reads.js.
|
|
642
328
|
if (routePath.startsWith('/api/download/') || routePath.startsWith('/api/download?')) {
|
|
643
|
-
|
|
644
|
-
const allowRoots = fsAllowRoots();
|
|
645
|
-
const conf = confineToRoots(decodedPath, allowRoots);
|
|
646
|
-
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
647
|
-
const normalizedPath = conf.realPath;
|
|
648
|
-
// Same secret-name block as /api/file: a download must not exfiltrate
|
|
649
|
-
// .env/.pem/.key/credential material just because it streams bytes.
|
|
650
|
-
if (SECRET_RE.test(path.basename(normalizedPath))) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
651
|
-
try {
|
|
652
|
-
const st = fs.statSync(normalizedPath);
|
|
653
|
-
if (!st.isFile()) { res.writeHead(400); res.end('Not a file'); return; }
|
|
654
|
-
const MAX = 50 * 1024 * 1024; // 50MB cap so a download can't exhaust memory
|
|
655
|
-
if (st.size > MAX) { res.writeHead(413); res.end('File too large to download'); return; }
|
|
656
|
-
const base = path.basename(normalizedPath);
|
|
657
|
-
const asciiName = base.replace(/[\\"\r\n]/g, '').replace(/[^\x20-\x7e]/g, '_');
|
|
658
|
-
res.writeHead(200, {
|
|
659
|
-
'Content-Type': 'application/octet-stream',
|
|
660
|
-
'Content-Disposition': 'attachment; filename="' + asciiName + '"; filename*=UTF-8\'\'' + encodeURIComponent(base),
|
|
661
|
-
'Content-Length': String(st.size),
|
|
662
|
-
'Cache-Control': 'no-cache',
|
|
663
|
-
});
|
|
664
|
-
const rs = fs.createReadStream(normalizedPath);
|
|
665
|
-
rs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
666
|
-
rs.pipe(res);
|
|
667
|
-
} catch (err) {
|
|
668
|
-
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
669
|
-
res.writeHead(code); res.end(safeErrMsg(err));
|
|
670
|
-
}
|
|
329
|
+
handleDownload(req, res, routePath);
|
|
671
330
|
return;
|
|
672
331
|
}
|
|
673
332
|
|
|
674
333
|
// --- File mutations ----------------------------------------------------
|
|
675
334
|
// The Files surface is a real manager (fsbrowse-grade): rename, delete,
|
|
676
|
-
// mkdir, upload.
|
|
677
|
-
//
|
|
678
|
-
// the roots themselves as targets, and sanitizes any NEW name to a single
|
|
679
|
-
// path component (sanitizeEntryName). All are POST/PUT-only with JSON or
|
|
680
|
-
// raw-byte bodies; errors map to plain machine codes the client renders
|
|
681
|
-
// as human copy.
|
|
335
|
+
// mkdir, upload. See lib/http-routes/mutations.js for the shared
|
|
336
|
+
// confinement/sanitization contract every route below follows.
|
|
682
337
|
|
|
683
338
|
// POST /api/rename {path, newName} -> {ok, path}
|
|
684
339
|
if (routePath.split('?')[0] === '/api/rename' && req.method === 'POST') {
|
|
685
|
-
|
|
686
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
687
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
688
|
-
const allowRoots = fsAllowRoots();
|
|
689
|
-
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
690
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
691
|
-
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot rename an allowed root' }); return; }
|
|
692
|
-
const newName = sanitizeEntryName(body.newName);
|
|
693
|
-
if (!newName) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
694
|
-
if (SECRET_RE.test(newName)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
695
|
-
const target = path.join(path.dirname(conf.realPath), newName);
|
|
696
|
-
// The target stays in the same (already-confined) directory by
|
|
697
|
-
// construction, but re-check anyway so the invariant is local.
|
|
698
|
-
const tConf = confineToRoots(path.dirname(target), allowRoots);
|
|
699
|
-
if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
|
|
700
|
-
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
701
|
-
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
702
|
-
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: safeErrMsg(err) }); }
|
|
340
|
+
await handleRename(req, res, sendJSON);
|
|
703
341
|
return;
|
|
704
342
|
}
|
|
705
343
|
|
|
706
|
-
// POST /api/move {path, destDir, overwrite?} -> {ok, path}
|
|
707
|
-
// entry into another directory; BOTH endpoints re-confine via realpath.
|
|
708
|
-
// Refuses: a root as the source, a directory moved into itself or its
|
|
709
|
-
// own subtree, and an existing target unless overwrite:true (and never
|
|
710
|
-
// overwrites a directory).
|
|
344
|
+
// POST /api/move {path, destDir, overwrite?} -> {ok, path}
|
|
711
345
|
if (routePath.split('?')[0] === '/api/move' && req.method === 'POST') {
|
|
712
|
-
|
|
713
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
714
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
715
|
-
const allowRoots = fsAllowRoots();
|
|
716
|
-
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
717
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
718
|
-
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot move an allowed root' }); return; }
|
|
719
|
-
const dConf = confineToRoots(String(body.destDir || ''), allowRoots);
|
|
720
|
-
if (!dConf.ok) { sendJSON(req, res, dConf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + dConf.reason }); return; }
|
|
721
|
-
let destIsDir = false;
|
|
722
|
-
try { destIsDir = fs.statSync(dConf.realPath).isDirectory(); } catch {}
|
|
723
|
-
if (!destIsDir) { sendJSON(req, res, 400, { error: 'destination is not a directory' }); return; }
|
|
724
|
-
const name = sanitizeEntryName(path.basename(conf.realPath));
|
|
725
|
-
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
726
|
-
// A directory must never move into itself or its own subtree.
|
|
727
|
-
const srcPrefix = conf.realPath + path.sep;
|
|
728
|
-
if (dConf.realPath === conf.realPath || dConf.realPath.startsWith(srcPrefix)) {
|
|
729
|
-
sendJSON(req, res, 400, { error: 'cannot move a folder into itself' }); return;
|
|
730
|
-
}
|
|
731
|
-
const target = path.join(dConf.realPath, name);
|
|
732
|
-
if (target === conf.realPath) { sendJSON(req, res, 200, { ok: true, path: target }); return; }
|
|
733
|
-
if (fs.existsSync(target)) {
|
|
734
|
-
let targetIsDir = false;
|
|
735
|
-
try { targetIsDir = fs.lstatSync(target).isDirectory(); } catch {}
|
|
736
|
-
if (targetIsDir || body.overwrite !== true) {
|
|
737
|
-
sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return;
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
741
|
-
catch (err) {
|
|
742
|
-
const code = err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400;
|
|
743
|
-
sendJSON(req, res, code, { error: err.code === 'EXDEV' ? 'cannot move across drives' : err.message });
|
|
744
|
-
}
|
|
346
|
+
await handleMove(req, res, sendJSON);
|
|
745
347
|
return;
|
|
746
348
|
}
|
|
747
349
|
|
|
748
|
-
// POST /api/delete {path, recursive?} -> {ok}
|
|
749
|
-
// requires recursive:true (the client confirms first).
|
|
350
|
+
// POST /api/delete {path, recursive?} -> {ok}
|
|
750
351
|
if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
|
|
751
|
-
|
|
752
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
753
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
754
|
-
const allowRoots = fsAllowRoots();
|
|
755
|
-
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
756
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
757
|
-
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
|
|
758
|
-
try {
|
|
759
|
-
const st = fs.lstatSync(conf.realPath);
|
|
760
|
-
// Soft-delete: move into a confined per-root .agentgui-trash/ instead
|
|
761
|
-
// of unlinking, so the only safety net isn't confirm-before (the
|
|
762
|
-
// pre-existing ConfirmDialog) but also undo-after, matching an
|
|
763
|
-
// fsbrowse-grade file manager. A non-empty directory without
|
|
764
|
-
// recursive=true still throws ENOTEMPTY BEFORE any move happens
|
|
765
|
-
// (checked via a dry probe) to keep that existing guard's semantics.
|
|
766
|
-
if (st.isDirectory() && body.recursive !== true) {
|
|
767
|
-
const dryEntries = fs.readdirSync(conf.realPath);
|
|
768
|
-
if (dryEntries.length) { sendJSON(req, res, 409, { error: 'directory is not empty' }); return; }
|
|
769
|
-
}
|
|
770
|
-
const trashInfo = moveToTrash(conf.realPath, allowRoots);
|
|
771
|
-
sendJSON(req, res, 200, { ok: true, trashId: trashInfo.trashId });
|
|
772
|
-
} catch (err) {
|
|
773
|
-
const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
|
|
774
|
-
sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
|
|
775
|
-
}
|
|
352
|
+
await handleDelete(req, res, sendJSON);
|
|
776
353
|
return;
|
|
777
354
|
}
|
|
778
355
|
|
|
779
|
-
// POST /api/restore {trashId} -> {ok, path}
|
|
780
|
-
// its retention window (trashRetentionMs, default 10 minutes) by moving
|
|
781
|
-
// the entry back from .agentgui-trash/ to its original confined path.
|
|
356
|
+
// POST /api/restore {trashId} -> {ok, path}
|
|
782
357
|
if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
|
|
783
|
-
|
|
784
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
785
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
786
|
-
try {
|
|
787
|
-
const restored = restoreFromTrash(String(body.trashId || ''), fsAllowRoots());
|
|
788
|
-
sendJSON(req, res, 200, { ok: true, path: restored.path });
|
|
789
|
-
} catch (err) {
|
|
790
|
-
sendJSON(req, res, err.code === 'NOT_FOUND' ? 404 : (err.code === 'CONFLICT' ? 409 : 400), { error: err.message });
|
|
791
|
-
}
|
|
358
|
+
await handleRestore(req, res, sendJSON);
|
|
792
359
|
return;
|
|
793
360
|
}
|
|
794
361
|
|
|
795
|
-
// POST /api/mkdir {dir, name} -> {ok, path}
|
|
362
|
+
// POST /api/mkdir {dir, name} -> {ok, path}
|
|
796
363
|
if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
|
|
797
|
-
|
|
798
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
799
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
800
|
-
const allowRoots = fsAllowRoots();
|
|
801
|
-
const conf = confineToRoots(String(body.dir || ''), allowRoots);
|
|
802
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
803
|
-
const name = sanitizeEntryName(body.name);
|
|
804
|
-
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
805
|
-
if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
806
|
-
const target = path.join(conf.realPath, name);
|
|
807
|
-
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
808
|
-
try { fs.mkdirSync(target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
809
|
-
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
|
|
364
|
+
await handleMkdir(req, res, sendJSON);
|
|
810
365
|
return;
|
|
811
366
|
}
|
|
812
367
|
|
|
813
|
-
// PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the
|
|
814
|
-
// body (no multipart dependency; the client sends fetch(file)). 50MB cap,
|
|
815
|
-
// never overwrites unless ?overwrite=1. Distinct path from the legacy
|
|
816
|
-
// express-mounted /api/upload/:conversationId.
|
|
368
|
+
// PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the body
|
|
817
369
|
if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
|
|
818
|
-
|
|
819
|
-
try { qs = new URL(req.url, 'http://localhost').searchParams; } catch { qs = new URLSearchParams(); }
|
|
820
|
-
// Require Content-Length header: rejects chunked or missing-length requests
|
|
821
|
-
// that could claim any size. Pre-validates the announced size before streaming.
|
|
822
|
-
const contentLength = req.headers['content-length'];
|
|
823
|
-
if (!contentLength) {
|
|
824
|
-
sendJSON(req, res, 411, { error: 'length required' }); return;
|
|
825
|
-
}
|
|
826
|
-
const MAX_UPLOAD = 50 * 1024 * 1024;
|
|
827
|
-
const len = parseInt(contentLength, 10);
|
|
828
|
-
if (isNaN(len) || len < 0 || len > MAX_UPLOAD) {
|
|
829
|
-
sendJSON(req, res, 413, { error: `file too large (max ${MAX_UPLOAD} bytes)` }); return;
|
|
830
|
-
}
|
|
831
|
-
const allowRoots = fsAllowRoots();
|
|
832
|
-
const conf = confineToRoots(qs.get('dir') || '', allowRoots);
|
|
833
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
834
|
-
const name = sanitizeEntryName(qs.get('name'));
|
|
835
|
-
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
836
|
-
if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
837
|
-
const target = path.join(conf.realPath, name);
|
|
838
|
-
if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
839
|
-
// Stream the upload body to a temp file to keep memory constant and
|
|
840
|
-
// avoid blocking the event loop with a large synchronous writeFileSync.
|
|
841
|
-
const tmpPath = target + '.tmp.' + crypto.randomBytes(6).toString('hex');
|
|
842
|
-
try {
|
|
843
|
-
await new Promise((resolve, reject) => {
|
|
844
|
-
const ws = fs.createWriteStream(tmpPath);
|
|
845
|
-
let total = 0;
|
|
846
|
-
ws.on('error', reject);
|
|
847
|
-
req.on('error', reject);
|
|
848
|
-
req.on('data', (chunk) => {
|
|
849
|
-
total += chunk.length;
|
|
850
|
-
if (total > MAX_UPLOAD) {
|
|
851
|
-
ws.destroy();
|
|
852
|
-
req.destroy();
|
|
853
|
-
const e = new Error('file too large (50MB cap)'); e.code = 'TOO_LARGE';
|
|
854
|
-
reject(e); return;
|
|
855
|
-
}
|
|
856
|
-
ws.write(chunk);
|
|
857
|
-
});
|
|
858
|
-
req.on('end', () => ws.end());
|
|
859
|
-
ws.on('finish', () => resolve(total));
|
|
860
|
-
});
|
|
861
|
-
fs.renameSync(tmpPath, target);
|
|
862
|
-
const uploadedSize = fs.statSync(target).size;
|
|
863
|
-
sendJSON(req, res, 200, { ok: true, path: target, size: uploadedSize });
|
|
864
|
-
} catch (err) {
|
|
865
|
-
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
866
|
-
if (err.code === 'TOO_LARGE') { sendJSON(req, res, 413, { error: 'file too large (50MB cap)' }); return; }
|
|
867
|
-
sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: 'upload failed' });
|
|
868
|
-
}
|
|
370
|
+
await handleUploadFile(req, res, sendJSON);
|
|
869
371
|
return;
|
|
870
372
|
}
|
|
871
373
|
|
|
374
|
+
// Confined image bytes - see lib/http-routes/reads.js.
|
|
872
375
|
if (routePath.startsWith('/api/image/') || routePath.startsWith('/api/image?')) {
|
|
873
|
-
|
|
874
|
-
// Confine reads to an allowlist root. Without this the route is an
|
|
875
|
-
// arbitrary-file-read of any image-extensioned path on the host (the
|
|
876
|
-
// prior `includes('..')` guard is a no-op after path.normalize resolves
|
|
877
|
-
// the segments). Allowed roots: the Claude projects dir (history images)
|
|
878
|
-
// only; add more via IMAGE_ROOTS (path-separated). The user home is NOT
|
|
879
|
-
// a default root - it covers ~/.ssh, ~/.aws, dotfiles etc., so an image
|
|
880
|
-
// route reaching all of home is a broad read of anything image-shaped.
|
|
881
|
-
// confineToRoots also realpath-resolves so a symlink inside a root can't
|
|
882
|
-
// point an image read at an out-of-root file.
|
|
883
|
-
const allowRoots = [
|
|
884
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
885
|
-
...(process.env.IMAGE_ROOTS ? process.env.IMAGE_ROOTS.split(path.delimiter) : []),
|
|
886
|
-
].map(r => path.normalize(r));
|
|
887
|
-
const conf = confineToRoots(decodedPath, allowRoots);
|
|
888
|
-
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end(conf.reason === 'not found' ? 'Not found' : 'Forbidden'); return; }
|
|
889
|
-
const normalizedPath = conf.realPath;
|
|
890
|
-
try {
|
|
891
|
-
const ext = path.extname(normalizedPath).toLowerCase();
|
|
892
|
-
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
893
|
-
// SVG is intentionally excluded: browsers render SVG as a live document
|
|
894
|
-
// in the app's origin, so an agent-written SVG with a <script src=CDN>
|
|
895
|
-
// would execute in the agentgui origin (CSP allows unpkg/jsdelivr).
|
|
896
|
-
// Files preview uses /api/file/download (attachment) for SVG.
|
|
897
|
-
const contentType = mimeTypes[ext];
|
|
898
|
-
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
899
|
-
const imgSt = fs.statSync(normalizedPath);
|
|
900
|
-
const IMG_MAX = 20 * 1024 * 1024; // 20MB hard cap
|
|
901
|
-
if (imgSt.size > IMG_MAX) { res.writeHead(413); res.end('Image too large'); return; }
|
|
902
|
-
// Always stream to avoid blocking the event loop on large synchronous reads.
|
|
903
|
-
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'Content-Length': String(imgSt.size) });
|
|
904
|
-
const imgRs = fs.createReadStream(normalizedPath);
|
|
905
|
-
imgRs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
906
|
-
imgRs.pipe(res);
|
|
907
|
-
} catch (err) { sendJSON(req, res, 400, { error: 'cannot read image' }); }
|
|
376
|
+
handleImage(req, res, routePath, sendJSON);
|
|
908
377
|
return;
|
|
909
378
|
}
|
|
910
379
|
|