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.

Files changed (170) hide show
  1. package/.gm/browser-config.json +1 -0
  2. package/.gm/disciplines/agentgui/mem-1779277005143-0-1352.json +1 -0
  3. package/.gm/disciplines/agentgui/memories/.flat-export-done +0 -0
  4. package/.gm/disciplines/agentgui/memories/mem-c07fa3b0836edbe2-1352.md +8 -0
  5. package/.gm/gm.db +0 -0
  6. package/.gm/memories/.flat-export-done +1 -0
  7. package/.gm/memories/mem-0131ff4869d86085-370.md +10 -0
  8. package/.gm/memories/mem-04049d11cb8c1661-2135.md +8 -0
  9. package/.gm/memories/mem-069ff10372ed1770-1375.md +8 -0
  10. package/.gm/memories/mem-08a4a9e050a4deff-3048.md +8 -0
  11. package/.gm/memories/mem-09597ee0df9c9224-965.md +8 -0
  12. package/.gm/memories/mem-0993d156f571b619-1059.md +8 -0
  13. package/.gm/memories/mem-0b681bf2340c8df1-3170.md +8 -0
  14. package/.gm/memories/mem-0c444abde86202f6-1681.md +8 -0
  15. package/.gm/memories/mem-0d1408fa048b9923-1159.md +8 -0
  16. package/.gm/memories/mem-0dad5d82fec829ba-887.md +8 -0
  17. package/.gm/memories/mem-10a776effb7ce3a6-935.md +8 -0
  18. package/.gm/memories/mem-10d264b4f8e66fb4-1492.md +8 -0
  19. package/.gm/memories/mem-118c35ac67541d98-2909.md +8 -0
  20. package/.gm/memories/mem-119cf70dd19aa0a5-2225.md +8 -0
  21. package/.gm/memories/mem-15a07cd761b337c4-4320.md +8 -0
  22. package/.gm/memories/mem-15c73aca528535cb-2183.md +8 -0
  23. package/.gm/memories/mem-166f52796185e0e7-1487.md +8 -0
  24. package/.gm/memories/mem-1a148cf9c3126df5-3963.md +8 -0
  25. package/.gm/memories/mem-1a459001058f618e-567.md +8 -0
  26. package/.gm/memories/mem-1af3e13f7618e668-2146.md +8 -0
  27. package/.gm/memories/mem-1fd31d6873e9c164-794.md +8 -0
  28. package/.gm/memories/mem-21f551be092fd77d-2340.md +8 -0
  29. package/.gm/memories/mem-231ff5de003f20f0-586.md +8 -0
  30. package/.gm/memories/mem-2567393d3adb03ab-347.md +8 -0
  31. package/.gm/memories/mem-27faf41cdd050c80-1261.md +8 -0
  32. package/.gm/memories/mem-28889e43e8ce64cc-3663.md +8 -0
  33. package/.gm/memories/mem-2f4fd06515abf439-2562.md +8 -0
  34. package/.gm/memories/mem-34a749430303c5f7-544.md +8 -0
  35. package/.gm/memories/mem-3602d11d7bce9446-1057.md +8 -0
  36. package/.gm/memories/mem-38b7e24dbcba2f3e-639.md +8 -0
  37. package/.gm/memories/mem-39a7cc2f887a90b1-1079.md +8 -0
  38. package/.gm/memories/mem-40d6169c8759061d-806.md +8 -0
  39. package/.gm/memories/mem-4420e6d00e5c8632-2318.md +8 -0
  40. package/.gm/memories/mem-447e5a54ddc9357a-654.md +8 -0
  41. package/.gm/memories/mem-455b3beb4afabe14-591.md +8 -0
  42. package/.gm/memories/mem-4845ee594759151c-560.md +8 -0
  43. package/.gm/memories/mem-4a1254c60b868b88-374.md +8 -0
  44. package/.gm/memories/mem-4bafafa47a0e4b6c-3237.md +8 -0
  45. package/.gm/memories/mem-4c06c45b4d31bc60-1202.md +8 -0
  46. package/.gm/memories/mem-4dfac801d0378448-3676.md +8 -0
  47. package/.gm/memories/mem-4ecd3b713e384170-122.md +10 -0
  48. package/.gm/memories/mem-51d8992a3c6f6c9c-1073.md +8 -0
  49. package/.gm/memories/mem-539eee7e8c6c10ee-2557.md +8 -0
  50. package/.gm/memories/mem-559b8b2abc4b1091-1665.md +8 -0
  51. package/.gm/memories/mem-59dd73a463e54948-871.md +8 -0
  52. package/.gm/memories/mem-5b99b882ffe1a3c2-2262.md +8 -0
  53. package/.gm/memories/mem-5feb252a5244149c-1791.md +8 -0
  54. package/.gm/memories/mem-6224f8d185797f2b-1047.md +8 -0
  55. package/.gm/memories/mem-6434ee8261a6a6a2-3126.md +8 -0
  56. package/.gm/memories/mem-66b0947b946f3437-1155.md +8 -0
  57. package/.gm/memories/mem-6bdf783a1f6be7c2-1062.md +8 -0
  58. package/.gm/memories/mem-6d49f08bebac54c9-744.md +8 -0
  59. package/.gm/memories/mem-70d96acb736af2d7-685.md +8 -0
  60. package/.gm/memories/mem-70de7d5e71fcd439-2680.md +8 -0
  61. package/.gm/memories/mem-713614f12d7029da-1577.md +8 -0
  62. package/.gm/memories/mem-720d76c88e64f6ee-1967.md +8 -0
  63. package/.gm/memories/mem-7535f34f430fc196-1340.md +8 -0
  64. package/.gm/memories/mem-861a588d2773681a-1311.md +8 -0
  65. package/.gm/memories/mem-8af7980a92e09959-891.md +8 -0
  66. package/.gm/memories/mem-8b438ef4c609b7af-401.md +8 -0
  67. package/.gm/memories/mem-8f59884039787ef3-743.md +8 -0
  68. package/.gm/memories/mem-92b199c2024ee1aa-2513.md +8 -0
  69. package/.gm/memories/mem-9329105b05b9c0f6-324.md +10 -0
  70. package/.gm/memories/mem-94f30cec3c283f97-782.md +10 -0
  71. package/.gm/memories/mem-956d4813319f65a3-1710.md +8 -0
  72. package/.gm/memories/mem-96fffbb6615e031b-1263.md +8 -0
  73. package/.gm/memories/mem-993ab31f7a10aefa-718.md +8 -0
  74. package/.gm/memories/mem-a3e440db285f655a-995.md +8 -0
  75. package/.gm/memories/mem-a52933cd6495e6f5-2624.md +8 -0
  76. package/.gm/memories/mem-a86426ac44138c96-1606.md +8 -0
  77. package/.gm/memories/mem-a95b1d49d71133ce-1007.md +8 -0
  78. package/.gm/memories/mem-a9968a186368b022-1150.md +8 -0
  79. package/.gm/memories/mem-aaeae38d8eb01809-1076.md +8 -0
  80. package/.gm/memories/mem-ade01d9c02677c55-1557.md +8 -0
  81. package/.gm/memories/mem-b06b8c85b81663e6-1250.md +8 -0
  82. package/.gm/memories/mem-b451e5e17e555202-1490.md +8 -0
  83. package/.gm/memories/mem-b64ad00dcb6f909e-937.md +8 -0
  84. package/.gm/memories/mem-b7d6018ceca55b6f-2220.md +8 -0
  85. package/.gm/memories/mem-bdf466854032cac9-1509.md +8 -0
  86. package/.gm/memories/mem-c0482039e6ab4c50-2047.md +8 -0
  87. package/.gm/memories/mem-c4aaec5c4e6f80c2-920.md +8 -0
  88. package/.gm/memories/mem-c66976ddacb4fbe8-1454.md +8 -0
  89. package/.gm/memories/mem-c7c5bc263850c332-1844.md +8 -0
  90. package/.gm/memories/mem-cda639547932c796-713.md +8 -0
  91. package/.gm/memories/mem-cdf04ff6a6a0aa6f-671.md +8 -0
  92. package/.gm/memories/mem-cf8472c84d9e04c4-1883.md +8 -0
  93. package/.gm/memories/mem-cfd4d894370c63c3-1288.md +8 -0
  94. package/.gm/memories/mem-d060480435f92eec-1146.md +8 -0
  95. package/.gm/memories/mem-d347c921b79e6d44-1099.md +8 -0
  96. package/.gm/memories/mem-d3c8ea6c861bfbb8-1457.md +8 -0
  97. package/.gm/memories/mem-d5e928c552cd87f9-214.md +10 -0
  98. package/.gm/memories/mem-d63bc3b8a0269844-406.md +10 -0
  99. package/.gm/memories/mem-d9c19257abc2fa5e-1061.md +8 -0
  100. package/.gm/memories/mem-d9f26358a7cd3bb8-866.md +8 -0
  101. package/.gm/memories/mem-da1c91acf63d81d3-863.md +8 -0
  102. package/.gm/memories/mem-dc1ac3b04d22317e-664.md +8 -0
  103. package/.gm/memories/mem-dceeae20bc56d407-711.md +8 -0
  104. package/.gm/memories/mem-dd98b21c0896dc04-1557.md +8 -0
  105. package/.gm/memories/mem-df74674eec7745ac-178.md +10 -0
  106. package/.gm/memories/mem-dfe3b466fd5d1199-802.md +8 -0
  107. package/.gm/memories/mem-e0f9286aed2d5a06-1116.md +8 -0
  108. package/.gm/memories/mem-e1950b064099b7be-1895.md +8 -0
  109. package/.gm/memories/mem-e1d5431ae2542872-961.md +8 -0
  110. package/.gm/memories/mem-e490a181de6469fc-487.md +8 -0
  111. package/.gm/memories/mem-e72c6efbb27326fb-392.md +8 -0
  112. package/.gm/memories/mem-e943b58115a02b4c-593.md +8 -0
  113. package/.gm/memories/mem-e9bc5f9ed6edbcf7-1580.md +8 -0
  114. package/.gm/memories/mem-eae1e383927854fb-2649.md +8 -0
  115. package/.gm/memories/mem-ebbe6c0694c526f0-1392.md +8 -0
  116. package/.gm/memories/mem-ebf85becc23410b0-1633.md +8 -0
  117. package/.gm/memories/mem-ee0596fa17c7102e-362.md +10 -0
  118. package/.gm/memories/mem-ee4450fbc311b583-1361.md +8 -0
  119. package/.gm/memories/mem-ee8858162518f144-1252.md +8 -0
  120. package/.gm/memories/mem-f3297e82df622649-1166.md +8 -0
  121. package/.gm/memories/mem-f3f317c083d979ee-1210.md +8 -0
  122. package/.gm/memories/mem-f714a86465fba158-2041.md +8 -0
  123. package/.gm/memories/mem-f786bc1626d92845-204.md +10 -0
  124. package/.gm/memories/mem-f9f0dc8895b21e18-1217.md +8 -0
  125. package/.gm/memories/mem-fb3b2a7395800041-525.md +8 -0
  126. package/.gm/memories/mem-fc5753f92ee7d654-966.md +8 -0
  127. package/.gm/memories/mem-fe3faa1652f07239-480.md +8 -0
  128. package/.gm/memories/mem-ff8669e527e5c427-2788.md +8 -0
  129. package/.gm/mutables.yml +53 -0
  130. package/.gm/prd.yml +3342 -1
  131. package/AGENTS.md +122 -122
  132. package/UX_OPTIMIZATION_SUMMARY.md +238 -0
  133. package/agentgui-after.png +0 -0
  134. package/agentgui-current.png +0 -0
  135. package/agentgui-final.png +0 -0
  136. package/agentgui-nobrand.png +0 -0
  137. package/agentgui-now.png +0 -0
  138. package/agentgui-v2.png +0 -0
  139. package/agentgui-v4.png +0 -0
  140. package/bash.exe.stackdump +28 -0
  141. package/database.js +110 -109
  142. package/design-reference.png +0 -0
  143. package/lib/acp-sdk-manager.js +9 -42
  144. package/lib/claude-runner-acp.js +155 -213
  145. package/lib/claude-runner-agents.js +157 -170
  146. package/lib/http-handler.js +931 -400
  147. package/lib/ws-handlers-util.js +466 -11
  148. package/package.json +1 -1
  149. package/site/app/js/app.js +4741 -4221
  150. package/site/app/js/backend.js +602 -621
  151. package/site/app/vendor/anentrypoint-design/247420.css +13673 -14799
  152. package/site/app/vendor/anentrypoint-design/247420.js +449 -735
  153. package/.gm/.embed-generation.code_chunks +0 -1
  154. package/.gm/.embed-generation.git_commit_vectors +0 -1
  155. package/.gm/.embed-generation.memories +0 -1
  156. package/.gm/.embed-generation.rssearch_vectors +0 -1
  157. package/lib/acp-http-protocol.js +0 -95
  158. package/lib/http-routes/mutations.js +0 -199
  159. package/lib/http-routes/reads.js +0 -212
  160. package/lib/http-routes/shared.js +0 -225
  161. package/lib/ws-handlers/agents.js +0 -137
  162. package/lib/ws-handlers/chat.js +0 -157
  163. package/lib/ws-handlers/git.js +0 -159
  164. package/lib/ws-handlers/misc.js +0 -36
  165. package/lib/ws-handlers/shared.js +0 -47
  166. package/lib/ws-handlers/terminal-state.js +0 -16
  167. package/site/app/js/chat-persistence.js +0 -123
  168. package/site/app/js/hash-routing.js +0 -62
  169. package/site/app/js/history.js +0 -435
  170. package/site/app/js/shortcuts.js +0 -128
@@ -1,400 +1,931 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import crypto from 'crypto';
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';
9
-
10
- export { confineToRoots, fsAllowRoots, maskToken, SECRET_RE };
11
-
12
- export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
13
- // Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
14
- // cross-origin page can make credentialless fetch() calls to all /api/*
15
- // endpoints (list, file, download, rename, delete, mkdir) and read or
16
- // modify the filesystem. The existing code is structurally correct (no
17
- // Access-Control-Allow-Credentials with wildcard), but the exposure is
18
- // easy to miss.
19
- if (process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD) {
20
- 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.');
21
- }
22
- return async function httpHandler(req, res) {
23
- // CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
24
- // let any webpage the user visits make credentialless fetches to /api/list,
25
- // /api/file/*, etc. and read ~/.claude/projects content — even on a no-
26
- // PASSWORD localhost deploy. Set CORS_ORIGIN=<origin> for cross-origin tools.
27
- const _corsOrigin = process.env.CORS_ORIGIN;
28
- if (_corsOrigin) {
29
- // Never set a wildcard when credentials (cookies) may be in play — a
30
- // wildcard + credentials is rejected by browsers and leaks session tokens.
31
- // A specific origin allows credentialed cross-origin requests safely.
32
- res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
33
- if (_corsOrigin !== '*') res.setHeader('Access-Control-Allow-Credentials', 'true');
34
- res.setHeader('Vary', 'Origin');
35
- // Allow-Methods and Allow-Headers are only meaningful on CORS preflights
36
- // or CORS requests; emitting them unconditionally leaks the method surface
37
- // to same-origin (and non-CORS) responses unnecessarily.
38
- if (req.method === 'OPTIONS' || req.headers['origin']) {
39
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
40
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
41
- }
42
- } // no ACAO header -> browsers enforce same-origin by default
43
- // The password can ride in a ?token= query param (EventSource/deep-links
44
- // can't set headers), so a navigation away from the app would leak it in
45
- // the Referer header to the destination. Strip the referrer on every
46
- // response so the credential never crosses an origin boundary that way.
47
- res.setHeader('Referrer-Policy', 'no-referrer');
48
- // Prevent MIME-sniffing attacks on all responses including file downloads.
49
- res.setHeader('X-Content-Type-Options', 'nosniff');
50
- // Emit HSTS when the connection is known-TLS so clients pin HTTPS and
51
- // refuse future downgrade attempts (protects cookie + ?token= in transit).
52
- if (req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https') {
53
- res.setHeader('Strict-Transport-Security', 'max-age=31536000');
54
- }
55
- // CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
56
- // from unpkg/jsdelivr; everything else is self. connect-src also allows
57
- // self for the same-origin WS/SSE. script-src drops 'unsafe-inline' in
58
- // favour of a per-request nonce threaded onto every server-injected and
59
- // static inline <script> (bootstrap, hot-reload, importmap) by the asset
60
- // server. style-src keeps 'unsafe-inline' because the DS injects a runtime
61
- // <style> with no hook to nonce.
62
- const cspNonce = crypto.randomBytes(16).toString('base64');
63
- res.setHeader('Content-Security-Policy', [
64
- "default-src 'self'",
65
- `script-src 'self' 'nonce-${cspNonce}' https://unpkg.com https://cdn.jsdelivr.net`,
66
- "style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://fonts.googleapis.com",
67
- "img-src 'self' data: blob:",
68
- "font-src 'self' data: https://unpkg.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
69
- "connect-src 'self' https://unpkg.com https://cdn.jsdelivr.net ws: wss:",
70
- "object-src 'none'",
71
- "base-uri 'self'",
72
- "frame-ancestors 'self'",
73
- ].join('; '));
74
- if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
75
- if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
76
-
77
- // Only honour X-Forwarded-For behind a trusted proxy (TRUST_PROXY=1), and
78
- // then take the RIGHT-MOST hop (the one the trusted proxy itself observed,
79
- // not a client-spoofed left-most value). Otherwise the socket peer is the
80
- // only trustworthy source - a client can forge the header freely.
81
- let clientIp;
82
- if (process.env.TRUST_PROXY === '1' && req.headers['x-forwarded-for']) {
83
- const hops = req.headers['x-forwarded-for'].split(',').map(s => s.trim()).filter(Boolean);
84
- clientIp = hops[hops.length - 1] || req.socket.remoteAddress;
85
- } else {
86
- clientIp = req.socket.remoteAddress;
87
- }
88
- // Normalize IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 -> 1.2.3.4) so the
89
- // rate-limit bucket is the same regardless of how the OS presents the peer.
90
- if (clientIp && clientIp.startsWith('::ffff:')) clientIp = clientIp.slice(7);
91
- const hits = (rateLimitMap.get(clientIp) || 0) + 1;
92
- rateLimitMap.set(clientIp, hits);
93
- res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
94
- res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
95
- if (hits > RATE_LIMIT_MAX) { res.writeHead(429, { 'Retry-After': '60' }); res.end('Too Many Requests'); return; }
96
-
97
- const _pwd = process.env.PASSWORD;
98
- // Optional: exempt /health from auth so container/k8s probes work
99
- // without distributing the password to monitoring infra.
100
- const _bareEarly = req.url.split('?')[0];
101
- const _healthExempt = process.env.HEALTH_NO_AUTH === '1' && (_bareEarly === '/health' || _bareEarly === '/api/health' || _bareEarly === (BASE_URL + '/health') || _bareEarly === (BASE_URL + '/api/health'));
102
- if (_pwd && !_healthExempt) {
103
- const _auth = req.headers['authorization'] || '';
104
- let _ok = false;
105
- // Constant-time compare over fixed-width SHA-256 digests so neither the
106
- // password length nor a byte-position mismatch leaks via timing.
107
- const _checkToken = (tok) => {
108
- try {
109
- const a = crypto.createHash('sha256').update(String(tok)).digest();
110
- const b = crypto.createHash('sha256').update(String(_pwd)).digest();
111
- return crypto.timingSafeEqual(a, b);
112
- } catch { return false; }
113
- };
114
- if (_auth.startsWith('Basic ')) {
115
- try {
116
- const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
117
- const _ci = _decoded.indexOf(':');
118
- if (_ci !== -1) _ok = _checkToken(_decoded.slice(_ci + 1));
119
- } catch (_) {}
120
- } else if (_auth.startsWith('Bearer ')) {
121
- const bearerToken = _auth.slice(7);
122
- // Validate Bearer token format: non-empty, no whitespace. Prevents
123
- // timing-attack length inference if PASSWORD contains spaces.
124
- if (/^[\S]+$/.test(bearerToken)) _ok = _checkToken(bearerToken);
125
- }
126
- // EventSource and same-origin links can't set headers - accept ?token= as fallback.
127
- let _viaQuery = false;
128
- if (!_ok) {
129
- try {
130
- const _qsTok = new URL(req.url, 'http://localhost').searchParams.get('token');
131
- if (_qsTok) { _ok = _checkToken(_qsTok); _viaQuery = _ok; }
132
- } catch (_) {}
133
- }
134
- // Static subresources (js/css/vendor) are requested by the BROWSER, which
135
- // can attach neither a header nor a ?token= - without a cookie the SPA
136
- // can never boot behind PASSWORD (index.html 200s, app.js 401s, blank
137
- // page). On any successful auth, set an HttpOnly SameSite=Lax cookie the
138
- // gate accepts for subsequent requests. Lax + the CSRF guard below keep
139
- // cross-site mutations rejected; the value is URL-encoded (a password
140
- // may carry cookie-hostile characters like commas).
141
- if (!_ok) {
142
- try {
143
- const _ck = req.headers['cookie'] || '';
144
- const _m = /(?:^|;\s*)agentgui_token=([^;]+)/.exec(_ck);
145
- if (_m) _ok = _checkToken(decodeURIComponent(_m[1]));
146
- } catch (_) {}
147
- } else if (_viaQuery || _auth) {
148
- try {
149
- // Secure only over a real TLS connection (or behind an https proxy /
150
- // explicit opt-in) so the localhost-http witness still gets a usable
151
- // cookie. Max-Age bounds the credential's lifetime to a day.
152
- const _https = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' || process.env.COOKIE_SECURE === '1';
153
- res.setHeader('Set-Cookie', 'agentgui_token=' + encodeURIComponent(_pwd) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400' + (_https ? '; Secure' : ''));
154
- } catch (_) {}
155
- }
156
- if (!_ok) {
157
- // Penalize failed auth heavily in the rate bucket so a credential
158
- // brute-force trips the 429 limiter long before it can guess.
159
- rateLimitMap.set(clientIp, (rateLimitMap.get(clientIp) || 0) + 100);
160
- res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return;
161
- }
162
- }
163
-
164
- const pathOnly = req.url.split('?')[0];
165
-
166
- // Terminal RCE guard: the terminal surface spawns an interactive shell, so
167
- // it is fail-closed by default. It is reachable ONLY when PASSWORD is set
168
- // AND ENABLE_TERMINAL=1 is explicitly opted in. (Under the localhost-PASSWORD
169
- // witness ENABLE_TERMINAL is unset, so the terminal is correctly disabled -
170
- // it is not part of the witness.)
171
- if (pathOnly.startsWith('/api/terminal/') || pathOnly.startsWith(BASE_URL + '/api/terminal/')) {
172
- if (!process.env.PASSWORD || process.env.ENABLE_TERMINAL !== '1') {
173
- sendJSON(req, res, 403, { error: 'terminal disabled (requires PASSWORD and ENABLE_TERMINAL=1)' });
174
- return;
175
- }
176
- }
177
-
178
- // CSRF guard on every state-changing method. Without PASSWORD the server
179
- // is open and advertises a wildcard ACAO, so a cross-site page could POST
180
- // a form at the mutation routes (rename/delete/mkdir/upload) on localhost.
181
- // A simple cross-site form cannot set Sec-Fetch-Site:same-origin, send
182
- // application/json, or attach custom headers - so: accept when the
183
- // browser says same-origin/none (direct nav, same-origin fetch), when the
184
- // body is declared JSON, or when an Authorization header rode along (a
185
- // credentialed programmatic client). Reject the rest with 403.
186
- if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
187
- const sfs = req.headers['sec-fetch-site'];
188
- const ct = (req.headers['content-type'] || '').toLowerCase();
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';
195
- // Only application/json counts as a non-cross-site body now: a simple
196
- // cross-site <form> can send only urlencoded/multipart/text-plain, and
197
- // octet-stream / empty CT were too broad an escape (a no-CORS fetch can
198
- // send octet-stream). The binary upload PUT still rides the same-origin
199
- // SPA fetch (Sec-Fetch-Site: same-origin), so it passes via sameSite.
200
- const jsonBody = ct.startsWith('application/json');
201
- const authed = !!req.headers['authorization'];
202
- // If an Origin header is present, its host MUST match the Host header -
203
- // a cross-origin page's Origin will not, regardless of Sec-Fetch-Site.
204
- let originOk = true;
205
- const origin = req.headers['origin'];
206
- if (origin) {
207
- try { originOk = new URL(origin).host === req.headers['host']; }
208
- catch { originOk = false; }
209
- }
210
- if (!originOk || (!sameSite && !jsonBody && !authed)) {
211
- res.writeHead(403, { 'Content-Type': 'application/json' });
212
- res.end(JSON.stringify({ error: 'cross-site request rejected' }));
213
- return;
214
- }
215
- }
216
-
217
- if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
218
-
219
- if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
220
- const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
221
- res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
222
- res.end(svg); return;
223
- }
224
-
225
- // serve index.html at root directly (no redirect)
226
-
227
- let routePath = req.url;
228
- const _bareUrl = req.url.split('?')[0];
229
- if (_bareUrl.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
230
- else if (_bareUrl === BASE_URL) { routePath = '/'; }
231
- else if (_bareUrl.startsWith('/api/') || _bareUrl.startsWith('/js/') || _bareUrl.startsWith('/css/') ||
232
- _bareUrl.startsWith('/vendor/') || _bareUrl.startsWith('/sync') || _bareUrl === '/' ||
233
- _bareUrl === '/health' || _bareUrl.startsWith('/v1/') ||
234
- _bareUrl.startsWith('/api/terminal/') ||
235
- _bareUrl.startsWith('/conversations/')) { routePath = req.url; }
236
- else { res.writeHead(404); res.end('Not found'); return; }
237
-
238
- routePath = routePath || '/';
239
-
240
- try {
241
- const pathOnly = routePath.split('?')[0];
242
-
243
- if ((pathOnly === '/api/health' || pathOnly === '/health') && req.method === 'GET') {
244
- let dbStatus = { ok: true };
245
- try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
246
- const queueSizes = {};
247
- for (const [k, v] of messageQueues) queueSizes[k] = v.length;
248
- const _body = {
249
- status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
250
- activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
251
- acp: getACPStatus(), db: dbStatus, queueSizes,
252
- // Surfaces the console-only CORS_ORIGIN=*+no-PASSWORD warning (above)
253
- // as a fact the GUI settings panel can render a persistent banner
254
- // from - an operator not tailing logs otherwise never sees the risk.
255
- corsWildcardOpen: process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD,
256
- };
257
- // Host-internal facts (memory, filesystem paths) are exposed only to an
258
- // authenticated caller, never on the unauthenticated health-probe bypass.
259
- if (!_healthExempt) {
260
- _body.memory = process.memoryUsage();
261
- _body.projectsDir = process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
262
- _body.allowRoots = fsAllowRoots();
263
- }
264
- sendJSON(req, res, 200, _body);
265
- return;
266
- }
267
-
268
- // Existence/shape probe for a proposed chat working directory - see
269
- // lib/http-routes/reads.js for the confinement contract.
270
- if (routePath.startsWith('/api/stat') && req.method === 'GET') {
271
- handleStat(req, res, routePath, sendJSON);
272
- return;
273
- }
274
-
275
- // Terminal sessions - gated by the Basic-auth check at the top of this handler.
276
- // Never expose these routes without PASSWORD set.
277
- if (pathOnly === '/api/terminal/sessions' && req.method === 'GET') {
278
- sendJSON(req, res, 200, term.listSessions()); return;
279
- }
280
- if (pathOnly === '/api/terminal/sessions' && req.method === 'POST') {
281
- let body = ''; for await (const c of req) body += c;
282
- let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
283
- // Never honour a client-supplied shell or env - that would be a direct
284
- // command/arg injection into the spawned process. Only geometry + cwd.
285
- // Confine cwd to allowed roots; fall back to STARTUP_CWD on failure.
286
- let termCwd = process.env.STARTUP_CWD || process.cwd();
287
- if (p.cwd) {
288
- const cwdConf = confineToRoots(p.cwd, fsAllowRoots());
289
- if (cwdConf.ok) termCwd = cwdConf.realPath;
290
- }
291
- const s = term.createSession({ cwd: termCwd, cols: p.cols, rows: p.rows });
292
- 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 });
293
- return;
294
- }
295
- const termMatch = pathOnly.match(/^\/api\/terminal\/sessions\/([0-9a-f]+)$/);
296
- if (termMatch && req.method === 'GET') {
297
- const s = term.getSession(termMatch[1]);
298
- if (!s) { sendJSON(req, res, 404, { error: 'session not found' }); return; }
299
- 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, clients: s.clients.size });
300
- return;
301
- }
302
- if (termMatch && req.method === 'DELETE') {
303
- const ok = term.closeSession(termMatch[1]);
304
- sendJSON(req, res, ok ? 200 : 404, { ok }); return;
305
- }
306
-
307
- // Legacy REST handlers removed. History served by ccsniff at /v1/history/*.
308
- for (const key of Object.keys(routes)) {
309
- try {
310
- const h = routes[key]?._match?.(req.method, pathOnly);
311
- if (h) { await h(req, res); return; }
312
- } catch (_) {}
313
- }
314
- // Directory listing for the Files / folder-browser view - see
315
- // lib/http-routes/reads.js for the confinement contract.
316
- if (routePath.startsWith('/api/list')) {
317
- handleList(req, res, routePath, sendJSON);
318
- return;
319
- }
320
-
321
- // Raw file bytes for the Files preview pane - see lib/http-routes/reads.js.
322
- if (routePath.startsWith('/api/file/') || routePath.startsWith('/api/file?')) {
323
- handleFile(req, res, routePath);
324
- return;
325
- }
326
-
327
- // Confined raw-bytes download - see lib/http-routes/reads.js.
328
- if (routePath.startsWith('/api/download/') || routePath.startsWith('/api/download?')) {
329
- handleDownload(req, res, routePath);
330
- return;
331
- }
332
-
333
- // --- File mutations ----------------------------------------------------
334
- // The Files surface is a real manager (fsbrowse-grade): rename, delete,
335
- // mkdir, upload. See lib/http-routes/mutations.js for the shared
336
- // confinement/sanitization contract every route below follows.
337
-
338
- // POST /api/rename {path, newName} -> {ok, path}
339
- if (routePath.split('?')[0] === '/api/rename' && req.method === 'POST') {
340
- await handleRename(req, res, sendJSON);
341
- return;
342
- }
343
-
344
- // POST /api/move {path, destDir, overwrite?} -> {ok, path}
345
- if (routePath.split('?')[0] === '/api/move' && req.method === 'POST') {
346
- await handleMove(req, res, sendJSON);
347
- return;
348
- }
349
-
350
- // POST /api/delete {path, recursive?} -> {ok}
351
- if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
352
- await handleDelete(req, res, sendJSON);
353
- return;
354
- }
355
-
356
- // POST /api/restore {trashId} -> {ok, path}
357
- if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
358
- await handleRestore(req, res, sendJSON);
359
- return;
360
- }
361
-
362
- // POST /api/mkdir {dir, name} -> {ok, path}
363
- if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
364
- await handleMkdir(req, res, sendJSON);
365
- return;
366
- }
367
-
368
- // PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the body
369
- if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
370
- await handleUploadFile(req, res, sendJSON);
371
- return;
372
- }
373
-
374
- // Confined image bytes - see lib/http-routes/reads.js.
375
- if (routePath.startsWith('/api/image/') || routePath.startsWith('/api/image?')) {
376
- handleImage(req, res, routePath, sendJSON);
377
- return;
378
- }
379
-
380
- if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req, cspNonce); return; }
381
-
382
- const routePathBare = routePath.split('?')[0];
383
- let filePath = routePathBare === '/' ? '/index.html' : routePathBare;
384
- filePath = path.join(staticDir, filePath);
385
- const normalizedPath = path.normalize(filePath);
386
- if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
387
-
388
- fs.stat(filePath, (err, stats) => {
389
- if (err) { res.writeHead(404); res.end('Not found'); return; }
390
- if (stats.isDirectory()) {
391
- filePath = path.join(filePath, 'index.html');
392
- fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req, cspNonce); });
393
- } else { serveFile(filePath, res, req, cspNonce); }
394
- });
395
- } catch (e) {
396
- console.error('Server error:', maskToken(e.message), e.stack, '| path:', maskToken(req.url.split('?')[0]));
397
- sendJSON(req, res, 500, { error: 'internal server error' });
398
- }
399
- };
400
- }
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import * as term from './terminal.js';
6
+
7
+ // Confine a requested filesystem path to an allowlist of roots. Two layers:
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
+ }
215
+
216
+ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
217
+ // Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
218
+ // cross-origin page can make credentialless fetch() calls to all /api/*
219
+ // endpoints (list, file, download, rename, delete, mkdir) and read or
220
+ // modify the filesystem. The existing code is structurally correct (no
221
+ // Access-Control-Allow-Credentials with wildcard), but the exposure is
222
+ // easy to miss.
223
+ if (process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD) {
224
+ 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.');
225
+ }
226
+ return async function httpHandler(req, res) {
227
+ // CORS: emit ACAO only when CORS_ORIGIN is explicitly set. A wildcard would
228
+ // let any webpage the user visits make credentialless fetches to /api/list,
229
+ // /api/file/*, etc. and read ~/.claude/projects content even on a no-
230
+ // PASSWORD localhost deploy. Set CORS_ORIGIN=<origin> for cross-origin tools.
231
+ const _corsOrigin = process.env.CORS_ORIGIN;
232
+ if (_corsOrigin) {
233
+ // Never set a wildcard when credentials (cookies) may be in play — a
234
+ // wildcard + credentials is rejected by browsers and leaks session tokens.
235
+ // A specific origin allows credentialed cross-origin requests safely.
236
+ res.setHeader('Access-Control-Allow-Origin', _corsOrigin);
237
+ if (_corsOrigin !== '*') res.setHeader('Access-Control-Allow-Credentials', 'true');
238
+ res.setHeader('Vary', 'Origin');
239
+ // Allow-Methods and Allow-Headers are only meaningful on CORS preflights
240
+ // or CORS requests; emitting them unconditionally leaks the method surface
241
+ // to same-origin (and non-CORS) responses unnecessarily.
242
+ if (req.method === 'OPTIONS' || req.headers['origin']) {
243
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
244
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
245
+ }
246
+ } // no ACAO header -> browsers enforce same-origin by default
247
+ // The password can ride in a ?token= query param (EventSource/deep-links
248
+ // can't set headers), so a navigation away from the app would leak it in
249
+ // the Referer header to the destination. Strip the referrer on every
250
+ // response so the credential never crosses an origin boundary that way.
251
+ res.setHeader('Referrer-Policy', 'no-referrer');
252
+ // Prevent MIME-sniffing attacks on all responses including file downloads.
253
+ res.setHeader('X-Content-Type-Options', 'nosniff');
254
+ // Emit HSTS when the connection is known-TLS so clients pin HTTPS and
255
+ // refuse future downgrade attempts (protects cookie + ?token= in transit).
256
+ if (req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https') {
257
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000');
258
+ }
259
+ // CSP: the markdown stack (marked/dompurify/prismjs) and the DS kit load
260
+ // from unpkg/jsdelivr; everything else is self. connect-src also allows
261
+ // self for the same-origin WS/SSE. script-src drops 'unsafe-inline' in
262
+ // favour of a per-request nonce threaded onto every server-injected and
263
+ // static inline <script> (bootstrap, hot-reload, importmap) by the asset
264
+ // server. style-src keeps 'unsafe-inline' because the DS injects a runtime
265
+ // <style> with no hook to nonce.
266
+ const cspNonce = crypto.randomBytes(16).toString('base64');
267
+ res.setHeader('Content-Security-Policy', [
268
+ "default-src 'self'",
269
+ `script-src 'self' 'nonce-${cspNonce}' https://unpkg.com https://cdn.jsdelivr.net`,
270
+ "style-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.jsdelivr.net https://fonts.googleapis.com",
271
+ "img-src 'self' data: blob:",
272
+ "font-src 'self' data: https://unpkg.com https://cdn.jsdelivr.net https://fonts.gstatic.com",
273
+ "connect-src 'self' https://unpkg.com https://cdn.jsdelivr.net ws: wss:",
274
+ "object-src 'none'",
275
+ "base-uri 'self'",
276
+ "frame-ancestors 'self'",
277
+ ].join('; '));
278
+ if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
279
+ if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
280
+
281
+ // Only honour X-Forwarded-For behind a trusted proxy (TRUST_PROXY=1), and
282
+ // then take the RIGHT-MOST hop (the one the trusted proxy itself observed,
283
+ // not a client-spoofed left-most value). Otherwise the socket peer is the
284
+ // only trustworthy source - a client can forge the header freely.
285
+ let clientIp;
286
+ if (process.env.TRUST_PROXY === '1' && req.headers['x-forwarded-for']) {
287
+ const hops = req.headers['x-forwarded-for'].split(',').map(s => s.trim()).filter(Boolean);
288
+ clientIp = hops[hops.length - 1] || req.socket.remoteAddress;
289
+ } else {
290
+ clientIp = req.socket.remoteAddress;
291
+ }
292
+ // Normalize IPv4-mapped IPv6 addresses (::ffff:1.2.3.4 -> 1.2.3.4) so the
293
+ // rate-limit bucket is the same regardless of how the OS presents the peer.
294
+ if (clientIp && clientIp.startsWith('::ffff:')) clientIp = clientIp.slice(7);
295
+ const hits = (rateLimitMap.get(clientIp) || 0) + 1;
296
+ rateLimitMap.set(clientIp, hits);
297
+ res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
298
+ res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
299
+ if (hits > RATE_LIMIT_MAX) { res.writeHead(429, { 'Retry-After': '60' }); res.end('Too Many Requests'); return; }
300
+
301
+ const _pwd = process.env.PASSWORD;
302
+ // Optional: exempt /health from auth so container/k8s probes work
303
+ // without distributing the password to monitoring infra.
304
+ const _bareEarly = req.url.split('?')[0];
305
+ const _healthExempt = process.env.HEALTH_NO_AUTH === '1' && (_bareEarly === '/health' || _bareEarly === '/api/health' || _bareEarly === (BASE_URL + '/health') || _bareEarly === (BASE_URL + '/api/health'));
306
+ if (_pwd && !_healthExempt) {
307
+ const _auth = req.headers['authorization'] || '';
308
+ let _ok = false;
309
+ // Constant-time compare over fixed-width SHA-256 digests so neither the
310
+ // password length nor a byte-position mismatch leaks via timing.
311
+ const _checkToken = (tok) => {
312
+ try {
313
+ const a = crypto.createHash('sha256').update(String(tok)).digest();
314
+ const b = crypto.createHash('sha256').update(String(_pwd)).digest();
315
+ return crypto.timingSafeEqual(a, b);
316
+ } catch { return false; }
317
+ };
318
+ if (_auth.startsWith('Basic ')) {
319
+ try {
320
+ const _decoded = Buffer.from(_auth.slice(6), 'base64').toString('utf8');
321
+ const _ci = _decoded.indexOf(':');
322
+ if (_ci !== -1) _ok = _checkToken(_decoded.slice(_ci + 1));
323
+ } catch (_) {}
324
+ } else if (_auth.startsWith('Bearer ')) {
325
+ const bearerToken = _auth.slice(7);
326
+ // Validate Bearer token format: non-empty, no whitespace. Prevents
327
+ // timing-attack length inference if PASSWORD contains spaces.
328
+ if (/^[\S]+$/.test(bearerToken)) _ok = _checkToken(bearerToken);
329
+ }
330
+ // EventSource and same-origin links can't set headers - accept ?token= as fallback.
331
+ let _viaQuery = false;
332
+ if (!_ok) {
333
+ try {
334
+ const _qsTok = new URL(req.url, 'http://localhost').searchParams.get('token');
335
+ if (_qsTok) { _ok = _checkToken(_qsTok); _viaQuery = _ok; }
336
+ } catch (_) {}
337
+ }
338
+ // Static subresources (js/css/vendor) are requested by the BROWSER, which
339
+ // can attach neither a header nor a ?token= - without a cookie the SPA
340
+ // can never boot behind PASSWORD (index.html 200s, app.js 401s, blank
341
+ // page). On any successful auth, set an HttpOnly SameSite=Lax cookie the
342
+ // gate accepts for subsequent requests. Lax + the CSRF guard below keep
343
+ // cross-site mutations rejected; the value is URL-encoded (a password
344
+ // may carry cookie-hostile characters like commas).
345
+ if (!_ok) {
346
+ try {
347
+ const _ck = req.headers['cookie'] || '';
348
+ const _m = /(?:^|;\s*)agentgui_token=([^;]+)/.exec(_ck);
349
+ if (_m) _ok = _checkToken(decodeURIComponent(_m[1]));
350
+ } catch (_) {}
351
+ } else if (_viaQuery || _auth) {
352
+ try {
353
+ // Secure only over a real TLS connection (or behind an https proxy /
354
+ // explicit opt-in) so the localhost-http witness still gets a usable
355
+ // cookie. Max-Age bounds the credential's lifetime to a day.
356
+ const _https = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' || process.env.COOKIE_SECURE === '1';
357
+ res.setHeader('Set-Cookie', 'agentgui_token=' + encodeURIComponent(_pwd) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400' + (_https ? '; Secure' : ''));
358
+ } catch (_) {}
359
+ }
360
+ if (!_ok) {
361
+ // Penalize failed auth heavily in the rate bucket so a credential
362
+ // brute-force trips the 429 limiter long before it can guess.
363
+ rateLimitMap.set(clientIp, (rateLimitMap.get(clientIp) || 0) + 100);
364
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="agentgui"' }); res.end('Unauthorized'); return;
365
+ }
366
+ }
367
+
368
+ const pathOnly = req.url.split('?')[0];
369
+
370
+ // Terminal RCE guard: the terminal surface spawns an interactive shell, so
371
+ // it is fail-closed by default. It is reachable ONLY when PASSWORD is set
372
+ // AND ENABLE_TERMINAL=1 is explicitly opted in. (Under the localhost-PASSWORD
373
+ // witness ENABLE_TERMINAL is unset, so the terminal is correctly disabled -
374
+ // it is not part of the witness.)
375
+ if (pathOnly.startsWith('/api/terminal/') || pathOnly.startsWith(BASE_URL + '/api/terminal/')) {
376
+ if (!process.env.PASSWORD || process.env.ENABLE_TERMINAL !== '1') {
377
+ sendJSON(req, res, 403, { error: 'terminal disabled (requires PASSWORD and ENABLE_TERMINAL=1)' });
378
+ return;
379
+ }
380
+ }
381
+
382
+ // CSRF guard on every state-changing method. Without PASSWORD the server
383
+ // is open and advertises a wildcard ACAO, so a cross-site page could POST
384
+ // a form at the mutation routes (rename/delete/mkdir/upload) on localhost.
385
+ // A simple cross-site form cannot set Sec-Fetch-Site:same-origin, send
386
+ // application/json, or attach custom headers - so: accept when the
387
+ // browser says same-origin/none (direct nav, same-origin fetch), when the
388
+ // body is declared JSON, or when an Authorization header rode along (a
389
+ // credentialed programmatic client). Reject the rest with 403.
390
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
391
+ const sfs = req.headers['sec-fetch-site'];
392
+ const ct = (req.headers['content-type'] || '').toLowerCase();
393
+ const sameSite = !sfs || sfs === 'same-origin' || sfs === 'none';
394
+ // Only application/json counts as a non-cross-site body now: a simple
395
+ // cross-site <form> can send only urlencoded/multipart/text-plain, and
396
+ // octet-stream / empty CT were too broad an escape (a no-CORS fetch can
397
+ // send octet-stream). The binary upload PUT still rides the same-origin
398
+ // SPA fetch (Sec-Fetch-Site: same-origin), so it passes via sameSite.
399
+ const jsonBody = ct.startsWith('application/json');
400
+ const authed = !!req.headers['authorization'];
401
+ // If an Origin header is present, its host MUST match the Host header -
402
+ // a cross-origin page's Origin will not, regardless of Sec-Fetch-Site.
403
+ let originOk = true;
404
+ const origin = req.headers['origin'];
405
+ if (origin) {
406
+ try { originOk = new URL(origin).host === req.headers['host']; }
407
+ catch { originOk = false; }
408
+ }
409
+ if (!originOk || (!sameSite && !jsonBody && !authed)) {
410
+ res.writeHead(403, { 'Content-Type': 'application/json' });
411
+ res.end(JSON.stringify({ error: 'cross-site request rejected' }));
412
+ return;
413
+ }
414
+ }
415
+
416
+ if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
417
+
418
+ if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
419
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="20" fill="#3b82f6"/><text x="50" y="68" font-size="50" font-family="sans-serif" font-weight="bold" fill="white" text-anchor="middle">G</text></svg>';
420
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' });
421
+ res.end(svg); return;
422
+ }
423
+
424
+ // serve index.html at root directly (no redirect)
425
+
426
+ let routePath = req.url;
427
+ const _bareUrl = req.url.split('?')[0];
428
+ if (_bareUrl.startsWith(BASE_URL + '/')) { routePath = req.url.slice(BASE_URL.length); }
429
+ else if (_bareUrl === BASE_URL) { routePath = '/'; }
430
+ else if (_bareUrl.startsWith('/api/') || _bareUrl.startsWith('/js/') || _bareUrl.startsWith('/css/') ||
431
+ _bareUrl.startsWith('/vendor/') || _bareUrl.startsWith('/sync') || _bareUrl === '/' ||
432
+ _bareUrl === '/health' || _bareUrl.startsWith('/v1/') ||
433
+ _bareUrl.startsWith('/api/terminal/') ||
434
+ _bareUrl.startsWith('/conversations/')) { routePath = req.url; }
435
+ else { res.writeHead(404); res.end('Not found'); return; }
436
+
437
+ routePath = routePath || '/';
438
+
439
+ try {
440
+ const pathOnly = routePath.split('?')[0];
441
+
442
+ if ((pathOnly === '/api/health' || pathOnly === '/health') && req.method === 'GET') {
443
+ let dbStatus = { ok: true };
444
+ try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
445
+ const queueSizes = {};
446
+ for (const [k, v] of messageQueues) queueSizes[k] = v.length;
447
+ const _body = {
448
+ status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
449
+ activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
450
+ acp: getACPStatus(), db: dbStatus, queueSizes,
451
+ // Surfaces the console-only CORS_ORIGIN=*+no-PASSWORD warning (above)
452
+ // as a fact the GUI settings panel can render a persistent banner
453
+ // from - an operator not tailing logs otherwise never sees the risk.
454
+ corsWildcardOpen: process.env.CORS_ORIGIN === '*' && !process.env.PASSWORD,
455
+ };
456
+ // Host-internal facts (memory, filesystem paths) are exposed only to an
457
+ // authenticated caller, never on the unauthenticated health-probe bypass.
458
+ if (!_healthExempt) {
459
+ _body.memory = process.memoryUsage();
460
+ _body.projectsDir = process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects');
461
+ _body.allowRoots = fsAllowRoots();
462
+ }
463
+ sendJSON(req, res, 200, _body);
464
+ return;
465
+ }
466
+
467
+ // Existence/shape probe for a proposed chat working directory, confined
468
+ // to the same allowlist as the Files surface (an unconfined stat would be
469
+ // a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
470
+ if (routePath.startsWith('/api/stat') && req.method === 'GET') {
471
+ const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/stat');
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
+ }
480
+ return;
481
+ }
482
+
483
+ // Terminal sessions - gated by the Basic-auth check at the top of this handler.
484
+ // Never expose these routes without PASSWORD set.
485
+ if (pathOnly === '/api/terminal/sessions' && req.method === 'GET') {
486
+ sendJSON(req, res, 200, term.listSessions()); return;
487
+ }
488
+ if (pathOnly === '/api/terminal/sessions' && req.method === 'POST') {
489
+ let body = ''; for await (const c of req) body += c;
490
+ let p = {}; try { p = body ? JSON.parse(body) : {}; } catch {}
491
+ // Never honour a client-supplied shell or env - that would be a direct
492
+ // command/arg injection into the spawned process. Only geometry + cwd.
493
+ // Confine cwd to allowed roots; fall back to STARTUP_CWD on failure.
494
+ let termCwd = process.env.STARTUP_CWD || process.cwd();
495
+ if (p.cwd) {
496
+ const cwdConf = confineToRoots(p.cwd, fsAllowRoots());
497
+ if (cwdConf.ok) termCwd = cwdConf.realPath;
498
+ }
499
+ const s = term.createSession({ cwd: termCwd, cols: p.cols, rows: p.rows });
500
+ 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 });
501
+ return;
502
+ }
503
+ const termMatch = pathOnly.match(/^\/api\/terminal\/sessions\/([0-9a-f]+)$/);
504
+ if (termMatch && req.method === 'GET') {
505
+ const s = term.getSession(termMatch[1]);
506
+ if (!s) { sendJSON(req, res, 404, { error: 'session not found' }); return; }
507
+ 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, clients: s.clients.size });
508
+ return;
509
+ }
510
+ if (termMatch && req.method === 'DELETE') {
511
+ const ok = term.closeSession(termMatch[1]);
512
+ sendJSON(req, res, ok ? 200 : 404, { ok }); return;
513
+ }
514
+
515
+ // Legacy REST handlers removed. History served by ccsniff at /v1/history/*.
516
+ for (const key of Object.keys(routes)) {
517
+ try {
518
+ const h = routes[key]?._match?.(req.method, pathOnly);
519
+ if (h) { await h(req, res); return; }
520
+ } catch (_) {}
521
+ }
522
+ // Directory listing for the Files / folder-browser view (mirrors fsbrowse
523
+ // /api/list). Confined to an allowlist root exactly like /api/image - the
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).
528
+ if (routePath.startsWith('/api/list')) {
529
+ const decodedPath = resolveConfinedPath(req, routePath, 'dir', '/api/list');
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
+ }
587
+ return;
588
+ }
589
+
590
+ // Raw file bytes for the Files preview pane. Confined to the SAME
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.
596
+ if (routePath.startsWith('/api/file/') || routePath.startsWith('/api/file?')) {
597
+ const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/file/');
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
+ }
636
+ return;
637
+ }
638
+
639
+ // Confined raw-bytes download (any type) with an attachment disposition,
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.
642
+ if (routePath.startsWith('/api/download/') || routePath.startsWith('/api/download?')) {
643
+ const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/download/');
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
+ }
671
+ return;
672
+ }
673
+
674
+ // --- File mutations ----------------------------------------------------
675
+ // The Files surface is a real manager (fsbrowse-grade): rename, delete,
676
+ // mkdir, upload. Every route re-confines via confineToRoots (realpath, so
677
+ // a symlink inside a root cannot point a mutation outside it), refuses
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.
682
+
683
+ // POST /api/rename {path, newName} -> {ok, path}
684
+ if (routePath.split('?')[0] === '/api/rename' && req.method === 'POST') {
685
+ let body;
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) }); }
703
+ return;
704
+ }
705
+
706
+ // POST /api/move {path, destDir, overwrite?} -> {ok, path}. Moves an
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).
711
+ if (routePath.split('?')[0] === '/api/move' && req.method === 'POST') {
712
+ let body;
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
+ }
745
+ return;
746
+ }
747
+
748
+ // POST /api/delete {path, recursive?} -> {ok}. Deleting a non-empty dir
749
+ // requires recursive:true (the client confirms first).
750
+ if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
751
+ let body;
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
+ }
776
+ return;
777
+ }
778
+
779
+ // POST /api/restore {trashId} -> {ok, path}. Undoes a /api/delete within
780
+ // its retention window (trashRetentionMs, default 10 minutes) by moving
781
+ // the entry back from .agentgui-trash/ to its original confined path.
782
+ if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
783
+ let body;
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
+ }
792
+ return;
793
+ }
794
+
795
+ // POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
796
+ if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
797
+ let body;
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 }); }
810
+ return;
811
+ }
812
+
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.
817
+ if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
818
+ let qs;
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
+ }
869
+ return;
870
+ }
871
+
872
+ if (routePath.startsWith('/api/image/') || routePath.startsWith('/api/image?')) {
873
+ const decodedPath = resolveConfinedPath(req, routePath, 'path', '/api/image/');
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' }); }
908
+ return;
909
+ }
910
+
911
+ if (pathOnly.match(/^\/conversations\/[^\/]+$/)) { serveFile(path.join(staticDir, 'index.html'), res, req, cspNonce); return; }
912
+
913
+ const routePathBare = routePath.split('?')[0];
914
+ let filePath = routePathBare === '/' ? '/index.html' : routePathBare;
915
+ filePath = path.join(staticDir, filePath);
916
+ const normalizedPath = path.normalize(filePath);
917
+ if (!normalizedPath.startsWith(staticDir)) { res.writeHead(403); res.end('Forbidden'); return; }
918
+
919
+ fs.stat(filePath, (err, stats) => {
920
+ if (err) { res.writeHead(404); res.end('Not found'); return; }
921
+ if (stats.isDirectory()) {
922
+ filePath = path.join(filePath, 'index.html');
923
+ fs.stat(filePath, (err2) => { if (err2) { res.writeHead(404); res.end('Not found'); return; } serveFile(filePath, res, req, cspNonce); });
924
+ } else { serveFile(filePath, res, req, cspNonce); }
925
+ });
926
+ } catch (e) {
927
+ console.error('Server error:', maskToken(e.message), e.stack, '| path:', maskToken(req.url.split('?')[0]));
928
+ sendJSON(req, res, 500, { error: 'internal server error' });
929
+ }
930
+ };
931
+ }