| 是否集成 Perplexity | 不做(所有者裁定) | 参考插件里的 3 个 perplexity 工具是收费直连,与本站「服务端持凭据 + 域白名单」的形态是另一套取舍。只取插件的第 1 个工具(经网关的 web_search) |
| DeepSeek 兼容 | 做,且零分支(所有者追加要求) | DeepSeek Responses API 与网关是同一套协议(POST {base}/v1/responses、tools:[{type:"web_search"}]、stream:true、response.output_text.delta/completed/failed)。差异只有 baseUrl / modelId / toolType 三个配置字段,不需要第二条代码路径 |
| tool_config 里的初始状态 | 默认关 | 新环境部署完还没配 provider,注册阶段本来就会丢弃它;默认关把「没配就没有」变成显式的一件事 |
| 超时默认值 | 总 180s / 空闲 45s(库级 CHECK 上界 300s / 120s) | 贴着实测:网关侧「检索 + 综述」常越过 90s。代价已认——最坏访客等 3 分钟且占着一个会话名额 |
| 前端是否改动 | 零改动(规则 7) | apps/web/lib/trace-view.ts 的三个投影都是泛型的:行名取 eventType + data.toolName,详情取白名单字段;Lifecycle 的 tool_call / tool_execution / tool_result 三个节点本就存在(此前恒为 pending)。搜索流程靠 pi 的 onUpdate → tool_execution_update(34 事件之一)进视图,不新造事件、不动组件 |
文档(规则 9:先改文档)
后端
测试
| # | 检查 | 命令 / 期望 |
|---|---|---|
| 1 | 编译与测试全绿 | dev.ps1 check + dev.ps1 test 全过,且 npx tsc --noEmit 干净(复审第 1 轮加:前两者都不跑全量 tsc) |
| 2 | 迁移 008 可施加且幂等 | 本机 dev.ps1 起库自动跑;deploy/migrate.sh 在 130 上重跑不报错 |
| 3 | 域白名单挡得住 | 单测:https://evil.tld/v1、https://api.deepseek.com.evil.tld、http://api.deepseek.com(非 https)、带内嵌凭据的 URL 全部被拒;MCP 写入侧同样拒 |
| 4 | 访客控不到网络原语 | 静态核验 + 单测:runWebSearch 的 URL/headers/model/toolType 只来自配置;query 只进 body 的 input,且有 300 字上限(schema maxLength) |
| 5 | SSE 解析正确 | 单测:delta 累积 / response.completed 优先 / response.failed 与 response.incomplete 报错 / [DONE] 与半条 JSON 不炸 / 非 text/event-stream 时按普通 JSON 降级 |
| 6 | 双计时器与字节上限 | 单测:空闲超时与总超时分别产出对应 kind;超过 4 MiB 中断 |
| 7 | 凭据不外泄 | 单测:任何抛出的 message 与 details 都不含 apiKey;/trace/stream 抽查无 Authorization;websearch_providers_list 只回掩码 |
| 8 | 限额生效且原子 | 单测:daily_search_limit=N 时第 N+1 次 reserveSearch 回 false;并发调用不超发 |
| 9 | 未配 provider 时不注册 | 单测:tool_config 开着 web_search 但表空 → loadEnabledTools 丢弃并记日志,names 不含它 |
| 10 | 配置变更下一轮生效 | 单测:改 websearch 配置 → EnabledTools.fingerprint 变化(会话据此重建) |
| 11 | 右栏看得见搜索流程 | 130 实跑:Timeline 出现 tool_execution_update · web_search ×N;Lifecycle 的 tool_call / tool_execution / tool_result 三节点点亮;进度条数受 MAX_PROGRESS_EVENTS(30)封顶 |
| 12 | MCP 四个 tool 可用 | 130 上 websearch_provider_upsert → websearch_providers_list → websearch_set_default → websearch_provider_delete 全通,写操作进 mcp_audit |
| 13 | DeepSeek 与网关两条配置都跑得通 | 130 实跑:同一份代码分别配 api.deepseek.com 与自建网关,各搜一次都拿到带来源的答案 |
| 14 | 前端零改动 | git diff --stat apps/web/ 为空 |
| # | 级别 | findings | 核验 | 处理 |
|---|---|---|---|---|
| 1 | P1 | fetch 默认跟随重定向,而白名单只校验原始 URL —— 白名单内端点上的开放重定向可把请求送到白名单外/内网 | 属实,且比描述更严重:bun 实测同源重定向下 Authorization: Bearer … 原样跟过去(见下) | 采纳:redirect: "manual" + 3xx 单独判为 kind:"redirected"。判据写进 docs/security.md §1 外呼组约束 2 |
| 2 | P1 | systemPromptFor 把 web_search 混进「它们不能访问服务器或网络」那句;且 promptSnippet/promptGuidelines 走 systemPromptOverride 后送不到 | 属实。前半:提示词逐字在说这个联网工具不能联网。后半:pi 源码 resource-loader.js:383 是 override ? override(base) : base,我们的实现忽略入参 → base 里那两节丢失 | 采纳:提示词按两组分段;注入防御从 promptGuidelines 搬进 systemPromptFor,并在工具定义处留注释挡住「再加回去」 |
| 3 | P2 | 非流式 JSON 走 res.json(),整体缓冲、绕开 MAX_RESPONSE_BYTES | 属实,且同一问题还存在于错误体的 res.text()(codex 没提到那一处) | 采纳:抽 readTextCapped,流式 / 非流式 / 错误体三条路径共用同一个上界 |
三条都补了回归用例(整改后 dev.ps1 test 12 文件 / 260 项全过,较整改前 +7): redirect 模式与 3xx 拒绝 · 非流式 JSON 的 4 MiB 上界 · 非 JSON 非 SSE 回 upstream_failed · systemPromptFor 分组四条(含「教程库那一句的名字列表里不许出现 web_search」)。
这一轮 codex 的价值主要在第 2 条:那是个不会让任何东西报错的缺陷 —— 编译过、测试过、 搜索也偶尔能用,只是模型收到一条自相矛盾的高优先级指令,表现为「时灵时不灵」。 本机门禁与我自己的用例都照不到它,因为它根本不在代码的行为里,在提示词的语义里。
| # | 级别 | findings | 核验 | 处理 |
|---|---|---|---|---|
| 4 | P1 | abortCause 的比较过不了 tsc --noEmit(TS2367);「blocks encore check/the build」 | 前半属实:实测 tsc --noEmit 报两条 TS2367 —— TS 不追踪闭包里的赋值,按「初始 null + try 块里那次直接赋值」把变量窄化成 "oversize" | null。后半不成立:dev.ps1 check 与 dev.ps1 test 都是绿的,Encore 的构建也不跑 tsc | 采纳:改成「掐断时直接存要抛的 WebSearchError」,判据变成一次真假判断,不再有字符串比较;顺带去掉了那条永远走不到的 oversize 分支与重复的消息拼装。整改后 tsc --noEmit 干净 |
| 5 | P2 | readTextCapped 读缓冲响应体时不重置空闲计时器 —— 响应头很快、body 慢的上游会在持续有数据的情况下被空闲超时掐掉 | 属实,且是我上一轮引入 readTextCapped 时带进来的(此前 res.json() 同样不重置,只是没人看得见) | 采纳:readTextCapped(res, onChunk),两个调用点都传 resetIdle,与 SSE 路径同一套判据 |
两条都补了回归用例(dev.ps1 test 261 项全过):非流式响应分块慢送(每块 40ms、 空闲窗口 60ms)必须成功而不是被自己掐死;tsc --noEmit 纳入本轮验收 #1。
这一轮暴露了一个门禁的洞:dev.ps1 check 与 dev.ps1 test 都不跑全量 tsc。 不是构建阻塞(Encore 的构建也不跑),但类型错误只能靠 IDE 或人工发现。 补门禁属新增机制、不在缺陷整改范围,已记 rounds/BACKLOG.md 待所有者裁定; 本轮自己每次整改后手动跑 npx tsc --noEmit。
| # | 级别 | findings | 核验 | 处理 |
|---|---|---|---|---|
| 6 | P2 | SSE 超限分支只抛错、不再 abort 也不 cancel 已锁定的 reader,底层连接可能一直活着;重复触发会积累连接与缓冲 | 属实,而且是我上一轮自己引入的回归。删 ctrl.abort() 时我写的注释是「ctrl 随作用域结束」—— 那句话是错的:AbortController 被 GC 不会取消 fetch。readTextCapped 自己 finally 里有 reader.cancel(),唯独 SSE 这条路径没有 | 采纳,但不按 findings 的建议只补那一个分支:把 ctrl.abort() 收进 runWebSearch 的 finally。分支会长新的(将来多一种中途失败就多一处漏),而「离开函数 = 连接一定被放掉」是不会漏的结构性保证;成功路径上流已读完,那次 abort 是无害空操作 |
回归用例覆盖三条离开路径(中途抛 / 读完后抛 / 正常成功),判据是「传给 fetch 的 signal 最终是否 aborted」—— 真实 fetch 正是靠它断连接的。dev.ps1 test 262 项全过。
零 findings。 审查者原文:「The change correctly aborts the internal fetch controller in the function's finally block, covering success, upstream failure, timeout, cancellation, and oversize paths without altering the public result behavior. TypeScript compilation succeeds, and no additional blocking issues were identified.」
(第一次跑到一半被会话中断、无完成记录,重跑一次得到上述结论;两次都是 --base 4355cdf。)
第 3 轮的教训比 findings 本身重要:上一轮我为了修 P1 顺手删了一行 ctrl.abort(), 并给它编了一个听起来合理、实际错误的理由(「随作用域结束」)。整改动作本身也会引入缺陷, 这正是缺陷门禁要求「有采纳整改就必须再发一轮复审」的原因;把第 3 轮范围收到整改 diff 恰好让这类回归无处可藏。
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-websearch/BLOCKED.md,停下呼人。
fetch 的三种 redirect 模式在 bun 下的实际行为(本地起 http server 实测, 不是照着规范推的)——这条决定了修法选 manual 而不是 error:
选 manual:error 抛出的 TypeError 会掉进通用的 upstream_failed 分支, 「网关配了个重定向」于是看起来像一次普通的上游报错,排查时分不出来。
审查门禁关闭后,所有者裁定直接合并 main 并 push origin,不打包、不发 130 验证。 于是验收表里四条依赖 130 实跑的项本轮未执行,并入下一次预发升级(R11 之前的任一次升级都行):
| # | 未执行的检查 | 届时怎么验 |
|---|---|---|
| 2 | deploy/migrate.sh 在 130 上施加迁移 008 | ./migrate.sh --status 应显示 7 → 8;重跑为空操作 |
| 11 | 右栏看得见搜索流程 | Timeline 出现 tool_execution_update · web_search ×N;Lifecycle 三节点点亮 |
| 12 | MCP 四个 websearch_* tool 可用 | tools/list 从 24 变 28;upsert → list(只回掩码)→ set_default → delete 全通,mcp_audit 有记录 |
| 13 | DeepSeek 与自建网关两条配置都跑得通 | 同一份代码分别配 api.deepseek.com 与网关,各搜一次都拿到带来源的答案 |
为什么这条不是「验收放宽」:本机能验的都验了(域白名单 / 请求形状 / SSE 解析 / 双计时器 / 凭据不外泄 / 限额原子 / 注册闸 / 指纹 —— 全在 dev.ps1 test 里);剩下四条的本质是 「真实网关 + 真实 compose 拓扑」,它们在哪一次升级里验都是同一件事。web_search 的种子行 默认关、且没配 provider 就不注册,所以这份代码上到任何环境的默认行为与合并前一模一样 —— 延后验证不引入任何未验证的默认路径。
已经为下一次预发升级备好的东西(本轮实测,不用再摸):