agentgui 1.0.1126 → 1.0.1127
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.
Potentially problematic release.
This version of agentgui might be problematic. Click here for more details.
- package/.gm/browser-config.json +1 -0
- package/.gm/disciplines/agentgui/mem-1779277005143-0-1352.json +1 -0
- package/.gm/disciplines/agentgui/memories/.flat-export-done +0 -0
- package/.gm/disciplines/agentgui/memories/mem-c07fa3b0836edbe2-1352.md +8 -0
- package/.gm/gm.db +0 -0
- package/.gm/memories/.flat-export-done +1 -0
- package/.gm/memories/mem-0131ff4869d86085-370.md +10 -0
- package/.gm/memories/mem-04049d11cb8c1661-2135.md +8 -0
- package/.gm/memories/mem-069ff10372ed1770-1375.md +8 -0
- package/.gm/memories/mem-08a4a9e050a4deff-3048.md +8 -0
- package/.gm/memories/mem-09597ee0df9c9224-965.md +8 -0
- package/.gm/memories/mem-0993d156f571b619-1059.md +8 -0
- package/.gm/memories/mem-0b681bf2340c8df1-3170.md +8 -0
- package/.gm/memories/mem-0c444abde86202f6-1681.md +8 -0
- package/.gm/memories/mem-0d1408fa048b9923-1159.md +8 -0
- package/.gm/memories/mem-0dad5d82fec829ba-887.md +8 -0
- package/.gm/memories/mem-10a776effb7ce3a6-935.md +8 -0
- package/.gm/memories/mem-10d264b4f8e66fb4-1492.md +8 -0
- package/.gm/memories/mem-118c35ac67541d98-2909.md +8 -0
- package/.gm/memories/mem-119cf70dd19aa0a5-2225.md +8 -0
- package/.gm/memories/mem-15a07cd761b337c4-4320.md +8 -0
- package/.gm/memories/mem-15c73aca528535cb-2183.md +8 -0
- package/.gm/memories/mem-166f52796185e0e7-1487.md +8 -0
- package/.gm/memories/mem-1a148cf9c3126df5-3963.md +8 -0
- package/.gm/memories/mem-1a459001058f618e-567.md +8 -0
- package/.gm/memories/mem-1af3e13f7618e668-2146.md +8 -0
- package/.gm/memories/mem-1fd31d6873e9c164-794.md +8 -0
- package/.gm/memories/mem-21f551be092fd77d-2340.md +8 -0
- package/.gm/memories/mem-231ff5de003f20f0-586.md +8 -0
- package/.gm/memories/mem-2567393d3adb03ab-347.md +8 -0
- package/.gm/memories/mem-27faf41cdd050c80-1261.md +8 -0
- package/.gm/memories/mem-28889e43e8ce64cc-3663.md +8 -0
- package/.gm/memories/mem-2f4fd06515abf439-2562.md +8 -0
- package/.gm/memories/mem-34a749430303c5f7-544.md +8 -0
- package/.gm/memories/mem-3602d11d7bce9446-1057.md +8 -0
- package/.gm/memories/mem-38b7e24dbcba2f3e-639.md +8 -0
- package/.gm/memories/mem-39a7cc2f887a90b1-1079.md +8 -0
- package/.gm/memories/mem-40d6169c8759061d-806.md +8 -0
- package/.gm/memories/mem-4420e6d00e5c8632-2318.md +8 -0
- package/.gm/memories/mem-447e5a54ddc9357a-654.md +8 -0
- package/.gm/memories/mem-455b3beb4afabe14-591.md +8 -0
- package/.gm/memories/mem-4845ee594759151c-560.md +8 -0
- package/.gm/memories/mem-4a1254c60b868b88-374.md +8 -0
- package/.gm/memories/mem-4bafafa47a0e4b6c-3237.md +8 -0
- package/.gm/memories/mem-4c06c45b4d31bc60-1202.md +8 -0
- package/.gm/memories/mem-4dfac801d0378448-3676.md +8 -0
- package/.gm/memories/mem-4ecd3b713e384170-122.md +10 -0
- package/.gm/memories/mem-51d8992a3c6f6c9c-1073.md +8 -0
- package/.gm/memories/mem-539eee7e8c6c10ee-2557.md +8 -0
- package/.gm/memories/mem-559b8b2abc4b1091-1665.md +8 -0
- package/.gm/memories/mem-59dd73a463e54948-871.md +8 -0
- package/.gm/memories/mem-5b99b882ffe1a3c2-2262.md +8 -0
- package/.gm/memories/mem-5feb252a5244149c-1791.md +8 -0
- package/.gm/memories/mem-6224f8d185797f2b-1047.md +8 -0
- package/.gm/memories/mem-6434ee8261a6a6a2-3126.md +8 -0
- package/.gm/memories/mem-66b0947b946f3437-1155.md +8 -0
- package/.gm/memories/mem-6bdf783a1f6be7c2-1062.md +8 -0
- package/.gm/memories/mem-6d49f08bebac54c9-744.md +8 -0
- package/.gm/memories/mem-70d96acb736af2d7-685.md +8 -0
- package/.gm/memories/mem-70de7d5e71fcd439-2680.md +8 -0
- package/.gm/memories/mem-713614f12d7029da-1577.md +8 -0
- package/.gm/memories/mem-720d76c88e64f6ee-1967.md +8 -0
- package/.gm/memories/mem-7535f34f430fc196-1340.md +8 -0
- package/.gm/memories/mem-861a588d2773681a-1311.md +8 -0
- package/.gm/memories/mem-8af7980a92e09959-891.md +8 -0
- package/.gm/memories/mem-8b438ef4c609b7af-401.md +8 -0
- package/.gm/memories/mem-8f59884039787ef3-743.md +8 -0
- package/.gm/memories/mem-92b199c2024ee1aa-2513.md +8 -0
- package/.gm/memories/mem-9329105b05b9c0f6-324.md +10 -0
- package/.gm/memories/mem-94f30cec3c283f97-782.md +10 -0
- package/.gm/memories/mem-956d4813319f65a3-1710.md +8 -0
- package/.gm/memories/mem-96fffbb6615e031b-1263.md +8 -0
- package/.gm/memories/mem-993ab31f7a10aefa-718.md +8 -0
- package/.gm/memories/mem-a3e440db285f655a-995.md +8 -0
- package/.gm/memories/mem-a52933cd6495e6f5-2624.md +8 -0
- package/.gm/memories/mem-a86426ac44138c96-1606.md +8 -0
- package/.gm/memories/mem-a95b1d49d71133ce-1007.md +8 -0
- package/.gm/memories/mem-a9968a186368b022-1150.md +8 -0
- package/.gm/memories/mem-aaeae38d8eb01809-1076.md +8 -0
- package/.gm/memories/mem-ade01d9c02677c55-1557.md +8 -0
- package/.gm/memories/mem-b06b8c85b81663e6-1250.md +8 -0
- package/.gm/memories/mem-b451e5e17e555202-1490.md +8 -0
- package/.gm/memories/mem-b64ad00dcb6f909e-937.md +8 -0
- package/.gm/memories/mem-b7d6018ceca55b6f-2220.md +8 -0
- package/.gm/memories/mem-bdf466854032cac9-1509.md +8 -0
- package/.gm/memories/mem-c0482039e6ab4c50-2047.md +8 -0
- package/.gm/memories/mem-c4aaec5c4e6f80c2-920.md +8 -0
- package/.gm/memories/mem-c66976ddacb4fbe8-1454.md +8 -0
- package/.gm/memories/mem-c7c5bc263850c332-1844.md +8 -0
- package/.gm/memories/mem-cda639547932c796-713.md +8 -0
- package/.gm/memories/mem-cdf04ff6a6a0aa6f-671.md +8 -0
- package/.gm/memories/mem-cf8472c84d9e04c4-1883.md +8 -0
- package/.gm/memories/mem-cfd4d894370c63c3-1288.md +8 -0
- package/.gm/memories/mem-d060480435f92eec-1146.md +8 -0
- package/.gm/memories/mem-d347c921b79e6d44-1099.md +8 -0
- package/.gm/memories/mem-d3c8ea6c861bfbb8-1457.md +8 -0
- package/.gm/memories/mem-d5e928c552cd87f9-214.md +10 -0
- package/.gm/memories/mem-d63bc3b8a0269844-406.md +10 -0
- package/.gm/memories/mem-d9c19257abc2fa5e-1061.md +8 -0
- package/.gm/memories/mem-d9f26358a7cd3bb8-866.md +8 -0
- package/.gm/memories/mem-da1c91acf63d81d3-863.md +8 -0
- package/.gm/memories/mem-dc1ac3b04d22317e-664.md +8 -0
- package/.gm/memories/mem-dceeae20bc56d407-711.md +8 -0
- package/.gm/memories/mem-dd98b21c0896dc04-1557.md +8 -0
- package/.gm/memories/mem-df74674eec7745ac-178.md +10 -0
- package/.gm/memories/mem-dfe3b466fd5d1199-802.md +8 -0
- package/.gm/memories/mem-e0f9286aed2d5a06-1116.md +8 -0
- package/.gm/memories/mem-e1950b064099b7be-1895.md +8 -0
- package/.gm/memories/mem-e1d5431ae2542872-961.md +8 -0
- package/.gm/memories/mem-e490a181de6469fc-487.md +8 -0
- package/.gm/memories/mem-e72c6efbb27326fb-392.md +8 -0
- package/.gm/memories/mem-e943b58115a02b4c-593.md +8 -0
- package/.gm/memories/mem-e9bc5f9ed6edbcf7-1580.md +8 -0
- package/.gm/memories/mem-eae1e383927854fb-2649.md +8 -0
- package/.gm/memories/mem-ebbe6c0694c526f0-1392.md +8 -0
- package/.gm/memories/mem-ebf85becc23410b0-1633.md +8 -0
- package/.gm/memories/mem-ee0596fa17c7102e-362.md +10 -0
- package/.gm/memories/mem-ee4450fbc311b583-1361.md +8 -0
- package/.gm/memories/mem-ee8858162518f144-1252.md +8 -0
- package/.gm/memories/mem-f3297e82df622649-1166.md +8 -0
- package/.gm/memories/mem-f3f317c083d979ee-1210.md +8 -0
- package/.gm/memories/mem-f714a86465fba158-2041.md +8 -0
- package/.gm/memories/mem-f786bc1626d92845-204.md +10 -0
- package/.gm/memories/mem-f9f0dc8895b21e18-1217.md +8 -0
- package/.gm/memories/mem-fb3b2a7395800041-525.md +8 -0
- package/.gm/memories/mem-fc5753f92ee7d654-966.md +8 -0
- package/.gm/memories/mem-fe3faa1652f07239-480.md +8 -0
- package/.gm/memories/mem-ff8669e527e5c427-2788.md +8 -0
- package/.gm/mutables.yml +53 -0
- package/.gm/prd.yml +3342 -1
- package/AGENTS.md +122 -122
- package/UX_OPTIMIZATION_SUMMARY.md +238 -0
- 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 +28 -0
- package/database.js +110 -109
- package/design-reference.png +0 -0
- package/lib/acp-sdk-manager.js +9 -42
- package/lib/claude-runner-acp.js +155 -213
- package/lib/claude-runner-agents.js +157 -170
- package/lib/http-handler.js +931 -400
- package/lib/ws-handlers-util.js +466 -11
- package/package.json +1 -1
- package/site/app/js/app.js +4741 -4221
- package/site/app/js/backend.js +602 -621
- package/site/app/vendor/anentrypoint-design/247420.css +13673 -14799
- package/site/app/vendor/anentrypoint-design/247420.js +449 -735
- package/.gm/.embed-generation.code_chunks +0 -1
- package/.gm/.embed-generation.git_commit_vectors +0 -1
- package/.gm/.embed-generation.memories +0 -1
- package/.gm/.embed-generation.rssearch_vectors +0 -1
- package/lib/acp-http-protocol.js +0 -95
- package/lib/http-routes/mutations.js +0 -199
- package/lib/http-routes/reads.js +0 -212
- package/lib/http-routes/shared.js +0 -225
- package/lib/ws-handlers/agents.js +0 -137
- package/lib/ws-handlers/chat.js +0 -157
- package/lib/ws-handlers/git.js +0 -159
- package/lib/ws-handlers/misc.js +0 -36
- package/lib/ws-handlers/shared.js +0 -47
- package/lib/ws-handlers/terminal-state.js +0 -16
- package/site/app/js/chat-persistence.js +0 -123
- package/site/app/js/hash-routing.js +0 -62
- package/site/app/js/history.js +0 -435
- package/site/app/js/shortcuts.js +0 -128
|
@@ -1 +0,0 @@
|
|
|
1
|
-
b8b6f24106dd526a
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
b8b6f24106dd526a
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
b8b6f24106dd526a
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
b8b6f24106dd526a
|
package/lib/acp-http-protocol.js
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
// Normalizes opencode's REST+SSE event shape (POST /session, POST
|
|
2
|
-
// /session/{id}/message, GET /global/event) into the SAME internal event
|
|
3
|
-
// shape lib/acp-protocol.js's acpProtocolHandler produces for the stdio ACP
|
|
4
|
-
// JSON-RPC transport (assistant/tool_use/tool_result/result), so a caller can
|
|
5
|
-
// switch transports without caring which one produced the event.
|
|
6
|
-
//
|
|
7
|
-
// Live-captured event shapes this maps (opencode 1.2.15, acp --port, SSE
|
|
8
|
-
// frames from GET /global/event during a real session/message round trip):
|
|
9
|
-
// {payload:{type:'message.part.updated', properties:{part:{type:'text',text,...}}}}
|
|
10
|
-
// {payload:{type:'message.part.updated', properties:{part:{type:'reasoning',text,...}}}}
|
|
11
|
-
// {payload:{type:'message.updated', properties:{info:{role,finish,...}}}}
|
|
12
|
-
// {payload:{type:'session.status', properties:{status:{type:'busy'|'idle'}}}}
|
|
13
|
-
// {payload:{type:'session.idle', properties:{...}}}
|
|
14
|
-
|
|
15
|
-
function normalizePart(part, sessionId) {
|
|
16
|
-
if (!part || typeof part !== 'object') return null;
|
|
17
|
-
// A text part streams in incrementally - the first frame for it can arrive
|
|
18
|
-
// with an empty string before content lands (live-observed). An empty
|
|
19
|
-
// chunk carries nothing a caller can render; drop it rather than emitting
|
|
20
|
-
// a blank assistant bubble.
|
|
21
|
-
if (part.type === 'text' && typeof part.text === 'string' && part.text.length > 0) {
|
|
22
|
-
return { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: part.text }] }, session_id: sessionId };
|
|
23
|
-
}
|
|
24
|
-
if (part.type === 'tool' || part.type === 'tool-invocation') {
|
|
25
|
-
return {
|
|
26
|
-
type: 'assistant',
|
|
27
|
-
message: { role: 'assistant', content: [{ type: 'tool_use', id: part.id, name: part.tool || part.name || 'tool', input: part.input || part.args || {} }] },
|
|
28
|
-
session_id: sessionId,
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
// reasoning/step-start/step-finish parts carry no user-facing content in
|
|
32
|
-
// agentgui's chat surface today - dropped (return null), same as the stdio
|
|
33
|
-
// path drops ACP updates it doesn't recognize.
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// A `message.part.updated` frame's `part` carries NO role of its own - live
|
|
38
|
-
// capture confirmed the part's role lives on the earlier `message.updated`
|
|
39
|
-
// frame for the SAME messageID (the prompt echo arrives as a role:"user"
|
|
40
|
-
// message.updated immediately before its own message.part.updated). Each
|
|
41
|
-
// createACPHttpProtocolHandler() call keeps its own messageId->role map so a
|
|
42
|
-
// caller with one handler per session never leaks state across sessions.
|
|
43
|
-
export function createACPHttpProtocolHandler() {
|
|
44
|
-
const roleByMessageId = new Map();
|
|
45
|
-
|
|
46
|
-
return function (frame, context) {
|
|
47
|
-
if (!frame || typeof frame !== 'object') return null;
|
|
48
|
-
const payload = frame.payload;
|
|
49
|
-
if (!payload || typeof payload !== 'object') return null;
|
|
50
|
-
const sid = context?.sessionId;
|
|
51
|
-
|
|
52
|
-
if (payload.type === 'message.updated') {
|
|
53
|
-
const info = payload.properties?.info;
|
|
54
|
-
if (!info) return null;
|
|
55
|
-
if (info.id) roleByMessageId.set(info.id, info.role);
|
|
56
|
-
if (info.role === 'assistant' && info.finish) {
|
|
57
|
-
return { type: 'result', result: '', stopReason: info.finish, usage: info.tokens ? { used: info.tokens.total } : undefined, session_id: info.sessionID || sid };
|
|
58
|
-
}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (payload.type === 'message.part.updated') {
|
|
63
|
-
const part = payload.properties?.part;
|
|
64
|
-
if (!part) return null;
|
|
65
|
-
// The user's own prompt echoes back as a part on their own message -
|
|
66
|
-
// only forward parts belonging to an assistant-role message.
|
|
67
|
-
const role = part.messageID ? roleByMessageId.get(part.messageID) : undefined;
|
|
68
|
-
if (role === 'user') return null;
|
|
69
|
-
return normalizePart(part, sid);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (payload.type === 'session.status' && payload.properties?.status?.type === 'error') {
|
|
73
|
-
return { type: 'error', error: payload.properties.status };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return null;
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export const acpHttpProtocolHandler = createACPHttpProtocolHandler();
|
|
81
|
-
|
|
82
|
-
// Parse one SSE "data: {...}\n\n" frame (or a raw chunk containing several)
|
|
83
|
-
// into an array of parsed JSON payloads. opencode's /global/event stream
|
|
84
|
-
// uses the standard `data: <json>\n\n` SSE framing (live-confirmed).
|
|
85
|
-
export function parseSSEChunk(chunk) {
|
|
86
|
-
const out = [];
|
|
87
|
-
for (const block of chunk.split('\n\n')) {
|
|
88
|
-
const line = block.trim();
|
|
89
|
-
if (!line.startsWith('data:')) continue;
|
|
90
|
-
const jsonText = line.slice(5).trim();
|
|
91
|
-
if (!jsonText) continue;
|
|
92
|
-
try { out.push(JSON.parse(jsonText)); } catch { /* partial frame, skip */ }
|
|
93
|
-
}
|
|
94
|
-
return out;
|
|
95
|
-
}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import crypto from 'crypto';
|
|
4
|
-
import {
|
|
5
|
-
confineToRoots, fsAllowRoots, sanitizeEntryName, safeErrMsg, readBody,
|
|
6
|
-
isAllowRoot, moveToTrash, restoreFromTrash, SECRET_RE,
|
|
7
|
-
} from './shared.js';
|
|
8
|
-
|
|
9
|
-
// --- File mutations ----------------------------------------------------
|
|
10
|
-
// The Files surface is a real manager (fsbrowse-grade): rename, delete,
|
|
11
|
-
// mkdir, upload. Every route re-confines via confineToRoots (realpath, so
|
|
12
|
-
// a symlink inside a root cannot point a mutation outside it), refuses
|
|
13
|
-
// the roots themselves as targets, and sanitizes any NEW name to a single
|
|
14
|
-
// path component (sanitizeEntryName). All are POST/PUT-only with JSON or
|
|
15
|
-
// raw-byte bodies; errors map to plain machine codes the client renders
|
|
16
|
-
// as human copy.
|
|
17
|
-
|
|
18
|
-
// POST /api/rename {path, newName} -> {ok, path}
|
|
19
|
-
export async function handleRename(req, res, sendJSON) {
|
|
20
|
-
let body;
|
|
21
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
22
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
23
|
-
const allowRoots = fsAllowRoots();
|
|
24
|
-
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
25
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
26
|
-
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot rename an allowed root' }); return; }
|
|
27
|
-
const newName = sanitizeEntryName(body.newName);
|
|
28
|
-
if (!newName) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
29
|
-
if (SECRET_RE.test(newName)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
30
|
-
const target = path.join(path.dirname(conf.realPath), newName);
|
|
31
|
-
// The target stays in the same (already-confined) directory by
|
|
32
|
-
// construction, but re-check anyway so the invariant is local.
|
|
33
|
-
const tConf = confineToRoots(path.dirname(target), allowRoots);
|
|
34
|
-
if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
|
|
35
|
-
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
36
|
-
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
37
|
-
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: safeErrMsg(err) }); }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// POST /api/move {path, destDir, overwrite?} -> {ok, path}. Moves an
|
|
41
|
-
// entry into another directory; BOTH endpoints re-confine via realpath.
|
|
42
|
-
// Refuses: a root as the source, a directory moved into itself or its
|
|
43
|
-
// own subtree, and an existing target unless overwrite:true (and never
|
|
44
|
-
// overwrites a directory).
|
|
45
|
-
export async function handleMove(req, res, sendJSON) {
|
|
46
|
-
let body;
|
|
47
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
48
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
49
|
-
const allowRoots = fsAllowRoots();
|
|
50
|
-
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
51
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
52
|
-
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot move an allowed root' }); return; }
|
|
53
|
-
const dConf = confineToRoots(String(body.destDir || ''), allowRoots);
|
|
54
|
-
if (!dConf.ok) { sendJSON(req, res, dConf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + dConf.reason }); return; }
|
|
55
|
-
let destIsDir = false;
|
|
56
|
-
try { destIsDir = fs.statSync(dConf.realPath).isDirectory(); } catch {}
|
|
57
|
-
if (!destIsDir) { sendJSON(req, res, 400, { error: 'destination is not a directory' }); return; }
|
|
58
|
-
const name = sanitizeEntryName(path.basename(conf.realPath));
|
|
59
|
-
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
60
|
-
// A directory must never move into itself or its own subtree.
|
|
61
|
-
const srcPrefix = conf.realPath + path.sep;
|
|
62
|
-
if (dConf.realPath === conf.realPath || dConf.realPath.startsWith(srcPrefix)) {
|
|
63
|
-
sendJSON(req, res, 400, { error: 'cannot move a folder into itself' }); return;
|
|
64
|
-
}
|
|
65
|
-
const target = path.join(dConf.realPath, name);
|
|
66
|
-
if (target === conf.realPath) { sendJSON(req, res, 200, { ok: true, path: target }); return; }
|
|
67
|
-
if (fs.existsSync(target)) {
|
|
68
|
-
let targetIsDir = false;
|
|
69
|
-
try { targetIsDir = fs.lstatSync(target).isDirectory(); } catch {}
|
|
70
|
-
if (targetIsDir || body.overwrite !== true) {
|
|
71
|
-
sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
75
|
-
catch (err) {
|
|
76
|
-
const code = err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400;
|
|
77
|
-
sendJSON(req, res, code, { error: err.code === 'EXDEV' ? 'cannot move across drives' : err.message });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// POST /api/delete {path, recursive?} -> {ok}. Deleting a non-empty dir
|
|
82
|
-
// requires recursive:true (the client confirms first).
|
|
83
|
-
export async function handleDelete(req, res, sendJSON) {
|
|
84
|
-
let body;
|
|
85
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
86
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
87
|
-
const allowRoots = fsAllowRoots();
|
|
88
|
-
const conf = confineToRoots(String(body.path || ''), allowRoots);
|
|
89
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
90
|
-
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
|
|
91
|
-
try {
|
|
92
|
-
const st = fs.lstatSync(conf.realPath);
|
|
93
|
-
// Soft-delete: move into a confined per-root .agentgui-trash/ instead
|
|
94
|
-
// of unlinking, so the only safety net isn't confirm-before (the
|
|
95
|
-
// pre-existing ConfirmDialog) but also undo-after, matching an
|
|
96
|
-
// fsbrowse-grade file manager. A non-empty directory without
|
|
97
|
-
// recursive=true still throws ENOTEMPTY BEFORE any move happens
|
|
98
|
-
// (checked via a dry probe) to keep that existing guard's semantics.
|
|
99
|
-
if (st.isDirectory() && body.recursive !== true) {
|
|
100
|
-
const dryEntries = fs.readdirSync(conf.realPath);
|
|
101
|
-
if (dryEntries.length) { sendJSON(req, res, 409, { error: 'directory is not empty' }); return; }
|
|
102
|
-
}
|
|
103
|
-
const trashInfo = moveToTrash(conf.realPath, allowRoots);
|
|
104
|
-
sendJSON(req, res, 200, { ok: true, trashId: trashInfo.trashId });
|
|
105
|
-
} catch (err) {
|
|
106
|
-
const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
|
|
107
|
-
sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// POST /api/restore {trashId} -> {ok, path}. Undoes a /api/delete within
|
|
112
|
-
// its retention window (trashRetentionMs, default 10 minutes) by moving
|
|
113
|
-
// the entry back from .agentgui-trash/ to its original confined path.
|
|
114
|
-
export async function handleRestore(req, res, sendJSON) {
|
|
115
|
-
let body;
|
|
116
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
117
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
118
|
-
try {
|
|
119
|
-
const restored = restoreFromTrash(String(body.trashId || ''), fsAllowRoots());
|
|
120
|
-
sendJSON(req, res, 200, { ok: true, path: restored.path });
|
|
121
|
-
} catch (err) {
|
|
122
|
-
sendJSON(req, res, err.code === 'NOT_FOUND' ? 404 : (err.code === 'CONFLICT' ? 409 : 400), { error: err.message });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
|
|
127
|
-
export async function handleMkdir(req, res, sendJSON) {
|
|
128
|
-
let body;
|
|
129
|
-
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
130
|
-
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
131
|
-
const allowRoots = fsAllowRoots();
|
|
132
|
-
const conf = confineToRoots(String(body.dir || ''), allowRoots);
|
|
133
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
134
|
-
const name = sanitizeEntryName(body.name);
|
|
135
|
-
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
136
|
-
if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
137
|
-
const target = path.join(conf.realPath, name);
|
|
138
|
-
if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
139
|
-
try { fs.mkdirSync(target); sendJSON(req, res, 200, { ok: true, path: target }); }
|
|
140
|
-
catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the
|
|
144
|
-
// body (no multipart dependency; the client sends fetch(file)). 50MB cap,
|
|
145
|
-
// never overwrites unless ?overwrite=1. Distinct path from the legacy
|
|
146
|
-
// express-mounted /api/upload/:conversationId.
|
|
147
|
-
export async function handleUploadFile(req, res, sendJSON) {
|
|
148
|
-
let qs;
|
|
149
|
-
try { qs = new URL(req.url, 'http://localhost').searchParams; } catch { qs = new URLSearchParams(); }
|
|
150
|
-
// Require Content-Length header: rejects chunked or missing-length requests
|
|
151
|
-
// that could claim any size. Pre-validates the announced size before streaming.
|
|
152
|
-
const contentLength = req.headers['content-length'];
|
|
153
|
-
if (!contentLength) {
|
|
154
|
-
sendJSON(req, res, 411, { error: 'length required' }); return;
|
|
155
|
-
}
|
|
156
|
-
const MAX_UPLOAD = 50 * 1024 * 1024;
|
|
157
|
-
const len = parseInt(contentLength, 10);
|
|
158
|
-
if (isNaN(len) || len < 0 || len > MAX_UPLOAD) {
|
|
159
|
-
sendJSON(req, res, 413, { error: `file too large (max ${MAX_UPLOAD} bytes)` }); return;
|
|
160
|
-
}
|
|
161
|
-
const allowRoots = fsAllowRoots();
|
|
162
|
-
const conf = confineToRoots(qs.get('dir') || '', allowRoots);
|
|
163
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
|
|
164
|
-
const name = sanitizeEntryName(qs.get('name'));
|
|
165
|
-
if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
|
|
166
|
-
if (SECRET_RE.test(name)) { sendJSON(req, res, 403, { error: 'forbidden: secret/dotfile name' }); return; }
|
|
167
|
-
const target = path.join(conf.realPath, name);
|
|
168
|
-
if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
|
|
169
|
-
// Stream the upload body to a temp file to keep memory constant and
|
|
170
|
-
// avoid blocking the event loop with a large synchronous writeFileSync.
|
|
171
|
-
const tmpPath = target + '.tmp.' + crypto.randomBytes(6).toString('hex');
|
|
172
|
-
try {
|
|
173
|
-
await new Promise((resolve, reject) => {
|
|
174
|
-
const ws = fs.createWriteStream(tmpPath);
|
|
175
|
-
let total = 0;
|
|
176
|
-
ws.on('error', reject);
|
|
177
|
-
req.on('error', reject);
|
|
178
|
-
req.on('data', (chunk) => {
|
|
179
|
-
total += chunk.length;
|
|
180
|
-
if (total > MAX_UPLOAD) {
|
|
181
|
-
ws.destroy();
|
|
182
|
-
req.destroy();
|
|
183
|
-
const e = new Error('file too large (50MB cap)'); e.code = 'TOO_LARGE';
|
|
184
|
-
reject(e); return;
|
|
185
|
-
}
|
|
186
|
-
ws.write(chunk);
|
|
187
|
-
});
|
|
188
|
-
req.on('end', () => ws.end());
|
|
189
|
-
ws.on('finish', () => resolve(total));
|
|
190
|
-
});
|
|
191
|
-
fs.renameSync(tmpPath, target);
|
|
192
|
-
const uploadedSize = fs.statSync(target).size;
|
|
193
|
-
sendJSON(req, res, 200, { ok: true, path: target, size: uploadedSize });
|
|
194
|
-
} catch (err) {
|
|
195
|
-
try { fs.unlinkSync(tmpPath); } catch (_) {}
|
|
196
|
-
if (err.code === 'TOO_LARGE') { sendJSON(req, res, 413, { error: 'file too large (50MB cap)' }); return; }
|
|
197
|
-
sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: 'upload failed' });
|
|
198
|
-
}
|
|
199
|
-
}
|
package/lib/http-routes/reads.js
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import {
|
|
5
|
-
confineToRoots, fsAllowRoots, resolveConfinedPath, safeErrMsg, SECRET_RE,
|
|
6
|
-
} from './shared.js';
|
|
7
|
-
|
|
8
|
-
// Existence/shape probe for a proposed chat working directory, confined
|
|
9
|
-
// to the same allowlist as the Files surface (an unconfined stat would be
|
|
10
|
-
// a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
|
|
11
|
-
export function handleStat(req, res, routePath, sendJSON) {
|
|
12
|
-
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/stat');
|
|
13
|
-
const conf = confineToRoots(decodedPath, fsAllowRoots());
|
|
14
|
-
if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: conf.reason }); return; }
|
|
15
|
-
try {
|
|
16
|
-
const st = fs.statSync(conf.realPath);
|
|
17
|
-
sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
|
|
18
|
-
} catch (err) {
|
|
19
|
-
sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: safeErrMsg(err) });
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Directory listing for the Files / folder-browser view (mirrors fsbrowse
|
|
24
|
-
// /api/list). Confined to an allowlist root exactly like /api/image - the
|
|
25
|
-
// normalize-then-prefix-check is the real guard (a `..` test after
|
|
26
|
-
// path.normalize is a no-op). Returns {path, segments, entries} so the kit
|
|
27
|
-
// FileGrid + BreadcrumbPath render directly. Allowed roots default to the
|
|
28
|
-
// server cwd + Claude projects dir; widen via FS_ROOTS (path-separated).
|
|
29
|
-
export function handleList(req, res, routePath, sendJSON) {
|
|
30
|
-
const decodedPath = resolveConfinedPath(req, routePath, 'dir', '/api/list');
|
|
31
|
-
const allowRoots = fsAllowRoots();
|
|
32
|
-
// Empty path = the first allow-root (a sane default landing dir).
|
|
33
|
-
const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
|
|
34
|
-
const conf = confineToRoots(reqPath, allowRoots);
|
|
35
|
-
if (!conf.ok) {
|
|
36
|
-
const code = conf.reason === 'not found' ? 404 : 403;
|
|
37
|
-
sendJSON(req, res, code, { error: 'forbidden: ' + conf.reason }); return;
|
|
38
|
-
}
|
|
39
|
-
// Use the symlink-resolved real path for all reads.
|
|
40
|
-
const normalizedPath = conf.realPath;
|
|
41
|
-
try {
|
|
42
|
-
const st = fs.statSync(normalizedPath);
|
|
43
|
-
if (!st.isDirectory()) { sendJSON(req, res, 400, { error: 'not a directory' }); return; }
|
|
44
|
-
// Classify by extension into the kit's data-file-type buckets so
|
|
45
|
-
// FileGrid renders the right rail/icon. dir/symlink come from stat.
|
|
46
|
-
const EXT_TYPE = {
|
|
47
|
-
image: ['png','jpg','jpeg','gif','webp','svg','bmp','ico','avif'],
|
|
48
|
-
video: ['mp4','webm','mov','mkv','avi','m4v'],
|
|
49
|
-
audio: ['mp3','wav','ogg','flac','m4a','aac'],
|
|
50
|
-
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'],
|
|
51
|
-
text: ['txt','md','log','csv','env'],
|
|
52
|
-
archive: ['zip','tar','gz','tgz','rar','7z','bz2','xz'],
|
|
53
|
-
document: ['pdf','doc','docx','xls','xlsx','ppt','pptx','odt'],
|
|
54
|
-
};
|
|
55
|
-
const typeFor = (name, dirent) => {
|
|
56
|
-
if (dirent.isSymbolicLink()) return 'symlink';
|
|
57
|
-
if (dirent.isDirectory()) return 'dir';
|
|
58
|
-
const ext = path.extname(name).slice(1).toLowerCase();
|
|
59
|
-
for (const [t, exts] of Object.entries(EXT_TYPE)) if (exts.includes(ext)) return t;
|
|
60
|
-
return 'other';
|
|
61
|
-
};
|
|
62
|
-
const dirents = fs.readdirSync(normalizedPath, { withFileTypes: true }).filter((d) => !SECRET_RE.test(d.name));
|
|
63
|
-
const entries = dirents.map((d) => {
|
|
64
|
-
const full = path.join(normalizedPath, d.name);
|
|
65
|
-
let size = null, modified = null, permissions;
|
|
66
|
-
try {
|
|
67
|
-
const s = fs.statSync(full); size = s.isDirectory() ? null : s.size; modified = s.mtime.toISOString();
|
|
68
|
-
// Per-entry permission probe (mirrors fsbrowse checkPermissions) so
|
|
69
|
-
// the row reads honestly (read-only / no access) instead of a silent
|
|
70
|
-
// size:null on a stat failure. A stat success means at least read.
|
|
71
|
-
const perms = ['read'];
|
|
72
|
-
try { fs.accessSync(full, fs.constants.W_OK); perms.push('write'); } catch (_) {}
|
|
73
|
-
permissions = perms;
|
|
74
|
-
} catch (e) {
|
|
75
|
-
// Could not stat (commonly EACCES): mark no-access so the client
|
|
76
|
-
// disables open + shows the tag, rather than failing silently.
|
|
77
|
-
permissions = e && e.code === 'EACCES' ? 'EACCES' : [];
|
|
78
|
-
}
|
|
79
|
-
return { name: d.name, type: typeFor(d.name, d), size, modified, path: full, permissions };
|
|
80
|
-
}).sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
|
|
81
|
-
// Breadcrumb segments from the absolute path (drive/root aware).
|
|
82
|
-
const segments = normalizedPath.split(/[\\\/]+/).filter(Boolean);
|
|
83
|
-
sendJSON(req, res, 200, { path: normalizedPath, segments, entries, roots: allowRoots });
|
|
84
|
-
} catch (err) {
|
|
85
|
-
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
86
|
-
sendJSON(req, res, code, { error: safeErrMsg(err) });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Raw file bytes for the Files preview pane. Confined to the SAME
|
|
91
|
-
// allowlist roots as /api/list (server cwd + Claude projects dir, widened
|
|
92
|
-
// via FS_ROOTS). Same normalize-then-prefix-check guard. Capped at 512KB
|
|
93
|
-
// and limited to reasonable text/code/image types so this is never a
|
|
94
|
-
// generic arbitrary-file reader. Images are served via /api/image; this
|
|
95
|
-
// returns text/* with utf-8.
|
|
96
|
-
export function handleFile(req, res, routePath) {
|
|
97
|
-
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/file/');
|
|
98
|
-
const allowRoots = fsAllowRoots();
|
|
99
|
-
const conf = confineToRoots(decodedPath, allowRoots);
|
|
100
|
-
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
101
|
-
const normalizedPath = conf.realPath;
|
|
102
|
-
// Block secret-bearing files regardless of root: dotfiles, env/key/cert
|
|
103
|
-
// material, and credential stores must never be readable through the
|
|
104
|
-
// Files preview even when they sit inside an allowed root.
|
|
105
|
-
const base = path.basename(normalizedPath);
|
|
106
|
-
if (SECRET_RE.test(base)) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
107
|
-
// Only known text/code extensions (images go through /api/image). An
|
|
108
|
-
// unknown/binary extension is rejected, never served as octet-stream.
|
|
109
|
-
// env/conf/cfg/ini are dropped - they commonly carry secrets.
|
|
110
|
-
const TEXT_EXTS = new Set([
|
|
111
|
-
'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',
|
|
112
|
-
'txt','md','log','csv','xml','gitignore','dockerfile','svg',
|
|
113
|
-
]);
|
|
114
|
-
const ext = path.extname(normalizedPath).slice(1).toLowerCase()
|
|
115
|
-
|| path.basename(normalizedPath).toLowerCase();
|
|
116
|
-
if (!TEXT_EXTS.has(ext)) { res.writeHead(403); res.end('Forbidden: unsupported type'); return; }
|
|
117
|
-
try {
|
|
118
|
-
const st = fs.statSync(normalizedPath);
|
|
119
|
-
if (!st.isFile()) { res.writeHead(400); res.end('Not a file'); return; }
|
|
120
|
-
const MAX = 512 * 1024;
|
|
121
|
-
const fd = fs.openSync(normalizedPath, 'r');
|
|
122
|
-
const len = Math.min(st.size, MAX);
|
|
123
|
-
const buf = Buffer.alloc(len);
|
|
124
|
-
fs.readSync(fd, buf, 0, len, 0);
|
|
125
|
-
fs.closeSync(fd);
|
|
126
|
-
res.writeHead(200, {
|
|
127
|
-
'Content-Type': 'text/plain; charset=utf-8',
|
|
128
|
-
'Cache-Control': 'no-cache',
|
|
129
|
-
'X-File-Truncated': st.size > MAX ? '1' : '0',
|
|
130
|
-
});
|
|
131
|
-
res.end(buf);
|
|
132
|
-
} catch (err) {
|
|
133
|
-
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
134
|
-
res.writeHead(code); res.end(safeErrMsg(err));
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Confined raw-bytes download (any type) with an attachment disposition,
|
|
139
|
-
// so the Files view can offer download on a row. Same allowlist + realpath
|
|
140
|
-
// confinement as /api/file and /api/image - never a generic file reader.
|
|
141
|
-
export function handleDownload(req, res, routePath) {
|
|
142
|
-
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/download/');
|
|
143
|
-
const allowRoots = fsAllowRoots();
|
|
144
|
-
const conf = confineToRoots(decodedPath, allowRoots);
|
|
145
|
-
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
|
|
146
|
-
const normalizedPath = conf.realPath;
|
|
147
|
-
// Same secret-name block as /api/file: a download must not exfiltrate
|
|
148
|
-
// .env/.pem/.key/credential material just because it streams bytes.
|
|
149
|
-
if (SECRET_RE.test(path.basename(normalizedPath))) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
150
|
-
try {
|
|
151
|
-
const st = fs.statSync(normalizedPath);
|
|
152
|
-
if (!st.isFile()) { res.writeHead(400); res.end('Not a file'); return; }
|
|
153
|
-
const MAX = 50 * 1024 * 1024; // 50MB cap so a download can't exhaust memory
|
|
154
|
-
if (st.size > MAX) { res.writeHead(413); res.end('File too large to download'); return; }
|
|
155
|
-
const base = path.basename(normalizedPath);
|
|
156
|
-
const asciiName = base.replace(/[\\"\r\n]/g, '').replace(/[^\x20-\x7e]/g, '_');
|
|
157
|
-
res.writeHead(200, {
|
|
158
|
-
'Content-Type': 'application/octet-stream',
|
|
159
|
-
'Content-Disposition': 'attachment; filename="' + asciiName + '"; filename*=UTF-8\'\'' + encodeURIComponent(base),
|
|
160
|
-
'Content-Length': String(st.size),
|
|
161
|
-
'Cache-Control': 'no-cache',
|
|
162
|
-
});
|
|
163
|
-
const rs = fs.createReadStream(normalizedPath);
|
|
164
|
-
rs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
165
|
-
rs.pipe(res);
|
|
166
|
-
} catch (err) {
|
|
167
|
-
const code = err && err.code === 'ENOENT' ? 404 : (err && err.code === 'EACCES' ? 403 : 400);
|
|
168
|
-
res.writeHead(code); res.end(safeErrMsg(err));
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Confined image bytes with a content-type allowlist (SVG intentionally
|
|
173
|
-
// excluded - see inline comment below). Its own allowlist roots (Claude
|
|
174
|
-
// projects dir + IMAGE_ROOTS), distinct from fsAllowRoots(), because the
|
|
175
|
-
// user home is never a default root for arbitrary image reads.
|
|
176
|
-
export function handleImage(req, res, routePath, sendJSON) {
|
|
177
|
-
const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/image/');
|
|
178
|
-
// Confine reads to an allowlist root. Without this the route is an
|
|
179
|
-
// arbitrary-file-read of any image-extensioned path on the host (the
|
|
180
|
-
// prior `includes('..')` guard is a no-op after path.normalize resolves
|
|
181
|
-
// the segments). Allowed roots: the Claude projects dir (history images)
|
|
182
|
-
// only; add more via IMAGE_ROOTS (path-separated). The user home is NOT
|
|
183
|
-
// a default root - it covers ~/.ssh, ~/.aws, dotfiles etc., so an image
|
|
184
|
-
// route reaching all of home is a broad read of anything image-shaped.
|
|
185
|
-
// confineToRoots also realpath-resolves so a symlink inside a root can't
|
|
186
|
-
// point an image read at an out-of-root file.
|
|
187
|
-
const allowRoots = [
|
|
188
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
189
|
-
...(process.env.IMAGE_ROOTS ? process.env.IMAGE_ROOTS.split(path.delimiter) : []),
|
|
190
|
-
].map(r => path.normalize(r));
|
|
191
|
-
const conf = confineToRoots(decodedPath, allowRoots);
|
|
192
|
-
if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end(conf.reason === 'not found' ? 'Not found' : 'Forbidden'); return; }
|
|
193
|
-
const normalizedPath = conf.realPath;
|
|
194
|
-
try {
|
|
195
|
-
const ext = path.extname(normalizedPath).toLowerCase();
|
|
196
|
-
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
197
|
-
// SVG is intentionally excluded: browsers render SVG as a live document
|
|
198
|
-
// in the app's origin, so an agent-written SVG with a <script src=CDN>
|
|
199
|
-
// would execute in the agentgui origin (CSP allows unpkg/jsdelivr).
|
|
200
|
-
// Files preview uses /api/file/download (attachment) for SVG.
|
|
201
|
-
const contentType = mimeTypes[ext];
|
|
202
|
-
if (!contentType) { res.writeHead(403); res.end('Forbidden'); return; }
|
|
203
|
-
const imgSt = fs.statSync(normalizedPath);
|
|
204
|
-
const IMG_MAX = 20 * 1024 * 1024; // 20MB hard cap
|
|
205
|
-
if (imgSt.size > IMG_MAX) { res.writeHead(413); res.end('Image too large'); return; }
|
|
206
|
-
// Always stream to avoid blocking the event loop on large synchronous reads.
|
|
207
|
-
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache', 'Content-Length': String(imgSt.size) });
|
|
208
|
-
const imgRs = fs.createReadStream(normalizedPath);
|
|
209
|
-
imgRs.on('error', (streamErr) => { if (!res.writableEnded) res.destroy(streamErr); });
|
|
210
|
-
imgRs.pipe(res);
|
|
211
|
-
} catch (err) { sendJSON(req, res, 400, { error: 'cannot read image' }); }
|
|
212
|
-
}
|