cc-viewer 1.6.274 → 1.6.276

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.
Files changed (106) hide show
  1. package/README.md +11 -0
  2. package/cli.js +29 -0
  3. package/dist/assets/App-VfOsEOzR.css +1 -0
  4. package/dist/assets/App-g0FMGNbK.js +1 -0
  5. package/dist/assets/{MdxEditorPanel-DvqnmX-m.css → MdxEditorPanel-1vSx6ymU.css} +1 -1
  6. package/dist/assets/{MdxEditorPanel-BAnfnKiF.js → MdxEditorPanel-lkBOg8Jh.js} +1 -1
  7. package/dist/assets/Mobile-CfP2RFHS.js +1 -0
  8. package/dist/assets/{_baseUniq-CPJrFyUF.js → _baseUniq-IhLiJsz9.js} +1 -1
  9. package/dist/assets/{arc-BLBrFElt.js → arc-CI5WB-FW.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-CbnBsMiQ.js → architectureDiagram-Q4EWVU46-YOnqcO8r.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-0mYr6-Fl.js → blockDiagram-DXYQGD6D-B6oK5mZJ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-CS7vcr0z.js → c4Diagram-AHTNJAMY-JsynU_tx.js} +1 -1
  13. package/dist/assets/{channel-CF3zZzSR.js → channel-DLsvHA9V.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-1FZYtnJ7.js → chunk-4BX2VUAB-DGhBdIp1.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-COs1qui5.js → chunk-4TB4RGXK-crHwtw-n.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-p77Qw3wN.js → chunk-55IACEB6-CHTfiCuV.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-5qaIrQKg.js → chunk-EDXVE4YY-buLXKlmZ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-DmCR8mDZ.js → chunk-FMBD7UC4-DSfXNwCE.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-D6xDfgW3.js → chunk-OYMX7WX6-16K1lWZl.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-B6cmUU0N.js → chunk-QZHKN3VN-DgDuGTt2.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-l-OyOqnn.js → chunk-YZCP3GAM-DHqswL_6.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-BCPnChlV.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-BCPnChlV.js +1 -0
  24. package/dist/assets/clone-CJyRHxRw.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-CEzaS8XS.js → cose-bilkent-S5V4N54A-D1j0CqN7.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-B3U2njWW.js → dagre-KV5264BT-XKbpcECY.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-BRSDbyBr.js → diagram-5BDNPKRD-CcbZUyrk.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-BZfOi9B7.js → diagram-G4DWMVQ6-UxGWOz8H.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-CWpb3Cg0.js → diagram-MMDJMWI5-COG8DLtM.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-CTJyBSVj.js → diagram-TYMM5635-_dGuaThe.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-CHtTRd5S.js → erDiagram-SMLLAGMA-CNu-hiRP.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-Bda8X-WJ.js → flowDiagram-DWJPFMVM-DQHinAg1.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CPfnGu5V.js → ganttDiagram-T4ZO3ILL-BAySDtwF.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-B5QxesQg.js → gitGraphDiagram-UUTBAWPF-BBO_XdYa.js} +1 -1
  35. package/dist/assets/{graph-ChltdhTU.js → graph-D9285_n5.js} +1 -1
  36. package/dist/assets/index-0hGZ2hk-.js +2 -0
  37. package/dist/assets/{index-BK4sui_O.js → index-6IDcGlHG.js} +1 -1
  38. package/dist/assets/{index-C2fhupP6.js → index-CXhN926Q.js} +1 -1
  39. package/dist/assets/{index-CfEkC3bc.js → index-CaKHIO3v.js} +1 -1
  40. package/dist/assets/{index-yClPXlMf.js → index-D18bphjK.js} +1 -1
  41. package/dist/assets/{index-B7lK5fJz.js → index-DTi4w1dY.js} +1 -1
  42. package/dist/assets/{index-C5PA4OJg.js → index-gSNq4ykV.js} +1 -1
  43. package/dist/assets/{index-DyGa-jNv.js → index-pEgUz6fU.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-CD4TS4O2.js → infoDiagram-42DDH7IO-DODFGD-6.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-jhiYabQ7.js → ishikawaDiagram-UXIWVN3A-kn6kHfYH.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-BDzIXxxt.js → journeyDiagram-VCZTEJTY-BRaTTmzg.js} +1 -1
  47. package/dist/assets/{jszip.min-CuGGBMI4.js → jszip.min-CnLSX52a.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-D34MYATQ.js → kanban-definition-6JOO6SKY-CzLQb2u1.js} +1 -1
  49. package/dist/assets/{layout-D6sLAapX.js → layout-B9TjxURJ.js} +1 -1
  50. package/dist/assets/{linear-CFV4P-wn.js → linear-Dpck4eFk.js} +1 -1
  51. package/dist/assets/{mermaid.core-YmJi7T-s.js → mermaid.core-Dd_GyvDR.js} +2 -2
  52. package/dist/assets/{min-akJQqRMn.js → min-CUkc58GS.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-w5zaJyrN.js → mindmap-definition-QFDTVHPH-BteQHO2X.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-B6EM4Ow6.js → pieDiagram-DEJITSTG-CaOYrVcc.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-2TmBxFy-.js → quadrantDiagram-34T5L4WZ-DTcmYcmQ.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-EOjvbxUy.js → requirementDiagram-MS252O5E-CY8WoU0d.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-BmTQD5eT.js → sankeyDiagram-XADWPNL6-9NPSQPLx.js} +1 -1
  58. package/dist/assets/seqResourceLoaders-BgEHTs14.js +2 -0
  59. package/dist/assets/seqResourceLoaders-DPmXT2Hh.css +41 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-CLjtqA1D.js → sequenceDiagram-FGHM5R23-e7V5HhHU.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-Cbq90iZ8.js → stateDiagram-FHFEXIEX-B6R9nxzj.js} +1 -1
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-sUx4g5Jk.js +1 -0
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-f3kEDay0.js → timeline-definition-GMOUNBTQ-CqpCMRBB.js} +1 -1
  64. package/dist/assets/{vendor-antd-5xE7sz6B.js → vendor-antd-BqzW9CXo.js} +2 -2
  65. package/dist/assets/{vendor-codemirror-ib-jPbXC.js → vendor-codemirror-B5WQ33Y0.js} +1 -1
  66. package/dist/assets/vendor-markdown-DOJHsAxX.js +3 -0
  67. package/dist/assets/{vendor-mdxeditor-BdKMdw6O.js → vendor-mdxeditor-DwzTmNBd.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-vKlE-WYu.js → vendor-qrcode-C9ZMcovz.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-DOIfjLfU.js → vendor-virtuoso-CQj5-1oy.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-0GDSFVnH.js → vennDiagram-DHZGUBPP-CxzTP1Mu.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-B2rj5j7G.js → wardley-RL74JXVD-_Dcq-0iY.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-COVTciJP.js → wardleyDiagram-NUSXRM2D-CltoIcVL.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-DXoLnGxX.js → xychartDiagram-5P7HB3ND-DXKa-Cxe.js} +1 -1
  74. package/dist/index.html +4 -4
  75. package/package.json +1 -1
  76. package/server/i18n.js +186 -2
  77. package/server/lib/auth.js +348 -0
  78. package/server/lib/voice-pack-events.js +2 -2
  79. package/server/routes/_dispatch.js +43 -0
  80. package/server/routes/ask-perm.js +452 -0
  81. package/server/routes/auth.js +166 -0
  82. package/server/routes/events.js +335 -0
  83. package/server/routes/files-content.js +421 -0
  84. package/server/routes/files-fs.js +955 -0
  85. package/server/routes/git.js +228 -0
  86. package/server/routes/logs.js +201 -0
  87. package/server/routes/misc.js +22 -0
  88. package/server/routes/plugins.js +96 -0
  89. package/server/routes/preferences.js +216 -0
  90. package/server/routes/project-meta.js +118 -0
  91. package/server/routes/skills.js +261 -0
  92. package/server/routes/team.js +169 -0
  93. package/server/routes/voice-pack.js +235 -0
  94. package/server/routes/workspaces.js +171 -0
  95. package/server/server.js +340 -3953
  96. package/dist/assets/App-BediOgt6.js +0 -1
  97. package/dist/assets/App-TGGslOeT.css +0 -1
  98. package/dist/assets/Mobile-C_nqfEXb.js +0 -1
  99. package/dist/assets/classDiagram-6PBFFD2Q-BiCYgTHO.js +0 -1
  100. package/dist/assets/classDiagram-v2-HSJHXN6E-BiCYgTHO.js +0 -1
  101. package/dist/assets/clone-ChTCnPsO.js +0 -1
  102. package/dist/assets/index-CuE3VwXB.js +0 -2
  103. package/dist/assets/seqResourceLoaders-CgNKpehN.js +0 -2
  104. package/dist/assets/seqResourceLoaders-Dd_x1M9-.css +0 -41
  105. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BKDg-3t_.js +0 -1
  106. package/dist/assets/vendor-markdown-BFrYfpb0.js +0 -1
package/server/server.js CHANGED
@@ -2,17 +2,32 @@ import { createServer } from 'node:http';
2
2
  import { createServer as createHttpsServer } from 'node:https';
3
3
  import { createConnection } from 'node:net';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { readFileSync, writeFileSync, existsSync, watchFile, unwatchFile, statSync, lstatSync, readdirSync, renameSync, unlinkSync, rmSync, openSync, readSync, closeSync, realpathSync, mkdirSync, createReadStream, cpSync, copyFileSync } from 'node:fs';
6
- import { dirname, join, extname, resolve, basename, sep } from 'node:path';
7
- import { homedir, platform, networkInterfaces, tmpdir } from 'node:os';
5
+ import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync } from 'node:fs';
6
+ import { join, extname, resolve, sep } from 'node:path';
7
+ import { platform, networkInterfaces, tmpdir } from 'node:os';
8
8
  import { execFile, exec, spawn } from 'node:child_process';
9
9
  import { promisify } from 'node:util';
10
10
  import { Worker } from 'node:worker_threads';
11
- import { isPathContained, ERROR_STATUS_MAP, validateImportDir } from './lib/file-api.js';
12
- import { loadAskStore, setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, consumeIfFinal as askStoreConsumeIfFinal } from './lib/ask-store.js';
11
+ import { isPathContained } from './lib/file-api.js';
12
+ import { setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled } from './lib/ask-store.js';
13
13
  import { ASK_TIMEOUT_MS } from './lib/ask-constants.js';
14
- import { isReadAllowed, reasonToStatus, bumpWorkspacesVersion } from './lib/file-access-policy.js';
15
- import { PACKAGE_JSON, CONCEPTS_DIR, DIST_DIR, NODE_MODULES, SERVER_LIB } from './_paths.js';
14
+ import { DIST_DIR, NODE_MODULES } from './_paths.js';
15
+ import { createDispatcher } from './routes/_dispatch.js';
16
+ import { projectMetaRoutes } from './routes/project-meta.js';
17
+ import { miscRoutes } from './routes/misc.js';
18
+ import { preferencesRoutes } from './routes/preferences.js';
19
+ import { gitRoutes } from './routes/git.js';
20
+ import { pluginsRoutes } from './routes/plugins.js';
21
+ import { logsRoutes } from './routes/logs.js';
22
+ import { voicePackRoutes } from './routes/voice-pack.js';
23
+ import { skillsRoutes } from './routes/skills.js';
24
+ import { filesContentRoutes } from './routes/files-content.js';
25
+ import { filesFsRoutes } from './routes/files-fs.js';
26
+ import { workspacesRoutes } from './routes/workspaces.js';
27
+ import { eventsRoutes } from './routes/events.js';
28
+ import { askPermRoutes } from './routes/ask-perm.js';
29
+ import { teamRoutes } from './routes/team.js';
30
+ import { authRoutes } from './routes/auth.js';
16
31
 
17
32
  const execFileAsync = promisify(execFile);
18
33
  const execAsync = promisify(exec);
@@ -37,42 +52,15 @@ function execWithStdin(cmd, args, input, options) {
37
52
  child.stdin.end();
38
53
  });
39
54
  }
40
- import { LOG_FILE, _initPromise, _resumeState, resolveResumeChoice, _projectName, _logDir, _cachedApiKey, _cachedAuthHeader, _cachedHaikuModel, initForWorkspace, resetWorkspace, streamingState, resetStreamingState, _loadProxyProfile, PROFILE_PATH, _defaultConfig, setLivePort, setActiveProfileForWorkspace, getActiveProfileId } from './interceptor.js';
55
+ import { LOG_FILE, _initPromise, _resumeState, _projectName, _logDir, streamingState, resetStreamingState, PROFILE_PATH, setLivePort } from './interceptor.js';
41
56
  import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../findcc.js';
42
- import { t, detectLanguage } from './i18n.js';
57
+ import { t, getLang } from './i18n.js';
58
+ import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
43
59
  import { checkAndUpdate } from './lib/updater.js';
44
- import { loadPlugins, runWaterfallHook, runParallelHook, getPluginsInfo, getPluginsDir } from './lib/plugin-loader.js';
45
- import { uploadPlugins, installPluginFromUrl } from './lib/plugin-manager.js';
46
- import { getUserProfile } from './lib/user-profile.js';
47
- import { getGitDiffs, countUntrackedLines, getUnpushedCommits, isValidCommitHash } from './lib/git-diff.js';
48
- import { CONTEXT_WINDOW_FILE, readModelContextSize, buildContextWindowEvent, getContextSizeForModel, readClaudeProjectModel } from './lib/context-watcher.js';
60
+ import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
61
+ import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
49
62
  import { watchLogFile, startWatching, getWatchedFiles, sendEventToClients, sendToClients } from './lib/log-watcher.js';
50
- import { isMainAgentEntry, extractCachedContent } from './lib/kv-cache-analyzer.js';
51
- import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles } from './lib/log-management.js';
52
63
  import { cleanupExtractCache } from './lib/jsonl-archive.js';
53
- import { countLogEntries, streamRawEntriesAsync, readPagedEntries } from './lib/log-stream.js';
54
- import { awaitDrainOrClose } from './lib/sse-backpressure.js';
55
- import { enrichRawIfNeeded } from './lib/enrich-plan-input.js';
56
- import { buildTeamStatusResponse } from './lib/team-runtime.js';
57
- import { discoverClaudeMdCandidates, readCandidateById } from './lib/claude-md-discovery.js';
58
- import {
59
- saveAudio as vpSaveAudio,
60
- listUserAudio as vpListUserAudio,
61
- deleteUserAudio as vpDeleteUserAudio,
62
- getUserAudioPath as vpGetUserAudioPath,
63
- getDefaultPackPath as vpGetDefaultPackPath,
64
- getBundledPackPath as vpGetBundledPackPath,
65
- listDefaultPack as vpListDefaultPack,
66
- listBundledPacks as vpListBundledPacks,
67
- isDefaultPackPlaceholder as vpIsDefaultPackPlaceholder,
68
- reconcileVoicePackPrefs as vpReconcile,
69
- mimeForFormat as vpMime,
70
- isValidId as vpIsValidId,
71
- BUNDLED_PACK_IDS as VP_BUNDLED_PACK_IDS,
72
- EVENT_KEYS as VP_EVENT_KEYS,
73
- MAX_AUDIO_BYTES as VP_MAX_BYTES,
74
- } from './lib/voice-pack-manager.js';
75
- import { mergeApprovalModalPrefs as vpMergeAM } from './lib/approval-modal-prefs.js';
76
64
 
77
65
 
78
66
  // 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
@@ -219,7 +207,6 @@ const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
219
207
  // 子命令序列被插队导致不可预测的工作树状态。Promise chain 串行化同 key 请求;finally 路径
220
208
  // 主清理,setTimeout 兜底防 finally 异常吞 entry 累积内存。
221
209
  const gitRestoreLocks = new Map(); // Map<absLockKey, Promise<void>>
222
- const GIT_RESTORE_LOCK_CLEANUP_MS = 30000;
223
210
 
224
211
  // Notify the parent process (Electron main, when forked under tab-worker) about pending state changes.
225
212
  // No-op outside Electron (process.send is undefined when run as a standalone Node server).
@@ -304,3956 +291,323 @@ const ACCESS_TOKEN = randomBytes(16).toString('hex');
304
291
  // token can't double as a bridge auth bypass for same-host CSRF (round-3 P1).
305
292
  const INTERNAL_TOKEN = randomBytes(16).toString('hex');
306
293
 
307
- let clients = [];
308
- let server;
309
- let actualPort = 0;
310
- let serverProtocol = 'http';
311
- // Stats Worker 实例
312
- let statsWorker = null;
313
-
314
- function startStatsWorker() {
315
- try {
316
- statsWorker = new Worker(new URL('./lib/stats-worker.js', import.meta.url));
317
- statsWorker.on('error', (err) => {
318
- console.error('[CC Viewer] Stats worker error:', err.message);
319
- statsWorker = null;
320
- });
321
- statsWorker.on('exit', (code) => {
322
- if (code !== 0) {
323
- console.error('[CC Viewer] Stats worker exited with code', code);
324
- }
325
- statsWorker = null;
326
- });
327
- // 初始化:全量扫描当前项目
328
- if (_projectName && _logDir) {
329
- statsWorker.postMessage({ type: 'init', logDir: LOG_DIR, projectName: _projectName });
330
- }
331
- } catch (err) {
332
- console.error('[CC Viewer] Failed to start stats worker:', err.message);
333
- }
334
- }
335
-
336
- function notifyStatsWorker(logFile) {
337
- if (statsWorker && _projectName) {
338
- statsWorker.postMessage({ type: 'update', logDir: LOG_DIR, projectName: _projectName, logFile });
339
- }
340
- }
341
-
342
- const MIME_TYPES = {
343
- '.html': 'text/html; charset=utf-8',
344
- '.js': 'application/javascript; charset=utf-8',
345
- '.css': 'text/css; charset=utf-8',
346
- '.json': 'application/json; charset=utf-8',
347
- '.svg': 'image/svg+xml',
348
- '.png': 'image/png',
349
- '.jpg': 'image/jpeg',
350
- '.woff': 'font/woff',
351
- '.woff2': 'font/woff2',
352
- };
353
-
354
- // Helper to build log-watcher options object
355
- function _logWatcherOpts(logFile) {
356
- return {
357
- logFile: logFile || LOG_FILE,
358
- clients,
359
- getClaudePid,
360
- runParallelHook,
361
- notifyStatsWorker,
362
- getLogFile: () => LOG_FILE,
363
- };
364
- }
365
-
366
- function getLocalIp() {
367
- const nets = networkInterfaces();
368
- for (const name of Object.keys(nets)) {
369
- for (const net of nets[name]) {
370
- if (net.family === 'IPv4' && !net.internal) return net.address;
371
- }
372
- }
373
- return '127.0.0.1';
374
- }
375
-
376
- function getAllLocalIps() {
377
- const ips = [];
378
- const nets = networkInterfaces();
379
- for (const name of Object.keys(nets)) {
380
- for (const net of nets[name]) {
381
- if (net.family === 'IPv4' && !net.internal) ips.push(net.address);
382
- }
383
- }
384
- return ips;
385
- }
386
-
387
- async function handleRequest(req, res) {
388
- const parsedUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
389
- const url = parsedUrl.pathname;
390
- const method = req.method;
391
-
392
- // WebSocket 路径不处理,交给 upgrade 事件
393
- if (url === '/ws/terminal' || url === '/ws/terminal-scratch') {
394
- return;
395
- }
396
-
397
- // CORS headers
398
- res.setHeader('Access-Control-Allow-Origin', '*');
399
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
400
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
401
-
402
- if (method === 'OPTIONS') {
403
- res.writeHead(200);
404
- res.end();
405
- return;
406
- }
407
-
408
- // 局域网访问 token 验证(本地 127.0.0.1 / ::1 免验证,静态资源免验证)
409
- const remoteIp = req.socket.remoteAddress;
410
- const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
411
- const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
412
- if (!isLocal && !isStaticAsset) {
413
- const urlToken = parsedUrl.searchParams.get('token');
414
- if (urlToken !== ACCESS_TOKEN) {
415
- res.writeHead(403, { 'Content-Type': 'application/json' });
416
- res.end(JSON.stringify({ error: 'Forbidden: invalid token' }));
417
- return;
418
- }
419
- }
420
-
421
- // DNS rebinding 防护:即使带了正确 token,Host header 必须落在 allowlist 里。
422
- // 默认放行 loopback + 本机所有 LAN IPv4(getAllLocalIps()):cc-viewer 核心场景就是手机扫码访问 LAN URL,
423
- // 要求用户每次手动设 CCV_ALLOWED_HOSTS 不可接受。token 仍是必需(server.js:300-310 ACCESS_TOKEN gate),
424
- // DNS rebinding 攻击者需精确知道用户 LAN IP 才能利用,门槛降低但不增新攻击面;Vite/Cursor 同行也默认放开 LAN。
425
- // CCV_ALLOWED_HOSTS 显式设(包括 '*' 关闭防护)时完全沿用用户值,与 1.6.227 行为一致,向后兼容。
426
- // 静态资源和 OPTIONS 预检不挡。
427
- if (!isStaticAsset && method !== 'OPTIONS') {
428
- const allowedHosts = process.env.CCV_ALLOWED_HOSTS
429
- ? process.env.CCV_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
430
- : ['localhost', '127.0.0.1', '::1', '[::1]', ...getAllLocalIps()];
431
- if (!allowedHosts.includes('*')) {
432
- const hostHeader = (req.headers.host || '').toLowerCase();
433
- // 端口剥离:RFC 3986 要求 IPv6 Host 必须带 brackets `[::1]:port`,bare `::1` 末尾 `\d` 会被错剥成 `:`。
434
- // 含 `::` 但无 `]` 闭合的视为 bare IPv6,不剥端口。
435
- const isBareIPv6 = hostHeader.includes('::') && !hostHeader.includes(']');
436
- const hostNoPort = isBareIPv6 ? hostHeader : hostHeader.replace(/:\d+$/, '');
437
- const stripBrackets = hostNoPort.replace(/^\[|\]$/g, '');
438
- const ok = allowedHosts.some(h => {
439
- const hl = h.toLowerCase();
440
- return hl === hostNoPort || hl === stripBrackets || hl === `[${stripBrackets}]`;
441
- });
442
- if (!ok) {
443
- res.writeHead(403, { 'Content-Type': 'application/json' });
444
- res.end(JSON.stringify({ ok: false, error: 'host-not-allowed', host: hostNoPort }));
445
- return;
446
- }
447
- }
448
- }
449
-
450
- // Plugin hook: intercept HTTP requests (after auth, before routing)
451
- try {
452
- const hookResult = await runWaterfallHook('beforeRequest', {
453
- req, res, url, method, parsedUrl, handled: false,
454
- });
455
- if (hookResult.handled) return;
456
- } catch (err) {
457
- if (!res.headersSent) {
458
- res.writeHead(500, { 'Content-Type': 'application/json' });
459
- res.end(JSON.stringify({ error: 'Plugin error' }));
460
- }
461
- return;
462
- }
463
-
464
- // User preferences API
465
- // File upload API — save to /tmp/cc-viewer-uploads/
466
- if (url === '/api/upload' && method === 'POST') {
467
- const contentType = req.headers['content-type'] || '';
468
- const boundaryMatch = contentType.match(/boundary=(.+)/);
469
- if (!boundaryMatch) {
470
- res.writeHead(400, { 'Content-Type': 'application/json' });
471
- res.end(JSON.stringify({ error: 'Missing boundary' }));
472
- return;
473
- }
474
- const MAX_UPLOAD = 100 * 1024 * 1024; // 100MB
475
- const contentLength = parseInt(req.headers['content-length'] || '0', 10);
476
- if (contentLength > MAX_UPLOAD) {
477
- res.writeHead(413, { 'Content-Type': 'application/json' });
478
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
479
- return;
480
- }
481
- const boundary = boundaryMatch[1];
482
- const chunks = [];
483
- let totalSize = 0;
484
- let aborted = false;
485
- req.on('data', chunk => {
486
- totalSize += chunk.length;
487
- if (totalSize > MAX_UPLOAD) {
488
- aborted = true;
489
- res.writeHead(413, { 'Content-Type': 'application/json' });
490
- res.end(JSON.stringify({ error: 'File too large (max 50MB)' }));
491
- req.destroy();
492
- return;
493
- }
494
- chunks.push(chunk);
495
- });
496
- req.on('end', () => {
497
- if (aborted) return;
498
- try {
499
- const buf = Buffer.concat(chunks);
500
- // Find the first part's headers and body
501
- const headerEnd = buf.indexOf('\r\n\r\n');
502
- if (headerEnd === -1) throw new Error('Malformed multipart');
503
- const headerStr = buf.slice(0, headerEnd).toString();
504
- const nameMatch = headerStr.match(/filename="([^"]+)"/);
505
- if (!nameMatch) throw new Error('No filename');
506
- // sanitize: 只过 null byte + 控制字符 + 路径分隔符(真正会破坏 fs 调用的字符);
507
- // Windows 非法字符 <>:"|?* 在 Unix 合法(ISO 时间戳 10:30:45.log、name:v1.txt 等常见),
508
- // 不做跨平台代理过滤,让 writeFileSync 在 Windows 上自行抛错即可。
509
- const originalName = nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_');
510
- // Windows 保留设备名守卫(/api/upload-image)——见 WINDOWS_RESERVED_NAMES 注释。
511
- {
512
- const base = originalName.split('.')[0].trim().toLowerCase();
513
- if (WINDOWS_RESERVED_NAMES.test(base)) {
514
- throw new Error('Reserved filename not allowed');
515
- }
516
- }
517
- const bodyStart = headerEnd + 4;
518
- // Find the closing boundary
519
- const closingBoundary = Buffer.from('\r\n--' + boundary);
520
- const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
521
- const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
522
- // Windows 没有 /tmp,走 os.tmpdir() (%TEMP%);POSIX 保留 /tmp/cc-viewer-uploads/
523
- // 以兼容 1.6.245 PR #81 的 macOS allowlist 修复(/private/tmp 双 realpath)。
524
- const uploadDir = process.platform === 'win32' ? join(tmpdir(), 'cc-viewer-uploads') : '/tmp/cc-viewer-uploads';
525
- mkdirSync(uploadDir, { recursive: true });
526
- bumpWorkspacesVersion();
527
- // Unique filename: prepend timestamp to avoid silent overwrite
528
- const ts = Date.now();
529
- const dotIdx = originalName.lastIndexOf('.');
530
- const uniqueName = dotIdx > 0
531
- ? `${originalName.slice(0, dotIdx)}-${ts}${originalName.slice(dotIdx)}`
532
- : `${originalName}-${ts}`;
533
- const savePath = join(uploadDir, uniqueName);
534
- writeFileSync(savePath, fileData);
535
- // 持久化副本到 ~/.claude/cc-viewer/${project}/images/,避免 /tmp 清理后丢失
536
- let persistPath = null;
537
- try {
538
- const pName = _projectName || 'default';
539
- const persistDir = join(getClaudeConfigDir(), 'cc-viewer', pName, 'images');
540
- mkdirSync(persistDir, { recursive: true });
541
- persistPath = join(persistDir, uniqueName);
542
- writeFileSync(persistPath, fileData);
543
- } catch { }
544
- res.writeHead(200, { 'Content-Type': 'application/json' });
545
- res.end(JSON.stringify({ ok: true, path: savePath, persistPath }));
546
- } catch (err) {
547
- console.error('upload error:', err);
548
- res.writeHead(500, { 'Content-Type': 'application/json' });
549
- res.end(JSON.stringify({ error: 'Upload failed' }));
550
- }
551
- });
552
- return;
553
- }
554
-
555
- // Import file directly into project directory
556
- if (url.startsWith('/api/import-file') && method === 'POST') {
557
- const contentType = req.headers['content-type'] || '';
558
- const boundaryMatch = contentType.match(/boundary=(.+)/);
559
- if (!boundaryMatch) {
560
- res.writeHead(400, { 'Content-Type': 'application/json' });
561
- res.end(JSON.stringify({ error: 'Missing boundary' }));
562
- return;
563
- }
564
- const importUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
565
- const dir = importUrl.searchParams.get('dir') || '';
566
- // 在 mkdirSync 之前纯字符串校验,防 symlink 副作用目录被创建在项目外
567
- const dirCheck = validateImportDir(dir);
568
- if (!dirCheck.ok) {
569
- res.writeHead(400, { 'Content-Type': 'application/json' });
570
- res.end(JSON.stringify({ error: dirCheck.error }));
571
- return;
572
- }
573
- const MAX_UPLOAD = 100 * 1024 * 1024; // 100MB
574
- const contentLength = parseInt(req.headers['content-length'] || '0', 10);
575
- if (contentLength > MAX_UPLOAD) {
576
- res.writeHead(413, { 'Content-Type': 'application/json' });
577
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
578
- return;
579
- }
580
- const boundary = boundaryMatch[1];
581
- const chunks = [];
582
- let totalSize = 0;
583
- let aborted = false;
584
- req.on('data', chunk => {
585
- totalSize += chunk.length;
586
- if (totalSize > MAX_UPLOAD) {
587
- aborted = true;
588
- res.writeHead(413, { 'Content-Type': 'application/json' });
589
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
590
- req.destroy();
591
- return;
592
- }
593
- chunks.push(chunk);
594
- });
595
- req.on('end', () => {
596
- if (aborted) return;
597
- try {
598
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
599
- const targetDir = join(cwd, dir);
600
- mkdirSync(targetDir, { recursive: true });
601
- const realDir = realpathSync(targetDir);
602
- const realCwd = realpathSync(cwd);
603
- if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
604
- res.writeHead(400, { 'Content-Type': 'application/json' });
605
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
606
- return;
607
- }
608
- const buf = Buffer.concat(chunks);
609
- const headerEnd = buf.indexOf('\r\n\r\n');
610
- if (headerEnd === -1) throw new Error('Malformed multipart');
611
- const headerStr = buf.slice(0, headerEnd).toString();
612
- const nameMatch = headerStr.match(/filename="([^"]+)"/);
613
- if (!nameMatch) throw new Error('No filename');
614
- // sanitize 与 /api/upload 一致:只过真正有害的字符,保留 Unix 合法 : " < > | ? * 等
615
- const originalName = nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_');
616
- // Windows 保留设备名守卫(见 WINDOWS_RESERVED_NAMES 注释)。
617
- {
618
- const base = originalName.split('.')[0].trim().toLowerCase();
619
- if (WINDOWS_RESERVED_NAMES.test(base)) {
620
- throw new Error('Reserved filename not allowed');
621
- }
622
- }
623
- const bodyStart = headerEnd + 4;
624
- const closingBoundary = Buffer.from('\r\n--' + boundary);
625
- const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
626
- const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
627
- // Resolve unique filename via exclusive write (wx);避免并发 TOCTOU 覆盖
628
- const dotIdx = originalName.lastIndexOf('.');
629
- const stem = dotIdx > 0 ? originalName.slice(0, dotIdx) : originalName;
630
- const ext = dotIdx > 0 ? originalName.slice(dotIdx) : '';
631
- let finalName = originalName;
632
- let savePath = join(realDir, finalName);
633
- let counter = 1;
634
- let written = false;
635
- // 最多重试 10000 次(极端场景保底);耗尽后必须显式抛错,防止返回虚假成功
636
- while (counter < 10001) {
637
- try {
638
- writeFileSync(savePath, fileData, { flag: 'wx' });
639
- written = true;
640
- break;
641
- } catch (e) {
642
- if (e && e.code === 'EEXIST') {
643
- finalName = `${stem}-${counter}${ext}`;
644
- savePath = join(realDir, finalName);
645
- counter++;
646
- continue;
647
- }
648
- throw e;
649
- }
650
- }
651
- if (!written) throw new Error('Too many filename conflicts');
652
- const relPath = dir ? `${dir}/${finalName}` : finalName;
653
- res.writeHead(200, { 'Content-Type': 'application/json' });
654
- res.end(JSON.stringify({ ok: true, name: finalName, relPath }));
655
- } catch (err) {
656
- console.error('import-file error:', err);
657
- res.writeHead(500, { 'Content-Type': 'application/json' });
658
- res.end(JSON.stringify({ error: 'Import failed' }));
659
- }
660
- });
661
- return;
662
- }
663
-
664
- // 读取 ~/.claude/plans/*.md 文件内容(用于 ExitPlanMode V2 input.planFilePath 的兜底显示)
665
- // 自身保留:.md 后缀、绝对路径、null-byte、2MB 体积。
666
- // 路径前缀 / realpath / 敏感拦截委托 server/lib/file-access-policy.js(allowlist 已含 ~/.claude/)。
667
- if (url === '/api/plan-file' && method === 'GET') {
668
- try {
669
- const raw = parsedUrl.searchParams.get('path') || '';
670
- if (!raw) {
671
- res.writeHead(400, { 'Content-Type': 'application/json' });
672
- res.end(JSON.stringify({ ok: false, error: 'missing path' }));
673
- return;
674
- }
675
- if (raw.indexOf('\x00') !== -1) {
676
- res.writeHead(400, { 'Content-Type': 'application/json' });
677
- res.end(JSON.stringify({ ok: false, error: 'invalid path (null byte)' }));
678
- return;
679
- }
680
- if (!raw.toLowerCase().endsWith('.md')) {
681
- res.writeHead(400, { 'Content-Type': 'application/json' });
682
- res.end(JSON.stringify({ ok: false, error: 'invalid extension' }));
683
- return;
684
- }
685
- const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(raw);
686
- if (!isAbs) {
687
- res.writeHead(400, { 'Content-Type': 'application/json' });
688
- res.end(JSON.stringify({ ok: false, error: 'absolute path required' }));
689
- return;
690
- }
691
-
692
- // 委托 policy:realpath + allowlist + denylist + 项目内豁免一气呵成
693
- const policy = isReadAllowed(raw);
694
- if (!policy.ok) {
695
- // 兼容旧测试断言:plan-file 历史用 'forbidden' / 'not found' 大类,reason 携带细节
696
- const status = policy.reason === 'realpath-failed' ? 404 : 403;
697
- const errLabel = policy.reason === 'realpath-failed' ? 'not found' : 'forbidden';
698
- res.writeHead(status, { 'Content-Type': 'application/json' });
699
- res.end(JSON.stringify({ ok: false, error: errLabel, reason: policy.reason }));
700
- return;
701
- }
702
-
703
- // 用 policy 返回的 real 读,避免 TOCTOU
704
- const real = policy.real;
705
- const st = statSync(real);
706
- if (!st.isFile()) {
707
- res.writeHead(404, { 'Content-Type': 'application/json' });
708
- res.end(JSON.stringify({ ok: false, error: 'not a file' }));
709
- return;
710
- }
711
- if (st.size > 2 * 1024 * 1024) {
712
- res.writeHead(413, { 'Content-Type': 'application/json' });
713
- res.end(JSON.stringify({ ok: false, error: 'too large' }));
714
- return;
715
- }
716
- const content = readFileSync(real, 'utf-8');
717
- res.writeHead(200, { 'Content-Type': 'application/json' });
718
- res.end(JSON.stringify({ ok: true, content }));
719
- } catch (e) {
720
- res.writeHead(500, { 'Content-Type': 'application/json' });
721
- res.end(JSON.stringify({ ok: false, error: String(e?.message || e) }));
722
- }
723
- return;
724
- }
725
-
726
- if (url === '/api/preferences' && method === 'GET') {
727
- let prefs = {};
728
- try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
729
- prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
730
- // home-friendly 展示形态:设了 CLAUDE_CONFIG_DIR 的用户看到真实路径,默认用户看到 "~/.claude"
731
- // join() 而非字符串拼接,避免 Windows 分隔符不匹配导致比较失败
732
- const _cDir = getClaudeConfigDir();
733
- prefs.claudeConfigDir = _cDir === join(homedir(), '.claude') ? '~/.claude' : _cDir;
734
- // voice-pack id reconcile — strip references to audio files that no longer exist
735
- // so the client never tries to play a 404. Read-only here; client save path also runs this.
736
- if (prefs.approvalModal?.voicePack) {
737
- prefs.approvalModal.voicePack = vpReconcile(LOG_DIR, prefs.approvalModal.voicePack);
738
- }
739
- res.writeHead(200, { 'Content-Type': 'application/json' });
740
- res.end(JSON.stringify(prefs));
741
- return;
742
- }
743
-
744
- if (url === '/api/preferences' && method === 'POST') {
745
- let body = '';
746
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
747
- req.on('end', () => {
748
- try {
749
- const incoming = JSON.parse(body);
750
- // 如果修改了日志目录,先切换再保存到新位置(新目录下生成 preferences.json)
751
- if (incoming.logDir && typeof incoming.logDir === 'string') {
752
- setLogDir(incoming.logDir);
753
- }
754
- let prefs = {};
755
- try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
756
- // Deep-merge approvalModal so partial updates (e.g. `{ voicePack: { events: { askQuestion: id } } }`)
757
- // don't blow away unrelated approval prefs. Shallow Object.assign for everything else.
758
- const { approvalModal: incAM, ...incRest } = incoming;
759
- Object.assign(prefs, incRest);
760
- if (incAM && typeof incAM === 'object') {
761
- prefs.approvalModal = vpMergeAM(prefs.approvalModal, incAM, {
762
- reconcile: (vp) => vpReconcile(LOG_DIR, vp),
763
- });
764
- }
765
- // 确保目录存在
766
- const prefsFile = getPrefsFile();
767
- const prefsDir = dirname(prefsFile);
768
- if (!existsSync(prefsDir)) mkdirSync(prefsDir, { recursive: true });
769
- writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
770
- // 主题切换时同步到 Claude Code CLI:发 /theme,监听输出验证结果,不对就再发一次
771
- if (incoming.themeColor && _writeToPty && _onPtyData) {
772
- const target = incoming.themeColor === 'light' ? 'light' : 'dark';
773
- let buf = '';
774
- let retried = false;
775
- const removeListener = _onPtyData((data) => {
776
- buf += data;
777
- if (buf.length > 4096) buf = buf.slice(-2048); // 限制 buf 大小
778
- // 解析 PTY 输出中的 "Theme set to light" 或 "Theme set to dark"
779
- const match = buf.match(/Theme set to (light|dark)/);
780
- if (match) {
781
- removeListener();
782
- clearTimeout(timeout);
783
- if (match[1] !== target && !retried) {
784
- // 结果与目标不一致,再 toggle 一次
785
- retried = true;
786
- try { _writeToPty('/theme\r'); } catch {}
787
- }
788
- }
789
- });
790
- // 5 秒超时,避免监听器泄漏
791
- const timeout = setTimeout(() => { removeListener(); }, 5000);
792
- try { _writeToPty('/theme\r'); } catch {}
793
- }
794
- prefs.logDir = LOG_DIR;
795
- res.writeHead(200, { 'Content-Type': 'application/json' });
796
- res.end(JSON.stringify(prefs));
797
- } catch {
798
- res.writeHead(400, { 'Content-Type': 'application/json' });
799
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
800
- }
801
- });
802
- return;
803
- }
804
-
805
- // ── Voice Pack API ────────────────────────────────────────────────────────
806
- // Manages user-uploaded audio + serves the bundled "皇上系列" default pack.
807
- // Uploads are loopback-only — LAN clients can play but not write.
808
- if (url === '/api/voice-pack/list' && method === 'GET') {
809
- try {
810
- const userAudio = vpListUserAudio(LOG_DIR);
811
- const bundledPacks = vpListBundledPacks();
812
- // SUNSET-MARKER: ccv-voice-pack-defaultPack-flat-shape
813
- // Legacy defaultPack / defaultPackPlaceholder fields kept alongside the
814
- // new bundledPacks[] for one release so any out-of-tree consumer (mobile
815
- // app shell, third-party fork) doesn't break on the shape change.
816
- // Drop after 1.6.273+. New code should iterate bundledPacks.
817
- const defaultPack = vpListDefaultPack();
818
- res.writeHead(200, { 'Content-Type': 'application/json' });
819
- res.end(JSON.stringify({
820
- userAudio,
821
- bundledPacks,
822
- defaultPack,
823
- defaultPackPlaceholder: vpIsDefaultPackPlaceholder(),
824
- eventKeys: VP_EVENT_KEYS,
825
- maxBytes: VP_MAX_BYTES,
826
- }));
827
- } catch (err) {
828
- res.writeHead(500, { 'Content-Type': 'application/json' });
829
- res.end(JSON.stringify({ error: 'list failed', detail: err?.message }));
830
- }
831
- return;
832
- }
833
-
834
- if (url === '/api/voice-pack/upload' && method === 'POST') {
835
- // Loopback-only — refuse LAN clients even if they hold a valid token.
836
- // The token already gates LAN access but voice-pack writes touch the local FS
837
- // and end up reachable from every client; keep the write side strictly local.
838
- if (!isLocal) {
839
- res.writeHead(403, { 'Content-Type': 'application/json' });
840
- res.end(JSON.stringify({ error: 'Upload allowed from loopback only' }));
841
- return;
842
- }
843
- const contentType = req.headers['content-type'] || '';
844
- const boundaryMatch = contentType.match(/boundary=(.+)/);
845
- if (!boundaryMatch) {
846
- res.writeHead(400, { 'Content-Type': 'application/json' });
847
- res.end(JSON.stringify({ error: 'Missing boundary' }));
848
- return;
849
- }
850
- const contentLength = parseInt(req.headers['content-length'] || '0', 10);
851
- if (contentLength > VP_MAX_BYTES + 4096) {
852
- res.writeHead(413, { 'Content-Type': 'application/json' });
853
- res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
854
- return;
855
- }
856
- const boundary = boundaryMatch[1];
857
- const chunks = [];
858
- let totalSize = 0;
859
- let aborted = false;
860
- req.on('data', chunk => {
861
- totalSize += chunk.length;
862
- if (totalSize > VP_MAX_BYTES + 4096) {
863
- aborted = true;
864
- res.writeHead(413, { 'Content-Type': 'application/json' });
865
- res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
866
- req.destroy();
867
- return;
868
- }
869
- chunks.push(chunk);
870
- });
871
- req.on('end', () => {
872
- if (aborted) return;
873
- try {
874
- const buf = Buffer.concat(chunks);
875
- const headerEnd = buf.indexOf('\r\n\r\n');
876
- if (headerEnd === -1) throw new Error('Malformed multipart');
877
- const headerStr = buf.slice(0, headerEnd).toString();
878
- const nameMatch = headerStr.match(/filename="([^"]+)"/);
879
- const originalName = nameMatch ? nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_') : 'upload';
880
- const bodyStart = headerEnd + 4;
881
- const closingBoundary = Buffer.from('\r\n--' + boundary);
882
- const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
883
- const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
884
- const result = vpSaveAudio(LOG_DIR, originalName, fileData, { isLoopback: true });
885
- res.writeHead(200, { 'Content-Type': 'application/json' });
886
- res.end(JSON.stringify({ ok: true, ...result }));
887
- } catch (err) {
888
- const status = err?.code === 'TOO_LARGE' ? 413 : err?.code === 'BAD_FORMAT' ? 415 : 400;
889
- res.writeHead(status, { 'Content-Type': 'application/json' });
890
- res.end(JSON.stringify({ error: err?.message || 'Upload failed' }));
891
- }
892
- });
893
- return;
894
- }
895
-
896
- if (url.startsWith('/api/voice-pack/delete/') && method === 'DELETE') {
897
- if (!isLocal) {
898
- res.writeHead(403, { 'Content-Type': 'application/json' });
899
- res.end(JSON.stringify({ error: 'Delete allowed from loopback only' }));
900
- return;
901
- }
902
- const id = url.slice('/api/voice-pack/delete/'.length);
903
- if (!vpIsValidId(id)) {
904
- res.writeHead(400, { 'Content-Type': 'application/json' });
905
- res.end(JSON.stringify({ error: 'Invalid id' }));
906
- return;
907
- }
908
- const ok = vpDeleteUserAudio(LOG_DIR, id);
909
- res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
910
- res.end(JSON.stringify({ ok }));
911
- return;
912
- }
913
-
914
- // Serve audio — supports HTTP Range so iOS Safari / mobile players can seek mp3
915
- // (Safari refuses to start playback when the server returns 200 without Accept-Ranges).
916
- // Path forms:
917
- // /api/voice-pack/audio/<packId>/<eventKey> — bundled pack (default, sanguo, …)
918
- // /api/voice-pack/audio/<uuid> — user-uploaded file
919
- if (url.startsWith('/api/voice-pack/audio/') && method === 'GET') {
920
- const tail = url.slice('/api/voice-pack/audio/'.length);
921
- let resolved = null;
922
- let isBundled = false;
923
- // Iterate the explicit BUNDLED_PACK_IDS list so an unknown prefix can never
924
- // accidentally hit the bundled branch — falls through to the uuid lookup,
925
- // which is whitelisted by isValidId.
926
- for (const packId of VP_BUNDLED_PACK_IDS) {
927
- const prefix = `${packId}/`;
928
- if (tail.startsWith(prefix)) {
929
- const eventKey = tail.slice(prefix.length);
930
- resolved = vpGetBundledPackPath(packId, eventKey);
931
- isBundled = true;
932
- break;
933
- }
934
- }
935
- if (!isBundled) {
936
- resolved = vpGetUserAudioPath(LOG_DIR, tail);
937
- }
938
- if (!resolved) {
939
- res.writeHead(404, { 'Content-Type': 'application/json' });
940
- res.end(JSON.stringify({ error: 'Not found' }));
941
- return;
942
- }
943
- // Cache strategy:
944
- // - default-pack: short max-age + must-revalidate, no `immutable` — the on-disk
945
- // file *can* change when a placeholder is replaced by a real recording at the
946
- // same path. `must-revalidate` keeps the file out of the stale bucket once
947
- // max-age expires. Paired with the ETag below, this lets browsers
948
- // conditional-request and pick up regenerated audio after a `gen-default-voicepack`
949
- // run — without an ETag they'd silently serve cached stale content.
950
- // - user audio: content-addressed by UUID (delete + re-upload always mints a
951
- // new id), so safe to mark immutable for a full day. Loopback-only writes,
952
- // so the LAN audience cannot mutate.
953
- const cacheControl = isBundled
954
- ? 'public, max-age=300, must-revalidate'
955
- : 'private, max-age=86400, immutable';
956
- try {
957
- // Symlink hardening: refuse to serve symlinks even though the routing layer
958
- // already enforces the id whitelist. A local attacker who can write to
959
- // LOG_DIR/voice-packs/ could otherwise drop `<uuid>.mp3 → /etc/passwd` and
960
- // have it streamed over LAN. Same family as the file-access-policy realpath
961
- // check used elsewhere in server.js for /api/read-file.
962
- const ls = lstatSync(resolved.path);
963
- if (ls.isSymbolicLink()) {
964
- res.writeHead(404, { 'Content-Type': 'application/json' });
965
- res.end(JSON.stringify({ error: 'Not found' }));
966
- return;
967
- }
968
- const stat = statSync(resolved.path);
969
- const fileSize = stat.size;
970
- // ETag = "<size>-<mtime ms>" — cheap, stable across restarts, changes whenever
971
- // the file is rewritten. Honors If-None-Match → 304 so a regenerated default
972
- // pack actually reaches the browser instead of being silently served stale.
973
- const etag = `"${fileSize.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
974
- if (req.headers['if-none-match'] === etag) {
975
- res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl });
976
- res.end();
977
- return;
978
- }
979
- const mime = vpMime(resolved.format);
980
- const range = req.headers.range;
981
- if (range) {
982
- const m = range.match(/bytes=(\d+)-(\d*)/);
983
- if (m) {
984
- const start = parseInt(m[1], 10);
985
- const end = m[2] ? parseInt(m[2], 10) : fileSize - 1;
986
- if (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end < fileSize && start <= end) {
987
- res.writeHead(206, {
988
- 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
989
- 'Accept-Ranges': 'bytes',
990
- 'Content-Length': end - start + 1,
991
- 'Content-Type': mime,
992
- 'Cache-Control': cacheControl,
993
- ETag: etag,
994
- });
995
- createReadStream(resolved.path, { start, end }).pipe(res);
996
- return;
997
- }
998
- }
999
- res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` });
1000
- res.end();
1001
- return;
1002
- }
1003
- res.writeHead(200, {
1004
- 'Content-Length': fileSize,
1005
- 'Content-Type': mime,
1006
- 'Accept-Ranges': 'bytes',
1007
- 'Cache-Control': cacheControl,
1008
- ETag: etag,
1009
- });
1010
- createReadStream(resolved.path).pipe(res);
1011
- } catch (err) {
1012
- res.writeHead(500, { 'Content-Type': 'application/json' });
1013
- res.end(JSON.stringify({ error: 'Read failed', detail: err?.message }));
1014
- }
1015
- return;
1016
- }
1017
- // ──────────────────────────────────────────────────────────────────────────
1018
-
1019
- // Turn-end notification — POSTed by server/lib/turn-end-bridge.js when Claude Code's
1020
- // Stop hook fires (CLI/PTY mode) AND directly by server/lib/sdk-manager.js's
1021
- // onTurnEnd callback (SDK mode — Stop hooks don't exist there, so cli.js
1022
- // wires the SDK 'result' event to broadcastTurnEnd() instead). Broadcasts a
1023
- // `turn_end` SSE so all connected tabs play voice-pack turnEnd at the real
1024
- // end of a user-prompt turn.
1025
- //
1026
- // Auth: loopback IP + X-CCViewer-Internal header matching INTERNAL_TOKEN.
1027
- // Defense-in-depth against a same-host malicious page POSTing fake turn_end
1028
- // events (round-3 P1). Internal-only POST → in-process SDK callback skips
1029
- // this endpoint and calls `_scheduleTurnEndBroadcast` directly below.
1030
- if (url === '/api/turn-end-notify' && method === 'POST') {
1031
- if (!isLocal) {
1032
- res.writeHead(403, { 'Content-Type': 'application/json' });
1033
- res.end(JSON.stringify({ error: 'Loopback only' }));
1034
- return;
1035
- }
1036
- if (req.headers['x-ccviewer-internal'] !== INTERNAL_TOKEN) {
1037
- res.writeHead(403, { 'Content-Type': 'application/json' });
1038
- res.end(JSON.stringify({ error: 'Invalid bridge token' }));
1039
- return;
1040
- }
1041
- let body = '';
1042
- let truncated = false;
1043
- req.on('data', chunk => {
1044
- body += chunk;
1045
- if (body.length > 16384) { truncated = true; req.destroy(); }
1046
- });
1047
- req.on('end', () => {
1048
- if (truncated) {
1049
- console.warn('[turn-end-notify] body exceeded 16KB cap — request destroyed');
1050
- return; // socket already closed by destroy()
1051
- }
1052
- let payload = {};
1053
- let badJson = false;
1054
- try { payload = body ? JSON.parse(body) : {}; }
1055
- catch { badJson = true; console.warn('[turn-end-notify] malformed JSON body'); }
1056
- if (badJson) {
1057
- res.writeHead(400, { 'Content-Type': 'application/json' });
1058
- res.end(JSON.stringify({ error: 'malformed JSON body' }));
1059
- return;
1060
- }
1061
- _scheduleTurnEndBroadcast(payload.sessionId || null, payload.ts || Date.now());
1062
- res.writeHead(200, { 'Content-Type': 'application/json' });
1063
- res.end(JSON.stringify({ ok: true }));
1064
- });
1065
- return;
1066
- }
1067
-
1068
- // 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
1069
- if (url === '/api/register-log' && method === 'POST') {
1070
- let body = '';
1071
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
1072
- req.on('end', () => {
1073
- try {
1074
- const { logFile } = JSON.parse(body);
1075
- if (logFile && typeof logFile === 'string' && logFile.startsWith(LOG_DIR) && existsSync(logFile)) {
1076
- watchLogFile(_logWatcherOpts(logFile));
1077
- res.writeHead(200, { 'Content-Type': 'application/json' });
1078
- res.end(JSON.stringify({ ok: true }));
1079
- } else {
1080
- res.writeHead(400, { 'Content-Type': 'application/json' });
1081
- res.end(JSON.stringify({ error: 'Invalid log file path' }));
1082
- }
1083
- } catch {
1084
- res.writeHead(400, { 'Content-Type': 'application/json' });
1085
- res.end(JSON.stringify({ error: 'Invalid request body' }));
1086
- }
1087
- });
1088
- return;
1089
- }
1090
-
1091
- // 用户选择继续/新开日志
1092
- if (url === '/api/resume-choice' && method === 'POST') {
1093
- let body = '';
1094
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
1095
- req.on('end', async () => {
1096
- try {
1097
- const { choice } = JSON.parse(body);
1098
- if (choice !== 'continue' && choice !== 'new') {
1099
- res.writeHead(400, { 'Content-Type': 'application/json' });
1100
- res.end(JSON.stringify({ error: 'Invalid choice' }));
1101
- return;
1102
- }
1103
- const result = resolveResumeChoice(choice);
1104
- if (!result) {
1105
- res.writeHead(409, { 'Content-Type': 'application/json' });
1106
- res.end(JSON.stringify({ error: 'Already resolved' }));
1107
- return;
1108
- }
1109
- // 重新 watch 最终的日志文件
1110
- watchLogFile(_logWatcherOpts(result.logFile));
1111
- // 广播 resume_resolved + full_reload
1112
- const resolvedData = JSON.stringify({ logFile: result.logFile });
1113
- clients.forEach(client => {
1114
- try {
1115
- client.write(`event: resume_resolved\ndata: ${resolvedData}\n\n`);
1116
- } catch { }
1117
- });
1118
- // 流式分段广播 full_reload,避免全量加载 OOM
1119
- const reloadTotal = countLogEntries(LOG_FILE);
1120
- clients.forEach(client => {
1121
- try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: reloadTotal, incremental: false })}\n\n`); } catch { }
1122
- });
1123
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
1124
- clients.forEach(client => {
1125
- try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch { }
1126
- });
1127
- });
1128
- clients.forEach(client => {
1129
- try { client.write(`event: load_end\ndata: {}\n\n`); } catch { }
1130
- });
1131
- res.writeHead(200, { 'Content-Type': 'application/json' });
1132
- res.end(JSON.stringify({ ok: true, logFile: result.logFile }));
1133
- } catch {
1134
- res.writeHead(400, { 'Content-Type': 'application/json' });
1135
- res.end(JSON.stringify({ error: 'Invalid request body' }));
1136
- }
1137
- });
1138
- return;
1139
- }
1140
-
1141
- // === Workspace API ===
1142
-
1143
- // 目录浏览器
1144
- if (url.startsWith('/api/browse-dir') && method === 'GET') {
1145
- try {
1146
- const dirPath = parsedUrl.searchParams.get('path') || homedir();
1147
- if (!existsSync(dirPath) || !statSync(dirPath).isDirectory()) {
1148
- res.writeHead(400, { 'Content-Type': 'application/json' });
1149
- res.end(JSON.stringify({ error: 'Invalid directory' }));
1150
- return;
1151
- }
1152
- const entries = readdirSync(dirPath, { withFileTypes: true });
1153
- const dirs = [];
1154
- for (const entry of entries) {
1155
- if (!entry.isDirectory()) continue;
1156
- if (entry.name.startsWith('.') && entry.name !== '.') continue;
1157
- const fullPath = join(dirPath, entry.name);
1158
- let hasGit = false;
1159
- try { hasGit = existsSync(join(fullPath, '.git')); } catch {}
1160
- dirs.push({ name: entry.name, path: fullPath, hasGit });
1161
- }
1162
- dirs.sort((a, b) => {
1163
- if (a.hasGit !== b.hasGit) return a.hasGit ? -1 : 1;
1164
- return a.name.localeCompare(b.name);
1165
- });
1166
- const parent = join(dirPath, '..');
1167
- res.writeHead(200, { 'Content-Type': 'application/json' });
1168
- res.end(JSON.stringify({ current: dirPath, parent: parent !== dirPath ? parent : null, dirs }));
1169
- } catch (err) {
1170
- res.writeHead(500, { 'Content-Type': 'application/json' });
1171
- res.end(JSON.stringify({ error: err.message }));
1172
- }
1173
- return;
1174
- }
1175
-
1176
- if (url === '/api/workspaces' && method === 'GET') {
1177
- import('./workspace-registry.js').then(({ getWorkspaces }) => {
1178
- const workspaces = getWorkspaces();
1179
- res.writeHead(200, { 'Content-Type': 'application/json' });
1180
- res.end(JSON.stringify({ workspaces, workspaceMode: isWorkspaceMode && !_workspaceLaunched }));
1181
- }).catch(err => {
1182
- res.writeHead(500, { 'Content-Type': 'application/json' });
1183
- res.end(JSON.stringify({ error: err.message }));
1184
- });
1185
- return;
1186
- }
1187
-
1188
- if (url === '/api/workspaces/launch' && method === 'POST') {
1189
- let body = '';
1190
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
1191
- req.on('end', async () => {
1192
- try {
1193
- const { path: wsPath, extraArgs: launchExtraArgs } = JSON.parse(body);
1194
- if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
1195
- res.writeHead(400, { 'Content-Type': 'application/json' });
1196
- res.end(JSON.stringify({ error: 'Invalid directory path' }));
1197
- return;
1198
- }
1199
-
1200
- const { registerWorkspace } = await import('./workspace-registry.js');
1201
- registerWorkspace(wsPath);
1202
-
1203
- // Electron multi-tab 模式:管理 server 只触发 callback,不做日志初始化
1204
- // 所有日志相关操作(initForWorkspace、watchLogFile、spawnClaude)由 tab-worker 子进程负责
1205
- if (process.env.CCV_ELECTRON_MULTITAB === '1') {
1206
- if (_launchCallback) {
1207
- _launchCallback(wsPath, Array.isArray(launchExtraArgs) ? launchExtraArgs : []);
1208
- }
1209
- _workspaceLaunched = true;
1210
- res.writeHead(200, { 'Content-Type': 'application/json' });
1211
- res.end(JSON.stringify({ ok: true, projectName: basename(wsPath) }));
1212
- return;
1213
- }
1214
-
1215
- // 非 Electron 模式(web / CLI):完整逻辑
1216
- const result = initForWorkspace(wsPath);
1217
- process.env.CCV_PROJECT_DIR = wsPath;
1218
-
1219
- // 启动日志监听
1220
- watchLogFile(_logWatcherOpts(LOG_FILE));
1221
-
1222
- // 启动 stats worker(如果尚未启动)
1223
- if (!statsWorker) startStatsWorker();
1224
- startStreamingStatusTimer();
1225
-
1226
- // 启动 PTY
1227
- const proxyPort = process.env.CCV_PROXY_PORT;
1228
- if (proxyPort) {
1229
- const { spawnClaude } = await import('./pty-manager.js');
1230
- const mergedArgs = [..._workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
1231
- await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol, INTERNAL_TOKEN);
1232
- }
1233
-
1234
- _workspaceLaunched = true;
1235
-
1236
- // 通知所有 SSE 客户端
1237
- clients.forEach(client => {
1238
- try {
1239
- client.write(`event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath, claudeProjectModel: readClaudeProjectModel(wsPath) })}\n\n`);
1240
- } catch {}
1241
- });
1242
-
1243
- // 流式分段广播以刷新会话区域,避免全量加载 OOM
1244
- const wsReloadTotal = countLogEntries(LOG_FILE);
1245
- clients.forEach(client => {
1246
- try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
1247
- });
1248
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
1249
- clients.forEach(client => {
1250
- try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch {}
1251
- });
1252
- });
1253
- clients.forEach(client => {
1254
- try { client.write(`event: load_end\ndata: {}\n\n`); } catch {}
1255
- });
1256
-
1257
- res.writeHead(200, { 'Content-Type': 'application/json' });
1258
- res.end(JSON.stringify({ ok: true, projectName: result.projectName }));
1259
- } catch (err) {
1260
- res.writeHead(500, { 'Content-Type': 'application/json' });
1261
- res.end(JSON.stringify({ error: err.message }));
1262
- }
1263
- });
1264
- return;
1265
- }
1266
-
1267
- if (url === '/api/workspaces/add' && method === 'POST') {
1268
- let body = '';
1269
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
1270
- req.on('end', async () => {
1271
- try {
1272
- const { path: wsPath } = JSON.parse(body);
1273
- if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
1274
- res.writeHead(400, { 'Content-Type': 'application/json' });
1275
- res.end(JSON.stringify({ error: 'Invalid directory path' }));
1276
- return;
1277
- }
1278
- const { registerWorkspace } = await import('./workspace-registry.js');
1279
- const entry = registerWorkspace(wsPath);
1280
- res.writeHead(200, { 'Content-Type': 'application/json' });
1281
- res.end(JSON.stringify({ ok: true, workspace: entry }));
1282
- } catch (err) {
1283
- res.writeHead(500, { 'Content-Type': 'application/json' });
1284
- res.end(JSON.stringify({ error: err.message }));
1285
- }
1286
- });
1287
- return;
1288
- }
1289
-
1290
- if (url.startsWith('/api/workspaces/') && method === 'DELETE') {
1291
- const id = url.split('/').pop();
1292
- import('./workspace-registry.js').then(({ removeWorkspace }) => {
1293
- const removed = removeWorkspace(id);
1294
- res.writeHead(200, { 'Content-Type': 'application/json' });
1295
- res.end(JSON.stringify({ ok: removed }));
1296
- }).catch(err => {
1297
- res.writeHead(500, { 'Content-Type': 'application/json' });
1298
- res.end(JSON.stringify({ error: err.message }));
1299
- });
1300
- return;
1301
- }
1302
-
1303
- if (url === '/api/workspaces/stop' && method === 'POST') {
1304
- Promise.all([
1305
- import('./pty-manager.js').then(({ killPty }) => killPty()),
1306
- import('./scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
1307
- ]).then(() => {
1308
- // 接续原有清理流程
1309
-
1310
- // 停止日志监听
1311
- for (const logFile of getWatchedFiles().keys()) {
1312
- unwatchFile(logFile);
1313
- }
1314
- getWatchedFiles().clear();
1315
-
1316
- // 重置 interceptor 状态
1317
- resetWorkspace();
1318
- _workspaceLaunched = false;
1319
-
1320
- // 通知所有 SSE 客户端
1321
- clients.forEach(client => {
1322
- try {
1323
- client.write(`event: workspace_stopped\ndata: {}\n\n`);
1324
- } catch {}
1325
- });
1326
-
1327
- res.writeHead(200, { 'Content-Type': 'application/json' });
1328
- res.end(JSON.stringify({ ok: true }));
1329
- }).catch(err => {
1330
- res.writeHead(500, { 'Content-Type': 'application/json' });
1331
- res.end(JSON.stringify({ error: err.message }));
1332
- });
1333
- return;
1334
- }
1335
-
1336
- // SSE endpoint
1337
- if (url === '/events' && method === 'GET') {
1338
- res.writeHead(200, {
1339
- 'Content-Type': 'text/event-stream',
1340
- 'Cache-Control': 'no-cache',
1341
- 'Connection': 'keep-alive',
1342
- });
1343
-
1344
- // 注意:不要在此处 clients.push(res)!
1345
- // 必须等 load_end + kv_cache + context_window 全部发送完毕后再加入广播列表,
1346
- // 否则 streamRawEntriesAsync 的 setImmediate yield 间隙会让 watcher 的
1347
- // sendToClients 向该客户端推送 live entry,而 load_end 的 setState 会覆盖这些
1348
- // 已处理的 live entry,导致 对话条目"显示→消失→重现"闪烁。
1349
-
1350
- // SSE 心跳保活:每 30s 发送 ping 事件,防止连接被 OS/代理/浏览器静默断开
1351
- const pingTimer = setInterval(() => {
1352
- try { res.write('event: ping\ndata: {}\n\n'); } catch {}
1353
- }, 30000);
1354
-
1355
- // server_config: 给前端推一次性的关键运行时常量,让前端 cooldown / debounce 等
1356
- // 模块常量能跟随 server env 自动对齐(避免「server 改了 env、前端写死」漂移)。
1357
- // 见 server.js 顶部 turnEnd debounce 的 SUNSET-MARKER 注释。
1358
- // write 失败要 warn:之前静默吞会让 env override 漂移到前端硬常量 10s 而无人发觉。
1359
- try {
1360
- res.write(`event: server_config\ndata: ${JSON.stringify({ turnEndDebounceMs: TURN_END_DEBOUNCE_MS })}\n\n`);
1361
- } catch (err) {
1362
- console.warn(`[server_config] SSE write failed (turnEndDebounceMs=${TURN_END_DEBOUNCE_MS}):`, err && err.message);
1363
- }
1364
-
1365
- // 如果有待决的 resume 选择,发送 resume_prompt 事件
1366
- if (_resumeState) {
1367
- res.write(`event: resume_prompt\ndata: ${JSON.stringify({ recentFileName: _resumeState.recentFileName })}\n\n`);
1368
- }
1369
-
1370
- // 增量加载参数:移动端带 since/cc/project 请求增量数据
1371
- const sinceParam = parsedUrl.searchParams.get('since');
1372
- const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
1373
- const projectParam = parsedUrl.searchParams.get('project');
1374
- const projectMatch = !projectParam || projectParam === (_projectName || '');
1375
- const useIncremental = !!(sinceParam && ccParam > 0 && projectMatch && !isNaN(new Date(sinceParam).getTime()));
1376
-
1377
- // 分页参数:
1378
- // - mobile 首次加载传 ?limit=200
1379
- // - bare desktop 请求(无任何 query 参数)默认套 DEFAULT_EVENTS_LIMIT
1380
- // - 显式 ?limit=0 表示"我要全量"(保留旧行为入口)
1381
- const limitParamRaw = parsedUrl.searchParams.get('limit');
1382
- const limitParamGiven = limitParamRaw !== null;
1383
- const limitParamNum = parseInt(limitParamRaw, 10);
1384
- let effectiveLimit = 0;
1385
- if (!useIncremental) {
1386
- if (limitParamGiven) {
1387
- effectiveLimit = Number.isFinite(limitParamNum) && limitParamNum > 0 ? limitParamNum : 0;
1388
- } else {
1389
- effectiveLimit = DEFAULT_EVENTS_LIMIT;
1390
- }
1391
- }
1392
- const useLimit = effectiveLimit > 0;
1393
-
1394
- // KV-Cache / context_window 追踪(扫描全量条目,不受 since 过滤影响)
1395
- let latestKvCache = null;
1396
- let latestContextWindow = null;
1397
- let pushedContextWindow = false;
1398
-
1399
- await streamRawEntriesAsync(LOG_FILE, async (raw) => {
1400
- // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
1401
- // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
1402
- if (res.destroyed || !res.writable) return;
1403
- const out = enrichRawIfNeeded(raw);
1404
- // SSE data 字段不允许裸换行,去除 pretty-printed JSON 的换行
1405
- // 写入路径整体 try-catch 兜底:连接在 res.write 之间被对端 RST/destroy 时不至于
1406
- // 把 EPIPE 抛穿 async callback;res.on('close'|'error') 已会做 clients 数组清理。
1407
- let drained = true;
1408
- try {
1409
- res.write('event: load_chunk\ndata: [');
1410
- drained = res.write(out.includes('\n') ? out.replace(/\n/g, '') : out);
1411
- res.write(']\n\n');
1412
- } catch {
1413
- return;
1414
- }
1415
- // 写缓冲满则等 drain(或 close/error/超时任一 fulfill),防止浏览器侧 renderer OOM。
1416
- // helper 内部会在 fulfill 时把另外两个监听器从 res 上摘掉,避免 N 次 backpressure
1417
- // 累积出 N 个 stale close/error listener 触发 MaxListenersExceededWarning。
1418
- if (!drained) {
1419
- await awaitDrainOrClose(res, SSE_BACKPRESSURE_TIMEOUT_MS);
1420
- }
1421
- }, {
1422
- since: useIncremental ? sinceParam : undefined,
1423
- limit: useLimit ? effectiveLimit : undefined,
1424
- onScan: (raw) => {
1425
- // 轻量追踪最新 MainAgent 的 KV-Cache 和 context_window(仅 regex 检测)
1426
- if (raw.includes('"mainAgent":true') || raw.includes('"mainAgent": true')) {
1427
- try {
1428
- const entry = JSON.parse(raw);
1429
- if (isMainAgentEntry(entry)) {
1430
- const cached = extractCachedContent(entry);
1431
- if (cached) latestKvCache = cached;
1432
- const usage = entry.response?.body?.usage;
1433
- if (usage) {
1434
- const contextSize = getContextSizeForModel(entry.body?.model);
1435
- const cw = buildContextWindowEvent(usage, contextSize);
1436
- if (cw) latestContextWindow = cw;
1437
- }
1438
- }
1439
- } catch { }
1440
- }
1441
- },
1442
- onReady: ({ totalCount, hasMore, oldestTs }) => {
1443
- // Pass 1 完成、Pass 2 开始前:发送 load_start
1444
- // 增量模式下不显示 loading 遮罩,非增量模式显示进度
1445
- const loadStartData = { total: totalCount, incremental: !!useIncremental };
1446
- // 分页模式下附加 hasMore/oldestTs(增量模式由客户端从缓存自行判断)
1447
- if (useLimit) {
1448
- loadStartData.hasMore = !!hasMore;
1449
- loadStartData.oldestTs = oldestTs || '';
1450
- }
1451
- res.write(`event: load_start\ndata: ${JSON.stringify(loadStartData)}\n\n`);
1452
- },
1453
- });
1454
-
1455
- res.write(`event: load_end\ndata: {}\n\n`);
1456
-
1457
- // 发送最新 MainAgent 的 KV-Cache 和 context_window
1458
- if (latestKvCache) {
1459
- res.write(`event: kv_cache_content\ndata: ${JSON.stringify(latestKvCache)}\n\n`);
1460
- }
1461
- if (latestContextWindow) {
1462
- res.write(`event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
1463
- pushedContextWindow = true;
1464
- }
1465
- // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
1466
- if (!pushedContextWindow) {
1467
- try {
1468
- const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
1469
- const cwFile = JSON.parse(cwRaw);
1470
- if (cwFile?.context_window) {
1471
- // Recalculate with correct context size from model.id
1472
- const { contextSize } = readModelContextSize();
1473
- const cw = cwFile.context_window;
1474
- const inputTokens = cw.total_input_tokens || 0;
1475
- const outputTokens = cw.total_output_tokens || 0;
1476
- const totalTokens = inputTokens + outputTokens;
1477
- const usedPct = contextSize > 0 ? Math.round((totalTokens / contextSize) * 100) : 0;
1478
- const data = { ...cw, context_window_size: contextSize, used_percentage: usedPct, remaining_percentage: 100 - usedPct };
1479
- res.write(`event: context_window\ndata: ${JSON.stringify(data)}\n\n`);
1480
- }
1481
- } catch { }
1482
- }
1483
-
1484
- // 历史数据 + KV-Cache + context_window 全部发送完毕后,才将客户端加入广播列表。
1485
- // 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
1486
- clients.push(res);
1487
-
1488
- // req.on('close') 在某些异常断连时不一定立即触发;res 端 close/error 兜底保证
1489
- // 不会在 clients 数组里留下幽灵 res,防止 sendToClients 后续写入触发慢泄漏。
1490
- const removeFromClients = () => {
1491
- clearInterval(pingTimer);
1492
- const idx = clients.indexOf(res);
1493
- if (idx !== -1) clients.splice(idx, 1);
1494
- };
1495
- req.on('close', removeFromClients);
1496
- res.on('close', removeFromClients);
1497
- res.on('error', removeFromClients);
1498
- return;
1499
- }
1500
-
1501
- // API endpoint
1502
- if (url === '/api/requests' && method === 'GET') {
1503
- // 异步流式 JSON 数组输出,不做 reconstruct,发原始条目
1504
- res.writeHead(200, { 'Content-Type': 'application/json' });
1505
- res.write('[');
1506
- let first = true;
1507
- await streamRawEntriesAsync(LOG_FILE, (raw) => {
1508
- if (!first) res.write(',');
1509
- res.write(enrichRawIfNeeded(raw));
1510
- first = false;
1511
- });
1512
- res.write(']');
1513
- res.end();
1514
- return;
1515
- }
1516
-
1517
- // 分页历史条目端点:移动端"加载更多"按需拉取
1518
- if (url === '/api/entries/page' && method === 'GET') {
1519
- const before = parsedUrl.searchParams.get('before');
1520
- const limitVal = Math.min(parseInt(parsedUrl.searchParams.get('limit'), 10) || 100, 500);
1521
- if (!before || isNaN(new Date(before).getTime())) {
1522
- res.writeHead(400, { 'Content-Type': 'application/json' });
1523
- res.end(JSON.stringify({ error: 'missing or invalid "before" parameter' }));
1524
- return;
1525
- }
1526
- try {
1527
- const result = readPagedEntries(LOG_FILE, { before, limit: limitVal });
1528
- // entries 是原始 JSON 字符串数组,parse 后返回给客户端
1529
- // ExitPlanMode V2 空 input 的条目用 enrichRawIfNeeded 在 raw 阶段补全
1530
- const entries = result.entries.map(raw => {
1531
- try { return JSON.parse(enrichRawIfNeeded(raw)); } catch { return null; }
1532
- }).filter(Boolean);
1533
- res.writeHead(200, { 'Content-Type': 'application/json' });
1534
- res.end(JSON.stringify({
1535
- entries,
1536
- hasMore: result.hasMore,
1537
- oldestTimestamp: result.oldestTimestamp,
1538
- count: entries.length,
1539
- }));
1540
- } catch (err) {
1541
- res.writeHead(500, { 'Content-Type': 'application/json' });
1542
- res.end(JSON.stringify({ error: err.message }));
1543
- }
1544
- return;
1545
- }
1546
-
1547
- // 当前监控的项目名称
1548
- if (url === '/api/project-name' && method === 'GET') {
1549
- res.writeHead(200, { 'Content-Type': 'application/json' });
1550
- res.end(JSON.stringify({ projectName: _projectName || '' }));
1551
- return;
1552
- }
1553
-
1554
- // 返回项目目录绝对路径(前端用于将绝对路径转为相对路径)
1555
- if (url === '/api/project-dir' && method === 'GET') {
1556
- const dir = process.env.CCV_PROJECT_DIR || process.cwd();
1557
- res.writeHead(200, { 'Content-Type': 'application/json' });
1558
- res.end(JSON.stringify({ dir }));
1559
- return;
1560
- }
1561
-
1562
- // 当前版本号
1563
- if (url === '/api/version-info' && method === 'GET') {
1564
- try {
1565
- const pkg = JSON.parse(readFileSync(PACKAGE_JSON, 'utf-8'));
1566
- res.writeHead(200, { 'Content-Type': 'application/json' });
1567
- res.end(JSON.stringify({ version: pkg.version }));
1568
- } catch {
1569
- res.writeHead(500, { 'Content-Type': 'application/json' });
1570
- res.end(JSON.stringify({ error: 'Failed to read version' }));
1571
- }
1572
- return;
1573
- }
1574
-
1575
- // 项目统计数据
1576
- if (url === '/api/project-stats' && method === 'GET') {
1577
- try {
1578
- if (!_projectName) {
1579
- res.writeHead(404, { 'Content-Type': 'application/json' });
1580
- res.end(JSON.stringify({ error: 'No project name' }));
1581
- return;
1582
- }
1583
- const statsFile = join(LOG_DIR, _projectName, `${_projectName}.json`);
1584
- if (!existsSync(statsFile)) {
1585
- res.writeHead(404, { 'Content-Type': 'application/json' });
1586
- res.end(JSON.stringify({ error: 'Stats file not found' }));
1587
- return;
1588
- }
1589
- const stats = readFileSync(statsFile, 'utf-8');
1590
- res.writeHead(200, { 'Content-Type': 'application/json' });
1591
- res.end(stats);
1592
- } catch (err) {
1593
- res.writeHead(500, { 'Content-Type': 'application/json' });
1594
- res.end(JSON.stringify({ error: err.message }));
1595
- }
1596
- return;
1597
- }
1598
-
1599
- // 所有项目统计数据
1600
- if (url === '/api/all-project-stats' && method === 'GET') {
1601
- try {
1602
- const allStats = {};
1603
- if (existsSync(LOG_DIR)) {
1604
- const entries = readdirSync(LOG_DIR, { withFileTypes: true });
1605
- for (const entry of entries) {
1606
- if (!entry.isDirectory()) continue;
1607
- const project = entry.name;
1608
- const statsFile = join(LOG_DIR, project, `${project}.json`);
1609
- if (existsSync(statsFile)) {
1610
- try {
1611
- allStats[project] = JSON.parse(readFileSync(statsFile, 'utf-8'));
1612
- } catch { }
1613
- }
1614
- }
1615
- }
1616
- res.writeHead(200, { 'Content-Type': 'application/json' });
1617
- res.end(JSON.stringify(allStats));
1618
- } catch (err) {
1619
- res.writeHead(500, { 'Content-Type': 'application/json' });
1620
- res.end(JSON.stringify({ error: err.message }));
1621
- }
1622
- return;
1623
- }
1624
-
1625
- // 刷新统计:强制重新扫描所有项目日志,等待完成后再响应
1626
- if (url === '/api/refresh-stats' && method === 'POST') {
1627
- try {
1628
- if (!statsWorker) startStatsWorker();
1629
- if (statsWorker) {
1630
- const timeout = setTimeout(() => {
1631
- statsWorker?.removeListener('message', onDone);
1632
- res.writeHead(504, { 'Content-Type': 'application/json' });
1633
- res.end(JSON.stringify({ error: 'Stats refresh timed out' }));
1634
- }, 30000);
1635
- const onDone = (m) => {
1636
- if (m.type === 'scan-all-done') {
1637
- clearTimeout(timeout);
1638
- statsWorker?.removeListener('message', onDone);
1639
- res.writeHead(200, { 'Content-Type': 'application/json' });
1640
- res.end(JSON.stringify({ ok: true }));
1641
- }
1642
- };
1643
- statsWorker.on('message', onDone);
1644
- statsWorker.postMessage({ type: 'scan-all', logDir: LOG_DIR });
1645
- } else {
1646
- res.writeHead(500, { 'Content-Type': 'application/json' });
1647
- res.end(JSON.stringify({ error: 'Stats worker not available' }));
1648
- }
1649
- } catch (err) {
1650
- res.writeHead(500, { 'Content-Type': 'application/json' });
1651
- res.end(JSON.stringify({ error: err.message }));
1652
- }
1653
- return;
1654
- }
1655
-
1656
- // Claude settings.json(启动时读取,不 watch)
1657
- if (url === '/api/claude-settings' && method === 'GET') {
1658
- res.writeHead(200, { 'Content-Type': 'application/json' });
1659
- const fileEnv = claudeSettings.env || {};
1660
- // 与 Claude Code 保持一致:settings.json env 优先,fallback 到 process.env
1661
- const env = { ...fileEnv };
1662
- if (!env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS && process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) {
1663
- env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
1664
- }
1665
- // claudeProjectModel:从 ~/.claude.json projects[cwd].lastModelUsage 推断,
1666
- // 给前端血条 calibration 'auto' 模式作启动期回落(避免 haiku init ping 让血条
1667
- // 错显 200K,详见 src/utils/helpers.js resolveCalibrationTokens)。
1668
- const projectCwd = process.env.CCV_PROJECT_DIR || process.cwd();
1669
- res.end(JSON.stringify({ env, model: claudeSettings.model || null, showThinkingSummaries: claudeSettings.showThinkingSummaries || false, claudeAvailable: process.env.CCV_CLAUDE_MISSING !== '1', claudeProjectModel: readClaudeProjectModel(projectCwd) }));
1670
- return;
1671
- }
1672
-
1673
- if (url === '/api/claude-settings' && method === 'POST') {
1674
- let body = '';
1675
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
1676
- req.on('end', () => {
1677
- try {
1678
- const incoming = JSON.parse(body);
1679
- const settingsPath = join(getClaudeConfigDir(), 'settings.json');
1680
- let settings = {};
1681
- try { if (existsSync(settingsPath)) settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { }
1682
- Object.assign(settings, incoming);
1683
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1684
- Object.assign(claudeSettings, incoming);
1685
- res.writeHead(200, { 'Content-Type': 'application/json' });
1686
- res.end(JSON.stringify({ ok: true }));
1687
- } catch {
1688
- res.writeHead(400, { 'Content-Type': 'application/json' });
1689
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1690
- }
1691
- });
1692
- return;
1693
- }
1694
-
1695
- // Proxy profile 热切换
1696
- // 数据拆分:profiles 列表 → profile.json(全局共享,fs.watchFile 跨进程同步)
1697
- // active → <workspace>/active-profile.json(每 workspace 独占,不污染其他 ccv 实例)
1698
- if (url === '/api/proxy-profiles' && method === 'GET') {
1699
- try {
1700
- const data = existsSync(PROFILE_PATH) ? JSON.parse(readFileSync(PROFILE_PATH, 'utf-8')) : _defaultProxyProfiles;
1701
- // 用 interceptor.getActiveProfileId() 返回 effective active(workspace > profile.json.active > 'max')
1702
- const effectiveActive = getActiveProfileId();
1703
- const masked = _maskProfiles({ ...data, active: effectiveActive });
1704
- if (_defaultConfig) masked.defaultConfig = { ..._defaultConfig, apiKey: _defaultConfig.apiKey ? _maskApiKey(_defaultConfig.apiKey) : null };
1705
- res.writeHead(200, { 'Content-Type': 'application/json' });
1706
- res.end(JSON.stringify(masked));
1707
- } catch {
1708
- res.writeHead(200, { 'Content-Type': 'application/json' });
1709
- res.end(JSON.stringify(_defaultProxyProfiles));
1710
- }
1711
- return;
1712
- }
1713
-
1714
- if (url === '/api/proxy-profiles' && method === 'POST') {
1715
- let body = '';
1716
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
1717
- req.on('end', () => {
1718
- try {
1719
- const incoming = JSON.parse(body);
1720
- if (!incoming || typeof incoming !== 'object' || !Array.isArray(incoming.profiles)) {
1721
- res.writeHead(400, { 'Content-Type': 'application/json' });
1722
- res.end(JSON.stringify({ error: 'Invalid profile data: profiles must be an array' }));
1723
- return;
1724
- }
1725
- // 确保 max profile 始终存在
1726
- if (!incoming.profiles.some(p => p.id === 'max')) {
1727
- incoming.profiles = [{ id: 'max', name: 'Default' }, ...(incoming.profiles || [])];
1728
- }
1729
- // 如果 apiKey 是 mask 值(未修改),从磁盘读取原始值保留
1730
- let existing = {};
1731
- try { if (existsSync(PROFILE_PATH)) existing = JSON.parse(readFileSync(PROFILE_PATH, 'utf-8')); } catch { }
1732
- const existingMap = {};
1733
- if (existing.profiles) existing.profiles.forEach(p => { if (p.apiKey) existingMap[p.id] = p.apiKey; });
1734
- for (const p of incoming.profiles) {
1735
- if (p.apiKey && _isMasked(p.apiKey) && existingMap[p.id]) {
1736
- p.apiKey = existingMap[p.id];
1737
- }
1738
- }
1739
- // 只写 profiles 列表到 profile.json;active 不再入文件(避免跨进程串台)
1740
- // 保留老数据里的 active 字段不变,以便老版本 ccv 或手动编辑者的回退能力
1741
- const toWrite = { ...existing, profiles: incoming.profiles };
1742
- const dir = dirname(PROFILE_PATH);
1743
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1744
- writeFileSync(PROFILE_PATH, JSON.stringify(toWrite, null, 2), { mode: 0o600 });
1745
- // active 走 workspace 级别存储(当前进程独占)
1746
- if (typeof incoming.active === 'string' && incoming.active) {
1747
- setActiveProfileForWorkspace(incoming.active);
1748
- } else {
1749
- _loadProxyProfile(); // 仅列表变化时也刷新一次以反映删除 / 重命名
1750
- }
1751
- // SSE 广播仅给本进程客户端(sendEventToClients 本就是 per-process;另外 active 不跨进程)
1752
- const effectiveActive = getActiveProfileId();
1753
- const activeProfile = incoming.profiles?.find(p => p.id === effectiveActive) || null;
1754
- const maskedProfile = activeProfile?.apiKey ? { ...activeProfile, apiKey: _maskApiKey(activeProfile.apiKey) } : activeProfile;
1755
- sendEventToClients(clients, 'proxy_profile', { active: effectiveActive, profile: maskedProfile });
1756
- res.writeHead(200, { 'Content-Type': 'application/json' });
1757
- res.end(JSON.stringify({ ok: true }));
1758
- } catch {
1759
- res.writeHead(400, { 'Content-Type': 'application/json' });
1760
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
1761
- }
1762
- });
1763
- return;
1764
- }
1765
-
1766
- // macOS 用户头像和显示名
1767
- if (url === '/api/user-profile' && method === 'GET') {
1768
- const profile = await getUserProfile();
1769
- res.writeHead(200, { 'Content-Type': 'application/json' });
1770
- res.end(JSON.stringify(profile));
1771
- return;
1772
- }
1773
-
1774
- // 文件浏览器 API(CLI 模式下项目目录浏览)
1775
- if (url === '/api/files' && method === 'GET') {
1776
- const reqPath = parsedUrl.searchParams.get('path') || '.';
1777
- // 安全校验:拒绝绝对路径和 .. 路径穿越
1778
- if (reqPath.startsWith('/') || reqPath.includes('..')) {
1779
- res.writeHead(400, { 'Content-Type': 'application/json' });
1780
- res.end(JSON.stringify({ error: 'Invalid path' }));
1781
- return;
1782
- }
1783
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1784
- const targetDir = join(cwd, reqPath);
1785
- try {
1786
- const entries = readdirSync(targetDir, { withFileTypes: true });
1787
- const items = entries
1788
- .filter(e => !IGNORED_PATTERNS.has(e.name))
1789
- .map(e => {
1790
- // Dirent.isDirectory() 不解引用 symlink —— 对指向目录的 symlink 也返回 false。
1791
- // 需要对 symlink 单独 statSync(follow link)才能拿到真实类型,否则前端会把
1792
- // symlink-to-dir 当成文件渲染,不可展开。断链时 fallback 到 file,避免单个
1793
- // 坏链接让整个目录返回 404。
1794
- let type = e.isDirectory() ? 'directory' : 'file';
1795
- if (e.isSymbolicLink()) {
1796
- try { type = statSync(join(targetDir, e.name)).isDirectory() ? 'directory' : 'file'; }
1797
- catch { type = 'file'; }
1798
- }
1799
- return { name: e.name, type };
1800
- })
1801
- .sort((a, b) => {
1802
- if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
1803
- return a.name.localeCompare(b.name);
1804
- });
1805
- // 使用 git check-ignore 批量检测被 .gitignore 忽略的文件
1806
- let gitIgnoredSet = new Set();
1807
- try {
1808
- const names = items.map(i => {
1809
- const rel = reqPath === '.' ? i.name : `${reqPath}/${i.name}`;
1810
- return i.type === 'directory' ? `${rel}/` : rel;
1811
- });
1812
- if (names.length > 0) {
1813
- const result = await execWithStdin('git', ['check-ignore', '--stdin'], names.join('\n'), {
1814
- cwd,
1815
- timeout: 3000,
1816
- });
1817
- result.split('\n').filter(Boolean).forEach(line => {
1818
- const name = line.endsWith('/') ? line.slice(0, -1) : line;
1819
- const baseName = name.includes('/') ? name.split('/').pop() : name;
1820
- gitIgnoredSet.add(baseName);
1821
- });
1822
- }
1823
- } catch { /* git 未安装或非 git 仓库,忽略 */ }
1824
- const result = items.map(i => gitIgnoredSet.has(i.name) ? { ...i, gitIgnored: true } : i);
1825
- res.writeHead(200, { 'Content-Type': 'application/json' });
1826
- res.end(JSON.stringify(result));
1827
- } catch (err) {
1828
- res.writeHead(404, { 'Content-Type': 'application/json' });
1829
- res.end(JSON.stringify({ error: 'Directory not found' }));
1830
- }
1831
- return;
1832
- }
1833
-
1834
- // Skill 动态装卸 —— 列出所有 skill(含来源)
1835
- if (url === '/api/skills' && method === 'GET') {
1836
- try {
1837
- const { listSkills } = await import('./lib/skills-api.js');
1838
- const skills = listSkills({ projectDir: process.env.CCV_PROJECT_DIR || process.cwd() });
1839
- res.writeHead(200, { 'Content-Type': 'application/json' });
1840
- res.end(JSON.stringify({ ok: true, skills }));
1841
- } catch (err) {
1842
- console.error('[api/skills]', err);
1843
- res.writeHead(500, { 'Content-Type': 'application/json' });
1844
- res.end(JSON.stringify({ error: 'internal_error' }));
1845
- }
1846
- return;
1847
- }
1848
-
1849
- // Skill 动态装卸 —— 切换单个 skill(在 skills/ 和 skills-skip/ 之间 move)
1850
- if (url === '/api/skills/toggle' && method === 'POST') {
1851
- let body = '';
1852
- req.on('data', chunk => { body += chunk; if (body.length > 4096) req.destroy(); });
1853
- req.on('end', async () => {
1854
- try {
1855
- const { source, name, enable } = JSON.parse(body);
1856
- const { moveSkill } = await import('./lib/skills-api.js');
1857
- // 不加进程锁:moveSkill 里 existsSync 前置 + renameSync 原子性已能让并发
1858
- // toggle 落到合理分支(一个成功、另一个拿 SOURCE_MISSING 或 DEST_CONFLICT);
1859
- // 前端 toggling:Set 也已防同 tab 连点,两者叠加足够安全
1860
- moveSkill({
1861
- source, name, enable: !!enable,
1862
- projectDir: process.env.CCV_PROJECT_DIR || process.cwd(),
1863
- });
1864
- res.writeHead(200, { 'Content-Type': 'application/json' });
1865
- res.end(JSON.stringify({ ok: true }));
1866
- } catch (err) {
1867
- const statusMap = {
1868
- INVALID_NAME: 400, INVALID_SOURCE: 400, PATH_ESCAPE: 400, SYMLINK: 400,
1869
- SOURCE_MISSING: 404, DEST_CONFLICT: 409,
1870
- };
1871
- const status = statusMap[err?.code] || 500;
1872
- res.writeHead(status, { 'Content-Type': 'application/json' });
1873
- res.end(JSON.stringify({ error: err?.message || 'internal_error', code: err?.code || 'unknown' }));
1874
- }
1875
- });
1876
- return;
1877
- }
1878
-
1879
- // Skill 上传/导入 API —— 接受 .zip 或 SKILL.md(忽略大小写),写入用户级 ~/.claude/skills/{name}/
1880
- // 设计要点:
1881
- // · 100MB 上限,跟 /api/upload 一致;
1882
- // · 扩展名白名单只放 zip / md(忽略大小写);其他类型直接 415;
1883
- // · zip 内必须含 SKILL.md(任意子目录、忽略大小写),找最浅的那个所在目录作为 skill 根;
1884
- // · skill 名优先取 SKILL.md frontmatter 的 name,回落到 zip / md 文件名(去扩展名);
1885
- // · 名字必须通过 validateSkillName,目标目录已存在则 409 拒绝(前端引导用户改 zip 名)。
1886
- if (url === '/api/skills/import' && method === 'POST') {
1887
- const contentType = req.headers['content-type'] || '';
1888
- // boundary 用 [^;]+ 终止避免吞掉后续 Content-Type 参数;长度封顶 200 防止超长串撑爆 buffer 比对。
1889
- const boundaryMatch = contentType.match(/boundary=([^;]+)/);
1890
- if (!boundaryMatch || boundaryMatch[1].length > 200) {
1891
- res.writeHead(400, { 'Content-Type': 'application/json' });
1892
- res.end(JSON.stringify({ error: 'Invalid boundary' }));
1893
- return;
1894
- }
1895
- const MAX_UPLOAD = 100 * 1024 * 1024;
1896
- const contentLength = parseInt(req.headers['content-length'] || '0', 10);
1897
- if (contentLength > MAX_UPLOAD) {
1898
- res.writeHead(413, { 'Content-Type': 'application/json' });
1899
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
1900
- return;
1901
- }
1902
- const boundary = boundaryMatch[1].trim().replace(/^["']|["']$/g, '');
1903
- const chunks = [];
1904
- let totalSize = 0;
1905
- let aborted = false;
1906
- req.on('data', chunk => {
1907
- totalSize += chunk.length;
1908
- if (totalSize > MAX_UPLOAD) {
1909
- aborted = true;
1910
- res.writeHead(413, { 'Content-Type': 'application/json' });
1911
- res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
1912
- req.destroy();
1913
- return;
1914
- }
1915
- chunks.push(chunk);
1916
- });
1917
- req.on('end', async () => {
1918
- if (aborted) return;
1919
- try {
1920
- const buf = Buffer.concat(chunks);
1921
- const headerEnd = buf.indexOf('\r\n\r\n');
1922
- if (headerEnd === -1) throw Object.assign(new Error('Malformed multipart'), { status: 400 });
1923
- const headerStr = buf.slice(0, headerEnd).toString();
1924
- const nameMatch = headerStr.match(/filename="([^"]+)"/);
1925
- if (!nameMatch) throw Object.assign(new Error('No filename'), { status: 400 });
1926
- // NFKC 规范化 + 控制字符/路径分隔符过滤 + 零宽和方向覆盖字符过滤(防止 homoglyph / RLO 混淆)
1927
- const originalName = nameMatch[1]
1928
- .normalize('NFKC')
1929
- .replace(/[\x00-\x1f/\\]/g, '_')
1930
- .replace(/[​-‏‪-‮⁠]/g, '');
1931
- // Windows 保留设备名守卫(见 WINDOWS_RESERVED_NAMES 注释)。
1932
- {
1933
- const base = originalName.split('.')[0].trim().toLowerCase();
1934
- if (WINDOWS_RESERVED_NAMES.test(base)) {
1935
- throw Object.assign(new Error('Reserved filename not allowed'), { status: 400 });
1936
- }
1937
- }
1938
- const lower = originalName.toLowerCase();
1939
- const isZip = lower.endsWith('.zip');
1940
- const isMd = lower.endsWith('.md');
1941
- if (!isZip && !isMd) {
1942
- throw Object.assign(new Error('Unsupported file type'), { status: 415, code: 'INVALID_TYPE' });
1943
- }
1944
- const bodyStart = headerEnd + 4;
1945
- const closingBoundary = Buffer.from('\r\n--' + boundary);
1946
- const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
1947
- const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
1948
-
1949
- const { validateSkillName } = await import('./lib/skills-api.js');
1950
- const skillsRoot = join(getClaudeConfigDir(), 'skills');
1951
- mkdirSync(skillsRoot, { recursive: true });
1952
-
1953
- // 简单 frontmatter name 解析:抓 `name: xxx` 单行(与 skills-api.js 的 description 解析风格保持一致,最小实现)
1954
- const parseNameFromMd = (text) => {
1955
- const m = /^---\s*\n([\s\S]*?)\n---/.exec(text);
1956
- if (!m) return null;
1957
- const nm = /^name\s*:\s*(.*)$/m.exec(m[1]);
1958
- if (!nm) return null;
1959
- return nm[1].trim().replace(/^["']|["']$/g, '');
1960
- };
1961
-
1962
- const fallbackBaseName = (filename, stripExt) => {
1963
- let n = filename.replace(/^.*[\\/]/, '');
1964
- if (stripExt) n = n.replace(/\.[^.]+$/, '');
1965
- return n;
1966
- };
1967
-
1968
- let skillName = null;
1969
- let skillFiles = []; // { relPath, data }
1970
-
1971
- if (isMd) {
1972
- const text = fileData.toString('utf8');
1973
- skillName = parseNameFromMd(text) || fallbackBaseName(originalName, true);
1974
- skillFiles = [{ relPath: 'SKILL.md', data: fileData }];
1975
- } else {
1976
- // zip:用 adm-zip 解压到内存,校验 SKILL.md 存在(忽略大小写),找最浅的 SKILL.md 所在目录作为 skill 根
1977
- const AdmZip = (await import('adm-zip')).default;
1978
- let zip;
1979
- try {
1980
- zip = new AdmZip(fileData);
1981
- } catch {
1982
- throw Object.assign(new Error('Invalid zip archive'), { status: 400, code: 'INVALID_ZIP' });
1983
- }
1984
- const entries = zip.getEntries();
1985
- // zip bomb 防护:单文件 ≤50MB,总解压 ≤200MB;以及拒绝 symlink entry
1986
- // adm-zip 不直接暴露 isSymbolicLink,需用 attr 高 16 位的 unix mode 判断 0o120000
1987
- const MAX_PER_FILE = 50 * 1024 * 1024;
1988
- const MAX_TOTAL_UNCOMPRESSED = 200 * 1024 * 1024;
1989
- let totalUncompressed = 0;
1990
- for (const e of entries) {
1991
- if (e.isDirectory) continue;
1992
- const unixMode = (e.attr >>> 16) & 0xffff;
1993
- if ((unixMode & 0o170000) === 0o120000) {
1994
- throw Object.assign(new Error('Symlinks not allowed in zip'), { status: 400, code: 'INVALID_ZIP' });
1995
- }
1996
- const sizeRaw = e.header?.size || 0;
1997
- if (sizeRaw > MAX_PER_FILE) {
1998
- throw Object.assign(new Error('File too large in archive'), { status: 400, code: 'ZIP_BOMB' });
1999
- }
2000
- totalUncompressed += sizeRaw;
2001
- if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED) {
2002
- throw Object.assign(new Error('Archive expands too large'), { status: 400, code: 'ZIP_BOMB' });
2003
- }
2004
- }
2005
- // 找所有 SKILL.md(忽略大小写)的 entry,挑最浅的
2006
- let bestSkillEntry = null;
2007
- let bestDepth = Infinity;
2008
- for (const e of entries) {
2009
- if (e.isDirectory) continue;
2010
- const en = e.entryName;
2011
- const base = en.split('/').pop() || '';
2012
- if (base.toLowerCase() === 'skill.md') {
2013
- const depth = en.split('/').length;
2014
- if (depth < bestDepth) { bestDepth = depth; bestSkillEntry = e; }
2015
- }
2016
- }
2017
- if (!bestSkillEntry) {
2018
- throw Object.assign(new Error('SKILL.md not found in zip'), { status: 400, code: 'MISSING_SKILL_MD' });
2019
- }
2020
- // skill 根 = SKILL.md 所在目录(如果在 zip 根则空字符串)
2021
- const lastSlash = bestSkillEntry.entryName.lastIndexOf('/');
2022
- const skillRootPrefix = lastSlash >= 0 ? bestSkillEntry.entryName.slice(0, lastSlash + 1) : '';
2023
- const skillMdText = bestSkillEntry.getData().toString('utf8');
2024
- skillName = parseNameFromMd(skillMdText)
2025
- || (skillRootPrefix ? skillRootPrefix.replace(/\/$/, '').split('/').pop() : null)
2026
- || fallbackBaseName(originalName, true);
2027
-
2028
- // 收集 skill 根下的所有文件,规范化 relPath(去掉 root prefix)
2029
- // 二次校验:header.size 来自 zip 中央目录是攻击者可控的(可谎报 size=0),
2030
- // 上面 zip bomb 检查已用 header.size 做"廉价初检"快速拒绝;这里 getData() 后用真实 data.length 复核,
2031
- // 防止恶意 zip 在 header 上撒谎绕过总大小限制。任一阶段超额都抛 ZIP_BOMB。
2032
- let actualTotal = 0;
2033
- for (const e of entries) {
2034
- if (e.isDirectory) continue;
2035
- if (skillRootPrefix && !e.entryName.startsWith(skillRootPrefix)) continue;
2036
- const rel = skillRootPrefix ? e.entryName.slice(skillRootPrefix.length) : e.entryName;
2037
- if (!rel || rel.includes('..')) continue;
2038
- const data = e.getData();
2039
- if (data.length > MAX_PER_FILE) {
2040
- throw Object.assign(new Error('File actual size too large'), { status: 400, code: 'ZIP_BOMB' });
2041
- }
2042
- actualTotal += data.length;
2043
- if (actualTotal > MAX_TOTAL_UNCOMPRESSED) {
2044
- throw Object.assign(new Error('Archive actual size too large'), { status: 400, code: 'ZIP_BOMB' });
2045
- }
2046
- // SKILL.md 统一规范化大小写
2047
- const finalRel = rel.split('/').pop().toLowerCase() === 'skill.md'
2048
- ? rel.replace(/[^/]*$/, 'SKILL.md')
2049
- : rel;
2050
- skillFiles.push({ relPath: finalRel, data });
2051
- }
2052
- }
2053
-
2054
- if (!validateSkillName(skillName)) {
2055
- throw Object.assign(new Error(`Invalid skill name: ${skillName}`), { status: 400, code: 'INVALID_NAME' });
2056
- }
2057
- const targetDir = join(skillsRoot, skillName);
2058
- // 原子创建:不带 recursive 让 mkdir 在已存在时直接抛 EEXIST,消除 existsSync 与 mkdirSync 之间的 TOCTOU 竞争窗口。
2059
- // skillsRoot 已在前面 mkdirSync(skillsRoot, { recursive: true }) 兜底创建,所以这里 join 出的 parent 一定存在。
2060
- try {
2061
- mkdirSync(targetDir);
2062
- } catch (err) {
2063
- if (err.code === 'EEXIST') {
2064
- throw Object.assign(new Error(`Skill already exists: ${skillName}`), { status: 409, code: 'EXISTS' });
2065
- }
2066
- throw err;
2067
- }
2068
- // 二次防御:resolved + sep 后缀比较,防止 prefix 攻击
2069
- // (e.g. dest=/skills/my-skill-evil/x 不能以 /skills/my-skill 单独 startsWith 通过)
2070
- const resolvedTarget = resolve(targetDir) + sep;
2071
- for (const f of skillFiles) {
2072
- const dest = join(targetDir, f.relPath);
2073
- if (!resolve(dest).startsWith(resolvedTarget)) continue;
2074
- mkdirSync(dirname(dest), { recursive: true });
2075
- writeFileSync(dest, f.data);
2076
- }
2077
- res.writeHead(200, { 'Content-Type': 'application/json' });
2078
- res.end(JSON.stringify({ ok: true, name: skillName, path: targetDir }));
2079
- } catch (err) {
2080
- const status = err?.status || 500;
2081
- if (status >= 500) console.error('[api/skills/import]', err);
2082
- // 5xx 不向前端泄漏内部 message(可能含路径),只返回脱敏文本 + code 让前端做 i18n
2083
- const safeError = status >= 500 ? 'server_error' : (err?.message || 'error');
2084
- res.writeHead(status, { 'Content-Type': 'application/json' });
2085
- res.end(JSON.stringify({ error: safeError, code: err?.code || 'unknown' }));
2086
- }
2087
- });
2088
- return;
2089
- }
2090
-
2091
- // 文件重命名 API
2092
- if (url === '/api/rename-file' && method === 'POST') {
2093
- let body = '';
2094
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2095
- req.on('end', () => {
2096
- let parsed;
2097
- try { parsed = JSON.parse(body); } catch {
2098
- res.writeHead(400, { 'Content-Type': 'application/json' });
2099
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2100
- return;
2101
- }
2102
- try {
2103
- const { oldPath, newName } = parsed;
2104
- if (!oldPath || !newName) {
2105
- res.writeHead(400, { 'Content-Type': 'application/json' });
2106
- res.end(JSON.stringify({ error: 'Missing oldPath or newName' }));
2107
- return;
2108
- }
2109
- // 安全校验
2110
- if (oldPath.startsWith('/') || oldPath.includes('..') || newName.includes('/') || newName.includes('\\') || newName.includes('..')) {
2111
- res.writeHead(400, { 'Content-Type': 'application/json' });
2112
- res.end(JSON.stringify({ error: 'Invalid path' }));
2113
- return;
2114
- }
2115
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2116
- const oldFullPath = join(cwd, oldPath);
2117
- const parentDir = dirname(oldFullPath);
2118
- const newFullPath = join(parentDir, newName);
2119
- // 检查源文件存在
2120
- if (!existsSync(oldFullPath)) {
2121
- res.writeHead(404, { 'Content-Type': 'application/json' });
2122
- res.end(JSON.stringify({ error: 'File not found' }));
2123
- return;
2124
- }
2125
- // 检查目标是否已存在
2126
- if (existsSync(newFullPath)) {
2127
- res.writeHead(409, { 'Content-Type': 'application/json' });
2128
- res.end(JSON.stringify({ error: 'Target already exists' }));
2129
- return;
2130
- }
2131
- renameSync(oldFullPath, newFullPath);
2132
- const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
2133
- res.writeHead(200, { 'Content-Type': 'application/json' });
2134
- res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
2135
- } catch (err) {
2136
- res.writeHead(500, { 'Content-Type': 'application/json' });
2137
- res.end(JSON.stringify({ error: err.message }));
2138
- }
2139
- });
2140
- return;
2141
- }
2142
-
2143
- // 文件移动 API
2144
- if (url === '/api/move-file' && method === 'POST') {
2145
- let body = '';
2146
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2147
- req.on('end', () => {
2148
- let parsed;
2149
- try { parsed = JSON.parse(body); } catch {
2150
- res.writeHead(400, { 'Content-Type': 'application/json' });
2151
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2152
- return;
2153
- }
2154
- try {
2155
- const { fromPath, toDir } = parsed;
2156
- if (!fromPath || !toDir) {
2157
- res.writeHead(400, { 'Content-Type': 'application/json' });
2158
- res.end(JSON.stringify({ error: 'Missing fromPath or toDir' }));
2159
- return;
2160
- }
2161
- // 安全校验
2162
- if (fromPath.startsWith('/') || fromPath.includes('..') || toDir.startsWith('/') || toDir.includes('..')) {
2163
- res.writeHead(400, { 'Content-Type': 'application/json' });
2164
- res.end(JSON.stringify({ error: 'Invalid path' }));
2165
- return;
2166
- }
2167
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2168
- const oldFullPath = join(cwd, fromPath);
2169
- const toDirFull = join(cwd, toDir);
2170
- // 检查源文件/目录存在
2171
- if (!existsSync(oldFullPath)) {
2172
- res.writeHead(404, { 'Content-Type': 'application/json' });
2173
- res.end(JSON.stringify({ error: 'Source not found' }));
2174
- return;
2175
- }
2176
- // 检查目标目录存在且是目录
2177
- if (!existsSync(toDirFull) || !statSync(toDirFull).isDirectory()) {
2178
- res.writeHead(400, { 'Content-Type': 'application/json' });
2179
- res.end(JSON.stringify({ error: 'Target directory not found' }));
2180
- return;
2181
- }
2182
- // 不能把目录移到自身或其子目录下
2183
- if (statSync(oldFullPath).isDirectory()) {
2184
- const srcResolved = resolve(oldFullPath);
2185
- const destResolved = resolve(toDirFull);
2186
- if (destResolved === srcResolved || destResolved.startsWith(srcResolved + sep)) {
2187
- res.writeHead(400, { 'Content-Type': 'application/json' });
2188
- res.end(JSON.stringify({ error: 'Cannot move directory into itself' }));
2189
- return;
2190
- }
2191
- }
2192
- const name = basename(fromPath);
2193
- const newFullPath = join(toDirFull, name);
2194
- // 检查目标位置不存在同名文件
2195
- if (existsSync(newFullPath)) {
2196
- res.writeHead(409, { 'Content-Type': 'application/json' });
2197
- res.end(JSON.stringify({ error: 'Target already exists' }));
2198
- return;
2199
- }
2200
- try {
2201
- renameSync(oldFullPath, newFullPath);
2202
- } catch (mvErr) {
2203
- if (mvErr.code === 'EXDEV') {
2204
- // 跨文件系统:fallback to copy + delete。先 lstat 拒 symlink ——避免攻击者 swap 让
2205
- // cpSync 跟随复制 + rmSync 跟随删除(同 /api/delete-file 的 TOCTOU)。
2206
- const oldStat = lstatSync(oldFullPath);
2207
- if (oldStat.isSymbolicLink()) {
2208
- res.writeHead(400, { 'Content-Type': 'application/json' });
2209
- res.end(JSON.stringify({ error: 'Cannot move symbolic links via this endpoint' }));
2210
- return;
2211
- }
2212
- if (oldStat.isDirectory()) {
2213
- // dereference: false 是 Node 默认,但显式写明意图——避免未来默认变更让递归 copy
2214
- // 跟随内嵌 symlink 复制到 newFullPath 形成 cwd 内"指向 cwd 外"的活链。
2215
- cpSync(oldFullPath, newFullPath, { recursive: true, dereference: false });
2216
- rmSync(oldFullPath, { recursive: true, force: true });
2217
- } else {
2218
- copyFileSync(oldFullPath, newFullPath);
2219
- unlinkSync(oldFullPath);
2220
- }
2221
- } else if (mvErr.code === 'EEXIST') {
2222
- res.writeHead(409, { 'Content-Type': 'application/json' });
2223
- res.end(JSON.stringify({ error: 'Target already exists' }));
2224
- return;
2225
- } else {
2226
- throw mvErr;
2227
- }
2228
- }
2229
- // 返回前端 JSON 统一 POSIX 风格,path.join 在 Win 上会产 backslash,需 normalize 回 '/'。
2230
- const newRelPath = join(toDir, name).replace(/\\/g, '/');
2231
- res.writeHead(200, { 'Content-Type': 'application/json' });
2232
- res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
2233
- } catch (err) {
2234
- console.error('move-file error:', err);
2235
- res.writeHead(500, { 'Content-Type': 'application/json' });
2236
- res.end(JSON.stringify({ error: 'Internal server error' }));
2237
- }
2238
- });
2239
- return;
2240
- }
2241
-
2242
- // 删除文件 API
2243
- if (url === '/api/delete-file' && method === 'POST') {
2244
- let body = '';
2245
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2246
- req.on('end', () => {
2247
- let parsed;
2248
- try { parsed = JSON.parse(body); } catch {
2249
- res.writeHead(400, { 'Content-Type': 'application/json' });
2250
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2251
- return;
2252
- }
2253
- try {
2254
- const { path: filePath } = parsed;
2255
- if (!filePath) {
2256
- res.writeHead(400, { 'Content-Type': 'application/json' });
2257
- res.end(JSON.stringify({ error: 'Missing path' }));
2258
- return;
2259
- }
2260
- if (filePath.startsWith('/') || filePath.includes('..')) {
2261
- res.writeHead(400, { 'Content-Type': 'application/json' });
2262
- res.end(JSON.stringify({ error: 'Invalid path' }));
2263
- return;
2264
- }
2265
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2266
- const fullPath = join(cwd, filePath);
2267
- if (!existsSync(fullPath)) {
2268
- res.writeHead(404, { 'Content-Type': 'application/json' });
2269
- res.end(JSON.stringify({ error: 'File not found' }));
2270
- return;
2271
- }
2272
- const realFull = realpathSync(fullPath);
2273
- const realCwd = realpathSync(cwd);
2274
- if (!realFull.startsWith(realCwd + sep)) {
2275
- res.writeHead(400, { 'Content-Type': 'application/json' });
2276
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2277
- return;
2278
- }
2279
- // 用 lstatSync 不跟随 symlink ——避免 TOCTOU 攻击者把目标换成软链让 rmSync(recursive)
2280
- // 在 POSIX 上跟着删 cwd 外的目录。一切 symlink 目标都拒绝。
2281
- const stat = lstatSync(fullPath);
2282
- if (stat.isSymbolicLink()) {
2283
- res.writeHead(400, { 'Content-Type': 'application/json' });
2284
- res.end(JSON.stringify({ error: 'Cannot delete symbolic links via this endpoint' }));
2285
- return;
2286
- }
2287
- if (stat.isDirectory()) {
2288
- // protectedDirs 守卫得对 Win backslash 路径 & NTFS case-insensitive 同时设防 ——
2289
- // 否则 `path: "node_modules\\foo"` 或 `".GIT"` 都能绕过 split('/') 直接删整目录。
2290
- const protectedDirs = new Set(['node_modules', '.git', '.svn', '.hg']);
2291
- const normalizedSegs = filePath.split(/[\\/]/).map(s => s.toLowerCase());
2292
- if (normalizedSegs.some(part => protectedDirs.has(part))) {
2293
- res.writeHead(400, { 'Content-Type': 'application/json' });
2294
- res.end(JSON.stringify({ error: 'Cannot delete protected directory' }));
2295
- return;
2296
- }
2297
- // Defense-in-depth:lstat 跟 rmSync 间窗内攻击者再次 swap → 再 realpath 确认。
2298
- const realFull2 = realpathSync(fullPath);
2299
- if (!realFull2.startsWith(realCwd + sep)) {
2300
- res.writeHead(400, { 'Content-Type': 'application/json' });
2301
- res.end(JSON.stringify({ error: 'Path escaped cwd after validation' }));
2302
- return;
2303
- }
2304
- rmSync(fullPath, { recursive: true, force: true });
2305
- } else if (stat.isFile()) {
2306
- unlinkSync(fullPath);
2307
- } else {
2308
- res.writeHead(400, { 'Content-Type': 'application/json' });
2309
- res.end(JSON.stringify({ error: 'Unsupported path type' }));
2310
- return;
2311
- }
2312
- res.writeHead(200, { 'Content-Type': 'application/json' });
2313
- res.end(JSON.stringify({ ok: true }));
2314
- } catch (err) {
2315
- res.writeHead(500, { 'Content-Type': 'application/json' });
2316
- res.end(JSON.stringify({ error: err.message }));
2317
- }
2318
- });
2319
- return;
2320
- }
2321
-
2322
- // 在系统文件管理器中显示文件
2323
- if (url === '/api/reveal-file' && method === 'POST') {
2324
- let body = '';
2325
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2326
- req.on('end', () => {
2327
- let parsed;
2328
- try { parsed = JSON.parse(body); } catch {
2329
- res.writeHead(400, { 'Content-Type': 'application/json' });
2330
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2331
- return;
2332
- }
2333
- try {
2334
- const { path: filePath } = parsed;
2335
- if (!filePath) {
2336
- res.writeHead(400, { 'Content-Type': 'application/json' });
2337
- res.end(JSON.stringify({ error: 'Missing path' }));
2338
- return;
2339
- }
2340
- if (filePath.startsWith('/') || filePath.includes('..')) {
2341
- res.writeHead(400, { 'Content-Type': 'application/json' });
2342
- res.end(JSON.stringify({ error: 'Invalid path' }));
2343
- return;
2344
- }
2345
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2346
- const fullPath = join(cwd, filePath);
2347
- if (!existsSync(fullPath)) {
2348
- res.writeHead(404, { 'Content-Type': 'application/json' });
2349
- res.end(JSON.stringify({ error: 'File not found' }));
2350
- return;
2351
- }
2352
- const realFull = realpathSync(fullPath);
2353
- const realCwd = realpathSync(cwd);
2354
- if (!realFull.startsWith(realCwd + sep)) {
2355
- res.writeHead(400, { 'Content-Type': 'application/json' });
2356
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2357
- return;
2358
- }
2359
- const plat = process.platform;
2360
- if (plat === 'darwin') {
2361
- execFile('open', ['-R', fullPath], () => {});
2362
- } else if (plat === 'win32') {
2363
- // explorer /select,<path> 必须合到一个 arg;分两个会让含空格/中文路径 escape 失败。
2364
- spawn('explorer.exe', [`/select,${fullPath}`], { shell: false, windowsHide: true });
2365
- } else {
2366
- execFile('xdg-open', [dirname(fullPath)], () => {});
2367
- }
2368
- res.writeHead(200, { 'Content-Type': 'application/json' });
2369
- res.end(JSON.stringify({ ok: true, fullPath }));
2370
- } catch (err) {
2371
- res.writeHead(500, { 'Content-Type': 'application/json' });
2372
- res.end(JSON.stringify({ error: err.message }));
2373
- }
2374
- });
2375
- return;
2376
- }
2377
-
2378
- // 用系统默认应用打开文件
2379
- if (url === '/api/open-file' && method === 'POST') {
2380
- let body = '';
2381
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2382
- req.on('end', () => {
2383
- let parsed;
2384
- try { parsed = JSON.parse(body); } catch {
2385
- res.writeHead(400, { 'Content-Type': 'application/json' });
2386
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2387
- return;
2388
- }
2389
- try {
2390
- const { path: filePath } = parsed;
2391
- if (!filePath) {
2392
- res.writeHead(400, { 'Content-Type': 'application/json' });
2393
- res.end(JSON.stringify({ error: 'Missing path' }));
2394
- return;
2395
- }
2396
- if (filePath.startsWith('/') || filePath.includes('..')) {
2397
- res.writeHead(400, { 'Content-Type': 'application/json' });
2398
- res.end(JSON.stringify({ error: 'Invalid path' }));
2399
- return;
2400
- }
2401
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2402
- const fullPath = join(cwd, filePath);
2403
- if (!existsSync(fullPath)) {
2404
- res.writeHead(404, { 'Content-Type': 'application/json' });
2405
- res.end(JSON.stringify({ error: 'File not found' }));
2406
- return;
2407
- }
2408
- const realFull = realpathSync(fullPath);
2409
- const realCwd = realpathSync(cwd);
2410
- if (!realFull.startsWith(realCwd + sep)) {
2411
- res.writeHead(400, { 'Content-Type': 'application/json' });
2412
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2413
- return;
2414
- }
2415
- const plat = process.platform;
2416
- if (plat === 'darwin') {
2417
- execFile('open', [fullPath], () => {});
2418
- } else if (plat === 'win32') {
2419
- execFile('cmd.exe', ['/c', 'start', '', fullPath], () => {});
2420
- } else {
2421
- execFile('xdg-open', [fullPath], () => {});
2422
- }
2423
- res.writeHead(200, { 'Content-Type': 'application/json' });
2424
- res.end(JSON.stringify({ ok: true }));
2425
- } catch (err) {
2426
- res.writeHead(500, { 'Content-Type': 'application/json' });
2427
- res.end(JSON.stringify({ error: err.message }));
2428
- }
2429
- });
2430
- return;
2431
- }
2432
-
2433
- // 解析相对路径为绝对路径(不触发任何副作用)
2434
- if (url === '/api/resolve-path' && method === 'POST') {
2435
- let body = '';
2436
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2437
- req.on('end', () => {
2438
- let parsed;
2439
- try { parsed = JSON.parse(body); } catch {
2440
- res.writeHead(400, { 'Content-Type': 'application/json' });
2441
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2442
- return;
2443
- }
2444
- try {
2445
- const relPath = parsed.path || '';
2446
- if (relPath.startsWith('/') || relPath.includes('..')) {
2447
- res.writeHead(400, { 'Content-Type': 'application/json' });
2448
- res.end(JSON.stringify({ error: 'Invalid path' }));
2449
- return;
2450
- }
2451
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2452
- const fullPath = relPath ? join(cwd, relPath) : cwd;
2453
- res.writeHead(200, { 'Content-Type': 'application/json' });
2454
- res.end(JSON.stringify({ ok: true, fullPath }));
2455
- } catch (err) {
2456
- res.writeHead(500, { 'Content-Type': 'application/json' });
2457
- res.end(JSON.stringify({ error: err.message }));
2458
- }
2459
- });
2460
- return;
2461
- }
2462
-
2463
- // 在指定目录下新建空文件
2464
- if (url === '/api/create-file' && method === 'POST') {
2465
- let body = '';
2466
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2467
- req.on('end', () => {
2468
- let parsed;
2469
- try { parsed = JSON.parse(body); } catch {
2470
- res.writeHead(400, { 'Content-Type': 'application/json' });
2471
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2472
- return;
2473
- }
2474
- try {
2475
- const { dirPath, name } = parsed;
2476
- if (!name) {
2477
- res.writeHead(400, { 'Content-Type': 'application/json' });
2478
- res.end(JSON.stringify({ error: 'Missing name' }));
2479
- return;
2480
- }
2481
- if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
2482
- res.writeHead(400, { 'Content-Type': 'application/json' });
2483
- res.end(JSON.stringify({ error: 'Invalid file name' }));
2484
- return;
2485
- }
2486
- const relDir = dirPath || '';
2487
- if (relDir.startsWith('/') || relDir.includes('..')) {
2488
- res.writeHead(400, { 'Content-Type': 'application/json' });
2489
- res.end(JSON.stringify({ error: 'Invalid path' }));
2490
- return;
2491
- }
2492
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2493
- const fullDirPath = relDir ? join(cwd, relDir) : cwd;
2494
- if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
2495
- res.writeHead(400, { 'Content-Type': 'application/json' });
2496
- res.end(JSON.stringify({ error: 'Directory not found' }));
2497
- return;
2498
- }
2499
- const realDir = realpathSync(fullDirPath);
2500
- const realCwd = realpathSync(cwd);
2501
- if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
2502
- res.writeHead(400, { 'Content-Type': 'application/json' });
2503
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2504
- return;
2505
- }
2506
- const fullPath = join(fullDirPath, name);
2507
- if (existsSync(fullPath)) {
2508
- res.writeHead(409, { 'Content-Type': 'application/json' });
2509
- res.end(JSON.stringify({ error: 'File already exists' }));
2510
- return;
2511
- }
2512
- writeFileSync(fullPath, '');
2513
- const relPath = relDir ? `${relDir}/${name}` : name;
2514
- res.writeHead(200, { 'Content-Type': 'application/json' });
2515
- res.end(JSON.stringify({ ok: true, path: relPath }));
2516
- } catch (err) {
2517
- res.writeHead(500, { 'Content-Type': 'application/json' });
2518
- res.end(JSON.stringify({ error: err.message }));
2519
- }
2520
- });
2521
- return;
2522
- }
2523
-
2524
- // 在指定目录下打开系统终端
2525
- if (url === '/api/open-terminal' && method === 'POST') {
2526
- let body = '';
2527
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2528
- req.on('end', () => {
2529
- let parsed;
2530
- try { parsed = JSON.parse(body); } catch {
2531
- res.writeHead(400, { 'Content-Type': 'application/json' });
2532
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2533
- return;
2534
- }
2535
- try {
2536
- const relDir = (parsed.path || '');
2537
- if (relDir.startsWith('/') || relDir.includes('..')) {
2538
- res.writeHead(400, { 'Content-Type': 'application/json' });
2539
- res.end(JSON.stringify({ error: 'Invalid path' }));
2540
- return;
2541
- }
2542
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2543
- const fullDir = relDir ? join(cwd, relDir) : cwd;
2544
- if (!existsSync(fullDir) || !statSync(fullDir).isDirectory()) {
2545
- res.writeHead(400, { 'Content-Type': 'application/json' });
2546
- res.end(JSON.stringify({ error: 'Directory not found' }));
2547
- return;
2548
- }
2549
- const realDir = realpathSync(fullDir);
2550
- const realCwd = realpathSync(cwd);
2551
- if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
2552
- res.writeHead(400, { 'Content-Type': 'application/json' });
2553
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2554
- return;
2555
- }
2556
- const plat = process.platform;
2557
- if (plat === 'darwin') {
2558
- spawn('open', ['-a', 'Terminal', fullDir], { stdio: 'ignore', detached: true }).unref();
2559
- } else if (plat === 'win32') {
2560
- spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true, windowsHide: true }).unref();
2561
- } else {
2562
- // Linux: try common terminal emulators
2563
- const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
2564
- let launched = false;
2565
- for (const term of terminals) {
2566
- try {
2567
- if (term === 'gnome-terminal') {
2568
- spawn(term, ['--working-directory=' + fullDir], { stdio: 'ignore', detached: true }).unref();
2569
- } else if (term === 'konsole') {
2570
- spawn(term, ['--workdir', fullDir], { stdio: 'ignore', detached: true }).unref();
2571
- } else {
2572
- spawn(term, [], { cwd: fullDir, stdio: 'ignore', detached: true }).unref();
2573
- }
2574
- launched = true;
2575
- break;
2576
- } catch { continue; }
2577
- }
2578
- if (!launched) {
2579
- spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true }).unref();
2580
- }
2581
- }
2582
- res.writeHead(200, { 'Content-Type': 'application/json' });
2583
- res.end(JSON.stringify({ ok: true }));
2584
- } catch (err) {
2585
- res.writeHead(500, { 'Content-Type': 'application/json' });
2586
- res.end(JSON.stringify({ error: err.message }));
2587
- }
2588
- });
2589
- return;
2590
- }
2591
-
2592
- // 在指定目录下新建空文件夹
2593
- if (url === '/api/create-dir' && method === 'POST') {
2594
- let body = '';
2595
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2596
- req.on('end', () => {
2597
- let parsed;
2598
- try { parsed = JSON.parse(body); } catch {
2599
- res.writeHead(400, { 'Content-Type': 'application/json' });
2600
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2601
- return;
2602
- }
2603
- try {
2604
- const { dirPath, name } = parsed;
2605
- if (!name) {
2606
- res.writeHead(400, { 'Content-Type': 'application/json' });
2607
- res.end(JSON.stringify({ error: 'Missing name' }));
2608
- return;
2609
- }
2610
- if (name.includes('/') || name.includes('\\') || name.includes('..') || /[\x00-\x1f]/.test(name)) {
2611
- res.writeHead(400, { 'Content-Type': 'application/json' });
2612
- res.end(JSON.stringify({ error: 'Invalid folder name' }));
2613
- return;
2614
- }
2615
- const relDir = dirPath || '';
2616
- if (relDir.startsWith('/') || relDir.includes('..')) {
2617
- res.writeHead(400, { 'Content-Type': 'application/json' });
2618
- res.end(JSON.stringify({ error: 'Invalid path' }));
2619
- return;
2620
- }
2621
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
2622
- const fullDirPath = relDir ? join(cwd, relDir) : cwd;
2623
- if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
2624
- res.writeHead(400, { 'Content-Type': 'application/json' });
2625
- res.end(JSON.stringify({ error: 'Directory not found' }));
2626
- return;
2627
- }
2628
- const realDir = realpathSync(fullDirPath);
2629
- const realCwd = realpathSync(cwd);
2630
- if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
2631
- res.writeHead(400, { 'Content-Type': 'application/json' });
2632
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
2633
- return;
2634
- }
2635
- const fullPath = join(fullDirPath, name);
2636
- if (existsSync(fullPath)) {
2637
- res.writeHead(409, { 'Content-Type': 'application/json' });
2638
- res.end(JSON.stringify({ error: 'Already exists' }));
2639
- return;
2640
- }
2641
- mkdirSync(fullPath);
2642
- const relPath = relDir ? `${relDir}/${name}` : name;
2643
- res.writeHead(200, { 'Content-Type': 'application/json' });
2644
- res.end(JSON.stringify({ ok: true, path: relPath }));
2645
- } catch (err) {
2646
- res.writeHead(500, { 'Content-Type': 'application/json' });
2647
- res.end(JSON.stringify({ error: err.message }));
2648
- }
2649
- });
2650
- return;
2651
- }
2652
-
2653
- // === Editor session API (for $EDITOR intercept) ===
2654
-
2655
- if (url === '/api/open-log-dir' && method === 'POST') {
2656
- const dir = LOG_FILE ? dirname(LOG_FILE) : LOG_DIR;
2657
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
2658
- execFile(cmd, [dir], () => {});
2659
- res.writeHead(200, { 'Content-Type': 'application/json' });
2660
- res.end(JSON.stringify({ ok: true, dir }));
2661
- return;
2662
- }
2663
-
2664
- if (url === '/api/open-profile-dir' && method === 'POST') {
2665
- const dir = dirname(PROFILE_PATH);
2666
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2667
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
2668
- execFile(cmd, [dir], () => {});
2669
- res.writeHead(200, { 'Content-Type': 'application/json' });
2670
- res.end(JSON.stringify({ ok: true, dir }));
2671
- return;
2672
- }
2673
-
2674
- if (url === '/api/open-project-dir' && method === 'POST') {
2675
- const dir = process.env.CCV_PROJECT_DIR || process.cwd();
2676
- const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
2677
- execFile(cmd, [dir], () => {});
2678
- res.writeHead(200, { 'Content-Type': 'application/json' });
2679
- res.end(JSON.stringify({ ok: true, dir }));
2680
- return;
2681
- }
2682
-
2683
- if (url === '/api/editor-open' && method === 'POST') {
2684
- let body = '';
2685
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2686
- req.on('end', () => {
2687
- try {
2688
- const { sessionId, filePath } = JSON.parse(body);
2689
- if (!sessionId || !filePath) {
2690
- res.writeHead(400, { 'Content-Type': 'application/json' });
2691
- res.end(JSON.stringify({ error: 'Missing sessionId or filePath' }));
2692
- return;
2693
- }
2694
- editorSessions.set(sessionId, { filePath, done: false, createdAt: Date.now() });
2695
- // Broadcast to all terminal WebSocket clients
2696
- if (terminalWss) {
2697
- const msg = JSON.stringify({ type: 'editor-open', sessionId, filePath });
2698
- terminalWss.clients.forEach(client => {
2699
- if (client.readyState === 1) {
2700
- try { client.send(msg); } catch {}
2701
- }
2702
- });
2703
- }
2704
- res.writeHead(200, { 'Content-Type': 'application/json' });
2705
- res.end(JSON.stringify({ ok: true }));
2706
- } catch {
2707
- res.writeHead(400, { 'Content-Type': 'application/json' });
2708
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2709
- }
2710
- });
2711
- return;
2712
- }
2713
-
2714
- if (url.startsWith('/api/editor-status') && method === 'GET') {
2715
- const id = parsedUrl.searchParams.get('id');
2716
- if (!id) {
2717
- res.writeHead(400, { 'Content-Type': 'application/json' });
2718
- res.end(JSON.stringify({ error: 'Missing id' }));
2719
- return;
2720
- }
2721
- const session = editorSessions.get(id);
2722
- res.writeHead(200, { 'Content-Type': 'application/json' });
2723
- res.end(JSON.stringify({ done: session ? session.done : true }));
2724
- return;
2725
- }
2726
-
2727
- if (url === '/api/editor-done' && method === 'POST') {
2728
- let body = '';
2729
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
2730
- req.on('end', () => {
2731
- try {
2732
- const { sessionId } = JSON.parse(body);
2733
- if (!sessionId) {
2734
- res.writeHead(400, { 'Content-Type': 'application/json' });
2735
- res.end(JSON.stringify({ error: 'Missing sessionId' }));
2736
- return;
2737
- }
2738
- const session = editorSessions.get(sessionId);
2739
- if (session) {
2740
- session.done = true;
2741
- }
2742
- // Clean up after a short delay to allow the polling to pick it up
2743
- setTimeout(() => editorSessions.delete(sessionId), 5000);
2744
- res.writeHead(200, { 'Content-Type': 'application/json' });
2745
- res.end(JSON.stringify({ ok: true }));
2746
- } catch {
2747
- res.writeHead(400, { 'Content-Type': 'application/json' });
2748
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2749
- }
2750
- });
2751
- return;
2752
- }
2753
-
2754
- // Ask 持久化恢复:浏览器 WS reconnect 后用来"补"那些 ws 断开期间到达的 pending ask
2755
- // (server.js 已有 ws reconnect replay 路径,但该路径仅 broadcast 内存 Map 中存活的 entry;
2756
- // server 自身重启过会丢内存态——此端点从磁盘 ask-store 镜像恢复 disk superset 列表)。
2757
- if (url === '/api/pending-asks' && method === 'GET') {
2758
- try {
2759
- // 优先内存 Map(含活跃 res / timer 的真实状态),用 createdAt 排序;
2760
- // 再 union disk(hydrate 已死 entry 或本进程未持有的孤儿 entry,用于恢复 UI 列表,
2761
- // 答案投递仍由 WS ask-hook-answer 路径走 server 端 first-write-wins)。
2762
- const memEntries = [...pendingAskHooks.entries()].map(([id, e]) => ({
2763
- id, questions: e.questions, createdAt: e.createdAt, source: 'memory',
2764
- }));
2765
- const memIds = new Set(memEntries.map(e => e.id));
2766
- const diskAll = loadAskStore();
2767
- // 只暴露 status === 'pending' 的 disk entry —— markAnswered 创建的 questions=[] 终态占位
2768
- // 也会被 loadAskStore 返回,但它不是真"待回答",前端 inject 会渲染空 ghost ask。
2769
- // 另外过滤 questions 数组非空(防御性)。
2770
- const diskOnly = Object.values(diskAll)
2771
- .filter(e => !memIds.has(e.id) && e.status === 'pending' && Array.isArray(e.questions) && e.questions.length > 0)
2772
- .map(e => ({ id: e.id, questions: e.questions, createdAt: e.createdAt, source: 'disk' }));
2773
- const all = [...memEntries, ...diskOnly].sort((a, b) => a.createdAt - b.createdAt);
2774
- res.writeHead(200, { 'Content-Type': 'application/json' });
2775
- res.end(JSON.stringify({ pendingAsks: all, askProtocolVersion: 1 }));
2776
- } catch (err) {
2777
- res.writeHead(500, { 'Content-Type': 'application/json' });
2778
- res.end(JSON.stringify({ error: 'failed to read pending asks', detail: String(err?.message || err) }));
2779
- }
2780
- return;
2781
- }
2782
-
2783
- // Ask hook bridge: long-poll endpoint for PreToolUse AskUserQuestion hook
2784
- if (url === '/api/ask-hook' && method === 'POST') {
2785
- let body = '';
2786
- let bodyTooLarge = false;
2787
- req.on('data', (chunk) => {
2788
- body += chunk;
2789
- if (body.length > 1000000) { // 1MB limit (questions may contain large previews)
2790
- bodyTooLarge = true;
2791
- req.destroy();
2792
- }
2793
- });
2794
- // Phase 3: ask-bridge 用 `X-Ask-Poll-Mode: short` 头声明走短轮询。新 server 看到后
2795
- // 立即返 { id, capability: 'short-poll' }(不挂 res),client 之后 GET /api/ask-hook/:id/result 取答案。
2796
- // 老 server 不识别此头 → 按长轮询走(仍挂 res 直到答案/24h 超时)→ 完全向后兼容。
2797
- const shortPollMode = (req.headers['x-ask-poll-mode'] || '').toLowerCase() === 'short';
2798
- req.on('end', async () => {
2799
- if (bodyTooLarge) {
2800
- try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
2801
- return;
2802
- }
2803
- try {
2804
- const { questions, toolUseId } = JSON.parse(body);
2805
- if (!Array.isArray(questions) || questions.length === 0) {
2806
- res.writeHead(400, { 'Content-Type': 'application/json' });
2807
- res.end(JSON.stringify({ error: 'Missing questions' }));
2808
- return;
2809
- }
2810
- // 镜像 ask-bridge 的 normalize:覆盖 plugin/SDK 等非 ask-bridge 的 client。
2811
- // 缺失 description 补 "",前端按 hasOptionDescription 判断走非渲染分支。
2812
- for (const q of questions) {
2813
- if (!q || typeof q !== 'object' || !Array.isArray(q.options)) continue;
2814
- for (const opt of q.options) {
2815
- if (opt && typeof opt === 'object' && opt.description === undefined) {
2816
- opt.description = '';
2817
- }
2818
- }
2819
- }
2820
-
2821
- // Evict oldest if Map is full (prevent memory leak from pathological concurrency).
2822
- // Mirrors perm-hook eviction; an evicted bridge process gets HTTP 429 and falls back
2823
- // to its own terminal UI rather than leaking long-poll state.
2824
- if (pendingAskHooks.size >= ASK_HOOK_MAP_MAX) {
2825
- const oldestId = pendingAskHooks.keys().next().value;
2826
- const oldest = pendingAskHooks.get(oldestId);
2827
- if (oldest) {
2828
- clearTimeout(oldest.timer);
2829
- try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
2830
- pendingAskHooks.delete(oldestId);
2831
- _persistAskDelete(oldestId);
2832
- if (terminalWss) {
2833
- const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id: oldestId });
2834
- terminalWss.clients.forEach((c) => {
2835
- if (c.readyState === 1) try { c.send(tmsg); } catch {}
2836
- });
2837
- }
2838
- _notifyParentPending({ type: 'ask-hook-timeout', id: oldestId });
2839
- }
2840
- }
2841
-
2842
- // 实质等同"无超时",仍保留兜底防 entry 泄漏(plugin throw / OOM 等异常路径)。
2843
- // ask-bridge 不设客户端 timeout,由 server 单向控制 response 终止。
2844
- const HOOK_TIMEOUT = ASK_HOOK_TIMEOUT_MS;
2845
- // toolUseId 路由策略:
2846
- // - char whitelist + ≤256 长度 防恶意 1MB key 撑大 Map
2847
- // - 已存在同 id 但旧 res 已断(writableEnded/destroyed)→ 复用槽位(ask-bridge 重试场景)
2848
- // - 已存在同 id 且旧 res 活 → 返 409 Conflict(真重复)而非走 fallback id 让前端 portal 失效
2849
- // - 缺 toolUseId 或非法 → fallback 自生成 id
2850
- const wantsToolUseId = toolUseId && typeof toolUseId === 'string'
2851
- && toolUseId.length > 0 && toolUseId.length <= 256
2852
- && /^[a-zA-Z0-9_-]+$/.test(toolUseId);
2853
- let id;
2854
- if (wantsToolUseId) {
2855
- const existing = pendingAskHooks.get(toolUseId);
2856
- if (!existing) {
2857
- id = toolUseId;
2858
- } else if (!existing.res || existing.res.writableEnded || existing.res.destroyed) {
2859
- // 旧 res 已断(包含占位 res:null 残留)— ask-bridge 重试同 toolUseId 合理,复用槽位
2860
- if (existing.timer) clearTimeout(existing.timer);
2861
- pendingAskHooks.delete(toolUseId);
2862
- _persistAskDelete(toolUseId);
2863
- id = toolUseId;
2864
- } else {
2865
- res.writeHead(409, { 'Content-Type': 'application/json' });
2866
- res.end(JSON.stringify({ error: 'Duplicate toolUseId, previous request still pending' }));
2867
- return;
2868
- }
2869
- } else {
2870
- // Fallback id:当 hook caller 没传 toolUseId(如老 Claude Code PreToolUse hook
2871
- // payload 不含 tool_use_id),生成 ask_${ts}_${rnd} 占位。这个 id 与 jsonl 里
2872
- // tool_use.id(toolu_xxx)不同名,前端 portal 决策必须按 ask_* 前缀通配命中。
2873
- // 协议锚点:src/utils/askPortalMatcher.js — 改此处前缀格式必须同步改 matcher。
2874
- do { id = `ask_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } while (pendingAskHooks.has(id));
2875
- }
2876
-
2877
- // TOCTOU 防御:占位 id 之前先注册 res.on('close'),否则 await runWaterfallHook 期间 client
2878
- // abort 的 close 事件落空 → entry 残 5min。占位 set 提前到 await 之前防两条同 ms 并发
2879
- // POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 5min)。
2880
- const _placeholderEntry = { questions, res, timer: null, createdAt: Date.now(), shortPoll: shortPollMode };
2881
- pendingAskHooks.set(id, _placeholderEntry);
2882
- _persistAskEntry(id, _placeholderEntry);
2883
-
2884
- // res.on('close') 提前注册:handler 用 entry.timer 守卫(占位期 timer:null → 仅 delete Map)
2885
- // Phase 3: short-poll 模式下 res 主动 end,close 不视为 client cancel;entry 由 24h timer 兜底清理。
2886
- res.on('close', () => {
2887
- const entry = pendingAskHooks.get(id);
2888
- if (entry) {
2889
- if (entry.shortPoll) return;
2890
- if (entry.timer) clearTimeout(entry.timer);
2891
- pendingAskHooks.delete(id);
2892
- _persistAskDelete(id);
2893
- if (terminalWss) {
2894
- const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
2895
- terminalWss.clients.forEach((c) => {
2896
- if (c.readyState === 1) try { c.send(tmsg); } catch {}
2897
- });
2898
- }
2899
- _notifyParentPending({ type: 'ask-hook-timeout', id });
2900
- }
2901
- });
2902
-
2903
- // Plugin hook: let plugins answer questions directly
2904
- try {
2905
- const hookResult = await runWaterfallHook('onAskRequest', { id, questions, mode: 'hook' });
2906
- if (hookResult.answers) {
2907
- pendingAskHooks.delete(id); // 释放占位 — plugin 直接答了
2908
- _persistAskDelete(id);
2909
- // race: plugin 执行期间 client 主动断开 → res.on('close') 已清 entry,
2910
- // 但本闭包仍持着 res 引用;guard 防 writeHead 抛 ERR_STREAM_WRITE_AFTER_END
2911
- if (!res.writableEnded && !res.destroyed && !res.headersSent) {
2912
- try {
2913
- res.writeHead(200, { 'Content-Type': 'application/json' });
2914
- res.end(JSON.stringify({ answers: hookResult.answers }));
2915
- } catch {}
2916
- }
2917
- return;
2918
- }
2919
- } catch {}
2920
-
2921
- const timer = setTimeout(() => {
2922
- const entry = pendingAskHooks.get(id);
2923
- if (entry) {
2924
- pendingAskHooks.delete(id);
2925
- _persistAskDelete(id);
2926
- try {
2927
- // null guard:plugin throw + outer body-parse race 极端下占位残留时 fire 防 TypeError
2928
- if (entry.res && !entry.res.headersSent) {
2929
- entry.res.writeHead(408, { 'Content-Type': 'application/json' });
2930
- entry.res.end(JSON.stringify({ error: 'Timeout' }));
2931
- }
2932
- } catch {}
2933
- if (terminalWss) {
2934
- const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
2935
- terminalWss.clients.forEach((c) => {
2936
- if (c.readyState === 1) try { c.send(tmsg); } catch {}
2937
- });
2938
- }
2939
- _notifyParentPending({ type: 'ask-hook-timeout', id });
2940
- }
2941
- }, HOOK_TIMEOUT);
2942
-
2943
- const askStartedAt = Date.now();
2944
- const _liveEntry = { questions, res, timer, createdAt: askStartedAt, shortPoll: shortPollMode };
2945
- pendingAskHooks.set(id, _liveEntry);
2946
- _persistAskEntry(id, _liveEntry);
2947
-
2948
- // Phase 3: short-poll 模式立即返 ack,让 client 改走 GET 轮询;entry 留在内存等 ws answer。
2949
- if (shortPollMode) {
2950
- try {
2951
- if (!res.headersSent) {
2952
- res.writeHead(200, { 'Content-Type': 'application/json' });
2953
- res.end(JSON.stringify({ id, capability: 'short-poll' }));
2954
- }
2955
- } catch {}
2956
- }
2957
-
2958
- // Broadcast to all terminal WS clients — 附 startedAt + timeoutMs 让前端渲染倒计时
2959
- if (terminalWss) {
2960
- const pmsg = JSON.stringify({ type: 'ask-hook-pending', id, questions, startedAt: askStartedAt, timeoutMs: HOOK_TIMEOUT });
2961
- terminalWss.clients.forEach((client) => {
2962
- if (client.readyState === 1) {
2963
- try { client.send(pmsg); } catch {}
2964
- }
2965
- });
2966
- }
2967
- _notifyParentPending({ type: 'ask-hook-pending', id, questions });
2968
- } catch {
2969
- res.writeHead(400, { 'Content-Type': 'application/json' });
2970
- res.end(JSON.stringify({ error: 'Invalid request body' }));
2971
- }
2972
- });
2973
- return;
2974
- }
2975
-
2976
- // Phase 3: short-poll handoff endpoint. ask-bridge GET /api/ask-hook/:id/result?wait=30000
2977
- // 在 wait ms 内若答案/cancel 到达 → 立即返;否则返 204 让 client 重发。
2978
- // 内存有 entry → 注册 listener;内存无 → 查 disk consume(server 重启场景)。
2979
- if (url.startsWith('/api/ask-hook/') && url.includes('/result') && method === 'GET') {
2980
- try {
2981
- // URL 形如 /api/ask-hook/<id>/result?wait=30000;id 受白名单约束(与 POST 同源)
2982
- const m = url.match(/^\/api\/ask-hook\/([^/?]+)\/result(?:\?(.*))?$/);
2983
- if (!m) { res.writeHead(400); res.end(); return; }
2984
- const id = decodeURIComponent(m[1]);
2985
- if (!id || id.length > 256 || !/^[a-zA-Z0-9_-]+$/.test(id)) {
2986
- res.writeHead(400, { 'Content-Type': 'application/json' });
2987
- res.end(JSON.stringify({ error: 'invalid id' }));
2988
- return;
2989
- }
2990
- const qs = new URLSearchParams(m[2] || '');
2991
- const wait = Math.max(1000, Math.min(60000, parseInt(qs.get('wait') || '30000', 10)));
2992
-
2993
- // 1) disk 命中 answered/cancelled:浏览器答得早过 GET 到达 → 立即返并消费(一次性)
2994
- // 用 consumeIfFinal 单次 withLock 内判 status 决定是否 delete —— 旧设计的
2995
- // "consume + 若 pending 再 setEntry 写回" 两段是 race window:中间被 markAnswered 命中后,
2996
- // setEntry 走 status guard 已经不会覆盖;但不删的 pending 也无须重写一遍。
2997
- const diskEntry = askStoreConsumeIfFinal(id);
2998
- if (diskEntry && diskEntry.status === 'answered') {
2999
- res.writeHead(200, { 'Content-Type': 'application/json' });
3000
- res.end(JSON.stringify({ answers: diskEntry.answers || {} }));
3001
- return;
3002
- }
3003
- if (diskEntry && diskEntry.status === 'cancelled') {
3004
- res.writeHead(200, { 'Content-Type': 'application/json' });
3005
- res.end(JSON.stringify({ cancelled: true, reason: diskEntry.cancelReason || '' }));
3006
- return;
3007
- }
3008
- // diskEntry.status === 'pending' → consumeIfFinal 已保留它,无须重写
3009
-
3010
- // 2) entry 仍 pending:注册 listener 等 wait ms。内存或 disk 至少一个有就允许注册——
3011
- // server 重启后 disk 是 pending,但内存无 entry,仍要让 GET 挂等(浏览器答案到达时
3012
- // 会走 ws ask-hook-answer handler 路径,handler 内 markAnswered 落 disk + _notifyShortPollAnswer 直推 listener)。
3013
- const memEntry = pendingAskHooks.get(id);
3014
- if (!memEntry && !diskEntry) {
3015
- res.writeHead(404, { 'Content-Type': 'application/json' });
3016
- res.end(JSON.stringify({ error: 'no such ask' }));
3017
- return;
3018
- }
3019
- const listener = { res, finished: false, tid: null };
3020
- if (!shortPollListeners.has(id)) shortPollListeners.set(id, new Set());
3021
- shortPollListeners.get(id).add(listener);
3022
- listener.tid = setTimeout(() => {
3023
- if (listener.finished) return;
3024
- listener.finished = true;
3025
- shortPollListeners.get(id)?.delete(listener);
3026
- try { if (!res.headersSent) { res.writeHead(204); res.end(); } } catch {}
3027
- }, wait);
3028
- res.on('close', () => {
3029
- if (listener.finished) return;
3030
- listener.finished = true;
3031
- clearTimeout(listener.tid);
3032
- shortPollListeners.get(id)?.delete(listener);
3033
- });
3034
- } catch (err) {
3035
- try {
3036
- if (!res.headersSent) {
3037
- res.writeHead(500, { 'Content-Type': 'application/json' });
3038
- res.end(JSON.stringify({ error: String(err?.message || err) }));
3039
- }
3040
- } catch {}
3041
- }
3042
- return;
3043
- }
3044
-
3045
- // Permission hook bridge: receive tool permission request from perm-bridge.js, long-poll for user decision
3046
- if (url === '/api/perm-hook' && method === 'POST') {
3047
- let body = '';
3048
- req.on('data', (chunk) => {
3049
- body += chunk;
3050
- if (body.length > 1000000) {
3051
- res.writeHead(413, { 'Content-Type': 'application/json' });
3052
- res.end(JSON.stringify({ error: 'Request body too large' }));
3053
- return;
3054
- }
3055
- });
3056
- req.on('end', async () => {
3057
- try {
3058
- const { toolName, input } = JSON.parse(body);
3059
- if (!toolName) {
3060
- res.writeHead(400, { 'Content-Type': 'application/json' });
3061
- res.end(JSON.stringify({ error: 'Missing toolName' }));
3062
- return;
3063
- }
3064
-
3065
- // Evict oldest if Map is full (prevent memory leak from pathological concurrency)
3066
- if (pendingPermHooks.size >= PERM_HOOK_MAP_MAX) {
3067
- const oldestId = pendingPermHooks.keys().next().value;
3068
- const oldest = pendingPermHooks.get(oldestId);
3069
- if (oldest) {
3070
- clearTimeout(oldest.timer);
3071
- try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
3072
- pendingPermHooks.delete(oldestId);
3073
- }
3074
- }
3075
-
3076
- const HOOK_TIMEOUT = 5 * 60 * 1000;
3077
- const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
3078
-
3079
- // Plugin hook: let plugins handle permission requests directly.
3080
- // 与 sdk-manager.js:401-412 对齐:严格白名单 'allow'|'deny',未知值 fall-through 到常规审批。
3081
- // 早期 truthy-check 会把 plugin 返回的任意字符串原样转发到 perm-bridge(再被 coerce 为 'deny'),
3082
- // 既违反 cb2326e 声明的 fail-safe 语义,又让 SDK 与 bridge 两路径行为不对称。
3083
- try {
3084
- const hookResult = await runWaterfallHook('onPermRequest', { id, toolName, input, mode: 'hook' });
3085
- if (hookResult.decision === 'allow' || hookResult.decision === 'deny') {
3086
- res.writeHead(200, { 'Content-Type': 'application/json' });
3087
- res.end(JSON.stringify({ decision: hookResult.decision }));
3088
- return;
3089
- }
3090
- } catch {}
3091
-
3092
- const timer = setTimeout(() => {
3093
- const entry = pendingPermHooks.get(id);
3094
- if (entry) {
3095
- pendingPermHooks.delete(id);
3096
- try {
3097
- if (!entry.res.headersSent) {
3098
- entry.res.writeHead(408, { 'Content-Type': 'application/json' });
3099
- entry.res.end(JSON.stringify({ error: 'Timeout' }));
3100
- }
3101
- } catch {}
3102
- if (terminalWss) {
3103
- const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });
3104
- terminalWss.clients.forEach((c) => {
3105
- if (c.readyState === 1) try { c.send(tmsg); } catch {}
3106
- });
3107
- }
3108
- }
3109
- }, HOOK_TIMEOUT);
3110
-
3111
- pendingPermHooks.set(id, { toolName, input, res, timer, createdAt: Date.now() });
3112
-
3113
- // Broadcast to all terminal WS clients
3114
- if (terminalWss) {
3115
- const pmsg = JSON.stringify({ type: 'perm-hook-pending', id, toolName, input });
3116
- terminalWss.clients.forEach((client) => {
3117
- if (client.readyState === 1) {
3118
- try { client.send(pmsg); } catch {}
3119
- }
3120
- });
3121
- }
3122
-
3123
- // Handle perm-bridge.js disconnection
3124
- res.on('close', () => {
3125
- const entry = pendingPermHooks.get(id);
3126
- if (entry) {
3127
- clearTimeout(entry.timer);
3128
- pendingPermHooks.delete(id);
3129
- if (terminalWss) {
3130
- const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });
3131
- terminalWss.clients.forEach((c) => {
3132
- if (c.readyState === 1) try { c.send(tmsg); } catch {}
3133
- });
3134
- }
3135
- }
3136
- });
3137
- } catch {
3138
- res.writeHead(400, { 'Content-Type': 'application/json' });
3139
- res.end(JSON.stringify({ error: 'Invalid request body' }));
3140
- }
3141
- });
3142
- return;
3143
- }
3144
-
3145
- // 流式 chunk 接收端点:interceptor 在 SSE 流过程中推送的 partial entry
3146
- // 仅广播,不落盘。前端按 timestamp|url 自动与最终 entry 去重覆盖。
3147
- // 鉴权:server 绑 0.0.0.0 允许同机任意进程访问,必须校验 remote 必须是 loopback
3148
- // 且请求带 x-cc-viewer-internal: 1 header,防止同机其他进程伪造 SSE 内容注入广播。
3149
- if (url === '/api/stream-chunk' && method === 'POST') {
3150
- const remote = req.socket.remoteAddress || '';
3151
- const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
3152
- const internalHeader = req.headers['x-cc-viewer-internal'] === '1';
3153
- if (!isLoopback || !internalHeader) {
3154
- try { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Forbidden' })); } catch {}
3155
- return;
3156
- }
3157
- let body = '';
3158
- let aborted = false;
3159
- req.on('data', (chunk) => {
3160
- if (aborted) return;
3161
- body += chunk;
3162
- if (body.length > 8 * 1024 * 1024) {
3163
- aborted = true;
3164
- try { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Payload too large' })); } catch {}
3165
- }
3166
- });
3167
- req.on('end', () => {
3168
- if (aborted) return;
3169
- try {
3170
- const entry = JSON.parse(body);
3171
- const key = `${entry.timestamp}|${entry.url}`;
3172
- const seq = typeof entry._chunkSeq === 'number' ? entry._chunkSeq : 0;
3173
- const lastSeq = _liveStreamLastSeq.get(key);
3174
- if (lastSeq !== undefined && seq < lastSeq) {
3175
- // 乱序到达的旧 chunk 丢弃
3176
- try { res.writeHead(204); res.end(); } catch {}
3177
- return;
3178
- }
3179
- _liveStreamLastSeq.set(key, seq);
3180
- // 清理 seq 记录:超过 200 条时 FIFO 驱逐最早的 100 条(Map 保持插入顺序)
3181
- if (_liveStreamLastSeq.size > 200) {
3182
- const keys = Array.from(_liveStreamLastSeq.keys()).slice(0, 100);
3183
- for (const k of keys) _liveStreamLastSeq.delete(k);
3184
- }
3185
- // 用 named event 'stream-progress' 避免混入 data: 流与 dedup 冲突
3186
- // 精简 payload:前端只需要 timestamp/url/content 渲染 Live overlay
3187
- const _streamChunkPayload = {
3188
- timestamp: entry.timestamp,
3189
- url: entry.url,
3190
- content: entry.response?.body?.content || [],
3191
- model: entry.body?.model,
3192
- };
3193
- sendEventToClients(clients, 'stream-progress', _streamChunkPayload);
3194
- runParallelHook('onStreamChunk', _streamChunkPayload);
3195
- } catch {}
3196
- try { res.writeHead(204); res.end(); } catch {}
3197
- });
3198
- return;
3199
- }
3200
-
3201
- // 读取文件内容 API —— 走 file-access-policy 统一校验
3202
- // 历史 isEditorSession 后门已收敛:绝对路径全部走 policy(allowlist 已含 ~/.claude/、CCV_PROJECT_DIR、
3203
- // 已注册 workspaces、上传/持久化目录),前端无需再传 editorSession=true。
3204
- if (url === '/api/file-content' && method === 'GET') {
3205
- const reqPath = parsedUrl.searchParams.get('path');
3206
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
3207
- try {
3208
- if (!reqPath) {
3209
- res.writeHead(400, { 'Content-Type': 'application/json' });
3210
- res.end(JSON.stringify({ error: 'Invalid path' }));
3211
- return;
3212
- }
3213
- // 相对路径含 .. → 直接拒(历史契约;绕过项目目录的明确攻击)
3214
- const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
3215
- if (!isAbs && reqPath.includes('..')) {
3216
- res.writeHead(400, { 'Content-Type': 'application/json' });
3217
- res.end(JSON.stringify({ error: 'Invalid path' }));
3218
- return;
3219
- }
3220
- // 相对路径在项目目录拼接;绝对路径直接送 policy
3221
- const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
3222
- const policy = isReadAllowed(absPath);
3223
- if (!policy.ok) {
3224
- const status = reasonToStatus(policy.reason);
3225
- const errLabel = status === 404 ? 'File not found'
3226
- : status === 400 ? 'Invalid path'
3227
- : 'Forbidden';
3228
- const body = { error: errLabel, reason: policy.reason };
3229
- if (policy.allowedRoots) body.allowedRoots = policy.allowedRoots;
3230
- res.writeHead(status, { 'Content-Type': 'application/json' });
3231
- res.end(JSON.stringify(body));
3232
- return;
3233
- }
3234
- // 用 policy 返回的 real 读,杜绝 TOCTOU
3235
- const real = policy.real;
3236
- const st = statSync(real);
3237
- if (!st.isFile()) {
3238
- res.writeHead(400, { 'Content-Type': 'application/json' });
3239
- res.end(JSON.stringify({ error: 'Not a file' }));
3240
- return;
3241
- }
3242
- if (st.size > 5 * 1024 * 1024) {
3243
- res.writeHead(413, { 'Content-Type': 'application/json' });
3244
- res.end(JSON.stringify({ error: 'File too large' }));
3245
- return;
3246
- }
3247
- const content = readFileSync(real, 'utf-8');
3248
- // path 字段回返原始入参,前端用它做路径展示与后续 POST 引用
3249
- res.writeHead(200, { 'Content-Type': 'application/json' });
3250
- res.end(JSON.stringify({ path: reqPath, content, size: st.size }));
3251
- } catch (err) {
3252
- const status = ERROR_STATUS_MAP[err.code] || 500;
3253
- const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
3254
- res.writeHead(status, { 'Content-Type': 'application/json' });
3255
- res.end(JSON.stringify({ error: message }));
3256
- }
3257
- return;
3258
- }
3259
-
3260
- // 当前项目「持久记忆」入口/明细 —— 路径编码: cwd 中所有非 [a-zA-Z0-9-] 字符替换为 -
3261
- // 编码方案与 Claude Code 写入 ~/.claude/projects/<encoded>/memory/ 时使用的一致(实地比对验证)。
3262
- // 不带参 → 返回 MEMORY.md 入口; ?file=<basename> → 返回同目录下指定 .md 明细。
3263
- // 安全分层: 1) basename 形态校验(单段 + .md) 2) realpath 必须严格在 memoryDir 之内 3) isReadAllowed 政策(~/.claude/ allowlist)。
3264
- if (url === '/api/project-memory' && method === 'GET') {
3265
- // 本端点内 helper:8 处响应去重(端点局部,不跨文件)
3266
- const respondJson = (status, body) => {
3267
- res.writeHead(status, { 'Content-Type': 'application/json' });
3268
- res.end(JSON.stringify(body));
3269
- };
3270
- try {
3271
- const cwdRaw = process.env.CCV_PROJECT_DIR || process.cwd();
3272
- const cwd = cwdRaw.replace(/[/\\]+$/, '');
3273
- const encoded = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
3274
- const dir = join(getClaudeConfigDir(), 'projects', encoded, 'memory');
3275
- const fileParam = parsedUrl.searchParams.get('file');
3276
- const MAX_BYTES = 512 * 1024;
3277
-
3278
- // 入口文件
3279
- if (!fileParam) {
3280
- const indexPath = join(dir, 'MEMORY.md');
3281
- if (!existsSync(indexPath)) return respondJson(200, { exists: false, dir, indexPath });
3282
- const policy = isReadAllowed(indexPath);
3283
- if (!policy.ok) return respondJson(reasonToStatus(policy.reason), { error: 'Forbidden', reason: policy.reason });
3284
- const st = statSync(policy.real);
3285
- if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
3286
- if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
3287
- const content = readFileSync(policy.real, 'utf-8');
3288
- return respondJson(200, { exists: true, dir, indexPath, content });
3289
- }
3290
-
3291
- // 明细文件: 仅接受单段 basename + .md 后缀
3292
- // 再次校验 realpath 严格在 memoryDir 内 —— policy 的 ~/.claude/ allowlist 范围比这里宽。
3293
- if (fileParam.includes('/') || fileParam.includes('\\') || fileParam.includes('\0') || fileParam === '..' || fileParam.startsWith('.')) {
3294
- return respondJson(400, { error: 'Invalid file name' });
3295
- }
3296
- if (!/\.md$/i.test(fileParam)) return respondJson(400, { error: 'Only .md files allowed' });
3297
- const detailPath = join(dir, fileParam);
3298
- if (!existsSync(detailPath)) return respondJson(404, { error: 'File not found' });
3299
- // realpath 收紧: 必须严格落在 realpath(dir) 内 —— 防 symlink 跳出 memoryDir
3300
- let realDir, realFile;
3301
- try {
3302
- realDir = realpathSync(dir);
3303
- realFile = realpathSync(detailPath);
3304
- } catch {
3305
- return respondJson(404, { error: 'File not found' });
3306
- }
3307
- const realDirWithSep = realDir.endsWith(sep) ? realDir : realDir + sep;
3308
- if (realFile !== realDir && !realFile.startsWith(realDirWithSep)) {
3309
- return respondJson(403, { error: 'Path traversal not allowed' });
3310
- }
3311
- const policy = isReadAllowed(realFile);
3312
- if (!policy.ok) return respondJson(reasonToStatus(policy.reason), { error: 'Forbidden', reason: policy.reason });
3313
- const st = statSync(realFile);
3314
- if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
3315
- if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
3316
- const content = readFileSync(realFile, 'utf-8');
3317
- return respondJson(200, { name: fileParam, path: realFile, content });
3318
- } catch (err) {
3319
- // 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
3320
- // 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
3321
- const status = ERROR_STATUS_MAP[err.code] || 500;
3322
- const message = status === 500 ? 'Internal error' : err.message;
3323
- respondJson(status, { error: message });
3324
- }
3325
- return;
3326
- }
3327
-
3328
- // CLAUDE.md 入口:列出 cwd 父链 + ~/.claude/CLAUDE.md,按 sha1(realpath).slice(0,12) 给稳定 id;
3329
- // 不带参 → 返回候选清单(无 content);?id=<hex12> → 二次校验 basename + isReadAllowed 后吐内容。
3330
- // 设计要点:客户端永远只传 id,path resolution 由 server 独占;id 由 realpath 派生 → swap 即变 id。
3331
- if (url === '/api/claude-md' && method === 'GET') {
3332
- const respondJson = (status, body) => {
3333
- res.writeHead(status, { 'Content-Type': 'application/json' });
3334
- res.end(JSON.stringify(body));
3335
- };
3336
- try {
3337
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
3338
- const claudeConfigDir = getClaudeConfigDir();
3339
- // 注入 isReadAllowed 做发现阶段预过滤: 不在 allowlist 内的祖先候选不进列表 ——
3340
- // 否则 UI 会渲染出点击必 403 的"看得见点不开"chip。
3341
- const candidates = discoverClaudeMdCandidates({
3342
- cwd,
3343
- claudeConfigDir,
3344
- isReadAllowedFn: isReadAllowed,
3345
- });
3346
- const idParam = parsedUrl.searchParams.get('id');
3347
- const MAX_BYTES = 512 * 1024;
3348
-
3349
- if (!idParam) {
3350
- // 列表: 不暴露 realPath / mtimeMs, 仅返回 {id, scope, tail}。mtimeMs 前端未使用,
3351
- // 去掉以收敛信息泄露面 (perf-sec review P2-C)。
3352
- return respondJson(200, {
3353
- entries: candidates.map(c => ({
3354
- id: c.id,
3355
- scope: c.scope,
3356
- tail: c.tail,
3357
- })),
3358
- });
3359
- }
3360
-
3361
- const r = readCandidateById(candidates, idParam, {
3362
- maxBytes: MAX_BYTES,
3363
- isReadAllowedFn: isReadAllowed,
3364
- });
3365
- if (!r.ok) {
3366
- // policy 失败时让 reasonToStatus 统一映射,与 /api/project-memory 一致
3367
- const status = r.reason ? reasonToStatus(r.reason) : r.status;
3368
- return respondJson(status, { error: r.error, reason: r.reason });
3369
- }
3370
- return respondJson(200, {
3371
- id: idParam,
3372
- scope: r.scope,
3373
- tail: r.tail,
3374
- content: r.content,
3375
- });
3376
- } catch (err) {
3377
- const status = ERROR_STATUS_MAP[err.code] || 500;
3378
- const message = status === 500 ? 'Internal error' : err.message;
3379
- respondJson(status, { error: message });
3380
- }
3381
- return;
3382
- }
3383
-
3384
- // 返回文件原始二进制内容(用于图片预览等)—— 走 file-access-policy 统一校验
3385
- // 历史 isEditorSession 后门 + uploadPrefix/persistPrefix 硬编码豁免已收敛:
3386
- // policy 的 allowlist 已含 /tmp/cc-viewer-uploads/、tmpdir()/cc-viewer-uploads/、
3387
- // ~/.claude/cc-viewer/<project>/images/、CCV_PROJECT_DIR、~/.claude/、registered workspaces。
3388
- // 支持两种调用形式:
3389
- // query-style `/api/file-raw?path=<encoded>` —— 老接口,前端按文件路径单次取(图片预览等)
3390
- // path-style `/api/file-raw/<encoded-segments>` —— HTML 预览专用,让 iframe 里 c8/nyc 这类
3391
- // 报告的相对 `<script src="sorter.js">` 能解析到同目录另一个文件(query-style URL 浏览器
3392
- // 无法做相对路径解析);reqPath 直接从 pathname 取,复用下方 isReadAllowed + mime + CSP 全套
3393
- if ((url === '/api/file-raw' || url.startsWith('/api/file-raw/')) && (method === 'GET' || method === 'HEAD')) {
3394
- let reqPath;
3395
- if (url.startsWith('/api/file-raw/')) {
3396
- const tail = parsedUrl.pathname.slice('/api/file-raw/'.length);
3397
- try { reqPath = decodeURIComponent(tail); } catch { reqPath = tail; }
3398
- } else {
3399
- reqPath = parsedUrl.searchParams.get('path');
3400
- }
3401
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
3402
- try {
3403
- if (!reqPath) {
3404
- res.writeHead(400, { 'Content-Type': 'application/json' });
3405
- res.end(JSON.stringify({ error: 'Invalid path' }));
3406
- return;
3407
- }
3408
- const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
3409
- const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
3410
-
3411
- // /tmp 原文件不存在时回退到持久化副本(保留原 fallback 语义,但用 policy 守卫)
3412
- let policy = isReadAllowed(absPath);
3413
- if (!policy.ok && policy.reason === 'realpath-failed' && isAbs) {
3414
- const pName = _projectName || 'default';
3415
- const persistPrefix = join(getClaudeConfigDir(), 'cc-viewer', pName, 'images');
3416
- const fileName = basename(absPath);
3417
- if (fileName) {
3418
- const persistFile = join(persistPrefix, fileName);
3419
- const fallbackPolicy = isReadAllowed(persistFile);
3420
- if (fallbackPolicy.ok) policy = fallbackPolicy;
3421
- }
3422
- }
3423
- if (!policy.ok) {
3424
- const status = reasonToStatus(policy.reason);
3425
- const errLabel = status === 404 ? 'File not found'
3426
- : status === 400 ? 'Invalid path'
3427
- : 'Forbidden';
3428
- const body = { error: errLabel, reason: policy.reason };
3429
- if (policy.allowedRoots) body.allowedRoots = policy.allowedRoots;
3430
- res.writeHead(status, { 'Content-Type': 'application/json' });
3431
- res.end(JSON.stringify(body));
3432
- return;
3433
- }
3434
- const targetFile = policy.real;
3435
- const stat = statSync(targetFile);
3436
- if (!stat.isFile()) {
3437
- res.writeHead(400, { 'Content-Type': 'application/json' });
3438
- res.end(JSON.stringify({ error: 'Not a file' }));
3439
- return;
3440
- }
3441
- if (stat.size > 10 * 1024 * 1024) {
3442
- res.writeHead(413, { 'Content-Type': 'application/json' });
3443
- res.end(JSON.stringify({ error: 'File too large' }));
3444
- return;
3445
- }
3446
- const extMime = {
3447
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
3448
- '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
3449
- '.webp': 'image/webp', '.html': 'text/html', '.htm': 'text/html',
3450
- };
3451
- const ext = (targetFile.match(/\.[^.]+$/) || [''])[0].toLowerCase();
3452
- const mime = extMime[ext] || 'application/octet-stream';
3453
- const data = method === 'HEAD' ? null : readFileSync(targetFile);
3454
- const size = method === 'HEAD' ? stat.size : data.length;
3455
- const headers = { 'Content-Type': mime, 'Content-Length': size };
3456
- // 防止用户项目中的恶意 HTML 在同源下执行脚本(XSS 防护):CSP sandbox 强制 unique origin,
3457
- // 即便绕过 iframe 直接访问也拿不到 cc-viewer 同源的 storage/cookie。
3458
- // CSP 与 iframe sandbox 同时存在时取交集(更严格者胜),iframe 端 sandbox 也只配 `allow-scripts`
3459
- // 跟这里保持一致,c8 / nyc 覆盖率报告需要的 sortable table / prettify / block-navigation 都
3460
- // 不依赖 popup / form,因此 allow-popups / allow-forms 都不放(防 `window.open` 弹外站 +
3461
- // `<form action>` 提交任意 URL)。再叠加 `connect-src 'none'` + `form-action 'none'` 兜底
3462
- // 阻断脚本外发流量(fetch / XHR / WebSocket / form 提交)—— 用户项目里的静态报告页都不需要
3463
- // 网络通信,能跑就够;外联即可疑。
3464
- if (mime === 'text/html') headers['Content-Security-Policy'] = "sandbox allow-scripts; connect-src 'none'; form-action 'none'";
3465
- res.writeHead(200, headers);
3466
- res.end(data);
3467
- } catch (err) {
3468
- const status = ERROR_STATUS_MAP[err.code] || 500;
3469
- const message = status === 500 ? `Cannot read file: ${err.message}` : err.message;
3470
- res.writeHead(status, { 'Content-Type': 'application/json' });
3471
- res.end(JSON.stringify({ error: message }));
3472
- }
3473
- return;
3474
- }
3475
-
3476
- // 保存文件内容 API
3477
- if (url === '/api/file-content' && method === 'POST') {
3478
- const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
3479
- let body = '';
3480
- let overflow = false;
3481
- req.on('data', chunk => {
3482
- body += chunk;
3483
- if (body.length > MAX_BODY) { overflow = true; req.destroy(); }
3484
- });
3485
- req.on('end', () => {
3486
- if (overflow) {
3487
- res.writeHead(413, { 'Content-Type': 'application/json' });
3488
- res.end(JSON.stringify({ error: 'Request body too large' }));
3489
- return;
3490
- }
3491
- try {
3492
- const { path: reqPath, content } = JSON.parse(body);
3493
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
3494
- if (!reqPath) {
3495
- res.writeHead(400, { 'Content-Type': 'application/json' });
3496
- res.end(JSON.stringify({ error: 'Invalid path' }));
3497
- return;
3498
- }
3499
- if (typeof content !== 'string') {
3500
- res.writeHead(400, { 'Content-Type': 'application/json' });
3501
- res.end(JSON.stringify({ error: 'Content must be a string' }));
3502
- return;
3503
- }
3504
- const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
3505
- const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
3506
-
3507
- // 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
3508
- // 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
3509
- // (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
3510
- let targetReal;
3511
- const policy = isReadAllowed(absPath);
3512
- if (policy.ok) {
3513
- targetReal = policy.real;
3514
- } else if (policy.reason === 'realpath-failed') {
3515
- // 递归向上找最近存在的祖先;若 allowlist 命中即允许新建,从该祖先 real 重建目标路径。
3516
- let cursor = resolve(absPath, '..');
3517
- let ancestorPolicy = null;
3518
- let descent = [basename(absPath)];
3519
- for (let depth = 0; depth < 32; depth++) {
3520
- const ap = isReadAllowed(cursor);
3521
- if (ap.ok) { ancestorPolicy = ap; break; }
3522
- if (ap.reason !== 'realpath-failed') {
3523
- // sensitive-prefix / outside-allowlist 等明确拒绝 → 直接 403
3524
- ancestorPolicy = ap;
3525
- break;
3526
- }
3527
- // 当前祖先也不存在,继续上溯
3528
- const parent = resolve(cursor, '..');
3529
- if (parent === cursor) break; // 抵达根,停止
3530
- descent.unshift(basename(cursor));
3531
- cursor = parent;
3532
- }
3533
- if (!ancestorPolicy || !ancestorPolicy.ok) {
3534
- const reason = (ancestorPolicy && ancestorPolicy.reason) || 'outside-allowlist';
3535
- const status = reasonToStatus(reason);
3536
- const body = { error: 'Forbidden', reason };
3537
- if (ancestorPolicy && ancestorPolicy.allowedRoots) body.allowedRoots = ancestorPolicy.allowedRoots;
3538
- res.writeHead(status, { 'Content-Type': 'application/json' });
3539
- res.end(JSON.stringify(body));
3540
- return;
3541
- }
3542
- // 在祖先 real 路径下重建目标。Denylist 已在祖先 prefix 层生效,
3543
- // 这里相信 allowlist 祖先的合法性(项目目录 / ~/.claude/cc-viewer 等)。
3544
- targetReal = join(ancestorPolicy.real, ...descent);
3545
- // 父目录可能不存在,递归 mkdir
3546
- try { mkdirSync(dirname(targetReal), { recursive: true }); } catch {}
3547
- } else {
3548
- const status = reasonToStatus(policy.reason);
3549
- const body = { error: 'Forbidden', reason: policy.reason };
3550
- if (policy.allowedRoots) body.allowedRoots = policy.allowedRoots;
3551
- res.writeHead(status, { 'Content-Type': 'application/json' });
3552
- res.end(JSON.stringify(body));
3553
- return;
3554
- }
3555
-
3556
- writeFileSync(targetReal, content, 'utf-8');
3557
- const stat = statSync(targetReal);
3558
- res.writeHead(200, { 'Content-Type': 'application/json' });
3559
- res.end(JSON.stringify({ ok: true, size: stat.size }));
3560
- } catch (err) {
3561
- const status = ERROR_STATUS_MAP[err.code] || 500;
3562
- const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
3563
- res.writeHead(status, { 'Content-Type': 'application/json' });
3564
- res.end(JSON.stringify({ error: message }));
3565
- }
3566
- });
3567
- return;
3568
- }
3569
-
3570
- // CLI 模式检测
3571
- if (url === '/api/cli-mode' && method === 'GET') {
3572
- res.writeHead(200, { 'Content-Type': 'application/json' });
3573
- res.end(JSON.stringify({ cliMode: isCliMode, sdkMode: isSdkMode, workspaceMode: isWorkspaceMode && !_workspaceLaunched }));
3574
- return;
3575
- }
3576
-
3577
- // Git 状态
3578
- // 扫描项目根目录及一级子目录的 git 仓库
3579
- if (url === '/api/git-repos' && method === 'GET') {
3580
- try {
3581
- const projectDir = process.env.CCV_PROJECT_DIR || process.cwd();
3582
- const repos = [];
3583
- if (existsSync(join(projectDir, '.git'))) {
3584
- repos.push({ name: basename(projectDir), path: '.', isRoot: true });
3585
- }
3586
- const entries = readdirSync(projectDir, { withFileTypes: true });
3587
- for (const entry of entries) {
3588
- if (!entry.isDirectory()) continue;
3589
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
3590
- try {
3591
- if (existsSync(join(projectDir, entry.name, '.git'))) {
3592
- repos.push({ name: entry.name, path: entry.name, isRoot: false });
3593
- }
3594
- } catch {}
3595
- }
3596
- res.writeHead(200, { 'Content-Type': 'application/json' });
3597
- res.end(JSON.stringify({ repos }));
3598
- } catch (err) {
3599
- res.writeHead(500, { 'Content-Type': 'application/json' });
3600
- res.end(JSON.stringify({ error: err.message, repos: [] }));
3601
- }
3602
- return;
3603
- }
3604
-
3605
- // 撤销单个文件的 git 变更
3606
- if (url === '/api/git-restore' && method === 'POST') {
3607
- let body = '';
3608
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
3609
- req.on('end', async () => {
3610
- let parsed;
3611
- try { parsed = JSON.parse(body); } catch {
3612
- res.writeHead(400, { 'Content-Type': 'application/json' });
3613
- res.end(JSON.stringify({ error: 'Invalid request body' }));
3614
- return;
3615
- }
3616
- try {
3617
- const { path: filePath, repo: repoParam } = parsed;
3618
- if (!filePath) {
3619
- res.writeHead(400, { 'Content-Type': 'application/json' });
3620
- res.end(JSON.stringify({ error: 'Missing path' }));
3621
- return;
3622
- }
3623
- if (filePath.startsWith('/') || filePath.includes('..')) {
3624
- res.writeHead(400, { 'Content-Type': 'application/json' });
3625
- res.end(JSON.stringify({ error: 'Invalid path' }));
3626
- return;
3627
- }
3628
- const cwd = resolveRepoCwd(repoParam);
3629
- if (!cwd) {
3630
- res.writeHead(400, { 'Content-Type': 'application/json' });
3631
- res.end(JSON.stringify({ error: 'Invalid repo parameter' }));
3632
- return;
3633
- }
3634
- const fullPath = join(cwd, filePath);
3635
- if (existsSync(fullPath)) {
3636
- const realFull = realpathSync(fullPath);
3637
- const realCwd = realpathSync(cwd);
3638
- if (!realFull.startsWith(realCwd + sep)) {
3639
- res.writeHead(400, { 'Content-Type': 'application/json' });
3640
- res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
3641
- return;
3642
- }
3643
- }
3644
- // Per-file mutex 序列化「git status + checkout/clean」子命令对。多 tab 并发同文件
3645
- // revert 时不再有 race 让两个 checkout 交错执行(最终状态不可预测)。
3646
- // resolve() 规整 `./foo.js` / `foo.js` / `.//foo.js` 为同一 lockKey,防形变绕过。
3647
- const lockKey = resolve(join(cwd, filePath));
3648
- const prev = gitRestoreLocks.get(lockKey) || Promise.resolve();
3649
- const current = prev.then(async () => {
3650
- const { stdout: statusOut } = await execFileAsync('git', ['status', '--porcelain', '--', filePath], { cwd, encoding: 'utf-8', timeout: 5000 });
3651
- const isUntracked = statusOut.trim().startsWith('??');
3652
- if (isUntracked) {
3653
- await execFileAsync('git', ['clean', '-fd', '--', filePath], { cwd, timeout: 10000 });
3654
- } else {
3655
- await execFileAsync('git', ['checkout', '--', filePath], { cwd, timeout: 10000 });
3656
- }
3657
- }).finally(() => {
3658
- if (gitRestoreLocks.get(lockKey) === current) gitRestoreLocks.delete(lockKey);
3659
- });
3660
- gitRestoreLocks.set(lockKey, current);
3661
- // setTimeout 兜底——防 finally 异常吞 entry 累积内存。
3662
- setTimeout(() => {
3663
- if (gitRestoreLocks.get(lockKey) === current) gitRestoreLocks.delete(lockKey);
3664
- }, GIT_RESTORE_LOCK_CLEANUP_MS).unref();
3665
- await current;
3666
- res.writeHead(200, { 'Content-Type': 'application/json' });
3667
- res.end(JSON.stringify({ ok: true }));
3668
- } catch (err) {
3669
- res.writeHead(500, { 'Content-Type': 'application/json' });
3670
- res.end(JSON.stringify({ error: err.message }));
3671
- }
3672
- });
3673
- return;
3674
- }
3675
-
3676
- if (url === '/api/git-status' && method === 'GET') {
3677
- try {
3678
- const repoParam = parsedUrl.searchParams.get('repo');
3679
- const cwd = resolveRepoCwd(repoParam);
3680
- if (!cwd) {
3681
- res.writeHead(400, { 'Content-Type': 'application/json' });
3682
- res.end(JSON.stringify({ error: 'Invalid repo parameter', changes: [] }));
3683
- return;
3684
- }
3685
- // `-uall` 让 git 把新增目录展开成具体文件,而不是收敛为 `?? path/`
3686
- // 否则前端树会把整个新目录当一个「空文件名」叶子渲染,且行数统计为 0。
3687
- // maxBuffer 拉到 10MB——默认 1MB 在 node_modules 未 gitignore 之类的极端
3688
- // 场景下会被截断,导致后续 split 解析错位。
3689
- const { stdout: output } = await execFileAsync('git', ['status', '--porcelain', '-uall'], { cwd, encoding: 'utf-8', timeout: 5000, maxBuffer: 10 * 1024 * 1024 });
3690
- // Win 上 git stdout 是 CRLF;现状靠下文 .trim() 兜底,加正则更稳防未来 strict 比较破窗。
3691
- const lines = output.split(/\r?\n/).filter(line => line.trim());
3692
- const changes = lines.map(line => {
3693
- const status = line.substring(0, 2).trim();
3694
- let file = line.substring(3).trim();
3695
- // git status --porcelain quotes paths with non-ASCII chars using octal escapes
3696
- if (file.startsWith('"') && file.endsWith('"')) {
3697
- file = file.slice(1, -1)
3698
- .replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)))
3699
- .replace(/\\t/g, '\t').replace(/\\n/g, '\n')
3700
- .replace(/\\\\/g, '\\').replace(/\\"/g, '"');
3701
- file = Buffer.from(file, 'latin1').toString('utf8');
3702
- }
3703
- return { status, file };
3704
- });
3705
-
3706
- // Collect per-file insertions/deletions via git diff --numstat (tracked) + --cached --numstat (staged).
3707
- // Neither covers untracked files, so add their line counts separately via countUntrackedLines
3708
- // — matching git's numstat semantics (binary and >5MB files contribute 0).
3709
- let insertions = 0, deletions = 0;
3710
- try {
3711
- const [{ stdout: numstat }, { stdout: cachedNumstat }] = await Promise.all([
3712
- execFileAsync('git', ['diff', '--numstat'], { cwd, encoding: 'utf-8', timeout: 5000 }),
3713
- execFileAsync('git', ['diff', '--cached', '--numstat'], { cwd, encoding: 'utf-8', timeout: 5000 }),
3714
- ]);
3715
- for (const raw of [numstat, cachedNumstat]) {
3716
- for (const l of raw.split('\n')) {
3717
- const m = l.match(/^(\d+)\t(\d+)\t/);
3718
- if (m) { insertions += Number(m[1]); deletions += Number(m[2]); }
3719
- }
3720
- }
3721
- } catch { /* non-critical — stats just stay 0 */ }
3722
-
3723
- // Cap untracked-file processing to keep the event loop responsive if a
3724
- // repo forgets to gitignore a huge directory (e.g. node_modules).
3725
- // 超限时仍继续计数,但不再调 countUntrackedLines——用 insertions_capped
3726
- // 通知前端"此数据被硬上限截断",避免静默少算给用户造成误判。
3727
- const MAX_UNTRACKED = 5000;
3728
- let untrackedProcessed = 0;
3729
- let untrackedTotal = 0;
3730
- for (const c of changes) {
3731
- if (c.status !== '??') continue;
3732
- untrackedTotal++;
3733
- if (untrackedProcessed >= MAX_UNTRACKED) continue;
3734
- insertions += countUntrackedLines(cwd, c.file);
3735
- untrackedProcessed++;
3736
- }
3737
- const insertions_capped = untrackedTotal > MAX_UNTRACKED;
3738
-
3739
- res.writeHead(200, { 'Content-Type': 'application/json' });
3740
- res.end(JSON.stringify({ changes, insertions, deletions, insertions_capped }));
3741
- } catch (err) {
3742
- res.writeHead(500, { 'Content-Type': 'application/json' });
3743
- res.end(JSON.stringify({ error: err.message, changes: [] }));
3744
- }
3745
- return;
3746
- }
3747
-
3748
- // Git diff 数据获取
3749
- if (url.startsWith('/api/git-diff') && method === 'GET') {
3750
- try {
3751
- const repoParam = parsedUrl.searchParams.get('repo');
3752
- const cwd = resolveRepoCwd(repoParam);
3753
- if (!cwd) {
3754
- res.writeHead(400, { 'Content-Type': 'application/json' });
3755
- res.end(JSON.stringify({ error: 'Invalid repo parameter', diffs: [] }));
3756
- return;
3757
- }
3758
- const filesParam = parsedUrl.searchParams.get('files');
3759
-
3760
- if (!filesParam) {
3761
- res.writeHead(400, { 'Content-Type': 'application/json' });
3762
- res.end(JSON.stringify({ error: 'Missing files parameter' }));
3763
- return;
3764
- }
3765
-
3766
- const files = filesParam.split(',').map(f => f.trim()).filter(Boolean);
3767
- const commitParam = parsedUrl.searchParams.get('commit');
3768
- // Reject malformed commit hashes; falsy = working-tree mode
3769
- const commitHash = commitParam && isValidCommitHash(commitParam) ? commitParam : undefined;
3770
- const diffs = await getGitDiffs(cwd, files, commitHash);
3771
-
3772
- res.writeHead(200, { 'Content-Type': 'application/json' });
3773
- res.end(JSON.stringify({ diffs }));
3774
- } catch (err) {
3775
- res.writeHead(500, { 'Content-Type': 'application/json' });
3776
- res.end(JSON.stringify({ error: err.message, diffs: [] }));
3777
- }
3778
- return;
3779
- }
3780
-
3781
- // Local commits ahead of upstream (origin/<branch>..HEAD).
3782
- // Silent empty result when no upstream / detached HEAD — by design.
3783
- if (url.startsWith('/api/git-log-unpushed') && method === 'GET') {
3784
- try {
3785
- const repoParam = parsedUrl.searchParams.get('repo');
3786
- const cwd = resolveRepoCwd(repoParam);
3787
- if (!cwd) {
3788
- res.writeHead(400, { 'Content-Type': 'application/json' });
3789
- res.end(JSON.stringify({ error: 'Invalid repo parameter', commits: [], hasUpstream: false }));
3790
- return;
3791
- }
3792
- const result = await getUnpushedCommits(cwd);
3793
- res.writeHead(200, { 'Content-Type': 'application/json' });
3794
- res.end(JSON.stringify(result));
3795
- } catch (err) {
3796
- res.writeHead(500, { 'Content-Type': 'application/json' });
3797
- res.end(JSON.stringify({ error: err.message, commits: [], hasUpstream: false }));
3798
- }
3799
- return;
3800
- }
294
+ // 密码登录配置(与 token 并存的第二种远程访问方式)。持久化为 preferences.json:全局 `auth`
295
+ // 键 + 可选 `authByProject[<projectDir>]` 覆盖(密码 base64 轻混淆 + 文件 0600)。
296
+ // AUTH_PROJECT = 本 server 服务的项目(CLI 模式取 CCV_PROJECT_DIR);非 CLI/日志模式为 null
297
+ // 只认全局。鉴权用 effective = 项目覆盖(若存在) else 全局。
298
+ // 同时在 --usePassword(CCV_USE_PASSWORD) 时取项目:该 flag 是「项目启动」专用,必须写「本项目」密码。
299
+ // 不能只靠 isCliMode —— 它在模块顶层只求值一次,而 server.js 可能经 interceptor 在 cli.js 设置
300
+ // CCV_CLI_MODE 之前就被加载(isCliMode=false),导致 --usePassword 误写全局。
301
+ const AUTH_PROJECT = (isCliMode || process.env.CCV_USE_PASSWORD === '1')
302
+ ? (process.env.CCV_PROJECT_DIR || process.cwd())
303
+ : null;
304
+ let authConfig = loadAuthConfig(AUTH_PROJECT);
305
+ // CLI --usePassword 交接(cli.js import 本模块前写入 env):写入本项目作用域(无项目则全局)。
306
+ // 优先级 显式值(CCV_PASSWORD) > 该作用域已持久化密码 > 随机生成。
307
+ if (process.env.CCV_USE_PASSWORD === '1') {
308
+ const explicit = process.env.CCV_PASSWORD;
309
+ let password = authConfig.password;
310
+ if (typeof explicit === 'string' && explicit.length > 0) password = explicit;
311
+ else if (!password) password = generatePassword();
312
+ const scope = AUTH_PROJECT ? 'project' : 'global';
313
+ saveAuthConfig({ enabled: true, password }, { scope, projectDir: AUTH_PROJECT });
314
+ authConfig = loadAuthConfig(AUTH_PROJECT);
315
+ }
316
+ // 钩子已消费完毕:清掉这两个 env,避免明文密码随 {...process.env} 泄漏进 spawn 出的 Claude 子进程
317
+ // (与刻意不把 ACCESS_TOKEN 放进 env 的策略一致)。此后无人再读它们(仅上面这段读取)。
318
+ delete process.env.CCV_USE_PASSWORD;
319
+ delete process.env.CCV_PASSWORD;
3801
320
 
3802
- // 插件管理 API
3803
- if (url === '/api/plugins' && method === 'GET') {
3804
- const plugins = getPluginsInfo();
3805
- res.writeHead(200, { 'Content-Type': 'application/json' });
3806
- res.end(JSON.stringify({ plugins, pluginsDir: getPluginsDir() }));
3807
- return;
3808
- }
321
+ let clients = [];
322
+ let server;
323
+ let actualPort = 0;
324
+ let serverProtocol = 'http';
325
+ // Stats Worker 实例
326
+ let statsWorker = null;
3809
327
 
3810
- if (url === '/api/plugins' && method === 'DELETE') {
3811
- const file = parsedUrl.searchParams.get('file');
3812
- if (!file || file.includes('..') || file.includes('/') || file.includes('\\')) {
3813
- res.writeHead(400, { 'Content-Type': 'application/json' });
3814
- res.end(JSON.stringify({ error: 'Invalid file name' }));
3815
- return;
3816
- }
3817
- const filePath = join(getPluginsDir(), file);
3818
- try {
3819
- if (!existsSync(filePath)) {
3820
- res.writeHead(404, { 'Content-Type': 'application/json' });
3821
- res.end(JSON.stringify({ error: 'File not found' }));
3822
- return;
328
+ function startStatsWorker() {
329
+ try {
330
+ statsWorker = new Worker(new URL('./lib/stats-worker.js', import.meta.url));
331
+ statsWorker.on('error', (err) => {
332
+ console.error('[CC Viewer] Stats worker error:', err.message);
333
+ statsWorker = null;
334
+ });
335
+ statsWorker.on('exit', (code) => {
336
+ if (code !== 0) {
337
+ console.error('[CC Viewer] Stats worker exited with code', code);
3823
338
  }
3824
- unlinkSync(filePath);
3825
- await loadPlugins();
3826
- const plugins = getPluginsInfo();
3827
- res.writeHead(200, { 'Content-Type': 'application/json' });
3828
- res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
3829
- } catch (err) {
3830
- res.writeHead(500, { 'Content-Type': 'application/json' });
3831
- res.end(JSON.stringify({ error: err.message }));
3832
- }
3833
- return;
3834
- }
3835
-
3836
- if (url === '/api/plugins/reload' && method === 'POST') {
3837
- try {
3838
- await loadPlugins();
3839
- const plugins = getPluginsInfo();
3840
- res.writeHead(200, { 'Content-Type': 'application/json' });
3841
- res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
3842
- } catch (err) {
3843
- res.writeHead(500, { 'Content-Type': 'application/json' });
3844
- res.end(JSON.stringify({ error: err.message }));
339
+ statsWorker = null;
340
+ });
341
+ // 初始化:全量扫描当前项目
342
+ if (_projectName && _logDir) {
343
+ statsWorker.postMessage({ type: 'init', logDir: LOG_DIR, projectName: _projectName });
3845
344
  }
3846
- return;
345
+ } catch (err) {
346
+ console.error('[CC Viewer] Failed to start stats worker:', err.message);
3847
347
  }
348
+ }
3848
349
 
3849
- if (url === '/api/plugins/upload' && method === 'POST') {
3850
- let body = '';
3851
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
3852
- req.on('end', async () => {
3853
- try {
3854
- const { files: fileList } = JSON.parse(body);
3855
- uploadPlugins(getPluginsDir(), fileList);
3856
- await loadPlugins();
3857
- const plugins = getPluginsInfo();
3858
- res.writeHead(200, { 'Content-Type': 'application/json' });
3859
- res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
3860
- } catch (err) {
3861
- const status = err.statusCode || 500;
3862
- res.writeHead(status, { 'Content-Type': 'application/json' });
3863
- res.end(JSON.stringify({ error: err.message }));
3864
- }
3865
- });
3866
- return;
350
+ function notifyStatsWorker(logFile) {
351
+ if (statsWorker && _projectName) {
352
+ statsWorker.postMessage({ type: 'update', logDir: LOG_DIR, projectName: _projectName, logFile });
3867
353
  }
354
+ }
3868
355
 
3869
- if (url === '/api/plugins/install-from-url' && method === 'POST') {
3870
- let body = '';
3871
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
3872
- req.on('end', async () => {
3873
- try {
3874
- const { url: fileUrl } = JSON.parse(body);
3875
- const extractScript = join(SERVER_LIB, 'extract-plugin-name.mjs');
3876
- await installPluginFromUrl(getPluginsDir(), fileUrl, extractScript);
3877
- await loadPlugins();
3878
- const plugins = getPluginsInfo();
3879
- res.writeHead(200, { 'Content-Type': 'application/json' });
3880
- res.end(JSON.stringify({ ok: true, plugins, pluginsDir: getPluginsDir() }));
3881
- } catch (err) {
3882
- const status = err.statusCode || 500;
3883
- res.writeHead(status, { 'Content-Type': 'application/json' });
3884
- res.end(JSON.stringify({ error: err.message }));
3885
- }
3886
- });
3887
- return;
3888
- }
356
+ const MIME_TYPES = {
357
+ '.html': 'text/html; charset=utf-8',
358
+ '.js': 'application/javascript; charset=utf-8',
359
+ '.css': 'text/css; charset=utf-8',
360
+ '.json': 'application/json; charset=utf-8',
361
+ '.svg': 'image/svg+xml',
362
+ '.png': 'image/png',
363
+ '.jpg': 'image/jpeg',
364
+ '.woff': 'font/woff',
365
+ '.woff2': 'font/woff2',
366
+ };
3889
367
 
3890
- // 返回局域网访问地址
3891
- if (url === '/api/local-url' && method === 'GET') {
3892
- const localIp = getLocalIp();
3893
- const defaultUrl = `${serverProtocol}://${localIp}:${actualPort}?token=${ACCESS_TOKEN}`;
3894
- const hookResult = await runWaterfallHook('localUrl', { url: defaultUrl, ip: localIp, port: actualPort, token: ACCESS_TOKEN });
3895
- res.writeHead(200, { 'Content-Type': 'application/json' });
3896
- res.end(JSON.stringify({ url: hookResult.url }));
3897
- return;
3898
- }
368
+ // Helper to build log-watcher options object
369
+ function _logWatcherOpts(logFile) {
370
+ return {
371
+ logFile: logFile || LOG_FILE,
372
+ clients,
373
+ getClaudePid,
374
+ runParallelHook,
375
+ notifyStatsWorker,
376
+ getLogFile: () => LOG_FILE,
377
+ };
378
+ }
3899
379
 
3900
- // 列出本地日志文件(按项目分组,遍历项目子目录)
3901
- if (url === '/api/local-logs' && method === 'GET') {
3902
- try {
3903
- const result = listLocalLogs(LOG_DIR, _projectName);
3904
- res.writeHead(200, { 'Content-Type': 'application/json' });
3905
- res.end(JSON.stringify(result));
3906
- } catch (err) {
3907
- res.writeHead(500, { 'Content-Type': 'application/json' });
3908
- res.end(JSON.stringify({ error: err.message }));
380
+ function getLocalIp() {
381
+ const nets = networkInterfaces();
382
+ for (const name of Object.keys(nets)) {
383
+ for (const net of nets[name]) {
384
+ if (net.family === 'IPv4' && !net.internal) return net.address;
3909
385
  }
3910
- return;
3911
386
  }
387
+ return '127.0.0.1';
388
+ }
3912
389
 
3913
- // 下载指定本地日志文件(原始 JSONL 格式)
3914
- if (url === '/api/download-log' && method === 'GET') {
3915
- const file = parsedUrl.searchParams.get('file');
3916
- if (!file || file.includes('..')) {
3917
- res.writeHead(400, { 'Content-Type': 'application/json' });
3918
- res.end(JSON.stringify({ error: 'Invalid file name' }));
3919
- return;
3920
- }
3921
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip')) {
3922
- res.writeHead(400, { 'Content-Type': 'application/json' });
3923
- res.end(JSON.stringify({ error: 'Invalid file type' }));
3924
- return;
3925
- }
3926
- const filePath = join(LOG_DIR, file);
3927
- try {
3928
- if (!existsSync(filePath)) {
3929
- res.writeHead(404, { 'Content-Type': 'application/json' });
3930
- res.end(JSON.stringify({ error: 'File not found' }));
3931
- return;
3932
- }
3933
- const realPath = realpathSync(filePath);
3934
- const realLogDir = realpathSync(LOG_DIR);
3935
- if (!realPath.startsWith(realLogDir)) {
3936
- res.writeHead(403, { 'Content-Type': 'application/json' });
3937
- res.end(JSON.stringify({ error: 'Access denied' }));
3938
- return;
3939
- }
3940
- const format = parsedUrl.searchParams.get('format');
3941
- // 默认下载重建后的 .jsonl;raw 下载原始字节(.zip 或 .jsonl 视实际而定)
3942
- const fileName = (format !== 'raw' && file.endsWith('.jsonl.zip'))
3943
- ? basename(file).slice(0, -4)
3944
- : basename(file);
3945
- // Delta storage: format=raw 下载原始文件;默认下载重建后的全量格式
3946
- if (format === 'raw') {
3947
- const stat = statSync(realPath);
3948
- res.writeHead(200, {
3949
- 'Content-Type': 'application/octet-stream',
3950
- 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
3951
- 'Content-Length': stat.size,
3952
- });
3953
- const stream = createReadStream(realPath);
3954
- stream.pipe(res);
3955
- } else {
3956
- // 流式下载原始条目(不重建,保持 delta 格式),避免 OOM
3957
- res.writeHead(200, {
3958
- 'Content-Type': 'application/octet-stream',
3959
- 'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}"`,
3960
- 'Transfer-Encoding': 'chunked',
3961
- });
3962
- await streamRawEntriesAsync(realPath, (raw) => {
3963
- res.write(raw);
3964
- res.write('\n---\n');
3965
- });
3966
- res.end();
3967
- }
3968
- } catch (err) {
3969
- res.writeHead(500, { 'Content-Type': 'application/json' });
3970
- res.end(JSON.stringify({ error: err.message }));
390
+ function getAllLocalIps() {
391
+ const ips = [];
392
+ const nets = networkInterfaces();
393
+ for (const name of Object.keys(nets)) {
394
+ for (const net of nets[name]) {
395
+ if (net.family === 'IPv4' && !net.internal) ips.push(net.address);
3971
396
  }
3972
- return;
3973
397
  }
398
+ return ips;
399
+ }
3974
400
 
3975
- // 读取指定本地日志文件(支持 project/file 路径)
3976
- if (url === '/api/local-log' && method === 'GET') {
3977
- const file = parsedUrl.searchParams.get('file');
3978
- if (!file || file.includes('..')) {
3979
- res.writeHead(400, { 'Content-Type': 'application/json' });
3980
- res.end(JSON.stringify({ error: 'Invalid file name' }));
3981
- return;
3982
- }
3983
-
3984
- // 验证文件类型:允许 .jsonl .jsonl.zip
3985
- if (!file.endsWith('.jsonl') && !file.endsWith('.jsonl.zip')) {
3986
- res.writeHead(400, { 'Content-Type': 'application/json' });
3987
- res.end(JSON.stringify({ error: 'Invalid file type. Only .jsonl(.zip) files are allowed.' }));
3988
- return;
3989
- }
401
+ // ─── Route dependency bag ──────────────────────────────────────────
402
+ // Single dependency object handed to every per-domain route handler (server/routes/*).
403
+ // Built once; route bodies were moved out of handleRequest's if-chain verbatim, with
404
+ // their closed-over identifiers rewritten to `deps.xxx`. Reassignable module state is
405
+ // exposed via GETTERS (read fresh at request time — never captured at import), while
406
+ // never-reassigned Maps/arrays are shared by reference. Helpers/constants that live in
407
+ // server.js (not importable elsewhere) are funneled through here too.
408
+ const deps = {
409
+ // Reassignable runtime state — must stay getters.
410
+ get protocol() { return serverProtocol; },
411
+ get actualPort() { return actualPort; },
412
+ get terminalWss() { return terminalWss; },
413
+ get writeToPty() { return _writeToPty; },
414
+ get onPtyData() { return _onPtyData; },
415
+ get statsWorker() { return statsWorker; },
416
+ get workspaceLaunched() { return _workspaceLaunched; },
417
+ setWorkspaceLaunched(v) { _workspaceLaunched = v; },
418
+ get launchCallback() { return _launchCallback; },
419
+ get workspaceClaudeArgs() { return _workspaceClaudeArgs; },
420
+ get workspaceClaudePath() { return _workspaceClaudePath; },
421
+ get workspaceIsNpmVersion() { return _workspaceIsNpmVersion; },
422
+ startStreamingStatusTimer,
423
+ get claudeSettings() { return claudeSettings; },
424
+ // const defined later in the file (TDZ) — must be a getter, read at request time.
425
+ get turnEndDebounceMs() { return TURN_END_DEBOUNCE_MS; },
426
+ // Shared collections — stable references (never reassigned; clients truncated in place).
427
+ clients,
428
+ pendingAskHooks,
429
+ pendingPermHooks,
430
+ shortPollListeners,
431
+ editorSessions,
432
+ gitRestoreLocks,
433
+ liveStreamLastSeq: _liveStreamLastSeq,
434
+ // Helpers defined in server.js.
435
+ execFileAsync,
436
+ execAsync,
437
+ execWithStdin,
438
+ resolveRepoCwd,
439
+ getPrefsFile,
440
+ getLocalIp,
441
+ startStatsWorker,
442
+ persistAskEntry: _persistAskEntry,
443
+ persistAskDelete: _persistAskDelete,
444
+ notifyParentPending: _notifyParentPending,
445
+ logWatcherOpts: _logWatcherOpts,
446
+ scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
447
+ maskProfiles: _maskProfiles,
448
+ maskApiKey: _maskApiKey,
449
+ isMasked: _isMasked,
450
+ // Password-auth config. authConfig = the EFFECTIVE config the gate enforces for this
451
+ // server's project (project override else global). Mutations persist to the chosen
452
+ // scope then recompute the effective in-memory value.
453
+ get authConfig() { return authConfig; },
454
+ get authProject() { return AUTH_PROJECT; },
455
+ getAuthState() { return loadAuthState(AUTH_PROJECT); },
456
+ setAuthConfig(c, scope) {
457
+ saveAuthConfig(c, { scope: scope === 'global' ? 'global' : (AUTH_PROJECT ? 'project' : 'global'), projectDir: AUTH_PROJECT });
458
+ authConfig = loadAuthConfig(AUTH_PROJECT);
459
+ return authConfig;
460
+ },
461
+ clearAuthOverride() {
462
+ clearProjectOverride(AUTH_PROJECT);
463
+ authConfig = loadAuthConfig(AUTH_PROJECT);
464
+ return authConfig;
465
+ },
466
+ // Constants local to server.js.
467
+ ACCESS_TOKEN,
468
+ INTERNAL_TOKEN,
469
+ MAX_POST_BODY,
470
+ ASK_HOOK_TIMEOUT_MS,
471
+ ASK_HOOK_MAP_MAX,
472
+ PERM_HOOK_MAP_MAX,
473
+ WINDOWS_RESERVED_NAMES,
474
+ DEFAULT_EVENTS_LIMIT,
475
+ SSE_BACKPRESSURE_TIMEOUT_MS,
476
+ IGNORED_PATTERNS,
477
+ isCliMode,
478
+ isSdkMode,
479
+ isWorkspaceMode,
480
+ defaultProxyProfiles: _defaultProxyProfiles,
481
+ };
3990
482
 
3991
- try {
3992
- // 独立 SSE 流:直接向请求方返回 event-stream,不走 /events 广播
3993
- const { validateLogPath } = await import('./lib/log-management.js');
3994
- validateLogPath(LOG_DIR, file);
3995
- const filePath = join(LOG_DIR, file);
3996
- const total = countLogEntries(filePath);
483
+ // ─── Route registry ────────────────────────────────────────────────
484
+ // Domain route modules concatenated IN THE SAME ORDER as the original if-chain
485
+ // (order is load-bearing: prefix-vs-exact and method-distinguished duplicates).
486
+ // dispatch() runs after the request prelude; an unmatched request returns false and
487
+ // falls through to static-file serving / 404.
488
+ const _routes = [
489
+ ...authRoutes,
490
+ ...projectMetaRoutes,
491
+ ...miscRoutes,
492
+ ...preferencesRoutes,
493
+ ...gitRoutes,
494
+ ...pluginsRoutes,
495
+ ...logsRoutes,
496
+ ...voicePackRoutes,
497
+ ...skillsRoutes,
498
+ ...filesContentRoutes,
499
+ ...filesFsRoutes,
500
+ ...workspacesRoutes,
501
+ ...eventsRoutes,
502
+ ...askPermRoutes,
503
+ ...teamRoutes,
504
+ ];
505
+ const dispatch = createDispatcher(_routes);
3997
506
 
3998
- res.writeHead(200, {
3999
- 'Content-Type': 'text/event-stream',
4000
- 'Cache-Control': 'no-cache',
4001
- 'Connection': 'keep-alive',
4002
- });
507
+ async function handleRequest(req, res) {
508
+ const parsedUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
509
+ const url = parsedUrl.pathname;
510
+ const method = req.method;
4003
511
 
4004
- res.write(`event: load_start\ndata: ${JSON.stringify({ total, incremental: false })}\n\n`);
4005
- await streamRawEntriesAsync(filePath, (raw) => {
4006
- res.write('event: load_chunk\ndata: [');
4007
- res.write(raw.includes('\n') ? raw.replace(/\n/g, '') : raw);
4008
- res.write(']\n\n');
4009
- });
4010
- res.write(`event: load_end\ndata: {}\n\n`);
4011
- res.end();
4012
- } catch (err) {
4013
- // 如果 headers 未发送,返回 JSON 错误;否则关闭连接
4014
- // 落 stderr 让用户在 ccv 终端能看到 .jsonl.zip 解压失败等具体原因(SSE onerror 在客户端
4015
- // 不携带错误明细,只能从服务端日志反查)
4016
- console.error('[local-log]', file, err && err.stack || err);
4017
- if (!res.headersSent) {
4018
- const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
4019
- res.writeHead(status, { 'Content-Type': 'application/json' });
4020
- res.end(JSON.stringify({ error: err.message }));
4021
- } else {
4022
- res.end();
4023
- }
4024
- }
512
+ // WebSocket 路径不处理,交给 upgrade 事件
513
+ if (url === '/ws/terminal' || url === '/ws/terminal-scratch') {
4025
514
  return;
4026
515
  }
4027
516
 
4028
- // 删除日志文件
4029
- if (url === '/api/delete-logs' && method === 'POST') {
4030
- let body = '';
4031
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
4032
- req.on('end', () => {
4033
- try {
4034
- const { files } = JSON.parse(body);
4035
- if (!Array.isArray(files) || files.length === 0) {
4036
- res.writeHead(400, { 'Content-Type': 'application/json' });
4037
- res.end(JSON.stringify({ error: 'No files specified' }));
4038
- return;
4039
- }
4040
- const results = deleteLogFiles(LOG_DIR, files);
4041
- res.writeHead(200, { 'Content-Type': 'application/json' });
4042
- res.end(JSON.stringify({ results }));
4043
- } catch {
4044
- res.writeHead(400, { 'Content-Type': 'application/json' });
4045
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
4046
- }
4047
- });
517
+ // CORS headers
518
+ res.setHeader('Access-Control-Allow-Origin', '*');
519
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
520
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
521
+
522
+ if (method === 'OPTIONS') {
523
+ res.writeHead(200);
524
+ res.end();
4048
525
  return;
4049
526
  }
4050
527
 
4051
- // 合并日志文件
4052
- if (url === '/api/merge-logs' && method === 'POST') {
4053
- let body = '';
4054
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
4055
- req.on('end', () => {
4056
- try {
4057
- const { files } = JSON.parse(body);
4058
- const merged = mergeLogFiles(LOG_DIR, files);
4059
- res.writeHead(200, { 'Content-Type': 'application/json' });
4060
- res.end(JSON.stringify({ ok: true, merged }));
4061
- } catch (err) {
4062
- const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'INVALID_INPUT' ? 400 : 500;
4063
- res.writeHead(status, { 'Content-Type': 'application/json' });
4064
- res.end(JSON.stringify({ error: err.message }));
4065
- }
4066
- });
528
+ // 局域网访问验证:decideAuth() 统一决策(本地 127.0.0.1/::1 免验、静态资源免验、
529
+ // ?token=/cookie/密码登录三选一)。详见 server/lib/auth.js。
530
+ // 不变式:login-page/unauthorized/forbidden 必须 return;只有 allow 才继续往下进
531
+ // Host allowlist + 路由。
532
+ const remoteIp = req.socket.remoteAddress;
533
+ const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
534
+ const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
535
+ const wantsHtml = method === 'GET' && ((req.headers.accept || '').includes('text/html') || url === '/');
536
+ const authDecision = decideAuth({
537
+ isStaticAsset,
538
+ pathname: url,
539
+ isLocal,
540
+ urlToken: parsedUrl.searchParams.get('token'),
541
+ cookieToken: parseCookies(req.headers.cookie).ccv_auth,
542
+ accessToken: ACCESS_TOKEN,
543
+ enabled: authConfig.enabled,
544
+ password: authConfig.password,
545
+ wantsHtml,
546
+ });
547
+ if (authDecision.action === 'login-page') {
548
+ const lang = localeFromAcceptLanguage(req.headers['accept-language']) || getLang();
549
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
550
+ res.end(renderLoginPage({ lang }));
4067
551
  return;
4068
552
  }
4069
-
4070
- // 压缩归档日志文件:每个 .jsonl 压成同目录同名 .jsonl.zip,删除原文件
4071
- if (url === '/api/archive-logs' && method === 'POST') {
4072
- let body = '';
4073
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
4074
- req.on('end', () => {
4075
- try {
4076
- const { files } = JSON.parse(body);
4077
- if (!Array.isArray(files) || files.length === 0) {
4078
- res.writeHead(400, { 'Content-Type': 'application/json' });
4079
- res.end(JSON.stringify({ error: 'No files specified' }));
4080
- return;
4081
- }
4082
- if (files.length > 50) {
4083
- res.writeHead(400, { 'Content-Type': 'application/json' });
4084
- res.end(JSON.stringify({ error: 'Too many files (max 50 per request)' }));
4085
- return;
4086
- }
4087
- const result = archiveLogFiles(LOG_DIR, files);
4088
- res.writeHead(200, { 'Content-Type': 'application/json' });
4089
- res.end(JSON.stringify(result));
4090
- } catch {
4091
- res.writeHead(400, { 'Content-Type': 'application/json' });
4092
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
4093
- }
4094
- });
553
+ if (authDecision.action === 'unauthorized') {
554
+ res.writeHead(401, { 'Content-Type': 'application/json' });
555
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
4095
556
  return;
4096
557
  }
4097
-
4098
- // GET /api/concept?lang=zh&doc=Tool-Bash
4099
- if (method === 'GET' && url === '/api/concept') {
4100
- const lang = parsedUrl.searchParams.get('lang') || 'zh';
4101
- const doc = parsedUrl.searchParams.get('doc') || '';
4102
- // 安全校验:只允许字母、数字、连字符
4103
- if (!/^[a-zA-Z0-9-]+$/.test(doc) || !/^[a-z]{2}(-[a-zA-Z]{2,})?$/.test(lang)) {
4104
- res.writeHead(400, { 'Content-Type': 'application/json' });
4105
- res.end(JSON.stringify({ error: 'Invalid parameters' }));
4106
- return;
4107
- }
4108
- let mdPath = join(CONCEPTS_DIR, lang, `${doc}.md`);
4109
- if (!existsSync(mdPath) && lang !== 'zh') {
4110
- mdPath = join(CONCEPTS_DIR, 'zh', `${doc}.md`);
4111
- }
4112
- if (existsSync(mdPath)) {
4113
- const content = readFileSync(mdPath, 'utf-8');
4114
- res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
4115
- res.end(content);
4116
- } else {
4117
- res.writeHead(404, { 'Content-Type': 'application/json' });
4118
- res.end(JSON.stringify({ error: 'Not found' }));
4119
- }
558
+ if (authDecision.action === 'forbidden') {
559
+ res.writeHead(403, { 'Content-Type': 'application/json' });
560
+ res.end(JSON.stringify({ error: 'Forbidden: invalid token' }));
4120
561
  return;
4121
562
  }
563
+ // action === 'allow' → 继续
4122
564
 
4123
- // CCV 进程列表
4124
- if (url === '/api/ccv-processes' && method === 'GET') {
4125
- if (platform() === 'win32') {
4126
- res.writeHead(200, { 'Content-Type': 'application/json' });
4127
- res.end(JSON.stringify({ processes: [] }));
4128
- return;
4129
- }
4130
- try {
4131
- const { stdout } = await execAsync('lsof -iTCP:7008-7099 -sTCP:LISTEN -P -n', { timeout: 5000 }).catch(() => ({ stdout: '' }));
4132
- const lines = stdout.trim().split('\n').filter(Boolean);
4133
- // Parse lsof output: skip header, filter node processes, dedupe by PID:port
4134
- const seen = new Map(); // pid -> port
4135
- for (const line of lines.slice(1)) {
4136
- const parts = line.trim().split(/\s+/);
4137
- const cmd = parts[0];
4138
- if (cmd !== 'node') continue;
4139
- const pid = parseInt(parts[1], 10);
4140
- if (!pid) continue;
4141
- // lsof 输出: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME (STATE)
4142
- // 端口在 NAME 列(倒数第二列),如 *:7008,最后一列是 (LISTEN)
4143
- const nameField = parts[parts.length - 2] || '';
4144
- const portMatch = nameField.match(/:(\d+)$/);
4145
- if (!portMatch) continue;
4146
- const port = portMatch[1];
4147
- if (!seen.has(pid)) seen.set(pid, port);
4148
- }
4149
- // 获取所有候选进程的 PPID,过滤掉 PPID 也在 CCV 进程集合中的子进程(即 ccv -c/-d 启动的 claude 子进程)
4150
- const ccvPids = new Set(seen.keys());
4151
- const filteredPids = [];
4152
- for (const [pid] of seen) {
4153
- try {
4154
- const { stdout: ppidOut } = await execAsync(`ps -o ppid= -p ${pid}`, { timeout: 2000 }).catch(() => ({ stdout: '' }));
4155
- const ppid = parseInt(ppidOut.trim(), 10);
4156
- if (ppid && ccvPids.has(ppid)) continue; // 是某个 CCV 进程的子进程,跳过
4157
- } catch {}
4158
- filteredPids.push(pid);
4159
- }
4160
- const processes = [];
4161
- for (const pid of filteredPids) {
4162
- const port = seen.get(pid);
4163
- let startTime = '';
4164
- let command = '';
4165
- try {
4166
- const { stdout: psOut } = await execAsync(`ps -p ${pid} -o lstart=,command=`, { timeout: 3000 }).catch(() => ({ stdout: '' }));
4167
- const psLine = psOut.trim();
4168
- // lstart format: "Day Mon DD HH:MM:SS YYYY rest..."
4169
- const lsMatch = psLine.match(/^\w+\s+(\w+)\s+(\d+)\s+([\d:]+)\s+(\d{4})\s+(.*)/);
4170
- if (lsMatch) {
4171
- const months = { Jan:1,Feb:2,Mar:3,Apr:4,May:5,Jun:6,Jul:7,Aug:8,Sep:9,Oct:10,Nov:11,Dec:12 };
4172
- const mon = String(months[lsMatch[1]] || 1).padStart(2, '0');
4173
- const day = String(lsMatch[2]).padStart(2, '0');
4174
- const time = lsMatch[3];
4175
- const year = lsMatch[4];
4176
- startTime = `${year}年${mon}月${day}日 ${time}`;
4177
- const rawCmd = lsMatch[5];
4178
- // Extract path after lib/ (e.g. node_modules/cc-viewer/cli.js -d → cc-viewer/cli.js -d)
4179
- const libMatch = rawCmd.match(/lib\/(.+)/);
4180
- command = libMatch ? libMatch[1] : rawCmd;
4181
- }
4182
- } catch {}
4183
- const isCurrent = pid === process.pid;
4184
- processes.push({ port, pid, command, startTime, isCurrent });
565
+ // DNS rebinding 防护:即使带了正确 token,Host header 必须落在 allowlist 里。
566
+ // 默认放行 loopback + 本机所有 LAN IPv4(getAllLocalIps()):cc-viewer 核心场景就是手机扫码访问 LAN URL,
567
+ // 要求用户每次手动设 CCV_ALLOWED_HOSTS 不可接受。token 仍是必需(server.js:300-310 ACCESS_TOKEN gate),
568
+ // DNS rebinding 攻击者需精确知道用户 LAN IP 才能利用,门槛降低但不增新攻击面;Vite/Cursor 同行也默认放开 LAN。
569
+ // CCV_ALLOWED_HOSTS 显式设(包括 '*' 关闭防护)时完全沿用用户值,与 1.6.227 行为一致,向后兼容。
570
+ // 静态资源和 OPTIONS 预检不挡。
571
+ if (!isStaticAsset && method !== 'OPTIONS') {
572
+ const allowedHosts = process.env.CCV_ALLOWED_HOSTS
573
+ ? process.env.CCV_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
574
+ : ['localhost', '127.0.0.1', '::1', '[::1]', ...getAllLocalIps()];
575
+ if (!allowedHosts.includes('*')) {
576
+ const hostHeader = (req.headers.host || '').toLowerCase();
577
+ // 端口剥离:RFC 3986 要求 IPv6 Host 必须带 brackets `[::1]:port`,bare `::1` 末尾 `\d` 会被错剥成 `:`。
578
+ // `::` 但无 `]` 闭合的视为 bare IPv6,不剥端口。
579
+ const isBareIPv6 = hostHeader.includes('::') && !hostHeader.includes(']');
580
+ const hostNoPort = isBareIPv6 ? hostHeader : hostHeader.replace(/:\d+$/, '');
581
+ const stripBrackets = hostNoPort.replace(/^\[|\]$/g, '');
582
+ const ok = allowedHosts.some(h => {
583
+ const hl = h.toLowerCase();
584
+ return hl === hostNoPort || hl === stripBrackets || hl === `[${stripBrackets}]`;
585
+ });
586
+ if (!ok) {
587
+ res.writeHead(403, { 'Content-Type': 'application/json' });
588
+ res.end(JSON.stringify({ ok: false, error: 'host-not-allowed', host: hostNoPort }));
589
+ return;
4185
590
  }
4186
- res.writeHead(200, { 'Content-Type': 'application/json' });
4187
- res.end(JSON.stringify({ processes }));
4188
- } catch (err) {
4189
- res.writeHead(500, { 'Content-Type': 'application/json' });
4190
- res.end(JSON.stringify({ error: err.message }));
4191
591
  }
4192
- return;
4193
592
  }
4194
593
 
4195
- // CCV 进程关闭
4196
- if (url === '/api/ccv-processes/kill' && method === 'POST') {
4197
- let body = '';
4198
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
4199
- req.on('end', async () => {
4200
- try {
4201
- const { pid } = JSON.parse(body);
4202
- if (!Number.isInteger(pid) || pid <= 0) {
4203
- res.writeHead(400, { 'Content-Type': 'application/json' });
4204
- res.end(JSON.stringify({ error: 'Invalid PID' }));
4205
- return;
4206
- }
4207
- if (pid === process.pid) {
4208
- res.writeHead(403, { 'Content-Type': 'application/json' });
4209
- res.end(JSON.stringify({ error: 'Cannot kill current process' }));
4210
- return;
4211
- }
4212
- // 安全检查:确认是监听 CCV 端口范围 (7008-7099) 的 node 进程
4213
- const { stdout: lsofOut } = await execAsync(`lsof -iTCP:7008-7099 -sTCP:LISTEN -P -n -p ${pid}`, { timeout: 5000 }).catch(() => ({ stdout: '' }));
4214
- const lsofLines = lsofOut.trim().split('\n').filter(Boolean).slice(1);
4215
- const isNodeOnCcvPort = lsofLines.some(line => line.trim().split(/\s+/)[0] === 'node');
4216
- if (!isNodeOnCcvPort) {
4217
- res.writeHead(403, { 'Content-Type': 'application/json' });
4218
- res.end(JSON.stringify({ error: 'Not a CCV process' }));
4219
- return;
4220
- }
4221
- process.kill(pid, 'SIGTERM');
4222
- res.writeHead(200, { 'Content-Type': 'application/json' });
4223
- res.end(JSON.stringify({ ok: true }));
4224
- } catch (err) {
4225
- res.writeHead(500, { 'Content-Type': 'application/json' });
4226
- res.end(JSON.stringify({ error: err.message }));
4227
- }
594
+ // Plugin hook: intercept HTTP requests (after auth, before routing)
595
+ try {
596
+ const hookResult = await runWaterfallHook('beforeRequest', {
597
+ req, res, url, method, parsedUrl, handled: false,
4228
598
  });
599
+ if (hookResult.handled) return;
600
+ } catch (err) {
601
+ if (!res.headersSent) {
602
+ res.writeHead(500, { 'Content-Type': 'application/json' });
603
+ res.end(JSON.stringify({ error: 'Plugin error' }));
604
+ }
4229
605
  return;
4230
606
  }
4231
607
 
4232
- // Team 运行时状态检测(fs-only:目录存在性 + inbox mtime)
4233
- if (url === '/api/team-status' && method === 'POST') {
4234
- let body = '';
4235
- req.on('data', chunk => { body += chunk; if (body.length > MAX_POST_BODY) req.destroy(); });
4236
- req.on('end', async () => {
4237
- let parsed;
4238
- try {
4239
- parsed = JSON.parse(body || '{}');
4240
- } catch {
4241
- // 固定文案避免把 JSON.parse 的原始 err.message 回显给客户端
4242
- res.writeHead(400, { 'Content-Type': 'application/json' });
4243
- res.end(JSON.stringify({ error: 'invalid_json' }));
4244
- return;
4245
- }
4246
- try {
4247
- const result = await buildTeamStatusResponse(parsed);
4248
- res.writeHead(200, { 'Content-Type': 'application/json' });
4249
- res.end(JSON.stringify(result));
4250
- } catch (err) {
4251
- res.writeHead(500, { 'Content-Type': 'application/json' });
4252
- res.end(JSON.stringify({ error: err.message }));
4253
- }
4254
- });
4255
- return;
4256
- }
608
+ // Per-domain routes (server/routes/*). Unmatched requests fall through to
609
+ // static-file serving and the API/SPA 404 below.
610
+ if (await dispatch(req, res, parsedUrl, isLocal, deps)) return;
4257
611
 
4258
612
  // 静态文件服务
4259
613
  if (method === 'GET') {
@@ -4435,6 +789,10 @@ export async function startViewer() {
4435
789
  for (const _ip of _ips) {
4436
790
  console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, token: ACCESS_TOKEN }));
4437
791
  }
792
+ if (authConfig.enabled) {
793
+ if (authConfig.password === '') console.error(t('server.passwordEmptyWarn'));
794
+ else console.error(t('server.passwordActive', { password: authConfig.password }));
795
+ }
4438
796
  }
4439
797
  // v2.0.69 之前的版本会清空控制台,自动打开浏览器确保用户能看到界面
4440
798
  try {
@@ -4578,14 +936,34 @@ async function setupTerminalWebSocket(httpServer) {
4578
936
  };
4579
937
 
4580
938
  httpServer.on('upgrade', (req, socket, head) => {
4581
- const pathname = new URL(req.url, `${serverProtocol}://${req.headers.host}`).pathname;
939
+ const wsUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
940
+ const pathname = wsUrl.pathname;
941
+ // 与 HTTP 一致的鉴权(此前 WS upgrade 完全不校验 token,远程终端实为无门禁——本次堵洞)。
942
+ // 在此显式计算 isLocal(与 handleRequest 同款三态判断),WS 视作非 HTML 请求。
943
+ const wsRemoteIp = req.socket.remoteAddress;
944
+ const wsIsLocal = wsRemoteIp === '127.0.0.1' || wsRemoteIp === '::1' || wsRemoteIp === '::ffff:127.0.0.1';
945
+ const wsAuth = decideAuth({
946
+ isStaticAsset: false,
947
+ pathname,
948
+ isLocal: wsIsLocal,
949
+ urlToken: wsUrl.searchParams.get('token'),
950
+ cookieToken: parseCookies(req.headers.cookie).ccv_auth,
951
+ accessToken: ACCESS_TOKEN,
952
+ enabled: authConfig.enabled,
953
+ password: authConfig.password,
954
+ wantsHtml: false,
955
+ });
956
+ if (wsAuth.action !== 'allow') {
957
+ socket.destroy();
958
+ return;
959
+ }
4582
960
  if (pathname === '/ws/terminal') {
4583
961
  wss.handleUpgrade(req, socket, head, (ws) => {
4584
962
  wss.emit('connection', ws, req);
4585
963
  });
4586
964
  } else if (pathname === '/ws/terminal-scratch') {
4587
965
  // 校验 id:缺失或非法 → destroy(避免 Map<id> 被注入空键 / 超长 / 特殊字符)
4588
- const scratchId = new URL(req.url, `${serverProtocol}://${req.headers.host}`).searchParams.get('id');
966
+ const scratchId = wsUrl.searchParams.get('id');
4589
967
  if (!scratchId || !SCRATCH_ID_RE.test(scratchId)) {
4590
968
  socket.destroy();
4591
969
  return;
@@ -5107,6 +1485,13 @@ export function getInternalToken() {
5107
1485
  return INTERNAL_TOKEN;
5108
1486
  }
5109
1487
 
1488
+ // Effective password-auth config for this server's project (enabled + plaintext password).
1489
+ // Used by cli.js to print the active password at CLI-mode startup (the non-CLI startup log
1490
+ // can't run in CLI mode). Plaintext is fine: this stays in-process (admin terminal).
1491
+ export function getAuthConfig() {
1492
+ return authConfig;
1493
+ }
1494
+
5110
1495
  // In-process broadcast helper for the `turn_end` SSE event. Two callers:
5111
1496
  // 1. /api/turn-end-notify POST handler (PTY/CLI mode via Stop hook bridge)
5112
1497
  // 2. cli.js runSdkMode → sdkManager.initSdkSession({ onTurnEnd }) (SDK mode —
@@ -5327,7 +1712,9 @@ async function _doStop() {
5327
1712
  unwatchFile(CONTEXT_WINDOW_FILE);
5328
1713
  getWatchedFiles().clear();
5329
1714
  clients.forEach(client => client.end());
5330
- clients = [];
1715
+ // Truncate in place (not `clients = []`) so the array reference stays stable across
1716
+ // stop/start cycles — deps.clients and _logWatcherOpts() hold this same reference.
1717
+ clients.length = 0;
5331
1718
  if (server) {
5332
1719
  // 销毁所有活跃连接,防止 keep-alive 阻止进程退出
5333
1720
  server.closeAllConnections();