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.
- package/README.md +1 -1
- package/cli.js +4 -3
- package/dist/assets/App-Ci9GYs24.js +2 -0
- package/dist/assets/{MdxEditorPanel-BJVD_bcU.js → MdxEditorPanel-8fwlNHMz.js} +1 -1
- package/dist/assets/Mobile-DngSOqYr.js +1 -0
- package/dist/assets/ProxyStatsModal-CWSIELLj.js +1 -0
- package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-6ZLcdMmL.js} +1 -1
- package/dist/assets/{arc-EdUdBxtG.js → arc-Dirf6EZP.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-BfIhmu_I.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-D0y4ALi1.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-Dlfp4SUK.js} +1 -1
- package/dist/assets/{channel-DYEktNap.js → channel-CaNcU8SI.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-ki0vHNS-.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-D8yO8Iuv.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-BvIgaNPO.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-DF2Of_VO.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-BIw7SXs-.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-AB76s0A0.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-CuN2LCFc.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-D4DLBzBN.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DLJzWgYm.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DLJzWgYm.js +1 -0
- package/dist/assets/clone-H_VGsHn9.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-Bn485PX9.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-e4QVCf17.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-_SBFGgnf.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-Bz1ovxLs.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-B-oojW8K.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-CTAyNaOj.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-BfvKAqq2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-BpTEZEAe.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-DUD_vkyE.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DnlXcTud.js} +1 -1
- package/dist/assets/{graph-BSB9fDSP.js → graph-D4xsUKSn.js} +1 -1
- package/dist/assets/{index-DKufsAzx.js → index-84-BjpGl.js} +1 -1
- package/dist/assets/index-BDKwOnE3.js +2 -0
- package/dist/assets/{index-Bo-12bug.js → index-BniBLRtZ.js} +1 -1
- package/dist/assets/{index-BRw4Iz2O.js → index-C2Zo3KOg.js} +1 -1
- package/dist/assets/{index-BuVComcY.js → index-CWZoKT7e.js} +1 -1
- package/dist/assets/{index-eM2ih5n7.js → index-D-4EsFub.js} +1 -1
- package/dist/assets/{index-CNNAYjkF.js → index-D3KzRmkP.js} +1 -1
- package/dist/assets/{index-DXBfwPYD.js → index-NGgdum2i.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-BbdvmCDt.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-oZBOPoTO.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-DWoH0hs9.js} +1 -1
- package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-I5y9RZhq.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-BLHtjKsS.js} +1 -1
- package/dist/assets/{layout-DTmHj0Wm.js → layout-C116SNcQ.js} +1 -1
- package/dist/assets/{linear-BOA3a9FE.js → linear-Br08WCkF.js} +1 -1
- package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-D-4dTrvI.js} +2 -2
- package/dist/assets/{min-CpjFTEec.js → min-khs74HbH.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-C3_yItFF.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-ByCwM2_P.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-CjV65X1r.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-DFJ_dbtV.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-BBvXoBfj.js} +1 -1
- package/dist/assets/{seqResourceLoaders-C0cU2e1o.css → seqResourceLoaders-C34z9uOa.css} +2 -2
- package/dist/assets/seqResourceLoaders-JJzgZZzU.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-BJM04Y3N.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-DI2q_Kkn.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-CrGC6kEY.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-JcfRUsjD.js} +1 -1
- package/dist/assets/vendor-antd-DxEVqVhD.js +17 -0
- package/dist/assets/vendor-codemirror-CgNlkZva.js +1 -0
- package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-DoreQf4A.js} +2 -2
- package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-q1CKtOyX.js} +1 -1
- package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-BXGe9q7E.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-mAHR1Ugj.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-mlTq_P-4.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-BDGfajRb.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Cf2pR1mV.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/plugins/README.md +9 -1
- package/server/interceptor.js +1 -1
- package/server/lib/builtin-model-prompts.js +1 -1
- package/server/lib/chat-queue.js +1 -1
- package/server/lib/credential-access.js +1 -1
- package/server/lib/credential-migrate.js +1 -1
- package/server/lib/error-report.js +26 -0
- package/server/lib/json-store.js +1 -1
- package/server/lib/launch-config.js +1 -1
- package/server/lib/log-watcher.js +1 -1
- package/server/lib/plugin-host.mjs +198 -0
- package/server/lib/plugin-loader.js +346 -90
- package/server/lib/plugin-manager.js +46 -2
- package/server/lib/plugin-manifest.js +329 -0
- package/server/lib/plugin-panels.js +183 -0
- package/server/lib/plugin-proxy.js +408 -0
- package/server/lib/plugin-runtime.js +775 -0
- package/server/lib/plugin-service-manager.js +463 -0
- package/server/lib/proxy/proxy-retry.js +1 -1
- package/server/lib/proxy/proxy-stats.js +1 -1
- package/server/lib/resume-watchdog.js +1 -1
- package/server/lib/same-origin.js +13 -3
- package/server/lib/sdk-manager.js +16 -9
- package/server/lib/sdk-session-index.js +1 -1
- package/server/lib/stats-worker.js +1 -1
- package/server/lib/system-prompt-files.js +1 -1
- package/server/lib/system-prompt-live.js +1 -1
- package/server/lib/system-prompt-snapshots.js +1 -1
- package/server/lib/v2/adapter.js +1 -1
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/jsonl-read.js +1 -1
- package/server/lib/v2/live-feed.js +1 -1
- package/server/lib/v2/meta-rows.js +1 -1
- package/server/lib/v2/resume-list.js +1 -1
- package/server/lib/v2/session-list.js +1 -1
- package/server/lib/v2/session-owner.js +1 -1
- package/server/lib/v2/v2-writer.js +1 -1
- package/server/lib/wire-compress.js +1 -1
- package/server/lib/ws-mux.js +1 -1
- package/server/proxy.js +1 -1
- package/server/pty-manager.js +1 -1
- package/server/routes/ask-perm.js +1 -1
- package/server/routes/events.js +1 -1
- package/server/routes/expert.js +1 -1
- package/server/routes/files-content.js +1 -1
- package/server/routes/files-fs.js +4 -2
- package/server/routes/misc.js +11 -0
- package/server/routes/plugin-services.js +104 -0
- package/server/routes/plugins.js +145 -17
- package/server/routes/preferences.js +1 -1
- package/server/routes/resume.js +1 -1
- package/server/routes/sdk-context.js +1 -1
- package/server/routes/sdk-mcp-status.js +1 -1
- package/server/routes/workspaces.js +1 -1
- package/server/server.js +328 -171
- package/dist/assets/App-CLteNB83.js +0 -2
- package/dist/assets/Mobile-CJCQf3MQ.js +0 -1
- package/dist/assets/ProxyStatsModal-B5ccoOPy.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
- package/dist/assets/clone-CFj55h49.js +0 -1
- package/dist/assets/index-CqX5TkEL.js +0 -2
- package/dist/assets/seqResourceLoaders-7lsgbvGa.js +0 -2
- package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
- 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 '
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
-
}
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
//
|
|
1577
|
-
//
|
|
1578
|
-
//
|
|
1579
|
-
|
|
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
|
-
|
|
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
|