# 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 解析,不能字符串比**。反例:`dsh-univer-office@0.2.14` 写 `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/dsh-llm@0.1.2-rc.1` 导出 **43** 个符号,**不含 `assertNever`** → 与崩溃日志完全吻合 ✅ ### 判据的覆盖边界(**重要**) 判据 B 只能看到"**被扫描目录里的 import 语句**"。anysearch 自身的 bundle 里 7 条 import **全部合法** —— 真正越界的是它的**传递依赖** `@deepseek-ai/dsh-tool-web@0.1.1-rc.2`(装在 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 来源** | 用平台已有的 `semver@7.8.5`(`/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-`)→ `npm run build` → 重启。数据面无改动。