cc-viewer 1.6.246 → 1.6.248
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/App-Dpya-PM3.js +1 -0
- package/dist/assets/{MdxEditorPanel-IIVzOold.js → MdxEditorPanel-DMxSX9QP.js} +1 -1
- package/dist/assets/Mobile-B60fQsoC.js +1 -0
- package/dist/assets/{SkillsManagerModal-C4ofB0oa.css → ProxyModal-BE9yXkhe.css} +2 -2
- package/dist/assets/ProxyModal-CFWAqXkN.js +2 -0
- package/dist/assets/{_baseUniq-BhNv8Xvc.js → _baseUniq-Ck0avQQW.js} +1 -1
- package/dist/assets/{arc-Bt4H5eKY.js → arc-DoPSafE-.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BgmUjRNd.js → architectureDiagram-Q4EWVU46-CXwNDWVJ.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-C9wK_ks5.js → blockDiagram-DXYQGD6D-DqcTnX5B.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DhchjDPN.js → c4Diagram-AHTNJAMY-CzZqJcRw.js} +1 -1
- package/dist/assets/{channel-Btktgvdp.js → channel-BPHggAg1.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-wZwjuujj.js → chunk-4BX2VUAB-_j7urp68.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-D6T-DnKQ.js → chunk-4TB4RGXK-DSm-83N3.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-9BqfRmoj.js → chunk-55IACEB6-vMas_Dfs.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-B2war4-Q.js → chunk-EDXVE4YY-DJmHQIxZ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-DHSqPi47.js → chunk-FMBD7UC4-C6U6noME.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CqDjPVtV.js → chunk-OYMX7WX6-DV09n2XA.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-oDMfsT4r.js → chunk-QZHKN3VN-Bwa__aOF.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DzHgASQw.js → chunk-YZCP3GAM-CBRCt3K4.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DKCDn4jL.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DKCDn4jL.js +1 -0
- package/dist/assets/clone-BbE0e7rU.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-Dklu1Lxo.js → cose-bilkent-S5V4N54A-BwOVr318.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-CvOmAdjY.js → dagre-KV5264BT-BrIcbm5i.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-D6vf00xD.js → diagram-5BDNPKRD-C5qxQhGg.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-DE_W63gG.js → diagram-G4DWMVQ6-DsH7kdR2.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-CXlvpIHZ.js → diagram-MMDJMWI5-RE-fR3Cz.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-BBGcvHl1.js → diagram-TYMM5635-voZ1zyYa.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BzOXo3fd.js → erDiagram-SMLLAGMA-PrA2wCsY.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-KZA-Rfi_.js → flowDiagram-DWJPFMVM-Ce7hKuco.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-zjU5qjHl.js → ganttDiagram-T4ZO3ILL-CDVZdk1O.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-g9CMzE95.js → gitGraphDiagram-UUTBAWPF-ud920O99.js} +1 -1
- package/dist/assets/{graph-CjZv0z2E.js → graph-S7ZyOMd5.js} +1 -1
- package/dist/assets/index-1OtQ-y7A.js +2 -0
- package/dist/assets/{index-KMvfg-yA.js → index-7d_JTozZ.js} +1 -1
- package/dist/assets/{index-D6pPmkFc.js → index-9sv7BDJr.js} +1 -1
- package/dist/assets/{index-l-XATlBm.js → index-BEpSzlsR.js} +1 -1
- package/dist/assets/{index-BlFsJkmk.js → index-BJjKgolB.js} +1 -1
- package/dist/assets/{index-DOMQnNl-.js → index-Bxmjxqf5.js} +1 -1
- package/dist/assets/{index-PGOaus9G.js → index-CGqcaIdp.js} +1 -1
- package/dist/assets/{index-lNyLi-vX.js → index-PkTBRWSU.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-DJXw9J58.js → infoDiagram-42DDH7IO-DqmBGCgd.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-C1o4HLh9.js → ishikawaDiagram-UXIWVN3A-TF8Q_01t.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-BSwplIhk.js → journeyDiagram-VCZTEJTY-y5GmBD-h.js} +1 -1
- package/dist/assets/{jszip.min-Dl4IqyRd.js → jszip.min-DKP-m-LG.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-WP2m7MrC.js → kanban-definition-6JOO6SKY-B-VIbcAu.js} +1 -1
- package/dist/assets/{layout-BlkPouba.js → layout-Ye8iwdjE.js} +1 -1
- package/dist/assets/{linear-DHkt6wuU.js → linear-B6rvw7LC.js} +1 -1
- package/dist/assets/{mermaid.core-ChPdLHKc.js → mermaid.core-BeZMQR0-.js} +2 -2
- package/dist/assets/{min-Cyf5226k.js → min-Ckbo1P6U.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DrtYHkPk.js → mindmap-definition-QFDTVHPH-CnCiGofR.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DH-70Dwc.js → pieDiagram-DEJITSTG-BqIhPE7F.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-eEhZyTA8.js → quadrantDiagram-34T5L4WZ-BUUOld6m.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BNwebD-X.js → requirementDiagram-MS252O5E-nXIfCEms.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DoA2qU3N.js → sankeyDiagram-XADWPNL6-DJThfilj.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-DN-boZj7.js → sequenceDiagram-FGHM5R23-CQiVWsC6.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-Dv-Qif4R.js → stateDiagram-FHFEXIEX-CxsMHyky.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-DI5tUYFH.js → stateDiagram-v2-QKLJ7IA2-Byzz6ymQ.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DYG8kyQd.js → timeline-definition-GMOUNBTQ-CkQgIeyQ.js} +1 -1
- package/dist/assets/{vendor-antd-BS2aqEyQ.js → vendor-antd-BeN8xqGk.js} +1 -1
- package/dist/assets/{vendor-codemirror-DpOKy7e3.js → vendor-codemirror-2nbmPewy.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-Dp19LkWF.js → vendor-mdxeditor-C7DYEBoH.js} +2 -2
- package/dist/assets/{vendor-qrcode-DWflB-OX.js → vendor-qrcode-BFV_3WPO.js} +1 -1
- package/dist/assets/{vendor-virtuoso-Bs61ExJ8.js → vendor-virtuoso-COKXGpsa.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-Eityxt48.js → vennDiagram-DHZGUBPP-CPS3ODG3.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-DthedtT3.js → wardley-RL74JXVD-D6zaNgzM.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CzQewpkK.js → wardleyDiagram-NUSXRM2D-Bzaw757Q.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-Dw72vagM.js → xychartDiagram-5P7HB3ND-DCZ9r4uS.js} +1 -1
- package/dist/index.html +4 -4
- package/lib/ask-bridge.js +32 -4
- package/lib/claude-md-discovery.js +181 -0
- package/lib/perm-bridge.js +4 -2
- package/package.json +1 -1
- package/server.js +184 -54
- package/dist/assets/App-BiQkZtui.js +0 -1
- package/dist/assets/Mobile-B7qYYu8v.js +0 -1
- package/dist/assets/SkillsManagerModal-dzR7wO2v.js +0 -2
- package/dist/assets/classDiagram-6PBFFD2Q-DhUw-2qG.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DhUw-2qG.js +0 -1
- package/dist/assets/clone-CAwv3NR1.js +0 -1
- package/dist/assets/index-kza-3Pji.js +0 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md 候选发现 + 安全读取。
|
|
3
|
+
*
|
|
4
|
+
* 用途:为 /api/claude-md 端点提供 (1) 候选清单计算 (2) 通过稳定 id 取回某条候选的内容。
|
|
5
|
+
*
|
|
6
|
+
* 策略:
|
|
7
|
+
* - 项目候选:从 cwd 实际路径(realpath 后)向上走,每层若 <dir>/CLAUDE.md 存在且为文件,收一条。
|
|
8
|
+
* 终止条件:dir === dirname(dir)(fs root) / dir === homedir() / <dir>/.git 存在 / depth 达 8(任一即停)。
|
|
9
|
+
* hit homedir 与 hit .git 时仍包含当前层再停,hit fs root 立即停。
|
|
10
|
+
* - 全局候选:~/.claude/CLAUDE.md(以参数 claudeConfigDir 为准,由调用方传入,便于沙箱测试)。
|
|
11
|
+
* - 排序:项目候选按"靠近 cwd"在前,全局总是最后一条。
|
|
12
|
+
* - 去重:基于 realpath 去重;同一物理文件被多入口指向只留一条(保留先入者)。
|
|
13
|
+
* - id:sha1(realPath).slice(0,12)。FS 不变 → 两次 list 结果一致;候选被换成不同物理文件 → id 变化(防 swap)。
|
|
14
|
+
* - Windows:跳过父链遍历,只返回全局候选(POSIX 父链语义在 win 上需要单独验证,留给 v2)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, statSync, realpathSync, openSync, fstatSync, readSync, closeSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, basename, relative } from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { createHash } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
const MAX_DEPTH = 8;
|
|
23
|
+
|
|
24
|
+
function safeRealpath(p) {
|
|
25
|
+
try { return realpathSync(p); } catch { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFileSafe(p) {
|
|
29
|
+
try {
|
|
30
|
+
const st = statSync(p);
|
|
31
|
+
return st.isFile();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeId(realPath) {
|
|
38
|
+
return createHash('sha1').update(realPath).digest('hex').slice(0, 12);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.cwd 起始目录(将被 realpath 化)。
|
|
44
|
+
* @param {string} opts.claudeConfigDir ~/.claude 目录(由 findcc.getClaudeConfigDir() 提供)。
|
|
45
|
+
* @param {(p:string) => {ok:boolean, real?:string}} [opts.isReadAllowedFn] 可选预过滤闸:
|
|
46
|
+
* 若提供, 候选必须 isReadAllowed(realPath).ok 才进列表 —— 避免父链祖先目录
|
|
47
|
+
* 因不在 file-access-policy allowlist 而点击必返回 403 的 UX 坑。测试可省略。
|
|
48
|
+
* @returns {Array<{id:string, scope:'project'|'global', realPath:string, tail:string, mtimeMs:number}>}
|
|
49
|
+
*/
|
|
50
|
+
export function discoverClaudeMdCandidates({ cwd, claudeConfigDir, isReadAllowedFn }) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const seenReal = new Set();
|
|
53
|
+
const home = homedir();
|
|
54
|
+
|
|
55
|
+
const pushIfFile = (path, scope, tail) => {
|
|
56
|
+
// path 可能是 dir/CLAUDE.md, 也可能是 ~/.claude/CLAUDE.md
|
|
57
|
+
if (!existsSync(path)) return;
|
|
58
|
+
const real = safeRealpath(path);
|
|
59
|
+
if (!real) return;
|
|
60
|
+
if (basename(real) !== 'CLAUDE.md') return; // 防 symlink-name swap
|
|
61
|
+
if (!isFileSafe(real)) return;
|
|
62
|
+
if (seenReal.has(real)) return;
|
|
63
|
+
// 预过滤: 若注入了 policy gate, 不在 allowlist 内的祖先候选直接剔除 ——
|
|
64
|
+
// 这样 UI chip 不会渲染出"看得见点不开"的 403 项 (defensive review P1-A)。
|
|
65
|
+
if (typeof isReadAllowedFn === 'function') {
|
|
66
|
+
const policy = isReadAllowedFn(real);
|
|
67
|
+
if (!policy || !policy.ok) return;
|
|
68
|
+
}
|
|
69
|
+
seenReal.add(real);
|
|
70
|
+
let mtimeMs = 0;
|
|
71
|
+
try { mtimeMs = statSync(real).mtimeMs; } catch {}
|
|
72
|
+
out.push({ id: makeId(real), scope, realPath: real, tail, mtimeMs });
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ── 项目链遍历 ─────────────────────────────────────────────────────────────
|
|
76
|
+
if (process.platform !== 'win32') {
|
|
77
|
+
const startReal = safeRealpath(cwd) || cwd;
|
|
78
|
+
let dir = startReal;
|
|
79
|
+
let depth = 0;
|
|
80
|
+
while (depth < MAX_DEPTH) {
|
|
81
|
+
const candidatePath = join(dir, 'CLAUDE.md');
|
|
82
|
+
// tail:相对 startReal 的路径;若就是 startReal,tail = "CLAUDE.md";
|
|
83
|
+
// 父级则 tail = "../../CLAUDE.md"。UI 只用 tail 做信息提示(title=fullPath)。
|
|
84
|
+
let tail;
|
|
85
|
+
if (dir === startReal) {
|
|
86
|
+
tail = 'CLAUDE.md';
|
|
87
|
+
} else {
|
|
88
|
+
const rel = relative(startReal, dir);
|
|
89
|
+
tail = (rel ? rel + '/' : '') + 'CLAUDE.md';
|
|
90
|
+
}
|
|
91
|
+
pushIfFile(candidatePath, 'project', tail);
|
|
92
|
+
|
|
93
|
+
// 终止判定:homedir / .git / fs root
|
|
94
|
+
const isHome = dir === home;
|
|
95
|
+
const hasGit = existsSync(join(dir, '.git'));
|
|
96
|
+
const parent = dirname(dir);
|
|
97
|
+
const atRoot = parent === dir;
|
|
98
|
+
if (isHome || hasGit || atRoot) break;
|
|
99
|
+
|
|
100
|
+
dir = parent;
|
|
101
|
+
depth++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── 全局候选 ───────────────────────────────────────────────────────────────
|
|
106
|
+
if (claudeConfigDir) {
|
|
107
|
+
pushIfFile(join(claudeConfigDir, 'CLAUDE.md'), 'global', '.claude/CLAUDE.md');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 通过稳定 id 取候选内容。调用方应:
|
|
115
|
+
* 1) 先 discoverClaudeMdCandidates 得到 candidates;
|
|
116
|
+
* 2) 用本函数按 id 取内容并做最终安全闸 (basename + isReadAllowed);
|
|
117
|
+
*
|
|
118
|
+
* 安全闸由 isReadAllowedFn 注入(默认 file-access-policy.isReadAllowed),便于测试 mock。
|
|
119
|
+
*
|
|
120
|
+
* @param {Array} candidates discoverClaudeMdCandidates 的返回。
|
|
121
|
+
* @param {string} id 12-hex 字符 id。
|
|
122
|
+
* @param {object} opts
|
|
123
|
+
* @param {number} opts.maxBytes 文件大小上限(字节)。
|
|
124
|
+
* @param {(p:string) => {ok:boolean, real?:string, reason?:string}} opts.isReadAllowedFn 安全闸。
|
|
125
|
+
* @returns {{ok:true, scope, tail, content, real} | {ok:false, status:number, error:string}}
|
|
126
|
+
*/
|
|
127
|
+
export function readCandidateById(candidates, id, { maxBytes, isReadAllowedFn }) {
|
|
128
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{12}$/i.test(id)) {
|
|
129
|
+
return { ok: false, status: 400, error: 'Invalid id' };
|
|
130
|
+
}
|
|
131
|
+
const entry = candidates.find(c => c.id === id);
|
|
132
|
+
if (!entry) return { ok: false, status: 404, error: 'Candidate not found' };
|
|
133
|
+
|
|
134
|
+
// 二次校验:basename 仍是 CLAUDE.md (防 discovery 与 read 之间的 FS 变更)
|
|
135
|
+
if (basename(entry.realPath) !== 'CLAUDE.md') {
|
|
136
|
+
return { ok: false, status: 404, error: 'Candidate basename mismatch' };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const policy = isReadAllowedFn(entry.realPath);
|
|
140
|
+
if (!policy.ok) {
|
|
141
|
+
// 复用 file-access-policy.reasonToStatus 的语义:caller 决定状态码映射;
|
|
142
|
+
// 这里给 403 作为默认,调用方可按 reason 重写。
|
|
143
|
+
return { ok: false, status: 403, error: 'Forbidden', reason: policy.reason };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 用 policy.real(已 realpath 过)做 size + read,避免再次 realpath 引入新的 TOCTOU 窗口。
|
|
147
|
+
const real = policy.real;
|
|
148
|
+
let size = 0;
|
|
149
|
+
let fd = null;
|
|
150
|
+
try {
|
|
151
|
+
fd = openSync(real, 'r');
|
|
152
|
+
const st = fstatSync(fd);
|
|
153
|
+
if (!st.isFile()) {
|
|
154
|
+
return { ok: false, status: 400, error: 'Not a file' };
|
|
155
|
+
}
|
|
156
|
+
size = st.size;
|
|
157
|
+
if (size > maxBytes) {
|
|
158
|
+
return { ok: false, status: 413, error: 'File too large' };
|
|
159
|
+
}
|
|
160
|
+
const buf = Buffer.alloc(size);
|
|
161
|
+
let read = 0;
|
|
162
|
+
while (read < size) {
|
|
163
|
+
const n = readSync(fd, buf, read, size - read, read);
|
|
164
|
+
if (n === 0) break;
|
|
165
|
+
read += n;
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
scope: entry.scope,
|
|
170
|
+
tail: entry.tail,
|
|
171
|
+
content: buf.toString('utf-8'),
|
|
172
|
+
real,
|
|
173
|
+
};
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { ok: false, status: 500, error: e && e.code ? e.code : 'read-failed' };
|
|
176
|
+
} finally {
|
|
177
|
+
if (fd != null) {
|
|
178
|
+
try { closeSync(fd); } catch {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
package/lib/perm-bridge.js
CHANGED
|
@@ -55,10 +55,12 @@ if (!toolName || !toolInput) {
|
|
|
55
55
|
process.exit(1);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// 硬拦截:
|
|
58
|
+
// 硬拦截:npm publish 即使在 --d (bypass) 模式下也强制走 Web UI 审批
|
|
59
59
|
// 这是安全底线,不受 --dangerously-skip-permissions 影响
|
|
60
|
+
// 注:git commit / git push 不在硬拦截范围内 —— 提交可重写、push 可 force-push 回退,
|
|
61
|
+
// 真正不可撤销的对外发布(npm publish)才走硬闸
|
|
60
62
|
const isPublishCmd = toolName === 'Bash' && toolInput.command &&
|
|
61
|
-
/
|
|
63
|
+
/npm\s+publish/i.test(toolInput.command);
|
|
62
64
|
|
|
63
65
|
// Bypass mode: auto-allow all tools except publish commands
|
|
64
66
|
// 使用显式 allow 而非 exit(1) fallback,避免 Claude Code 记录 hook error 日志
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -51,6 +51,7 @@ import { countLogEntries, streamRawEntriesAsync, readPagedEntries } from './lib/
|
|
|
51
51
|
import { awaitDrainOrClose } from './lib/sse-backpressure.js';
|
|
52
52
|
import { enrichRawIfNeeded } from './lib/enrich-plan-input.js';
|
|
53
53
|
import { buildTeamStatusResponse } from './lib/team-runtime.js';
|
|
54
|
+
import { discoverClaudeMdCandidates, readCandidateById } from './lib/claude-md-discovery.js';
|
|
54
55
|
|
|
55
56
|
|
|
56
57
|
// 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
|
|
@@ -118,8 +119,10 @@ let _workspaceIsNpmVersion = false;
|
|
|
118
119
|
let _workspaceLaunched = false; // 工作区是否已经启动了会话
|
|
119
120
|
|
|
120
121
|
// Ask hook bridge state (for PreToolUse AskUserQuestion hook)
|
|
121
|
-
//
|
|
122
|
-
|
|
122
|
+
// Map supports concurrent ask requests (sub-agents / teammates) so a stale unanswered
|
|
123
|
+
// ask never blocks the next one. Keyed by server-generated id.
|
|
124
|
+
const pendingAskHooks = new Map(); // Map<id, { questions, res, timer, createdAt }>
|
|
125
|
+
const ASK_HOOK_MAP_MAX = 50;
|
|
123
126
|
|
|
124
127
|
// Permission hook bridge state (for PreToolUse permission approval)
|
|
125
128
|
// Map supports concurrent sub-agent/teammate requests (keyed by request id)
|
|
@@ -2303,40 +2306,111 @@ async function handleRequest(req, res) {
|
|
|
2303
2306
|
// Ask hook bridge: long-poll endpoint for PreToolUse AskUserQuestion hook
|
|
2304
2307
|
if (url === '/api/ask-hook' && method === 'POST') {
|
|
2305
2308
|
let body = '';
|
|
2309
|
+
let bodyTooLarge = false;
|
|
2306
2310
|
req.on('data', (chunk) => {
|
|
2307
2311
|
body += chunk;
|
|
2308
2312
|
if (body.length > 1000000) { // 1MB limit (questions may contain large previews)
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
return;
|
|
2313
|
+
bodyTooLarge = true;
|
|
2314
|
+
req.destroy();
|
|
2312
2315
|
}
|
|
2313
2316
|
});
|
|
2314
2317
|
req.on('end', async () => {
|
|
2318
|
+
if (bodyTooLarge) {
|
|
2319
|
+
try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2315
2322
|
try {
|
|
2316
|
-
const { questions } = JSON.parse(body);
|
|
2323
|
+
const { questions, toolUseId } = JSON.parse(body);
|
|
2317
2324
|
if (!Array.isArray(questions) || questions.length === 0) {
|
|
2318
2325
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2319
2326
|
res.end(JSON.stringify({ error: 'Missing questions' }));
|
|
2320
2327
|
return;
|
|
2321
2328
|
}
|
|
2329
|
+
// 镜像 ask-bridge 的 normalize:覆盖 plugin/SDK 等非 ask-bridge 的 client。
|
|
2330
|
+
// 缺失 description 补 "",前端按 hasOptionDescription 判断走非渲染分支。
|
|
2331
|
+
for (const q of questions) {
|
|
2332
|
+
if (!q || typeof q !== 'object' || !Array.isArray(q.options)) continue;
|
|
2333
|
+
for (const opt of q.options) {
|
|
2334
|
+
if (opt && typeof opt === 'object' && opt.description === undefined) {
|
|
2335
|
+
opt.description = '';
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2322
2339
|
|
|
2323
|
-
//
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2340
|
+
// Evict oldest if Map is full (prevent memory leak from pathological concurrency).
|
|
2341
|
+
// Mirrors perm-hook eviction; an evicted bridge process gets HTTP 429 and falls back
|
|
2342
|
+
// to its own terminal UI rather than leaking long-poll state.
|
|
2343
|
+
if (pendingAskHooks.size >= ASK_HOOK_MAP_MAX) {
|
|
2344
|
+
const oldestId = pendingAskHooks.keys().next().value;
|
|
2345
|
+
const oldest = pendingAskHooks.get(oldestId);
|
|
2346
|
+
if (oldest) {
|
|
2347
|
+
clearTimeout(oldest.timer);
|
|
2348
|
+
try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
|
|
2349
|
+
pendingAskHooks.delete(oldestId);
|
|
2350
|
+
if (terminalWss) {
|
|
2351
|
+
const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id: oldestId });
|
|
2352
|
+
terminalWss.clients.forEach((c) => {
|
|
2353
|
+
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
2354
|
+
});
|
|
2329
2355
|
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2356
|
+
_notifyParentPending({ type: 'ask-hook-timeout', id: oldestId });
|
|
2357
|
+
}
|
|
2332
2358
|
}
|
|
2333
2359
|
|
|
2334
2360
|
const HOOK_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
|
2361
|
+
// toolUseId 路由策略:
|
|
2362
|
+
// - char whitelist + ≤256 长度 防恶意 1MB key 撑大 Map
|
|
2363
|
+
// - 已存在同 id 但旧 res 已断(writableEnded/destroyed)→ 复用槽位(ask-bridge 重试场景)
|
|
2364
|
+
// - 已存在同 id 且旧 res 活 → 返 409 Conflict(真重复)而非走 fallback id 让前端 portal 失效
|
|
2365
|
+
// - 缺 toolUseId 或非法 → fallback 自生成 id
|
|
2366
|
+
const wantsToolUseId = toolUseId && typeof toolUseId === 'string'
|
|
2367
|
+
&& toolUseId.length > 0 && toolUseId.length <= 256
|
|
2368
|
+
&& /^[a-zA-Z0-9_-]+$/.test(toolUseId);
|
|
2369
|
+
let id;
|
|
2370
|
+
if (wantsToolUseId) {
|
|
2371
|
+
const existing = pendingAskHooks.get(toolUseId);
|
|
2372
|
+
if (!existing) {
|
|
2373
|
+
id = toolUseId;
|
|
2374
|
+
} else if (!existing.res || existing.res.writableEnded || existing.res.destroyed) {
|
|
2375
|
+
// 旧 res 已断(包含占位 res:null 残留)— ask-bridge 重试同 toolUseId 合理,复用槽位
|
|
2376
|
+
if (existing.timer) clearTimeout(existing.timer);
|
|
2377
|
+
pendingAskHooks.delete(toolUseId);
|
|
2378
|
+
id = toolUseId;
|
|
2379
|
+
} else {
|
|
2380
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
2381
|
+
res.end(JSON.stringify({ error: 'Duplicate toolUseId, previous request still pending' }));
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
} else {
|
|
2385
|
+
do { id = `ask_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } while (pendingAskHooks.has(id));
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// TOCTOU 防御:占位 id 之前先注册 res.on('close'),否则 await runWaterfallHook 期间 client
|
|
2389
|
+
// abort 的 close 事件落空 → entry 残 5min。占位 set 提前到 await 之前防两条同 ms 并发
|
|
2390
|
+
// POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 5min)。
|
|
2391
|
+
pendingAskHooks.set(id, { questions, res, timer: null, createdAt: Date.now() });
|
|
2392
|
+
|
|
2393
|
+
// res.on('close') 提前注册:handler 用 entry.timer 守卫(占位期 timer:null → 仅 delete Map)
|
|
2394
|
+
res.on('close', () => {
|
|
2395
|
+
const entry = pendingAskHooks.get(id);
|
|
2396
|
+
if (entry) {
|
|
2397
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
2398
|
+
pendingAskHooks.delete(id);
|
|
2399
|
+
if (terminalWss) {
|
|
2400
|
+
const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
|
|
2401
|
+
terminalWss.clients.forEach((c) => {
|
|
2402
|
+
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
2403
|
+
});
|
|
2404
|
+
}
|
|
2405
|
+
_notifyParentPending({ type: 'ask-hook-timeout', id });
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2335
2408
|
|
|
2336
2409
|
// Plugin hook: let plugins answer questions directly
|
|
2337
2410
|
try {
|
|
2338
|
-
const hookResult = await runWaterfallHook('onAskRequest', { id
|
|
2411
|
+
const hookResult = await runWaterfallHook('onAskRequest', { id, questions, mode: 'hook' });
|
|
2339
2412
|
if (hookResult.answers) {
|
|
2413
|
+
pendingAskHooks.delete(id); // 释放占位 — plugin 直接答了
|
|
2340
2414
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2341
2415
|
res.end(JSON.stringify({ answers: hookResult.answers }));
|
|
2342
2416
|
return;
|
|
@@ -2344,52 +2418,38 @@ async function handleRequest(req, res) {
|
|
|
2344
2418
|
} catch {}
|
|
2345
2419
|
|
|
2346
2420
|
const timer = setTimeout(() => {
|
|
2347
|
-
|
|
2348
|
-
|
|
2421
|
+
const entry = pendingAskHooks.get(id);
|
|
2422
|
+
if (entry) {
|
|
2423
|
+
pendingAskHooks.delete(id);
|
|
2349
2424
|
try {
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
res.
|
|
2425
|
+
// null guard:plugin throw + outer body-parse race 极端下占位残留时 fire 防 TypeError
|
|
2426
|
+
if (entry.res && !entry.res.headersSent) {
|
|
2427
|
+
entry.res.writeHead(408, { 'Content-Type': 'application/json' });
|
|
2428
|
+
entry.res.end(JSON.stringify({ error: 'Timeout' }));
|
|
2353
2429
|
}
|
|
2354
2430
|
} catch {}
|
|
2355
|
-
// Broadcast timeout to clients
|
|
2356
2431
|
if (terminalWss) {
|
|
2357
|
-
const tmsg = JSON.stringify({ type: 'ask-hook-timeout' });
|
|
2432
|
+
const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
|
|
2358
2433
|
terminalWss.clients.forEach((c) => {
|
|
2359
2434
|
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
2360
2435
|
});
|
|
2361
2436
|
}
|
|
2362
|
-
_notifyParentPending({ type: 'ask-hook-timeout' });
|
|
2437
|
+
_notifyParentPending({ type: 'ask-hook-timeout', id });
|
|
2363
2438
|
}
|
|
2364
2439
|
}, HOOK_TIMEOUT);
|
|
2365
2440
|
|
|
2366
|
-
|
|
2441
|
+
pendingAskHooks.set(id, { questions, res, timer, createdAt: Date.now() });
|
|
2367
2442
|
|
|
2368
2443
|
// Broadcast to all terminal WS clients
|
|
2369
2444
|
if (terminalWss) {
|
|
2370
|
-
const pmsg = JSON.stringify({ type: 'ask-hook-pending', questions });
|
|
2445
|
+
const pmsg = JSON.stringify({ type: 'ask-hook-pending', id, questions });
|
|
2371
2446
|
terminalWss.clients.forEach((client) => {
|
|
2372
2447
|
if (client.readyState === 1) {
|
|
2373
2448
|
try { client.send(pmsg); } catch {}
|
|
2374
2449
|
}
|
|
2375
2450
|
});
|
|
2376
2451
|
}
|
|
2377
|
-
_notifyParentPending({ type: 'ask-hook-pending', questions });
|
|
2378
|
-
|
|
2379
|
-
// Handle ask-bridge.js disconnection (use res instead of req — Node.js v24+ fires req 'close' immediately after body is read)
|
|
2380
|
-
res.on('close', () => {
|
|
2381
|
-
if (pendingAskHook && pendingAskHook.res === res) {
|
|
2382
|
-
clearTimeout(pendingAskHook.timer);
|
|
2383
|
-
pendingAskHook = null;
|
|
2384
|
-
if (terminalWss) {
|
|
2385
|
-
const tmsg = JSON.stringify({ type: 'ask-hook-timeout' });
|
|
2386
|
-
terminalWss.clients.forEach((c) => {
|
|
2387
|
-
if (c.readyState === 1) try { c.send(tmsg); } catch {}
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
_notifyParentPending({ type: 'ask-hook-timeout' });
|
|
2391
|
-
}
|
|
2392
|
-
});
|
|
2452
|
+
_notifyParentPending({ type: 'ask-hook-pending', id, questions });
|
|
2393
2453
|
} catch {
|
|
2394
2454
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2395
2455
|
res.end(JSON.stringify({ error: 'Invalid request body' }));
|
|
@@ -2681,6 +2741,62 @@ async function handleRequest(req, res) {
|
|
|
2681
2741
|
return;
|
|
2682
2742
|
}
|
|
2683
2743
|
|
|
2744
|
+
// CLAUDE.md 入口:列出 cwd 父链 + ~/.claude/CLAUDE.md,按 sha1(realpath).slice(0,12) 给稳定 id;
|
|
2745
|
+
// 不带参 → 返回候选清单(无 content);?id=<hex12> → 二次校验 basename + isReadAllowed 后吐内容。
|
|
2746
|
+
// 设计要点:客户端永远只传 id,path resolution 由 server 独占;id 由 realpath 派生 → swap 即变 id。
|
|
2747
|
+
if (url === '/api/claude-md' && method === 'GET') {
|
|
2748
|
+
const respondJson = (status, body) => {
|
|
2749
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2750
|
+
res.end(JSON.stringify(body));
|
|
2751
|
+
};
|
|
2752
|
+
try {
|
|
2753
|
+
const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
|
|
2754
|
+
const claudeConfigDir = getClaudeConfigDir();
|
|
2755
|
+
// 注入 isReadAllowed 做发现阶段预过滤: 不在 allowlist 内的祖先候选不进列表 ——
|
|
2756
|
+
// 否则 UI 会渲染出点击必 403 的"看得见点不开"chip。
|
|
2757
|
+
const candidates = discoverClaudeMdCandidates({
|
|
2758
|
+
cwd,
|
|
2759
|
+
claudeConfigDir,
|
|
2760
|
+
isReadAllowedFn: isReadAllowed,
|
|
2761
|
+
});
|
|
2762
|
+
const idParam = parsedUrl.searchParams.get('id');
|
|
2763
|
+
const MAX_BYTES = 512 * 1024;
|
|
2764
|
+
|
|
2765
|
+
if (!idParam) {
|
|
2766
|
+
// 列表: 不暴露 realPath / mtimeMs, 仅返回 {id, scope, tail}。mtimeMs 前端未使用,
|
|
2767
|
+
// 去掉以收敛信息泄露面 (perf-sec review P2-C)。
|
|
2768
|
+
return respondJson(200, {
|
|
2769
|
+
entries: candidates.map(c => ({
|
|
2770
|
+
id: c.id,
|
|
2771
|
+
scope: c.scope,
|
|
2772
|
+
tail: c.tail,
|
|
2773
|
+
})),
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
const r = readCandidateById(candidates, idParam, {
|
|
2778
|
+
maxBytes: MAX_BYTES,
|
|
2779
|
+
isReadAllowedFn: isReadAllowed,
|
|
2780
|
+
});
|
|
2781
|
+
if (!r.ok) {
|
|
2782
|
+
// policy 失败时让 reasonToStatus 统一映射,与 /api/project-memory 一致
|
|
2783
|
+
const status = r.reason ? reasonToStatus(r.reason) : r.status;
|
|
2784
|
+
return respondJson(status, { error: r.error, reason: r.reason });
|
|
2785
|
+
}
|
|
2786
|
+
return respondJson(200, {
|
|
2787
|
+
id: idParam,
|
|
2788
|
+
scope: r.scope,
|
|
2789
|
+
tail: r.tail,
|
|
2790
|
+
content: r.content,
|
|
2791
|
+
});
|
|
2792
|
+
} catch (err) {
|
|
2793
|
+
const status = ERROR_STATUS_MAP[err.code] || 500;
|
|
2794
|
+
const message = status === 500 ? 'Internal error' : err.message;
|
|
2795
|
+
respondJson(status, { error: message });
|
|
2796
|
+
}
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2684
2800
|
// 返回文件原始二进制内容(用于图片预览等)—— 走 file-access-policy 统一校验
|
|
2685
2801
|
// 历史 isEditorSession 后门 + uploadPrefix/persistPrefix 硬编码豁免已收敛:
|
|
2686
2802
|
// policy 的 allowlist 已含 /tmp/cc-viewer-uploads/、tmpdir()/cc-viewer-uploads/、
|
|
@@ -3644,12 +3760,13 @@ export async function startViewer() {
|
|
|
3644
3760
|
}
|
|
3645
3761
|
return true;
|
|
3646
3762
|
},
|
|
3647
|
-
|
|
3648
|
-
resolveAsk: (answers) => {
|
|
3649
|
-
|
|
3650
|
-
|
|
3763
|
+
getPendingAsks: () => [...pendingAskHooks.entries()].map(([id, e]) => ({ id, questions: e.questions, createdAt: e.createdAt })),
|
|
3764
|
+
resolveAsk: (id, answers) => {
|
|
3765
|
+
const entry = pendingAskHooks.get(id);
|
|
3766
|
+
if (!entry) return false;
|
|
3767
|
+
const { res: hookRes, timer } = entry;
|
|
3651
3768
|
clearTimeout(timer);
|
|
3652
|
-
|
|
3769
|
+
pendingAskHooks.delete(id);
|
|
3653
3770
|
try {
|
|
3654
3771
|
if (!hookRes.headersSent) {
|
|
3655
3772
|
hookRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -3657,10 +3774,10 @@ export async function startViewer() {
|
|
|
3657
3774
|
}
|
|
3658
3775
|
} catch {}
|
|
3659
3776
|
if (terminalWss) {
|
|
3660
|
-
const rmsg = JSON.stringify({ type: 'ask-hook-resolved' });
|
|
3777
|
+
const rmsg = JSON.stringify({ type: 'ask-hook-resolved', id });
|
|
3661
3778
|
terminalWss.clients.forEach((c) => { if (c.readyState === 1) try { c.send(rmsg); } catch {} });
|
|
3662
3779
|
}
|
|
3663
|
-
_notifyParentPending({ type: 'ask-hook-resolved' });
|
|
3780
|
+
_notifyParentPending({ type: 'ask-hook-resolved', id });
|
|
3664
3781
|
return true;
|
|
3665
3782
|
},
|
|
3666
3783
|
resolveSdkApproval: (...args) => _sdkResolveApproval?.(...args),
|
|
@@ -3910,12 +4027,25 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
3910
4027
|
}, { settleMs: msg.settleMs || 150 });
|
|
3911
4028
|
}
|
|
3912
4029
|
} else if (msg.type === 'ask-hook-answer') {
|
|
3913
|
-
// Client answered AskUserQuestion via hook bridge
|
|
4030
|
+
// Client answered AskUserQuestion via hook bridge.
|
|
4031
|
+
// New protocol: msg.id required to address one of multiple pending asks.
|
|
4032
|
+
// Legacy fallback: no id → resolve the oldest pending ask (preserves pre-Map client behavior).
|
|
3914
4033
|
let askAnswered = false;
|
|
3915
|
-
|
|
3916
|
-
|
|
4034
|
+
let askId = msg.id;
|
|
4035
|
+
let askEntry = null;
|
|
4036
|
+
if (askId) {
|
|
4037
|
+
askEntry = pendingAskHooks.get(askId);
|
|
4038
|
+
} else {
|
|
4039
|
+
const firstId = pendingAskHooks.keys().next().value;
|
|
4040
|
+
if (firstId) {
|
|
4041
|
+
askId = firstId;
|
|
4042
|
+
askEntry = pendingAskHooks.get(firstId);
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
if (askEntry) {
|
|
4046
|
+
const { res: hookRes, timer } = askEntry;
|
|
3917
4047
|
clearTimeout(timer);
|
|
3918
|
-
|
|
4048
|
+
pendingAskHooks.delete(askId);
|
|
3919
4049
|
askAnswered = true;
|
|
3920
4050
|
try {
|
|
3921
4051
|
if (!hookRes.headersSent) {
|
|
@@ -3926,12 +4056,12 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
3926
4056
|
}
|
|
3927
4057
|
// Broadcast resolved to other clients so they clear their ask panel
|
|
3928
4058
|
if (askAnswered && terminalWss) {
|
|
3929
|
-
const rmsg = JSON.stringify({ type: 'ask-hook-resolved' });
|
|
4059
|
+
const rmsg = JSON.stringify({ type: 'ask-hook-resolved', id: askId });
|
|
3930
4060
|
terminalWss.clients.forEach((c) => {
|
|
3931
4061
|
if (c !== ws && c.readyState === 1) try { c.send(rmsg); } catch {}
|
|
3932
4062
|
});
|
|
3933
4063
|
}
|
|
3934
|
-
if (askAnswered) _notifyParentPending({ type: 'ask-hook-resolved' });
|
|
4064
|
+
if (askAnswered) _notifyParentPending({ type: 'ask-hook-resolved', id: askId });
|
|
3935
4065
|
} else if (msg.type === 'perm-hook-answer') {
|
|
3936
4066
|
// Permission approval — SDK mode (canUseTool) or PTY mode (hook bridge)
|
|
3937
4067
|
let permAnswered = false;
|