cc-viewer 1.6.292 → 1.6.294

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 (99) hide show
  1. package/cli.js +91 -8
  2. package/dist/assets/App-C66LoBEz.js +2 -0
  3. package/dist/assets/App-eFrjLzF_.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-DZyDzHVy.js → MdxEditorPanel-B8xrlDZJ.js} +1 -1
  5. package/dist/assets/{Mobile-Blo3o6S7.js → Mobile-fsi8-Lpb.js} +1 -1
  6. package/dist/assets/{_baseUniq-Bbls6LNZ.js → _baseUniq-r3p3rodd.js} +1 -1
  7. package/dist/assets/{arc-Dxdkihpv.js → arc-CjTV5gxc.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-DZEl5ue8.js → architectureDiagram-Q4EWVU46-BqzjXpCq.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-CqlYS2ns.js → blockDiagram-DXYQGD6D-CLyFfeHh.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-Jxp541Qe.js → c4Diagram-AHTNJAMY-BaO-0tuc.js} +1 -1
  11. package/dist/assets/{channel-CyTjYjAe.js → channel-yOyhvOLV.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-BaHtjjCx.js → chunk-4BX2VUAB-CMTnvZkS.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-DjCpyfxt.js → chunk-4TB4RGXK-QI41m9WP.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-LkM5KZyD.js → chunk-55IACEB6-C4ZO8bM3.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-CTwZjamm.js → chunk-EDXVE4YY-Bo8P4o65.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-BMGbfhBg.js → chunk-FMBD7UC4-CTHLGcHh.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-CL9bBWYk.js → chunk-OYMX7WX6-D0OHxKGd.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-B_htBFcu.js → chunk-QZHKN3VN-CoYnjUpS.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-C2sEYgGN.js → chunk-YZCP3GAM-BY71mTXM.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-C9o5ip5q.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-C9o5ip5q.js +1 -0
  22. package/dist/assets/clone-GDqN3kwT.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-B2QExtqQ.js → cose-bilkent-S5V4N54A-DUNsA_MT.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-DMrP1-R5.js → dagre-KV5264BT-BzlT2Exr.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DmnnioaR.js → diagram-5BDNPKRD-CiqQK3Ci.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-BznBn-DT.js → diagram-G4DWMVQ6-BciK18tQ.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-CCtZmcc-.js → diagram-MMDJMWI5-C1WH1vfU.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-DwrE4PW2.js → diagram-TYMM5635-CR5RzJ6u.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-C-ksosE7.js → erDiagram-SMLLAGMA-NJQKXu51.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-BJVpW7EG.js → flowDiagram-DWJPFMVM-Cjx5t_1H.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-BMXdFiqN.js → ganttDiagram-T4ZO3ILL-YFTDBBiU.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DBTPkZc9.js → gitGraphDiagram-UUTBAWPF-C2muKahz.js} +1 -1
  33. package/dist/assets/{graph-B8CBUW3s.js → graph-I1olozIg.js} +1 -1
  34. package/dist/assets/{index-C_7Sqw3d.js → index-7vxIrUNA.js} +1 -1
  35. package/dist/assets/index-BTZqk5O5.js +2 -0
  36. package/dist/assets/{index-BJ8yRdWs.css → index-Be9T-kDq.css} +1 -1
  37. package/dist/assets/{index-DhDblkcm.js → index-C1RNAzAB.js} +1 -1
  38. package/dist/assets/{index-DBeeKbJt.js → index-Cf4FBg-V.js} +1 -1
  39. package/dist/assets/{index-CHdOT_E7.js → index-D-HPuqxB.js} +1 -1
  40. package/dist/assets/{index-xT0MApQE.js → index-D2QUxu18.js} +1 -1
  41. package/dist/assets/{index-DEXzVXu7.js → index-DhzoJ5wE.js} +1 -1
  42. package/dist/assets/{index-B-KwaWha.js → index-fhI0i2p3.js} +1 -1
  43. package/dist/assets/{infoDiagram-42DDH7IO-K_Tx4lQN.js → infoDiagram-42DDH7IO-C9bza97c.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-kxQMq1sT.js → ishikawaDiagram-UXIWVN3A-BtZGipfW.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-D73gF_Pb.js → journeyDiagram-VCZTEJTY-CKTp590c.js} +1 -1
  46. package/dist/assets/{jszip.min-CuShp3Z_.js → jszip.min-DDU-_oA-.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-BauEKrut.js → kanban-definition-6JOO6SKY-BHLNWfr5.js} +1 -1
  48. package/dist/assets/{layout-BMLto1IX.js → layout-DBmqcl9N.js} +1 -1
  49. package/dist/assets/{linear-D2Qb161Z.js → linear-Br9n7mCI.js} +1 -1
  50. package/dist/assets/{mermaid.core-IJXp8OCw.js → mermaid.core-BV3ugHFm.js} +2 -2
  51. package/dist/assets/{min-PfweEw_8.js → min-D-YA3MGY.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-CbFxa-Zo.js → mindmap-definition-QFDTVHPH-CzrYj3cB.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-DKeJuPU5.js → pieDiagram-DEJITSTG-BAvtfiT3.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-5VHUtwyi.js → quadrantDiagram-34T5L4WZ-i4zhnBJq.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-SToTOJlA.js → requirementDiagram-MS252O5E-Cb2wX9Sk.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-BdJHEZA8.js → sankeyDiagram-XADWPNL6-CcpbP6z5.js} +1 -1
  57. package/dist/assets/seqResourceLoaders-6k4uXcNn.js +2 -0
  58. package/dist/assets/{seqResourceLoaders-DWKAvGtj.css → seqResourceLoaders-De_-fYhE.css} +2 -2
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-D1KEiigh.js → sequenceDiagram-FGHM5R23-BcbUxMmI.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-CbSD6ZEY.js → stateDiagram-FHFEXIEX-CpIa1qoO.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-Cf2M_W2L.js → stateDiagram-v2-QKLJ7IA2-d3GoyW9S.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-DEcX1h4h.js → timeline-definition-GMOUNBTQ-BfQPSOuT.js} +1 -1
  63. package/dist/assets/{vendor-antd-CAcFBnhC.js → vendor-antd-Bur5ZxWE.js} +1 -1
  64. package/dist/assets/{vendor-codemirror-gWcsCyZl.js → vendor-codemirror-Si44UqBp.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-2Gql6yhw.js → vendor-mdxeditor-Cco3AQJS.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-BZBwE21F.js → vendor-qrcode-Dn3GYC4l.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-CIzhrc1B.js → vendor-virtuoso-CW9EqKMt.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-nSXsYxfo.js → vennDiagram-DHZGUBPP-hTgiYDQL.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-BQ_e66gQ.js → wardley-RL74JXVD-ByDpAPp1.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-BU_Pk5MC.js → wardleyDiagram-NUSXRM2D-D7LJTuWq.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-D0K8r6CO.js → xychartDiagram-5P7HB3ND-MW_KOomO.js} +1 -1
  72. package/dist/index.html +5 -5
  73. package/package.json +1 -1
  74. package/server/i18n.js +60 -0
  75. package/server/interceptor.js +7 -0
  76. package/server/lib/adapters/dingtalk-adapter.js +7 -0
  77. package/server/lib/adapters/discord-adapter.js +9 -1
  78. package/server/lib/adapters/feishu-adapter.js +19 -0
  79. package/server/lib/adapters/wecom-adapter.js +4 -0
  80. package/server/lib/im-bridge-core.js +68 -11
  81. package/server/lib/im-claude-md.js +118 -0
  82. package/server/lib/im-deny.js +100 -0
  83. package/server/lib/im-lock.js +184 -0
  84. package/server/lib/im-process-manager.js +161 -0
  85. package/server/lib/im-senders.js +73 -0
  86. package/server/lib/interceptor-core.js +3 -1
  87. package/server/lib/perm-bridge.js +17 -0
  88. package/server/pty-manager.js +24 -3
  89. package/server/routes/dingtalk.js +38 -13
  90. package/server/routes/im.js +235 -36
  91. package/server/routes/skills.js +180 -165
  92. package/server/server.js +49 -21
  93. package/dist/assets/App-BIHUxfib.css +0 -1
  94. package/dist/assets/App-CG3B88Gg.js +0 -1
  95. package/dist/assets/classDiagram-6PBFFD2Q-D_SztMqP.js +0 -1
  96. package/dist/assets/classDiagram-v2-HSJHXN6E-D_SztMqP.js +0 -1
  97. package/dist/assets/clone-BQLytaFZ.js +0 -1
  98. package/dist/assets/index-BSMKfstl.js +0 -2
  99. package/dist/assets/seqResourceLoaders-IL0QIxz1.js +0 -2
@@ -0,0 +1,161 @@
1
+ // IM 进程管理(父进程侧)—— 由主交互式 ccv 调用,负责把每个启用的 IM 作为 detached 常驻
2
+ // 子进程拉起 / 停止 / 查询状态 / 启动时 reconcile。worker 自身(cli.js runImMode)持有 im.lock;
3
+ // 本模块只读锁 + 探活 + spawn/kill。
4
+ //
5
+ // 设计:detached + unref(主 ccv 关闭后 worker 仍在线);停止时杀**进程组**清理 worker 的
6
+ // Claude PTY + proxy 等孙进程;env 用「前缀剥离 + 白名单回填」杜绝 CCV_*/CCVIEWER_* 泄漏。
7
+ // v1 不做常驻 supervisor(reconcile-on-startup + 配置保存即驱动 已覆盖主路径,且避免多主进程互斗)。
8
+ import { spawn as nodeSpawn, execSync } from 'node:child_process';
9
+ import { openSync, closeSync, mkdirSync } from 'node:fs';
10
+ import { join, dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { listPlatforms, loadConfig } from './im-config.js';
13
+ import {
14
+ imDir, readImLock, clearImLock, isPidAlive, getImLiveness, defaultProbe,
15
+ } from './im-lock.js';
16
+
17
+ const CLI_JS = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'cli.js');
18
+
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ /** 解析真实 node 二进制(Electron 下 process.execPath 是 Electron,需要 `which node`)。 */
22
+ export function resolveNodeBinary() {
23
+ if (!process.versions.electron) return process.execPath;
24
+ try {
25
+ const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8' });
26
+ const p = process.platform === 'win32' ? out.split('\n')[0].trim() : out.trim();
27
+ if (p) return p;
28
+ } catch { /* fall through */ }
29
+ return process.platform === 'win32' ? 'node' : '/usr/local/bin/node';
30
+ }
31
+
32
+ /**
33
+ * 构建 worker 子进程环境(前缀剥离 + 白名单回填)。
34
+ * - 先继承全部 process.env(保住 PATH / HOME / ANTHROPIC_ * / 代理 / locale 等)。
35
+ * - 删除所有 CCV_ 与 CCVIEWER_ 前缀的内部变量(面向未来:新增的 CCV_ 变量也不会泄漏)。
36
+ * 尤其防住:CCV_BYPASS_PERMISSIONS、CCV_ELECTRON_MULTITAB(会让 worker 不起 Claude PTY)、
37
+ * CCV_PASSWORD、CCV_USER_NAME/AVATAR、CCV_PROXY_MODE、CCV_PROJECT_DIR 等误导项。
38
+ * - 仅 CCV_LOG_DIR 在父进程显式设置时回填(共享同一份 prefs 与日志根;生产默认不设,worker 走默认)。
39
+ * - 再写入 worker 专属 env。
40
+ */
41
+ export function buildChildEnv(id, base = process.env) {
42
+ const env = { ...base };
43
+ const inheritedLogDir = base.CCV_LOG_DIR;
44
+ for (const k of Object.keys(env)) {
45
+ if (k.startsWith('CCV_') || k.startsWith('CCVIEWER_')) delete env[k];
46
+ }
47
+ if (inheritedLogDir) env.CCV_LOG_DIR = inheritedLogDir;
48
+ env.CCV_IM_PLATFORM = id;
49
+ env.CCV_START_PORT = '7050';
50
+ env.CCV_MAX_PORT = '7099';
51
+ env.CCV_HOST = '127.0.0.1';
52
+ env.CCV_IM_DENY = '1';
53
+ return env;
54
+ }
55
+
56
+ /**
57
+ * 以 detached 子进程拉起一个 IM worker:`node cli.js --im <id> --no-open`,cwd=IM_<id>/。
58
+ * @param {string} id
59
+ * @param {{ spawnImpl?: Function }} [opts] 测试可注入 spawnImpl
60
+ * @returns {{ pid:number|undefined, dir:string, outLog:string }}
61
+ */
62
+ export function spawnImProcess(id, opts = {}) {
63
+ const spawnImpl = opts.spawnImpl || nodeSpawn;
64
+ const dir = imDir(id);
65
+ mkdirSync(dir, { recursive: true });
66
+ const outLog = join(dir, 'process.out.log');
67
+
68
+ let stdio = 'ignore';
69
+ let fd = null;
70
+ try {
71
+ fd = openSync(outLog, 'a');
72
+ stdio = ['ignore', fd, fd];
73
+ } catch { /* 无法开日志文件时退化为 ignore */ }
74
+
75
+ const child = spawnImpl(resolveNodeBinary(), [CLI_JS, '--im', id, '--no-open'], {
76
+ cwd: dir,
77
+ env: buildChildEnv(id),
78
+ stdio,
79
+ detached: true, // 自成进程组 leader → 停止时可整组杀,清理孙进程
80
+ windowsHide: true,
81
+ });
82
+ try { child.unref?.(); } catch { /* noop */ }
83
+ if (fd !== null) { try { closeSync(fd); } catch { /* 子进程已 dup */ } }
84
+ return { pid: child?.pid, dir, outLog };
85
+ }
86
+
87
+ /** 默认进程组杀(worker 是 detached group leader);失败回退单进程。 */
88
+ function defaultKill(pid, signal) {
89
+ try { process.kill(-pid, signal); return; } catch { /* not a group leader / windows */ }
90
+ try { process.kill(pid, signal); } catch { /* already gone */ }
91
+ }
92
+
93
+ /**
94
+ * 停止一个 IM worker:SIGTERM(worker cleanup 断适配器 + 释放锁)→ 轮询锁释放/进程死 →
95
+ * 超时 SIGKILL 进程组 → 清锁。
96
+ * @param {string} id
97
+ * @param {{ killImpl?:Function, isAlive?:Function, timeoutMs?:number, pollIntervalMs?:number }} [opts]
98
+ */
99
+ export async function stopImProcess(id, opts = {}) {
100
+ const kill = opts.killImpl || defaultKill;
101
+ const isAlive = opts.isAlive || isPidAlive;
102
+ const timeoutMs = opts.timeoutMs ?? 8000;
103
+ const pollMs = opts.pollIntervalMs ?? 200;
104
+
105
+ const lock = readImLock(id);
106
+ if (!lock || !lock.pid || !isAlive(lock.pid)) {
107
+ clearImLock(id);
108
+ return { stopped: true, alreadyDead: true };
109
+ }
110
+
111
+ kill(lock.pid, 'SIGTERM');
112
+
113
+ const deadline = Date.now() + timeoutMs;
114
+ while (Date.now() < deadline) {
115
+ await sleep(pollMs);
116
+ const l = readImLock(id);
117
+ if (!l || l.pid !== lock.pid) return { stopped: true }; // worker 干净退出已释放锁
118
+ if (!isAlive(lock.pid)) { clearImLock(id); return { stopped: true }; }
119
+ }
120
+ kill(lock.pid, 'SIGKILL');
121
+ await sleep(pollMs);
122
+ clearImLock(id);
123
+ return { stopped: true, forced: true };
124
+ }
125
+
126
+ /**
127
+ * 查询某 IM 的进程状态(供 header chip / 路由)。
128
+ * @returns {Promise<{state:string, running:boolean, connected:boolean, pid:number|null, port:number|null, startedAt:string|null}>}
129
+ */
130
+ export async function getImProcessStatus(id, opts = {}) {
131
+ const live = await getImLiveness(id, opts);
132
+ return {
133
+ state: live.state,
134
+ running: live.state === 'ready' || live.state === 'booting' || live.state === 'hung',
135
+ connected: live.state === 'ready' && !!live.connected,
136
+ pid: live.lock?.pid ?? null,
137
+ port: live.lock?.port ?? null,
138
+ startedAt: live.lock?.startedAt ?? null,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * 启动时对账:为每个 enabled 且当前无活进程(state==='dead')的 IM 拉起 worker。幂等(worker 侧 wx 锁兜底)。
144
+ * @returns {Promise<string[]>} 本次拉起的平台 id 列表
145
+ */
146
+ export async function reconcileImProcesses(opts = {}) {
147
+ const spawned = [];
148
+ for (const id of listPlatforms()) {
149
+ let cfg;
150
+ try { cfg = loadConfig(id); } catch { continue; }
151
+ if (!cfg.enabled) continue;
152
+ const live = await getImLiveness(id, opts);
153
+ if (live.state === 'dead') {
154
+ try { spawnImProcess(id, opts); spawned.push(id); }
155
+ catch (e) { console.error(`[CC Viewer] reconcile spawn failed for IM ${id}:`, e?.message || e); }
156
+ }
157
+ }
158
+ return spawned;
159
+ }
160
+
161
+ export { defaultProbe };
@@ -0,0 +1,73 @@
1
+ // IM 发送者身份持久化 —— 每个 IM worker 把解析到的「发送者 senderId → {name, avatar}」写到
2
+ // ~/.claude/cc-viewer/IM_<id>/im-senders.json,供主进程的 /api/im/:platform/senders 读取,
3
+ // 让「对话记录」弹窗能按 senderId 显示真实姓名 + 头像。
4
+ //
5
+ // 设计(与 im-lock.js 同风格):
6
+ // - 原子写:temp + renameSyncWithRetry,读方永不见半写 JSON。
7
+ // - 读容忍:坏 / 缺 / 非对象一律降级为 {},绝不抛。
8
+ // - 容量上限:按 ts 保留最近 MAX_SENDERS 个,避免无限增长(群聊发送者可能很多)。
9
+ // - 这些是本地数据(不入 preferences、不外发),与现有本地日志同级。
10
+ import { openSync, closeSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { imDir } from './im-lock.js';
14
+ import { renameSyncWithRetry } from './file-api.js';
15
+
16
+ export const MAX_SENDERS = 500;
17
+
18
+ function sendersPath(id) { return join(imDir(id), 'im-senders.json'); }
19
+
20
+ /** 读发送者映射;不存在 / 坏 JSON / 非对象 → {}。 */
21
+ export function readSenders(id) {
22
+ try {
23
+ const o = JSON.parse(readFileSync(sendersPath(id), 'utf-8'));
24
+ return (o && typeof o === 'object' && !Array.isArray(o)) ? o : {};
25
+ } catch { return {}; }
26
+ }
27
+
28
+ /** 原子写整张映射(temp + rename)。 */
29
+ function writeSenders(id, map) {
30
+ const dir = imDir(id);
31
+ mkdirSync(dir, { recursive: true });
32
+ const tmp = join(dir, `im-senders.json.tmp-${process.pid}-${randomBytes(4).toString('hex')}`);
33
+ try {
34
+ writeFileSync(tmp, JSON.stringify(map), { mode: 0o600 });
35
+ renameSyncWithRetry(tmp, sendersPath(id));
36
+ } catch (err) {
37
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * upsert 一个发送者。merge 进现有映射,盖上 ts;超过 MAX_SENDERS 时丢弃最旧的。
44
+ * name/avatar 任一为空都接受(部分平台只有名字没头像)。整段失败静默(持久化失败非致命)。
45
+ * @returns {boolean} 是否实际写入
46
+ */
47
+ export function upsertSender(id, senderId, profile = {}) {
48
+ if (typeof senderId !== 'string' || !senderId) return false;
49
+ const name = profile.name != null ? String(profile.name) : null;
50
+ const avatar = profile.avatar != null ? String(profile.avatar) : null;
51
+ try {
52
+ const map = readSenders(id);
53
+ const prev = map[senderId];
54
+ // 无新信息(name/avatar 都没变且都已存在)→ 跳过写盘,省 IO。
55
+ if (prev && prev.name === name && prev.avatar === avatar) return false;
56
+ map[senderId] = { name, avatar, ts: tsNow() };
57
+
58
+ const keys = Object.keys(map);
59
+ if (keys.length > MAX_SENDERS) {
60
+ keys
61
+ .sort((a, b) => (map[a].ts || 0) - (map[b].ts || 0))
62
+ .slice(0, keys.length - MAX_SENDERS)
63
+ .forEach((k) => { delete map[k]; });
64
+ }
65
+ writeSenders(id, map);
66
+ return true;
67
+ } catch {
68
+ return false; // 持久化失败不影响消息流
69
+ }
70
+ }
71
+
72
+ // 单独抽出便于测试注入(避免直接用 Date.now 影响可测性,但保持简单)。
73
+ function tsNow() { return Date.now(); }
@@ -247,7 +247,9 @@ export function createStreamAssembler() {
247
247
  export function findRecentLog(dir, projectName) {
248
248
  try {
249
249
  const files = readdirSync(dir)
250
- .filter(f => f.startsWith(projectName + '_') && f.endsWith('.jsonl'))
250
+ // 排除 *_temp.jsonl:临时文件是未完成的写入态(resume 流程中途产物),
251
+ // 不应被当作"最近完整日志"(否则 _temp 因 sort 排在正式文件之后会被误选)。
252
+ .filter(f => f.startsWith(projectName + '_') && f.endsWith('.jsonl') && !f.endsWith('_temp.jsonl'))
251
253
  .sort()
252
254
  .reverse();
253
255
  if (files.length === 0) return null;
@@ -14,6 +14,7 @@
14
14
  import { readFileSync } from 'node:fs';
15
15
  import http from 'node:http';
16
16
  import https from 'node:https';
17
+ import { evaluateImDeny } from './im-deny.js';
17
18
 
18
19
  const port = process.env.CCVIEWER_PORT;
19
20
  const rawProtocol = process.env.CCVIEWER_PROTOCOL;
@@ -62,6 +63,22 @@ if (!toolName || !toolInput) {
62
63
  const isPublishCmd = toolName === 'Bash' && toolInput.command &&
63
64
  /npm\s+publish/i.test(toolInput.command);
64
65
 
66
+ // IM worker (skip-permissions) 硬拦截 —— 必须在下面的 bypass auto-allow 之前求值,
67
+ // 否则 CCV_BYPASS_PERMISSIONS=1 会把一切先放行(见 plan §安全 2)。仅对 IM worker 生效。
68
+ if (process.env.CCV_IM_DENY === '1') {
69
+ const verdict = evaluateImDeny(toolName, toolInput);
70
+ if (verdict.deny) {
71
+ process.stdout.write(JSON.stringify({
72
+ hookSpecificOutput: {
73
+ hookEventName: 'PreToolUse',
74
+ permissionDecision: 'deny',
75
+ permissionDecisionReason: `cc-viewer IM guard: ${verdict.reason}. 该操作在 IM 机器人(--dangerously-skip-permissions)下被禁止;请改用更安全的方式,或在回复中说明并请用户手动执行。`,
76
+ },
77
+ }) + '\n');
78
+ process.exit(0);
79
+ }
80
+ }
81
+
65
82
  // Bypass mode: auto-allow all tools except publish commands
66
83
  // 使用显式 allow 而非 exit(1) fallback,避免 Claude Code 记录 hook error 日志
67
84
  if (process.env.CCV_BYPASS_PERMISSIONS === '1' && !isPublishCmd) {
@@ -2,7 +2,7 @@ import { resolveNativePath, LOG_DIR } from '../findcc.js';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { chmodSync, statSync } from 'node:fs';
5
- import { platform, arch } from 'node:os';
5
+ import { platform, arch, homedir } from 'node:os';
6
6
  import { createRequire } from 'node:module';
7
7
  import { prepareEmbeddedShellSpawn, stripClaudeNoFlickerUnlessOptedIn } from './lib/terminal-env.js';
8
8
 
@@ -208,11 +208,32 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
208
208
 
209
209
  // 通过 --settings 注入 ANTHROPIC_BASE_URL,确保覆盖 settings.json 中的配置。
210
210
  // 仅覆盖 env.ANTHROPIC_BASE_URL,不影响其他 settings 字段。
211
- const settingsJson = JSON.stringify({
211
+ const settingsObj = {
212
212
  env: {
213
213
  ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL
214
214
  }
215
- });
215
+ };
216
+ // IM worker(skip-permissions)注入 permissions.deny 作为第二道防御(见 plan §安全 3)。
217
+ // 仅追加 deny 规则(deny 优先级最高、只会收紧不会放宽),不会破坏用户既有 permissions。
218
+ // 注:bypass 模式下 deny 是否仍被消费取决于 Claude Code 行为;真正可靠的强制层是
219
+ // perm-bridge.js 的 PreToolUse deny(CCV_IM_DENY)。此处为纵深防御的 best-effort 一层。
220
+ if (process.env.CCV_IM_DENY === '1') {
221
+ const home = homedir();
222
+ settingsObj.permissions = {
223
+ deny: [
224
+ 'Bash(sudo:*)', 'Bash(rm -rf:*)', 'Bash(rm -fr:*)',
225
+ 'Bash(git push:*)', 'Bash(npm publish:*)', 'Bash(ssh:*)', 'Bash(scp:*)',
226
+ `Read(${home}/.ssh/**)`, `Edit(${home}/.ssh/**)`, `Write(${home}/.ssh/**)`,
227
+ `Read(${home}/.aws/**)`, `Edit(${home}/.aws/**)`, `Write(${home}/.aws/**)`,
228
+ // 精确到文件:保护 deny 机制本身(settings/hooks)与 IM 密钥库(preferences.json),
229
+ // 但不封禁整个 ~/.claude —— worker 的工作目录就在 ~/.claude/cc-viewer/IM_<id>/ 下,需保持可写。
230
+ `Edit(${home}/.claude/settings.json)`, `Write(${home}/.claude/settings.json)`,
231
+ `Edit(${home}/.claude/settings.local.json)`, `Write(${home}/.claude/settings.local.json)`,
232
+ `Edit(${home}/.claude/cc-viewer/preferences.json)`, `Write(${home}/.claude/cc-viewer/preferences.json)`,
233
+ ],
234
+ };
235
+ }
236
+ const settingsJson = JSON.stringify(settingsObj);
216
237
 
217
238
  // 注入 --thinking-display summarized;以下任一情况跳过注入:
218
239
  // - 路径在拒绝集里(上次因此 crash 过)
@@ -18,13 +18,19 @@ function readBody(req, deps, cb) {
18
18
  req.on('end', () => cb(body));
19
19
  }
20
20
 
21
- function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
22
- const conn = deps.dingtalk.getBridgeStatus();
21
+ async function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
22
+ // /api/im/:platform/status 一致:worker 报自身在进程适配器状态(manager 据此探活);
23
+ // 主进程经 manager 报 detached worker 的进程/连接状态。
24
+ let conn;
25
+ let processInfo = null;
26
+ if (deps.dingtalk.isWorker) {
27
+ conn = deps.dingtalk.getBridgeStatus();
28
+ } else {
29
+ processInfo = await deps.dingtalk.getProcessStatus();
30
+ conn = { running: processInfo.running, connected: processInfo.connected };
31
+ }
23
32
  res.writeHead(200, JSON_HEADERS);
24
33
  if (!isLocal) {
25
- // Loopback gate: a token-authorized LAN client must not see the appKey, the staffId
26
- // allowlist, the bound conversation id, or raw error strings. Expose only the minimum the
27
- // header status chip needs. (config/test are already loopback-only.)
28
34
  res.end(JSON.stringify({
29
35
  enabled: loadDingTalkState().enabled,
30
36
  hasSecret: loadDingTalkState().hasSecret,
@@ -32,9 +38,14 @@ function dingtalkStatus(req, res, parsedUrl, isLocal, deps) {
32
38
  }));
33
39
  return;
34
40
  }
35
- // 本机(127.0.0.1)= admin:附带明文 appSecret 供本人查阅/复制(镜像 /api/auth/state 的密码、
36
- // /api/proxy-profiles 的 apiKey 策略)。上方 !isLocal 分支只下发 hasSecret,绝不下发 secret。
37
- res.end(JSON.stringify({ ...loadDingTalkState(), appSecret: loadDingTalkConfig().appSecret, connection: conn }));
41
+ // 本机(127.0.0.1)= admin / manager 探活:附带明文 appSecret pid(供身份匹配)。
42
+ res.end(JSON.stringify({
43
+ ...loadDingTalkState(),
44
+ appSecret: loadDingTalkConfig().appSecret,
45
+ connection: conn,
46
+ process: processInfo,
47
+ pid: deps.dingtalk.isWorker ? process.pid : (processInfo?.pid ?? null),
48
+ }));
38
49
  }
39
50
 
40
51
  function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
@@ -44,7 +55,7 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
44
55
  res.end(JSON.stringify({ error: 'Loopback only' }));
45
56
  return;
46
57
  }
47
- readBody(req, deps, (body) => {
58
+ readBody(req, deps, async (body) => {
48
59
  let incoming;
49
60
  try { incoming = JSON.parse(body); }
50
61
  catch {
@@ -52,8 +63,17 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
52
63
  res.end(JSON.stringify({ error: 'Invalid JSON' }));
53
64
  return;
54
65
  }
66
+ // 发送者白名单为非必填:启用时若 allowStaffIds 为空不再硬拦截(前端弹安全警告)。worker 以
67
+ // --dangerously-skip-permissions 运行,空白名单运行期退化为 bind-first-conversation。打一条
68
+ // 服务端审计(curl/headless 启用走不到前端 toast);PreToolUse permissions.deny 硬拦截仍生效。
69
+ const staff = Array.isArray(incoming.allowStaffIds)
70
+ ? incoming.allowStaffIds.filter((s) => typeof s === 'string' && s.trim())
71
+ : [];
72
+ if (incoming.enabled && staff.length === 0) {
73
+ console.warn('[CC Viewer] IM dingtalk enabled with EMPTY allowlist — bind-first-conversation; the first conversation to message can drive this --dangerously-skip-permissions session');
74
+ }
55
75
  // saveDingTalkConfig preserves the stored secret when appSecret is empty/omitted.
56
- saveDingTalkConfig({
76
+ const saved = saveDingTalkConfig({
57
77
  enabled: incoming.enabled,
58
78
  appKey: incoming.appKey,
59
79
  appSecret: incoming.appSecret,
@@ -61,10 +81,15 @@ function dingtalkConfigPost(req, res, parsedUrl, isLocal, deps) {
61
81
  maxChunkChars: incoming.maxChunkChars,
62
82
  blockOnSkipPermissions: incoming.blockOnSkipPermissions,
63
83
  });
64
- // Apply immediately: stop the old Stream connection and (re)start with the new config.
65
- Promise.resolve(deps.dingtalk.reloadBridge()).catch(() => {});
84
+ // 驱动进程管理器(替代旧的在进程 reloadBridge):启用→重启 worker,停用→停 worker。
85
+ try {
86
+ if (saved?.enabled ?? incoming.enabled) await deps.dingtalk.restartProcess();
87
+ else await deps.dingtalk.stopProcess();
88
+ } catch (e) {
89
+ console.error('[CC Viewer] IM config apply failed for dingtalk:', e?.message || e);
90
+ }
66
91
  res.writeHead(200, JSON_HEADERS);
67
- res.end(JSON.stringify({ ...loadDingTalkState(), connection: deps.dingtalk.getBridgeStatus() }));
92
+ res.end(JSON.stringify({ ...loadDingTalkState(), connection: { running: !!(saved?.enabled ?? incoming.enabled), connected: false } }));
68
93
  });
69
94
  }
70
95