cc-viewer 1.6.300 → 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 -14
- package/dist/assets/{App-DO_8O7_4.js → App-CsXuZvCc.js} +1 -1
- package/dist/assets/{MdxEditorPanel-kMkQ3-Hd.js → MdxEditorPanel-B-y66Flk.js} +1 -1
- package/dist/assets/{Mobile-DYGGuCPh.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/package.json +1 -1
- package/server/i18n.js +56 -36
- package/server/lib/base-path.js +34 -0
- package/server/lib/log-watcher.js +7 -1
- package/server/lib/term-signals.js +80 -0
- package/server/pty-manager.js +9 -1
- package/server/routes/misc.js +5 -1
- package/server/scratch-pty-manager.js +6 -1
- package/server/server.js +49 -32
- package/dist/assets/index-BXb1GO0R.js +0 -2
- package/dist/assets/seqResourceLoaders-BJ_EK_tf.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/package.json
CHANGED
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",
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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/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;
|
package/server/routes/misc.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Miscellaneous small routes (moved verbatim from server.js handleRequest).
|
|
2
2
|
import { getUserProfile } from '../lib/user-profile.js';
|
|
3
3
|
import { runWaterfallHook } from '../lib/plugin-loader.js';
|
|
4
|
+
import { normalizeBasePath } from '../lib/base-path.js';
|
|
4
5
|
|
|
5
6
|
async function userProfile(req, res) {
|
|
6
7
|
const profile = await getUserProfile();
|
|
@@ -10,7 +11,10 @@ async function userProfile(req, res) {
|
|
|
10
11
|
|
|
11
12
|
async function localUrl(req, res, parsedUrl, isLocal, deps) {
|
|
12
13
|
const localIp = deps.getLocalIp();
|
|
13
|
-
|
|
14
|
+
// 反代子路径部署时分享/二维码 URL 也要带前缀,否则扫码绕过代理直连源站端口(与
|
|
15
|
+
// server.startedNetwork 启动打印保持一致)。未设 CCV_BASE_PATH 时为空串,行为不变。
|
|
16
|
+
const basePath = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
17
|
+
const defaultUrl = `${deps.protocol}://${localIp}:${deps.actualPort}${basePath}?token=${deps.ACCESS_TOKEN}`;
|
|
14
18
|
const hookResult = await runWaterfallHook('localUrl', { url: defaultUrl, ip: localIp, port: deps.actualPort, token: deps.ACCESS_TOKEN });
|
|
15
19
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
16
20
|
res.end(JSON.stringify({ url: hookResult.url }));
|
|
@@ -4,6 +4,7 @@ import { chmodSync, statSync } from 'node:fs';
|
|
|
4
4
|
import { platform, arch, homedir } from 'node:os';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { prepareEmbeddedShellSpawn } from './lib/terminal-env.js';
|
|
7
|
+
import { killPtyTree } from './lib/term-signals.js';
|
|
7
8
|
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = dirname(__filename);
|
|
@@ -219,7 +220,11 @@ export function killScratch(id) {
|
|
|
219
220
|
flushBatch(s);
|
|
220
221
|
s.batchBuffer = '';
|
|
221
222
|
s.batchScheduled = false;
|
|
222
|
-
|
|
223
|
+
// 与 pty-manager.killPty 同款:win32 用 taskkill /T 收割 ConPTY 树,
|
|
224
|
+
// 绕开 node-pty kill 的同步挂起问题;非 Windows 走原路径。
|
|
225
|
+
if (!killPtyTree(s.ptyProcess.pid)) {
|
|
226
|
+
try { s.ptyProcess.kill(); } catch { }
|
|
227
|
+
}
|
|
223
228
|
s.ptyProcess = null;
|
|
224
229
|
}
|
|
225
230
|
// 显式 kill 后整条记录清掉,监听器一并丢弃(前端 ws.close 也会清)
|
package/server/server.js
CHANGED
|
@@ -78,6 +78,8 @@ import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher
|
|
|
78
78
|
import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
|
|
79
79
|
import { cleanupExtractCache } from './lib/jsonl-archive.js';
|
|
80
80
|
import { backupConfigs } from './lib/config-backup.js';
|
|
81
|
+
import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
|
|
82
|
+
import { createHardenedCleanup } from './lib/term-signals.js';
|
|
81
83
|
import { createBackpressureGate } from './lib/ws-backpressure.js';
|
|
82
84
|
|
|
83
85
|
|
|
@@ -567,12 +569,13 @@ async function handleRequest(req, res) {
|
|
|
567
569
|
let url = parsedUrl.pathname;
|
|
568
570
|
|
|
569
571
|
// CCV_BASE_PATH reverse proxy: strip prefix at TOP so API/WS/static/SPA
|
|
570
|
-
// all work with original unprefixed paths.
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
572
|
+
// all work with original unprefixed paths. 剥离后必须写回 parsedUrl.pathname ——
|
|
573
|
+
// dispatch()(routes/_dispatch.js)与多个 handler(files-content/ask-perm/im)直读
|
|
574
|
+
// parsedUrl.pathname 做路由匹配和偏移 slice,不写回则前缀下全部 /api/* 与 SSE /events
|
|
575
|
+
// 命不中、落 SPA fallback(PR #108 遗留 P0)。searchParams 不受 pathname 赋值影响。
|
|
576
|
+
const bp = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
577
|
+
url = stripBasePath(url, bp);
|
|
578
|
+
parsedUrl.pathname = url;
|
|
576
579
|
const method = req.method;
|
|
577
580
|
|
|
578
581
|
// WebSocket 路径不处理,交给 upgrade 事件
|
|
@@ -677,14 +680,9 @@ async function handleRequest(req, res) {
|
|
|
677
680
|
|
|
678
681
|
// 静态文件服务
|
|
679
682
|
if (method === 'GET') {
|
|
680
|
-
|
|
681
|
-
//
|
|
682
|
-
// incorrectly matching /proxy/ws-other due to startsWith ambiguity.
|
|
683
|
-
const basePath = rawBase && rawBase !== '/' ? rawBase.replace(/\/?$/, '/') : '';
|
|
683
|
+
// basePath 已在 handleRequest 顶部统一剥离,这里不可再剥——否则 /proxy/proxy/x
|
|
684
|
+
// 这类路径会被双重剥离。
|
|
684
685
|
let filePath = url;
|
|
685
|
-
if (basePath && url.startsWith(basePath)) {
|
|
686
|
-
filePath = url.slice(basePath.length) || '/';
|
|
687
|
-
}
|
|
688
686
|
if (filePath === '/') filePath = '/index.html';
|
|
689
687
|
// 去掉 query string
|
|
690
688
|
filePath = filePath.split('?')[0];
|
|
@@ -728,12 +726,12 @@ async function handleRequest(req, res) {
|
|
|
728
726
|
html = html.replace(/<html([^>]*?)data-theme="[^"]*"/, `<html$1data-theme="${themeColor}"`);
|
|
729
727
|
// 运行时注入 <base> 标签:当 CCV_BASE_PATH 设置为非空非根路径时,
|
|
730
728
|
// 使浏览器将所有相对 URL 解析到代理子路径下。配合 Vite base='' 输出相对路径。
|
|
731
|
-
const
|
|
732
|
-
if (
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
729
|
+
const injectBase = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
730
|
+
if (injectBase) {
|
|
731
|
+
const escapedBase = injectBase.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
732
|
+
// JS 双引号字符串转义:\ → \\、" → \"、</ → <\/(防 </script> 提前闭合)
|
|
733
|
+
const jsSafeBase = injectBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/<\//g, '<\\/');
|
|
734
|
+
html = html.replace(/<head[^>]*>/i, m => m + `<base href="${escapedBase}"><script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`);
|
|
737
735
|
}
|
|
738
736
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
739
737
|
res.end(html);
|
|
@@ -877,17 +875,25 @@ export async function startViewer() {
|
|
|
877
875
|
if (_prefs.lang) setLang(_prefs.lang);
|
|
878
876
|
}
|
|
879
877
|
} catch { /* 读 prefs 失败就保持默认语言 */ }
|
|
878
|
+
// CCV_BASE_PATH 配置校验:缺前导 '/' 时剥离静默失效(startsWith 永不命中),
|
|
879
|
+
// 启动期告警一次。放在 setLang 之后,告警语言才跟随用户配置。
|
|
880
|
+
{
|
|
881
|
+
const _bpCheck = validateBasePath(process.env.CCV_BASE_PATH);
|
|
882
|
+
if (_bpCheck.warning) console.warn(t(_bpCheck.warning, { value: process.env.CCV_BASE_PATH }));
|
|
883
|
+
}
|
|
880
884
|
// interceptor.js runs in this same process (via proxy.js → setupInterceptor).
|
|
881
885
|
// Inject live-port via module-level setter instead of process.env to avoid
|
|
882
886
|
// polluting env of child_process.spawn descendants (Bash tools / MCP / Electron tabs).
|
|
883
887
|
setLivePort(port, serverProtocol);
|
|
884
|
-
|
|
888
|
+
// 自动打开/serverStarted hook 用的 URL 也要带反代前缀(与启动打印一致)
|
|
889
|
+
const url = `${serverProtocol}://127.0.0.1:${port}${normalizeBasePath(process.env.CCV_BASE_PATH)}`;
|
|
885
890
|
if (!isCliMode) {
|
|
886
891
|
console.error(t('server.started'));
|
|
887
|
-
|
|
892
|
+
const _bp = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
893
|
+
console.error(t('server.startedLocal', { protocol: serverProtocol, port, basePath: _bp }));
|
|
888
894
|
const _ips = getAllLocalIps();
|
|
889
895
|
for (const _ip of _ips) {
|
|
890
|
-
console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, token: ACCESS_TOKEN }));
|
|
896
|
+
console.error(t('server.startedNetwork', { protocol: serverProtocol, ip: _ip, port, basePath: _bp, token: ACCESS_TOKEN }));
|
|
891
897
|
}
|
|
892
898
|
if (authConfig.enabled) {
|
|
893
899
|
if (authConfig.password === '') console.error(t('server.passwordEmptyWarn'));
|
|
@@ -1064,12 +1070,8 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1064
1070
|
|
|
1065
1071
|
httpServer.on('upgrade', (req, socket, head) => {
|
|
1066
1072
|
const wsUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
const wsBp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
|
|
1070
|
-
if (wsBp && pathname.startsWith(wsBp)) {
|
|
1071
|
-
pathname = '/' + pathname.slice(wsBp.length);
|
|
1072
|
-
}
|
|
1073
|
+
// upgrade 不经 handleRequest,basePath 需独立剥离(与 HTTP 段同走统一函数)
|
|
1074
|
+
let pathname = stripBasePath(wsUrl.pathname, normalizeBasePath(process.env.CCV_BASE_PATH));
|
|
1073
1075
|
// 与 HTTP 一致的鉴权(此前 WS upgrade 完全不校验 token,远程终端实为无门禁——本次堵洞)。
|
|
1074
1076
|
// 在此显式计算 isLocal(与 handleRequest 同款三态判断),WS 视作非 HTML 请求。
|
|
1075
1077
|
const wsRemoteIp = req.socket.remoteAddress;
|
|
@@ -1929,8 +1931,20 @@ async function _doStop() {
|
|
|
1929
1931
|
_lastCliActive = false;
|
|
1930
1932
|
// Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
|
|
1931
1933
|
// leaks a second WS to the same app. Idempotent + swallows errors.
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
+
// IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
|
|
1935
|
+
// Windows 上 IM bridge WS teardown 挂住会卡死整条退出链(原本裸 await 是
|
|
1936
|
+
// "Ctrl+C 完全无反应"的 B 类成因);两段若各自 3s race 串行最坏 6s,会越过
|
|
1937
|
+
// cleanup watchdog(5s) 截断其后的 temp jsonl rename(用户数据)——合并为单预算
|
|
1938
|
+
// 保证 teardown ≤3s,watchdog 前始终留出 rename 余量。超时后控制流顺序继续。
|
|
1939
|
+
try {
|
|
1940
|
+
await Promise.race([
|
|
1941
|
+
(async () => {
|
|
1942
|
+
try { await imCore.stopAll(); } catch { }
|
|
1943
|
+
await runParallelHook('serverStopping');
|
|
1944
|
+
})(),
|
|
1945
|
+
new Promise(r => setTimeout(r, 3000)),
|
|
1946
|
+
]);
|
|
1947
|
+
} catch { }
|
|
1934
1948
|
// 如果用户未做选择,将临时文件转为正式文件
|
|
1935
1949
|
if (_resumeState && _resumeState.tempFile) {
|
|
1936
1950
|
try {
|
|
@@ -2100,6 +2114,9 @@ function handleExit() {
|
|
|
2100
2114
|
if (!globalThis._ccvServerSignalsRegistered) {
|
|
2101
2115
|
globalThis._ccvServerSignalsRegistered = true;
|
|
2102
2116
|
process.on('exit', handleExit);
|
|
2103
|
-
|
|
2104
|
-
|
|
2117
|
+
// hardened:watchdog 5s 强退 + 重复触发立退(防 Windows 上 stopViewer 内部
|
|
2118
|
+
// await 挂住导致 .finally(exit) 永不执行 = Ctrl+C 完全无反应)。
|
|
2119
|
+
const _hardenedStop = createHardenedCleanup({ doCleanup: () => stopViewer() });
|
|
2120
|
+
process.on('SIGINT', _hardenedStop);
|
|
2121
|
+
process.on('SIGTERM', _hardenedStop);
|
|
2105
2122
|
}
|