cc-viewer 1.6.299 → 1.6.301
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/cli.js +26 -87
- package/dist/assets/{App-CCSG-Uk4.js → App-CsXuZvCc.js} +1 -1
- package/dist/assets/{MdxEditorPanel-CEwcZJSb.js → MdxEditorPanel-B-y66Flk.js} +1 -1
- package/dist/assets/{Mobile-EHy38ALw.js → Mobile-O0bfec7C.js} +1 -1
- package/dist/assets/index-DpIkVZv8.js +2 -0
- package/dist/assets/seqResourceLoaders-DpUrNd29.js +2 -0
- package/dist/index.html +1 -1
- package/findcc.js +19 -0
- package/package.json +4 -4
- package/server/i18n.js +56 -36
- package/server/interceptor.js +24 -8
- package/server/lib/async-write-queue.js +30 -4
- package/server/lib/base-path.js +34 -0
- package/server/lib/config-backup.js +54 -0
- package/server/lib/im-process-manager.js +14 -0
- package/server/lib/interceptor-core.js +40 -0
- package/server/lib/log-watcher.js +7 -1
- package/server/lib/sdk-manager.js +4 -0
- package/server/lib/term-signals.js +80 -0
- package/server/lib/updater.js +18 -1
- package/server/pty-manager.js +9 -1
- package/server/routes/events.js +37 -15
- package/server/routes/misc.js +5 -1
- package/server/routes/preferences.js +5 -0
- package/server/scratch-pty-manager.js +6 -1
- package/server/server.js +66 -33
- package/dist/assets/index-DWYtIFvu.js +0 -2
- package/dist/assets/seqResourceLoaders-DYyvUcvx.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
+
<script type="module" crossorigin src="/assets/index-DpIkVZv8.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
|
package/findcc.js
CHANGED
|
@@ -26,6 +26,18 @@ export function getClaudeConfigDir() {
|
|
|
26
26
|
const raw = envDir.trim();
|
|
27
27
|
return raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : resolve(raw);
|
|
28
28
|
}
|
|
29
|
+
// ████████ 测试隔离铁闸 L1b —— 绝对不可移除(2026-06-06 数据事故防再犯)████████
|
|
30
|
+
// CCV_LOG_DIR=tmp 只重定向 LOG_DIR,管不到本函数:updater.js 的 CACHE_DIR=
|
|
31
|
+
// join(getClaudeConfigDir(),'cc-viewer') 在测试里直指真实 ~/.claude/cc-viewer,
|
|
32
|
+
// branch-lib-updater.test.js 的 rmSync(CACHE_DIR,{recursive}) 曾因此把用户 40GB
|
|
33
|
+
// 历史日志整树删除(2026-06-06 第 1/4 次事故确证真凶)。settings.json、ensure-hooks、
|
|
34
|
+
// ~/.claude/* 展开全部派生于此 —— 测试态(node:test 注入 NODE_TEST_CONTEXT)未显式
|
|
35
|
+
// 设 CLAUDE_CONFIG_DIR 时,一律走进程私有临时目录,绝不解析到真实 ~/.claude。
|
|
36
|
+
// 单测:test/logdir-test-guard.test.js。
|
|
37
|
+
if (process.env.NODE_TEST_CONTEXT) {
|
|
38
|
+
return join(tmpdir(), 'cc-viewer-test', `guard-cfg-${process.pid}-${threadId}`);
|
|
39
|
+
}
|
|
40
|
+
// ████████████████████████████████████████████████████████████████████████████
|
|
29
41
|
return join(homedir(), '.claude');
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -40,6 +52,13 @@ function resolveLogDir() {
|
|
|
40
52
|
const expanded = raw.startsWith('~/') ? join(homedir(), raw.slice(2)) : raw;
|
|
41
53
|
return resolve(expanded);
|
|
42
54
|
}
|
|
55
|
+
// 测试隔离铁闸:node:test 环境(NODE_TEST_CONTEXT 由测试 runner 自动注入,spread env
|
|
56
|
+
// 的子进程会继承)下若未显式设 CCV_LOG_DIR,绝不解析到真实用户目录——强制进程私有临时
|
|
57
|
+
// 目录。2026-06-06 事故:无 env 的测试探针把真实 ~/.claude/cc-viewer 当 LOG_DIR,
|
|
58
|
+
// 清理逻辑将用户数据整树删除。任何测试运行不允许触碰在用的文件体系和本地存储。
|
|
59
|
+
if (process.env.NODE_TEST_CONTEXT) {
|
|
60
|
+
return join(tmpdir(), 'cc-viewer-test', `guard-${process.pid}-${threadId}`);
|
|
61
|
+
}
|
|
43
62
|
return join(getClaudeConfigDir(), 'cc-viewer');
|
|
44
63
|
}
|
|
45
64
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.301",
|
|
4
4
|
"description": "Claude Code Logger visualization management tool",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
"pretest": "npm run lint:control-bytes",
|
|
23
23
|
"pretest:coverage": "npm run lint:control-bytes",
|
|
24
24
|
"pretest:coverage:html": "npm run lint:control-bytes",
|
|
25
|
-
"test": "CCV_LOG_DIR=tmp node --test --test-force-exit",
|
|
26
|
-
"test:coverage": "CCV_LOG_DIR=tmp node --test --test-force-exit --experimental-test-coverage --test-coverage-include='server/**/*.js' --test-coverage-include='src/utils/**/*.js' --test-coverage-include='*.js'",
|
|
27
|
-
"test:coverage:html": "CCV_LOG_DIR=tmp c8 --reporter=text-summary --reporter=html node --test",
|
|
25
|
+
"test": "CCV_LOG_DIR=tmp CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 node --test --test-force-exit --test-timeout=120000",
|
|
26
|
+
"test:coverage": "CCV_LOG_DIR=tmp CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 node --test --test-force-exit --test-timeout=120000 --experimental-test-coverage --test-coverage-include='server/**/*.js' --test-coverage-include='src/utils/**/*.js' --test-coverage-include='*.js'",
|
|
27
|
+
"test:coverage:html": "CCV_LOG_DIR=tmp CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 c8 --reporter=text-summary --reporter=html node --test --test-force-exit --test-timeout=120000",
|
|
28
28
|
"prepublishOnly": "npm run build",
|
|
29
29
|
"electron:dev": "electron electron/main.js",
|
|
30
30
|
"electron:build": "npm run build && electron-builder",
|
package/server/i18n.js
CHANGED
|
@@ -448,44 +448,44 @@ const i18nData = {
|
|
|
448
448
|
"uk": "\nCC Viewer запущено:"
|
|
449
449
|
},
|
|
450
450
|
"server.startedLocal": {
|
|
451
|
-
"zh": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
452
|
-
"en": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
453
|
-
"zh-TW": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
454
|
-
"ko": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
455
|
-
"ja": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
456
|
-
"de": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
457
|
-
"es": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
458
|
-
"fr": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
459
|
-
"it": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
460
|
-
"da": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
461
|
-
"pl": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
462
|
-
"ru": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
463
|
-
"ar": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
464
|
-
"no": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
465
|
-
"pt-BR": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
466
|
-
"th": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
467
|
-
"tr": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
468
|
-
"uk": " ➜ Local: {protocol}://127.0.0.1:{port}"
|
|
451
|
+
"zh": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
452
|
+
"en": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
453
|
+
"zh-TW": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
454
|
+
"ko": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
455
|
+
"ja": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
456
|
+
"de": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
457
|
+
"es": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
458
|
+
"fr": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
459
|
+
"it": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
460
|
+
"da": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
461
|
+
"pl": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
462
|
+
"ru": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
463
|
+
"ar": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
464
|
+
"no": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
465
|
+
"pt-BR": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
466
|
+
"th": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
467
|
+
"tr": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
468
|
+
"uk": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}"
|
|
469
469
|
},
|
|
470
470
|
"server.startedNetwork": {
|
|
471
|
-
"zh": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
472
|
-
"en": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
473
|
-
"zh-TW": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
474
|
-
"ko": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
475
|
-
"ja": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
476
|
-
"de": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
477
|
-
"es": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
478
|
-
"fr": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
479
|
-
"it": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
480
|
-
"da": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
481
|
-
"pl": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
482
|
-
"ru": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
483
|
-
"ar": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
484
|
-
"no": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
485
|
-
"pt-BR": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
486
|
-
"th": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
487
|
-
"tr": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
488
|
-
"uk": " ➜ Network: {protocol}://{ip}:{port}?token={token}"
|
|
471
|
+
"zh": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
472
|
+
"en": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
473
|
+
"zh-TW": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
474
|
+
"ko": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
475
|
+
"ja": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
476
|
+
"de": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
477
|
+
"es": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
478
|
+
"fr": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
479
|
+
"it": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
480
|
+
"da": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
481
|
+
"pl": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
482
|
+
"ru": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
483
|
+
"ar": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
484
|
+
"no": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
485
|
+
"pt-BR": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
486
|
+
"th": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
487
|
+
"tr": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
488
|
+
"uk": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}"
|
|
489
489
|
},
|
|
490
490
|
"server.passwordActive": {
|
|
491
491
|
"zh": " 🔒 密码保护已开启,密码: {password}",
|
|
@@ -527,6 +527,26 @@ const i18nData = {
|
|
|
527
527
|
"tr": " ⚠️ Parola boş: uzaktan erişim doğrulama gerektirmez — güvenlik riski",
|
|
528
528
|
"uk": " ⚠️ Пароль порожній: віддалений доступ не потребує перевірки — загроза безпеці"
|
|
529
529
|
},
|
|
530
|
+
"basePath.missingLeadingSlash": {
|
|
531
|
+
"zh": " ⚠️ CCV_BASE_PATH \"{value}\" 必须以 \"/\" 开头,已忽略。示例:\"/proxy/\"",
|
|
532
|
+
"en": " ⚠️ CCV_BASE_PATH \"{value}\" must start with \"/\" — ignored. Use e.g. \"/proxy/\".",
|
|
533
|
+
"zh-TW": " ⚠️ CCV_BASE_PATH \"{value}\" 必須以 \"/\" 開頭,已忽略。範例:\"/proxy/\"",
|
|
534
|
+
"ko": " ⚠️ CCV_BASE_PATH \"{value}\"는 \"/\"로 시작해야 합니다 — 무시됨. 예: \"/proxy/\"",
|
|
535
|
+
"ja": " ⚠️ CCV_BASE_PATH \"{value}\" は \"/\" で始まる必要があります — 無視されました。例: \"/proxy/\"",
|
|
536
|
+
"de": " ⚠️ CCV_BASE_PATH \"{value}\" muss mit \"/\" beginnen — ignoriert. Beispiel: \"/proxy/\"",
|
|
537
|
+
"es": " ⚠️ CCV_BASE_PATH \"{value}\" debe comenzar con \"/\" — ignorado. Ejemplo: \"/proxy/\"",
|
|
538
|
+
"fr": " ⚠️ CCV_BASE_PATH \"{value}\" doit commencer par \"/\" — ignoré. Exemple : \"/proxy/\"",
|
|
539
|
+
"it": " ⚠️ CCV_BASE_PATH \"{value}\" deve iniziare con \"/\" — ignorato. Esempio: \"/proxy/\"",
|
|
540
|
+
"da": " ⚠️ CCV_BASE_PATH \"{value}\" skal starte med \"/\" — ignoreret. Eksempel: \"/proxy/\"",
|
|
541
|
+
"pl": " ⚠️ CCV_BASE_PATH \"{value}\" musi zaczynać się od \"/\" — zignorowano. Przykład: \"/proxy/\"",
|
|
542
|
+
"ru": " ⚠️ CCV_BASE_PATH \"{value}\" должен начинаться с \"/\" — проигнорировано. Пример: \"/proxy/\"",
|
|
543
|
+
"ar": " ⚠️ يجب أن يبدأ CCV_BASE_PATH \"{value}\" بـ \"/\" — تم التجاهل. مثال: \"/proxy/\"",
|
|
544
|
+
"no": " ⚠️ CCV_BASE_PATH \"{value}\" må starte med \"/\" — ignorert. Eksempel: \"/proxy/\"",
|
|
545
|
+
"pt-BR": " ⚠️ CCV_BASE_PATH \"{value}\" deve começar com \"/\" — ignorado. Exemplo: \"/proxy/\"",
|
|
546
|
+
"th": " ⚠️ CCV_BASE_PATH \"{value}\" ต้องขึ้นต้นด้วย \"/\" — ถูกละเว้น ตัวอย่าง: \"/proxy/\"",
|
|
547
|
+
"tr": " ⚠️ CCV_BASE_PATH \"{value}\" \"/\" ile başlamalıdır — yok sayıldı. Örnek: \"/proxy/\"",
|
|
548
|
+
"uk": " ⚠️ CCV_BASE_PATH \"{value}\" має починатися з \"/\" — проігноровано. Приклад: \"/proxy/\""
|
|
549
|
+
},
|
|
530
550
|
"server.auth.loginTitle": {
|
|
531
551
|
"zh": "请输入访问密码",
|
|
532
552
|
"en": "Enter access password",
|
package/server/interceptor.js
CHANGED
|
@@ -16,7 +16,7 @@ import { homedir } from 'node:os';
|
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
import { dirname, join, basename } from 'node:path';
|
|
18
18
|
import { LOG_DIR } from '../findcc.js';
|
|
19
|
-
import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg } from './lib/interceptor-core.js';
|
|
19
|
+
import { assembleStreamMessage, createStreamAssembler, cleanupTempFiles, findRecentLog, isAnthropicApiPath, isMainAgentRequest, rotateLogFile, fingerprintMsg, replaceTopLevelModel } from './lib/interceptor-core.js';
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
|
|
@@ -780,15 +780,31 @@ export function setupInterceptor() {
|
|
|
780
780
|
}
|
|
781
781
|
}
|
|
782
782
|
}
|
|
783
|
-
// 3. Model 替换
|
|
783
|
+
// 3. Model 替换 —— 避免对整条 wire body(-c 重启后全量 checkpoint 可达数十 MB)
|
|
784
|
+
// 二次 JSON.parse + 全量 re-stringify:用 L557 已解析的 body 读旧值,对原始
|
|
785
|
+
// 字符串做有界定向替换(唯一非歧义匹配才生效);定位失败回退旧 parse 路径,
|
|
786
|
+
// 最坏退化为现状。注意不能复用 requestEntry.body 重建 wire —— delta 路径
|
|
787
|
+
// (上方 needsCheckpoint=false 分支)已把它的 messages 改成增量切片。
|
|
784
788
|
if (_activeProfile.activeModel && _fetchOpts?.body) {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
789
|
+
const _oldModel = (body && typeof body === 'object') ? body.model : undefined;
|
|
790
|
+
if (_oldModel === _activeProfile.activeModel) {
|
|
791
|
+
// 已是目标 model,跳过(旧路径会原样 re-stringify,对上游等价)
|
|
792
|
+
} else {
|
|
793
|
+
const _replaced = (typeof _fetchOpts.body === 'string' && typeof _oldModel === 'string')
|
|
794
|
+
? replaceTopLevelModel(_fetchOpts.body, _oldModel, _activeProfile.activeModel)
|
|
795
|
+
: null;
|
|
796
|
+
if (_replaced !== null) {
|
|
797
|
+
_fetchOpts = { ..._fetchOpts, body: _replaced };
|
|
798
|
+
} else {
|
|
799
|
+
try {
|
|
800
|
+
const _b = JSON.parse(_fetchOpts.body);
|
|
801
|
+
if (_b.model) {
|
|
802
|
+
_b.model = _activeProfile.activeModel;
|
|
803
|
+
_fetchOpts = { ..._fetchOpts, body: JSON.stringify(_b) };
|
|
804
|
+
}
|
|
805
|
+
} catch { }
|
|
790
806
|
}
|
|
791
|
-
}
|
|
807
|
+
}
|
|
792
808
|
}
|
|
793
809
|
// 记录 proxy 信息到日志条目
|
|
794
810
|
requestEntry.proxyProfile = _activeProfile.name;
|
|
@@ -4,13 +4,15 @@
|
|
|
4
4
|
import { appendFile } from 'node:fs/promises';
|
|
5
5
|
import { appendFileSync } from 'node:fs';
|
|
6
6
|
|
|
7
|
-
const HIGH_WATER_MARK = 50 * 1024 * 1024; // 50MB — 超过此值降级为同步写入
|
|
7
|
+
const HIGH_WATER_MARK = 50 * 1024 * 1024; // 50MB — backlog 超过此值降级为同步写入
|
|
8
|
+
const WRITE_CHUNK_BYTES = 8 * 1024 * 1024; // 8MB — 单次 async append 上限,巨条分块让出 FS handle
|
|
8
9
|
|
|
9
10
|
export class AsyncWriteQueue {
|
|
10
11
|
/**
|
|
11
12
|
* @param {string|(() => string)} pathOrGetter - 文件路径或返回路径的函数(支持动态路径)
|
|
12
13
|
* @param {object} [opts]
|
|
13
14
|
* @param {boolean} [opts.syncMode] - 强制同步模式
|
|
15
|
+
* @param {number} [opts.highWaterMark] - backlog 同步降级阈值(默认 50MB;测试用)
|
|
14
16
|
*/
|
|
15
17
|
constructor(pathOrGetter, opts = {}) {
|
|
16
18
|
this._pathOrGetter = pathOrGetter;
|
|
@@ -21,6 +23,9 @@ export class AsyncWriteQueue {
|
|
|
21
23
|
this._closed = false;
|
|
22
24
|
this._flushResolvers = []; // resolve() callbacks waiting for flush()
|
|
23
25
|
this._syncMode = opts.syncMode || !!process.env.CCV_SYNC_WRITES;
|
|
26
|
+
// typeof 防御:字符串 "1000" 能通过 > 0 协转检查,但后续字节比较会退化为字典序
|
|
27
|
+
this._highWaterMark = (typeof opts.highWaterMark === 'number' && opts.highWaterMark > 0)
|
|
28
|
+
? opts.highWaterMark : HIGH_WATER_MARK;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
_getPath() {
|
|
@@ -38,13 +43,21 @@ export class AsyncWriteQueue {
|
|
|
38
43
|
return;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
// 同步降级是 backlog 的内存压力保护,不是按条规则:
|
|
47
|
+
// - 单条超大 buffer(-c 重启后的全量 checkpoint,可达数十 MB)必须走异步 ——
|
|
48
|
+
// 对它 appendFileSync 正是 Windows event loop 卡死点;其内存代价 ≈ 字符串本就在内存。
|
|
49
|
+
// - drain 在途时绝不抢同步(正确性硬约束):_drain 现按 8MB 分块写,appendFileSync
|
|
50
|
+
// 插队会落在分块中间、把条目撕裂进另一条 JSON 内部。又因"队列非空 ⇒ 必已 scheduleDrain
|
|
51
|
+
// ⇒ _draining=true",这条 backlog 同步分支实际只是防御性兜底,正常运行不会触发。
|
|
52
|
+
const byteLen = Buffer.byteLength(data);
|
|
53
|
+
const wouldExceed = this._pendingBytes + byteLen >= this._highWaterMark;
|
|
54
|
+
const oversizedSingle = byteLen >= this._highWaterMark;
|
|
55
|
+
if (this._syncMode || (wouldExceed && !oversizedSingle && !this._draining)) {
|
|
42
56
|
try { appendFileSync(path, data); } catch {}
|
|
43
57
|
if (onDone) try { onDone(); } catch {}
|
|
44
58
|
return;
|
|
45
59
|
}
|
|
46
60
|
|
|
47
|
-
const byteLen = Buffer.byteLength(data);
|
|
48
61
|
this._queue.push({ path, data, onDone });
|
|
49
62
|
this._pendingBytes += byteLen;
|
|
50
63
|
this._scheduleDrain();
|
|
@@ -108,7 +121,20 @@ export class AsyncWriteQueue {
|
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
try {
|
|
111
|
-
|
|
124
|
+
// 巨条分块:单次 async append 超过 8MB 时按字节切片顺序写,
|
|
125
|
+
// 避免一次巨型写独占 FS handle(Windows NTFS 上拖垮其它 I/O)。
|
|
126
|
+
// 必须按 Buffer 字节切(而非字符串 slice),否则多字节 UTF-8 字符
|
|
127
|
+
// 跨界会被 per-call 编码撕裂成乱码。顺序 await 保证落盘顺序。
|
|
128
|
+
// Buffer.from 对超大 combined 有一次性内存翻倍(30MB 串 → +30MB Buffer),
|
|
129
|
+
// 循环结束即释放,等价于字符串自身的量级,可接受。
|
|
130
|
+
const buf = Buffer.from(combined, 'utf-8');
|
|
131
|
+
if (buf.length <= WRITE_CHUNK_BYTES) {
|
|
132
|
+
await appendFile(path, buf);
|
|
133
|
+
} else {
|
|
134
|
+
for (let pos = 0; pos < buf.length; pos += WRITE_CHUNK_BYTES) {
|
|
135
|
+
await appendFile(path, buf.subarray(pos, pos + WRITE_CHUNK_BYTES));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
112
138
|
} catch {}
|
|
113
139
|
for (const cb of callbacks) {
|
|
114
140
|
try { cb(); } catch {}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// CCV_BASE_PATH(反向代理子路径部署)的统一 normalize / validate / strip。
|
|
2
|
+
// 纯函数,无副作用,server.js(HTTP 剥离 / <base> 注入 / WS upgrade)与 cli.js(启动 URL
|
|
3
|
+
// 打印)共用,消除各处复制粘贴的 normalize 逻辑。vite.config.js 不复用(构建期 base 有
|
|
4
|
+
// `undefined→'/'` 的三态语义,与运行时"未设=无前缀"不同,见该文件注释)。
|
|
5
|
+
|
|
6
|
+
// 规范化 basePath:未设 / 空串 / 根 '/' → ''(无前缀);其余补尾斜杠('/proxy' → '/proxy/')。
|
|
7
|
+
// 尾斜杠是 startsWith 匹配的防歧义关键('/proxy/' 不会误命中 '/proxyextra/x')。
|
|
8
|
+
// 缺前导 '/' 的非法值('proxy/x')也返回 ''(忽略)——否则注入段会产出相对 <base> 破坏页面;
|
|
9
|
+
// 告警由 validateBasePath 在启动期负责。
|
|
10
|
+
export function normalizeBasePath(raw) {
|
|
11
|
+
if (!raw || raw === '/' || !raw.startsWith('/')) return '';
|
|
12
|
+
// 剥裸换行符:含 \n/\r 的值会把 index.html 注入的 JS 字符串断成语法错误(页面级 DoS)
|
|
13
|
+
return raw.replace(/[\r\n]/g, '').replace(/\/?$/, '/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 校验 + 规范化。非空但缺前导 '/'(如 'proxy/x')属配置错误:startsWith 永不命中、剥离
|
|
17
|
+
// 静默失效。不自动补 '/' —— 自动修正会掩盖与代理侧前缀的错配,更难排查;这里选择
|
|
18
|
+
// 忽略(按无前缀工作)并返回 i18n key 供启动期 console.warn。
|
|
19
|
+
export function validateBasePath(raw) {
|
|
20
|
+
if (raw && raw !== '/' && !raw.startsWith('/')) {
|
|
21
|
+
return { ok: false, normalized: '', warning: 'basePath.missingLeadingSlash' };
|
|
22
|
+
}
|
|
23
|
+
return { ok: true, normalized: normalizeBasePath(raw), warning: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 从请求 pathname 剥掉 basePath 前缀,保证结果带前导 '/'(路由表按 '/api/...' 匹配)。
|
|
27
|
+
// slice(length - 1) 让 normalizedBase 的尾斜杠留作结果的前导斜杠:
|
|
28
|
+
// stripBasePath('/proxy/api/x', '/proxy/') → '/api/x'
|
|
29
|
+
// stripBasePath('/proxy/', '/proxy/') → '/'
|
|
30
|
+
// 不匹配(含裸前缀 '/proxy' 无尾斜杠的访问)原样返回,由调用方按 SPA/404 处理。
|
|
31
|
+
export function stripBasePath(pathname, normalizedBase) {
|
|
32
|
+
if (!normalizedBase || !pathname.startsWith(normalizedBase)) return pathname;
|
|
33
|
+
return pathname.slice(normalizedBase.length - 1) || '/';
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 启动期配置备份 — 2026-06-06 LOG_DIR 整树删除事故防再犯。
|
|
2
|
+
// preferences.json(auth/IM token/偏好)、profile.json(代理热切换)、workspaces.json(工作区注册表)
|
|
3
|
+
// 是无法从日志/会话重建的"静态设置";本模块在 server 启动时把它们备份到 LOG_DIR **之外**
|
|
4
|
+
// 的兄弟目录(默认 ~/.claude/cc-viewer-config-backups/<时间戳>/),滚动保留最近 KEEP 份。
|
|
5
|
+
// 全程 best-effort:任何失败只返回 {ok:false},绝不抛错阻塞启动。
|
|
6
|
+
import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { LOG_DIR } from '../../findcc.js';
|
|
9
|
+
|
|
10
|
+
const CONFIG_FILES = ['preferences.json', 'profile.json', 'workspaces.json'];
|
|
11
|
+
const KEEP = 10;
|
|
12
|
+
// 备份子目录名:严格时间戳形态。prune 只删匹配此形态的目录,绝不波及其它内容。
|
|
13
|
+
const STAMP_RE = /^\d{8}_\d{6}$/;
|
|
14
|
+
|
|
15
|
+
export function getBackupRoot(logDir = LOG_DIR) {
|
|
16
|
+
return join(dirname(logDir), 'cc-viewer-config-backups');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 备份 logDir 下的配置文件到 getBackupRoot()/<YYYYMMDD_HHMMSS>/,并滚动清理。
|
|
21
|
+
* @param {string} [logDir] 默认 findcc 的 LOG_DIR(live binding)
|
|
22
|
+
* @param {Date} [now] 注入时钟,便于测试
|
|
23
|
+
* @returns {{ok:boolean, dir?:string, copied?:string[], pruned?:number, error?:string}}
|
|
24
|
+
*/
|
|
25
|
+
export function backupConfigs(logDir = LOG_DIR, now = new Date()) {
|
|
26
|
+
try {
|
|
27
|
+
const candidates = CONFIG_FILES.filter((f) => existsSync(join(logDir, f)));
|
|
28
|
+
if (!candidates.length) return { ok: true, copied: [], pruned: 0 };
|
|
29
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace('T', '_').slice(0, 15);
|
|
30
|
+
const root = getBackupRoot(logDir);
|
|
31
|
+
const dir = join(root, stamp);
|
|
32
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
33
|
+
const copied = [];
|
|
34
|
+
for (const f of candidates) {
|
|
35
|
+
try {
|
|
36
|
+
copyFileSync(join(logDir, f), join(dir, f));
|
|
37
|
+
// preferences 携带密钥,备份份保持 0600(copyFileSync 跟随 umask,需显式收紧)
|
|
38
|
+
chmodSync(join(dir, f), 0o600);
|
|
39
|
+
copied.push(f);
|
|
40
|
+
} catch { /* 单文件失败不影响其它 */ }
|
|
41
|
+
}
|
|
42
|
+
let pruned = 0;
|
|
43
|
+
try {
|
|
44
|
+
const entries = readdirSync(root).filter((d) => STAMP_RE.test(d)).sort();
|
|
45
|
+
while (entries.length > KEEP) {
|
|
46
|
+
rmSync(join(root, entries.shift()), { recursive: true, force: true });
|
|
47
|
+
pruned++;
|
|
48
|
+
}
|
|
49
|
+
} catch { /* prune 失败不影响本次备份 */ }
|
|
50
|
+
return { ok: true, dir, copied, pruned };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, error: err?.message || String(err) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -60,6 +60,20 @@ export function buildChildEnv(id, base = process.env) {
|
|
|
60
60
|
* @returns {{ pid:number|undefined, dir:string, outLog:string }}
|
|
61
61
|
*/
|
|
62
62
|
export function spawnImProcess(id, opts = {}) {
|
|
63
|
+
// ████████ 测试隔离铁闸 L4 —— 绝对不可移除(2026-06-06 数据事故防再犯)████████
|
|
64
|
+
// 单元测试【绝不允许】拉起真实 detached IM worker:
|
|
65
|
+
// 1) detached worker 脱离测试生命周期常驻(PPID=1),测试结束后仍在运行;
|
|
66
|
+
// 2) buildChildEnv 按设计剥离 CCV_*,worker 子链最终把真实 ~/.claude/cc-viewer 当 LOG_DIR;
|
|
67
|
+
// 3) 测试与用户真实 worker 在锁/端口(7050-7099)/配置上互相干扰——
|
|
68
|
+
// 2026-06-06 该链条曾三次把用户 40GB 历史日志整树删除。
|
|
69
|
+
// 注入 spawnImpl 的纯单测(假 spawn)不受影响;确需真实 spawn 的集成测试必须显式
|
|
70
|
+
// CCV_TEST_ALLOW_IM_SPAWN=1 并自行负责完全隔离(私有 CCV_LOG_DIR + 私有端口窗)。
|
|
71
|
+
if (process.env.NODE_TEST_CONTEXT && !opts.spawnImpl && process.env.CCV_TEST_ALLOW_IM_SPAWN !== '1') {
|
|
72
|
+
const dir = imDir(id);
|
|
73
|
+
console.warn(`[im-process-manager] 测试环境拒绝真实 spawn IM worker '${id}'(L4 铁闸;如确需请设 CCV_TEST_ALLOW_IM_SPAWN=1)`);
|
|
74
|
+
return { pid: undefined, dir, outLog: join(dir, 'process.out.log'), blockedByTestGuard: true };
|
|
75
|
+
}
|
|
76
|
+
// ████████████████████████████████████████████████████████████████████████
|
|
63
77
|
const spawnImpl = opts.spawnImpl || nodeSpawn;
|
|
64
78
|
const dir = imDir(id);
|
|
65
79
|
mkdirSync(dir, { recursive: true });
|
|
@@ -400,3 +400,43 @@ export function rotateLogFile(currentFile, newFile, maxSize) {
|
|
|
400
400
|
} catch { }
|
|
401
401
|
return { rotated: false };
|
|
402
402
|
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* 在原始 JSON 字符串上定向替换顶层 "model" 字段的值,避免对巨型 wire body
|
|
406
|
+
* (`-c` 重启后的全量 checkpoint 请求可达数十 MB)做二次 JSON.parse + 全量 re-stringify。
|
|
407
|
+
*
|
|
408
|
+
* 安全性依据:
|
|
409
|
+
* - JSON 字符串值内的引号必然转义为 \",裸 `"model":"<old>"` 字节序列只能出现在真实结构处;
|
|
410
|
+
* - 候选必须满足成员边界(前一个非空白字符是 `{` 或 `,`);
|
|
411
|
+
* - 顶层 model 恒存在恒命中 → 嵌套对象若有同值 model 键则候选 ≥2 → 返回 null 由调用方
|
|
412
|
+
* 回退 parse/stringify 旧路径(最坏退化为现状,绝不误改)。
|
|
413
|
+
*
|
|
414
|
+
* @param {string} jsonStr - 原始 wire body(紧凑或带单空格的 JSON 字符串)
|
|
415
|
+
* @param {string} oldModel - 当前顶层 model 值(来自已解析的 body.model)
|
|
416
|
+
* @param {string} newModel - 目标 model 值
|
|
417
|
+
* @returns {string|null} 替换后的字符串;无法唯一定位时返回 null(调用方回退)
|
|
418
|
+
*/
|
|
419
|
+
export function replaceTopLevelModel(jsonStr, oldModel, newModel) {
|
|
420
|
+
if (typeof jsonStr !== 'string' || typeof oldModel !== 'string' || !oldModel ||
|
|
421
|
+
typeof newModel !== 'string' || !newModel) return null;
|
|
422
|
+
const oldVal = JSON.stringify(oldModel);
|
|
423
|
+
// 覆盖紧凑(JSON.stringify 默认)与冒号后单空格两种序列化形态;其它形态 → 0 候选 → 回退
|
|
424
|
+
const needles = [`"model":${oldVal}`, `"model": ${oldVal}`];
|
|
425
|
+
const candidates = [];
|
|
426
|
+
for (const needle of needles) {
|
|
427
|
+
let idx = jsonStr.indexOf(needle);
|
|
428
|
+
while (idx !== -1) {
|
|
429
|
+
// 成员边界校验:前一个非空白字符必须是 { 或 ,
|
|
430
|
+
let p = idx - 1;
|
|
431
|
+
while (p >= 0 && (jsonStr[p] === ' ' || jsonStr[p] === '\t' || jsonStr[p] === '\n' || jsonStr[p] === '\r')) p--;
|
|
432
|
+
if (p >= 0 && (jsonStr[p] === '{' || jsonStr[p] === ',')) {
|
|
433
|
+
candidates.push({ idx, needle });
|
|
434
|
+
}
|
|
435
|
+
idx = jsonStr.indexOf(needle, idx + 1);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (candidates.length !== 1) return null;
|
|
439
|
+
const { idx, needle } = candidates[0];
|
|
440
|
+
const replaced = needle.slice(0, needle.length - oldVal.length) + JSON.stringify(newModel);
|
|
441
|
+
return jsonStr.slice(0, idx) + replaced + jsonStr.slice(idx + needle.length);
|
|
442
|
+
}
|
|
@@ -249,10 +249,16 @@ function _scheduleDebouncedRead(fileState) {
|
|
|
249
249
|
}, FSWATCH_DEBOUNCE_MS);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// 测试注入缝(仿 updater fetchImpl 惯例):替换轮询分支的 watchFile 实现。
|
|
253
|
+
// 真实 watchFile 的 stat 基线与 500ms 间隔在 CI 慢机上不可确定性驱动(基线竞态曾致
|
|
254
|
+
// 25s 全程静默 flake),单测注入假实现手动触发回调即可零时序覆盖。生产恒为 node:fs 原版。
|
|
255
|
+
let _watchFileImpl = watchFile;
|
|
256
|
+
export function __setWatchFileImplForTests(fn) { _watchFileImpl = fn || watchFile; }
|
|
257
|
+
|
|
252
258
|
function _fallbackToPolling(fileState) {
|
|
253
259
|
if (fileState.polling) return;
|
|
254
260
|
fileState.polling = true;
|
|
255
|
-
|
|
261
|
+
_watchFileImpl(fileState.logFile, { interval: 500 }, () => {
|
|
256
262
|
_readDelta(fileState);
|
|
257
263
|
});
|
|
258
264
|
}
|
|
@@ -19,6 +19,10 @@ try {
|
|
|
19
19
|
console.warn('[SDK] Agent SDK not available:', err.message);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Test seam — inject a fake query() (仿 im-bridge-core.js 的 __setFetchForTests 惯例).
|
|
23
|
+
// 仅供单测注入 fake async-generator,不改任何业务逻辑。
|
|
24
|
+
export function __setQueryForTests(fn) { _query = fn; }
|
|
25
|
+
|
|
22
26
|
// Interactive tool names — filtered from entries, handled via canUseTool → WS
|
|
23
27
|
const INTERACTIVE_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode']);
|
|
24
28
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Windows Ctrl+C 退出链三层防御(macOS/Linux 行为不变)。
|
|
2
|
+
// 背景:cli.js / server.js 的 SIGINT handler 都是 `doCleanup → .finally(process.exit)` 形态,
|
|
3
|
+
// 注册 handler 后 Node 不再默认退出 —— 链上任何一环挂住(node-pty ConPTY kill 同步挂起、
|
|
4
|
+
// IM bridge teardown await 无超时、SIGINT 事件被 ConPTY 吞掉)进程就永不退出,
|
|
5
|
+
// 即 Windows 用户报告的 "Ctrl+C 完全无反应"。
|
|
6
|
+
// 三函数全部依赖注入(仿 log-watcher __setWatchFileImplForTests 惯例),纯逻辑可单测。
|
|
7
|
+
|
|
8
|
+
import { spawnSync as _spawnSync } from 'node:child_process';
|
|
9
|
+
import { emitKeypressEvents as _emitKeypressEvents } from 'node:readline';
|
|
10
|
+
|
|
11
|
+
// 把 `doCleanup → exit` 包装成幂等 + 双保险的 cleanup:
|
|
12
|
+
// 首次触发:先武装 watchdog(watchdogMs 后强制 exit(130),unref 不滞留事件循环),
|
|
13
|
+
// 再 try/catch 跑 doCleanup(同步抛错不阻断退出),完成后 exit()。
|
|
14
|
+
// 二次触发(用户连按 Ctrl+C):立即 exit(130),不再等优雅收尾。
|
|
15
|
+
// watchdogMs 默认 5s:_doStop 内 IM teardown + serverStopping hook 共用一个 3s 总预算
|
|
16
|
+
// (见 server.js _doStop 的合并 race),其后还有 rename temp jsonl(用户数据)要顺序
|
|
17
|
+
// 执行 —— watchdog 必须 > 3s 总预算 + rename 余量,避免中途截断。
|
|
18
|
+
export function createHardenedCleanup({ doCleanup, exit = process.exit, setTimeoutImpl = setTimeout, watchdogMs = 5000 }) {
|
|
19
|
+
let invoked = false;
|
|
20
|
+
return function hardenedCleanup() {
|
|
21
|
+
if (invoked) { exit(130); return; }
|
|
22
|
+
invoked = true;
|
|
23
|
+
const watchdog = setTimeoutImpl(() => exit(130), watchdogMs);
|
|
24
|
+
if (watchdog && typeof watchdog.unref === 'function') watchdog.unref();
|
|
25
|
+
try {
|
|
26
|
+
const r = doCleanup();
|
|
27
|
+
if (r && typeof r.then === 'function') {
|
|
28
|
+
r.then(() => exit(), () => exit(130));
|
|
29
|
+
} else {
|
|
30
|
+
// 同步 doCleanup:完成即退(统一契约"doCleanup 结束 → exit";当前所有调用方
|
|
31
|
+
// 都返回 promise 走上面分支,此分支为前向兼容)
|
|
32
|
+
exit();
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
exit(130);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Windows 下 ConPTY/控制台事件链偶发吞掉 Ctrl+C(SIGINT 永不送达)的兜底:
|
|
41
|
+
// 把本地终端 stdin 切 raw mode 监听 keypress,\x03(Ctrl+C)/\x04(Ctrl+D) 直连 onInterrupt。
|
|
42
|
+
// 注意:raw mode 下控制台**不再产生 SIGINT**,onInterrupt 必须直接是 hardened cleanup
|
|
43
|
+
// 本体(不能 re-emit SIGINT —— 那条路在 raw mode 下已死)。
|
|
44
|
+
// 仅 win32 且 stdin 是 TTY 时安装(silent/PTY 与 SDK 模式本地终端本就无人读 stdin,
|
|
45
|
+
// 无副作用;proxy 模式 stdio:'inherit' 子进程持有控制台,调用方不得安装)。
|
|
46
|
+
// 进程退出时通过 'exit' 同步钩子恢复 cooked mode,防 Windows 终端残留 raw 态
|
|
47
|
+
// (watchdog 的 exit(130) 同样触发 'exit' 钩子)。
|
|
48
|
+
export function installWinKeypressFallback({ stdin = process.stdin, onInterrupt, platform = process.platform, emitKeypressEvents = _emitKeypressEvents }) {
|
|
49
|
+
if (platform !== 'win32' || !stdin || !stdin.isTTY) return null;
|
|
50
|
+
emitKeypressEvents(stdin);
|
|
51
|
+
try { stdin.setRawMode(true); } catch { return null; }
|
|
52
|
+
const restore = () => { try { stdin.setRawMode(false); } catch { /* noop */ } };
|
|
53
|
+
process.on('exit', restore);
|
|
54
|
+
const onKeypress = (str, key) => {
|
|
55
|
+
const isCtrlC = (key && key.ctrl && key.name === 'c') || str === '\u0003';
|
|
56
|
+
const isCtrlD = (key && key.ctrl && key.name === 'd') || str === '\u0004';
|
|
57
|
+
if (isCtrlC || isCtrlD) onInterrupt();
|
|
58
|
+
};
|
|
59
|
+
stdin.on('keypress', onKeypress);
|
|
60
|
+
stdin.resume();
|
|
61
|
+
return () => {
|
|
62
|
+
stdin.off('keypress', onKeypress);
|
|
63
|
+
restore();
|
|
64
|
+
process.off('exit', restore);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Windows 下杀掉 PTY 进程树(ConPTY agent + claude)。
|
|
69
|
+
// 用 spawnSync taskkill 而非 ptyProcess.kill():后者在 ConPTY 下有已知同步挂起问题
|
|
70
|
+
// (microsoft/node-pty#454 等),且 killPty 的 respawn 调用方(spawnClaude 内部
|
|
71
|
+
// kill→立即重启、workspaces stop→launch)需要"返回时进程已死"的同步语义 ——
|
|
72
|
+
// taskkill /F 通常 <200ms,timeout 2s 兜底有界,不会废掉 5s watchdog。
|
|
73
|
+
// 非 win32 返回 false,调用方走原 ptyProcess.kill() 路径。
|
|
74
|
+
export function killPtyTree(pid, { platform = process.platform, spawnSyncImpl = _spawnSync } = {}) {
|
|
75
|
+
if (platform !== 'win32' || !pid) return false;
|
|
76
|
+
try {
|
|
77
|
+
spawnSyncImpl('taskkill', ['/pid', String(pid), '/T', '/F'], { timeout: 2000, windowsHide: true });
|
|
78
|
+
} catch { /* taskkill 不在 PATH 等极端情况,调用方仍有 watchdog 兜底 */ }
|
|
79
|
+
return true;
|
|
80
|
+
}
|
package/server/lib/updater.js
CHANGED
|
@@ -140,7 +140,7 @@ export function isAnyCcvBusy({ currentPid, busy, portRange, lsofImpl } = {}) {
|
|
|
140
140
|
// 却被当显式传值,从而绕过自动检测,让 brew 用户被错误地走 npm 路径升级。
|
|
141
141
|
//
|
|
142
142
|
// 返回 status:
|
|
143
|
-
// disabled | skipped | latest | major_available | deferred_busy
|
|
143
|
+
// disabled | skipped | skipped_test_context | latest | major_available | deferred_busy
|
|
144
144
|
// | brew_managed | upgrading_in_background | error
|
|
145
145
|
export async function checkAndUpdate(options = {}) {
|
|
146
146
|
const fetchImpl = options.fetchImpl || fetch;
|
|
@@ -164,6 +164,23 @@ export async function checkAndUpdate(options = {}) {
|
|
|
164
164
|
return { status: 'skipped', currentVersion, remoteVersion: null };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
// ████████ 测试隔离铁闸 L5 —— 绝对不可移除(2026-06-06 数据事故防再犯)████████
|
|
168
|
+
// 单元测试【绝不允许】发送真实网络请求:起真实 server 的测试,启动链的 30s 定时器
|
|
169
|
+
// (server.js startViewer)会走到这里,真打 registry.npmjs.org,同大版本空闲时甚至
|
|
170
|
+
// 触发 detached `npm install` 真实自更新——测试能改写用户的 cc-viewer 安装。
|
|
171
|
+
// 条件说明:
|
|
172
|
+
// - NODE_TEST_CONTEXT:node:test runner 自动注入,覆盖测试进程及 spread env 子进程;
|
|
173
|
+
// - NODE_ENV==='test' 兜底:覆盖测试用 spawnSync 起的孙进程(NODE_TEST_CONTEXT 跨代
|
|
174
|
+
// 传递不保证,如 branch-server.test.js 的 runScenario);
|
|
175
|
+
// - 注入 fetchImpl 的 updater 单测(假网络)不受影响,走正常逻辑;
|
|
176
|
+
// - 闸位必须在 disabled/skipped 早退【之后】、真实 fetch【之前】:放函数入口会劫持
|
|
177
|
+
// 存量无-fetchImpl 用例对 disabled/skipped 的断言。
|
|
178
|
+
// 守卫单测:test/updater-test-guard.test.js。
|
|
179
|
+
if ((process.env.NODE_TEST_CONTEXT || process.env.NODE_ENV === 'test') && !options.fetchImpl) {
|
|
180
|
+
return { status: 'skipped_test_context', currentVersion, remoteVersion: null };
|
|
181
|
+
}
|
|
182
|
+
// ████████████████████████████████████████████████████████████████████████████
|
|
183
|
+
|
|
167
184
|
try {
|
|
168
185
|
const res = await fetchImpl('https://registry.npmjs.org/cc-viewer');
|
|
169
186
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
package/server/pty-manager.js
CHANGED
|
@@ -5,6 +5,7 @@ import { chmodSync, statSync } from 'node:fs';
|
|
|
5
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
|
+
import { killPtyTree } from './lib/term-signals.js';
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = dirname(__filename);
|
|
@@ -474,7 +475,14 @@ export function killPty() {
|
|
|
474
475
|
flushBatch();
|
|
475
476
|
batchBuffer = '';
|
|
476
477
|
batchScheduled = false;
|
|
477
|
-
|
|
478
|
+
// Windows:node-pty 的 ConPTY kill 有已知同步挂起问题(microsoft/node-pty#454),
|
|
479
|
+
// 挂住会连 Ctrl+C 退出链的 watchdog 一起废掉。改用 spawnSync taskkill /T /F 收割
|
|
480
|
+
// 整棵进程树(ConPTY agent + claude),有界(timeout 2s)且提供"返回时已死"语义
|
|
481
|
+
// (spawnClaude 内部 kill→respawn、workspaces stop→launch 依赖这一点)。
|
|
482
|
+
// win32 下完全跳过 ptyProcess.kill()。非 Windows 行为不变。
|
|
483
|
+
if (!killPtyTree(ptyProcess.pid)) {
|
|
484
|
+
try { ptyProcess.kill(); } catch { }
|
|
485
|
+
}
|
|
478
486
|
ptyProcess = null;
|
|
479
487
|
ptyKind = null;
|
|
480
488
|
ptySkipPermissions = false;
|