cc-viewer 1.9.9 → 1.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +1 -1
  2. package/cli.js +4 -3
  3. package/dist/assets/App-Ci9GYs24.js +2 -0
  4. package/dist/assets/{MdxEditorPanel-BJVD_bcU.js → MdxEditorPanel-8fwlNHMz.js} +1 -1
  5. package/dist/assets/Mobile-DngSOqYr.js +1 -0
  6. package/dist/assets/ProxyStatsModal-CWSIELLj.js +1 -0
  7. package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-6ZLcdMmL.js} +1 -1
  8. package/dist/assets/{arc-EdUdBxtG.js → arc-Dirf6EZP.js} +1 -1
  9. package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-BfIhmu_I.js} +1 -1
  10. package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-D0y4ALi1.js} +1 -1
  11. package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-Dlfp4SUK.js} +1 -1
  12. package/dist/assets/{channel-DYEktNap.js → channel-CaNcU8SI.js} +1 -1
  13. package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-ki0vHNS-.js} +1 -1
  14. package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-D8yO8Iuv.js} +1 -1
  15. package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-BvIgaNPO.js} +1 -1
  16. package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-DF2Of_VO.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-BIw7SXs-.js} +1 -1
  18. package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-AB76s0A0.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-CuN2LCFc.js} +1 -1
  20. package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-D4DLBzBN.js} +1 -1
  21. package/dist/assets/classDiagram-6PBFFD2Q-DLJzWgYm.js +1 -0
  22. package/dist/assets/classDiagram-v2-HSJHXN6E-DLJzWgYm.js +1 -0
  23. package/dist/assets/clone-H_VGsHn9.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-Bn485PX9.js} +1 -1
  25. package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-e4QVCf17.js} +1 -1
  26. package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-_SBFGgnf.js} +1 -1
  27. package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-Bz1ovxLs.js} +1 -1
  28. package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-B-oojW8K.js} +1 -1
  29. package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-CTAyNaOj.js} +1 -1
  30. package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-BfvKAqq2.js} +1 -1
  31. package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-BpTEZEAe.js} +1 -1
  32. package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-DUD_vkyE.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DnlXcTud.js} +1 -1
  34. package/dist/assets/{graph-BSB9fDSP.js → graph-D4xsUKSn.js} +1 -1
  35. package/dist/assets/{index-DKufsAzx.js → index-84-BjpGl.js} +1 -1
  36. package/dist/assets/index-BDKwOnE3.js +2 -0
  37. package/dist/assets/{index-Bo-12bug.js → index-BniBLRtZ.js} +1 -1
  38. package/dist/assets/{index-BRw4Iz2O.js → index-C2Zo3KOg.js} +1 -1
  39. package/dist/assets/{index-BuVComcY.js → index-CWZoKT7e.js} +1 -1
  40. package/dist/assets/{index-eM2ih5n7.js → index-D-4EsFub.js} +1 -1
  41. package/dist/assets/{index-CNNAYjkF.js → index-D3KzRmkP.js} +1 -1
  42. package/dist/assets/{index-DXBfwPYD.js → index-NGgdum2i.js} +1 -1
  43. package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-BbdvmCDt.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-oZBOPoTO.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-DWoH0hs9.js} +1 -1
  46. package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-I5y9RZhq.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-BLHtjKsS.js} +1 -1
  48. package/dist/assets/{layout-DTmHj0Wm.js → layout-C116SNcQ.js} +1 -1
  49. package/dist/assets/{linear-BOA3a9FE.js → linear-Br08WCkF.js} +1 -1
  50. package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-D-4dTrvI.js} +2 -2
  51. package/dist/assets/{min-CpjFTEec.js → min-khs74HbH.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-C3_yItFF.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-ByCwM2_P.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-CjV65X1r.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-DFJ_dbtV.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-BBvXoBfj.js} +1 -1
  57. package/dist/assets/{seqResourceLoaders-C0cU2e1o.css → seqResourceLoaders-C34z9uOa.css} +2 -2
  58. package/dist/assets/seqResourceLoaders-JJzgZZzU.js +2 -0
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-BJM04Y3N.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-DI2q_Kkn.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-CrGC6kEY.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-JcfRUsjD.js} +1 -1
  63. package/dist/assets/vendor-antd-DxEVqVhD.js +17 -0
  64. package/dist/assets/vendor-codemirror-CgNlkZva.js +1 -0
  65. package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-DoreQf4A.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-q1CKtOyX.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-BXGe9q7E.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-mAHR1Ugj.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-mlTq_P-4.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-BDGfajRb.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Cf2pR1mV.js} +1 -1
  72. package/dist/index.html +4 -4
  73. package/package.json +1 -1
  74. package/plugins/README.md +9 -1
  75. package/server/interceptor.js +1 -1
  76. package/server/lib/builtin-model-prompts.js +1 -1
  77. package/server/lib/chat-queue.js +1 -1
  78. package/server/lib/credential-access.js +1 -1
  79. package/server/lib/credential-migrate.js +1 -1
  80. package/server/lib/error-report.js +26 -0
  81. package/server/lib/json-store.js +1 -1
  82. package/server/lib/launch-config.js +1 -1
  83. package/server/lib/log-watcher.js +1 -1
  84. package/server/lib/plugin-host.mjs +198 -0
  85. package/server/lib/plugin-loader.js +346 -90
  86. package/server/lib/plugin-manager.js +46 -2
  87. package/server/lib/plugin-manifest.js +329 -0
  88. package/server/lib/plugin-panels.js +183 -0
  89. package/server/lib/plugin-proxy.js +408 -0
  90. package/server/lib/plugin-runtime.js +775 -0
  91. package/server/lib/plugin-service-manager.js +463 -0
  92. package/server/lib/proxy/proxy-retry.js +1 -1
  93. package/server/lib/proxy/proxy-stats.js +1 -1
  94. package/server/lib/resume-watchdog.js +1 -1
  95. package/server/lib/same-origin.js +13 -3
  96. package/server/lib/sdk-manager.js +16 -9
  97. package/server/lib/sdk-session-index.js +1 -1
  98. package/server/lib/stats-worker.js +1 -1
  99. package/server/lib/system-prompt-files.js +1 -1
  100. package/server/lib/system-prompt-live.js +1 -1
  101. package/server/lib/system-prompt-snapshots.js +1 -1
  102. package/server/lib/v2/adapter.js +1 -1
  103. package/server/lib/v2/convert.js +1 -1
  104. package/server/lib/v2/jsonl-read.js +1 -1
  105. package/server/lib/v2/live-feed.js +1 -1
  106. package/server/lib/v2/meta-rows.js +1 -1
  107. package/server/lib/v2/resume-list.js +1 -1
  108. package/server/lib/v2/session-list.js +1 -1
  109. package/server/lib/v2/session-owner.js +1 -1
  110. package/server/lib/v2/v2-writer.js +1 -1
  111. package/server/lib/wire-compress.js +1 -1
  112. package/server/lib/ws-mux.js +1 -1
  113. package/server/proxy.js +1 -1
  114. package/server/pty-manager.js +1 -1
  115. package/server/routes/ask-perm.js +1 -1
  116. package/server/routes/events.js +1 -1
  117. package/server/routes/expert.js +1 -1
  118. package/server/routes/files-content.js +1 -1
  119. package/server/routes/files-fs.js +4 -2
  120. package/server/routes/misc.js +11 -0
  121. package/server/routes/plugin-services.js +104 -0
  122. package/server/routes/plugins.js +145 -17
  123. package/server/routes/preferences.js +1 -1
  124. package/server/routes/resume.js +1 -1
  125. package/server/routes/sdk-context.js +1 -1
  126. package/server/routes/sdk-mcp-status.js +1 -1
  127. package/server/routes/workspaces.js +1 -1
  128. package/server/server.js +328 -171
  129. package/dist/assets/App-CLteNB83.js +0 -2
  130. package/dist/assets/Mobile-CJCQf3MQ.js +0 -1
  131. package/dist/assets/ProxyStatsModal-B5ccoOPy.js +0 -1
  132. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
  133. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
  134. package/dist/assets/clone-CFj55h49.js +0 -1
  135. package/dist/assets/index-CqX5TkEL.js +0 -2
  136. package/dist/assets/seqResourceLoaders-7lsgbvGa.js +0 -2
  137. package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
  138. package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
package/server/server.js CHANGED
@@ -23,6 +23,7 @@ import { projectPrefsRoutes } from './routes/project-prefs.js';
23
23
  import { sessionPinRoutes } from './routes/session-pin.js';
24
24
  import { gitRoutes } from './routes/git.js';
25
25
  import { pluginsRoutes } from './routes/plugins.js';
26
+ import { pluginServicesRoutes } from './routes/plugin-services.js';
26
27
  import { logsRoutes } from './routes/logs.js';
27
28
  import { voicePackRoutes } from './routes/voice-pack.js';
28
29
  import { skillsRoutes } from './routes/skills.js';
@@ -48,7 +49,7 @@ import { setProxyStatsListener } from './lib/proxy/proxy-stats.js';
48
49
  import * as imCore from './lib/im/im-bridge-core.js';
49
50
  import * as imProcMgr from './lib/im/im-process-manager.js';
50
51
  import * as chatQueue from './lib/chat-queue.js';
51
- import { reportSwallowed } from '@ccv/core/error-report';
52
+ import { reportSwallowed } from './lib/error-report.js';
52
53
  import './lib/adapters/dingtalk-adapter.js'; // side-effect: registers the DingTalk adapter
53
54
  import './lib/adapters/feishu-adapter.js'; // side-effect: registers the Feishu adapter
54
55
  import './lib/adapters/wecom-adapter.js'; // side-effect: registers the WeCom adapter
@@ -117,6 +118,8 @@ import { t, getLang, setLang } from './i18n.js';
117
118
  import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
118
119
  import { checkAndUpdate } from './lib/updater.js';
119
120
  import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
121
+ import * as pluginRuntime from './lib/plugin-runtime.js';
122
+ import { isSameOriginBrowserRequest } from './lib/same-origin.js';
120
123
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
121
124
  import { sendEventToClients } from './lib/log-watcher.js';
122
125
  import { applyTaskEvent, resetTasks, resetAllTasks, getTaskSnapshot, shouldResetTasks } from './lib/task-state.js';
@@ -842,6 +845,7 @@ const _routes = [
842
845
  ...sessionPinRoutes,
843
846
  ...gitRoutes,
844
847
  ...pluginsRoutes,
848
+ ...pluginServicesRoutes,
845
849
  ...logsRoutes,
846
850
  ...voicePackRoutes,
847
851
  ...skillsRoutes,
@@ -865,8 +869,56 @@ const _routes = [
865
869
  ];
866
870
  const dispatch = createDispatcher(_routes);
867
871
 
872
+ // Host header validation: llhttp accepts values like `Host: [`, while `new URL(path, proto://host)`
873
+ // throws Invalid URL - handleRequest is async and wired straight into createServer, so a throw becomes
874
+ // an unhandled rejection that kills the process (same for the upgrade listener). Validate before parsing.
875
+ function isValidHostHeader(host) {
876
+ if (typeof host !== 'string' || host.length === 0) return false;
877
+ try {
878
+ // Fixed base only validates that the host part parses; it is not used for business logic
879
+ new URL(`http://${host}/`);
880
+ return true;
881
+ } catch {
882
+ return false;
883
+ }
884
+ }
885
+
886
+ function safeParseRequestUrl(req) {
887
+ try {
888
+ if (!isValidHostHeader(req.headers.host)) return null;
889
+ return new URL(req.url, `${serverProtocol}://${req.headers.host}`);
890
+ } catch {
891
+ return null;
892
+ }
893
+ }
894
+
895
+ // Shared DNS-rebinding guard (HTTP and WS upgrade share it so port/IPv6 logic cannot drift).
896
+ // Returns null to allow; otherwise { host } for the caller to reject.
897
+ function checkHostAllowed(hostHeaderValue) {
898
+ const allowedHosts = process.env.CCV_ALLOWED_HOSTS
899
+ ? process.env.CCV_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
900
+ : ['localhost', '127.0.0.1', '::1', '[::1]', ...getAllLocalIps()];
901
+ if (allowedHosts.includes('*')) return null;
902
+ const hostHeader = (hostHeaderValue || '').toLowerCase();
903
+ // Port stripping: RFC 3986 requires bracketed IPv6 `[::1]:port`; bare `::1` would lose its last `\d` to port stripping.
904
+ // Treat a value containing `::` without a closing `]` as bare IPv6 and skip port stripping.
905
+ const isBareIPv6 = hostHeader.includes('::') && !hostHeader.includes(']');
906
+ const hostNoPort = isBareIPv6 ? hostHeader : hostHeader.replace(/:\d+$/, '');
907
+ const stripBrackets = hostNoPort.replace(/^\[|\]$/g, '');
908
+ const ok = allowedHosts.some(h => {
909
+ const hl = h.toLowerCase();
910
+ return hl === hostNoPort || hl === stripBrackets || hl === `[${stripBrackets}]`;
911
+ });
912
+ return ok ? null : { host: hostNoPort };
913
+ }
914
+
868
915
  async function handleRequest(req, res) {
869
- const parsedUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
916
+ const parsedUrl = safeParseRequestUrl(req);
917
+ if (!parsedUrl) {
918
+ res.writeHead(400, { 'Content-Type': 'application/json' });
919
+ res.end(JSON.stringify({ error: 'Bad Request: invalid Host header' }));
920
+ return;
921
+ }
870
922
  let url = parsedUrl.pathname;
871
923
 
872
924
  // CCV_BASE_PATH reverse proxy: strip prefix at TOP so API/WS/static/SPA
@@ -901,13 +953,18 @@ async function handleRequest(req, res) {
901
953
  // Host allowlist + 路由。
902
954
  const remoteIp = req.socket.remoteAddress;
903
955
  const isLocal = remoteIp === '127.0.0.1' || remoteIp === '::1' || remoteIp === '::ffff:127.0.0.1';
956
+ // authExemptStatic: no auth (panel bundles load via <script src> and cannot carry a token);
957
+ // hostExemptStatic: also skips the Host allowlist (host app assets only). Plugin panel statics
958
+ // are auth-exempt but keep DNS-rebinding protection - they load same-origin from a validated page.
904
959
  const isStaticAsset = url.startsWith('/assets/') || url === '/favicon.ico';
960
+ const isPluginPanelAsset = url.startsWith('/plugin-panels/');
961
+ const authExemptStatic = isStaticAsset || isPluginPanelAsset;
905
962
  const wantsHtml = method === 'GET' && ((req.headers.accept || '').includes('text/html') || url === '/');
906
963
  // Hoisted so decideAuth and the isAdmin computation share the SAME credential values.
907
964
  const urlToken = parsedUrl.searchParams.get('token');
908
965
  const cookieToken = parseCookies(req.headers.cookie).ccv_auth;
909
966
  const authDecision = decideAuth({
910
- isStaticAsset,
967
+ isStaticAsset: authExemptStatic,
911
968
  pathname: url,
912
969
  isLocal,
913
970
  urlToken,
@@ -948,27 +1005,13 @@ async function handleRequest(req, res) {
948
1005
  // 要求用户每次手动设 CCV_ALLOWED_HOSTS 不可接受。token 仍是必需(server.js:300-310 ACCESS_TOKEN gate),
949
1006
  // DNS rebinding 攻击者需精确知道用户 LAN IP 才能利用,门槛降低但不增新攻击面;Vite/Cursor 同行也默认放开 LAN。
950
1007
  // CCV_ALLOWED_HOSTS 显式设(包括 '*' 关闭防护)时完全沿用用户值,与 1.6.227 行为一致,向后兼容。
951
- // 静态资源和 OPTIONS 预检不挡。
1008
+ // Host app statics and OPTIONS preflights skip this; plugin panel statics (authExempt, not hostExempt) still run it.
952
1009
  if (!isStaticAsset && method !== 'OPTIONS') {
953
- const allowedHosts = process.env.CCV_ALLOWED_HOSTS
954
- ? process.env.CCV_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
955
- : ['localhost', '127.0.0.1', '::1', '[::1]', ...getAllLocalIps()];
956
- if (!allowedHosts.includes('*')) {
957
- const hostHeader = (req.headers.host || '').toLowerCase();
958
- // 端口剥离:RFC 3986 要求 IPv6 Host 必须带 brackets `[::1]:port`,bare `::1` 末尾 `\d` 会被错剥成 `:`。
959
- // 含 `::` 但无 `]` 闭合的视为 bare IPv6,不剥端口。
960
- const isBareIPv6 = hostHeader.includes('::') && !hostHeader.includes(']');
961
- const hostNoPort = isBareIPv6 ? hostHeader : hostHeader.replace(/:\d+$/, '');
962
- const stripBrackets = hostNoPort.replace(/^\[|\]$/g, '');
963
- const ok = allowedHosts.some(h => {
964
- const hl = h.toLowerCase();
965
- return hl === hostNoPort || hl === stripBrackets || hl === `[${stripBrackets}]`;
966
- });
967
- if (!ok) {
968
- res.writeHead(403, { 'Content-Type': 'application/json' });
969
- res.end(JSON.stringify({ ok: false, error: 'host-not-allowed', host: hostNoPort }));
970
- return;
971
- }
1010
+ const denied = checkHostAllowed(req.headers.host);
1011
+ if (denied) {
1012
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1013
+ res.end(JSON.stringify({ ok: false, error: 'host-not-allowed', host: denied.host }));
1014
+ return;
972
1015
  }
973
1016
  }
974
1017
 
@@ -1112,8 +1155,25 @@ export async function startViewer() {
1112
1155
  if (_stoppingPromise) {
1113
1156
  try { await _stoppingPromise; } catch { /* stop 内部已 try/catch,最坏继续 */ }
1114
1157
  }
1158
+ // Start de-duplication: two concurrent startViewer calls in one process would create two servers and two plugin runtimes
1159
+ if (_startingPromise) {
1160
+ return _startingPromise;
1161
+ }
1162
+ _startingPromise = _doStart();
1163
+ try {
1164
+ return await _startingPromise;
1165
+ } finally {
1166
+ _startingPromise = null;
1167
+ }
1168
+ }
1169
+
1170
+ let _startingPromise = null;
1171
+
1172
+ async function _doStart() {
1115
1173
  _isStopping = false;
1116
1174
  _stoppingPromise = null;
1175
+ // If the plugin runtime is still active (duplicate start), stop the previous generation before reloading
1176
+ try { await pluginRuntime.onServerStopping(); } catch (err) { reportSwallowed('server.plugin-restop', err); }
1117
1177
  // 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
1118
1178
  await loadPlugins();
1119
1179
  // Per-workspace: warm the pty-manager namespace for the synchronous hook
@@ -1303,6 +1363,26 @@ export async function startViewer() {
1303
1363
  if (isCliMode) {
1304
1364
  await setupTerminalWebSocket(currentServer);
1305
1365
  }
1366
+ // Install the unified upgrade router (all modes): plugin service WS prefixes + registered terminal/mux.
1367
+ // Install after createServer and before the first request to close the upgrade gap.
1368
+ installUpgradeRouter(currentServer);
1369
+ // Service plugin lifecycle: spawn -> probe -> mount -> activate (3s grace; slow plugins continue in background)
1370
+ try {
1371
+ await pluginRuntime.onServerStarted({
1372
+ logDir: LOG_DIR,
1373
+ port, host: HOST,
1374
+ basePath: normalizeBasePath(process.env.CCV_BASE_PATH),
1375
+ protocol: serverProtocol,
1376
+ token: ACCESS_TOKEN,
1377
+ internalToken: INTERNAL_TOKEN,
1378
+ httpServer: currentServer,
1379
+ pty: null,
1380
+ interactions: null,
1381
+ isStopping: () => _isStopping,
1382
+ });
1383
+ } catch (err) {
1384
+ reportSwallowed('server.plugin-start', err);
1385
+ }
1306
1386
  // 通知插件服务器已启动
1307
1387
  let ptyApi = null;
1308
1388
  if (isCliMode) {
@@ -1315,52 +1395,14 @@ export async function startViewer() {
1315
1395
  onPtyData: pm.onPtyData,
1316
1396
  };
1317
1397
  }
1398
+ const interactions = _buildPluginInteractions();
1399
+ // Feed pty/interactions to activated service plugins (activate runs before ptyApi exists, so null first)
1400
+ try { pluginRuntime._enrichContext({ pty: ptyApi, interactions }); } catch (err) { reportSwallowed('server.plugin-enrich', err); }
1318
1401
  await runParallelHook('serverStarted', {
1319
1402
  port, host: HOST, url, ip: getLocalIp(),
1320
1403
  token: ACCESS_TOKEN, protocol: serverProtocol,
1321
1404
  httpServer: currentServer, pty: ptyApi,
1322
- interactions: {
1323
- getPendingPerms: () => [...pendingPermHooks.entries()].map(([id, e]) => ({ id, toolName: e.toolName, input: e.input, createdAt: e.createdAt })),
1324
- resolvePerm: (id, decision, allowSession) => {
1325
- const entry = pendingPermHooks.get(id);
1326
- if (!entry) return false;
1327
- clearTimeout(entry.timer);
1328
- pendingPermHooks.delete(id);
1329
- try {
1330
- if (!entry.res.headersSent) {
1331
- entry.res.writeHead(200, { 'Content-Type': 'application/json' });
1332
- entry.res.end(JSON.stringify({ decision }));
1333
- }
1334
- } catch {}
1335
- if (terminalWss) {
1336
- const rmsg = JSON.stringify({ type: 'perm-hook-resolved', id });
1337
- terminalWss.clients.forEach((c) => { if (c.readyState === 1) try { c.send(rmsg); } catch {} });
1338
- }
1339
- return true;
1340
- },
1341
- getPendingAsks: () => [...pendingAskHooks.entries()].map(([id, e]) => ({ id, questions: e.questions, createdAt: e.createdAt })),
1342
- resolveAsk: (id, answers) => {
1343
- const entry = pendingAskHooks.get(id);
1344
- if (!entry) return false;
1345
- const { res: hookRes, timer } = entry;
1346
- clearTimeout(timer);
1347
- pendingAskHooks.delete(id);
1348
- _persistAskDelete(id);
1349
- try {
1350
- if (!hookRes.headersSent) {
1351
- hookRes.writeHead(200, { 'Content-Type': 'application/json' });
1352
- hookRes.end(JSON.stringify({ answers }));
1353
- }
1354
- } catch {}
1355
- if (terminalWss) {
1356
- const rmsg = JSON.stringify({ type: 'ask-hook-resolved', id });
1357
- terminalWss.clients.forEach((c) => { if (c.readyState === 1) try { c.send(rmsg); } catch {} });
1358
- }
1359
- _notifyParentPending({ type: 'ask-hook-resolved', id });
1360
- return true;
1361
- },
1362
- resolveSdkApproval: (...args) => _sdkResolveApproval?.(...args),
1363
- },
1405
+ interactions,
1364
1406
  });
1365
1407
  // IM adapters no longer run in the main ccv. Each enabled IM runs as an independent,
1366
1408
  // detached worker process (im-process-manager). Two CLI-mode cases:
@@ -1429,6 +1471,136 @@ export async function startViewer() {
1429
1471
  });
1430
1472
  }
1431
1473
 
1474
+ // Plugin interactions object (shared definition for the serverStarted hook and service activate ctx;
1475
+ // drift would hand service plugins a capability subset).
1476
+ function _buildPluginInteractions() {
1477
+ return {
1478
+ getPendingPerms: () => [...pendingPermHooks.entries()].map(([id, e]) => ({ id, toolName: e.toolName, input: e.input, createdAt: e.createdAt })),
1479
+ resolvePerm: (id, decision, allowSession) => {
1480
+ const entry = pendingPermHooks.get(id);
1481
+ if (!entry) return false;
1482
+ clearTimeout(entry.timer);
1483
+ pendingPermHooks.delete(id);
1484
+ try {
1485
+ if (!entry.res.headersSent) {
1486
+ entry.res.writeHead(200, { 'Content-Type': 'application/json' });
1487
+ entry.res.end(JSON.stringify({ decision }));
1488
+ }
1489
+ } catch {}
1490
+ if (terminalWss) {
1491
+ const rmsg = JSON.stringify({ type: 'perm-hook-resolved', id });
1492
+ terminalWss.clients.forEach((c) => { if (c.readyState === 1) try { c.send(rmsg); } catch {} });
1493
+ }
1494
+ return true;
1495
+ },
1496
+ getPendingAsks: () => [...pendingAskHooks.entries()].map(([id, e]) => ({ id, questions: e.questions, createdAt: e.createdAt })),
1497
+ resolveAsk: (id, answers) => {
1498
+ const entry = pendingAskHooks.get(id);
1499
+ if (!entry) return false;
1500
+ const { res: hookRes, timer } = entry;
1501
+ clearTimeout(timer);
1502
+ pendingAskHooks.delete(id);
1503
+ _persistAskDelete(id);
1504
+ try {
1505
+ if (!hookRes.headersSent) {
1506
+ hookRes.writeHead(200, { 'Content-Type': 'application/json' });
1507
+ hookRes.end(JSON.stringify({ answers }));
1508
+ }
1509
+ } catch {}
1510
+ if (terminalWss) {
1511
+ const rmsg = JSON.stringify({ type: 'ask-hook-resolved', id });
1512
+ terminalWss.clients.forEach((c) => { if (c.readyState === 1) try { c.send(rmsg); } catch {} });
1513
+ }
1514
+ _notifyParentPending({ type: 'ask-hook-resolved', id });
1515
+ return true;
1516
+ },
1517
+ resolveSdkApproval: (...args) => _sdkResolveApproval?.(...args),
1518
+ };
1519
+ }
1520
+
1521
+ // Unified upgrade router: plugin service WS prefixes + registered terminal/mux handlers. Installed
1522
+ // once per httpServer (_upgradeRouterInstalled), early in the listen callback, eliminating the
1523
+ // "upgrade requests before setup" gap. The sync section is one try/catch; async handlers guard
1524
+ // themselves (plugin-proxy never rejects). Auth and Host checks run before route matching;
1525
+ // a miss always destroys the socket (same as before).
1526
+ // The route table is per-httpServer state (httpServer._ccvUpgradeRoutes): after stop->start the new
1527
+ // server gets an empty table and re-registers; handler closures always reference the current wss/streamMux
1528
+ // (a module-level table kept the closed first-generation wss - 2026-10-22 review found /ws/terminal 503 after stop->start).
1529
+ function installUpgradeRouter(httpServer) {
1530
+ if (!httpServer || httpServer._ccvUpgradeRouterInstalled) return;
1531
+ httpServer._ccvUpgradeRouterInstalled = true;
1532
+ // Initialize only when no table exists yet - setupTerminalWebSocket may have registered first
1533
+ // (setup runs before installUpgradeRouter in the listen callback); unconditional assignment clears
1534
+ // registered routes (2026-10-22: all terminal/mux routes were destroyed).
1535
+ if (!httpServer._ccvUpgradeRoutes) httpServer._ccvUpgradeRoutes = [];
1536
+ httpServer.on('upgrade', (req, socket, head) => {
1537
+ try {
1538
+ const wsUrl = safeParseRequestUrl(req);
1539
+ if (!wsUrl) { socket.destroy(); return; }
1540
+ // Upgrades bypass handleRequest, so basePath is stripped independently (same helper as HTTP)
1541
+ const pathname = stripBasePath(wsUrl.pathname, normalizeBasePath(process.env.CCV_BASE_PATH));
1542
+ // Same auth as HTTP (WS upgrades previously ignored tokens entirely - a remote-terminal hole).
1543
+ // Compute isLocal explicitly here (same three-way check as handleRequest); WS is never an HTML request.
1544
+ const wsRemoteIp = req.socket.remoteAddress;
1545
+ const wsIsLocal = wsRemoteIp === '127.0.0.1' || wsRemoteIp === '::1' || wsRemoteIp === '::ffff:127.0.0.1';
1546
+ const wsAuth = decideAuth({
1547
+ isStaticAsset: false,
1548
+ pathname,
1549
+ isLocal: wsIsLocal,
1550
+ urlToken: wsUrl.searchParams.get('token'),
1551
+ cookieToken: parseCookies(req.headers.cookie).ccv_auth,
1552
+ accessToken: ACCESS_TOKEN,
1553
+ enabled: authConfig.enabled,
1554
+ password: authConfig.password,
1555
+ passwordUnreadable: authConfig.passwordUnreadable,
1556
+ wantsHtml: false,
1557
+ });
1558
+ if (wsAuth.action !== 'allow') {
1559
+ socket.destroy();
1560
+ return;
1561
+ }
1562
+ // Upgrades previously skipped the Host check (HTTP had it); now share the same allowlist.
1563
+ // Leave one diagnostic line on rejection - a silent destroy is nearly undebuggable behind proxies that rewrite only HTTP Host.
1564
+ const hostDenied = checkHostAllowed(req.headers.host);
1565
+ if (hostDenied) {
1566
+ reportSwallowed('server.upgrade-host', new Error(`ws upgrade rejected by host allowlist: ${hostDenied.host}`));
1567
+ socket.destroy();
1568
+ return;
1569
+ }
1570
+ // Same-origin guard: loopback sockets do not prove the browser page behind them is
1571
+ // ours. A cross-site page riding the user's token cookie must not open terminal/mux
1572
+ // or plugin-service WebSockets. CLI/native clients send no Origin and stay allowed.
1573
+ if (!isSameOriginBrowserRequest(req, wsUrl)) {
1574
+ reportSwallowed('server.upgrade-origin', new Error(`ws upgrade rejected by origin check: ${req.headers.origin || '<none>'}`));
1575
+ socket.destroy();
1576
+ return;
1577
+ }
1578
+ wsUrl.pathname = pathname;
1579
+ // 1) Service plugin prefixes (mount-table match; a miss falls through)
1580
+ if (pluginRuntime.tryProxyUpgrade(req, socket, head, wsUrl)) return;
1581
+ // 2) Registered built-in WS routes (terminal/scratch/mux; only present after setup in CLI mode)
1582
+ for (const route of httpServer._ccvUpgradeRoutes) {
1583
+ if (route.match(pathname)) {
1584
+ route.handle(req, socket, head, wsUrl);
1585
+ return;
1586
+ }
1587
+ }
1588
+ socket.destroy();
1589
+ } catch (err) {
1590
+ reportSwallowed('server.upgrade-router', err);
1591
+ try { socket.destroy(); } catch { /* noop */ }
1592
+ }
1593
+ });
1594
+ }
1595
+
1596
+ function registerUpgradeRoute(httpServer, name, match, handle) {
1597
+ const routes = httpServer._ccvUpgradeRoutes || (httpServer._ccvUpgradeRoutes = []);
1598
+ const existing = routes.findIndex(r => r.name === name);
1599
+ // Same-name override: after stop->start the handler closure must point at the new wss/streamMux
1600
+ if (existing >= 0) routes[existing] = { name, match, handle };
1601
+ else routes.push({ name, match, handle });
1602
+ }
1603
+
1432
1604
  async function setupTerminalWebSocket(httpServer) {
1433
1605
  try {
1434
1606
  const { WebSocketServer } = await import('ws');
@@ -1508,118 +1680,95 @@ async function setupTerminalWebSocket(httpServer) {
1508
1680
  return null;
1509
1681
  };
1510
1682
 
1511
- httpServer.on('upgrade', (req, socket, head) => {
1512
- const wsUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
1513
- // upgrade 不经 handleRequest,basePath 需独立剥离(与 HTTP 段同走统一函数)
1514
- let pathname = stripBasePath(wsUrl.pathname, normalizeBasePath(process.env.CCV_BASE_PATH));
1515
- // 与 HTTP 一致的鉴权(此前 WS upgrade 完全不校验 token,远程终端实为无门禁——本次堵洞)。
1516
- // 在此显式计算 isLocal(与 handleRequest 同款三态判断),WS 视作非 HTML 请求。
1517
- const wsRemoteIp = req.socket.remoteAddress;
1518
- const wsIsLocal = wsRemoteIp === '127.0.0.1' || wsRemoteIp === '::1' || wsRemoteIp === '::ffff:127.0.0.1';
1519
- const wsAuth = decideAuth({
1520
- isStaticAsset: false,
1521
- pathname,
1522
- isLocal: wsIsLocal,
1523
- urlToken: wsUrl.searchParams.get('token'),
1524
- cookieToken: parseCookies(req.headers.cookie).ccv_auth,
1525
- accessToken: ACCESS_TOKEN,
1526
- enabled: authConfig.enabled,
1527
- password: authConfig.password,
1528
- passwordUnreadable: authConfig.passwordUnreadable,
1529
- wantsHtml: false,
1683
+ // Terminal/mux upgrade handlers register into the unified table (installUpgradeRouter runs in all
1684
+ // modes; in modes without setup these routes do not exist and a miss destroys - same as before).
1685
+ registerUpgradeRoute(httpServer, 'terminal', (p) => p === '/ws/terminal', (req, socket, head, wsUrl) => {
1686
+ // Multi-PTY view attach (2026-10): `?project=<name>` pins the freshly
1687
+ // connected client to the project it is VIEWING, so the initial
1688
+ // state/buffer frames below come from that project's record — not from
1689
+ // whatever project happened to spawn last. Unknown/absent project is a
1690
+ // silent no-op (single-project behavior unchanged).
1691
+ const viewProject = wsUrl.searchParams.get('project');
1692
+ // Multi-instance: `&instance=<key>` pins the attach to THAT exact process when a
1693
+ // same-cwd project runs two concurrent instances (basename alone is ambiguous).
1694
+ const viewInstance = wsUrl.searchParams.get('instance');
1695
+ if (viewProject || viewInstance) {
1696
+ try { attachPtyFor({ project: viewProject || undefined, instanceKey: viewInstance || undefined }); } catch { }
1697
+ }
1698
+ // SDK multi-session (2026-10-12): remember this connection's viewed project so
1699
+ // broadcastWsMessage can route cwd-tagged SDK frames only to the client(s) watching
1700
+ // that workspace. No ?project= → bound view → receives every project (old behavior).
1701
+ // Set it directly in THIS upgrade's callback (fires once per connection, so reconnects
1702
+ // get re-stamped) — NOT via a one-shot wss.once('connection') listener, which would
1703
+ // only ever stamp the FIRST connection and leave every later/reconnected client
1704
+ // unstamped (receiving all workspaces' frames).
1705
+ wss.handleUpgrade(req, socket, head, (ws) => {
1706
+ try { ws._ccvViewProject = viewProject || null; } catch {}
1707
+ try { ws._ccvViewInstance = viewInstance || null; } catch {}
1708
+ wss.emit('connection', ws, req);
1530
1709
  });
1531
- if (wsAuth.action !== 'allow') {
1710
+ });
1711
+ registerUpgradeRoute(httpServer, 'terminal-scratch', (p) => p === '/ws/terminal-scratch', (req, socket, head, wsUrl) => {
1712
+ // Validate id: missing/illegal -> destroy (prevents empty/long/special Map keys)
1713
+ const scratchId = wsUrl.searchParams.get('id');
1714
+ if (!scratchId || !SCRATCH_ID_RE.test(scratchId)) {
1532
1715
  socket.destroy();
1533
1716
  return;
1534
1717
  }
1535
- if (pathname === '/ws/terminal') {
1536
- // Multi-PTY view attach (2026-10): `?project=<name>` pins the freshly
1537
- // connected client to the project it is VIEWING, so the initial
1538
- // state/buffer frames below come from that project's record — not from
1539
- // whatever project happened to spawn last. Unknown/absent project is a
1540
- // silent no-op (single-project behavior unchanged).
1541
- const viewProject = wsUrl.searchParams.get('project');
1542
- // Multi-instance: `&instance=<key>` pins the attach to THAT exact process when a
1543
- // same-cwd project runs two concurrent instances (basename alone is ambiguous).
1544
- const viewInstance = wsUrl.searchParams.get('instance');
1545
- if (viewProject || viewInstance) {
1546
- try { attachPtyFor({ project: viewProject || undefined, instanceKey: viewInstance || undefined }); } catch { }
1547
- }
1548
- // SDK multi-session (2026-10-12): remember this connection's viewed project so
1549
- // broadcastWsMessage can route cwd-tagged SDK frames only to the client(s) watching
1550
- // that workspace. No ?project= → bound view → receives every project (old behavior).
1551
- // Set it directly in THIS upgrade's callback (fires once per connection, so reconnects
1552
- // get re-stamped) — NOT via a one-shot wss.once('connection') listener, which would
1553
- // only ever stamp the FIRST connection and leave every later/reconnected client
1554
- // unstamped (receiving all workspaces' frames).
1555
- wss.handleUpgrade(req, socket, head, (ws) => {
1556
- try { ws._ccvViewProject = viewProject || null; } catch {}
1557
- try { ws._ccvViewInstance = viewInstance || null; } catch {}
1558
- wss.emit('connection', ws, req);
1718
+ // Single upgrade hand-off used by the accept and both reject branches below.
1719
+ const handOffScratch = () => {
1720
+ req.ccvScratchId = scratchId;
1721
+ wssScratch.handleUpgrade(req, socket, head, (ws) => {
1722
+ wssScratch.emit('connection', ws, req);
1559
1723
  });
1560
- } else if (pathname === '/ws/terminal-scratch') {
1561
- // 校验 id:缺失或非法 → destroy(避免 Map<id> 被注入空键 / 超长 / 特殊字符)
1562
- const scratchId = wsUrl.searchParams.get('id');
1563
- if (!scratchId || !SCRATCH_ID_RE.test(scratchId)) {
1564
- socket.destroy();
1565
- return;
1566
- }
1567
- // Single upgrade hand-off used by the accept and both reject branches below.
1568
- const handOffScratch = () => {
1569
- req.ccvScratchId = scratchId;
1570
- wssScratch.handleUpgrade(req, socket, head, (ws) => {
1571
- wssScratch.emit('connection', ws, req);
1572
- });
1573
- };
1574
- // Multi-project (2026-10-12): ?project=/?instance= pin the scratch shell's cwd to
1575
- // the VIEWED workspace — same resolution as every project-scoped READ route
1576
- // (resolveViewRoot is sync: fs + sync registry read). No params → bound root.
1577
- // Accepted: with no instance and a same-basename twin live, the name path can
1578
- // resolve to the twin's dir (the single-live-PTY rule designed for READ routes);
1579
- // an unresolvable/ambiguous name takes the fatal-toast path below instead, so a
1580
- // wrong-cwd spawn is bounded to that deliberate arbitration, never silent.
1581
- const vr = resolveViewRoot({
1582
- projectParam: wsUrl.searchParams.get('project'),
1583
- instanceParam: wsUrl.searchParams.get('instance'),
1584
- boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
1585
- loadWorkspaces,
1586
- listLivePtys,
1587
- });
1588
- if (!vr.ok) {
1589
- // Unresolvable/deleted/ambiguous workspace: do NOT bare-destroy — the client
1590
- // would retry every 2s forever. Complete the upgrade, report once with a fatal
1591
- // toast (client stops reconnecting), then close. Nothing spawns on this path.
1592
- req.ccvScratchReject = vr.error || 'workspace unavailable';
1593
- handOffScratch();
1594
- return;
1595
- }
1596
- // Normalize once: the bound branch of resolveViewRoot returns the root un-normalized,
1597
- // named branches realpathSync — without this the same dir can key two buckets.
1598
- let scratchCwd = vr.root;
1599
- try { scratchCwd = realpathSync(vr.root); } catch { }
1600
- // Hard caps (per-workspace + global) on the ptys Map size (running and
1601
- // exited-not-yet-reaped entries included); an existing id+cwd reconnect is exempt
1602
- // so it can't be locked out by its own still-alive entry. Rejection goes through
1603
- // the same fatal-toast hand-off as a resolve failure (never a bare destroy) so the
1604
- // client stops reconnecting instead of retrying every 2s against a full bucket.
1605
- if (!hasScratchPty(scratchId, { cwd: scratchCwd })
1606
- && (getScratchPtyCount() >= MAX_SCRATCH_PTYS_TOTAL
1607
- || getScratchPtyCountForCwd(scratchCwd) >= MAX_SCRATCH_PTYS_PER_CWD)) {
1608
- req.ccvScratchReject = 'scratch terminal limit reached';
1609
- handOffScratch();
1610
- return;
1611
- }
1612
- req.ccvScratchCwd = scratchCwd;
1724
+ };
1725
+ // Multi-project (2026-10-12): ?project=/?instance= pin the scratch shell's cwd to
1726
+ // the VIEWED workspace — same resolution as every project-scoped READ route
1727
+ // (resolveViewRoot is sync: fs + sync registry read). No params → bound root.
1728
+ // Accepted: with no instance and a same-basename twin live, the name path can
1729
+ // resolve to the twin's dir (the single-live-PTY rule designed for READ routes);
1730
+ // an unresolvable/ambiguous name takes the fatal-toast path below instead, so a
1731
+ // wrong-cwd spawn is bounded to that deliberate arbitration, never silent.
1732
+ const vr = resolveViewRoot({
1733
+ projectParam: wsUrl.searchParams.get('project'),
1734
+ instanceParam: wsUrl.searchParams.get('instance'),
1735
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
1736
+ loadWorkspaces,
1737
+ listLivePtys,
1738
+ });
1739
+ if (!vr.ok) {
1740
+ // Unresolvable/deleted/ambiguous workspace: do NOT bare-destroy — the client
1741
+ // would retry every 2s forever. Complete the upgrade, report once with a fatal
1742
+ // toast (client stops reconnecting), then close. Nothing spawns on this path.
1743
+ req.ccvScratchReject = vr.error || 'workspace unavailable';
1613
1744
  handOffScratch();
1614
- } else if (pathname === '/ws/mux') {
1615
- // Dialogue-channel stream mux. Auth already enforced by decideAuth above (token from
1616
- // ?token= / ccv_auth cookie — tokenInterceptor/appendToken put it on the ws:// URL
1617
- // exactly like /ws/terminal). No socket-level ?project= stamp: each logical stream
1618
- // carries its own scope in its `open` frame (one mux socket serves many workspaces).
1619
- streamMux.handleUpgrade(req, socket, head);
1620
- } else {
1621
- socket.destroy();
1745
+ return;
1622
1746
  }
1747
+ // Normalize once: the bound branch of resolveViewRoot returns the root un-normalized,
1748
+ // named branches realpathSync — without this the same dir can key two buckets.
1749
+ let scratchCwd = vr.root;
1750
+ try { scratchCwd = realpathSync(vr.root); } catch { }
1751
+ // Hard caps (per-workspace + global) on the ptys Map size (running and
1752
+ // exited-not-yet-reaped entries included); an existing id+cwd reconnect is exempt
1753
+ // so it can't be locked out by its own still-alive entry. Rejection goes through
1754
+ // the same fatal-toast hand-off as a resolve failure (never a bare destroy) so the
1755
+ // client stops reconnecting instead of retrying every 2s against a full bucket.
1756
+ if (!hasScratchPty(scratchId, { cwd: scratchCwd })
1757
+ && (getScratchPtyCount() >= MAX_SCRATCH_PTYS_TOTAL
1758
+ || getScratchPtyCountForCwd(scratchCwd) >= MAX_SCRATCH_PTYS_PER_CWD)) {
1759
+ req.ccvScratchReject = 'scratch terminal limit reached';
1760
+ handOffScratch();
1761
+ return;
1762
+ }
1763
+ req.ccvScratchCwd = scratchCwd;
1764
+ handOffScratch();
1765
+ });
1766
+ registerUpgradeRoute(httpServer, 'mux', (p) => p === '/ws/mux', (req, socket, head) => {
1767
+ // Dialogue-channel stream mux. Auth already enforced by decideAuth above (token from
1768
+ // ?token= / ccv_auth cookie — tokenInterceptor/appendToken put it on the ws:// URL
1769
+ // exactly like /ws/terminal). No socket-level ?project= stamp: each logical stream
1770
+ // carries its own scope in its `open` frame (one mux socket serves many workspaces).
1771
+ streamMux.handleUpgrade(req, socket, head);
1623
1772
  });
1624
1773
 
1625
1774
  // 反压状态转换日志(observability:线上排"页面卡"时可直接判断是否触发过反压、几次、积压量)。
@@ -3189,13 +3338,18 @@ async function _doStop() {
3189
3338
  // cleanup watchdog(5s) 截断其后的 temp jsonl rename(用户数据)——合并为单预算
3190
3339
  // 保证 teardown ≤3s,watchdog 前始终留出 rename 余量。超时后控制流顺序继续。
3191
3340
  try {
3341
+ let _teardownTimer = null;
3192
3342
  await Promise.race([
3193
3343
  (async () => {
3194
3344
  try { await imCore.stopAll(); } catch { }
3345
+ // Service plugin teardown shares the 3s budget with IM/hooks: SIGTERM every child synchronously,
3346
+ // then deactivate + destroy connections in parallel, SIGKILL after the deadline (see plugin-runtime P0-5).
3347
+ try { await pluginRuntime.onServerStopping(); } catch (err) { reportSwallowed('server.plugin-stop', err); }
3195
3348
  await runParallelHook('serverStopping');
3196
3349
  })(),
3197
- new Promise(r => setTimeout(r, 3000)),
3350
+ new Promise(r => { _teardownTimer = setTimeout(r, 3000); _teardownTimer.unref?.(); }),
3198
3351
  ]);
3352
+ clearTimeout(_teardownTimer);
3199
3353
  } catch { }
3200
3354
  stopLogWatch();
3201
3355
  unwatchAllWorkflows();
@@ -3279,6 +3433,9 @@ export function broadcastWsMessage(msg) { if (terminalWss) {
3279
3433
  // — every frame for such a workspace would be silently dropped for its viewer.
3280
3434
  let frameProj = null;
3281
3435
  if (frameCwd) frameProj = projectKeyForCwd(frameCwd) || null;
3436
+ // Anchors (e.g. the sdk-manager route-failure fallback) carry a project key but no
3437
+ // cwd — honor it so those frames don't broadcast to every viewer.
3438
+ if (!frameProj && msg && typeof msg === 'object' && typeof msg.project === 'string' && msg.project) frameProj = msg.project;
3282
3439
  // Same-name twin isolation (review P2): a frame tagged with an instance key narrows
3283
3440
  // delivery to clients viewing THAT instance — otherwise two same-basename workspaces
3284
3441
  // (same frameProj) would cross-deliver (e.g. a close teardown's idle/queue-state wipe