-
Notifications
You must be signed in to change notification settings - Fork 122
Expand file tree
/
Copy pathserver.js
More file actions
2424 lines (2298 loc) · 118 KB
/
Copy pathserver.js
File metadata and controls
2424 lines (2298 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { createServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { createConnection } from 'node:net';
import { randomBytes } from 'node:crypto';
import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync } from 'node:fs';
import { join, extname, resolve, sep } from 'node:path';
import { platform, networkInterfaces, tmpdir } from 'node:os';
import { execFile, exec, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { Worker } from 'node:worker_threads';
import { isPathContained } from './lib/file-api.js';
import { setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, loadAskStore as askStoreLoad } from './lib/ask-store.js';
import { ASK_TIMEOUT_MS, ASK_WAITER_REAP_INTERVAL_MS, ASK_WAITER_LIVENESS_MS } from './lib/ask-constants.js';
import { reapDeadAskWaiters, sweepOrphanedDiskAsks } from './lib/ask-reaper.js';
import { sdkApprovalCloseType } from './lib/sdk-adapter.js';
import { DIST_DIR, NODE_MODULES } from './_paths.js';
import { createDispatcher } from './routes/_dispatch.js';
import { projectMetaRoutes } from './routes/project-meta.js';
import { miscRoutes } from './routes/misc.js';
import { preferencesRoutes } from './routes/preferences.js';
import { projectPrefsRoutes } from './routes/project-prefs.js';
import { sessionPinRoutes } from './routes/session-pin.js';
import { gitRoutes } from './routes/git.js';
import { pluginsRoutes } from './routes/plugins.js';
import { logsRoutes } from './routes/logs.js';
import { voicePackRoutes } from './routes/voice-pack.js';
import { skillsRoutes } from './routes/skills.js';
import { ultraAgentsRoutes } from './routes/ultra-agents.js';
import { filesContentRoutes } from './routes/files-content.js';
import { workflowJournalRoutes } from './routes/workflow-journal.js';
import { filesFsRoutes } from './routes/files-fs.js';
import { searchRoutes } from './routes/search.js';
import { workspacesRoutes } from './routes/workspaces.js';
import { expertRoutes } from './routes/expert.js';
import { eventsRoutes } from './routes/events.js';
import { askPermRoutes } from './routes/ask-perm.js';
import { teamRoutes } from './routes/team.js';
import { authRoutes } from './routes/auth.js';
import { dingtalkRoutes } from './routes/dingtalk.js';
import { imRoutes } from './routes/im.js';
import * as imCore from './lib/im-bridge-core.js';
import * as imProcMgr from './lib/im-process-manager.js';
import './lib/adapters/dingtalk-adapter.js'; // side-effect: registers the DingTalk adapter
import './lib/adapters/feishu-adapter.js'; // side-effect: registers the Feishu adapter
import './lib/adapters/wecom-adapter.js'; // side-effect: registers the WeCom adapter
import './lib/adapters/discord-adapter.js'; // side-effect: registers the Discord adapter
import { loadConfig } from './lib/im-config.js';
// Windows:git.exe / cmd.exe 等 console-subsystem 子进程从无控制台的 worker node.exe 启动时
// 会各弹一个可见控制台窗口(diff/status 轮询路径高频闪现)。在 promisify 包装层统一默认
// windowsHide(POSIX 上为 no-op,调用方传入可覆盖)。deps.execFileAsync 注入下游路由同样受益。
const _execFileAsyncRaw = promisify(execFile);
const execFileAsync = (cmd, args, opts) => _execFileAsyncRaw(cmd, args, { windowsHide: true, ...opts });
const _execAsyncRaw = promisify(exec);
const execAsync = (cmd, opts) => _execAsyncRaw(cmd, { windowsHide: true, ...opts });
// execFile with stdin input support (for git check-ignore --stdin)
function execWithStdin(cmd, args, input, options) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
let stdout = '';
let stderr = '';
child.stdout.on('data', d => { stdout += d; });
child.stderr.on('data', d => { stderr += d; });
child.on('error', reject);
child.on('close', code => {
// git check-ignore exits 1 when no files are ignored — treat as success
resolve(stdout);
});
if (options?.timeout) {
setTimeout(() => { try { child.kill(); } catch {} reject(new Error('timeout')); }, options.timeout);
}
child.stdin.write(input);
child.stdin.end();
});
}
import { LOG_FILE, _initPromise, _resumeState, _projectName, _logDir, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
import { recordInstance, listInstances } from './lib/instance-registry.js';
import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
import { t, getLang, setLang } from './i18n.js';
import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
import { checkAndUpdate } from './lib/updater.js';
import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
import { createImLogWatcher } from './lib/im-log-watcher.js';
import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
import { cleanupExtractCache } from './lib/jsonl-archive.js';
import { backupConfigs } from './lib/config-backup.js';
import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
import { createHardenedCleanup } from './lib/term-signals.js';
import { createBackpressureGate } from './lib/ws-backpressure.js';
import { createFloodCoalescer, envIntAllowZero } from './lib/pty-flood-coalescer.js';
import { createResyncNudgeGate } from './lib/resync-nudge-gate.js';
// 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
// 启动时一次性读取 ~/.claude/settings.json(不 watch)
let claudeSettings = {};
// SSR theme 注入自检状态:模板缺 data-theme 时仅首次 warn(避免高 QPS 刷屏)
let _ssrThemeAttrWarned = false;
let _indexHtmlCache = null; // { html: string, mtime: number }
try {
const settingsPath = join(getClaudeConfigDir(), 'settings.json');
if (existsSync(settingsPath)) {
claudeSettings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
}
} catch { }
const isCliMode = process.env.CCV_CLI_MODE === '1';
const isSdkMode = process.env.CCV_SDK_MODE === '1';
const isWorkspaceMode = process.env.CCV_WORKSPACE_MODE === '1';
// Instance id from `ccv --pid <name>` (sanitized in cli.js). null = default mode (no
// per-instance isolation): the session-pin file falls back to the project-shared key.
const INSTANCE_ID = process.env.CCV_INSTANCE_ID || null;
// Remember this id under the project so the CLI banner can list past ids next run (memory
// aid only). Fire-and-forget; no-ops when there's no active project (e.g. workspace mode).
if (INSTANCE_ID && _logDir) recordInstance(_logDir, INSTANCE_ID).catch(() => {});
const _defaultProxyProfiles = { active: 'max', profiles: [{ id: 'max', name: 'Default' }] };
const _maskApiKey = (k) => k && typeof k === 'string' && k.length > 4 ? '****' + k.slice(-4) : k ? '****' : '';
const _maskProfiles = (data) => {
if (!data?.profiles) return data;
return { ...data, profiles: data.profiles.map(p => p.apiKey ? { ...p, apiKey: _maskApiKey(p.apiKey) } : p) };
};
const _isMasked = (k) => typeof k === 'string' && /^\*{4}.{0,4}$/.test(k);
// 获取 Claude 进程 PID(CLI 模式下从 pty-manager 获取)
let _getPtyPidFn = null;
function getClaudePid() {
if (!isCliMode) return process.pid;
if (_getPtyPidFn) return _getPtyPidFn();
// lazy load 尚未完成,尝试同步获取(pty-manager 可能已被其他路径加载)
return null;
}
if (isCliMode) {
import('./pty-manager.js').then(m => {
_getPtyPidFn = m.getPtyPid;
}).catch(err => {
console.error('[CC Viewer] Failed to load pty-manager for PID tracking:', err.message);
});
}
// 统一的文件/目录忽略规则(仅隐藏系统和版本控制目录)
const IGNORED_PATTERNS = new Set([
'.git', '.svn', '.hg', '.DS_Store',
'.idea', '.vscode'
]);
// 多 git 仓库支持:解析 repo 参数为安全的 cwd 路径
function resolveRepoCwd(repoParam) {
const projectDir = process.env.CCV_PROJECT_DIR || process.cwd();
if (!repoParam || repoParam === '.') return projectDir;
if (repoParam.includes('/') || repoParam.includes('..') || repoParam.includes('\\')) return null;
const candidate = join(projectDir, repoParam);
try {
if (!existsSync(candidate) || !statSync(candidate).isDirectory()) return null;
if (!existsSync(join(candidate, '.git'))) return null;
if (!isPathContained(candidate, projectDir)) return null;
} catch { return null; }
return candidate;
}
// 工作区模式:保存 Claude 额外参数,供 launch API 使用
let _workspaceClaudeArgs = [];
let _workspaceClaudePath = null;
let _workspaceIsNpmVersion = false;
let _workspaceLaunched = false; // 工作区是否已经启动了会话
// Ask hook bridge state (for PreToolUse AskUserQuestion hook)
// Map supports concurrent ask requests (sub-agents / teammates) so a stale unanswered
// ask never blocks the next one. Keyed by server-generated id.
const pendingAskHooks = new Map(); // Map<id, { questions, res, timer, createdAt }>
// 1000 远超任何合理并发场景;保留 LRU 仅作为防恶意/bug 撑爆内存的兜底,
// 不再用于"正常使用时的容量上限"——用户的 ask 不应该因为 50 个 cap 被强行 evict。
const ASK_HOOK_MAP_MAX = 1000;
// 单一来源的"无超时"实质上限——延伸至 24h 兼顾防 entry 泄漏;
// 任何引用此值的地方(HOOK_TIMEOUT / REPLAY_HOOK_TIMEOUT / 广播 timeoutMs)都从这里取。
// 实际常量定义在 server/lib/ask-constants.js(hook 路径 + SDK 路径同源)。
const ASK_HOOK_TIMEOUT_MS = ASK_TIMEOUT_MS;
// 内存 Map 是权威源;ask-store 是镜像(best-effort)。崩溃时只丢"未落盘窗口"内的最新一次变更。
// 任何 pendingAskHooks.set(...) 后必须调 _persistAskEntry;.delete(...) 后必须调 _persistAskDelete。
function _persistAskEntry(id, entry) {
if (!entry || !Array.isArray(entry.questions)) return;
setImmediate(() => {
askStoreSetEntry(id, { questions: entry.questions, createdAt: entry.createdAt }).catch(() => {});
});
}
function _persistAskDelete(id) {
setImmediate(() => {
askStoreDeleteEntry(id).catch(() => {});
});
}
// Phase 3: short-poll listener registry. Hangs GET /api/ask-hook/:id/result responses
// until either an answer/cancel arrives or wait ms elapses (then 204).
const shortPollListeners = new Map(); // id -> Set<{ res, tid, finished }>
// Waiter-liveness tracking for short-poll asks (see server/lib/ask-reaper.js).
// Memory-only BY DESIGN: persisting it would bump the ask-store SCHEMA_VERSION;
// the reaper's boot-time orphan sweep covers restarts instead.
const askWaiterLastPoll = new Map(); // id -> ms of last POST create / GET result poll
function _notifyShortPollAnswer(id, answers) {
const set = shortPollListeners.get(id);
if (!set) return;
for (const listener of set) {
if (listener.finished) continue;
listener.finished = true;
clearTimeout(listener.tid);
try {
if (!listener.res.headersSent) {
listener.res.writeHead(200, { 'Content-Type': 'application/json' });
listener.res.end(JSON.stringify({ answers }));
}
} catch {}
}
shortPollListeners.delete(id);
}
function _notifyShortPollCancel(id, reason) {
const set = shortPollListeners.get(id);
if (!set) return;
for (const listener of set) {
if (listener.finished) continue;
listener.finished = true;
clearTimeout(listener.tid);
try {
if (!listener.res.headersSent) {
listener.res.writeHead(200, { 'Content-Type': 'application/json' });
listener.res.end(JSON.stringify({ cancelled: true, reason: reason || '' }));
}
} catch {}
}
shortPollListeners.delete(id);
}
// Permission hook bridge state (for PreToolUse permission approval)
// Map supports concurrent sub-agent/teammate requests (keyed by request id)
const pendingPermHooks = new Map(); // Map<id, { toolName, input, res, timer, createdAt }>
const PERM_HOOK_MAP_MAX = 50;
// Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9)模块级常量——multipart 3 处 upload
// handler 都用此校验,避免内联 regex 复制粘贴漂移。
const WINDOWS_RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
// Per-file mutex for /api/git-restore —— 防多 tab 并发 revert 同文件造成 git status + checkout
// 子命令序列被插队导致不可预测的工作树状态。Promise chain 串行化同 key 请求;finally 路径
// 主清理,setTimeout 兜底防 finally 异常吞 entry 累积内存。
const gitRestoreLocks = new Map(); // Map<absLockKey, Promise<void>>
// Notify the parent process (Electron main, when forked under tab-worker) about pending state changes.
// No-op outside Electron (process.send is undefined when run as a standalone Node server).
// Only ask-hook-* / sdk-ask-* are translated. Permission and SDK plan stay inline-only and do not
// drive global modal / flashFrame / Notification (per UX direction). PTY plan is parsed in the
// renderer and reported via window.tabBridge directly, not through this server-side hook.
function _notifyParentPending(msg) {
if (!process.send || !msg || typeof msg !== 'object' || !msg.type) return;
let event = null;
switch (msg.type) {
case 'ask-hook-pending':
case 'sdk-ask-pending':
event = { type: 'pending-add', kind: 'ask', id: msg.id != null ? String(msg.id) : '__ask__', payload: { questions: msg.questions, projectName: _projectName || '' } };
break;
case 'ask-hook-timeout':
case 'sdk-ask-timeout':
case 'ask-hook-resolved':
case 'sdk-ask-resolved':
case 'ask-hook-cancelled':
// 注:ask-cancel handler 统一发 ask-hook-cancelled(不论 SDK / Hook 路径)。
event = { type: 'pending-remove', kind: 'ask', id: msg.id != null ? String(msg.id) : '__ask__' };
break;
default:
return;
}
try { process.send(event); } catch {}
}
// Live stream chunk sequence tracking (per request key) — prevents out-of-order broadcasts
const _liveStreamLastSeq = new Map(); // Map<`${timestamp}|${url}`, lastSeq>
// Editor session state (for $EDITOR intercept)
const editorSessions = new Map(); // sessionId → { filePath, done, createdAt }
// Periodically clean up abandoned editor sessions (older than 1 hour)
const _editorCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [id, session] of editorSessions) {
if (now - (session.createdAt || 0) > 3600000) editorSessions.delete(id);
}
}, 60000);
_editorCleanupTimer.unref(); // Don't keep process alive for cleanup
let terminalWss = null; // WebSocketServer reference for broadcasting
let _writeToPty = null; // PTY write function reference (set by setupTerminalWebSocket)
let _onPtyData = null; // PTY data listener registration (set by setupTerminalWebSocket)
export function setWorkspaceClaudeArgs(args) {
_workspaceClaudeArgs = args;
}
export function setWorkspaceClaudePath(path, isNpm) {
_workspaceClaudePath = path;
_workspaceIsNpmVersion = isNpm;
}
let _launchCallback = null;
export function setLaunchCallback(fn) { _launchCallback = fn; }
export function setWorkspaceLaunched(v) { _workspaceLaunched = v; }
export function initPostLaunch() {
watchLogFile(_logWatcherOpts(LOG_FILE));
if (!statsWorker) startStatsWorker();
startStreamingStatusTimer();
}
// Global POST body size limit (10MB) to prevent OOM from malicious/buggy clients
const MAX_POST_BODY = 10 * 1024 * 1024;
// /events 默认重放窗口:bare 请求(无 since、无 limit、无 cc)时使用,
// 防止长会话把数十 MB 历史一次性灌进浏览器导致 renderer OOM。
// 用户显式 ?limit=0 可恢复全量加载(power-user 逃生口)。
const DEFAULT_EVENTS_LIMIT = 1000;
// SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
// 调高至 30s:大会话首屏/重连重放时,渲染器(尤其 Windows 浏览器,大 DOM layout 更重)
// 可能短暂忙到来不及排空 socket。过早剔除会触发「断开→EventSource 自动重连→再次重放」
// 风暴,把瞬时卡顿放大成持续卡死。30s 仍能剔除真正死掉的连接。
const SSE_BACKPRESSURE_TIMEOUT_MS = 30000;
const START_PORT = parseInt(process.env.CCV_START_PORT) || 7008;
// 主交互式 ccv 默认收到 7049,把 7050-7099 让给独立 IM worker 进程(worker 经 env 覆盖为 7050-7099)。
// env 可覆盖(向后兼容逃生口)。
const MAX_PORT = parseInt(process.env.CCV_MAX_PORT) || 7049;
// IM worker 绑 127.0.0.1(仅 loopback),避免把 N 个 skip-permissions 端点暴露到局域网(见 plan §安全 1)。
// 主进程默认仍绑 0.0.0.0 以支持手机/局域网访问。
const HOST = process.env.CCV_HOST || '0.0.0.0';
// 局域网访问 token(本地 127.0.0.1 免验证)
const ACCESS_TOKEN = randomBytes(16).toString('hex');
// Internal token used ONLY for bridge → server calls (env-leaked to the spawned
// claude process via pty-manager). Separate from ACCESS_TOKEN so the LAN URL
// token can't double as a bridge auth bypass for same-host CSRF (round-3 P1).
const INTERNAL_TOKEN = randomBytes(16).toString('hex');
// 密码登录配置(与 token 并存的第二种远程访问方式)。持久化为 preferences.json:全局 `auth`
// 键 + 可选 `authByProject[<projectDir>]` 覆盖(密码 base64 轻混淆 + 文件 0600)。
// AUTH_PROJECT = 本 server 服务的项目(CLI 模式取 CCV_PROJECT_DIR);非 CLI/日志模式为 null
// → 只认全局。鉴权用 effective = 项目覆盖(若存在) else 全局。
// 同时在 --usePassword(CCV_USE_PASSWORD) 时取项目:该 flag 是「项目启动」专用,必须写「本项目」密码。
// 不能只靠 isCliMode —— 它在模块顶层只求值一次,而 server.js 可能经 interceptor 在 cli.js 设置
// CCV_CLI_MODE 之前就被加载(isCliMode=false),导致 --usePassword 误写全局。
const AUTH_PROJECT = (isCliMode || process.env.CCV_USE_PASSWORD === '1')
? (process.env.CCV_PROJECT_DIR || process.cwd())
: null;
let authConfig = loadAuthConfig(AUTH_PROJECT);
// CLI --usePassword 交接(cli.js 在 import 本模块前写入 env):写入本项目作用域(无项目则全局)。
// 优先级 显式值(CCV_PASSWORD) > 该作用域已持久化密码 > 随机生成。
if (process.env.CCV_USE_PASSWORD === '1') {
const explicit = process.env.CCV_PASSWORD;
let password = authConfig.password;
if (typeof explicit === 'string' && explicit.length > 0) password = explicit;
else if (!password) password = generatePassword();
const scope = AUTH_PROJECT ? 'project' : 'global';
saveAuthConfig({ enabled: true, password }, { scope, projectDir: AUTH_PROJECT });
authConfig = loadAuthConfig(AUTH_PROJECT);
}
// 钩子已消费完毕:清掉这两个 env,避免明文密码随 {...process.env} 泄漏进 spawn 出的 Claude 子进程
// (与刻意不把 ACCESS_TOKEN 放进 env 的策略一致)。此后无人再读它们(仅上面这段读取)。
delete process.env.CCV_USE_PASSWORD;
delete process.env.CCV_PASSWORD;
let clients = [];
// 内存级缓存:30s 启动检查若发现「有新版」(major_available / deferred_busy / brew_managed),
// 在此存下 {version, source},供 events 路由向新连接(刷新/新标签页)补推 update_major_available,
// 让版本徽标在本进程存续期内跨刷新持续显示。进程重启即归零(不落盘)。
let pendingMajorUpdate = null;
let server;
let actualPort = 0;
let serverProtocol = 'http';
// Stats Worker 实例
let statsWorker = null;
function startStatsWorker() {
try {
statsWorker = new Worker(new URL('./lib/stats-worker.js', import.meta.url));
statsWorker.on('error', (err) => {
console.error('[CC Viewer] Stats worker error:', err.message);
statsWorker = null;
});
statsWorker.on('exit', (code) => {
if (code !== 0) {
console.error('[CC Viewer] Stats worker exited with code', code);
}
statsWorker = null;
});
// 初始化:全量扫描当前项目
if (_projectName && _logDir) {
statsWorker.postMessage({ type: 'init', logDir: LOG_DIR, projectName: _projectName });
}
} catch (err) {
console.error('[CC Viewer] Failed to start stats worker:', err.message);
}
}
function notifyStatsWorker(logFile) {
if (statsWorker && _projectName) {
statsWorker.postMessage({ type: 'update', logDir: LOG_DIR, projectName: _projectName, logFile });
}
}
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
};
// Helper to build log-watcher options object
function _logWatcherOpts(logFile) {
return {
logFile: logFile || LOG_FILE,
clients,
getClaudePid,
runParallelHook,
notifyStatsWorker,
getLogFile: () => LOG_FILE,
};
}
function getLocalIp() {
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) return net.address;
}
}
return '127.0.0.1';
}
function getAllLocalIps() {
const ips = [];
const nets = networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) ips.push(net.address);
}
}
return ips;
}
// ─── Route dependency bag ──────────────────────────────────────────
// Single dependency object handed to every per-domain route handler (server/routes/*).
// Built once; route bodies were moved out of handleRequest's if-chain verbatim, with
// their closed-over identifiers rewritten to `deps.xxx`. Reassignable module state is
// exposed via GETTERS (read fresh at request time — never captured at import), while
// never-reassigned Maps/arrays are shared by reference. Helpers/constants that live in
// server.js (not importable elsewhere) are funneled through here too.
// IM 日志目录监听器:仅主 web 服务启用(IM worker 无浏览器 SSE 客户端,广播无意义)。
// 惰性 ensure:「对话记录」弹窗请求 /api/im/:platform/logs 时才开始 watch 该平台目录;
// 写入即广播 im_log_update SSE → 前端零滞后重拉(详见 im-log-watcher.js / AppBase im_log_update 监听)。
const _imLogWatcher = process.env.CCV_IM_PLATFORM ? null : createImLogWatcher({
getLogDir: () => LOG_DIR,
onChange: (platform) => {
if (clients.length > 0 && sendEventToClients) {
sendEventToClients(clients, 'im_log_update', { platform, ts: Date.now() });
}
},
});
// 惰性登记 IM 日志目录监听(主服务);worker 上 _imLogWatcher 为 null → no-op。
function _ensureImWatch(id) {
try { _imLogWatcher?.ensure(id); }
catch (e) { console.warn(`[im-log-watcher] ensure(${id}) failed:`, e?.message || e); }
}
const deps = {
// Reassignable runtime state — must stay getters.
get protocol() { return serverProtocol; },
get actualPort() { return actualPort; },
get terminalWss() { return terminalWss; },
get writeToPty() { return _writeToPty; },
get onPtyData() { return _onPtyData; },
get statsWorker() { return statsWorker; },
get workspaceLaunched() { return _workspaceLaunched; },
setWorkspaceLaunched(v) { _workspaceLaunched = v; },
get launchCallback() { return _launchCallback; },
get workspaceClaudeArgs() { return _workspaceClaudeArgs; },
get workspaceClaudePath() { return _workspaceClaudePath; },
get workspaceIsNpmVersion() { return _workspaceIsNpmVersion; },
startStreamingStatusTimer,
get claudeSettings() { return claudeSettings; },
// const defined later in the file (TDZ) — must be a getter, read at request time.
get turnEndDebounceMs() { return TURN_END_DEBOUNCE_MS; },
// Shared collections — stable references (never reassigned; clients truncated in place).
clients,
pendingAskHooks,
pendingPermHooks,
shortPollListeners,
askWaiterLastPoll,
notifyShortPollCancel: _notifyShortPollCancel,
editorSessions,
gitRestoreLocks,
liveStreamLastSeq: _liveStreamLastSeq,
// Helpers defined in server.js.
execFileAsync,
execAsync,
execWithStdin,
resolveRepoCwd,
getPrefsFile,
getLocalIp,
startStatsWorker,
persistAskEntry: _persistAskEntry,
persistAskDelete: _persistAskDelete,
notifyParentPending: _notifyParentPending,
logWatcherOpts: _logWatcherOpts,
scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
ensureImWatch: _ensureImWatch,
maskProfiles: _maskProfiles,
maskApiKey: _maskApiKey,
isMasked: _isMasked,
// Password-auth config. authConfig = the EFFECTIVE config the gate enforces for this
// server's project (project override else global). Mutations persist to the chosen
// scope then recompute the effective in-memory value.
get authConfig() { return authConfig; },
get authProject() { return AUTH_PROJECT; },
// 重新赋值的运行时状态 → 必须 getter,请求时读最新值(详见 let pendingMajorUpdate 注释)
get pendingMajorUpdate() { return pendingMajorUpdate; },
getAuthState() { return loadAuthState(AUTH_PROJECT); },
setAuthConfig(c, scope) {
saveAuthConfig(c, { scope: scope === 'global' ? 'global' : (AUTH_PROJECT ? 'project' : 'global'), projectDir: AUTH_PROJECT });
authConfig = loadAuthConfig(AUTH_PROJECT);
return authConfig;
},
clearAuthOverride() {
clearProjectOverride(AUTH_PROJECT);
authConfig = loadAuthConfig(AUTH_PROJECT);
return authConfig;
},
// Constants local to server.js.
// Generic IM bridge admin surface, keyed by platform id.
// IM adapters now run in detached worker processes (im-process-manager), NOT in this process.
// - isWorker: are we an IM worker (CCV_IM_PLATFORM set)? The worker reports its own in-process
// adapter status (getBridgeStatus) — that's what the main process's manager probes.
// - In the MAIN process, status/lifecycle go through the manager (lock + loopback probe / spawn / kill).
im: {
isWorker: !!process.env.CCV_IM_PLATFORM,
getBridgeStatus: (id) => imCore.getBridgeStatus(id), // worker-side: real in-process adapter status
getProcessStatus: (id) => imProcMgr.getImProcessStatus(id), // main-side: detached worker status (async)
startProcess: (id) => imProcMgr.spawnImProcess(id),
stopProcess: (id) => imProcMgr.stopImProcess(id),
restartProcess: async (id) => { await imProcMgr.stopImProcess(id); return imProcMgr.spawnImProcess(id); },
testConnection: (id, cfg) => imCore.testConnection(id, cfg),
},
// DingTalk back-compat alias (legacy /api/dingtalk/* routes). Same manager-backed semantics.
dingtalk: {
isWorker: !!process.env.CCV_IM_PLATFORM,
getBridgeStatus: () => imCore.getBridgeStatus('dingtalk'),
getProcessStatus: () => imProcMgr.getImProcessStatus('dingtalk'),
startProcess: () => imProcMgr.spawnImProcess('dingtalk'),
stopProcess: () => imProcMgr.stopImProcess('dingtalk'),
restartProcess: async () => { await imProcMgr.stopImProcess('dingtalk'); return imProcMgr.spawnImProcess('dingtalk'); },
testConnection: (cfg) => imCore.testConnection('dingtalk', cfg),
},
ACCESS_TOKEN,
INTERNAL_TOKEN,
MAX_POST_BODY,
ASK_HOOK_TIMEOUT_MS,
ASK_HOOK_MAP_MAX,
PERM_HOOK_MAP_MAX,
WINDOWS_RESERVED_NAMES,
DEFAULT_EVENTS_LIMIT,
SSE_BACKPRESSURE_TIMEOUT_MS,
IGNORED_PATTERNS,
isCliMode,
isSdkMode,
isWorkspaceMode,
instanceId: INSTANCE_ID,
defaultProxyProfiles: _defaultProxyProfiles,
};
// ─── Route registry ────────────────────────────────────────────────
// Domain route modules concatenated IN THE SAME ORDER as the original if-chain
// (order is load-bearing: prefix-vs-exact and method-distinguished duplicates).
// dispatch() runs after the request prelude; an unmatched request returns false and
// falls through to static-file serving / 404.
const _routes = [
...authRoutes,
...projectMetaRoutes,
...miscRoutes,
...preferencesRoutes,
...projectPrefsRoutes,
...sessionPinRoutes,
...gitRoutes,
...pluginsRoutes,
...logsRoutes,
...voicePackRoutes,
...skillsRoutes,
...ultraAgentsRoutes,
...filesContentRoutes,
...workflowJournalRoutes,
...filesFsRoutes,
...searchRoutes,
...workspacesRoutes,
...expertRoutes,
...eventsRoutes,
...askPermRoutes,
...teamRoutes,
...dingtalkRoutes,
...imRoutes,
];
const dispatch = createDispatcher(_routes);
async function handleRequest(req, res) {
const parsedUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
let url = parsedUrl.pathname;
// CCV_BASE_PATH reverse proxy: strip prefix at TOP so API/WS/static/SPA
// all work with original unprefixed paths. 剥离后必须写回 parsedUrl.pathname ——
// dispatch()(routes/_dispatch.js)与多个 handler(files-content/ask-perm/im)直读
// parsedUrl.pathname 做路由匹配和偏移 slice,不写回则前缀下全部 /api/* 与 SSE /events
// 命不中、落 SPA fallback(PR #108 遗留 P0)。searchParams 不受 pathname 赋值影响。
const bp = normalizeBasePath(process.env.CCV_BASE_PATH);
url = stripBasePath(url, bp);
parsedUrl.pathname = url;
const method = req.method;
// WebSocket 路径不处理,交给 upgrade 事件
if (url === '/ws/terminal' || url === '/ws/terminal-scratch') {
return;
}
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// 局域网访问验证:decideAuth() 统一决策(本地 127.0.0.1/::1 免验、静态资源免验、
// ?token=/cookie/密码登录三选一)。详见 server/lib/auth.js。
// 不变式:login-page/unauthorized/forbidden 必须 return;只有 allow 才继续往下进
// Host allowlist + 路由。
const remoteIp = req.socket.remoteAddress;
const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
const wantsHtml = method === 'GET' && ((req.headers.accept || '').includes('text/html') || url === '/');
const authDecision = decideAuth({
isStaticAsset,
pathname: url,
isLocal,
urlToken: parsedUrl.searchParams.get('token'),
cookieToken: parseCookies(req.headers.cookie).ccv_auth,
accessToken: ACCESS_TOKEN,
enabled: authConfig.enabled,
password: authConfig.password,
wantsHtml,
});
if (authDecision.action === 'login-page') {
const lang = localeFromAcceptLanguage(req.headers['accept-language']) || getLang();
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(renderLoginPage({ lang }));
return;
}
if (authDecision.action === 'unauthorized') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
if (authDecision.action === 'forbidden') {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Forbidden: invalid token' }));
return;
}
// action === 'allow' → 继续
// DNS rebinding 防护:即使带了正确 token,Host header 必须落在 allowlist 里。
// 默认放行 loopback + 本机所有 LAN IPv4(getAllLocalIps()):cc-viewer 核心场景就是手机扫码访问 LAN URL,
// 要求用户每次手动设 CCV_ALLOWED_HOSTS 不可接受。token 仍是必需(server.js:300-310 ACCESS_TOKEN gate),
// DNS rebinding 攻击者需精确知道用户 LAN IP 才能利用,门槛降低但不增新攻击面;Vite/Cursor 同行也默认放开 LAN。
// CCV_ALLOWED_HOSTS 显式设(包括 '*' 关闭防护)时完全沿用用户值,与 1.6.227 行为一致,向后兼容。
// 静态资源和 OPTIONS 预检不挡。
if (!isStaticAsset && method !== 'OPTIONS') {
const allowedHosts = process.env.CCV_ALLOWED_HOSTS
? process.env.CCV_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
: ['localhost', '127.0.0.1', '::1', '[::1]', ...getAllLocalIps()];
if (!allowedHosts.includes('*')) {
const hostHeader = (req.headers.host || '').toLowerCase();
// 端口剥离:RFC 3986 要求 IPv6 Host 必须带 brackets `[::1]:port`,bare `::1` 末尾 `\d` 会被错剥成 `:`。
// 含 `::` 但无 `]` 闭合的视为 bare IPv6,不剥端口。
const isBareIPv6 = hostHeader.includes('::') && !hostHeader.includes(']');
const hostNoPort = isBareIPv6 ? hostHeader : hostHeader.replace(/:\d+$/, '');
const stripBrackets = hostNoPort.replace(/^\[|\]$/g, '');
const ok = allowedHosts.some(h => {
const hl = h.toLowerCase();
return hl === hostNoPort || hl === stripBrackets || hl === `[${stripBrackets}]`;
});
if (!ok) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'host-not-allowed', host: hostNoPort }));
return;
}
}
}
// Plugin hook: intercept HTTP requests (after auth, before routing)
try {
const hookResult = await runWaterfallHook('beforeRequest', {
req, res, url, method, parsedUrl, handled: false,
});
if (hookResult.handled) return;
} catch (err) {
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Plugin error' }));
}
return;
}
// Per-domain routes (server/routes/*). Unmatched requests fall through to
// static-file serving and the API/SPA 404 below.
if (await dispatch(req, res, parsedUrl, isLocal, deps)) return;
// 静态文件服务
if (method === 'GET') {
// basePath 已在 handleRequest 顶部统一剥离,这里不可再剥——否则 /proxy/proxy/x
// 这类路径会被双重剥离。
let filePath = url;
if (filePath === '/') filePath = '/index.html';
// 去掉 query string
filePath = filePath.split('?')[0];
const fullPath = join(DIST_DIR, filePath);
// index.html 服务端注入主题:根治"老用户首屏闪屏"。
// 老用户 preferences.json 里 themeColor='dark' 但浏览器 localStorage 还没缓存过 →
// 静态 index.html 的 <html data-theme="light"> 会导致 Loading 页先渲成白色,
// 等 React 拿到 prefs 才切回 dark,肉眼可见一次"白闪"。
// 这里把当前 prefs 里的 themeColor 直接写进 HTML,inline boot script 仍负责
// 处理 URL ?theme= 优先级与 localStorage 缓存。
//
// 主题来源优先级(首屏 → React 接管后):
// 1. URL ?theme= (inline boot script 读取,最高优先)
// 2. localStorage ccv_themeColor (inline boot script 读取,跨刷新缓存)
// 3. preferences.json 的 themeColor (此处 SSR 注入到 <html data-theme="...">,老用户兜底)
// 4. dist/index.html 模板里的硬编码 default ("light")
// React 接管后 AppBase._applyTheme() 会基于 1/2/3 重新统一 state + DOM + localStorage 三向同步。
const serveIndexHtml = () => {
try {
const indexPath = join(DIST_DIR, 'index.html');
// mtime 缓存:避免每次请求都 readFileSync(Windows Defender 下每次读 5-50ms)
let st;
try { st = statSync(indexPath); } catch { return false; }
if (!_indexHtmlCache || _indexHtmlCache.mtime !== st.mtimeMs) {
_indexHtmlCache = { html: readFileSync(indexPath, 'utf-8'), mtime: st.mtimeMs };
}
let html = _indexHtmlCache.html;
let themeColor = process.platform === 'win32' ? 'dark' : 'light';
try {
if (existsSync(getPrefsFile())) {
const prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8'));
if (prefs.themeColor === 'dark' || prefs.themeColor === 'light') themeColor = prefs.themeColor;
}
} catch {}
if (!_ssrThemeAttrWarned && !/<html[^>]*data-theme="[^"]*"/.test(html)) {
_ssrThemeAttrWarned = true;
console.warn('[serveIndexHtml] dist/index.html 没有 <html data-theme="..."> 属性,SSR theme 注入将不生效。检查 index.html 模板。');
}
html = html.replace(/<html([^>]*?)data-theme="[^"]*"/, `<html$1data-theme="${themeColor}"`);
// 运行时始终注入 <base> 标签(根部署用 '/',子路径用规范化前缀):配合 Vite 默认
// base=''(相对路径产物),让浏览器把所有相对 URL(含任意深度 SPA-fallback 文档里的
// ./assets)解析到正确根/前缀下,避免深链直访(如 /a/b)时相对当前路径解析 → 白屏。
// window.__CCV_BASE_PATH__ 仅子路径时注入:运行时 API/WS 的 base 语义保持"未设=无前缀"不变。
const injectBase = normalizeBasePath(process.env.CCV_BASE_PATH);
const baseHref = injectBase || '/';
const escapedBase = baseHref.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
let injectHtml = `<base href="${escapedBase}">`;
if (injectBase) {
// JS 双引号字符串转义:\ → \\、" → \"、</ → <\/(防 </script> 提前闭合)
const jsSafeBase = injectBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/<\//g, '<\\/');
injectHtml += `<script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`;
}
html = html.replace(/<head[^>]*>/i, m => m + injectHtml);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(html);
return true;
} catch { return false; }
};
if (filePath === '/index.html') {
if (serveIndexHtml()) return;
// serveIndexHtml 失败时 fall through 到下面的常规静态路径
}
try {
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
const content = readFileSync(fullPath);
const ext = extname(filePath);
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
// 缓存策略:/assets/ 下文件名带 content-hash,永远不变 → 长缓存 + immutable;
// 其它(主要 index.html)每次必须回源校验,否则用户升级 server 后浏览器还在用陈旧 index.html,
// 引用旧 hash chunk 找不到 → SPA fallback 给 text/html → 浏览器 strict MIME 拒绝。
const cacheControl = filePath.startsWith('/assets/')
? 'public, max-age=31536000, immutable'
: 'no-cache';
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': cacheControl });
res.end(content);
return;
}
} catch (err) {
// fall through
}
// /assets/ 下文件找不到 = 陈旧 chunk hash(部署后旧标签页请求被替换的文件名)。
// 直接 404,不走 SPA fallback —— 否则浏览器拿到 text/html 当 ESM 加载会报 strict MIME 错,
// 错误堆栈反而误导排查方向。客户端的 lazy().catch() 拿到这个 404 会自动 reload。
if (filePath.startsWith('/assets/')) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Asset not found (likely a stale chunk after upgrade — please refresh)');
return;
}
// SPA fallback: 非 API/非静态文件请求返回 index.html(路由由前端处理)
if (serveIndexHtml()) return;
res.writeHead(404);
res.end('Not Found');
return;
}
// 非 GET 请求的 API 404
if (url.startsWith('/api/')) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not Found' }));
return;
}
res.writeHead(404);
res.end('Not Found');
}
export async function startViewer() {
// 启动新一轮 → 若上一次 stop 仍在飞行,先 await 它再重置,避免与并发 _doStop 共享状态。
if (_stoppingPromise) {
try { await _stoppingPromise; } catch { /* stop 内部已 try/catch,最坏继续 */ }
}
_isStopping = false;
_stoppingPromise = null;
// 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
await loadPlugins();
// 清理过期解压缓存(fire-and-forget;任何错误吞掉)
setImmediate(() => { try { cleanupExtractCache(); } catch { /* ignore */ } });
// 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
// (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
setImmediate(() => { try { backupConfigs(); } catch { /* ignore */ } });
// 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
// 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
// (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
// 留下来的 disk 镜像供 /api/pending-asks 端点查询,让浏览器重连后仍能看见 pending 列表。
setImmediate(() => { askStorePruneStale(ASK_HOOK_TIMEOUT_MS).catch(() => {}); });
// 长跑进程兜底:短轮询路径下 markAnswered 标的终态 entry 若 ask-bridge 已死(GET 不再来 consume),
// 仅靠启动 prune 永远清不掉。1h 周期触发一次,.unref() 不阻塞进程退出。
const _pruneAskStoreInterval = setInterval(() => {
askStorePruneStale(ASK_HOOK_TIMEOUT_MS).catch(() => {});
}, 60 * 60 * 1000);
_pruneAskStoreInterval.unref();
// Waiter-liveness reaper: resolves short-poll asks whose hook process (ask-bridge)
// died without notifying us (e.g. AskUserQuestion declined at the CLI → SIGTERM).
// Keys on waiter liveness, never on wall-clock ask age — the "GUI effectively
// no-timeout" contract is untouched. See server/lib/ask-reaper.js.
const _reaperDeps = {
pendingAskHooks,
shortPollListeners,
askWaiterLastPoll,
markCancelled: askStoreMarkCancelled,
loadAskStore: askStoreLoad,
notifyShortPollCancel: _notifyShortPollCancel,
// terminalWss is assigned later in startServer — resolve at call time via closure.
broadcastCancelled: (id, reason) => {
if (!terminalWss) return;
const cmsg = JSON.stringify({ type: 'ask-hook-cancelled', id, reason });
terminalWss.clients.forEach((c) => {
if (c.readyState === 1) try { c.send(cmsg); } catch {}
});
},
notifyParentPending: _notifyParentPending,
};
const _reaperState = { lastSweepAt: Date.now() };
_askReaperTimer = setInterval(() => {
reapDeadAskWaiters(_reaperDeps, _reaperState).catch(() => {});
}, ASK_WAITER_REAP_INTERVAL_MS);
_askReaperTimer.unref();
// One-shot boot sweep for disk-only orphans left by a previous server process.
// Delayed one liveness window so a bridge that survived our restart can re-poll
// and prove ownership first; skipped when another cc-viewer instance is running.
// Also skipped entirely under custom CCV_START_PORT/CCV_MAX_PORT: the lsof scan
// covers only OUR range, so an instance on a different custom range would be
// invisible and its live asks could be falsely swept. The memory-owned reaper
// and the ApprovalModal fallback UI still cover orphans in that setup.
const _customPortRange = !!(process.env.CCV_START_PORT || process.env.CCV_MAX_PORT);
const _bootTime = Date.now();
if (!_customPortRange) {
_askOrphanSweepTimer = setTimeout(() => {
sweepOrphanedDiskAsks({
..._reaperDeps,
bootTime: _bootTime,
ownPid: process.pid,
portRange: [START_PORT, MAX_PORT],
// Async on purpose: a wedged lsof would otherwise block the event loop
// (and every in-flight request) for up to the full timeout.
lsofImpl: async (cmd) => (await execAsync(cmd, { timeout: 2000, encoding: 'utf-8' })).stdout,
}).catch(() => {});
}, ASK_WAITER_LIVENESS_MS);
_askOrphanSweepTimer.unref();
}
// 通过插件 hook 获取 HTTPS 证书选项
let httpsOptions = null;
try {
const httpsResult = await runWaterfallHook('httpsOptions', {});
httpsOptions = (httpsResult.pfx || httpsResult.cert) ? httpsResult : null;
} catch (err) {
console.error('[CC Viewer] httpsOptions hook error:', err.message);
}
const useHttps = !!httpsOptions;
const protocol = useHttps ? 'https' : 'http';
serverProtocol = protocol;
if (useHttps) console.error('[CC Viewer] HTTPS mode enabled via plugin hook');
return new Promise((resolve, reject) => {
function tryListen(port) {
if (port > MAX_PORT) {
console.error(t('server.portsBusy', { start: START_PORT, end: MAX_PORT }));
resolve(null);
return;
}
// 先检测 127.0.0.1:port 是否已被占用(避免 0.0.0.0 和 127.0.0.1 绑定不冲突的问题)
const probe = createConnection({ host: '127.0.0.1', port });
probe.on('connect', () => {
probe.destroy();
tryListen(port + 1); // 端口已被占用,尝试下一个
});
probe.on('error', () => {
probe.destroy();
// 端口空闲,绑定
let currentServer;
if (useHttps) {
try {
currentServer = createHttpsServer(httpsOptions, handleRequest);
} catch (err) {
console.error('[CC Viewer] HTTPS server creation failed, falling back to HTTP:', err.message);
currentServer = createServer(handleRequest);
serverProtocol = 'http';
}
} else {
currentServer = createServer(handleRequest);
}
currentServer.listen(port, HOST, () => {
server = currentServer;
actualPort = port;
// Wrap the entire async setup in a fire-and-forget try-catch so that any
// unhandled rejection (e.g. setupTerminalWebSocket / runParallelHook
// / imCore.startBridge throwing) cannot crash the process after the port
// is already bound and cli.js has proceeded to spawnClaude.
(async () => {
try {
// 把服务端 i18n 的 currentLang 同步成用户在 UI 配置的语言(preferences.lang)。
// 否则服务端 t() 恒为默认 'zh'——DingTalk 桥接的系统提示、登录页回落语言都不跟随配置。
// setLang 自带 locale 校验,非法/缺失值回落 en,读 prefs 失败也安全跳过。
try {
if (existsSync(getPrefsFile())) {
const _prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8'));
if (_prefs.lang) setLang(_prefs.lang);
}
} catch { /* 读 prefs 失败就保持默认语言 */ }
// CCV_BASE_PATH 配置校验:缺前导 '/' 时剥离静默失效(startsWith 永不命中),
// 启动期告警一次。放在 setLang 之后,告警语言才跟随用户配置。
{
const _bpCheck = validateBasePath(process.env.CCV_BASE_PATH);
if (_bpCheck.warning) console.warn(t(_bpCheck.warning, { value: process.env.CCV_BASE_PATH }));
}
// interceptor.js runs in this same process (via proxy.js → setupInterceptor).
// Inject live-port via module-level setter instead of process.env to avoid
// polluting env of child_process.spawn descendants (Bash tools / MCP / Electron tabs).
setLivePort(port, serverProtocol);
// 自动打开/serverStarted hook 用的 URL 也要带反代前缀(与启动打印一致)
const url = `${serverProtocol}://127.0.0.1:${port}${normalizeBasePath(process.env.CCV_BASE_PATH)}`;