@moonquake2004/dsh-doctor 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -23,6 +23,7 @@ const zh = {
23
23
  sectionEnv: '环境',
24
24
  sectionProfile: 'Profile',
25
25
  sectionSession: '会话',
26
+ sectionSecurity: '🔒 安全',
26
27
  fix: '修复',
27
28
  quarantineHint: '隔离建议(手动执行,勿自动)',
28
29
  error: '诊断失败:{msg}',
@@ -37,6 +38,7 @@ const en = {
37
38
  sectionEnv: 'Environment',
38
39
  sectionProfile: 'Profile',
39
40
  sectionSession: 'Session',
41
+ sectionSecurity: '🔒 Security',
40
42
  fix: 'Fix',
41
43
  quarantineHint: 'Quarantine suggestion (run manually, never auto)',
42
44
  error: 'Doctor failed: {msg}',
@@ -86,7 +88,7 @@ function DoctorSection(props) {
86
88
  finally { setRunning(false) }
87
89
  }, [])
88
90
  useEffect(() => { injectStyles(); run() }, [run])
89
- const sectionName = (s) => ({ env: L.sectionEnv, profile: L.sectionProfile, session: L.sectionSession }[s] || s)
91
+ const sectionName = (s) => ({ env: L.sectionEnv, profile: L.sectionProfile, session: L.sectionSession, security: L.sectionSecurity }[s] || s)
90
92
  return h('div', { className: 'dshd-wrap' },
91
93
  h('div', { style: { display: 'flex', gap: '10px', alignItems: 'center' } },
92
94
  h('button', { className: 'dshd-btn', onClick: run, disabled: running }, running ? L.running : L.run)),
package/dsh-doctor.mjs CHANGED
@@ -61,6 +61,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
61
61
  const HOME = process.env.DSH_HOME || join(homedir(), '.dsh');
62
62
  const results = []; // { section, id, ok, detail, fix? }
63
63
  const jsonOut = process.argv.includes('--json');
64
+ const securityOnly = process.argv.includes('--security-only');
64
65
  const only = process.argv
65
66
  .filter((a) => a.startsWith('--profile') || a.startsWith('--session') || a === '--env')
66
67
  .map((a) => a.startsWith('--') ? a.slice(2) : a);
@@ -335,6 +336,10 @@ function checkProfile(name) {
335
336
  for (const b of bundles) {
336
337
  const dir2 = findPkg(b);
337
338
  if (!dir2) {
339
+ // installAnchor 为 null(web GUI/无 dsh 锚点)时,宿主侧 bundle 无法验证——
340
+ // 跳过而非误报(#917 core bundles dsh-base/dsh-web-app 由宿主直接提供,
341
+ // 不在 profile node_modules 里,installAnchor 缺失时 findPkg 找不到是正常的)
342
+ if (!installAnchor) continue;
338
343
  report('profile', 'P1', false, `bundle 条目 ${b} 无法在安装目录或 profile node_modules 解析(#917/#1377/#880)`, `dsh plugin --profile ${name} add ${b} 或从 dsh.profile.bundles 移除`);
339
344
  } else {
340
345
  const pkg = JSON.parse(readFileSync(join(dir2, 'package.json'), 'utf8'));
@@ -343,19 +348,28 @@ function checkProfile(name) {
343
348
  }
344
349
  }
345
350
  }
346
- // P2 id 冲突
347
- const bundleIds = new Set();
351
+ // P2 id 冲突(#1404 bundle↔user + #2315 bundle↔bundle)
352
+ const bundleIdSources = new Map(); // id → Set<bundle name>
348
353
  for (const b of bundles) {
349
354
  const dir2 = findPkg(b);
350
355
  if (!dir2) continue;
351
356
  const pkg = JSON.parse(readFileSync(join(dir2, 'package.json'), 'utf8'));
352
357
  const rel = pkg.dsh?.bundle?.patch;
353
358
  if (!rel) continue;
354
- for (const id of readInsertIds(join(dir2, rel))) bundleIds.add(id);
359
+ for (const id of readInsertIds(join(dir2, rel))) {
360
+ if (!bundleIdSources.has(id)) bundleIdSources.set(id, new Set());
361
+ bundleIdSources.get(id).add(b);
362
+ }
355
363
  }
356
- const dup = [...bundleIds].filter((id) => userIds.has(id));
357
- if (dup.length) {
358
- report('profile', 'P2', false, `bundle 与用户 patch 的 id 冲突(启动必崩 duplicate loader entry id,#1404): ${dup.join(', ')}`, `备份后从 ${patchPath} 删除这些 insert(或运行 check-dsh-profile.mjs 查看详情)`);
364
+ // bundle 冲突:多个 bundle 注册同一 entry id(#2315 dsh-tui↔dsh-web-app agent-presets)
365
+ const crossBundleDup = [...bundleIdSources].filter(([, s]) => s.size > 1).map(([id, s]) => `${id}(${[...s].join(' + ')})`);
366
+ // bundle vs 用户 patch 冲突(#1404
367
+ const userBundleDup = [...bundleIdSources.keys()].filter((id) => userIds.has(id));
368
+ const p2Issues = [];
369
+ if (crossBundleDup.length) p2Issues.push(`多个 bundle 注册相同 entry id(启动必崩 duplicate loader entry id,#2315): ${crossBundleDup.join('; ')}`);
370
+ if (userBundleDup.length) p2Issues.push(`bundle 与用户 patch 的 id 冲突(启动必崩 duplicate loader entry id,#1404): ${userBundleDup.join(', ')}`);
371
+ if (p2Issues.length) {
372
+ report('profile', 'P2', false, p2Issues.join(' | '), crossBundleDup.length ? '移除冲突 bundle 中的一个(如不兼容的 TUI/standalone 插件误装入 profile),或让上游协商唯一 entry id' : `备份后从 ${patchPath} 删除这些 insert(或运行 check-dsh-profile.mjs 查看详情)`);
359
373
  } else {
360
374
  report('profile', 'P2', true, '无 bundle/用户 patch id 冲突', undefined);
361
375
  }
@@ -395,7 +409,8 @@ function checkProfile(name) {
395
409
  const fp = join(topDir, p);
396
410
  let st;
397
411
  try { st = lstatSync(fp); } catch { continue; }
398
- if (st.isSymbolicLink() && hostScope) {
412
+ if (st.isSymbolicLink()) {
413
+ if (!hostScope) continue; // installAnchor 缺失时无法验证 symlink 指向宿主——跳过(#1697 workaround 已知安全形态)
399
414
  try {
400
415
  const real = realpathSync(fp);
401
416
  const hostPkg = join(hostScope, p);
@@ -1248,45 +1263,109 @@ async function run() {
1248
1263
  process.exit(1);
1249
1264
  }
1250
1265
  }
1251
- try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
1252
- try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
1253
- try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
1254
- try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
1255
- try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
1266
+ // --security-only 跳过非安全检查
1267
+ if (!securityOnly) {
1268
+ try { checkEnv(); } catch (e) { report('env', 'E0', false, `env 检查异常: ${e.message.slice(0, 80)}`); }
1269
+ try { await checkPort3080(); } catch (e) { report('env', 'E10-port-3080', false, `端口检查异常: ${e.message.slice(0, 60)}`); }
1270
+ try { checkProfile(profileArg); } catch (e) { report('profile', 'P0', false, `profile 检查异常: ${e.message.slice(0, 100)}`); }
1271
+ try { checkSession(sessionArg); } catch (e) { report('session', 'S0', false, `session 检查异常: ${e.message.slice(0, 100)}`); }
1272
+ try { scanAllSessions(); } catch (e) { report('session', 'S11', false, `全会话扫描异常: ${e.message.slice(0, 100)}`); }
1273
+ }
1256
1274
 
1257
- // 远程检查目录(层 A):内置检查之后追加执行;--no-catalog 只走内置副本
1275
+ // 远程检查目录(层 A):内置检查之后追加执行;--no-catalog 只走内置副本;--security-only 跳过
1258
1276
  let catalogMeta = { source: 'none', checks: 0 };
1259
- try {
1260
- const catalog = await loadCatalog({ noRemote: process.argv.includes('--no-catalog'), fetchImpl: typeof fetch === 'function' ? fetch : undefined });
1261
- catalogMeta = { source: catalog.source, checks: catalog.checks.length };
1262
- const profileDir = (() => { try { return resolveProfile(profileArg); } catch { return null; } })();
1263
- if (catalog.checks.length && profileDir) checkCatalog({ home: HOME, profile: profileArg, profileDir }, catalog);
1264
- else if (catalog.checks.length) report('catalog', 'C0', true, `profile 无效(${profileArg}),目录检查跳过(${catalog.source})`, undefined, 'catalog');
1265
- } catch (e) {
1266
- catalogMeta = { source: 'error', checks: 0, error: e.message.slice(0, 80) };
1277
+ if (!securityOnly) {
1278
+ try {
1279
+ const catalog = await loadCatalog({ noRemote: process.argv.includes('--no-catalog'), fetchImpl: typeof fetch === 'function' ? fetch : undefined });
1280
+ catalogMeta = { source: catalog.source, checks: catalog.checks.length };
1281
+ const profileDir = (() => { try { return resolveProfile(profileArg); } catch { return null; } })();
1282
+ if (catalog.checks.length && profileDir) checkCatalog({ home: HOME, profile: profileArg, profileDir }, catalog);
1283
+ else if (catalog.checks.length) report('catalog', 'C0', true, `profile 无效(${profileArg}),目录检查跳过(${catalog.source})`, undefined, 'catalog');
1284
+ } catch (e) {
1285
+ catalogMeta = { source: 'error', checks: 0, error: e.message.slice(0, 80) };
1286
+ }
1267
1287
  }
1268
1288
 
1269
- // 层 B:版本检查与更新(--no-catalog 同时禁用网络检查;--update 手动更新;DSH_DOCTOR_AUTO_UPDATE=1 自动)
1289
+ // 层 B:版本检查与更新(--no-catalog 同时禁用网络检查;--update 手动更新;DSH_DOCTOR_AUTO_UPDATE=1 自动;--security-only 跳过)
1270
1290
  const noRemote = process.argv.includes('--no-catalog');
1271
1291
  let updateInfo = { current: localVersion(), latest: null, available: false };
1272
- try {
1273
- updateInfo = await checkForUpdate({ noRemote, fetchImpl: typeof fetch === 'function' ? fetch : undefined });
1274
- } catch (e) {
1275
- updateInfo = { current: localVersion(), latest: null, available: false, error: e.message.slice(0, 60) };
1292
+ if (!securityOnly) {
1293
+ try {
1294
+ updateInfo = await checkForUpdate({ noRemote, fetchImpl: typeof fetch === 'function' ? fetch : undefined });
1295
+ } catch (e) {
1296
+ updateInfo = { current: localVersion(), latest: null, available: false, error: e.message.slice(0, 60) };
1297
+ }
1298
+ if (process.argv.includes('--update') || (process.env.DSH_DOCTOR_AUTO_UPDATE === '1' && updateInfo.available)) {
1299
+ updateInfo.applied = runUpdate();
1300
+ }
1276
1301
  }
1277
- if (process.argv.includes('--update') || (process.env.DSH_DOCTOR_AUTO_UPDATE === '1' && updateInfo.available)) {
1278
- updateInfo.applied = runUpdate();
1302
+
1303
+ // 安全检查(--security):导入 dsh-security 运行安全检查,合并到 results
1304
+ const securityEnabled = process.argv.includes('--security');
1305
+ let securityMeta = { enabled: false, summary: {} };
1306
+ if (securityEnabled) {
1307
+ try {
1308
+ // 尝试从 profile node_modules 或全局安装导入 dsh-security
1309
+ let secMod;
1310
+ const profileDir = (() => { try { return resolveProfile(profileArg); } catch { return null; } })();
1311
+ const secCandidates = [
1312
+ profileDir ? join(profileDir, 'node_modules', '@moonquake2004', 'dsh-security', 'src', 'index.mjs') : null,
1313
+ join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'node_modules', '@moonquake2004', 'dsh-security', 'src', 'index.mjs'),
1314
+ '/Users/waterfly/dsh工作区/dsh-security/src/index.mjs',
1315
+ ].filter(Boolean);
1316
+ for (const candidate of secCandidates) {
1317
+ if (existsSync(candidate)) { secMod = await import(candidate); break; }
1318
+ }
1319
+ if (secMod) {
1320
+ const registry = await secMod.createDefaultRegistry();
1321
+ // 获取最新会话文件(供 SR*/SS* 检查使用)
1322
+ const findLatestSession = () => {
1323
+ if (sessionArg) return sessionArg;
1324
+ try {
1325
+ const sDir = join(HOME, 'sessions');
1326
+ const latest = readdirSync(sDir).filter(f => f.endsWith('.jsonl')).sort().pop();
1327
+ return latest ? join(sDir, latest) : null;
1328
+ } catch { return null; }
1329
+ };
1330
+
1331
+ const { results: secResults, exitCode: secExit, summary: secSummary } = await registry.runAll(
1332
+ (check) => {
1333
+ // 静态检查用 profile 目录
1334
+ if (check.phase === 'pre-install' || check.phase === 'lifecycle') {
1335
+ return profileDir || profileArg;
1336
+ }
1337
+ // SR*/SS* 检查用会话文件
1338
+ if (check.id && (check.id.startsWith('SR') || check.id.startsWith('SS'))) {
1339
+ return findLatestSession() || null;
1340
+ }
1341
+ // 默认用 profile 目录
1342
+ return profileDir || profileArg;
1343
+ }
1344
+ );
1345
+ for (const r of secResults) {
1346
+ results.push({ section: 'security', id: r.id, ok: r.ok, detail: r.detail, fix: r.fix, severity: r.severity, skip: false });
1347
+ }
1348
+ securityMeta = { enabled: true, summary: secSummary, exitCode: secExit };
1349
+ } else {
1350
+ securityMeta = { enabled: true, error: 'dsh-security not found', summary: {} };
1351
+ }
1352
+ } catch (e) {
1353
+ securityMeta = { enabled: true, error: e.message.slice(0, 80), summary: {} };
1354
+ }
1279
1355
  }
1280
1356
 
1281
1357
  // 退出码只计内置失败 + catalog 中 severity=error 的失败;warn 失败提示但不改退出码
1358
+ // 安全检查退出码:CRITICAL→2, HIGH→1(取 max)
1282
1359
  const bad = results.filter((r) => !r.ok && catalogSeverity.get(r.id) !== 'warn');
1360
+ const secExit = securityMeta.exitCode ?? 0;
1283
1361
  if (jsonOut && process.argv.includes('--envelope')) {
1284
1362
  // v1 契约信封(dsh doctor 规格,zoahdev/doctor 对齐):status 小写 + 退出码 0/1/2
1285
1363
  const st = (r) => (r.skip ? 'skip' : (!r.ok ? (catalogSeverity.get(r.id) === 'warn' ? 'warn' : 'fail') : 'pass'));
1286
1364
  const summary = { pass: 0, warn: 0, fail: 0, skip: 0 }; // skip 常驻(v1 词汇表 r5:#1719),r5 后 P12 会在未装 bundle 时实际触发
1287
- const checks = results.map((r) => { summary[st(r)]++; return { name: r.id, status: st(r), detail: r.detail }; });
1288
- const exitCode = summary.fail > 0 ? 2 : summary.warn > 0 ? 1 : 0;
1289
- console.log(JSON.stringify({
1365
+ const checks = results.map((r) => { summary[st(r)]++; return { name: r.id, status: st(r), detail: r.detail, ...(r.severity ? { severity: r.severity } : {}), ...(r.section === 'security' ? { section: 'security' } : {}) }; });
1366
+ const baseExit = summary.fail > 0 ? 2 : summary.warn > 0 ? 1 : 0;
1367
+ const exitCode = Math.max(baseExit, secExit);
1368
+ const out = {
1290
1369
  schema: 'dsh-doctor/v1',
1291
1370
  tool: 'dsh-doctor',
1292
1371
  generatedAt: new Date().toISOString(),
@@ -1295,10 +1374,14 @@ async function run() {
1295
1374
  summary,
1296
1375
  ok: exitCode === 0,
1297
1376
  checks,
1298
- }, null, 2));
1377
+ };
1378
+ if (securityMeta.enabled) {
1379
+ out.security = { enabled: true, summary: securityMeta.summary, ...(securityMeta.error ? { error: securityMeta.error } : {}) };
1380
+ }
1381
+ console.log(JSON.stringify(out, null, 2));
1299
1382
  process.exit(exitCode);
1300
1383
  } else if (jsonOut) {
1301
- console.log(JSON.stringify({ ok: bad.length === 0, checks: results, catalog: catalogMeta, update: updateInfo }, null, 2));
1384
+ console.log(JSON.stringify({ ok: bad.length === 0 && secExit === 0, checks: results, catalog: catalogMeta, update: updateInfo, ...(securityMeta.enabled ? { security: securityMeta } : {}) }, null, 2));
1302
1385
  } else {
1303
1386
  const sectionOrder = { env: 0, profile: 1, session: 2, catalog: 3 };
1304
1387
  const ordered = [...results].sort((a, b) => (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9));
package/lib/index.js CHANGED
@@ -35,7 +35,7 @@ export function apply(ctx, config) {
35
35
  */
36
36
  function runChecks(profile, sessionPath) {
37
37
  return new Promise((resolvePromise) => {
38
- const args = ['--json'];
38
+ const args = ['--json', '--security'];
39
39
  if (profile) args.push('--profile', profile);
40
40
  if (sessionPath) args.push('--session', sessionPath);
41
41
  const child = spawn(process.execPath, [SCRIPT, ...args], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moonquake2004/dsh-doctor",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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": [
@@ -48,4 +48,4 @@
48
48
  "bin": {
49
49
  "dsh-doctor": "./dsh-doctor.mjs"
50
50
  }
51
- }
51
+ }