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,329 @@
1
+ // Service-plugin manifest (plugin.json) parsing, validation and fingerprints.
2
+ // A directory plugin lives in <pluginsDir>/<name>/ with a plugin.json manifest:
3
+ // { name, version?, main?, service?: {entry,prefix?,env?}, panel?: {entry,css?,title?,icon?,width?,desktopOnly?}, config?: {defaults?} }
4
+ import { readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
5
+ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import { reportSwallowed } from './error-report.js';
8
+
9
+ export const PLUGIN_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
10
+ export const SERVICE_PREFIX_RE = /^\/plugins\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
11
+ const PANEL_ICON_WHITELIST = new Set(['api', 'dashboard', 'appstore', 'tool', 'database', 'chart', 'file', 'global']);
12
+ const MAIN_CANDIDATES = ['main.mjs', 'main.js', 'index.mjs', 'index.js'];
13
+ const FINGERPRINT_MAX_FILES = 64;
14
+ const FINGERPRINT_MAX_BYTES = 4 * 1024 * 1024;
15
+
16
+ export function validatePluginName(name) {
17
+ return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
18
+ }
19
+
20
+ export function defaultServicePrefix(name) {
21
+ return `/plugins/${name}`;
22
+ }
23
+
24
+ export function validateServicePrefix(prefix) {
25
+ return typeof prefix === 'string' && SERVICE_PREFIX_RE.test(prefix);
26
+ }
27
+
28
+ function isPathInside(parent, child) {
29
+ const rel = relative(parent, child);
30
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
31
+ }
32
+
33
+ function realpathSafe(p) {
34
+ try {
35
+ return realpathSync(p);
36
+ } catch (err) {
37
+ reportSwallowed('plugin-manifest.realpath', err);
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolve a manifest-relative path and ensure it stays inside the plugin dir.
44
+ * Returns { abs, real } or { error }.
45
+ */
46
+ export function resolveInsidePluginDir(pluginDir, relPath, label) {
47
+ if (typeof relPath !== 'string' || relPath.length === 0) {
48
+ return { error: `${label} must be a non-empty string` };
49
+ }
50
+ if (relPath.includes('\0') || relPath.includes('\\')) {
51
+ return { error: `${label} contains illegal characters` };
52
+ }
53
+ // manifest fields must not contain explicit './'/'../'; internal callers (e.g. import resolution) may allowDotSegments
54
+ const segments = relPath.split('/');
55
+ if (segments.some(s => s === '..' || s === '.')) {
56
+ return { error: `${label} must not contain '.' or '..' segments` };
57
+ }
58
+ return resolvePluginPath(pluginDir, relPath, label);
59
+ }
60
+
61
+ function resolvePluginPath(pluginDir, relPath, label) {
62
+ const abs = resolve(pluginDir, relPath);
63
+ const realRoot = realpathSafe(pluginDir);
64
+ const realTarget = realpathSafe(abs);
65
+ if (!realRoot || !realTarget) {
66
+ return { error: `${label} does not exist: ${relPath}` };
67
+ }
68
+ // Windows filesystems are case-insensitive: compare in lowercase
69
+ const rootCmp = process.platform === 'win32' ? realRoot.toLowerCase() : realRoot;
70
+ const targetCmp = process.platform === 'win32' ? realTarget.toLowerCase() : realTarget;
71
+ if (!isPathInside(rootCmp, targetCmp) && rootCmp !== targetCmp) {
72
+ return { error: `${label} escapes plugin directory: ${relPath}` };
73
+ }
74
+ return { abs, real: realTarget };
75
+ }
76
+
77
+ /**
78
+ * Parse and validate plugin.json in a directory. Never executes plugin code.
79
+ * Returns { manifest, error }.
80
+ */
81
+ export function parsePluginManifest(pluginDir) {
82
+ const manifestPath = join(pluginDir, 'plugin.json');
83
+ let raw;
84
+ try {
85
+ raw = readFileSync(manifestPath, 'utf-8');
86
+ } catch {
87
+ return { error: 'missing plugin.json' };
88
+ }
89
+ let json;
90
+ try {
91
+ json = JSON.parse(raw);
92
+ } catch (err) {
93
+ return { error: `plugin.json is not valid JSON: ${err.message}` };
94
+ }
95
+ if (!json || typeof json !== 'object' || Array.isArray(json)) {
96
+ return { error: 'plugin.json must be an object' };
97
+ }
98
+ if (!validatePluginName(json.name)) {
99
+ return { error: `invalid plugin name: ${JSON.stringify(json.name)} (must match ${PLUGIN_NAME_RE})` };
100
+ }
101
+ const manifest = {
102
+ name: json.name,
103
+ version: typeof json.version === 'string' ? json.version : null,
104
+ description: typeof json.description === 'string' ? json.description : null,
105
+ raw: raw,
106
+ };
107
+
108
+ // main: optional; probed from candidate names by default. When declared explicitly it must live inside the plugin directory
109
+ if (json.main !== undefined) {
110
+ const r = resolveInsidePluginDir(pluginDir, json.main, 'main');
111
+ if (r.error) return { error: r.error };
112
+ manifest.mainRel = json.main;
113
+ manifest.mainPath = r.real;
114
+ } else {
115
+ manifest.mainRel = null;
116
+ manifest.mainPath = null;
117
+ for (const candidate of MAIN_CANDIDATES) {
118
+ const r = resolveInsidePluginDir(pluginDir, candidate, 'main');
119
+ if (!r.error) {
120
+ manifest.mainRel = candidate;
121
+ manifest.mainPath = r.real;
122
+ break;
123
+ }
124
+ }
125
+ }
126
+
127
+ // service: optional; entry is required and must exist inside the plugin directory.
128
+ // prefix is locked to /plugins/<name> for now (the mount table routes by name); opening
129
+ // custom prefixes later requires a prefix index so a half-state cannot ship.
130
+ if (json.service !== undefined) {
131
+ if (!json.service || typeof json.service !== 'object') {
132
+ return { error: 'service must be an object' };
133
+ }
134
+ const prefix = json.service.prefix !== undefined ? json.service.prefix : defaultServicePrefix(manifest.name);
135
+ if (!validateServicePrefix(prefix)) {
136
+ return { error: `invalid service.prefix: ${JSON.stringify(prefix)} (must match ${SERVICE_PREFIX_RE})` };
137
+ }
138
+ if (prefix !== defaultServicePrefix(manifest.name)) {
139
+ return { error: `service.prefix must be ${defaultServicePrefix(manifest.name)} in v1 (custom prefixes are not routable yet)` };
140
+ }
141
+ const r = resolveInsidePluginDir(pluginDir, json.service.entry, 'service.entry');
142
+ if (r.error) return { error: r.error };
143
+ const env = {};
144
+ if (json.service.env !== undefined) {
145
+ if (!json.service.env || typeof json.service.env !== 'object' || Array.isArray(json.service.env)) {
146
+ return { error: 'service.env must be an object of string values' };
147
+ }
148
+ for (const [k, v] of Object.entries(json.service.env)) {
149
+ if (k.startsWith('CCV_') || k.startsWith('CCVIEWER_')) {
150
+ return { error: `service.env must not override reserved variable: ${k}` };
151
+ }
152
+ if (typeof v !== 'string') {
153
+ return { error: `service.env.${k} must be a string` };
154
+ }
155
+ env[k] = v;
156
+ }
157
+ }
158
+ manifest.service = { entryRel: json.service.entry, entryPath: r.real, prefix, env };
159
+ } else {
160
+ manifest.service = null;
161
+ }
162
+
163
+ // panel: optional; entry must be a .js bundle in a subdirectory (dirname != plugin root, so main/service never become the static root)
164
+ if (json.panel !== undefined) {
165
+ if (!json.panel || typeof json.panel !== 'object') {
166
+ return { error: 'panel must be an object' };
167
+ }
168
+ const entry = json.panel.entry;
169
+ if (typeof entry !== 'string' || !entry.endsWith('.js')) {
170
+ return { error: 'panel.entry must be a .js bundle path (iframe-style .html panels are no longer supported)' };
171
+ }
172
+ if (dirname(entry) === '.') {
173
+ return { error: 'panel.entry must live inside a subdirectory (e.g. panel/index.js), not the plugin root' };
174
+ }
175
+ const r = resolveInsidePluginDir(pluginDir, entry, 'panel.entry');
176
+ if (r.error) return { error: r.error };
177
+ const panel = {
178
+ entryRel: entry,
179
+ entryPath: r.real,
180
+ // static root = the directory containing entry (after realpath)
181
+ rootDir: dirname(r.real),
182
+ cssRel: null,
183
+ cssPath: null,
184
+ title: null,
185
+ titleI18n: null,
186
+ icon: 'appstore',
187
+ width: 1100,
188
+ desktopOnly: false,
189
+ };
190
+ if (json.panel.css !== undefined) {
191
+ const css = json.panel.css;
192
+ if (typeof css !== 'string' || !css.endsWith('.css') || dirname(css) === '.') {
193
+ return { error: 'panel.css must be a .css path inside a subdirectory' };
194
+ }
195
+ const rc = resolveInsidePluginDir(pluginDir, css, 'panel.css');
196
+ if (rc.error) return { error: rc.error };
197
+ if (dirname(rc.real) !== panel.rootDir) {
198
+ return { error: 'panel.css must live in the same directory as panel.entry' };
199
+ }
200
+ panel.cssRel = css;
201
+ panel.cssPath = rc.real;
202
+ }
203
+ if (typeof json.panel.title === 'string') {
204
+ panel.title = json.panel.title;
205
+ } else if (json.panel.title && typeof json.panel.title === 'object') {
206
+ panel.titleI18n = {};
207
+ for (const [lang, text] of Object.entries(json.panel.title)) {
208
+ if (typeof text === 'string') panel.titleI18n[lang] = text;
209
+ }
210
+ }
211
+ if (json.panel.icon !== undefined) {
212
+ panel.icon = PANEL_ICON_WHITELIST.has(json.panel.icon) ? json.panel.icon : 'appstore';
213
+ }
214
+ if (json.panel.width !== undefined) {
215
+ const w = Number(json.panel.width);
216
+ if (Number.isFinite(w)) panel.width = Math.min(1600, Math.max(360, Math.round(w)));
217
+ }
218
+ panel.desktopOnly = json.panel.desktopOnly === true;
219
+ manifest.panel = panel;
220
+ } else {
221
+ manifest.panel = null;
222
+ }
223
+
224
+ // config.defaults: optional static defaults (does not go through the preferences write path)
225
+ manifest.configDefaults = {};
226
+ if (json.config !== undefined) {
227
+ if (!json.config || typeof json.config !== 'object' || Array.isArray(json.config)) {
228
+ return { error: 'config must be an object' };
229
+ }
230
+ if (json.config.defaults !== undefined) {
231
+ if (!json.config.defaults || typeof json.config.defaults !== 'object' || Array.isArray(json.config.defaults)) {
232
+ return { error: 'config.defaults must be an object' };
233
+ }
234
+ manifest.configDefaults = json.config.defaults;
235
+ }
236
+ }
237
+
238
+ return { manifest };
239
+ }
240
+
241
+ /**
242
+ * Collect static relative imports of a main module (one level deep), so helper
243
+ * edits also bust the fingerprint. Best-effort: unparseable imports are ignored.
244
+ */
245
+ export function collectMainDependencyPaths(mainPath, pluginDir) {
246
+ const deps = [];
247
+ let src;
248
+ try {
249
+ src = readFileSync(mainPath, 'utf-8');
250
+ } catch (err) {
251
+ reportSwallowed('plugin-manifest.read-main', err);
252
+ return deps;
253
+ }
254
+ const importRe = /import\s+(?:[^'"]*\s+from\s+)?['"](\.[^'"]+)['"]/g;
255
+ let m;
256
+ while ((m = importRe.exec(src)) !== null) {
257
+ // import specifiers commonly start with './'; the realpath containment check is the escape backstop
258
+ const r = resolvePluginPath(pluginDir, m[1], 'import');
259
+ if (!r.error) deps.push(r.real);
260
+ if (deps.length >= FINGERPRINT_MAX_FILES) break;
261
+ }
262
+ return deps.sort();
263
+ }
264
+
265
+ /**
266
+ * Fingerprint for the main-process module: manifest bytes + main bytes +
267
+ * one-level helper bytes. Falls back to manifest+main when deps are unreadable.
268
+ */
269
+ export function computeMainFingerprint(manifestRaw, mainPath, pluginDir) {
270
+ const hash = createHash('sha256');
271
+ hash.update(manifestRaw || '');
272
+ if (mainPath) {
273
+ try {
274
+ hash.update(readFileSync(mainPath));
275
+ } catch (err) {
276
+ reportSwallowed('plugin-manifest.fp-main', err);
277
+ }
278
+ let total = 0;
279
+ for (const dep of collectMainDependencyPaths(mainPath, pluginDir)) {
280
+ try {
281
+ const st = statSync(dep);
282
+ if (total + st.size > FINGERPRINT_MAX_BYTES) break;
283
+ total += st.size;
284
+ hash.update(dep);
285
+ hash.update(readFileSync(dep));
286
+ } catch (err) {
287
+ reportSwallowed('plugin-manifest.fp-dep', err);
288
+ }
289
+ }
290
+ }
291
+ return hash.digest('hex');
292
+ }
293
+
294
+ /**
295
+ * Fingerprint for the panel bundle (entry + css): used by the panel manifest API
296
+ * so the frontend reloads the bundle after it actually changed on disk.
297
+ */
298
+ export function computePanelFingerprint(panel) {
299
+ const hash = createHash('sha256');
300
+ for (const p of [panel.entryPath, panel.cssPath]) {
301
+ if (!p) continue;
302
+ try {
303
+ const st = statSync(p);
304
+ hash.update(p);
305
+ hash.update(String(st.mtimeMs));
306
+ hash.update(String(st.size));
307
+ } catch (err) {
308
+ reportSwallowed('plugin-manifest.fp-panel', err);
309
+ }
310
+ }
311
+ return hash.digest('hex').slice(0, 16);
312
+ }
313
+
314
+ /**
315
+ * List directory-plugin candidate dirs directly under a plugins dir.
316
+ * Returns sorted directory names that contain a plugin.json.
317
+ */
318
+ export function listPluginDirs(pluginsDir) {
319
+ let entries;
320
+ try {
321
+ entries = readdirSync(pluginsDir, { withFileTypes: true });
322
+ } catch {
323
+ return [];
324
+ }
325
+ return entries
326
+ .filter(e => e.isDirectory())
327
+ .map(e => e.name)
328
+ .sort();
329
+ }
@@ -0,0 +1,183 @@
1
+ // Plugin panel descriptors + static bundle serving for /plugin-panels/<name>/*.
2
+ //
3
+ // Static root is the directory containing panel.entry (a .js bundle) — NEVER the plugin
4
+ // root, so main.mjs/service.mjs/helpers can never be served. The manifest's resolved
5
+ // main/service paths are additionally rejected by realpath even if an author drops a
6
+ // copy inside panel/. Extension whitelist excludes .html/.json/.map/.mjs.
7
+ import { createReadStream, statSync, realpathSync } from 'node:fs';
8
+ import { join, extname, relative, isAbsolute } from 'node:path';
9
+ import { reportSwallowed } from './error-report.js';
10
+
11
+ const EXT_MIME = {
12
+ '.js': 'text/javascript; charset=utf-8',
13
+ '.css': 'text/css; charset=utf-8',
14
+ '.png': 'image/png',
15
+ '.jpg': 'image/jpeg',
16
+ '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif',
18
+ '.webp': 'image/webp',
19
+ '.svg': 'image/svg+xml',
20
+ '.ico': 'image/x-icon',
21
+ '.woff': 'font/woff',
22
+ '.woff2': 'font/woff2',
23
+ '.ttf': 'font/ttf',
24
+ };
25
+
26
+ function realpathSafe(p) {
27
+ try { return realpathSync(p); } catch { return null; }
28
+ }
29
+
30
+ function isInside(parent, child) {
31
+ const rel = relative(parent, child);
32
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
33
+ }
34
+
35
+ /**
36
+ * Build one panel descriptor for GET /api/plugin-panels.
37
+ * entry: loader directory-plugin entry (panel info + fingerprint).
38
+ * serviceStatus: supplied by the runtime (may be null).
39
+ */
40
+ export function buildPanelDescriptor(entry, { serviceStatus = null } = {}) {
41
+ const p = entry.panel;
42
+ let entryStats = null;
43
+ try { entryStats = statSync(p.entryPath); } catch { /* bundle missing */ }
44
+ const panelStatus = entryStats ? 'ready' : 'missing';
45
+ return {
46
+ name: entry.name,
47
+ title: p.title || entry.name,
48
+ titleI18n: p.titleI18n || null,
49
+ version: entry.manifest?.version ?? null,
50
+ icon: p.icon,
51
+ width: p.width,
52
+ desktopOnly: p.desktopOnly,
53
+ // Fingerprint goes into the URL so the frontend reloads the bundle after it changes (otherwise hot reload is an illusion)
54
+ entryUrl: entryStats ? `/plugin-panels/${encodeURIComponent(entry.name)}/${relative(p.rootDir, p.entryPath).split('\\').join('/')}?v=${p.fingerprint}` : null,
55
+ cssUrl: p.cssPath && exists(p.cssPath)
56
+ ? `/plugin-panels/${encodeURIComponent(entry.name)}/${relative(p.rootDir, p.cssPath).split('\\').join('/')}?v=${p.fingerprint}`
57
+ : null,
58
+ assetBaseUrl: `/plugin-panels/${encodeURIComponent(entry.name)}/`,
59
+ prefix: entry.service ? entry.service.prefix : null,
60
+ panelStatus,
61
+ // Only service plugins have a serviceStatus; panel-only plugins are null so the UI
62
+ // does not report a service that never existed. When the runtime has not started
63
+ // (or has no record), report null = unknown instead of fabricating 'ready'.
64
+ serviceStatus: entry.service ? (serviceStatus ? serviceStatus.state : null) : null,
65
+ error: serviceStatus?.lastError ?? null,
66
+ };
67
+ }
68
+
69
+ function exists(p) {
70
+ try { statSync(p); return true; } catch { return false; }
71
+ }
72
+
73
+ /**
74
+ * Resolve /plugin-panels/<name>/<rel> to a file on disk.
75
+ * @returns {{ filePath, stat } | { error, status } }
76
+ */
77
+ export function resolvePanelFile(entry, relPath) {
78
+ if (!relPath || relPath.includes('\0') || relPath.includes('\\')) {
79
+ return { error: 'illegal path', status: 400 };
80
+ }
81
+ let decoded;
82
+ try {
83
+ decoded = decodeURIComponent(relPath);
84
+ } catch {
85
+ return { error: 'bad encoding', status: 400 };
86
+ }
87
+ const segments = decoded.split('/');
88
+ if (segments.some(s => s === '..' || s === '.' || s === '')) {
89
+ return { error: 'illegal segments', status: 400 };
90
+ }
91
+ const lastSegment = segments[segments.length - 1];
92
+ if (lastSegment.startsWith('.')) {
93
+ return { error: 'dotfiles rejected', status: 404 };
94
+ }
95
+ const ext = extname(lastSegment).toLowerCase();
96
+ if (!EXT_MIME[ext]) {
97
+ return { error: 'extension not allowed', status: 404 };
98
+ }
99
+ const rootDir = entry.panel.rootDir;
100
+ const target = join(rootDir, ...segments);
101
+ const realRoot = realpathSafe(rootDir);
102
+ const realTarget = realpathSafe(target);
103
+ if (!realRoot || !realTarget) {
104
+ return { error: 'not found', status: 404 };
105
+ }
106
+ const rootCmp = process.platform === 'win32' ? realRoot.toLowerCase() : realRoot;
107
+ const targetCmp = process.platform === 'win32' ? realTarget.toLowerCase() : realTarget;
108
+ if (!isInside(rootCmp, targetCmp)) {
109
+ return { error: 'escapes panel root', status: 403 };
110
+ }
111
+ // Explicitly reject the manifest's resolved main/service paths even if copied into panel/
112
+ const forbidden = [entry.manifest?.mainPath, entry.service?.entryPath].filter(Boolean);
113
+ for (const f of forbidden) {
114
+ const fCmp = process.platform === 'win32' ? f.toLowerCase() : f;
115
+ if (fCmp === targetCmp) {
116
+ return { error: 'source files are not served', status: 404 };
117
+ }
118
+ }
119
+ let stat;
120
+ try {
121
+ stat = statSync(realTarget);
122
+ } catch {
123
+ return { error: 'not found', status: 404 };
124
+ }
125
+ if (!stat.isFile()) {
126
+ return { error: 'not found', status: 404 };
127
+ }
128
+ return { filePath: realTarget, stat };
129
+ }
130
+
131
+ /**
132
+ * Serve a panel static file. Never throws (dispatch has no try/catch).
133
+ * @returns true (handled, including 404)
134
+ */
135
+ export function servePanelFile(req, res, entry, relPath) {
136
+ try {
137
+ const resolved = resolvePanelFile(entry, relPath);
138
+ if (resolved.error) {
139
+ res.writeHead(resolved.status, { 'Content-Type': 'text/plain; charset=utf-8' });
140
+ res.end(resolved.error);
141
+ return true;
142
+ }
143
+ const { filePath, stat } = resolved;
144
+ const ext = extname(filePath).toLowerCase();
145
+ const etag = `W/"${stat.mtimeMs}-${stat.size}"`;
146
+ const headers = {
147
+ 'Content-Type': EXT_MIME[ext],
148
+ 'Content-Length': stat.size,
149
+ 'Cache-Control': 'no-cache',
150
+ 'X-Content-Type-Options': 'nosniff',
151
+ 'Referrer-Policy': 'no-referrer',
152
+ ETag: etag,
153
+ };
154
+ if (req.headers['if-none-match'] === etag) {
155
+ res.writeHead(304, { ETag: etag, 'Cache-Control': 'no-cache' });
156
+ res.end();
157
+ return true;
158
+ }
159
+ res.writeHead(200, headers);
160
+ if (req.method === 'HEAD') {
161
+ res.end();
162
+ return true;
163
+ }
164
+ const stream = createReadStream(filePath);
165
+ stream.on('error', (err) => {
166
+ reportSwallowed('plugin-panels.stream', err);
167
+ res.destroy();
168
+ });
169
+ stream.pipe(res);
170
+ return true;
171
+ } catch (err) {
172
+ reportSwallowed('plugin-panels.serve', err);
173
+ try {
174
+ if (!res.headersSent) {
175
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
176
+ res.end('panel serve failure');
177
+ } else {
178
+ res.destroy();
179
+ }
180
+ } catch { /* noop */ }
181
+ return true;
182
+ }
183
+ }