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 一律写「远程服务器」。
8.3 KiB
65 · 功能插件启停与 web provider 配置联动
- 日期:2026-09-12
- 触发:用户决定 AnySearch 接入改走候选池统一机制("有问题解决问题"),而候选池的启停会打穿 profile 层的 provider 硬绑定
- 结论一句话:把「插件启停」与「dsh-web 的 provider 选择」从两件互不知情的事,变成一件由平台统一维护的事 —— 启用时写死插件声明的 searchProvider,禁用时整段删除,回到「只有一个可用」的自动选择。
- 状态:✅ 已部署并生效(服务器
lib/2026-09-13 00:13 构建 → 00:15:23 重启即已载入;08:02 随档案 78 部署再载入一次)
TL;DR|结论:候选池「禁用即坏」的根因不是候选池,而是 profile 层那段硬绑定的 provider 覆写 —— 插件一被
pnpm remove,配置就指向了不存在的 provider(CONFIGURED_MISSING,且 dsh 不回落)。 解法:平台在install/uninstall后按当前 bundles 重算托管段,让两态都正确。 状态:✅ 代码完成并已用真实数据验证两态(§七),待部署
一、问题
dsh 的 web provider 选择语义(源码实证):
- 显式配置即硬绑定 —— provider 不在了就报
WEB_PROVIDER_CONFIGURED_MISSING,不回落 - 未配置时只有「恰好一个可用」才自动选,多个可用直接
WEB_PROVIDER_AMBIGUOUS报错
而候选池的禁用是 pnpm remove(真卸载),于是:
禁用插件 → bundle 移除 → 插件自带 patch(searchProvider: anysearch)不再加载
→ 但 profile 覆写段仍在,仍写死 searchProvider: anysearch
→ 指向未注册的 provider → CONFIGURED_MISSING(搜索直接坏,且用户修不了)
三种静态写法都有坏格子(详见档案 64 §九.3):写死→禁用即坏;不写→共存即歧义;禁 deepseek→禁用后 0 个可用。
二、方案
把 provider 配置从静态改成随 bundles 动态重算:
| 状态 | profile 托管段 | 结果 |
|---|---|---|
| 插件已启用 | searchProvider: <插件声明值> + fetchProvider: http |
✅ 走 AnySearch,本地抓取保留 |
| 插件已禁用 | 整段删除 | ✅ 只剩 deepseek 一个可用 → 自动选中 |
平台策略(本档定):fetchProvider 恒为本地 http,忽略插件对 fetch 的声明 —— 这是「保留本地抓取 + SSRF 防护」这条决策的落地位置。插件若声明了别的 fetch provider,不采纳。
三、实现
位置:src/web/routes/business-plugins.ts
| 新增 | 职责 |
|---|---|
collectWebProviderClaims(dir, bundles) |
扫描已启用 bundle 自带的 cordis.patch.yml,只取 - id: web 条目下 config.searchProvider 的声明值(跳过 @deepseek-ai/* 官方骨架;正则行扫描,不引入 YAML 依赖) |
syncWebProviderPatch(dir) |
幂等重算:先摘掉旧托管段(含历史遗留的 platform: anysearch-search),再按当前 bundles 追加 |
调用点:install() 与 uninstall() 里紧接 reconcileBundles(dir) 之后 —— 保证「bundles 已定 → 配置随之重算」的因果顺序。
托管段标记(用 marker 包裹,便于幂等替换与人工识别):
# >>> platform: web-provider (managed by business-plugins)
# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。
- id: web
config:
searchProvider: anysearch
fetchProvider: http
# <<< platform: web-provider
与既有机制的关系:
- 与档案 34 的 profile 快照/还原天然兼容 ——
SNAPSHOT_FILES本就含cordis.patch.yml,探活失败回滚会把这段一起还原。 - 与档案 16 的三层归属模型不冲突:这是「功能插件」层内部的实现细节,不新增归属层。
四、为什么不让插件自己声明得更明确(备选方案否决记录)
| 方案 | 否决理由 |
|---|---|
插件 package.json 加 dsh.webProviders 字段 |
第三方插件(AnySearch)不会按平台的自定义约定声明,等于白设 |
| 平台代码里硬编码插件名 → provider 映射 | 每接一个 provider 插件就要改平台代码;且与「插件自带 patch 已声明」的事实重复 |
| 读插件自带 patch 推导(采用) | 零配置、通用、自动跟随插件升级 |
五、待办与迁移
- 部署:
npm run build→ scplib/→systemctl restart dshs(会中断在线用户,R8 需先知会); - 投放候选池:把
anysearch-dsh-0.1.4.tgz落入<dataRoot>/business-plugins/+ 写business_plugins行(正规入口是门户#/plugins上传,仅 admin); - 迁移 admin:移除
scripts/ensure-anysearch-admin.cjs写的手工段(platform: anysearch-search),改由平台托管段接管 —— 该脚本后续仅保留「铺候选池」职责或整体退役; - 验证两态:启用态(
searchProvider: anysearch)与禁用态(托管段消失 → 自动选 deepseek)各跑一次--dump-config。
六、红线遵守
- R1/R2:只改平台自有代码(
src/web/routes/),不碰官方 dsh 主程序与缓存。 - R7:改动仅 1 个源文件(+ 2 处调用);不批量改写、不
git add -A。 - R8:重启服务前先告知影响并取得确认。
七、实施与验证(2026-09-12)
7.1 代码
src/web/routes/business-plugins.ts 单文件改动:
| 位置 | 改动 |
|---|---|
| 模块级(导出) | 新增 WEB_PROVIDER_OPEN/CLOSE/LEGACY + collectWebProviderClaims() + syncWebProviderPatch() |
install() / uninstall() |
在 reconcileBundles(dir) 之后各加一行 syncWebProviderPatch(dir) |
函数刻意放在模块级并导出(不依赖
app):一是便于被真实产物直接验证,二是为后续加单测留口子。
自检:npm run typecheck exit 0 · npm run build exit 0。
7.2 真实数据验证(未用生产文件)
方法:把编译产物(lib/web/routes/business-plugins.js)以 business-plugins.new.js 名义放到服务器同目录(相对 import 可解析、不覆盖生产文件),再由一次性脚本在 /tmp 副本上跑 admin profile 的真实数据(真实 package.json、真实 profile patch、真实插件包)。脚本与副本用完即删。
| 态 | 输入 | 期望 | 实测 |
|---|---|---|---|
| A 启用 | bundles 含 @anysearch/anysearch-dsh(照抄 admin 现状,patch 里还带着早期手工直铺段) |
清掉手工段、写平台托管段 | ✅ claims={"search":"anysearch"};手工 anysearch-search 段被移除;写入 web-provider 段(searchProvider: anysearch + fetchProvider: http) |
| B 禁用 | 从 deps + bundles 移除该插件、删其 node_modules |
托管段应整段消失 | ✅ claims={"search":null};同步后文件里既无 web-provider 也无 anysearch-search |
| C 幂等 | 对态 A 再跑一次 | 逐字不变 | ✅ true |
| D 无残留 | 检查态 B 产物 | 不含任一托管段 | ✅ 两个都 false |
结论:两态都正确 —— 启用时走 AnySearch 且保留本地抓取;禁用时自动回到 DeepSeek,没有 CONFIGURED_MISSING 坏状态。迁移也顺带完成(手工段被自动清除)。
7.3 待办
- 部署:
lib/→ 服务器 +systemctl restart dshs(R8:中断在线用户,待确认窗口); - 投放候选池:tgz 落
<dataRoot>/business-plugins/+ 写business_plugins行(正规入口 = 门户#/plugins上传); 退役→ ✅ 2026-09-13 08:0x 已完成:脚本头加硬拦(默认scripts/ensure-anysearch-admin.cjs的手工覆写职责exit 2,仅DSH_ALLOW_RETIRED_ANYSEARCH=1可放行),保留为「考古/回滚」用途、不再日常可跑;备份ensure-anysearch-admin.cjs.bak-retire-20260913;实测默认拒绝 rc=2、显式放行可干跑、node --check语法 OK。原条目 —— 由平台托管段接管后,该脚本只应保留「铺候选池」或整体退役(否则两段并存、后者覆盖前者,会产生难以察觉的配置漂移);- 端到端:admin 在「功能管理」里启用 → 搜一次;再禁用 → 搜一次(应回落到 DeepSeek)。