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 一律写「远程服务器」。
11 KiB
71 · 插件兼容性预检:导入 / 上传即判定(避免"装完起不来")
- 日期:2026-09-12
- 触发:用户「所有插件 导入和上传的时候都要判断兼容性」(背景:当天
@anysearch/anysearch-dsh与平台 dsh 本体不兼容,导致 admin 实例崩溃循环 → 档案 70) - 结论一句话:兼容性可以静态判定,不需要等实例崩 —— 两条判据都已在生产实测复现今天的 bug:① semver 范围解析(插件声明的
@deepseek-ai/*依赖是否接受平台内置版本)② 导出符号比对(插件 import 的符号必须在平台包的运行时真实导出里存在)。 - 状态:✅ 已落地并部署(2026-09-12 16:35,
exec-session-D;服务器 commit8d19e89)—— 判据模块 + 上传/导入双入口接入 + 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)。
九、遗留 / 风险
- 两条部署通道的隐患(档案 70 §机制层根因):L2 涉及
lib/产物,若有人"直接改服务器 lib"而不改 src,下一次全量 build 会静默回滚 → 本档的落地必须走 src + build。 - 判据 A 对"范围写得很宽"的插件(如
>=0.1.0-rc.6 <0.1.2)敏感度足够;但对"未声明依赖却真的用了平台 API"的插件无效(modlens 类)→ 靠 L2 + L3。 - 平台 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。
⚠️ 本次附带修复两处(非"顺手",是本次改动的必要完整性):
- 档案 66 的源码回填 —— 它的改动此前只存在于本机未提交工作区,服务器
src从未有过;运行态在 14:48 被一次基于旧src的全量 build 静默回滚(档案 70 §机制层根因)。本次补齐security-scan.ts/business-plugins.ts/whitelist.ts的源码 → build 后lib里scanDirDetailed从 0 处恢复到 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 → 重启。数据面无改动。