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
@@ -0,0 +1,452 @@
1
+ // Ask / permission hook bridge routes (moved verbatim from server.js handleRequest).
2
+ import { loadAskStore, consumeIfFinal as askStoreConsumeIfFinal } from '../lib/ask-store.js';
3
+ import { runWaterfallHook, runParallelHook } from '../lib/plugin-loader.js';
4
+ import { sendEventToClients } from '../lib/log-watcher.js';
5
+
6
+ function pendingAsks(req, res, parsedUrl, isLocal, deps) {
7
+ try {
8
+ // 优先内存 Map(含活跃 res / timer 的真实状态),用 createdAt 排序;
9
+ // 再 union disk(hydrate 已死 entry 或本进程未持有的孤儿 entry,用于恢复 UI 列表,
10
+ // 答案投递仍由 WS ask-hook-answer 路径走 server 端 first-write-wins)。
11
+ const memEntries = [...deps.pendingAskHooks.entries()].map(([id, e]) => ({
12
+ id, questions: e.questions, createdAt: e.createdAt, source: 'memory',
13
+ }));
14
+ const memIds = new Set(memEntries.map(e => e.id));
15
+ const diskAll = loadAskStore();
16
+ // 只暴露 status === 'pending' 的 disk entry —— markAnswered 创建的 questions=[] 终态占位
17
+ // 也会被 loadAskStore 返回,但它不是真"待回答",前端 inject 会渲染空 ghost ask。
18
+ // 另外过滤 questions 数组非空(防御性)。
19
+ const diskOnly = Object.values(diskAll)
20
+ .filter(e => !memIds.has(e.id) && e.status === 'pending' && Array.isArray(e.questions) && e.questions.length > 0)
21
+ .map(e => ({ id: e.id, questions: e.questions, createdAt: e.createdAt, source: 'disk' }));
22
+ const all = [...memEntries, ...diskOnly].sort((a, b) => a.createdAt - b.createdAt);
23
+ res.writeHead(200, { 'Content-Type': 'application/json' });
24
+ res.end(JSON.stringify({ pendingAsks: all, askProtocolVersion: 1 }));
25
+ } catch (err) {
26
+ res.writeHead(500, { 'Content-Type': 'application/json' });
27
+ res.end(JSON.stringify({ error: 'failed to read pending asks', detail: String(err?.message || err) }));
28
+ }
29
+ }
30
+
31
+ // Ask hook bridge: long-poll endpoint for PreToolUse AskUserQuestion hook
32
+ function askHook(req, res, parsedUrl, isLocal, deps) {
33
+ let body = '';
34
+ let bodyTooLarge = false;
35
+ req.on('data', (chunk) => {
36
+ body += chunk;
37
+ if (body.length > 1000000) { // 1MB limit (questions may contain large previews)
38
+ bodyTooLarge = true;
39
+ req.destroy();
40
+ }
41
+ });
42
+ // Phase 3: ask-bridge 用 `X-Ask-Poll-Mode: short` 头声明走短轮询。新 server 看到后
43
+ // 立即返 { id, capability: 'short-poll' }(不挂 res),client 之后 GET /api/ask-hook/:id/result 取答案。
44
+ // 老 server 不识别此头 → 按长轮询走(仍挂 res 直到答案/24h 超时)→ 完全向后兼容。
45
+ const shortPollMode = (req.headers['x-ask-poll-mode'] || '').toLowerCase() === 'short';
46
+ req.on('end', async () => {
47
+ if (bodyTooLarge) {
48
+ try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
49
+ return;
50
+ }
51
+ try {
52
+ const { questions, toolUseId } = JSON.parse(body);
53
+ if (!Array.isArray(questions) || questions.length === 0) {
54
+ res.writeHead(400, { 'Content-Type': 'application/json' });
55
+ res.end(JSON.stringify({ error: 'Missing questions' }));
56
+ return;
57
+ }
58
+ // 镜像 ask-bridge 的 normalize:覆盖 plugin/SDK 等非 ask-bridge 的 client。
59
+ // 缺失 description 补 "",前端按 hasOptionDescription 判断走非渲染分支。
60
+ for (const q of questions) {
61
+ if (!q || typeof q !== 'object' || !Array.isArray(q.options)) continue;
62
+ for (const opt of q.options) {
63
+ if (opt && typeof opt === 'object' && opt.description === undefined) {
64
+ opt.description = '';
65
+ }
66
+ }
67
+ }
68
+
69
+ // Evict oldest if Map is full (prevent memory leak from pathological concurrency).
70
+ // Mirrors perm-hook eviction; an evicted bridge process gets HTTP 429 and falls back
71
+ // to its own terminal UI rather than leaking long-poll state.
72
+ if (deps.pendingAskHooks.size >= deps.ASK_HOOK_MAP_MAX) {
73
+ const oldestId = deps.pendingAskHooks.keys().next().value;
74
+ const oldest = deps.pendingAskHooks.get(oldestId);
75
+ if (oldest) {
76
+ clearTimeout(oldest.timer);
77
+ try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
78
+ deps.pendingAskHooks.delete(oldestId);
79
+ deps.persistAskDelete(oldestId);
80
+ if (deps.terminalWss) {
81
+ const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id: oldestId });
82
+ deps.terminalWss.clients.forEach((c) => {
83
+ if (c.readyState === 1) try { c.send(tmsg); } catch {}
84
+ });
85
+ }
86
+ deps.notifyParentPending({ type: 'ask-hook-timeout', id: oldestId });
87
+ }
88
+ }
89
+
90
+ // 实质等同"无超时",仍保留兜底防 entry 泄漏(plugin throw / OOM 等异常路径)。
91
+ // ask-bridge 不设客户端 timeout,由 server 单向控制 response 终止。
92
+ const HOOK_TIMEOUT = deps.ASK_HOOK_TIMEOUT_MS;
93
+ // toolUseId 路由策略:
94
+ // - char whitelist + ≤256 长度 防恶意 1MB key 撑大 Map
95
+ // - 已存在同 id 但旧 res 已断(writableEnded/destroyed)→ 复用槽位(ask-bridge 重试场景)
96
+ // - 已存在同 id 且旧 res 活 → 返 409 Conflict(真重复)而非走 fallback id 让前端 portal 失效
97
+ // - 缺 toolUseId 或非法 → fallback 自生成 id
98
+ const wantsToolUseId = toolUseId && typeof toolUseId === 'string'
99
+ && toolUseId.length > 0 && toolUseId.length <= 256
100
+ && /^[a-zA-Z0-9_-]+$/.test(toolUseId);
101
+ let id;
102
+ if (wantsToolUseId) {
103
+ const existing = deps.pendingAskHooks.get(toolUseId);
104
+ if (!existing) {
105
+ id = toolUseId;
106
+ } else if (!existing.res || existing.res.writableEnded || existing.res.destroyed) {
107
+ // 旧 res 已断(包含占位 res:null 残留)— ask-bridge 重试同 toolUseId 合理,复用槽位
108
+ if (existing.timer) clearTimeout(existing.timer);
109
+ deps.pendingAskHooks.delete(toolUseId);
110
+ deps.persistAskDelete(toolUseId);
111
+ id = toolUseId;
112
+ } else {
113
+ res.writeHead(409, { 'Content-Type': 'application/json' });
114
+ res.end(JSON.stringify({ error: 'Duplicate toolUseId, previous request still pending' }));
115
+ return;
116
+ }
117
+ } else {
118
+ // Fallback id:当 hook caller 没传 toolUseId(如老 Claude Code PreToolUse hook
119
+ // payload 不含 tool_use_id),生成 ask_${ts}_${rnd} 占位。这个 id 与 jsonl 里
120
+ // tool_use.id(toolu_xxx)不同名,前端 portal 决策必须按 ask_* 前缀通配命中。
121
+ // 协议锚点:src/utils/askPortalMatcher.js — 改此处前缀格式必须同步改 matcher。
122
+ do { id = `ask_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } while (deps.pendingAskHooks.has(id));
123
+ }
124
+
125
+ // TOCTOU 防御:占位 id 之前先注册 res.on('close'),否则 await runWaterfallHook 期间 client
126
+ // abort 的 close 事件落空 → entry 残 5min。占位 set 提前到 await 之前防两条同 ms 并发
127
+ // POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 5min)。
128
+ const _placeholderEntry = { questions, res, timer: null, createdAt: Date.now(), shortPoll: shortPollMode };
129
+ deps.pendingAskHooks.set(id, _placeholderEntry);
130
+ deps.persistAskEntry(id, _placeholderEntry);
131
+
132
+ // res.on('close') 提前注册:handler 用 entry.timer 守卫(占位期 timer:null → 仅 delete Map)
133
+ // Phase 3: short-poll 模式下 res 主动 end,close 不视为 client cancel;entry 由 24h timer 兜底清理。
134
+ res.on('close', () => {
135
+ const entry = deps.pendingAskHooks.get(id);
136
+ if (entry) {
137
+ if (entry.shortPoll) return;
138
+ if (entry.timer) clearTimeout(entry.timer);
139
+ deps.pendingAskHooks.delete(id);
140
+ deps.persistAskDelete(id);
141
+ if (deps.terminalWss) {
142
+ const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
143
+ deps.terminalWss.clients.forEach((c) => {
144
+ if (c.readyState === 1) try { c.send(tmsg); } catch {}
145
+ });
146
+ }
147
+ deps.notifyParentPending({ type: 'ask-hook-timeout', id });
148
+ }
149
+ });
150
+
151
+ // Plugin hook: let plugins answer questions directly
152
+ try {
153
+ const hookResult = await runWaterfallHook('onAskRequest', { id, questions, mode: 'hook' });
154
+ if (hookResult.answers) {
155
+ deps.pendingAskHooks.delete(id); // 释放占位 — plugin 直接答了
156
+ deps.persistAskDelete(id);
157
+ // race: plugin 执行期间 client 主动断开 → res.on('close') 已清 entry,
158
+ // 但本闭包仍持着 res 引用;guard 防 writeHead 抛 ERR_STREAM_WRITE_AFTER_END
159
+ if (!res.writableEnded && !res.destroyed && !res.headersSent) {
160
+ try {
161
+ res.writeHead(200, { 'Content-Type': 'application/json' });
162
+ res.end(JSON.stringify({ answers: hookResult.answers }));
163
+ } catch {}
164
+ }
165
+ return;
166
+ }
167
+ } catch {}
168
+
169
+ const timer = setTimeout(() => {
170
+ const entry = deps.pendingAskHooks.get(id);
171
+ if (entry) {
172
+ deps.pendingAskHooks.delete(id);
173
+ deps.persistAskDelete(id);
174
+ try {
175
+ // null guard:plugin throw + outer body-parse race 极端下占位残留时 fire 防 TypeError
176
+ if (entry.res && !entry.res.headersSent) {
177
+ entry.res.writeHead(408, { 'Content-Type': 'application/json' });
178
+ entry.res.end(JSON.stringify({ error: 'Timeout' }));
179
+ }
180
+ } catch {}
181
+ if (deps.terminalWss) {
182
+ const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
183
+ deps.terminalWss.clients.forEach((c) => {
184
+ if (c.readyState === 1) try { c.send(tmsg); } catch {}
185
+ });
186
+ }
187
+ deps.notifyParentPending({ type: 'ask-hook-timeout', id });
188
+ }
189
+ }, HOOK_TIMEOUT);
190
+
191
+ const askStartedAt = Date.now();
192
+ const _liveEntry = { questions, res, timer, createdAt: askStartedAt, shortPoll: shortPollMode };
193
+ deps.pendingAskHooks.set(id, _liveEntry);
194
+ deps.persistAskEntry(id, _liveEntry);
195
+
196
+ // Phase 3: short-poll 模式立即返 ack,让 client 改走 GET 轮询;entry 留在内存等 ws answer。
197
+ if (shortPollMode) {
198
+ try {
199
+ if (!res.headersSent) {
200
+ res.writeHead(200, { 'Content-Type': 'application/json' });
201
+ res.end(JSON.stringify({ id, capability: 'short-poll' }));
202
+ }
203
+ } catch {}
204
+ }
205
+
206
+ // Broadcast to all terminal WS clients — 附 startedAt + timeoutMs 让前端渲染倒计时
207
+ if (deps.terminalWss) {
208
+ const pmsg = JSON.stringify({ type: 'ask-hook-pending', id, questions, startedAt: askStartedAt, timeoutMs: HOOK_TIMEOUT });
209
+ deps.terminalWss.clients.forEach((client) => {
210
+ if (client.readyState === 1) {
211
+ try { client.send(pmsg); } catch {}
212
+ }
213
+ });
214
+ }
215
+ deps.notifyParentPending({ type: 'ask-hook-pending', id, questions });
216
+ } catch {
217
+ res.writeHead(400, { 'Content-Type': 'application/json' });
218
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
219
+ }
220
+ });
221
+ }
222
+
223
+ // Phase 3: short-poll handoff endpoint. ask-bridge GET /api/ask-hook/:id/result?wait=30000
224
+ // 在 wait ms 内若答案/cancel 到达 → 立即返;否则返 204 让 client 重发。
225
+ // 内存有 entry → 注册 listener;内存无 → 查 disk consume(server 重启场景)。
226
+ function askHookResult(req, res, parsedUrl, isLocal, deps) {
227
+ const url = parsedUrl.pathname;
228
+ try {
229
+ // URL 形如 /api/ask-hook/<id>/result?wait=30000;id 受白名单约束(与 POST 同源)
230
+ const m = url.match(/^\/api\/ask-hook\/([^/?]+)\/result(?:\?(.*))?$/);
231
+ if (!m) { res.writeHead(400); res.end(); return; }
232
+ const id = decodeURIComponent(m[1]);
233
+ if (!id || id.length > 256 || !/^[a-zA-Z0-9_-]+$/.test(id)) {
234
+ res.writeHead(400, { 'Content-Type': 'application/json' });
235
+ res.end(JSON.stringify({ error: 'invalid id' }));
236
+ return;
237
+ }
238
+ const qs = new URLSearchParams(m[2] || '');
239
+ const wait = Math.max(1000, Math.min(60000, parseInt(qs.get('wait') || '30000', 10)));
240
+
241
+ // 1) disk 命中 answered/cancelled:浏览器答得早过 GET 到达 → 立即返并消费(一次性)
242
+ // 用 consumeIfFinal 单次 withLock 内判 status 决定是否 delete —— 旧设计的
243
+ // "consume + 若 pending 再 setEntry 写回" 两段是 race window:中间被 markAnswered 命中后,
244
+ // setEntry 走 status guard 已经不会覆盖;但不删的 pending 也无须重写一遍。
245
+ const diskEntry = askStoreConsumeIfFinal(id);
246
+ if (diskEntry && diskEntry.status === 'answered') {
247
+ res.writeHead(200, { 'Content-Type': 'application/json' });
248
+ res.end(JSON.stringify({ answers: diskEntry.answers || {} }));
249
+ return;
250
+ }
251
+ if (diskEntry && diskEntry.status === 'cancelled') {
252
+ res.writeHead(200, { 'Content-Type': 'application/json' });
253
+ res.end(JSON.stringify({ cancelled: true, reason: diskEntry.cancelReason || '' }));
254
+ return;
255
+ }
256
+ // diskEntry.status === 'pending' → consumeIfFinal 已保留它,无须重写
257
+
258
+ // 2) entry 仍 pending:注册 listener 等 wait ms。内存或 disk 至少一个有就允许注册——
259
+ // server 重启后 disk 是 pending,但内存无 entry,仍要让 GET 挂等(浏览器答案到达时
260
+ // 会走 ws ask-hook-answer handler 路径,handler 内 markAnswered 落 disk + _notifyShortPollAnswer 直推 listener)。
261
+ const memEntry = deps.pendingAskHooks.get(id);
262
+ if (!memEntry && !diskEntry) {
263
+ res.writeHead(404, { 'Content-Type': 'application/json' });
264
+ res.end(JSON.stringify({ error: 'no such ask' }));
265
+ return;
266
+ }
267
+ const listener = { res, finished: false, tid: null };
268
+ if (!deps.shortPollListeners.has(id)) deps.shortPollListeners.set(id, new Set());
269
+ deps.shortPollListeners.get(id).add(listener);
270
+ listener.tid = setTimeout(() => {
271
+ if (listener.finished) return;
272
+ listener.finished = true;
273
+ deps.shortPollListeners.get(id)?.delete(listener);
274
+ try { if (!res.headersSent) { res.writeHead(204); res.end(); } } catch {}
275
+ }, wait);
276
+ res.on('close', () => {
277
+ if (listener.finished) return;
278
+ listener.finished = true;
279
+ clearTimeout(listener.tid);
280
+ deps.shortPollListeners.get(id)?.delete(listener);
281
+ });
282
+ } catch (err) {
283
+ try {
284
+ if (!res.headersSent) {
285
+ res.writeHead(500, { 'Content-Type': 'application/json' });
286
+ res.end(JSON.stringify({ error: String(err?.message || err) }));
287
+ }
288
+ } catch {}
289
+ }
290
+ }
291
+
292
+ // Permission hook bridge: receive tool permission request from perm-bridge.js, long-poll for user decision
293
+ function permHook(req, res, parsedUrl, isLocal, deps) {
294
+ let body = '';
295
+ req.on('data', (chunk) => {
296
+ body += chunk;
297
+ if (body.length > 1000000) {
298
+ res.writeHead(413, { 'Content-Type': 'application/json' });
299
+ res.end(JSON.stringify({ error: 'Request body too large' }));
300
+ return;
301
+ }
302
+ });
303
+ req.on('end', async () => {
304
+ try {
305
+ const { toolName, input } = JSON.parse(body);
306
+ if (!toolName) {
307
+ res.writeHead(400, { 'Content-Type': 'application/json' });
308
+ res.end(JSON.stringify({ error: 'Missing toolName' }));
309
+ return;
310
+ }
311
+
312
+ // Evict oldest if Map is full (prevent memory leak from pathological concurrency)
313
+ if (deps.pendingPermHooks.size >= deps.PERM_HOOK_MAP_MAX) {
314
+ const oldestId = deps.pendingPermHooks.keys().next().value;
315
+ const oldest = deps.pendingPermHooks.get(oldestId);
316
+ if (oldest) {
317
+ clearTimeout(oldest.timer);
318
+ try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
319
+ deps.pendingPermHooks.delete(oldestId);
320
+ }
321
+ }
322
+
323
+ const PERM_HOOK_TIMEOUT = 5 * 60 * 1000;
324
+ const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
325
+
326
+ // Plugin hook: let plugins handle permission requests directly.
327
+ // 与 sdk-manager.js:401-412 对齐:严格白名单 'allow'|'deny',未知值 fall-through 到常规审批。
328
+ // 早期 truthy-check 会把 plugin 返回的任意字符串原样转发到 perm-bridge(再被 coerce 为 'deny'),
329
+ // 既违反 cb2326e 声明的 fail-safe 语义,又让 SDK 与 bridge 两路径行为不对称。
330
+ try {
331
+ const hookResult = await runWaterfallHook('onPermRequest', { id, toolName, input, mode: 'hook' });
332
+ if (hookResult.decision === 'allow' || hookResult.decision === 'deny') {
333
+ res.writeHead(200, { 'Content-Type': 'application/json' });
334
+ res.end(JSON.stringify({ decision: hookResult.decision }));
335
+ return;
336
+ }
337
+ } catch {}
338
+
339
+ const timer = setTimeout(() => {
340
+ const entry = deps.pendingPermHooks.get(id);
341
+ if (entry) {
342
+ deps.pendingPermHooks.delete(id);
343
+ try {
344
+ if (!entry.res.headersSent) {
345
+ entry.res.writeHead(408, { 'Content-Type': 'application/json' });
346
+ entry.res.end(JSON.stringify({ error: 'Timeout' }));
347
+ }
348
+ } catch {}
349
+ if (deps.terminalWss) {
350
+ const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });
351
+ deps.terminalWss.clients.forEach((c) => {
352
+ if (c.readyState === 1) try { c.send(tmsg); } catch {}
353
+ });
354
+ }
355
+ }
356
+ }, PERM_HOOK_TIMEOUT);
357
+
358
+ deps.pendingPermHooks.set(id, { toolName, input, res, timer, createdAt: Date.now() });
359
+
360
+ // Broadcast to all terminal WS clients
361
+ if (deps.terminalWss) {
362
+ const pmsg = JSON.stringify({ type: 'perm-hook-pending', id, toolName, input });
363
+ deps.terminalWss.clients.forEach((client) => {
364
+ if (client.readyState === 1) {
365
+ try { client.send(pmsg); } catch {}
366
+ }
367
+ });
368
+ }
369
+
370
+ // Handle perm-bridge.js disconnection
371
+ res.on('close', () => {
372
+ const entry = deps.pendingPermHooks.get(id);
373
+ if (entry) {
374
+ clearTimeout(entry.timer);
375
+ deps.pendingPermHooks.delete(id);
376
+ if (deps.terminalWss) {
377
+ const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });
378
+ deps.terminalWss.clients.forEach((c) => {
379
+ if (c.readyState === 1) try { c.send(tmsg); } catch {}
380
+ });
381
+ }
382
+ }
383
+ });
384
+ } catch {
385
+ res.writeHead(400, { 'Content-Type': 'application/json' });
386
+ res.end(JSON.stringify({ error: 'Invalid request body' }));
387
+ }
388
+ });
389
+ }
390
+
391
+ // 流式 chunk 接收端点:interceptor 在 SSE 流过程中推送的 partial entry
392
+ // 仅广播,不落盘。前端按 timestamp|url 自动与最终 entry 去重覆盖。
393
+ // 鉴权:server 绑 0.0.0.0 允许同机任意进程访问,必须校验 remote 必须是 loopback
394
+ // 且请求带 x-cc-viewer-internal: 1 header,防止同机其他进程伪造 SSE 内容注入广播。
395
+ function streamChunk(req, res, parsedUrl, isLocal, deps) {
396
+ const remote = req.socket.remoteAddress || '';
397
+ const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
398
+ const internalHeader = req.headers['x-cc-viewer-internal'] === '1';
399
+ if (!isLoopback || !internalHeader) {
400
+ try { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Forbidden' })); } catch {}
401
+ return;
402
+ }
403
+ let body = '';
404
+ let aborted = false;
405
+ req.on('data', (chunk) => {
406
+ if (aborted) return;
407
+ body += chunk;
408
+ if (body.length > 8 * 1024 * 1024) {
409
+ aborted = true;
410
+ try { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Payload too large' })); } catch {}
411
+ }
412
+ });
413
+ req.on('end', () => {
414
+ if (aborted) return;
415
+ try {
416
+ const entry = JSON.parse(body);
417
+ const key = `${entry.timestamp}|${entry.url}`;
418
+ const seq = typeof entry._chunkSeq === 'number' ? entry._chunkSeq : 0;
419
+ const lastSeq = deps.liveStreamLastSeq.get(key);
420
+ if (lastSeq !== undefined && seq < lastSeq) {
421
+ // 乱序到达的旧 chunk 丢弃
422
+ try { res.writeHead(204); res.end(); } catch {}
423
+ return;
424
+ }
425
+ deps.liveStreamLastSeq.set(key, seq);
426
+ // 清理 seq 记录:超过 200 条时 FIFO 驱逐最早的 100 条(Map 保持插入顺序)
427
+ if (deps.liveStreamLastSeq.size > 200) {
428
+ const keys = Array.from(deps.liveStreamLastSeq.keys()).slice(0, 100);
429
+ for (const k of keys) deps.liveStreamLastSeq.delete(k);
430
+ }
431
+ // 用 named event 'stream-progress' 避免混入 data: 流与 dedup 冲突
432
+ // 精简 payload:前端只需要 timestamp/url/content 渲染 Live overlay
433
+ const _streamChunkPayload = {
434
+ timestamp: entry.timestamp,
435
+ url: entry.url,
436
+ content: entry.response?.body?.content || [],
437
+ model: entry.body?.model,
438
+ };
439
+ sendEventToClients(deps.clients, 'stream-progress', _streamChunkPayload);
440
+ runParallelHook('onStreamChunk', _streamChunkPayload);
441
+ } catch {}
442
+ try { res.writeHead(204); res.end(); } catch {}
443
+ });
444
+ }
445
+
446
+ export const askPermRoutes = [
447
+ { method: 'GET', match: 'exact', path: '/api/pending-asks', handler: pendingAsks },
448
+ { method: 'POST', match: 'exact', path: '/api/ask-hook', handler: askHook },
449
+ { predicate: (url, method) => url.startsWith('/api/ask-hook/') && url.includes('/result') && method === 'GET', handler: askHookResult },
450
+ { method: 'POST', match: 'exact', path: '/api/perm-hook', handler: permHook },
451
+ { method: 'POST', match: 'exact', path: '/api/stream-chunk', handler: streamChunk },
452
+ ];
@@ -0,0 +1,166 @@
1
+ // Password-auth API routes. See server/lib/auth.js for the underlying logic and
2
+ // the plan for the overall design.
3
+ //
4
+ // GET /api/auth/state — public; reveals the plaintext password only to admin (isLocal).
5
+ // POST /api/auth/config — admin-only (!isLocal → 403); enable/disable + set password.
6
+ // POST /api/auth/login — public + IP rate-limited; on success Set-Cookie ccv_auth=ACCESS_TOKEN.
7
+ import { createHash, timingSafeEqual } from 'node:crypto';
8
+ import { generatePassword } from '../lib/auth.js';
9
+
10
+ const JSON_HEADERS = { 'Content-Type': 'application/json' };
11
+
12
+ // Constant-time, case-INSENSITIVE password comparison. Both sides are upper-cased
13
+ // (the canonical display form) before hashing, so a remote user typing lowercase on
14
+ // a phone still matches. Hashing to a fixed 32-byte digest sidesteps length leakage
15
+ // and timingSafeEqual's equal-length requirement.
16
+ function passwordMatches(input, expected) {
17
+ const a = createHash('sha256').update(String(input).toUpperCase()).digest();
18
+ const b = createHash('sha256').update(String(expected).toUpperCase()).digest();
19
+ return timingSafeEqual(a, b);
20
+ }
21
+
22
+ function readBody(req, deps, cb) {
23
+ let body = '';
24
+ req.on('data', (chunk) => {
25
+ body += chunk;
26
+ if (body.length > deps.MAX_POST_BODY) req.destroy();
27
+ });
28
+ req.on('end', () => cb(body));
29
+ }
30
+
31
+ function buildState(deps, isLocal) {
32
+ // Reports the EFFECTIVE config (gate enforces this) plus enough scope info for the
33
+ // admin UI to manage both the global default and a per-project override.
34
+ // Passwords (effective + global) are only revealed to the admin (local).
35
+ const s = deps.getAuthState();
36
+ return {
37
+ enabled: s.effective.enabled,
38
+ isAdmin: isLocal,
39
+ password: isLocal ? s.effective.password : null,
40
+ scope: s.scope, // 'project' | 'global' — which one is in effect
41
+ hasProjectOverride: s.hasProjectOverride,
42
+ projectDir: s.projectDir, // null when not project-scoped (non-CLI mode)
43
+ global: { enabled: s.global.enabled, password: isLocal ? s.global.password : null },
44
+ };
45
+ }
46
+
47
+ // ─── Login rate limiting (per source IP, in-memory) ───
48
+ // Keyed on req.socket.remoteAddress (X-Forwarded-For is never parsed, so it can't
49
+ // be spoofed over LAN). 20/60s is generous enough for NAT'd households yet trivial
50
+ // against a 6-char A-Z0-9 space (36^6 ≈ 2.2e9; case-insensitive doesn't shrink it since
51
+ // the charset is uppercase-only). The Map is bounded to avoid growth.
52
+ const RATE_WINDOW_MS = 60_000;
53
+ const RATE_MAX = 20;
54
+ const RATE_MAP_MAX = 1000;
55
+ const loginAttempts = new Map(); // ip -> { count, windowStart }
56
+
57
+ // Only *failed* logins are counted (recordFailedAttempt), so a NAT'd household
58
+ // doing many correct logins is never locked out — the limiter only throttles
59
+ // brute-force guessing. isRateLimited is a pure read (no increment).
60
+ function isRateLimited(ip) {
61
+ const rec = loginAttempts.get(ip);
62
+ return !!rec && Date.now() - rec.windowStart <= RATE_WINDOW_MS && rec.count > RATE_MAX;
63
+ }
64
+
65
+ function recordFailedAttempt(ip) {
66
+ const now = Date.now();
67
+ let rec = loginAttempts.get(ip);
68
+ if (!rec || now - rec.windowStart > RATE_WINDOW_MS) {
69
+ rec = { count: 0, windowStart: now };
70
+ loginAttempts.set(ip, rec);
71
+ }
72
+ rec.count++;
73
+ if (loginAttempts.size > RATE_MAP_MAX) {
74
+ for (const [k, v] of loginAttempts) {
75
+ if (now - v.windowStart > RATE_WINDOW_MS) loginAttempts.delete(k);
76
+ if (loginAttempts.size <= RATE_MAP_MAX) break;
77
+ }
78
+ }
79
+ }
80
+
81
+ function authState(req, res, parsedUrl, isLocal, deps) {
82
+ res.writeHead(200, JSON_HEADERS);
83
+ res.end(JSON.stringify(buildState(deps, isLocal)));
84
+ }
85
+
86
+ function authConfigPost(req, res, parsedUrl, isLocal, deps) {
87
+ if (!isLocal) {
88
+ res.writeHead(403, JSON_HEADERS);
89
+ res.end(JSON.stringify({ error: 'admin-only' }));
90
+ return;
91
+ }
92
+ readBody(req, deps, (body) => {
93
+ let incoming;
94
+ try {
95
+ incoming = JSON.parse(body);
96
+ } catch {
97
+ res.writeHead(400, JSON_HEADERS);
98
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
99
+ return;
100
+ }
101
+ const state = deps.getAuthState();
102
+ // clearOverride → drop this project's override so it inherits the global default.
103
+ if (incoming.clearOverride === true) {
104
+ deps.clearAuthOverride();
105
+ res.writeHead(200, JSON_HEADERS);
106
+ res.end(JSON.stringify(buildState(deps, isLocal)));
107
+ return;
108
+ }
109
+ // Target scope: explicit 'global' → global; otherwise project when this server has a
110
+ // project (else global). The server coerces project→global when no project exists.
111
+ const scope = incoming.scope === 'global' || !state.projectDir ? 'global' : 'project';
112
+ // Start from the target scope's CURRENT config (NOT the effective one): editing the
113
+ // global default while a project override is active must not read the override.
114
+ const cur = scope === 'global'
115
+ ? state.global
116
+ : (state.hasProjectOverride ? state.effective : { enabled: false, password: '' });
117
+ const next = { enabled: cur.enabled, password: cur.password };
118
+ if (typeof incoming.enabled === 'boolean') next.enabled = incoming.enabled;
119
+ const passwordProvided = typeof incoming.password === 'string';
120
+ if (passwordProvided) next.password = incoming.password;
121
+ // Enabling with no existing password and none explicitly provided → auto-generate.
122
+ // An explicit '' is respected (admin chose "no protection").
123
+ if (next.enabled && next.password === '' && !passwordProvided) {
124
+ next.password = generatePassword();
125
+ }
126
+ deps.setAuthConfig(next, scope);
127
+ res.writeHead(200, JSON_HEADERS);
128
+ res.end(JSON.stringify(buildState(deps, isLocal)));
129
+ });
130
+ }
131
+
132
+ function authLogin(req, res, parsedUrl, isLocal, deps) {
133
+ const ip = req.socket.remoteAddress || 'unknown';
134
+ if (isRateLimited(ip)) {
135
+ res.writeHead(429, JSON_HEADERS);
136
+ res.end(JSON.stringify({ ok: false, error: 'rate-limited' }));
137
+ return;
138
+ }
139
+ readBody(req, deps, (body) => {
140
+ let password = '';
141
+ try {
142
+ password = String(JSON.parse(body).password ?? '');
143
+ } catch { /* treat as empty → fails below */ }
144
+ const cfg = deps.authConfig;
145
+ // Only issue a cookie when protection is on with a non-empty password that matches.
146
+ // (Empty-password mode needs no login; disabled mode must not mint new cookies.)
147
+ const ok = cfg.enabled && cfg.password !== '' && passwordMatches(password, cfg.password);
148
+ if (ok) {
149
+ res.writeHead(200, {
150
+ ...JSON_HEADERS,
151
+ 'Set-Cookie': `ccv_auth=${deps.ACCESS_TOKEN}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000`,
152
+ });
153
+ res.end(JSON.stringify({ ok: true }));
154
+ } else {
155
+ recordFailedAttempt(ip);
156
+ res.writeHead(401, JSON_HEADERS);
157
+ res.end(JSON.stringify({ ok: false }));
158
+ }
159
+ });
160
+ }
161
+
162
+ export const authRoutes = [
163
+ { method: 'GET', match: 'exact', path: '/api/auth/state', handler: authState },
164
+ { method: 'POST', match: 'exact', path: '/api/auth/config', handler: authConfigPost },
165
+ { method: 'POST', match: 'exact', path: '/api/auth/login', handler: authLogin },
166
+ ];