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.
Files changed (141) hide show
  1. package/README.md +1 -1
  2. package/cli.js +5 -3
  3. package/dist/assets/App-Ci9GYs24.js +2 -0
  4. package/dist/assets/App-D06hBGII.css +1 -0
  5. package/dist/assets/{MdxEditorPanel-BnbZpgDp.js → MdxEditorPanel-8fwlNHMz.js} +1 -1
  6. package/dist/assets/Mobile-DngSOqYr.js +1 -0
  7. package/dist/assets/ProxyStatsModal-CWSIELLj.js +1 -0
  8. package/dist/assets/{_baseUniq-CwQykUuc.js → _baseUniq-6ZLcdMmL.js} +1 -1
  9. package/dist/assets/{arc-EdUdBxtG.js → arc-Dirf6EZP.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DtBIdElY.js → architectureDiagram-Q4EWVU46-BfIhmu_I.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-guNZIKkZ.js → blockDiagram-DXYQGD6D-D0y4ALi1.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-xV5vB-Z9.js → c4Diagram-AHTNJAMY-Dlfp4SUK.js} +1 -1
  13. package/dist/assets/{channel-DYEktNap.js → channel-CaNcU8SI.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-ZTkItpvj.js → chunk-4BX2VUAB-ki0vHNS-.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-dYIewyyR.js → chunk-4TB4RGXK-D8yO8Iuv.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-CSsPNgPX.js → chunk-55IACEB6-BvIgaNPO.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-CnMiuqzQ.js → chunk-EDXVE4YY-DF2Of_VO.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-CVpATPZ3.js → chunk-FMBD7UC4-BIw7SXs-.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-Dpq4s8dW.js → chunk-OYMX7WX6-AB76s0A0.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-Bnojh6X6.js → chunk-QZHKN3VN-CuN2LCFc.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-BYqK4Dsa.js → chunk-YZCP3GAM-D4DLBzBN.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-DLJzWgYm.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-DLJzWgYm.js +1 -0
  24. package/dist/assets/clone-H_VGsHn9.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-OJ7mmWyV.js → cose-bilkent-S5V4N54A-Bn485PX9.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-B1Kwd_Rk.js → dagre-KV5264BT-e4QVCf17.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-D8qrw8sp.js → diagram-5BDNPKRD-_SBFGgnf.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-ip-N4ho8.js → diagram-G4DWMVQ6-Bz1ovxLs.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-2PZaskUF.js → diagram-MMDJMWI5-B-oojW8K.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-CeAOQUZP.js → diagram-TYMM5635-CTAyNaOj.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BCBaEYEb.js → erDiagram-SMLLAGMA-BfvKAqq2.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-CsGyIqcX.js → flowDiagram-DWJPFMVM-BpTEZEAe.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-COsj3FZ-.js → ganttDiagram-T4ZO3ILL-DUD_vkyE.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DvQVQGJG.js → gitGraphDiagram-UUTBAWPF-DnlXcTud.js} +1 -1
  35. package/dist/assets/{graph-BSB9fDSP.js → graph-D4xsUKSn.js} +1 -1
  36. package/dist/assets/{index-DKufsAzx.js → index-84-BjpGl.js} +1 -1
  37. package/dist/assets/index-BDKwOnE3.js +2 -0
  38. package/dist/assets/{index-Bo-12bug.js → index-BniBLRtZ.js} +1 -1
  39. package/dist/assets/{index-BRw4Iz2O.js → index-C2Zo3KOg.js} +1 -1
  40. package/dist/assets/{index-BuVComcY.js → index-CWZoKT7e.js} +1 -1
  41. package/dist/assets/{index-eM2ih5n7.js → index-D-4EsFub.js} +1 -1
  42. package/dist/assets/{index-CNNAYjkF.js → index-D3KzRmkP.js} +1 -1
  43. package/dist/assets/{index-DXBfwPYD.js → index-NGgdum2i.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-eNucS4iW.js → infoDiagram-42DDH7IO-BbdvmCDt.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js → ishikawaDiagram-UXIWVN3A-oZBOPoTO.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-yJJzVEBb.js → journeyDiagram-VCZTEJTY-DWoH0hs9.js} +1 -1
  47. package/dist/assets/{jszip.min-ByI0ds5u.js → jszip.min-I5y9RZhq.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-2rfTi2zw.js → kanban-definition-6JOO6SKY-BLHtjKsS.js} +1 -1
  49. package/dist/assets/{layout-DTmHj0Wm.js → layout-C116SNcQ.js} +1 -1
  50. package/dist/assets/{linear-BOA3a9FE.js → linear-Br08WCkF.js} +1 -1
  51. package/dist/assets/{mermaid.core-5H-NR79H.js → mermaid.core-D-4dTrvI.js} +2 -2
  52. package/dist/assets/{min-CpjFTEec.js → min-khs74HbH.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-B8KCjFgB.js → mindmap-definition-QFDTVHPH-C3_yItFF.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-BARy96g5.js → pieDiagram-DEJITSTG-ByCwM2_P.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-DYoTapuI.js → quadrantDiagram-34T5L4WZ-CjV65X1r.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-EeANLLK2.js → requirementDiagram-MS252O5E-DFJ_dbtV.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-DmQ-YVPj.js → sankeyDiagram-XADWPNL6-BBvXoBfj.js} +1 -1
  58. package/dist/assets/seqResourceLoaders-C34z9uOa.css +41 -0
  59. package/dist/assets/seqResourceLoaders-JJzgZZzU.js +2 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-SZtW0rYC.js → sequenceDiagram-FGHM5R23-BJM04Y3N.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-CEygsIld.js → stateDiagram-FHFEXIEX-DI2q_Kkn.js} +1 -1
  62. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js → stateDiagram-v2-QKLJ7IA2-CrGC6kEY.js} +1 -1
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-DhPBaQBt.js → timeline-definition-GMOUNBTQ-JcfRUsjD.js} +1 -1
  64. package/dist/assets/vendor-antd-DxEVqVhD.js +17 -0
  65. package/dist/assets/vendor-codemirror-CgNlkZva.js +1 -0
  66. package/dist/assets/{vendor-mdxeditor-Cz09a1_I.js → vendor-mdxeditor-DoreQf4A.js} +2 -2
  67. package/dist/assets/{vendor-qrcode-qbbntD6-.js → vendor-qrcode-q1CKtOyX.js} +1 -1
  68. package/dist/assets/{vendor-virtuoso-Sk8fQ4Hx.js → vendor-virtuoso-BXGe9q7E.js} +1 -1
  69. package/dist/assets/{vennDiagram-DHZGUBPP-NniTHxDw.js → vennDiagram-DHZGUBPP-mAHR1Ugj.js} +1 -1
  70. package/dist/assets/{wardley-RL74JXVD-D9SNOrDw.js → wardley-RL74JXVD-mlTq_P-4.js} +1 -1
  71. package/dist/assets/{wardleyDiagram-NUSXRM2D-DvHXKNza.js → wardleyDiagram-NUSXRM2D-BDGfajRb.js} +1 -1
  72. package/dist/assets/{xychartDiagram-5P7HB3ND-CaiwPTQB.js → xychartDiagram-5P7HB3ND-Cf2pR1mV.js} +1 -1
  73. package/dist/index.html +4 -4
  74. package/package.json +1 -1
  75. package/plugins/README.md +9 -1
  76. package/server/interceptor.js +1 -1
  77. package/server/lib/builtin-model-prompts.js +1 -1
  78. package/server/lib/chat-queue.js +1 -1
  79. package/server/lib/credential-access.js +1 -1
  80. package/server/lib/credential-migrate.js +1 -1
  81. package/server/lib/error-report.js +26 -0
  82. package/server/lib/json-store.js +1 -1
  83. package/server/lib/launch-config.js +1 -1
  84. package/server/lib/log-watcher.js +1 -1
  85. package/server/lib/plugin-host.mjs +198 -0
  86. package/server/lib/plugin-loader.js +346 -90
  87. package/server/lib/plugin-manager.js +46 -2
  88. package/server/lib/plugin-manifest.js +329 -0
  89. package/server/lib/plugin-panels.js +183 -0
  90. package/server/lib/plugin-proxy.js +408 -0
  91. package/server/lib/plugin-runtime.js +775 -0
  92. package/server/lib/plugin-service-manager.js +463 -0
  93. package/server/lib/proxy/proxy-retry.js +1 -1
  94. package/server/lib/proxy/proxy-stats.js +1 -1
  95. package/server/lib/resume-watchdog.js +1 -1
  96. package/server/lib/same-origin.js +13 -3
  97. package/server/lib/sdk-manager.js +117 -23
  98. package/server/lib/sdk-session-index.js +1 -1
  99. package/server/lib/stats-worker.js +1 -1
  100. package/server/lib/system-prompt-files.js +1 -1
  101. package/server/lib/system-prompt-live.js +1 -1
  102. package/server/lib/system-prompt-snapshots.js +1 -1
  103. package/server/lib/v2/adapter.js +1 -1
  104. package/server/lib/v2/convert.js +1 -1
  105. package/server/lib/v2/jsonl-read.js +1 -1
  106. package/server/lib/v2/live-feed.js +1 -1
  107. package/server/lib/v2/meta-rows.js +1 -1
  108. package/server/lib/v2/resume-list.js +1 -1
  109. package/server/lib/v2/session-list.js +1 -1
  110. package/server/lib/v2/session-owner.js +1 -1
  111. package/server/lib/v2/v2-writer.js +1 -1
  112. package/server/lib/wire-compress.js +1 -1
  113. package/server/lib/ws-mux.js +1 -1
  114. package/server/proxy.js +1 -1
  115. package/server/pty-manager.js +1 -1
  116. package/server/routes/ask-perm.js +1 -1
  117. package/server/routes/events.js +1 -1
  118. package/server/routes/expert.js +1 -1
  119. package/server/routes/files-content.js +1 -1
  120. package/server/routes/files-fs.js +4 -2
  121. package/server/routes/misc.js +11 -0
  122. package/server/routes/plugin-services.js +104 -0
  123. package/server/routes/plugins.js +145 -17
  124. package/server/routes/preferences.js +1 -1
  125. package/server/routes/resume.js +1 -1
  126. package/server/routes/sdk-context.js +1 -1
  127. package/server/routes/sdk-mcp-status.js +1 -1
  128. package/server/routes/workspaces.js +1 -1
  129. package/server/server.js +370 -173
  130. package/dist/assets/App-D2zrtS4P.js +0 -2
  131. package/dist/assets/App-DuDeDOuX.css +0 -1
  132. package/dist/assets/Mobile-CU-g7cTn.js +0 -1
  133. package/dist/assets/ProxyStatsModal-CavWQfBA.js +0 -1
  134. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +0 -1
  135. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +0 -1
  136. package/dist/assets/clone-CFj55h49.js +0 -1
  137. package/dist/assets/index-D2maXxkC.js +0 -2
  138. package/dist/assets/seqResourceLoaders-DZF6ofB7.css +0 -41
  139. package/dist/assets/seqResourceLoaders-jOSz1x7F.js +0 -2
  140. package/dist/assets/vendor-antd-CS_S2mBN.js +0 -17
  141. package/dist/assets/vendor-codemirror-B5l-VbBO.js +0 -1
@@ -0,0 +1,463 @@
1
+ // Service-plugin child-process management (parent side).
2
+ // Spawns one detached child per (server process × service plugin): the child runs the
3
+ // plugin's service.mjs as its own HTTP server on 127.0.0.1:0, writes a lock file with
4
+ // its port, and the parent reverse-proxies /plugins/<name>/* to it (see plugin-proxy.js).
5
+ //
6
+ // Modeled on lib/im/im-process-manager.js: detached process group (group kill on stop),
7
+ // env prefix-stripping, lock-file handshake, liveness probing, test guard. Key
8
+ // differences: locks are per-server-PID (Electron runs several server processes over the
9
+ // same LOG_DIR, each gets its own plugin instance), identity includes a per-spawn nonce
10
+ // (prevents adopting a previous generation's child), and stale-lock cleanup verifies
11
+ // health identity before killing (a reused PID may belong to an unrelated process).
12
+ import { spawn as nodeSpawn, execSync, spawnSync } from 'node:child_process';
13
+ import { openSync, closeSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { reportSwallowed } from './error-report.js';
18
+
19
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
20
+
21
+ const PLUGIN_HOST_PATH = join(dirname(fileURLToPath(import.meta.url)), 'plugin-host.mjs');
22
+
23
+ /** Resolve the real node binary (under Electron, process.execPath is the Electron binary). */
24
+ export function resolveNodeBinary() {
25
+ if (!process.versions.electron) return process.execPath;
26
+ try {
27
+ const out = execSync(process.platform === 'win32' ? 'where node' : 'which node', { encoding: 'utf-8', windowsHide: true });
28
+ const p = process.platform === 'win32' ? out.split('\n')[0].trim() : out.trim();
29
+ if (p) return p;
30
+ } catch { /* fall through */ }
31
+ // #129: packaged GUI environments may lack a shell PATH; fall back to Electron itself + ELECTRON_RUN_AS_NODE
32
+ return null; // null -> spawn with process.execPath + ELECTRON_RUN_AS_NODE=1
33
+ }
34
+
35
+ export function serviceRuntimeDir(logDir, name) {
36
+ return join(logDir, 'plugins-runtime', name);
37
+ }
38
+
39
+ export function serviceDataDir(logDir, name) {
40
+ return join(logDir, 'plugin-data', name);
41
+ }
42
+
43
+ export function serviceLockPath(runtimeDir, serverPid) {
44
+ return join(runtimeDir, `service-${serverPid}.lock`);
45
+ }
46
+
47
+ export function serviceConfigPath(runtimeDir, serverPid, generation) {
48
+ return join(runtimeDir, `config-${serverPid}-${generation}.json`);
49
+ }
50
+
51
+ export function serviceLogPath(runtimeDir, serverPid) {
52
+ return join(runtimeDir, `process-${serverPid}.log`);
53
+ }
54
+
55
+ // Inherited environment is an allowlist, not a denylist: ANTHROPIC_* / CCV_* secrets
56
+ // (auth tokens, passwords) must never reach a plugin child. /proc/<pid>/environ is
57
+ // readable by the same user, and argv/env leaks are not worth saving a config file read.
58
+ const CHILD_ENV_ALLOWLIST = new Set([
59
+ 'PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'LC_CTYPE',
60
+ 'SYSTEMROOT', 'COMSPEC', 'PATHEXT', 'TERM', 'SHELL', 'USER', 'LOGNAME',
61
+ 'HOSTNAME', 'NODE_ENV',
62
+ // Windows user-profile dirs: Node's os.homedir() throws without USERPROFILE, and many
63
+ // runtimes write caches under APPDATA/LOCALAPPDATA. None of these carry secrets.
64
+ 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA',
65
+ 'PROGRAMDATA', 'PROGRAMFILES', 'PROGRAMFILES(X86)',
66
+ ]);
67
+
68
+ /**
69
+ * Build the service child environment from an explicit allowlist.
70
+ * Inherits only OS basics plus LC_* and CCV_PLUGIN_*; manifest.service.env is injected
71
+ * explicitly below. The plugin secret/nonce live only in the 0600 config file.
72
+ */
73
+ export function buildChildEnv({ name, version, pluginDir, configPath, lockPath, runtimeDir, dataDir, parentPort, parentBasePath, manifestEnv = {}, base = process.env }) {
74
+ const env = {};
75
+ for (const [k, v] of Object.entries(base)) {
76
+ const upper = k.toUpperCase();
77
+ if (CHILD_ENV_ALLOWLIST.has(upper) || upper.startsWith('LC_') || k.startsWith('CCV_PLUGIN_')) {
78
+ env[k] = v;
79
+ }
80
+ }
81
+ env.CCV_PLUGIN_NAME = name;
82
+ if (version) env.CCV_PLUGIN_VERSION = version;
83
+ env.CCV_PLUGIN_DIR = pluginDir;
84
+ env.CCV_PLUGIN_CONFIG = configPath;
85
+ env.CCV_PLUGIN_HOST = pathToFileURL(PLUGIN_HOST_PATH).href; // file:// URL so Windows can import it too
86
+ env.CCV_PLUGIN_LOCK = lockPath;
87
+ env.CCV_PLUGIN_RUNTIME_DIR = runtimeDir;
88
+ env.CCV_PLUGIN_DATA_DIR = dataDir;
89
+ env.CCV_PLUGIN_PARENT_PORT = String(parentPort || '');
90
+ env.CCV_PLUGIN_PARENT_BASE_PATH = parentBasePath || '';
91
+ for (const [k, v] of Object.entries(manifestEnv)) {
92
+ if (!k.startsWith('CCV_') && !k.startsWith('CCVIEWER_')) env[k] = v;
93
+ }
94
+ return env;
95
+ }
96
+
97
+ /**
98
+ * Spawn one service child. Returns { pid, child, lockPath, configPath, outLog, secret, nonce }
99
+ * or { blockedByTestGuard: true } (test guard).
100
+ *
101
+ * @param {object} desc { name, version, entryPath, pluginDir, prefix, env }
102
+ * @param {object} ctx { logDir, parentPort, parentBasePath, generation, serverPid, bootNonce }
103
+ * @param {object} opts { spawnImpl?, nodeBinary? } test injection points
104
+ */
105
+ export function spawnServiceProcess(desc, ctx, opts = {}) {
106
+ const runtimeDir = serviceRuntimeDir(ctx.logDir, desc.name);
107
+ const serverPid = ctx.serverPid ?? process.pid;
108
+ // ████████ test isolation guard (modeled on im-process-manager L4) ████████
109
+ if (process.env.NODE_TEST_CONTEXT && !opts.spawnImpl && process.env.CCV_TEST_ALLOW_PLUGIN_SPAWN !== '1') {
110
+ return { blockedByTestGuard: true, runtimeDir, pid: undefined };
111
+ }
112
+ // ████████████████████████████████████████████████████████████
113
+ const spawnImpl = opts.spawnImpl || nodeSpawn;
114
+ const dataDir = serviceDataDir(ctx.logDir, desc.name);
115
+ mkdirSync(runtimeDir, { recursive: true });
116
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 });
117
+
118
+ const generation = ctx.generation ?? 1;
119
+ const lockPath = serviceLockPath(runtimeDir, serverPid);
120
+ const configPath = serviceConfigPath(runtimeDir, serverPid, generation);
121
+ const outLog = serviceLogPath(runtimeDir, serverPid);
122
+ const secret = randomBytes(24).toString('hex');
123
+ const nonce = randomBytes(16).toString('hex');
124
+
125
+ // 0600 config: the plugin secret/nonce live only here (atomic write). The parent
126
+ // ACCESS_TOKEN is deliberately absent — loopback parent APIs need no token, and a
127
+ // secret sitting in config (or a URL query) is needless exposure.
128
+ const configBody = {
129
+ name: desc.name,
130
+ version: desc.version ?? null,
131
+ prefix: desc.prefix,
132
+ pluginDir: desc.pluginDir,
133
+ dataDir,
134
+ runtimeDir,
135
+ lockPath,
136
+ secret,
137
+ nonce,
138
+ generation,
139
+ parentPid: serverPid,
140
+ bootNonce: ctx.bootNonce ?? null, // lets cleanup detect a stale same-PID/different-boot lock
141
+ parentPort: ctx.parentPort ?? null,
142
+ parentBasePath: ctx.parentBasePath ?? '',
143
+ };
144
+ try {
145
+ const tmpPath = `${configPath}.tmp-${process.pid}`;
146
+ writeFileSync(tmpPath, JSON.stringify(configBody), { mode: 0o600 });
147
+ renameSync(tmpPath, configPath);
148
+ } catch (err) {
149
+ reportSwallowed('plugin-service.config-write', err);
150
+ }
151
+
152
+ let stdio = 'ignore';
153
+ let fd = null;
154
+ try {
155
+ fd = openSync(outLog, 'a');
156
+ stdio = ['ignore', fd, fd];
157
+ } catch { /* fall back to ignore when the log file cannot be opened */ }
158
+
159
+ const nodeBinary = opts.nodeBinary !== undefined ? opts.nodeBinary : resolveNodeBinary();
160
+ const spawnEnv = buildChildEnv({
161
+ name: desc.name,
162
+ version: desc.version,
163
+ pluginDir: desc.pluginDir,
164
+ configPath,
165
+ lockPath,
166
+ runtimeDir,
167
+ dataDir,
168
+ parentPort: ctx.parentPort,
169
+ parentBasePath: ctx.parentBasePath,
170
+ manifestEnv: desc.env,
171
+ });
172
+ let executable = nodeBinary || process.execPath;
173
+ if (!nodeBinary && process.versions.electron) {
174
+ // fall back to Electron itself as the node runtime
175
+ spawnEnv.ELECTRON_RUN_AS_NODE = '1';
176
+ } else {
177
+ delete spawnEnv.ELECTRON_RUN_AS_NODE;
178
+ }
179
+
180
+ const child = spawnImpl(executable, [desc.entryPath], {
181
+ cwd: desc.pluginDir,
182
+ env: spawnEnv,
183
+ stdio,
184
+ detached: true,
185
+ windowsHide: true,
186
+ });
187
+ // No unref: the parent must keep the event loop referenced while the child lives (detached
188
+ // does not mean unsupervised; stopServiceProcess/process-group kill owns cleanup). unref
189
+ // would let the parent exit while the child lives, and some runtimes harvest detached+unref children instantly (fake code=0 exit).
190
+ if (fd !== null) { try { closeSync(fd); } catch { /* child already duped it */ } }
191
+ return { pid: child?.pid, child, lockPath, configPath, outLog, secret, nonce, runtimeDir, dataDir };
192
+ }
193
+
194
+ export function readServiceLock(lockPath) {
195
+ try {
196
+ return JSON.parse(readFileSync(lockPath, 'utf-8'));
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ export function isPidAlive(pid) {
203
+ if (!pid || typeof pid !== 'number') return false;
204
+ try {
205
+ process.kill(pid, 0);
206
+ return true;
207
+ } catch (err) {
208
+ return err && err.code === 'EPERM';
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Wait for the child to become ready: poll lock (pid+name) -> GET /__ccv/health (secret + nonce).
214
+ * @returns {{ ok:true, port, pid, lock } | { ok:false, error, state }}
215
+ */
216
+ export async function waitForServiceReady(desc, opts = {}) {
217
+ const lockPath = opts.lockPath;
218
+ const secret = opts.secret;
219
+ const nonce = opts.nonce;
220
+ const expectedPid = opts.expectedPid;
221
+ const timeoutMs = opts.timeoutMs ?? 10000;
222
+ const pollMs = opts.pollMs ?? 50;
223
+ const probe = opts.probeImpl || probeServiceHealth;
224
+ const deadline = Date.now() + timeoutMs;
225
+
226
+ while (Date.now() < deadline) {
227
+ const lock = readServiceLock(lockPath);
228
+ if (lock && lock.pid && lock.port && lock.name === desc.name) {
229
+ if (expectedPid && lock.pid !== expectedPid) {
230
+ // lock overwritten by another process/previous generation - keep waiting for ours
231
+ } else {
232
+ const health = await probe(lock.port, secret, { timeoutMs: 400 });
233
+ if (health.ok && health.body && health.body.ok === true && health.body.name === desc.name) {
234
+ if (nonce && health.body.nonce !== nonce) {
235
+ return { ok: false, error: 'health nonce mismatch (stale or foreign service)', state: 'foreign' };
236
+ }
237
+ if (health.body.pid && health.body.pid !== lock.pid) {
238
+ return { ok: false, error: 'health pid mismatch', state: 'foreign' };
239
+ }
240
+ return { ok: true, port: lock.port, pid: lock.pid, lock };
241
+ }
242
+ if (!health.ok && health.state === 'foreign') {
243
+ return { ok: false, error: 'health identity mismatch', state: 'foreign' };
244
+ }
245
+ }
246
+ }
247
+ if (opts.isChildDead && opts.isChildDead()) {
248
+ return { ok: false, error: 'child exited before becoming ready', state: 'dead' };
249
+ }
250
+ await sleep(pollMs);
251
+ }
252
+ return { ok: false, error: `service did not become ready within ${timeoutMs}ms`, state: 'timeout' };
253
+ }
254
+
255
+ /**
256
+ * GET http://127.0.0.1:<port>/__ccv/health with secret header.
257
+ * x-cc-viewer-internal:1 lets the interceptor's fetch patch pass through (otherwise loopback
258
+ * requests are rewritten as API traffic to ANTHROPIC_BASE_URL - 2026-10-22 field incident:
259
+ * in CLI mode every health probe hit the upstream and the service never became ready).
260
+ * @returns {Promise<{ok:boolean, state?:string, body?:object}>}
261
+ */
262
+ export async function probeServiceHealth(port, secret, opts = {}) {
263
+ const timeoutMs = opts.timeoutMs ?? 400;
264
+ try {
265
+ const controller = new AbortController();
266
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
267
+ timer.unref?.();
268
+ const res = await fetch(`http://127.0.0.1:${port}/__ccv/health`, {
269
+ headers: { 'x-ccv-plugin-secret': secret, 'x-cc-viewer-internal': '1' },
270
+ signal: controller.signal,
271
+ });
272
+ clearTimeout(timer);
273
+ const body = await res.json().catch(() => null);
274
+ if (res.status === 401 || res.status === 403) return { ok: false, state: 'foreign', body };
275
+ return { ok: res.ok, body };
276
+ } catch (err) {
277
+ return { ok: false, error: err.message };
278
+ }
279
+ }
280
+
281
+ /** Default process-group kill (detached group leader); Windows uses taskkill /T /F; falls back to the single process. */
282
+ export function defaultKill(pid, signal) {
283
+ if (process.platform === 'win32' && signal === 'SIGKILL') {
284
+ try {
285
+ spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, timeout: 1000 });
286
+ return;
287
+ } catch { /* fall through */ }
288
+ }
289
+ try { process.kill(-pid, signal); return; } catch { /* not a group leader / windows */ }
290
+ try { process.kill(pid, signal); } catch { /* already gone */ }
291
+ }
292
+
293
+ /**
294
+ * Stop a service child: SIGTERM → poll for exit → SIGKILL on timeout → remove lock/config.
295
+ *
296
+ * A PID alone proves nothing — a crashed child's PID can be reused by an unrelated
297
+ * process, and killing it would be catastrophic. Signal only when the lock file confirms
298
+ * this exact PID (and, when both sides carry one, the same per-spawn nonce). Otherwise
299
+ * remove the stale files and leave the process alone.
300
+ */
301
+ export async function stopServiceProcess(name, opts = {}) {
302
+ const lockPath = opts.lockPath;
303
+ const configPath = opts.configPath;
304
+ const kill = opts.killImpl || defaultKill;
305
+ const isAlive = opts.isAlive || isPidAlive;
306
+ const timeoutMs = opts.timeoutMs ?? 2000;
307
+ const pollMs = opts.pollMs ?? 100;
308
+
309
+ const lock = lockPath ? readServiceLock(lockPath) : null;
310
+ const pid = opts.pid ?? lock?.pid;
311
+ const removeFiles = () => {
312
+ for (const p of [lockPath, configPath]) {
313
+ if (p) { try { unlinkSync(p); } catch { /* already gone */ } }
314
+ }
315
+ };
316
+ const lockMatchesPid = !!lock && !!pid && lock.pid === pid;
317
+ const nonceMatches = !opts.nonce || !lock?.nonce || lock.nonce === opts.nonce;
318
+ if (!pid || !lockMatchesPid || !nonceMatches || !isAlive(pid)) {
319
+ removeFiles();
320
+ return { stopped: true, alreadyDead: !isAlive(pid), skippedSignal: !lockMatchesPid || !nonceMatches };
321
+ }
322
+
323
+ kill(pid, 'SIGTERM');
324
+ const deadline = Date.now() + timeoutMs;
325
+ while (Date.now() < deadline) {
326
+ await sleep(pollMs);
327
+ if (!isAlive(pid)) {
328
+ removeFiles();
329
+ return { stopped: true };
330
+ }
331
+ }
332
+ kill(pid, 'SIGKILL');
333
+ await sleep(pollMs);
334
+ removeFiles();
335
+ return { stopped: true, forced: true };
336
+ }
337
+
338
+ /**
339
+ * Clean stale locks at startup:
340
+ * - parentPid dead: verify health identity first (kill only on nonce/pid match - the PID may be reused);
341
+ * - parentPid === this process but bootNonce differs: same-PID reboot; the old child is orphaned, treat as above;
342
+ * - parentPid alive and not this process: another cc-viewer instance's plugin, keep it.
343
+ *
344
+ * @param {string} logDir
345
+ * @param {{ bootNonce:string, isAlive?:Function, killImpl?:Function }} opts
346
+ */
347
+ export async function cleanupStaleServiceLocks(logDir, opts = {}) {
348
+ const bootNonce = opts.bootNonce;
349
+ const isAlive = opts.isAlive || isPidAlive;
350
+ const kill = opts.killImpl || defaultKill;
351
+ const runtimeRoot = join(logDir, 'plugins-runtime');
352
+ let pluginDirs;
353
+ try {
354
+ pluginDirs = readdirSync(runtimeRoot, { withFileTypes: true });
355
+ } catch {
356
+ return { cleaned: 0, kept: 0 };
357
+ }
358
+ let cleaned = 0;
359
+ let kept = 0;
360
+ for (const dirent of pluginDirs) {
361
+ if (!dirent.isDirectory()) continue;
362
+ const dir = join(runtimeRoot, dirent.name);
363
+ let lockFiles;
364
+ try {
365
+ lockFiles = readdirSync(dir).filter(f => f.startsWith('service-') && f.endsWith('.lock'));
366
+ } catch {
367
+ continue;
368
+ }
369
+ for (const lockFile of lockFiles) {
370
+ const lockPath = join(dir, lockFile);
371
+ const lock = readServiceLock(lockPath);
372
+ if (!lock || !lock.pid) {
373
+ try { unlinkSync(lockPath); } catch { /* noop */ }
374
+ continue;
375
+ }
376
+ const parentAlive = lock.parentPid && isAlive(lock.parentPid);
377
+ const ownParent = lock.parentPid === process.pid;
378
+ const bootMismatch = ownParent && bootNonce && lock.bootNonce && lock.bootNonce !== bootNonce;
379
+ if (parentAlive && !bootMismatch) {
380
+ kept++;
381
+ continue;
382
+ }
383
+ // parent dead or boot mismatch: verify the child's identity before killing
384
+ if (isAlive(lock.pid)) {
385
+ let identityConfirmed = false;
386
+ try {
387
+ // pick the config with the same serverPid and highest numeric generation (lexical order puts -10 before -9)
388
+ const lockServerPid = lockFile.slice('service-'.length, -'.lock'.length);
389
+ const configFiles = readdirSync(dir)
390
+ .filter(f => f.startsWith(`config-${lockServerPid}-`) && f.endsWith('.json'))
391
+ .sort((a, b) => {
392
+ const ga = Number(a.slice(`config-${lockServerPid}-`.length, -'.json'.length)) || 0;
393
+ const gb = Number(b.slice(`config-${lockServerPid}-`.length, -'.json'.length)) || 0;
394
+ return ga - gb;
395
+ });
396
+ const configPath = configFiles.length ? join(dir, configFiles[configFiles.length - 1]) : null;
397
+ const config = configPath ? JSON.parse(readFileSync(configPath, 'utf-8')) : null;
398
+ if (config && config.secret && lock.port) {
399
+ const health = await probeServiceHealth(lock.port, config.secret, { timeoutMs: 400 });
400
+ if (health.ok && health.body && health.body.pid === lock.pid) {
401
+ identityConfirmed = true;
402
+ }
403
+ }
404
+ } catch (err) {
405
+ reportSwallowed('plugin-service.stale-probe', err);
406
+ }
407
+ if (!identityConfirmed) {
408
+ // identity unconfirmed - the PID may be unrelated; clear the lock without killing
409
+ kept++;
410
+ try { unlinkSync(lockPath); } catch { /* noop */ }
411
+ continue;
412
+ }
413
+ kill(lock.pid, 'SIGTERM');
414
+ await sleep(300);
415
+ if (isAlive(lock.pid)) kill(lock.pid, 'SIGKILL');
416
+ }
417
+ try { unlinkSync(lockPath); } catch { /* noop */ }
418
+ // also clear configs for older generations of this serverPid (aborted startups and
419
+ // previous generations can leave them behind; crash cleanup removes the current one)
420
+ try {
421
+ const lockServerPid = lockFile.slice('service-'.length, -'.lock'.length);
422
+ for (const f of readdirSync(dir)) {
423
+ if (f.startsWith(`config-${lockServerPid}-`) && f.endsWith('.json')) {
424
+ try { unlinkSync(join(dir, f)); } catch { /* noop */ }
425
+ }
426
+ }
427
+ } catch { /* noop */ }
428
+ cleaned++;
429
+ }
430
+ }
431
+ return { cleaned, kept };
432
+ }
433
+
434
+ /**
435
+ * Restart backoff: at most 5 restarts per 60s window; the counter resets after 60s of stable uptime.
436
+ */
437
+ export function createRestartBackoff(opts = {}) {
438
+ const delays = opts.delays || [0, 500, 1000, 2000, 5000];
439
+ const windowMs = opts.windowMs ?? 60000;
440
+ const stableMs = opts.stableMs ?? 60000;
441
+ let crashes = [];
442
+ let lastStartAt = 0;
443
+ return {
444
+ markStarted() {
445
+ const now = Date.now();
446
+ if (lastStartAt && now - lastStartAt >= stableMs) crashes = [];
447
+ lastStartAt = now;
448
+ },
449
+ /** @returns {{ allowed:boolean, delayMs:number }} */
450
+ next() {
451
+ const now = Date.now();
452
+ crashes = crashes.filter(t => now - t < windowMs);
453
+ if (crashes.length >= delays.length) return { allowed: false, delayMs: 0 };
454
+ const delayMs = delays[crashes.length];
455
+ crashes.push(now);
456
+ return { allowed: true, delayMs };
457
+ },
458
+ reset() { crashes = []; },
459
+ get crashCount() { return crashes.length; },
460
+ };
461
+ }
462
+
463
+ export { PLUGIN_HOST_PATH };
@@ -17,7 +17,7 @@
17
17
  // - race/stagger use AbortController; cancelled requests must be released correctly.
18
18
  import { resolveProfileModel, replaceTopLevelSystem } from '../interceptor-core.js';
19
19
  import { readFileSync, existsSync } from 'node:fs';
20
- import { reportSwallowed } from '@ccv/core/error-report';
20
+ import { reportSwallowed } from '../../lib/error-report.js';
21
21
  import { liveSystemPromptEnabled, getLiveEntry, applyLiveSystem, knownInjectedTexts } from '../system-prompt-live.js';
22
22
  import { parseUserId } from '../session-id.js';
23
23
 
@@ -9,7 +9,7 @@
9
9
  import { mkdirSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { AsyncWriteQueue } from '../async-write-queue.js';
12
- import { reportSwallowed } from '@ccv/core/error-report';
12
+ import { reportSwallowed } from '../../lib/error-report.js';
13
13
 
14
14
  // ── Detail record schema ──────────────────────────────────────────────
15
15
  // Appends one JSON line after each proxied LLM API request completes. Fields align with llm-retry-proxy's retry records.
@@ -12,7 +12,7 @@
12
12
  // affects the request or spawn paths.
13
13
  import { existsSync, readdirSync, statSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
- import { reportSwallowed } from '@ccv/core/error-report';
15
+ import { reportSwallowed } from '../lib/error-report.js';
16
16
  import { LOG_DIR } from '../../findcc.js';
17
17
  import { injectionConfigured } from './launch-config.js';
18
18
  import { readSnapshotByKey, transcriptDirForCwd, projectKeyForCwd } from './system-prompt-snapshots.js';
@@ -9,13 +9,23 @@
9
9
  //
10
10
  // Canonical implementation — every call site imports from here.
11
11
  export function isSameOriginBrowserRequest(req, parsedUrl) {
12
+ // Sec-Fetch-Site is set by browsers even on Origin-less subresource/no-cors GETs;
13
+ // check it first so a cross-site page cannot slip through the no-Origin CLI path.
14
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
12
15
  const origin = req.headers?.origin;
13
16
  if (!origin) return true; // CLI/native clients do not send Origin.
14
- if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
15
17
  try {
16
18
  const originUrl = new URL(origin);
17
- if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
18
- return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
19
+ // Host is the invariant (already allowlist-guarded). Scheme can differ legitimately
20
+ // when a TLS-terminating reverse proxy fronts the server (browser sees https, the
21
+ // backend still parses http) — so compare host, and only when the proxy explicitly
22
+ // forwards the scheme (X-Forwarded-Proto) do we also require it to match.
23
+ const originHost = originUrl.host.toLowerCase();
24
+ const reqHost = String(parsedUrl?.host || req.headers?.host || '').toLowerCase();
25
+ if (originHost !== reqHost) return false;
26
+ const xfp = req.headers?.['x-forwarded-proto'];
27
+ if (xfp && originUrl.protocol.replace(':', '').toLowerCase() !== String(xfp).toLowerCase()) return false;
28
+ return true;
19
29
  } catch {
20
30
  return false;
21
31
  }