Files
dsh_shenxian/dsh-server-docs/04-调整方案/71-插件兼容性预检-导入上传即判定.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

11 KiB
Raw Blame History

71 · 插件兼容性预检:导入 / 上传即判定(避免"装完起不来")

  • 日期:2026-09-12
  • 触发:用户「所有插件 导入和上传的时候都要判断兼容性」(背景:当天 @anysearch/anysearch-dsh 与平台 dsh 本体不兼容,导致 admin 实例崩溃循环 → 档案 70)
  • 结论一句话:兼容性可以静态判定,不需要等实例崩 —— 两条判据都已在生产实测复现今天的 bug:① semver 范围解析(插件声明的 @deepseek-ai/* 依赖是否接受平台内置版本)② 导出符号比对(插件 import 的符号必须在平台包的运行时真实导出里存在)。
  • 状态:✅ 已落地并部署(2026-09-12 16:35,exec-session-D;服务器 commit 8d19e89)—— 判据模块 + 上传/导入双入口接入 + build + 重启,验收全绿(见 §十)
  • 关联:档案 70(anysearch 崩溃)、档案 66(fail-closed + admin 显式信任的模式来源)、档案 34(启用探活 = 第三层)、档案 29(官方白名单导入)、档案 47/44(已有的版本比对零件)

一、问题

今天 anysearch 的事故说明:插件能不能装,与它跟平台 dsh 版本兼不兼容,是两件事 —— 现有链路只查前者(安全扫描 / 供应链),不查后者。结果:

现有防线 时机 晚了多少
上传安全扫描(档案 41 / 66) 上传时 只查"有没有恶意内容",不查 API 兼容
官方目录导入(档案 29) 导入时 只查来源与构建形态
启用探活(档案 34) 启用 + 实例启动后 ❌ 已经崩了才发现 —— 而且崩溃循环会一路撞到熔断(档案 20)

⇒ 缺的是装之前/启动之前的兼容性判定。

二、判据(两条,均已在生产实测)

判据 A · 依赖范围(semver)—— 最快、最先命中

插件 package.json 里声明它对 @deepseek-ai/* 的版本要求;平台内置同名包的版本必须落在该范围内。

today's case 实测(_anysearch_anysearch-dsh.tgz@0.1.4):

@deepseek-ai/dsh-llm          >=0.1.0-rc.6 <0.1.1 || >=0.1.1-rc.1 <0.1.2   ← 平台为 0.1.2-rc.1,落在范围外 ❌
@deepseek-ai/dsh-tool-web     同上                                        ❌
@deepseek-ai/dsh-credentials  同上                                        ❌
@deepseek-ai/dsh-system-prompt / dsh-tools / dsh-web                      ❌
@deepseek-ai/cordis           >=4.0.1-rc.1 <5      ← 平台 4.0.2 ✅
@deepseek-ai/schemastery      >=3.18.1-rc.1 <4     ← 平台 3.18.2 ✅

7 个 @deepseek-ai/dsh-* 依赖全部落在范围外 → 纯依赖比对就能拦住它(不需要扫一行代码)。

⚠️ 必须用 semver 解析,不能字符串比。反例:[email protected] 写 0.1.1-rc.2 || 0.1.2-rc.1 —— 平台 0.1.2-rc.1 恰在允许列表内(兼容),但字符串比对会误报为"不一致"。PoC 首版就踩了这个坑。

判据 B · 导出符号(运行时真值)—— 精确,抓 API 级不兼容

插件 bundle 里 import { X } from '@deepseek-ai/Y' → 平台内置的 Y 必须真的导出 X。

判据取值方式(实测可靠):直接 await import() 平台包的入口拿到 Object.keys(m) = 真实导出,比解析 .d.ts 稳。

实测:平台内置 @deepseek-ai/* 子包 223 个,220 个能取到导出清单;@deepseek-ai/[email protected] 导出 43 个符号,不含 assertNever → 与崩溃日志完全吻合 ✅

判据的覆盖边界(重要)

判据 B 只能看到"被扫描目录里的 import 语句"。anysearch 自身的 bundle 里 7 条 import 全部合法 —— 真正越界的是它的传递依赖 @deepseek-ai/[email protected](装在 profile 的 node_modules 里,不在 tgz 内)。

⇒ 静态检查必须分两级:上传时只能看 tgz 自身(判据 A 已足够拦住 anysearch);要覆盖传递依赖,必须在安装后、启动前扫 profile 的 node_modules。

三、PoC 实测:候选池 3 个插件的现状判定

脚本 scripts/plugin-compat-check.mjs(随库分发,可在服务器直接跑):

tgz 依赖判据 A 符号判据 B 结论
_anysearch_anysearch-dsh.tgz @0.1.4 7/9 依赖范围不满足 自身 bundle 合法(问题在传递依赖) 🔴 不兼容(决定)
dsh-univer-office.tgz @0.2.14 9 个依赖全部满足(含 0.1.2-rc.1 备选) 19 条 import 符号全存在 🟢 兼容
_liustack_modlens.tgz @3.26.1 未声明任何 @deepseek-ai 依赖 0 条 import ⚪ 需装后复核(静态无法判定,不阻断)

⇒ 这直接给出了任何 search 的处置依据:它静态就不兼容,不是"配置没调好"。

四、三层防线(本次补前两层)

层 时机 判据 拦截策略
L1 上传 / 导入 admin 上传 tgz、官方目录导入 A(+ B 对 tgz 自身) 不满足 → 阻断;静态无法判定 → 放行但标记"待装后复核"
L2 启用前(装后、启动前) 门户 enable,pnpm add 完成后 B 扫 profile node_modules/@deepseek-ai/*(含传递依赖) 命中缺失符号 → 拒绝启用,逐条回显证据
L3 启用探活(已有,档案 34) 实例启动后 restartAndProbe 探活 失败自动禁用 —— 保留为兜底

五、实现方案

项 定稿(技术决策已定,不再作为待确认项)
判据单一来源 新建 src/web/plugin-compat.ts:导出 checkPluginCompat(dir) → { ok, level, findings[] };平台版本表从 dsh 根目录现读并缓存
semver 来源 用平台已有的 [email protected](/opt/dshs/node_modules/semver 与 dsh 自带的都在);显式加进 package.json dependencies,不依赖传递解析
接入点 ① src/web/routes/business-plugins.ts 上传路径(现 L145 scanDir(unzipDir) 同一处,复用已解包目录)② src/web/routes/whitelist.ts 官方目录导入 ③ 门户 enable 路径(L2)
拦截级别 fail-closed + admin 显式信任,与档案 66 同一模式(逐条回显命中依据 + 信任后放行并留痕),不降低任何现有规则强度
前端 复用档案 66 的"命中详情 + 信任"交互;新增"待装后复核"标记位
不做 ❌ 不改官方 dsh 与其缓存 ❌ 不动 node_modules ❌ 不重新定义现有安全扫描规则 ❌ 不引入联网校验(判据全部本地可算)

六、验收基线(用现成的 3 个 tgz,可复现)

node scripts/plugin-compat-check.mjs 在服务器上应输出:

  • _anysearch_anysearch-dsh.tgz → 判不兼容(7 个依赖范围不满足),且理由逐条可读
  • dsh-univer-office.tgz → 判兼容(不得因字符串比对误报)
  • _liustack_modlens.tgz → 判"需装后复核"(不阻断)

L2 验收:给一个装好但含传递依赖越界的 profile(anysearch 的历史现场可复现)→ 命中 assertNever 并拒绝启用;平台侧日志可查。

七、红线

  • 只读平台包目录、只读 tgz/profile 的 node_modules,不修改任何被检查对象。
  • 新增拦截是收窄(更严)而非扩大可见面 —— 符合 R5 方向;但必须提供 admin 显式信任出口(否则会卡住正常的插件投放)。
  • 任何"命中即阻断"的改动,必须有逐条证据回显(档案 66 的教训:只进日志 = 用户不知道为什么被拒)。

八、回滚

src/web/plugin-compat.ts 是新文件;接入点是新增调用,回滚 = 摘掉 3 处调用 + 删文件 + npm run build。数据面无改动(不写 DB)。

九、遗留 / 风险

  1. 两条部署通道的隐患(档案 70 §机制层根因):L2 涉及 lib/ 产物,若有人"直接改服务器 lib"而不改 src,下一次全量 build 会静默回滚 → 本档的落地必须走 src + build。
  2. 判据 A 对"范围写得很宽"的插件(如 >=0.1.0-rc.6 <0.1.2)敏感度足够;但对"未声明依赖却真的用了平台 API"的插件无效(modlens 类)→ 靠 L2 + L3。
  3. 平台 dsh 升级后(档案 26 六类耦合点),判据来源(内置包版本表)会自动变化 —— 这是期望行为(升级后原本兼容的插件可能变不兼容,L1/L2 会立刻反映)。

十、落地记录(2026-09-12 16:35,exec-session-D)

commit 8d19e89(服务器),改动 6 个文件:

文件 动作
src/web/plugin-compat.ts 新增 —— 判据单一来源(判据 A 依赖范围 + 判据 B 导出符号),全 try/catch,任何异常降级 unknown 不误伤
src/web/semver-shim.d.ts 新增 —— semver 是传递依赖(无 @types/semver),按需声明所需形状,不引入新依赖
src/web/routes/business-plugins.ts StagedPlugin.compat + stageTgzArchive 改 async 并预检 + 裁决(fail-closed + 显式信任)+ audit 记 compatLevel
src/web/routes/whitelist.ts 调用点 await + 补上 P0 裁决(见下)+ 兼容性裁决(批量导入以 error 文本回显)
src/web/security-scan.ts 档案 66 的源码回填(见下)
web/portal.html renderScanBlocked 兼容两类拒绝(scan_blocked / compat_incompatible)

关键设计点:改 stageTgzArchive 一处 → 同时覆盖门户上传(L1)+ 官方目录导入 两个入口。

验收(用部署产物 lib/web/plugin-compat.js 跑真值测试):

tgz 判定 耗时
_anysearch_anysearch-dsh.tgz incompatible(5 条 dep-range,回显逐条依据) 299ms
dsh-univer-office.tgz ok 1366ms
_liustack_modlens.tgz unknown(放行 + 标记待装后复核) 6ms

npm run typecheck exit 0 | npm run build 产 lib/web/plugin-compat.js | 服务重启后 active + 门户 HTTP 200。

⚠️ 本次附带修复两处(非"顺手",是本次改动的必要完整性):

  1. 档案 66 的源码回填 —— 它的改动此前只存在于本机未提交工作区,服务器 src 从未有过;运行态在 14:48 被一次基于旧 src 的全量 build 静默回滚(档案 70 §机制层根因)。本次补齐 security-scan.ts / business-plugins.ts / whitelist.ts 的源码 → build 后 lib 里 scanDirDetailed 从 0 处恢复到 2 处。
  2. whitelist.ts 导入路径缺失 P0 裁决 —— 档案 66 把 stageTgzArchive 由「命中即 throw」改成「收集式返回」后,该调用点没有跟着做裁决 → P0 命中会被静默放过(安全缺口)。本次一并补上。

未做(有意):L2(启用前扫 profile node_modules,覆盖"未声明依赖却用了平台 API"与传递依赖越界)留作后续 —— 本次的 L1 已能拦住 anysearch 这个实际案例。

R8 影响面:重启 dshs → 中断当时 2 个在线实例(guest 4092b965 / admin cce6d1cd)约 4 秒;执行时已获用户授权("现在可以改了吗"),重启后服务 active、门户 200。

回滚:git -C /opt/dshs revert 8d19e89(或还原 4 个 .bak-t05-<ts>)→ npm run build → 重启。数据面无改动。