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

152 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` → 重启。数据面无改动。