cc-viewer 1.9.8 → 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 +5 -3
- package/dist/assets/App-Ci9GYs24.js +2 -0
- package/dist/assets/App-D06hBGII.css +1 -0
- package/dist/assets/{MdxEditorPanel-BnbZpgDp.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-C34z9uOa.css +41 -0
- 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 +117 -23
- 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 +370 -173
- package/dist/assets/App-D2zrtS4P.js +0 -2
- package/dist/assets/App-DuDeDOuX.css +0 -1
- package/dist/assets/Mobile-CU-g7cTn.js +0 -1
- package/dist/assets/ProxyStatsModal-CavWQfBA.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-D2maXxkC.js +0 -2
- package/dist/assets/seqResourceLoaders-DZF6ofB7.css +0 -41
- package/dist/assets/seqResourceLoaders-jOSz1x7F.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,117 +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
|
-
wss.emit('connection', ws, req);
|
|
1558
|
-
});
|
|
1559
|
-
} else if (pathname === '/ws/terminal-scratch') {
|
|
1560
|
-
// 校验 id:缺失或非法 → destroy(避免 Map<id> 被注入空键 / 超长 / 特殊字符)
|
|
1561
|
-
const scratchId = wsUrl.searchParams.get('id');
|
|
1562
|
-
if (!scratchId || !SCRATCH_ID_RE.test(scratchId)) {
|
|
1563
|
-
socket.destroy();
|
|
1564
|
-
return;
|
|
1565
|
-
}
|
|
1566
|
-
// Single upgrade hand-off used by the accept and both reject branches below.
|
|
1567
|
-
const handOffScratch = () => {
|
|
1568
|
-
req.ccvScratchId = scratchId;
|
|
1569
|
-
wssScratch.handleUpgrade(req, socket, head, (ws) => {
|
|
1570
|
-
wssScratch.emit('connection', ws, req);
|
|
1571
|
-
});
|
|
1572
|
-
};
|
|
1573
|
-
// Multi-project (2026-10-12): ?project=/?instance= pin the scratch shell's cwd to
|
|
1574
|
-
// the VIEWED workspace — same resolution as every project-scoped READ route
|
|
1575
|
-
// (resolveViewRoot is sync: fs + sync registry read). No params → bound root.
|
|
1576
|
-
// Accepted: with no instance and a same-basename twin live, the name path can
|
|
1577
|
-
// resolve to the twin's dir (the single-live-PTY rule designed for READ routes);
|
|
1578
|
-
// an unresolvable/ambiguous name takes the fatal-toast path below instead, so a
|
|
1579
|
-
// wrong-cwd spawn is bounded to that deliberate arbitration, never silent.
|
|
1580
|
-
const vr = resolveViewRoot({
|
|
1581
|
-
projectParam: wsUrl.searchParams.get('project'),
|
|
1582
|
-
instanceParam: wsUrl.searchParams.get('instance'),
|
|
1583
|
-
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
1584
|
-
loadWorkspaces,
|
|
1585
|
-
listLivePtys,
|
|
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);
|
|
1586
1723
|
});
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
//
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
req.ccvScratchReject = 'scratch terminal limit reached';
|
|
1608
|
-
handOffScratch();
|
|
1609
|
-
return;
|
|
1610
|
-
}
|
|
1611
|
-
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';
|
|
1612
1744
|
handOffScratch();
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1745
|
+
return;
|
|
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;
|
|
1621
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);
|
|
1622
1772
|
});
|
|
1623
1773
|
|
|
1624
1774
|
// 反压状态转换日志(observability:线上排"页面卡"时可直接判断是否触发过反压、几次、积压量)。
|
|
@@ -1859,11 +2009,13 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1859
2009
|
// workspace's approval/spinner/queue would pop into the foreground. Filter each
|
|
1860
2010
|
// snapshot by this connection's `_ccvViewProject` (bound view / no ?project= → all).
|
|
1861
2011
|
const _viewProj = ws._ccvViewProject || null;
|
|
1862
|
-
const _base = (c) => (typeof c === 'string' && c) ? c.replace(/\\/g, '/').split('/').filter(Boolean).pop() : null;
|
|
1863
2012
|
const _sdkFrameForView = (frame) => {
|
|
1864
2013
|
if (!frame || typeof frame !== 'object') return true; // no cwd → view-agnostic, replay
|
|
1865
2014
|
if (!_viewProj) return true; // bound view → all workspaces
|
|
1866
|
-
|
|
2015
|
+
// The view stamp is a SANITIZED project key (projectKeyForCwd), so compare against the
|
|
2016
|
+
// frame's cwd sanitized the same way — a raw basename would silently drop legit frames
|
|
2017
|
+
// for space/CJK workspace names.
|
|
2018
|
+
const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? (projectKeyForCwd(frame.cwd) || null) : null;
|
|
1867
2019
|
return !fproj || fproj === _viewProj; // no-cwd frame → replay; else must match
|
|
1868
2020
|
};
|
|
1869
2021
|
|
|
@@ -1892,6 +2044,20 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1892
2044
|
}
|
|
1893
2045
|
}
|
|
1894
2046
|
|
|
2047
|
+
// Replay the sdk-skills surface (supportedCommands) so a refreshed page repopulates
|
|
2048
|
+
// the `/` skill menu without a fresh init. Anchor by view instance (exact) when stamped,
|
|
2049
|
+
// else by view project; an ambiguous same-name project resolves to null → no frame, and
|
|
2050
|
+
// the client stays on its /api/skills fallback.
|
|
2051
|
+
if (isSdkMode && _sdkGetSkillsSnapshot) {
|
|
2052
|
+
const anchor = ws._ccvViewInstance
|
|
2053
|
+
? { instanceKey: ws._ccvViewInstance }
|
|
2054
|
+
: (_viewProj ? { project: _viewProj } : null);
|
|
2055
|
+
const skillsSnap = anchor ? _sdkGetSkillsSnapshot(anchor) : _sdkGetSkillsSnapshot();
|
|
2056
|
+
if (skillsSnap && _sdkFrameForView(skillsSnap)) {
|
|
2057
|
+
try { ws.send(JSON.stringify(skillsSnap)); } catch {}
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1895
2061
|
// Replay the last sdk-session-state frame so a page refreshed mid-turn restores
|
|
1896
2062
|
// the spinner immediately (sdk-init snapshot parity; null → no frame, client
|
|
1897
2063
|
// stays at its idle default).
|
|
@@ -2064,7 +2230,28 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2064
2230
|
if (msg.type === 'attach') {
|
|
2065
2231
|
try { ws._ccvViewProject = (typeof msg.project === 'string' && msg.project) || null; } catch {}
|
|
2066
2232
|
try { ws._ccvViewInstance = (typeof msg.instanceKey === 'string' && msg.instanceKey) || null; } catch {}
|
|
2067
|
-
if (isSdkMode)
|
|
2233
|
+
if (isSdkMode) {
|
|
2234
|
+
// Re-attach switched the viewed workspace — re-send that view's sdk-skills
|
|
2235
|
+
// surface so the `/` menu isn't stuck on the /api/skills fallback until the next
|
|
2236
|
+
// session init. Anchor by the JUST-stamped view instance (exact) else project;
|
|
2237
|
+
// ambiguous same-name project yields no frame and the client keeps its fallback.
|
|
2238
|
+
// (Must live here, before the return: the PTY attach branch below is unreachable
|
|
2239
|
+
// in SDK mode, and _sdkGetSkillsSnapshot is only registered under runSdkMode.)
|
|
2240
|
+
if (_sdkGetSkillsSnapshot) {
|
|
2241
|
+
const anchor = ws._ccvViewInstance
|
|
2242
|
+
? { instanceKey: ws._ccvViewInstance }
|
|
2243
|
+
: (ws._ccvViewProject ? { project: ws._ccvViewProject } : null);
|
|
2244
|
+
const skillsSnap = anchor ? _sdkGetSkillsSnapshot(anchor) : _sdkGetSkillsSnapshot();
|
|
2245
|
+
if (skillsSnap) {
|
|
2246
|
+
// Filter against the freshly-stamped view (NOT the connection-time _viewProj).
|
|
2247
|
+
const fp = (typeof skillsSnap.cwd === 'string' && skillsSnap.cwd) ? (projectKeyForCwd(skillsSnap.cwd) || null) : null;
|
|
2248
|
+
if (!fp || !ws._ccvViewProject || fp === ws._ccvViewProject) {
|
|
2249
|
+
try { ws.send(JSON.stringify(skillsSnap)); } catch { }
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2068
2255
|
} else if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
|
|
2069
2256
|
|| msg.type === 'resync-request' || msg.type === 'resize')) {
|
|
2070
2257
|
return;
|
|
@@ -3151,13 +3338,18 @@ async function _doStop() {
|
|
|
3151
3338
|
// cleanup watchdog(5s) 截断其后的 temp jsonl rename(用户数据)——合并为单预算
|
|
3152
3339
|
// 保证 teardown ≤3s,watchdog 前始终留出 rename 余量。超时后控制流顺序继续。
|
|
3153
3340
|
try {
|
|
3341
|
+
let _teardownTimer = null;
|
|
3154
3342
|
await Promise.race([
|
|
3155
3343
|
(async () => {
|
|
3156
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); }
|
|
3157
3348
|
await runParallelHook('serverStopping');
|
|
3158
3349
|
})(),
|
|
3159
|
-
new Promise(r => setTimeout(r, 3000)),
|
|
3350
|
+
new Promise(r => { _teardownTimer = setTimeout(r, 3000); _teardownTimer.unref?.(); }),
|
|
3160
3351
|
]);
|
|
3352
|
+
clearTimeout(_teardownTimer);
|
|
3161
3353
|
} catch { }
|
|
3162
3354
|
stopLogWatch();
|
|
3163
3355
|
unwatchAllWorkflows();
|
|
@@ -3241,6 +3433,9 @@ export function broadcastWsMessage(msg) { if (terminalWss) {
|
|
|
3241
3433
|
// — every frame for such a workspace would be silently dropped for its viewer.
|
|
3242
3434
|
let frameProj = null;
|
|
3243
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;
|
|
3244
3439
|
// Same-name twin isolation (review P2): a frame tagged with an instance key narrows
|
|
3245
3440
|
// delivery to clients viewing THAT instance — otherwise two same-basename workspaces
|
|
3246
3441
|
// (same frameProj) would cross-deliver (e.g. a close teardown's idle/queue-state wipe
|
|
@@ -3307,6 +3502,8 @@ export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }
|
|
|
3307
3502
|
/** Reference to sdk-manager's getSdkInitSnapshot (set by cli.js after import). */
|
|
3308
3503
|
let _sdkGetInitSnapshot = null;
|
|
3309
3504
|
export function setSdkGetInitSnapshot(fn) { _sdkGetInitSnapshot = fn; }
|
|
3505
|
+
let _sdkGetSkillsSnapshot = null;
|
|
3506
|
+
export function setSdkGetSkillsSnapshot(fn) { _sdkGetSkillsSnapshot = fn; }
|
|
3310
3507
|
|
|
3311
3508
|
/** Reference to sdk-manager's getContextUsageSnapshot (set by cli.js after import). */
|
|
3312
3509
|
let _sdkGetContextUsageSnapshot = null;
|