@starpak/cli 0.0.0-stage → 1.0.5

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 (44) hide show
  1. package/LICENSE +22 -0
  2. package/bin/spak.js +6 -0
  3. package/lib/cli/firewall.d.ts +20 -0
  4. package/lib/cli/firewall.d.ts.map +1 -0
  5. package/lib/cli/firewall.js +207 -0
  6. package/lib/cli/firewall.js.map +1 -0
  7. package/lib/cli/index.d.ts +2 -0
  8. package/lib/cli/index.d.ts.map +1 -0
  9. package/lib/cli/index.js +76 -0
  10. package/lib/cli/index.js.map +1 -0
  11. package/lib/cli/registry.d.ts +10 -0
  12. package/lib/cli/registry.d.ts.map +1 -0
  13. package/lib/cli/registry.js +207 -0
  14. package/lib/cli/registry.js.map +1 -0
  15. package/lib/cli/start.d.ts +4 -0
  16. package/lib/cli/start.d.ts.map +1 -0
  17. package/lib/cli/start.js +301 -0
  18. package/lib/cli/start.js.map +1 -0
  19. package/lib/cli/types.d.ts +2 -0
  20. package/lib/cli/types.d.ts.map +1 -0
  21. package/lib/cli/types.js +3 -0
  22. package/lib/cli/types.js.map +1 -0
  23. package/lib/commands/check.d.ts +4 -0
  24. package/lib/commands/check.d.ts.map +1 -0
  25. package/lib/commands/check.js +84 -0
  26. package/lib/commands/check.js.map +1 -0
  27. package/lib/commands/config.d.ts +4 -0
  28. package/lib/commands/config.d.ts.map +1 -0
  29. package/lib/commands/config.js +79 -0
  30. package/lib/commands/config.js.map +1 -0
  31. package/lib/commands/cpc.d.ts +41 -0
  32. package/lib/commands/cpc.d.ts.map +1 -0
  33. package/lib/commands/cpc.js +801 -0
  34. package/lib/commands/cpc.js.map +1 -0
  35. package/lib/index.d.ts +32 -0
  36. package/lib/index.d.ts.map +1 -0
  37. package/lib/index.js +95 -0
  38. package/lib/index.js.map +1 -0
  39. package/lib/version.d.ts +2 -0
  40. package/lib/version.d.ts.map +1 -0
  41. package/lib/version.js +16 -0
  42. package/lib/version.js.map +1 -0
  43. package/package.json +47 -3
  44. package/README.md +0 -3
@@ -0,0 +1,801 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.circuitBreakers = void 0;
7
+ exports.runModuleWhitelistCheck = runModuleWhitelistCheck;
8
+ exports.runSandboxWorker = runSandboxWorker;
9
+ exports.isolatePlugin = isolatePlugin;
10
+ exports.terminateSandbox = terminateSandbox;
11
+ exports.isAvailable = isAvailable;
12
+ exports.ensureAvailable = ensureAvailable;
13
+ exports.initCPC = initCPC;
14
+ exports.triggerCircuitBreaker = triggerCircuitBreaker;
15
+ exports.restoreCircuitBreaker = restoreCircuitBreaker;
16
+ const child_process_1 = require("child_process");
17
+ const fs_1 = require("fs");
18
+ const path_1 = require("path");
19
+ const config_1 = require("@starpak/config");
20
+ const kleur_1 = __importDefault(require("kleur"));
21
+ const firewall_1 = require("../cli/firewall");
22
+ // ====== Plugin Check ======
23
+ const BUILTIN_PACKAGES_DIR = (0, path_1.resolve)(process.cwd(), 'packages');
24
+ const PLUGINS_DIR = (0, path_1.resolve)(process.cwd(), 'plugins');
25
+ // ============================================================
26
+ // 🛡️ MODULE WHITELIST — Authorized modules ONLY
27
+ // Any package/plugin discovered at runtime MUST be present in
28
+ // this list; otherwise CPC triggers a hard process exit so the
29
+ // main framework refuses to start with untrusted code.
30
+ // ============================================================
31
+ const MODULE_WHITELIST = new Set([
32
+ // --- Core @starpak packages (monorepo internal) ---
33
+ '@starpak/util',
34
+ '@starpak/log',
35
+ '@starpak/config',
36
+ '@starpak/core',
37
+ '@starpak/loader',
38
+ '@starpak/cli',
39
+ '@starpak/node-b',
40
+ '@starpak/lang',
41
+ '@starpak/mods',
42
+ // --- Spak Package Manager ---
43
+ '@starpak/spm',
44
+ 'spm',
45
+ 'util',
46
+ 'log',
47
+ 'config',
48
+ 'core',
49
+ 'loader',
50
+ 'cli',
51
+ 'node-b',
52
+ 'lang',
53
+ 'mods',
54
+ ]);
55
+ /**
56
+ * Run the whitelist validation step.
57
+ *
58
+ * Discovers every builtin package under /packages and every plugin
59
+ * under /plugins, then verifies each name appears in the hard-coded
60
+ * MODULE_WHITELIST above.
61
+ *
62
+ * If ANY unauthorized module is found:
63
+ * 1. log each offending name (未授权模块提示)
64
+ * 2. log a final violation summary
65
+ * 3. call process.exit(1) immediately so the framework cannot boot
66
+ *
67
+ * Returns silently when everything is authorized.
68
+ */
69
+ function runModuleWhitelistCheck(failHard = true) {
70
+ console.log(kleur_1.default.cyan('\n 正在执行模块白名单检查...\n'));
71
+ const discovered = [];
72
+ // Collect builtin packages (scanned by /packages directory).
73
+ if ((0, fs_1.existsSync)(BUILTIN_PACKAGES_DIR)) {
74
+ const pkgs = (0, fs_1.readdirSync)(BUILTIN_PACKAGES_DIR).filter(d => (0, fs_1.statSync)((0, path_1.resolve)(BUILTIN_PACKAGES_DIR, d)).isDirectory());
75
+ for (const p of pkgs) {
76
+ discovered.push(p);
77
+ // Also add the @starpak/ scoped form so we accept directory name AND
78
+ // the full published package name convention in the whitelist.
79
+ discovered.push(`@starpak/${p}`);
80
+ }
81
+ }
82
+ // Collect plugins (scanned by /plugins directory).
83
+ if ((0, fs_1.existsSync)(PLUGINS_DIR)) {
84
+ const plugs = (0, fs_1.readdirSync)(PLUGINS_DIR).filter(d => (0, fs_1.statSync)((0, path_1.resolve)(PLUGINS_DIR, d)).isDirectory());
85
+ for (const p of plugs) {
86
+ discovered.push(p);
87
+ discovered.push(`@starpak/${p}`);
88
+ }
89
+ }
90
+ // Also honour any user-added extra modules declared in config via
91
+ // cpc.whitelist array, so users can self-approve without editing
92
+ // this source file. Fail silently if config loading errors.
93
+ let extraWhitelist = [];
94
+ try {
95
+ const cfg = (0, config_1.loadConfig)();
96
+ if (Array.isArray(cfg?.cpc?.whitelist))
97
+ extraWhitelist = cfg.cpc.whitelist;
98
+ }
99
+ catch { /* ignore */ }
100
+ const effectiveWhitelist = new Set([...MODULE_WHITELIST, ...extraWhitelist]);
101
+ const uniqueModules = [...new Set(discovered)];
102
+ const unauthorized = uniqueModules.filter(name => !effectiveWhitelist.has(name));
103
+ if (unauthorized.length > 0) {
104
+ for (const name of unauthorized) {
105
+ console.log(` ${kleur_1.default.red('✗')} 未知模块不在白名单中: ${name}`);
106
+ }
107
+ console.log(kleur_1.default.red(`\n CPC 白名单校验失败: 发现 ${unauthorized.length} 个未经授权的模块,为安全起见进程将立即退出。\n`));
108
+ if (failHard) {
109
+ // Deliberately crash the main framework process. Using exit(1)
110
+ // guarantees any caller (CLI serve, programmatic createApp, tests)
111
+ // observes a non-zero status and cannot continue.
112
+ process.exit(1);
113
+ }
114
+ }
115
+ else {
116
+ console.log(` ${kleur_1.default.green('✓')} 模块白名单校验通过:${uniqueModules.length}/${uniqueModules.length} 个模块均为授权模块\n`);
117
+ }
118
+ return { unauthorized, total: uniqueModules.length };
119
+ }
120
+ function checkBuiltinPackage(name) {
121
+ const pkgDir = (0, path_1.resolve)(BUILTIN_PACKAGES_DIR, name);
122
+ const requiredDirs = ['src'];
123
+ const requiredFiles = ['package.json'];
124
+ if (!(0, fs_1.existsSync)(pkgDir)) {
125
+ return { name, valid: false, reason: '包目录未找到' };
126
+ }
127
+ for (const dir of requiredDirs) {
128
+ if (!(0, fs_1.existsSync)((0, path_1.resolve)(pkgDir, dir))) {
129
+ return { name, valid: false, reason: `缺失目录: ${dir}` };
130
+ }
131
+ }
132
+ for (const file of requiredFiles) {
133
+ if (!(0, fs_1.existsSync)((0, path_1.resolve)(pkgDir, file))) {
134
+ return { name, valid: false, reason: `缺失文件: ${file}` };
135
+ }
136
+ }
137
+ return { name, valid: true };
138
+ }
139
+ function checkPlugin(name) {
140
+ const pluginDir = (0, path_1.resolve)(PLUGINS_DIR, name);
141
+ if (!(0, fs_1.existsSync)(pluginDir)) {
142
+ return { name, valid: false, reason: '插件目录未找到' };
143
+ }
144
+ const pkgPath = (0, path_1.resolve)(pluginDir, 'package.json');
145
+ if (!(0, fs_1.existsSync)(pkgPath)) {
146
+ return { name, valid: false, reason: '未找到 package.json' };
147
+ }
148
+ let pkg;
149
+ try {
150
+ pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf-8'));
151
+ }
152
+ catch {
153
+ return { name, valid: false, reason: '无效的 package.json' };
154
+ }
155
+ // Check main entry
156
+ if (pkg.main && !(0, fs_1.existsSync)((0, path_1.resolve)(pluginDir, pkg.main))) {
157
+ return { name, valid: false, reason: `主入口未找到: ${pkg.main}` };
158
+ }
159
+ // Check dependencies (warn if missing)
160
+ if (pkg.dependencies) {
161
+ const missingDeps = [];
162
+ for (const dep of Object.keys(pkg.dependencies)) {
163
+ const depPath = (0, path_1.resolve)(process.cwd(), 'node_modules', dep);
164
+ if (!(0, fs_1.existsSync)(depPath) && !dep.startsWith('@starpak/')) {
165
+ missingDeps.push(dep);
166
+ }
167
+ }
168
+ if (missingDeps.length > 0) {
169
+ return { name, valid: false, reason: `缺失依赖: ${missingDeps.join(', ')}` };
170
+ }
171
+ }
172
+ return { name, valid: true };
173
+ }
174
+ function runPluginCheck() {
175
+ // Step 0 — Module whitelist validation. If this fails, process.exit(1)
176
+ // is triggered inside runModuleWhitelistCheck itself; we never reach the
177
+ // rest of runPluginCheck.
178
+ runModuleWhitelistCheck(true);
179
+ console.log(kleur_1.default.cyan('\n 正在检查插件和包...\n'));
180
+ // Check built-in packages
181
+ const builtinPackages = (0, fs_1.readdirSync)(BUILTIN_PACKAGES_DIR).filter(d => {
182
+ return (0, fs_1.statSync)((0, path_1.resolve)(BUILTIN_PACKAGES_DIR, d)).isDirectory();
183
+ });
184
+ let validCount = 0;
185
+ let totalCount = 0;
186
+ for (const pkg of builtinPackages) {
187
+ totalCount++;
188
+ const result = checkBuiltinPackage(pkg);
189
+ if (result.valid) {
190
+ validCount++;
191
+ console.log(` ${kleur_1.default.green('✓')} ${kleur_1.default.bold(pkg)}: ${result.reason || '正常'}`);
192
+ }
193
+ else {
194
+ console.log(` ${kleur_1.default.red('✗')} ${kleur_1.default.bold(pkg)}: ${result.reason}`);
195
+ }
196
+ }
197
+ // Check external plugins
198
+ if ((0, fs_1.existsSync)(PLUGINS_DIR)) {
199
+ const plugins = (0, fs_1.readdirSync)(PLUGINS_DIR).filter(d => {
200
+ return (0, fs_1.statSync)((0, path_1.resolve)(PLUGINS_DIR, d)).isDirectory();
201
+ });
202
+ for (const plugin of plugins) {
203
+ totalCount++;
204
+ const result = checkPlugin(plugin);
205
+ if (result.valid) {
206
+ validCount++;
207
+ console.log(` ${kleur_1.default.green('✓')} ${kleur_1.default.bold(plugin)}: 正常`);
208
+ }
209
+ else {
210
+ console.log(` ${kleur_1.default.red('✗')} ${kleur_1.default.bold(plugin)}: ${result.reason}`);
211
+ }
212
+ }
213
+ }
214
+ console.log(`\n ${validCount}/${totalCount} 项检查通过\n`);
215
+ }
216
+ // ====== Sandbox ======
217
+ const sandboxProcesses = new Map();
218
+ /**
219
+ * Sandbox worker 主进程内运行入口(SEA 二进制标记分支)。
220
+ *
221
+ * 封闭二进制里 spawn 子进程用「自 re-exec」:
222
+ * spawn(process.execPath, ['--spak-sandbox', name])
223
+ * 主 bundle 启动时检测到该标记,直接执行本函数(内嵌代码);
224
+ * 防火墙来自同包模块引用,无需 cwd 的 node_modules ——
225
+ * 与非 SEA 场景的 `node -e` 字符串脚本行为等价。
226
+ */
227
+ function runSandboxWorker(name) {
228
+ const pluginName = name || '';
229
+ const path = require('path');
230
+ const fs = require('fs');
231
+ const { createRequire } = require('module');
232
+ // 1) 安装防火墙规则(env 继承则直接应用;否则默认 fail-closed)
233
+ try {
234
+ if (typeof firewall_1.applyFirewallFromEnv === 'function')
235
+ (0, firewall_1.applyFirewallFromEnv)();
236
+ if (typeof firewall_1.installFirewall === 'function' && !process.env.SPAK_FIREWALL_RULES)
237
+ (0, firewall_1.installFirewall)();
238
+ }
239
+ catch (e) {
240
+ if (process.env.SPAK_DEBUG)
241
+ console.debug('[sandbox] firewall:', String(e?.message || e));
242
+ }
243
+ // 2) 真实入口 loading(smoke):plugins/<name> 存在则装载
244
+ try {
245
+ const req = createRequire(path.resolve(process.cwd(), 'package.json'));
246
+ let entry = null;
247
+ const pkgPath = path.resolve(process.cwd(), 'plugins', pluginName, 'package.json');
248
+ if (fs.existsSync(pkgPath)) {
249
+ try {
250
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
251
+ entry = pkg.main ? path.resolve(process.cwd(), 'plugins', pluginName, pkg.main) : null;
252
+ }
253
+ catch { }
254
+ }
255
+ if (!entry) {
256
+ const mPath = path.resolve(process.cwd(), 'plugins', pluginName, 'spak.manifest.json');
257
+ if (fs.existsSync(mPath)) {
258
+ try {
259
+ const m = JSON.parse(fs.readFileSync(mPath, 'utf8'));
260
+ entry = m.master ? path.resolve(process.cwd(), 'plugins', pluginName, m.master) : null;
261
+ }
262
+ catch { }
263
+ }
264
+ }
265
+ if (entry && fs.existsSync(entry)) {
266
+ req(entry);
267
+ console.log('[sandbox] entry-loaded: ' + entry);
268
+ }
269
+ else {
270
+ try {
271
+ req.resolve(pluginName);
272
+ console.log('[sandbox] plugin-resolved: ' + pluginName);
273
+ }
274
+ catch { }
275
+ }
276
+ }
277
+ catch (err) {
278
+ if (process.env.SPAK_DEBUG)
279
+ console.debug('[sandbox] entry load guard:', err?.message ?? String(err));
280
+ }
281
+ // 3) RSS 自守卫:超预算上报父进程触发熔断
282
+ const limitMB = Number(process.env.SPAK_SANDBOX_MEM_MB) || 512;
283
+ const guard = setInterval(() => {
284
+ const rssMB = process.memoryUsage().rss / 1024 / 1024;
285
+ if (rssMB > limitMB && process.send) {
286
+ process.send({ type: 'overbudget', plugin: pluginName, rss: Math.round(rssMB) });
287
+ }
288
+ }, 5000);
289
+ if (guard.unref)
290
+ guard.unref();
291
+ console.log('[sandbox] plugin "' + pluginName + '" started');
292
+ if (process.send) {
293
+ process.send({ type: 'ready', plugin: pluginName, pid: process.pid });
294
+ process.on('message', (msg) => {
295
+ if (msg && msg.type === 'shutdown')
296
+ process.exit(0);
297
+ });
298
+ }
299
+ process.stdin.resume();
300
+ }
301
+ function isolatePlugin(name) {
302
+ if (process.env.SPAK_NO_SANDBOX === '1') {
303
+ console.log(kleur_1.default.yellow(' ⚠ 沙箱已禁用 — 插件将在无隔离环境下运行 (不安全!)'));
304
+ return;
305
+ }
306
+ console.log(kleur_1.default.cyan(` 正在隔离插件: ${name}...`));
307
+ // Respect circuit breakers: if the plugin is in the open state, refuse to
308
+ // isolate it — same behaviour as the runtime loader should use.
309
+ if (circuitBreakers.get(name)) {
310
+ console.log(kleur_1.default.red(`${name} 熔断器已断开 — 隔离已中止`));
311
+ return;
312
+ }
313
+ // Sandbox memory cap: worker gets its own V8 heap limit so RSS can't
314
+ // grow unbounded — and reports back to trip the circuit breaker.
315
+ const memLimitMB = resolveMemoryLimitMB();
316
+ // Pass the plugin name via argv[3] so we never concatenate a user-controlled
317
+ // string into JS source code (prevents command injection via crafted names
318
+ // that escape the JSON string context).
319
+ const workerScript = `
320
+ // argv mapping for: node -e SCRIPT -- <name> → argv[1]=<name> ('--' consumed)
321
+ const pluginName = process.argv[1] || '';
322
+ const { createRequire } = require('module');
323
+ const path = require('path');
324
+ const fs = require('fs');
325
+
326
+ // 1) Install the same firewall rules as the parent (net-layer blocking).
327
+ // If no rules are inherited, the sandbox still defaults to fail-closed.
328
+ try {
329
+ const req0 = createRequire(path.resolve(process.cwd(), 'package.json'));
330
+ const fw = req0('@starpak/cli/cli/firewall');
331
+ if (fw.applyFirewallFromEnv) fw.applyFirewallFromEnv();
332
+ if (fw.installFirewall && !process.env.SPAK_FIREWALL_RULES) fw.installFirewall();
333
+ } catch (e) { if (process.env.SPAK_DEBUG) console.debug('[sandbox] firewall:', String((e)?.message || e)); }
334
+
335
+ // 2) Real plugin entry loading (smoke): resolve main from package.json,
336
+ // fall back to spak.manifest.json master, then require it.
337
+ try {
338
+ const req = createRequire(path.resolve(process.cwd(), 'package.json'));
339
+ let entry = null;
340
+ const pkgPath = path.resolve(process.cwd(), 'plugins', pluginName, 'package.json');
341
+ if (fs.existsSync(pkgPath)) {
342
+ try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); entry = pkg.main ? path.resolve(process.cwd(), 'plugins', pluginName, pkg.main) : null; } catch {}
343
+ }
344
+ if (!entry) {
345
+ const mPath = path.resolve(process.cwd(), 'plugins', pluginName, 'spak.manifest.json');
346
+ if (fs.existsSync(mPath)) {
347
+ try { const m = JSON.parse(fs.readFileSync(mPath, 'utf8')); entry = m.master ? path.resolve(process.cwd(), 'plugins', pluginName, m.master) : null; } catch {}
348
+ }
349
+ }
350
+ if (entry && fs.existsSync(entry)) { req(entry); console.log('[sandbox] entry-loaded: ' + entry); }
351
+ else { try { req.resolve(pluginName); console.log('[sandbox] plugin-resolved: ' + pluginName); } catch {} }
352
+ } catch (err) {
353
+ if (process.env.SPAK_DEBUG) console.debug('[sandbox] entry load guard:', (err as any)?.message ?? String(err))
354
+ }
355
+
356
+ // 3) Self RSS guard: report over-budget to parent so the circuit breaker
357
+ // opens and the parent can terminate us.
358
+ const limitMB = Number(process.env.SPAK_SANDBOX_MEM_MB) || ${memLimitMB};
359
+ const guard = setInterval(() => {
360
+ const rssMB = process.memoryUsage().rss / 1024 / 1024;
361
+ if (rssMB > limitMB && process.send) {
362
+ process.send({ type: 'overbudget', plugin: pluginName, rss: Math.round(rssMB) });
363
+ }
364
+ }, 5000);
365
+ if (guard.unref) guard.unref();
366
+
367
+ console.log('[sandbox] plugin "' + pluginName + '" started');
368
+ if (process.send) {
369
+ process.send({ type: 'ready', plugin: pluginName, pid: process.pid });
370
+ process.on('message', (msg) => {
371
+ if (msg && (msg as any).type === 'shutdown') process.exit(0);
372
+ });
373
+ }
374
+ process.stdin.resume();
375
+ `;
376
+ const isSea = (() => {
377
+ try {
378
+ const sea = require('node:sea');
379
+ return typeof sea.isSea === 'function' && sea.isSea();
380
+ }
381
+ catch {
382
+ return false;
383
+ }
384
+ })();
385
+ // SEA 二进制:自 re-exec 走内嵌 runSandboxWorker 分支;
386
+ // 普通 node 环境:保持原 `node -e` 字符串脚本行为。
387
+ const child = isSea
388
+ ? (0, child_process_1.spawn)(process.execPath, ['--spak-sandbox', name], {
389
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
390
+ detached: true,
391
+ env: {
392
+ ...process.env,
393
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${memLimitMB}`.trim(),
394
+ SPAK_FIREWALL_RULES: JSON.stringify((0, firewall_1.getFirewallRules)()),
395
+ SPAK_SANDBOX_MEM_MB: String(memLimitMB),
396
+ },
397
+ })
398
+ : (0, child_process_1.spawn)(process.execPath, ['-e', workerScript, '--', name], {
399
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
400
+ detached: true,
401
+ env: {
402
+ ...process.env,
403
+ // V8 heap cap via NODE_OPTIONS (spawn has no execArgv; NODE_OPTIONS is
404
+ // honoured by node children and limits sandbox RSS growth).
405
+ NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=${memLimitMB}`.trim(),
406
+ SPAK_FIREWALL_RULES: JSON.stringify((0, firewall_1.getFirewallRules)()),
407
+ SPAK_SANDBOX_MEM_MB: String(memLimitMB),
408
+ },
409
+ });
410
+ // Prevent accidental memory leaks from pending IPC buffers — we don't
411
+ // forward raw stdio to the user's terminal (they are ignored above) but
412
+ // we still want lifecycle notifications.
413
+ child.on('error', (err) => {
414
+ console.warn(kleur_1.default.yellow(`[沙箱] ${name} 错误: ${String(err.message)}`));
415
+ });
416
+ // Wire the sandbox into the circuit breaker: over-budget workers trip
417
+ // their breaker and get terminated.
418
+ child.on('message', (msg) => {
419
+ const m = msg;
420
+ if (m && m.type === 'overbudget') {
421
+ console.warn(kleur_1.default.red(`[沙箱] ${name} 内存超限 (${String(m.rss)}MB),触发熔断并终止!`));
422
+ if (!circuitBreakers.get(name))
423
+ triggerCircuitBreaker(name);
424
+ terminateSandbox(name);
425
+ }
426
+ });
427
+ child.on('exit', (code) => {
428
+ sandboxProcesses.delete(name);
429
+ });
430
+ sandboxProcesses.set(name, child);
431
+ console.log(kleur_1.default.green(` 插件 ${name} 已隔离 (pid=${child.pid ?? -1})\n`)
432
+ + kleur_1.default.dim(` 沙箱内存上限: ${memLimitMB}MB`));
433
+ }
434
+ function terminateSandbox(name) {
435
+ const proc = sandboxProcesses.get(name);
436
+ if (proc && proc.connected) {
437
+ try {
438
+ proc.send({ type: 'shutdown' });
439
+ }
440
+ catch { /* IPC already closed */ }
441
+ // Fallback kill if graceful shutdown didn't happen within 500ms
442
+ const killTimer = setTimeout(() => {
443
+ if (proc.pid)
444
+ try {
445
+ process.kill(proc.pid, 'SIGKILL');
446
+ }
447
+ catch { }
448
+ }, 500);
449
+ if (killTimer.unref)
450
+ killTimer.unref();
451
+ }
452
+ else if (proc && proc.pid) {
453
+ try {
454
+ process.kill(proc.pid);
455
+ }
456
+ catch { }
457
+ }
458
+ if (proc) {
459
+ const pid = proc.pid;
460
+ sandboxProcesses.delete(name);
461
+ console.log(kleur_1.default.yellow(` 沙箱已终止 (pid=${pid ?? -1})`));
462
+ }
463
+ }
464
+ // ====== SSetPS - Safety Set Protection System ======
465
+ let ssetpsInterval = null;
466
+ const DEFAULT_MEMORY_LIMIT_MB = 512;
467
+ let currentMemoryLimitMB = DEFAULT_MEMORY_LIMIT_MB;
468
+ const circuitBreakers = new Map();
469
+ exports.circuitBreakers = circuitBreakers;
470
+ let gcHintShown = false;
471
+ /**
472
+ * Resolve the effective SSetPS memory limit (MB). Precedence:
473
+ * 1. SPAK_SSETPS_MEMORY_LIMIT_MB env var (highest, deploy-time override)
474
+ * 2. config.cpc.ssetps.memoryLimitMB (from user config)
475
+ * 3. DEFAULT_MEMORY_LIMIT_MB = 512 (fallback, historic hard-coded value)
476
+ */
477
+ function resolveMemoryLimitMB(userLimit) {
478
+ const envVal = Number(process.env.SPAK_SSETPS_MEMORY_LIMIT_MB);
479
+ if (Number.isFinite(envVal) && envVal > 0)
480
+ return Math.floor(envVal);
481
+ if (Number.isFinite(userLimit) && userLimit > 0)
482
+ return Math.floor(userLimit);
483
+ return DEFAULT_MEMORY_LIMIT_MB;
484
+ }
485
+ function startSSetPS(overrideLimitMB) {
486
+ if (ssetpsInterval)
487
+ return;
488
+ currentMemoryLimitMB = resolveMemoryLimitMB(overrideLimitMB);
489
+ console.log(kleur_1.default.cyan(` SSetPS 监控已启动 ${kleur_1.default.dim(`(limit=${currentMemoryLimitMB}MB)`)}`));
490
+ // Check if global.gc is available (--expose-gc flag required)
491
+ if (typeof global.gc !== 'function' && !gcHintShown) {
492
+ gcHintShown = true;
493
+ console.log(kleur_1.default.yellow(' 提示: 使用 NODE_OPTIONS="--expose-gc" 启动可让 SSetPS 在内存过高时强制 GC'));
494
+ }
495
+ ssetpsInterval = setInterval(() => {
496
+ const memUsage = process.memoryUsage();
497
+ const rssMB = memUsage.rss / 1024 / 1024;
498
+ const limitMB = currentMemoryLimitMB;
499
+ const usagePercent = (rssMB / limitMB) * 100;
500
+ if (usagePercent > 80) {
501
+ console.log(kleur_1.default.yellow(` 内存使用率 ${usagePercent.toFixed(1)}% ${kleur_1.default.dim(`[rss=${rssMB.toFixed(0)}/${limitMB}MB]`)}`));
502
+ // Check if global.gc is available (--expose-gc flag required)
503
+ if (typeof global.gc === 'function') {
504
+ const before = process.memoryUsage().rss;
505
+ global.gc();
506
+ const after = process.memoryUsage().rss;
507
+ const freed = (before - after) / 1024 / 1024;
508
+ if (freed > 0) {
509
+ console.log(kleur_1.default.green(` GC 释放了 ${freed.toFixed(1)}MB`));
510
+ }
511
+ }
512
+ // Tripping the 80% threshold for a sandboxed plugin auto-trips its
513
+ // circuit breaker. This finally wires `circuitBreakers` into the
514
+ // rest of the CPC system (isolation, status and restore commands
515
+ // already reference the same Map).
516
+ for (const name of sandboxProcesses.keys()) {
517
+ if (!circuitBreakers.get(name))
518
+ triggerCircuitBreaker(name);
519
+ }
520
+ }
521
+ }, 10000);
522
+ }
523
+ function stopSSetPS() {
524
+ if (ssetpsInterval) {
525
+ clearInterval(ssetpsInterval);
526
+ ssetpsInterval = null;
527
+ }
528
+ }
529
+ function triggerCircuitBreaker(pluginName) {
530
+ circuitBreakers.set(pluginName, true);
531
+ console.log(kleur_1.default.red(` 已为 ${pluginName} 触发熔断器`));
532
+ console.log(kleur_1.default.red(` ${pluginName} 熔断器已断开`));
533
+ }
534
+ function restoreCircuitBreaker(pluginName) {
535
+ circuitBreakers.delete(pluginName);
536
+ console.log(kleur_1.default.green(` ${pluginName} 熔断器已恢复`));
537
+ }
538
+ /**
539
+ * Apply a firewall rule to control network access.
540
+ * Rules are in the format: 'action: target' where action is 'allow' or 'deny'
541
+ * and target can be 'localhost', 'external', or a specific host/IP[:port].
542
+ *
543
+ * Real implementation: the rule is registered into the net-layer firewall
544
+ * engine (cli/firewall.ts) which patches Socket.connect — so http/https/net
545
+ * outbound calls are actually blocked, not just logged.
546
+ */
547
+ function applyFirewallRule(rule, notify = true) {
548
+ const result = (0, firewall_1.addFirewallRule)(rule);
549
+ if (!result.ok) {
550
+ console.log(kleur_1.default.yellow(` ⚠ 无效的防火墙规则: ${rule}`));
551
+ return;
552
+ }
553
+ if (notify) {
554
+ console.log(kleur_1.default.cyan(` 防火墙规则: ${rule} ${kleur_1.default.green('✓')}`));
555
+ }
556
+ }
557
+ // ====== Test Suite ======
558
+ function runTestServe(url) {
559
+ console.log(kleur_1.default.cyan('\n 正在启动测试服务器...'));
560
+ console.log(kleur_1.default.green(` 测试服务器运行在 ${url}`));
561
+ process.env.DEBUG = '*';
562
+ process.env.SPAK_LOG_LEVEL = '3';
563
+ console.log(` ${kleur_1.default.cyan('[DEBUG]')} 调试模式已启用`);
564
+ console.log(` ${kleur_1.default.cyan('[DEBUG]')} 日志级别: verbose`);
565
+ console.log(` ${kleur_1.default.cyan('[DEBUG]')} 环境: test`);
566
+ }
567
+ // ====== Export ======
568
+ function isAvailable() {
569
+ try {
570
+ const config = (0, config_1.loadConfig)();
571
+ return config.cpc?.enabled === true;
572
+ }
573
+ catch {
574
+ return false;
575
+ }
576
+ }
577
+ function ensureAvailable() {
578
+ if (!isAvailable()) {
579
+ console.log(kleur_1.default.red(`\n CPC 未启用。请在配置中设置 'cpc.enabled: true'\n`));
580
+ process.exit(1);
581
+ }
582
+ }
583
+ // Main CPC initialization
584
+ function initCPC(config) {
585
+ if (!config?.enabled)
586
+ return;
587
+ console.log(kleur_1.default.cyan('\n 插件安全检查 (CPC)\n'));
588
+ if (process.env.SPAK_NO_SANDBOX === '1') {
589
+ console.log(kleur_1.default.yellow(' ⚠ 沙箱已禁用 — 插件将在无隔离环境下运行 (不安全!)'));
590
+ }
591
+ else if (config.sandbox?.enabled) {
592
+ console.log(` ${kleur_1.default.green('✓')} 沙箱已启用`);
593
+ }
594
+ if (config.processIsolation?.enabled) {
595
+ console.log(` ${kleur_1.default.green('✓')} 进程隔离已启用`);
596
+ }
597
+ if (config.ssetps?.enabled !== false) {
598
+ // Wire the user's configured memory limit through (env overrides still
599
+ // win inside startSSetPS via resolveMemoryLimitMB).
600
+ startSSetPS(config.ssetps?.memoryLimitMB);
601
+ // Install the actual net-layer firewall (was a logging stub). Default
602
+ // policy: allow loopback, deny everything external (fail-closed).
603
+ (0, firewall_1.installFirewall)();
604
+ for (const r of (0, firewall_1.getFirewallRules)()) {
605
+ console.log(kleur_1.default.cyan(` 防火墙已生效: ${r.action}: ${r.target}`));
606
+ }
607
+ }
608
+ runPluginCheck();
609
+ }
610
+ function testAction(args, options) {
611
+ // Extract subcommand from argv (e.g. "test serve" → "serve")
612
+ const testIdx = process.argv.indexOf('test');
613
+ const sub = testIdx >= 0 ? process.argv.slice(testIdx + 1).filter(a => !a.startsWith('-'))[0] || '' : '';
614
+ if (sub === 'serve') {
615
+ const host = options.host || '0.0.0.0';
616
+ const port = options.port || '4321';
617
+ runTestServe(`http://${host}:${port}`);
618
+ return;
619
+ }
620
+ console.log(kleur_1.default.cyan('\n 正在运行 CPC 测试...\n'));
621
+ runPluginCheck();
622
+ console.log(` ${kleur_1.default.green('✓')} 0/0 项测试通过\n`);
623
+ }
624
+ function cpcAction(args, options) {
625
+ ensureAvailable();
626
+ const sub = args.command || '';
627
+ const arg1 = args.name || '';
628
+ const action = args.action || '';
629
+ if (!sub || sub === 'help') {
630
+ console.log(`CPC 命令:
631
+ spak cpc check 检查所有插件和包
632
+ spak cpc sandbox <名称> 在沙箱中隔离插件
633
+ spak cpc sandbox stop <名称> 停止沙箱中的插件
634
+ spak cpc ssetps 启动 SSetPS 监控
635
+ spak cpc circuit <名称> 触发熔断器
636
+ spak cpc circuit restore <名称> 恢复熔断器
637
+ spak cpc status 显示 CPC 状态`);
638
+ return;
639
+ }
640
+ switch (sub) {
641
+ case 'check':
642
+ runPluginCheck();
643
+ break;
644
+ case 'sandbox':
645
+ if (arg1 === 'stop') {
646
+ if (!action) {
647
+ console.log(kleur_1.default.red('用法: spak cpc sandbox stop <名称>'));
648
+ return;
649
+ }
650
+ terminateSandbox(action);
651
+ }
652
+ else {
653
+ if (!arg1) {
654
+ console.log(kleur_1.default.red('用法: spak cpc sandbox <名称>'));
655
+ return;
656
+ }
657
+ isolatePlugin(arg1);
658
+ }
659
+ break;
660
+ case 'ssetps': {
661
+ // CLI one-shot snapshot mode: print the current memory state and exit.
662
+ // Long-running monitoring is owned by `serve` (via initCPC → startSSetPS),
663
+ // so a standalone `cpc ssetps` must NOT leave a setInterval keeping the
664
+ // process alive — otherwise the CLI hangs forever after printing.
665
+ const ssetpsCfg = (() => { try {
666
+ return (0, config_1.loadConfig)();
667
+ }
668
+ catch {
669
+ return undefined;
670
+ } })();
671
+ const limit = resolveMemoryLimitMB(ssetpsCfg?.cpc?.ssetps?.memoryLimitMB);
672
+ const usedNum = process.memoryUsage().rss / 1024 / 1024;
673
+ const used = usedNum.toFixed(1);
674
+ const usagePercent = (usedNum / limit) * 100;
675
+ console.log(kleur_1.default.cyan(` SSetPS 监控已启动 ${kleur_1.default.dim(`(limit=${limit}MB)`)}`));
676
+ console.log(` ${used} / ${limit}MB (阈值 80%)`);
677
+ if (usagePercent > 80) {
678
+ console.log(kleur_1.default.yellow(` 内存使用率 ${usagePercent.toFixed(1)}%`));
679
+ }
680
+ else {
681
+ console.log(kleur_1.default.green(` 内存健康 (${usagePercent.toFixed(1)}%)`));
682
+ }
683
+ break;
684
+ }
685
+ case 'circuit':
686
+ if (arg1 === 'restore') {
687
+ if (!action) {
688
+ console.log(kleur_1.default.red('用法: spak cpc circuit restore <名称>'));
689
+ return;
690
+ }
691
+ restoreCircuitBreaker(action);
692
+ }
693
+ else {
694
+ if (!arg1) {
695
+ console.log(kleur_1.default.red('用法: spak cpc circuit <名称>'));
696
+ return;
697
+ }
698
+ triggerCircuitBreaker(arg1);
699
+ }
700
+ break;
701
+ case 'status': {
702
+ // Reflect the effective memory limit (env > config > default) even when
703
+ // SSetPS hasn't been started, so `cpc status` never shows a stale 512MB.
704
+ const statusCfg = (() => { try {
705
+ return (0, config_1.loadConfig)();
706
+ }
707
+ catch {
708
+ return undefined;
709
+ } })();
710
+ const effectiveLimit = resolveMemoryLimitMB(statusCfg?.cpc?.ssetps?.memoryLimitMB);
711
+ console.log(kleur_1.default.cyan('\n 插件安全检查 (CPC)'));
712
+ console.log(` ${kleur_1.default.bold('沙箱:')} ${sandboxProcesses.size}`);
713
+ console.log(` ${kleur_1.default.bold('熔断器:')} ${circuitBreakers.size}`);
714
+ console.log(` ${kleur_1.default.bold('SSetPS:')} ${ssetpsInterval ? '活跃' : '未活跃'}`);
715
+ console.log(` ${kleur_1.default.bold('内存:')} ${(process.memoryUsage().rss / 1024 / 1024).toFixed(1)} / ${effectiveLimit}MB (阈值 80%)\n`);
716
+ break;
717
+ }
718
+ default:
719
+ console.log(kleur_1.default.red(`未知命令: ${sub}`));
720
+ }
721
+ }
722
+ const cpcDeclarations = [
723
+ {
724
+ command: 'test',
725
+ description: '运行 CPC 测试套件',
726
+ action: testAction,
727
+ },
728
+ {
729
+ command: 'cpc',
730
+ description: '插件安全检查 (CPC) 命令',
731
+ args: [
732
+ { name: 'command', description: '子命令: check, sandbox, ssetps, circuit, status', required: false },
733
+ { name: 'name', description: '插件名参数', required: false },
734
+ { name: 'action', description: '子操作参数', required: false },
735
+ ],
736
+ action: cpcAction,
737
+ },
738
+ {
739
+ command: 'cpc check',
740
+ description: '检查所有插件和内置包',
741
+ action: () => { ensureAvailable(); runPluginCheck(); },
742
+ },
743
+ {
744
+ command: 'cpc sandbox',
745
+ description: '在沙箱中隔离插件',
746
+ args: [{ name: 'name', description: '插件名', required: true }],
747
+ action: (args) => {
748
+ ensureAvailable();
749
+ if (!args.name) {
750
+ console.log(kleur_1.default.red('用法: spak cpc sandbox <名称>'));
751
+ return;
752
+ }
753
+ // Handle "sandbox stop <name>" form
754
+ if (args.name === 'stop') {
755
+ const stopName = process.argv[process.argv.indexOf('stop') + 1] || '';
756
+ if (!stopName) {
757
+ console.log(kleur_1.default.red('用法: spak cpc sandbox stop <名称>'));
758
+ return;
759
+ }
760
+ terminateSandbox(stopName);
761
+ return;
762
+ }
763
+ isolatePlugin(args.name);
764
+ },
765
+ },
766
+ {
767
+ command: 'cpc ssetps',
768
+ description: '启动 SSetPS 监控 (memoryLimitMB 来自配置或 SPAK_SSETPS_MEMORY_LIMIT_MB 环境变量)',
769
+ action: () => { ensureAvailable(); cpcAction({ command: 'ssetps', name: '', action: '' }, {}); },
770
+ },
771
+ {
772
+ command: 'cpc circuit',
773
+ description: '为插件触发熔断器',
774
+ args: [{ name: 'name', description: '插件名', required: true }],
775
+ action: (args) => {
776
+ ensureAvailable();
777
+ if (!args.name) {
778
+ console.log(kleur_1.default.red('用法: spak cpc circuit <名称>'));
779
+ return;
780
+ }
781
+ // Handle "circuit restore <name>" form
782
+ if (args.name === 'restore') {
783
+ const restoreName = process.argv[process.argv.indexOf('restore') + 1] || '';
784
+ if (!restoreName) {
785
+ console.log(kleur_1.default.red('用法: spak cpc circuit restore <名称>'));
786
+ return;
787
+ }
788
+ restoreCircuitBreaker(restoreName);
789
+ return;
790
+ }
791
+ triggerCircuitBreaker(args.name);
792
+ },
793
+ },
794
+ {
795
+ command: 'cpc status',
796
+ description: '显示 CPC 状态',
797
+ action: () => { ensureAvailable(); cpcAction({ command: 'status', name: '', action: '' }, {}); },
798
+ },
799
+ ];
800
+ exports.default = cpcDeclarations;
801
+ //# sourceMappingURL=cpc.js.map