@moonquake2004/dsh-doctor 0.8.3 → 0.8.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.
- package/CHANGELOG.md +46 -0
- package/dsh-doctor.mjs +121 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -16,6 +16,52 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## [0.8.5] — 2026-09-16
|
|
20
|
+
|
|
21
|
+
### Fixed — P23 判据重构(红队第四轮:6 条反例,2 条高severity)
|
|
22
|
+
|
|
23
|
+
巡查 #6789 时新增的 P23(插件自带宿主机包副本),判据被红队**打穿三版**后定为 v5。完整档案 `docs/redteam-0.8.5.md`。
|
|
24
|
+
|
|
25
|
+
| # | 反例 | 处置 |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **A(高)** | `dsh.bundle`/`dsh.client` **不能**用来区分"插件 vs 宿主库" —— 宿主树上 `dsh-base`/`dsh-web-app`/`dsh-client-ui-*` 全带这两个字段,于是"**宿主核心 bundle 的第二份实例**"(更致命)恰好被豁免 | 不再比类型标签,改判 **"同一包名既在 bundle 解析路径上、也能沿祖先链解析到"** |
|
|
28
|
+
| **B(高)** | 空目录 / 截断 manifest / 同名普通文件被误报成"宿主包副本" | 只有 manifest 可读且 name 匹配才算副本;读不出的单列为**残留目录** |
|
|
29
|
+
| **C(中高)** | 别名(`nm/scope-copy`,真身在 manifest 的 `name`)、深层嵌套、`.pnpm` 实体全漏 | 扫描时**读每个目录自己的 manifest** + 有界递归 6 层;旧语料对"别名"是**假保证**,已重写 |
|
|
30
|
+
| **D(中)** | "排除 `_quarantined`"读的是顶层字段,而写入方在 `dsh.profile._quarantined` → 从不生效 | 改读真实位置 |
|
|
31
|
+
| **F(低)** | 头注释仍宣称已删除/从未实现的判据(**注释说谎**,正是 C 类漏报来源) | 注释按实际判据重写,并把 v1→v5 弯路写进注释 |
|
|
32
|
+
| E | 用户 patch `insert` 加载的插件不被扫 | **未修 → D8** |
|
|
33
|
+
|
|
34
|
+
### 修的过程中**作者自己引入 4 处新问题**(全部被机制抓出)
|
|
35
|
+
|
|
36
|
+
`ancestorResolves` 从 bundle 目录开始 → 嵌套副本**自我确认**(F5 对照被误报);别名目录从未被读到;
|
|
37
|
+
`topLines` 作用域错误让**整个 profile 段 P0 崩溃**;语料用 `null` 表示空目录导致驱动抛错。
|
|
38
|
+
|
|
39
|
+
### 验证矩阵
|
|
40
|
+
|
|
41
|
+
12 例(**真实布局**:宿主层在 `profiles/node_modules`)全部通过 —— 含 A/A2/F1/C/C2/C3 六种阳性形态,
|
|
42
|
+
与 B1/B2/B3/F5/误报对照/D 六种阴性或豁免形态。**fixture 真实性(R9)再次成为关键**:
|
|
43
|
+
只放嵌套副本、不放祖先层宿主包,就测不到真实条件。
|
|
44
|
+
|
|
45
|
+
测试 221 → **224**;语料 21 → **24**;变异 **8 杀死 / 0 存活 / 1 等价**。
|
|
46
|
+
|
|
47
|
+
## [0.8.4] — 2026-09-16
|
|
48
|
+
|
|
49
|
+
**巡查产出的新检查**:社区 #6789 报告的形状,用新机制(语料优先 + 变体分离)验证后发现是**真实漏检**。
|
|
50
|
+
|
|
51
|
+
### Added — P23:插件把宿主机包声明为普通依赖(社区 #6789)
|
|
52
|
+
|
|
53
|
+
- **报告的现象**:`@deepseek-ai/dsh-experimental-browser-use-runtime` 把 `@deepseek-ai/dsh-scope` 写成
|
|
54
|
+
**dependency**(而非 peer)→ 该包自带**第二份副本** → 两份实例 Symbol/协议不匹配 → **第一个会话能用、之后每个会话都失败**。
|
|
55
|
+
- **为什么此前抓不到**:`P5` 只扫 profile **顶层** `node_modules/@deepseek-ai`;未 hoist 时副本**嵌套在插件自己的 node_modules 里**,
|
|
56
|
+
顶层什么也看不到。**用两个变体分别实测确认**:有顶层副本 → P5 命中;**只有嵌套副本 → 没有任何检查提及**
|
|
57
|
+
(这一步很关键:若只看"顶层也有一份"的那个变体,就会误判为"我们已覆盖"——正是本会话反复犯的"用巧合冒充覆盖")。
|
|
58
|
+
- **新检查判据**:已装 bundle 的 manifest 里 `dependencies` 出现 `@deepseek-ai/*` 即 fail(宿主包应由宿主共享根提供),
|
|
59
|
+
并申报 `examined`/`examinedWhat`。对照实测:改用 `peerDependencies` 的插件 **不误报**。
|
|
60
|
+
- **配套(W4/W6)**:语料新增两条(病态 + **健康对照**),共 16 条;变异清单新增 `P23-host-dep-detection`
|
|
61
|
+
(把检出判据改成恒假,语料必须变红)。
|
|
62
|
+
|
|
63
|
+
测试 214 → **218**;变异 **9 个 → 8 杀死 / 0 存活 / 1 等价**。
|
|
64
|
+
|
|
19
65
|
## [0.8.3] — 2026-09-16
|
|
20
66
|
|
|
21
67
|
**把"自造的对抗者"变成默认动作**(用户要求:红队入流程 + 不依赖外部报错也能自我进化)。
|
package/dsh-doctor.mjs
CHANGED
|
@@ -286,6 +286,11 @@ function report(section, id, ok, detail, fix, src, examined) {
|
|
|
286
286
|
// 源码里没有 ✓ 也能让 stdout 出现 ✓。故在收口处剥掉数据里的判定符号(含 ⚠)。
|
|
287
287
|
const safeDetail = String(detail).replace(VERDICT_GLYPH_RE, '·');
|
|
288
288
|
const rec = { section, id, ok, detail: safeDetail, fix, src: src ?? 'builtin', phase: currentPhase };
|
|
289
|
+
// 覆盖量对**失败**记录同样重要("我看了多少才说它有问题")——此前只在 ok===true 时记录(实测缺口)
|
|
290
|
+
if (ok !== true && typeof examined === 'number') {
|
|
291
|
+
rec.examined = examined;
|
|
292
|
+
if (typeof arguments[7] === 'string') rec.examinedWhat = arguments[7];
|
|
293
|
+
}
|
|
289
294
|
if (ok === true) {
|
|
290
295
|
// 只有显式申报才算数;`examinedWhat` 也必须与**同一处**申报配套(由调用点传 what)
|
|
291
296
|
if (typeof examined === 'number') {
|
|
@@ -857,6 +862,113 @@ function checkProfile(name) {
|
|
|
857
862
|
}
|
|
858
863
|
}
|
|
859
864
|
|
|
865
|
+
// P23:bundle 的解析路径上出现了**宿主包的第二个实例**(社区 #6789)
|
|
866
|
+
//
|
|
867
|
+
// 现象:插件把 `@deepseek-ai/dsh-scope` 当普通依赖 → 自带一份副本 → 两份实例的 Symbol/协议不匹配 →
|
|
868
|
+
// "第一个会话能用、之后每个会话都失败"。
|
|
869
|
+
//
|
|
870
|
+
// **判据(第四轮红队打穿三版后才站住):判"条件",且不靠任何类型标签。**
|
|
871
|
+
// 条件 = 同一个包名**既在 bundle 自己的解析路径里、也能沿祖先链解析到** ⇒ 确实存在两个实例。
|
|
872
|
+
// 走过的弯路(都在 docs/redteam-0.8.4.md / -0.8.5.md 留档):
|
|
873
|
+
// v1 只看 manifest 键名 → 副本躺在盘上也失明(F1);
|
|
874
|
+
// v2 用 installAnchor 判"宿主是否提供" → 该锚点依赖 PATH,**本机为 null** → 静默失效;
|
|
875
|
+
// v3 用 `dsh.bundle`/`dsh.client` 区分"插件 vs 宿主库" → **宿主自己的 dsh-base/dsh-web-app/dsh-client-ui-*
|
|
876
|
+
// 全都带这两个字段**,于是"宿主核心 bundle 的第二份实例"(更致命)恰好被豁免(红队 A);
|
|
877
|
+
// v4(现行)**不比标签、只比名字与解析路径** —— 无豁免面,也不需要锚点。
|
|
878
|
+
// 另:manifest 读不出来时**不得据此报副本**(红队 B:空目录/截断/同名文件都会被误报),
|
|
879
|
+
// 只作为"残留目录"单独提示。
|
|
880
|
+
{
|
|
881
|
+
// 在一个目录的 node_modules 里做有界扫描,找出所有包名属于 @deepseek-ai/* 的包
|
|
882
|
+
const scanHostCopies = (fromDir) => {
|
|
883
|
+
const hits = []; const residual = [];
|
|
884
|
+
let budget = 3000;
|
|
885
|
+
const walk = (dir, depth) => {
|
|
886
|
+
if (depth > 6 || budget <= 0) return;
|
|
887
|
+
let entries = [];
|
|
888
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
889
|
+
// 目录自身的 manifest 也可能是别名(红队 C:`nm/scope-copy` 的 name 才是 @deepseek-ai/dsh-scope)
|
|
890
|
+
if (!dir.endsWith('node_modules')) {
|
|
891
|
+
const selfMf = (() => { try { return readJsonReportingBom(join(dir, 'package.json')).data; } catch { return null; } })();
|
|
892
|
+
if (selfMf && typeof selfMf.name === 'string' && selfMf.name.startsWith('@deepseek-ai/')) {
|
|
893
|
+
hits.push({ name: selfMf.name, dir, version: selfMf.version });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
for (const e of entries) {
|
|
897
|
+
if (budget-- <= 0) return;
|
|
898
|
+
if (!e.isDirectory() && !e.isSymbolicLink()) continue;
|
|
899
|
+
const full = join(dir, e.name);
|
|
900
|
+
if (e.name === '@deepseek-ai') {
|
|
901
|
+
let subs = [];
|
|
902
|
+
try { subs = readdirSync(full, { withFileTypes: true }); } catch { continue; }
|
|
903
|
+
for (const sub of subs) {
|
|
904
|
+
if (!sub.isDirectory() && !sub.isSymbolicLink()) continue;
|
|
905
|
+
const pdir = join(full, sub.name);
|
|
906
|
+
const mf = (() => { try { return readJsonReportingBom(join(pdir, 'package.json')).data; } catch { return null; } })();
|
|
907
|
+
const name = `@deepseek-ai/${sub.name}`;
|
|
908
|
+
if (mf && typeof mf.name === 'string' && mf.name === name) hits.push({ name, dir: pdir, version: mf.version });
|
|
909
|
+
else if (!mf) residual.push(pdir);
|
|
910
|
+
}
|
|
911
|
+
continue; // 不再往 @deepseek-ai 内部递归
|
|
912
|
+
}
|
|
913
|
+
if (e.name === 'node_modules' || e.name === '.pnpm' || e.name.startsWith('@') || !e.name.startsWith('.')) {
|
|
914
|
+
walk(full, depth + 1);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
walk(join(fromDir, 'node_modules'), 1);
|
|
919
|
+
return { hits, residual };
|
|
920
|
+
};
|
|
921
|
+
// 同一包名能否沿**祖先链**解析到(= 宿主层也提供它)
|
|
922
|
+
// 解析链:bundle 所在的 node_modules(profile 顶层)→ 上一层 node_modules(宿主共享根)→ 再往上。
|
|
923
|
+
// **必须从 bundle 之外开始**:若从 bundle 目录自己开始,嵌套的那份副本会把条件自我确认为真
|
|
924
|
+
// (实测:F5 对照"宿主不提供的同 scope 插件"因此被误报)。
|
|
925
|
+
const ancestorResolves = (bundleDir, name) => {
|
|
926
|
+
let nm = dirname(bundleDir); // <profile>/node_modules
|
|
927
|
+
for (let i = 0; i < 10 && nm; i++) {
|
|
928
|
+
if (existsSync(join(nm, name, 'package.json'))) return true;
|
|
929
|
+
const up = dirname(dirname(nm));
|
|
930
|
+
if (up === dirname(nm)) break;
|
|
931
|
+
nm = join(up, 'node_modules');
|
|
932
|
+
}
|
|
933
|
+
return false;
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
const roots23 = [join(dir, 'node_modules'), join(dirname(dir), 'node_modules')];
|
|
937
|
+
const quarantined23 = new Set(((manifest.dsh?.profile?._quarantined) ?? manifest._quarantined ?? [])
|
|
938
|
+
.map((q) => String(q.name ?? q))); // 红队 D:写入方在 dsh.profile._quarantined(此前只读顶层,功能不生效)
|
|
939
|
+
let scanned23 = 0;
|
|
940
|
+
const issues23 = []; const residual23 = [];
|
|
941
|
+
for (const b of bundles) {
|
|
942
|
+
if (quarantined23.has(String(b))) continue;
|
|
943
|
+
const bdir = roots23.map((r) => join(r, String(b))).find((d) => existsSync(join(d, 'package.json')));
|
|
944
|
+
if (!bdir) continue; // 解析不到 → 由 P1 负责
|
|
945
|
+
scanned23++;
|
|
946
|
+
const { hits, residual } = scanHostCopies(bdir);
|
|
947
|
+
for (const r of residual) residual23.push(r.replace(dir + '/', '').replace(bdir + '/', `${b}/`));
|
|
948
|
+
for (const h of hits) {
|
|
949
|
+
// 只有当同一包名**沿祖先链也能解析到**时,才是"两个实例"(宿主也提供它)
|
|
950
|
+
if (ancestorResolves(bdir, h.name)) {
|
|
951
|
+
issues23.push(`${b} 的解析路径里另有一份 ${h.name}${h.version ? `@${h.version}` : ''}(宿主层也提供同名包 → 两个实例)`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (issues23.length) {
|
|
956
|
+
report('profile', 'P23', false,
|
|
957
|
+
`bundle 的解析路径上出现**宿主包的第二份实例**(${issues23.length} 处 / 扫过 ${scanned23} 个 bundle)——`
|
|
958
|
+
+ `两份实例的 Symbol/协议不匹配时,典型症状是"第一个会话能用、之后每个会话都失败"(社区 #6789:\`dsh-scope\`):\n `
|
|
959
|
+
+ issues23.slice(0, 5).join('\n ')
|
|
960
|
+
+ (issues23.length > 5 ? `\n …另有 ${issues23.length - 5} 处` : '')
|
|
961
|
+
+ (residual23.length ? `\n (另有 ${residual23.length} 个读取失败的残留目录,未计入副本判定)` : ''),
|
|
962
|
+
'把该插件的这类依赖改为 peerDependencies(宿主包由宿主共享根提供);若副本来自构建产物或残留安装,清理其 node_modules 后重装',
|
|
963
|
+
undefined, scanned23, '已装 bundle');
|
|
964
|
+
} else {
|
|
965
|
+
report('profile', 'P23', true,
|
|
966
|
+
`bundle 的解析路径上未出现宿主包的第二个实例(扫过 ${scanned23} 个 bundle)`
|
|
967
|
+
+ (residual23.length ? `;另有 ${residual23.length} 个读取失败的残留目录,未计入判定` : ''),
|
|
968
|
+
undefined, undefined, scanned23, '已装 bundle');
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
860
972
|
// P20 / P21:第三方插件把整棵插件树拖垮的两种**静态可判**形态(社区 #6693 实测)
|
|
861
973
|
// 报告者在 host 侧 `apply()` 里连续撞了两个独立致命错、client 侧撞了两个静默白屏错;
|
|
862
974
|
// 其核心抱怨是"唯一的诊断入口被故障本身摧毁"。这两条都不需要执行插件代码即可判定。
|
|
@@ -957,7 +1069,7 @@ function checkProfile(name) {
|
|
|
957
1069
|
} else if (notes21.length) {
|
|
958
1070
|
report('profile', 'P21', true, `未见裸引用沙箱专属符号(${notes21.length} 处有 typeof 守卫,未计入)`, undefined, undefined, hostFiles.length + clientFiles.length, 'host/client 文件');
|
|
959
1071
|
} else {
|
|
960
|
-
report('profile', 'P21', true, `扫描 ${hostFiles.length} 个 host 入口与 ${clientFiles.length} 个 client
|
|
1072
|
+
report('profile', 'P21', true, `扫描 ${hostFiles.length} 个 host 入口与 ${clientFiles.length} 个 client 产物,未见沙箱专属符号引用`, undefined, undefined, hostFiles.length + clientFiles.length, 'host/client 文件');
|
|
961
1073
|
}
|
|
962
1074
|
}
|
|
963
1075
|
|
|
@@ -1057,6 +1169,7 @@ function checkProfile(name) {
|
|
|
1057
1169
|
// 2) 顶层映射(key: value)与顶层序列(- xxx)混排 → js-yaml "document separator expected"
|
|
1058
1170
|
// 3) tab 缩进(YAML 硬错误);4) insert 缺冒号
|
|
1059
1171
|
const yamlProblems = [];
|
|
1172
|
+
let patchEntries = 0; // 块外声明:else 分支要用它申报覆盖量(块内的 topLines 在那里不可见)
|
|
1060
1173
|
const patchText = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : '';
|
|
1061
1174
|
if (patchText) {
|
|
1062
1175
|
const topLines = patchText.split('\n');
|
|
@@ -1064,6 +1177,7 @@ function checkProfile(name) {
|
|
|
1064
1177
|
topLines.forEach((line, i) => {
|
|
1065
1178
|
if (!line.trim() || line.trim().startsWith('#')) return;
|
|
1066
1179
|
if (line.includes('\t')) yamlProblems.push(`第 ${i + 1} 行含制表符缩进(YAML 禁止 tab)`);
|
|
1180
|
+
if (/^\s*-\s/.test(line)) patchEntries++; // 覆盖量单位:patch 条目数
|
|
1067
1181
|
if (/^\s*(~|null|Null|NULL)\s*insert\s*:/.test(line)) yamlProblems.push(`第 ${i + 1} 行 "${line.trim()}" —— ~ 是 YAML null 字面量,应为 "- insert:"(#1724)`);
|
|
1068
1182
|
else if (/^\s*-\s*insert(\s|$)/.test(line) && !/^\s*-\s*insert\s*:/.test(line)) yamlProblems.push(`第 ${i + 1} 行 "${line.trim()}" —— "- insert" 缺冒号`);
|
|
1069
1183
|
// 顶层混排检测:col 0 的映射键 vs col 0 的序列项
|
|
@@ -1075,7 +1189,11 @@ function checkProfile(name) {
|
|
|
1075
1189
|
if (hasTopMapping && hasTopSeq) yamlProblems.push('顶层同时存在 key: value 映射与 - xxx 序列(js-yaml 报 "stream or a document separator is expected",#1724 实测)');
|
|
1076
1190
|
}
|
|
1077
1191
|
if (yamlProblems.length) report('profile', 'P7', false, `cordis.patch.yml 结构错误(boot 会崩,UI 打不开 #1724): ${yamlProblems.join('; ')}`, 'patch 必须是顶层纯列表(只有 - insert: / - id: 条目):删掉顶层 key: value 行;~ 是 YAML null;缩进用空格不用 tab');
|
|
1078
|
-
else
|
|
1192
|
+
else {
|
|
1193
|
+
// 显式申报覆盖量:单位是**patch 条目数**。`~`(YAML null)表示 0 个条目 → 由覆盖不变量降级为 skip,
|
|
1194
|
+
// 而不是宣称「结构正常」(那正是「没比较却说没问题」)。
|
|
1195
|
+
report('profile', 'P7', true, 'cordis.patch.yml 结构正常(无 tab / 无 ~ insert / 无映射-序列混排)', undefined, undefined, patchEntries, 'patch 条目');
|
|
1196
|
+
}
|
|
1079
1197
|
|
|
1080
1198
|
// P8/P9 需要扫描 bundle 构建产物:收集目录下有限深度的 .js 文件(lib/dist/根 + main 入口,跳过 node_modules)
|
|
1081
1199
|
|
|
@@ -2391,6 +2509,7 @@ function checkCatalog(ctx, catalog) {
|
|
|
2391
2509
|
// 于是"没有目标可查"被记成**通过**(实测:无 profile 目录时 P6 仍 pass)。判据用探测自己的话:
|
|
2392
2510
|
// **凡自述跳过的一律是 skip** —— 这类"文案与状态不一致"只能从结构上消灭,不能靠逐个改分支。
|
|
2393
2511
|
if (!r.skipped && r.ok === true && /跳过|不适用/.test(String(r.detail))) r.skipped = true;
|
|
2512
|
+
// R1(红队,2026-09-16;**曾被后续编辑覆盖回去,由离线目录用例抓回**):自报 skipped 必须是 skip。
|
|
2394
2513
|
if (r.skipped) { reportSkip(check.section, check.id, r.detail, 'catalog'); continue; }
|
|
2395
2514
|
const severity = check.severity ?? 'error';
|
|
2396
2515
|
catalogSeverity.set(check.id, severity);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonquake2004/dsh-doctor",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.5",
|
|
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": [
|