@moonquake2004/dsh-doctor 0.4.30 → 0.4.32

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 (2) hide show
  1. package/dsh-doctor.mjs +157 -20
  2. package/package.json +1 -1
package/dsh-doctor.mjs CHANGED
@@ -506,7 +506,32 @@ function checkPort3080() {
506
506
  srv.on('error', finish((e) => {
507
507
  if (e.code === 'EADDRINUSE') {
508
508
  const info = portOccupierInfo(port);
509
- if (info && info.dsh) report('env', 'E10-port-3080', true, `端口 ${port} 被 dsh web 实例占用(PID ${info.pid})——宿主自身或另一实例,正常`, undefined);
509
+ // 端口被占用 **不等于** 服务健康(#6693):一个在 apply() 里抛错的插件会让 cordis 中断装载链、
510
+ // 进程退出、systemd 重启 —— 表现为"端口绑着、systemd active、页面永远打不开"。故这里真发一次 HTTP:
511
+ // 应答 → 正常;无应答 → 如实报"绑着但不服务",这正是崩溃重启循环的样子。
512
+ const httpAlive = (() => {
513
+ for (const bin of ['curl', 'wget']) {
514
+ try {
515
+ const args = bin === 'curl'
516
+ ? ['-s', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '5', `http://127.0.0.1:${port}/`]
517
+ : ['-q', '-O', '-T', '2', `http://127.0.0.1:${port}/`];
518
+ const rr = spawnSync(bin, args, { encoding: 'utf8', timeout: 8000 });
519
+ const code = String(rr.stdout || '').trim();
520
+ if (bin === 'wget') return rr.status === 0;
521
+ return /^\d{3}$/.test(code) && code !== '000';
522
+ } catch { /* 换下一个工具 */ }
523
+ }
524
+ return null; // 无工具可探测 → 不据此下结论
525
+ })();
526
+ if (info && info.dsh && httpAlive === false) {
527
+ report('env', 'E10-port-3080', false,
528
+ `端口 ${port} 被 dsh 进程占用(PID ${info.pid})但 **HTTP 无应答** —— 绑定成功 ≠ 服务健康:`
529
+ + `这与"插件在 apply() 期间抛错 → 装载链中断 → 进程退出 → systemd 重启"的崩溃重启循环一致(#6693)`,
530
+ `先在会话外跑 npx @moonquake2004/dsh-doctor --boot-check 找失败的 entry;确认后 --quarantine <包名> 让 dsh 先起来`);
531
+ } else if (info && info.dsh) {
532
+ report('env', 'E10-port-3080', true,
533
+ `端口 ${port} 被 dsh web 实例占用(PID ${info.pid})${httpAlive === true ? '且 HTTP 有应答' : ''}——宿主自身或另一实例,正常`, undefined);
534
+ }
510
535
  else if (info) report('env', 'E10-port-3080', false, `端口 ${port} 被其他程序占用(PID ${info.pid}: ${info.cmd}),dsh web 启动会 address in use(#1719)`, `关掉占用进程,或让 dsh web 用别的端口`);
511
536
  else report('env', 'E10-port-3080', true, `⚠ 端口 ${port} 被占用但无法识别占用者`, undefined);
512
537
  } else {
@@ -665,6 +690,110 @@ function checkProfile(name) {
665
690
  }
666
691
  }
667
692
 
693
+ // P20 / P21:第三方插件把整棵插件树拖垮的两种**静态可判**形态(社区 #6693 实测)
694
+ // 报告者在 host 侧 `apply()` 里连续撞了两个独立致命错、client 侧撞了两个静默白屏错;
695
+ // 其核心抱怨是"唯一的诊断入口被故障本身摧毁"。这两条都不需要执行插件代码即可判定。
696
+ {
697
+ const pkgDirs19 = listProfilePackages(dir);
698
+ // 收集 client 产物(三种真实布局,与 dsh-security SP7 的收集口径一致)
699
+ const clientFiles = [];
700
+ const hostFiles = [];
701
+ const MAX_PER_PKG = 8;
702
+ for (const { dir: pkgDir } of pkgDirs19) {
703
+ const push = (arr, f) => { if (arr.length < MAX_PER_PKG && existsSync(f) && /\.(js|mjs|cjs)$/.test(f)) arr.push(f); };
704
+ const clientDir = join(pkgDir, 'client');
705
+ if (existsSync(clientDir)) {
706
+ try { for (const e of readdirSync(clientDir, { withFileTypes: true })) if (e.isFile()) push(clientFiles, join(clientDir, e.name)); } catch { /* 不可读 */ }
707
+ }
708
+ push(clientFiles, join(pkgDir, 'lib', 'client.js'));
709
+ push(clientFiles, join(pkgDir, 'client.js'));
710
+ let mf = null;
711
+ try { mf = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); } catch { /* 跳过 */ }
712
+ const mainRel = typeof mf?.main === 'string' ? mf.main : (typeof mf?.exports === 'string' ? mf.exports : null);
713
+ if (mainRel) push(hostFiles, join(pkgDir, mainRel));
714
+ push(hostFiles, join(pkgDir, 'lib', 'index.js'));
715
+ push(hostFiles, join(pkgDir, 'index.js'));
716
+ }
717
+
718
+ // P20:client 产物必须是 `__ModuleLoader__.load({ id, factory })` 的 CJS 工厂。
719
+ // 写成裸 ESM(顶层 export/import)会在浏览器抛 `Unexpected token 'export'` 而**服务端零痕迹**——
720
+ // 注意:`node --check` 抓不到它,因为那是**合法 ESM 语法**(dsh-security SP7 用的是 --check)。
721
+ const p20 = [];
722
+ let p20scanned = 0;
723
+ for (const f of clientFiles) {
724
+ let text; try { text = readFileSync(f, 'utf8'); } catch { continue; }
725
+ p20scanned++;
726
+ const hasFactory = /__ModuleLoader__\s*\.\s*load\s*\(/.test(text);
727
+ const topLevelEsm = /^\s*(export\s|import\s)/m.test(text);
728
+ if (hasFactory) continue;
729
+ if (topLevelEsm) p20.push(`${f.split('/node_modules/')[1] || f}(未见 __ModuleLoader__.load,且有顶层 export/import)`);
730
+ }
731
+ if (!p20scanned) reportSkip('profile', 'P20', '未发现 client 产物,跳过加载格式检查');
732
+ else if (p20.length) {
733
+ report('profile', 'P20', false,
734
+ `client 产物不是 CJS 工厂(${p20.length}/${p20scanned} 个):\n ${p20.slice(0, 5).join('\n ')}\n`
735
+ + ` —— 浏览器会抛 \`Uncaught SyntaxError: Unexpected token 'export'\`,**服务端日志里出现 0 次**:`
736
+ + `systemd 健康、端口在听、页面永远空白(#6693 实测:整份 journal 里该错误计数为 0)`,
737
+ '把 client 半边改成 `__ModuleLoader__.load({ id, factory })` 的 CJS 工厂形态(参照 dsh-better-sidebar / dsh-dream-skin)');
738
+ } else {
739
+ report('profile', 'P20', true, `client 产物均为 CJS 工厂形态(${p20scanned} 个)`);
740
+ }
741
+
742
+ // P21:**沙箱专属符号在普通插件里不存在**(host 与 client 两侧一律如此,同一根因两个面)
743
+ // host 侧:裸 `harness` → ReferenceError: harness is not defined(#6693 规则 2,出现 55 次)
744
+ // client 侧:`styles` / `ctx.get('host')` 等同样拿不到(#6693 规则 4)
745
+ // 判据保守:文件里**完全没有** `typeof <symbol>` 守卫才报(有守卫则只提示——词法上无法证明守卫顺序,
746
+ // 而报告者特别指出 `harness.handle && …` 这种写法挡不住:裸标识符在 `&&` 短路之前就被解析)。
747
+ const p21 = [];
748
+ const notes21 = [];
749
+ // 匹配前的净化(两轮实测教训,都是真实 profile 上的误报):
750
+ // 1) `//#region styles` 这类**注释**区域标记会被当成裸引用(6 处全是它);
751
+ // 2) `"deepseek-harness"` / `/api/harness/connector/stream` 这类**字符串**同理(3 处全是它)。
752
+ // 故:剥注释 + 剥字符串字面量(近似,不处理转义/嵌套模板)+ 要求符号处于**代码位置**
753
+ // (后跟 `.` / `[` / `(` —— 真正使用一个全局必然如此)+ 若文件本地声明了同名变量则不算。
754
+ const stripAll = (t) => t
755
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
756
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1 ')
757
+ .replace(/'(?:[^'\\\n]|\\.)*'/g, ' ')
758
+ .replace(/"(?:[^"\\\n]|\\.)*"/g, ' ')
759
+ .replace(/`(?:[^`\\]|\\.)*`/g, ' ');
760
+ const scan = (files, symbols, side) => {
761
+ for (const f of files) {
762
+ let text; try { text = stripAll(readFileSync(f, 'utf8')); } catch { continue; }
763
+ const rel = f.split('/node_modules/')[1] || f;
764
+ for (const sym of symbols) {
765
+ const useRe = new RegExp(`(^|[^\\w.$])\\b${sym}\\b\\s*[.[(]`, 'm');
766
+ if (!useRe.test(text)) continue;
767
+ const declared = new RegExp(`\\b(?:const|let|var|function|class)\\s+${sym}\\b|[{,]\\s*${sym}\\s*[,}]`).test(text);
768
+ if (declared) continue; // 本地声明同名变量 → 不是沙箱符号
769
+ const guarded = new RegExp(`typeof\\s+${sym}\\b`).test(text);
770
+ if (guarded) { notes21.push(`${rel}: 引用 ${sym}(同文件有 typeof 守卫,但词法上无法证明守卫顺序)`); continue; }
771
+ p21.push(`${rel}(${side}): 裸引用沙箱专属符号 \`${sym}\``);
772
+ }
773
+ }
774
+ };
775
+ scan(hostFiles, ['harness'], 'host');
776
+ scan(clientFiles, ['harness', 'styles'], 'client');
777
+ // client 侧还常通过 ctx.get('host'/'styles') 取沙箱服务
778
+ for (const f of clientFiles) {
779
+ let text; try { text = stripAll(readFileSync(f, 'utf8')); } catch { continue; }
780
+ const m = /ctx\.get\(\s*['"](host|styles)['"]/.exec(text);
781
+ if (m) p21.push(`${f.split('/node_modules/')[1] || f}(client): ctx.get('${m[1]}') —— 沙箱宿主专属服务,常驻 bundle 里拿不到`);
782
+ }
783
+ if (p21.length) {
784
+ report('profile', 'P21', false,
785
+ `插件的 host/client 半边引用了**沙箱专属符号**(${p21.length} 处)——这类符号在从 node_modules 加载的普通插件里一律不存在:\n `
786
+ + p21.slice(0, 6).join('\n ')
787
+ + (p21.length > 6 ? `\n …另有 ${p21.length - 6} 处` : '')
788
+ + `\n host 侧表现为 \`ReferenceError: harness is not defined\`(在 \`apply()\` 内同步抛出 → cordis 中断装载链 → Web UI 整体不可用)`,
789
+ '移除这些引用或用宿主提供的等价能力;若确需守卫,必须写成 `typeof X !== \"undefined\"` **放在最前**——`X.foo && …` 挡不住(裸标识符先被解析)');
790
+ } else if (notes21.length) {
791
+ report('profile', 'P21', true, `未见裸引用沙箱专属符号(${notes21.length} 处有 typeof 守卫,未计入)`);
792
+ } else {
793
+ report('profile', 'P21', true, `扫描 ${hostFiles.length} 个 host 入口与 ${clientFiles.length} 个 client 产物,未见沙箱专属符号引用`);
794
+ }
795
+ }
796
+
668
797
  // P19:插件声明的 host peer 范围 vs 实际提供的 host 版本(社区 #6678 @ciceroyang 提案)
669
798
  // 这是"升级后起不来"的常见原因之一:插件声明只支持某段 core 版本,而实际装的核心已在区间外,
670
799
  // 安装时却没有任何警告(#6680 的机制:profile 的 pnpm 配置 autoInstallPeers:false 且 host 由
@@ -673,24 +802,7 @@ function checkProfile(name) {
673
802
  const findings19 = [];
674
803
  let unknown19 = 0;
675
804
  let checked19 = 0;
676
- // 内联枚举(含 scoped;isDirectory() || isSymbolicLink() —— pnpm 与 file:/dev 安装都是软链接,
677
- // 只看 isDirectory 会漏掉它们,这正是社区提醒的坑)
678
- const pkgDirs19 = [];
679
- try {
680
- for (const e of readdirSync(join(dir, 'node_modules'), { withFileTypes: true })) {
681
- if (!e.name || e.name.startsWith('.') || e.name === '.bin') continue;
682
- if (e.name.startsWith('@')) {
683
- try {
684
- for (const sub of readdirSync(join(dir, 'node_modules', e.name), { withFileTypes: true })) {
685
- if (!sub.name || sub.name.startsWith('.')) continue;
686
- if (sub.isDirectory() || sub.isSymbolicLink()) pkgDirs19.push({ name: `${e.name}/${sub.name}`, dir: join(dir, 'node_modules', e.name, sub.name) });
687
- }
688
- } catch { /* 跳过不可读 scope */ }
689
- } else if (e.isDirectory() || e.isSymbolicLink()) {
690
- pkgDirs19.push({ name: e.name, dir: join(dir, 'node_modules', e.name) });
691
- }
692
- }
693
- } catch { /* 无 node_modules */ }
805
+ const pkgDirs19 = listProfilePackages(dir);
694
806
  for (const { name: pkgName, dir: pkgDir } of pkgDirs19) {
695
807
  let mf;
696
808
  try { mf = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')); } catch { continue; }
@@ -1850,7 +1962,10 @@ catalogSeverity.set('P15', 'error');
1850
1962
  catalogSeverity.set('P16', 'warn');
1851
1963
  catalogSeverity.set('P17', 'warn');
1852
1964
  catalogSeverity.set('P18', 'warn');
1853
- catalogSeverity.set('P19', 'warn'); // #6678:声明不匹配是风险信号而非确定失败,提示但不阻断 // #6667:条件性风险(需游离本地模块才触发),提示但不翻退出码
1965
+ catalogSeverity.set('P19', 'warn');
1966
+ catalogSeverity.set('P20', 'warn'); // #6693:client 格式问题在浏览器才炸,服务端零痕迹——高价值提示但不阻断 CI
1967
+ // P21 不设 warn:它是在 apply() 内**同步抛出**、直接中断整棵装载链的致命类(#6693 实测 195+55 次),
1968
+ // 且我们两轮去误报(注释/字符串)后在真实 profile 的 8 个 host 入口 + 8 个 client 产物上零误报,故按 error 处理。 // #6678:声明不匹配是风险信号而非确定失败,提示但不阻断 // #6667:条件性风险(需游离本地模块才触发),提示但不翻退出码
1854
1969
 
1855
1970
  function bundledCatalog() {
1856
1971
  const p = new URL('./checks.json', import.meta.url);
@@ -2307,6 +2422,28 @@ function resolveHostVersion(profileDir, name) {
2307
2422
  * 注意:--boot-check 会**执行插件顶层代码**(这正是"启动"本身的语义),故为显式开关、非默认行为。
2308
2423
  */
2309
2424
 
2425
+ /** 枚举 profile node_modules 里的包(含 scoped)。**跟随软链接**——pnpm 与 file:/dev 安装都是软链接,
2426
+ * 只看 isDirectory() 会漏掉它们(社区 #6678 提醒的坑)。P19/P20/P21 共用。 */
2427
+ function listProfilePackages(profileDir) {
2428
+ const out = [];
2429
+ try {
2430
+ for (const e of readdirSync(join(profileDir, 'node_modules'), { withFileTypes: true })) {
2431
+ if (!e.name || e.name.startsWith('.') || e.name === '.bin') continue;
2432
+ if (e.name.startsWith('@')) {
2433
+ try {
2434
+ for (const sub of readdirSync(join(profileDir, 'node_modules', e.name), { withFileTypes: true })) {
2435
+ if (!sub.name || sub.name.startsWith('.')) continue;
2436
+ if (sub.isDirectory() || sub.isSymbolicLink()) out.push({ name: `${e.name}/${sub.name}`, dir: join(profileDir, 'node_modules', e.name, sub.name) });
2437
+ }
2438
+ } catch { /* 跳过不可读 scope */ }
2439
+ } else if (e.isDirectory() || e.isSymbolicLink()) {
2440
+ out.push({ name: e.name, dir: join(profileDir, 'node_modules', e.name) });
2441
+ }
2442
+ }
2443
+ } catch { /* 无 node_modules */ }
2444
+ return out;
2445
+ }
2446
+
2310
2447
  /** 解析 profile 的启动列表与各 bundle 的 entry(含用户 patch 的 insert),跳过 disabled 与已隔离项。 */
2311
2448
  function collectBootEntries(profileDir) {
2312
2449
  const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.4.30",
3
+ "version": "0.4.32",
4
4
  "description": "Offline diagnostic for DeepSeek Harness — 28 built-in + 5 catalog checks across env/profile/session (Layer A checks-as-data), self-update (Layer B), and a semi-automatic LLM observer (Layer C, --observe); Doctor panel in web UI settings.",
5
5
  "main": "lib/index.js",
6
6
  "files": [