# 64 · 接入 AnySearch 搜索 provider(B 方案) - 日期:2026-09-12 - 触发:用户问「平台是否配了联网搜索 / 用的什么搜索 / 没有地方管理这类功能 / 能否装 anysearch」,随后选定 **B 方案**并给出 API Key - 结论一句话:**平台现用 DeepSeek 官方搜索;接 AnySearch 可行,且官方插件自带 provider 选择配置(不用自己写 patch 解决歧义)——但落地前有 3 个必答点,其中「这把 key 给谁用」是 P0,因为它决定外泄面。** - 状态:**admin 侧已实施**(见 §八);⚠️ 未闭环 = **§8.3**(端到端确认 → 铺普通用户)。§9.4 的「保持平台级直铺」建议**已由档案 65 推翻**(改走候选池 + 平台托管段) > **TL;DR**|**结论**:接 AnySearch 技术上零障碍(官方插件 `@anysearch/anysearch-dsh@0.1.4`,MIT,自带 `searchProvider: anysearch` 配置);**卡点不在技术,在「key 归属」**——全租户共用一把 key 意味着任何租户可从自己实例读出它。 > **关键**:前置验证 4 项全绿(key 有效 / 服务器可达 / 联合可用 / 包安全);插件会**一并替换 fetch provider**,需决定是否保留本地带 SSRF 防护的 `http`。 > **状态**:admin 侧已实施(§八);剩余待办 = §8.3 --- ## 一、现状(只读取证,2026-09-12) | 项 | 实测值 | |---|---| | dsh 版本 | `0.1.2-rc.1`(`/usr/local/lib/node_modules/@deepseek-ai/dsh`) | | 搜索 provider | **只有 `dsh-web-search-deepseek`**(id `deepseek-official`)——DeepSeek 官方联网搜索 | | 抓取 provider | `dsh-web-fetch-http`(id `http`,本地实现,带 SSRF 防护) | | 机制 | POST `https://api.deepseek.com/anthropic/v1/messages`,Anthropic 兼容 + 原生 `web_search_20250305`(`max_uses` 默认 5);复用 `DEEPSEEK_API_KEY`(**不**复用 `DEEPSEEK_BASE_URL`) | | 成本特征 | **一次搜索 = 一次模型 turn**(比专用搜索 API 贵) | | 未安装 | 官方另有 `dsh-web-search-exa`、`dsh-web-search-perplexity`,本部署未装 | | 管理面 | dsh 自带「设置 → 插件 → 插件配置 → Web search」,但被 `ensure-role-profile-patch.cjs` 对**非 admin** `disabled: true`(连同 models / plugin-inventory / cordis)→ **admin 能管、普通用户看不到**;门户侧无搜索管理面 | ## 二、前置验证(已完成,全绿) | # | 项 | 结果 | 命令要点 | |---|---|---|---| | 1 | Key 有效性(本机) | ✅ HTTP 200 · `code:0` · 1.4s | `curl -X POST https://api.anysearch.com/v1/search -H 'Authorization: Bearer ' -d '{"query":..,"max_results":1}'` | | 2 | 服务器网络可达 | ✅ 拿到 `request_id` | 服务器 DNS 走 CloudFront;IPv6 解析正常 | | 3 | 服务器 + key 联合 | ✅ HTTP 200 · `code:0` · 2.6s | 同上,在服务器执行 | | 4 | 插件包安全审查 | ✅ P2 安全 | 无 `child_process` / `eval` / `new Function` / `require()` / `process.env` / 文件写 / `chmod`;唯一外联 `https://api.anysearch.com` | > 注:本机 `Invoke-RestMethod` 对该端点**必超时**(两次实测),用 `curl` 正常 —— 排查时勿被误导。 ## 三、插件包分析(`@anysearch/anysearch-dsh@0.1.4`) 168 KB / 33 文件,`lib/*.js` 为预构建产物(消费方无需构建)。 **自带的 `cordis.patch.yml`(关键,直接解决了 provider 歧义):** ```yaml - id: web config: searchProvider: anysearch fetchProvider: anysearch - insert: - id: web-search-anysearch name: '@anysearch/anysearch-dsh' config: apiKeyEnv: ANYSEARCH_API_KEY ``` | 事实 | 值 | 影响 | |---|---|---| | provider id | `anysearch`(search + fetch 各注册一个) | patch 已显式指定 → **不会触发 `WEB_PROVIDER_AMBIGUOUS`** | | `available()` | `endpoint(baseURL,'/v1/search') !== undefined` → **不校验 key,恒为 true** | 装上后 search registry = {deepseek-official, anysearch},靠 patch 消歧 | | key 解析 | `ctx.credentials.resolve(credentialRef('ANYSEARCH_API_KEY'))` | 走 dsh credentials 服务(读 `$DSH_HOME/.credentials.yaml` / 环境) | | 额外工具 | `anysearch_search` / `anysearch_batch_search` / `anysearch_capabilities` | 白得 3 个工具 | | `web_fetch` 工具 | 已存在则不重复注册 | 无副作用 | | 默认 baseURL | `https://api.anysearch.com` | 与验证一致 | **它的 patch 同时把 `fetchProvider` 换成 `anysearch`** —— 即抓取从本地实现改为第三方 AnySearch Extract,**本地那套 SSRF 防护(拒非公网地址、逐点解析、逐跳重定向校验)随之失效**。 平台若要保留本地 fetch,需在 `profiles/web/cordis.patch.yml` 覆写: ```yaml - id: web config: fetchProvider: http ``` > ⚠️ **合并顺序未实测**:平台的 profile patch 与 bundle patch 都经 `applyEntryPatches` 合入,谁后应用决定谁生效。**必须用 `dsh --profile web --dump-config` 实测合成结果**,不能假设。 ## 四、三个必答点 | # | 级别 | 问题 | 选项 | |---|---|---|---| | **1** | **P0** | **这把 key 给谁用?** | (a) 全平台统一注入 —— 所有实例共用;**任何租户都能从自己实例读出它**(`.credentials.yaml` 属主即该用户,或 env 可读),等同档案 23 已记的 `DEEPSEEK_API_KEY` 外泄面,且这把是个人账号 key、配额与账单都绑在你身上
(b) 只给 admin → 普通用户继续 DeepSeek
(c) 全平台用 + 接受外泄面(写进已知风险) | | **2** | P0 | fetch 是否一并换成 AnySearch Extract? | (a) **保留本地 `http`**(推荐:SSRF 防护不丢,只换搜索)
(b) 接受插件的默认(search + fetch 都走 AnySearch,抓取质量可能更好,但防护逻辑改变) | | **3** | P1 | 投放方式 | (a) **平台级统一铺**(同 `portal-entry` / `business-plugins` 姿势,用户无感、不可关)
(b) 进功能插件候选池 → 用户在实例「功能管理」自助启停(搜索是基础能力,用户关掉会退回 DeepSeek,不建议) | ## 五、执行步骤(确认后) | 步 | 动作 | 中断? | |---|---|---| | S0 | 把 tgz 放 `/opt/dsh/artifacts/anysearch-dsh-0.1.4.tgz` | 否 | | S1 | **先写 key**:合并 `/.credentials.yaml` → `ANYSEARCH_API_KEY`(**必须合并**,该文件已有 `client-connection/browser-session` 记录;保持 600 + 用户属主) | 否 | | S2 | 仿 `ensure-biz-plugins.cjs` 写 `ensure-anysearch.cjs`:tgz 暂存 `/.dsh-stage/`(444、chown uid)→ `setpriv --reuid env HOME= pnpm add --store-dir file:` → `reconcileBundles` | 否 | | S3 | 写 profile patch 覆写段(若第 2 点选保留本地 fetch) | 否 | | S4 | **停实例 scope**(`systemctl stop dsh--*.scope`,下次访问自动拉起) | **是** | | S5 | `--dump-config` + 实例内实跑 `web_search` 验证 | — | > **顺序不可颠倒**:profile 里 `patchReload: live`,bundle patch 有**热加载**可能 → 先写好 key 再装插件,避免出现「provider 已切换、key 还没配」的窗口。 ## 六、影响与回滚 **影响(R8)**:S4 会停掉 2 个用户实例(guest `4092b965…`、admin `cce6d1cd…`),**当前会话断开**,下次访问自动拉起(冷启动约 4-5s,含编译缓存)。 **回滚**(任一步均可独立回滚): 1. 从 profile `package.json` 的 `dependencies` 摘掉 `@anysearch/anysearch-dsh` + `bundles` 移除该名目 → 重启实例即回到 DeepSeek 搜索; 2. 删除 `.credentials.yaml` 里的 `ANYSEARCH_API_KEY` 记录; 3. 移除 S3 的覆写段; 4. tgz 与 `.dsh-stage/` 暂存可留(只读 444,不生效)。 ## 七、红线遵守 - **R1** 不触发 dsh 升级(插件走 profile bundle,不动 dsh 主程序/缓存)。 - **R2** 不改官方 dsh 主程序与缓存。 - **R3** 不依赖 patch 官方包。 - **R7** 只动「本次明确清单」内的文件:每用户 `profile/package.json` 1 个 + `.credentials.yaml` 1 个(合并,非覆盖)+ `cordis.patch.yml` 1 个;不批量改换行符 / 不 `git add -A`。 - **R8** 停实例前已在本档列明影响并取得确认。 --- ## 八、实施记录(2026-09-12 10:30-11:20) **用户裁决**:① 分两步走(**先只装 admin 验证**)② **保留本地抓取** ③ 现在就执行。 **范围**:仅 admin(`cce6d1cd-b376-4304-80f0-0e1c58c9ffde`,uid **114801**)。 **载体**:新脚本 **`dsh_shenxian/scripts/ensure-anysearch-admin.cjs`**(仿 `ensure-biz-plugins.cjs` 的 setpriv + 沿用旧 store 姿势;支持 `--apply` / `--restart` / 干跑 / 指定用户)。 | 步 | 结果 | |---|---| | 传包 | `/opt/dsh/artifacts/anysearch-dsh-0.1.4.tgz`(168851 B) | | 凭据 | `refs.ANYSEARCH_API_KEY` 写入,**600 + uid 114801** | | patch | `platform: anysearch-search` 覆写段写入 | | 依赖 | `@anysearch/anysearch-dsh` 安装成功(store 沿用旧,ws 保持干净) | | bundles | 6 项,含该包 ✅ | | 停实例 | **0 个** —— 执行时 admin 实例未运行 → 下次访问自动拉起新配置(无需额外重启) | ### 8.1 关键实测发现(推翻本档 §三 的假设) > **profile 层 `- id: web / config:` 对 `dsh-web` 条目是「整体替换 config」,不是字段级合并。** 首次只写 `fetchProvider: http` 时,**插件自带 patch 的 `searchProvider: anysearch` 被一并冲掉** —— dump-config 里该条目只剩 `fetchProvider: http`。若就此上线,search 侧两个 provider 都 `available()=true` 又无显式选择 → **命中 `WEB_PROVIDER_AMBIGUOUS`(报错,不会自动选一个)**。 **修正**:覆写块**必须两个字段都写全**;脚本 `ensurePatch` 已改为幂等「整块替换」(新增 `updated` 动作)。 ### 8.2 验证记录 ```yaml # dsh --profile web --dump-config 实测(admin) - id: web name: '@deepseek-ai/dsh-web' config: searchProvider: anysearch fetchProvider: http - id: web-search-anysearch name: '@anysearch/anysearch-dsh' config: apiKeyEnv: ANYSEARCH_API_KEY ``` | # | 验证项 | 结果 | |---|---|---| | 1 | provider 选择 | ✅ search=`anysearch` / fetch=`http` | | 2 | 凭据文档格式 | ✅ dsh 严格解析通过(dump-config 无 warning / 无报错) | | 3 | 凭据权限 | ✅ `600` + `dsh-cce6d1cdb376430480f0` | | 4 | 网络 + key(服务器侧 curl) | ✅ HTTP 200 · `code:0` · 2.6s | | 5 | **端到端(实例内实跑 `web_search`)** | ⏳ **未做** —— 留待 admin 首次访问实例时确认 | > **附注**:dump 中 `tool-web` 条目带 `disabled: true`,注释显示来自 `@deepseek-ai/dsh-web-app` —— 这是**既有状态**(档案 37a 已证 `web_search` 当时可用),非本次引入。 ### 8.3 待办 1. **admin 端到端确认**(访问自己的实例 → 让它搜一次 → 确认走 AnySearch); 2. 通过后铺普通用户:`ANYSEARCH_API_KEY=… node scripts/ensure-anysearch-admin.cjs --apply --restart guest`; 3. 观察配额消耗(`anysearch.com/console`),评估是否需把 key 换成团队账号。 > **⚠️ 修正(2026-09-12 用户拍板)**:**上文第 2 条的「每用户直铺」路线作废** —— 三方插件**一律**走「**admin 在门户导入候选池 → 用户在实例『功能管理』自助启用/禁用**」,**不再向用户 profile 铺任何东西**。 > 执行口径改以 **档案 65 §7.3** 为准:部署 65 → 投候选池 → **退役本脚本的手工覆写职责** → 启/禁两态端到端。 > 原因:直铺段与档案 65 的平台托管段同属「整体替换 config」语义,两段并存会互相覆盖,产生难以察觉的配置漂移(档案 66 §待办 3)。 ### 8.4 回滚 | 步 | 动作 | |---|---| | 1 | profile `package.json`:从 `dependencies` 与 `dsh.profile.bundles` 摘掉 `@anysearch/anysearch-dsh` → 停实例 | | 2 | `.credentials.yaml`:删掉 `refs.ANYSEARCH_API_KEY`(删 key = 该段为空则连 `refs:` 段一起删) | | 3 | `cordis.patch.yml`:删掉 `# >>> platform: anysearch-search` ~ `# <<< platform: anysearch-search` 整块(或直接还原 `.bak-anysearch`) | > ⚠️ **`.dsh-stage/anysearch-dsh-0.1.4.tgz` 不可删** —— profile 的 `file:` 依赖指向它,删了会重现「清理残留 tgz 导致依赖断裂」那个事故(2026-09-12 上午,目前**只记在当日工作日志的「事故 63」里、无正式档案**):此后任何 `pnpm add` 都会在解析阶段 ENOENT 失败。 > 含密钥的 `.credentials.yaml.bak-anysearch` 已在验证后**删除**(回滚只需删 3 行,无需备份)。 --- ## 九、与「候选池 + 用户自助启停」方式的差异(2026-09-12 追问后核实) 用户问:能否改成「admin 在门户导入候选池 → 用户自己在实例里启用/禁用」。**机制确实存在**(档案 16 三层模型,已实施),但**本场景不建议走**。 ### 9.1 两条路的事实差异 | | 平台级直铺(**本次采用**) | 候选池 + 用户启停 | |---|---|---| | 入口 | 脚本直接 `pnpm add` 进 profile bundle | 门户 `portal.html#/plugins` 上传 → `business_plugins` 表 + 磁盘 tgz | | 用户可见 | 实例「功能管理」里**看不到** | 列表可见、可勾选 | | 用户可关 | 不能 | 能 | | 配置一致性 | 恒定(覆写段与插件同时在场) | **可能失衡**(见 9.2) | ### 9.2 关键坑:走候选池会「一禁用就坏」 实测实现(`src/web/routes/business-plugins.ts` L399-402、L410-413): - **启用** = `pnpm add file:` + `reconcileBundles` - **禁用** = **`pnpm remove `** + `reconcileBundles` —— **真卸载**,不是「留包 + `disabled: true`」 > ⚠️ **档案 16 §7.1-5 写的「禁用机制 = 保留包 + cordis patch `disabled: true`(不删 node_modules)」与现实现不符**(实现是移除依赖)。该条需修订 —— 本次不擅自改档案 16,仅在此登记。 于是用户禁用 AnySearch 时: ``` 插件 bundle 被移除 → 插件自带 patch(searchProvider: anysearch)不再加载 → 但 §八 的 profile 覆写段仍在,仍写死 searchProvider: anysearch → 指向一个未注册的 provider → WEB_PROVIDER_CONFIGURED_MISSING(报错,不回落 DeepSeek) ``` **且用户自己修不了**(他看不到 `/cordis.patch.yml`)。 ### 9.3 为什么不能靠「不写死」绕开 dsh 的选择语义决定了「两个 provider 共存 + 用户可任意启停」**必然存在坏状态**: | 配置策略 | anysearch 启用时 | anysearch 被禁用时 | |---|---|---| | 写死 `searchProvider: anysearch` | ✅ 正常 | ❌ `CONFIGURED_MISSING` | | 什么都不写 | ❌ `AMBIGUOUS`(2 个可用) | ✅ 自动选 deepseek | | 禁掉 deepseek 那条 provider | ✅ 自动选 anysearch | ❌ `UNAVAILABLE`(0 个可用) | 三种组合都有坏格子 —— 要优雅解决,需要**平台在 apply 时同步维护 web 配置**,属平台改造,不是配置能解决的。 ### 9.4 结论与建议 - **保持平台级直铺(现状)**:搜索是基础能力,用户不该关;配置恒定正确,无坏状态。 - 若确要让用户自助启停,**前置条件 = 先补一个平台能力**:在 `/api/plugins/mine/apply` 的启用/禁用路径里,对「搜索/抓取 provider 类插件」同步 upsert profile patch 的 `searchProvider` / `fetchProvider`。 - 另一条折中:候选池支持**定向投放**(目前档案 16 定为「全员可见,先不做分组」)—— 即便做了定向,9.2 的坑依然存在。 > **⚠️ 状态更新(2026-09-12,档案 65 落地后)**:本节的「保持平台级直铺」建议**已被后续决策推翻** —— 用户选定**走候选池统一机制**("有问题解决问题"),档案 65 已在平台侧实现 9.4 第二条所述的前置能力(`install`/`uninstall` 后按当前 bundles **重算托管段**),**9.2 的坏状态已消除**。**以档案 65 为准**,本节保留为决策过程记录。 > 另:本档头部「⏸ 待用户确认 2 个决策点」已过时 —— 见 §八 实施记录(admin 侧已实施);当前未闭环项见 §8.3。 > **再更新(2026-09-12 15:40,实证)**:§9.1 的「走候选池」这条路又暴露一个**更硬的问题** —— 该插件与平台 dsh `0.1.2-rc.1` **本体不兼容**(`@deepseek-ai/dsh-llm` 未导出 `assertNever`),从候选池启用会让实例**崩溃循环**(已实证并止损)→ **见档案 70**。即:§9.2/§9.4 讨论的「provider 坏状态」档案 65 可解,但**兼容性问题档案 65 解不了** —— 先解决「插件能不能装」,再谈「启停怎么联动」。 > **终局更新(2026-09-12 18:56):该能力已被用户放弃(选 **B · 下架**)** —— 候选池条目已下架(HTTP 200 / `audit_log` id 175),凭据中 `ANYSEARCH_API_KEY` 实测**已不存在**,全部用户 profile 无残留;**§8.3 的「铺普通用户」与 §8.4 的回滚步骤(删 3 行)随之一起作废**。后续若重启此能力,请从**换兼容版本**开始(先过档案 71 的兼容性预检),**不要再导入 `0.1.4`**。完整处置见 **档案 70 §九**。