feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

Z5(c2e413e)把 web-fetch 的降级链与注入点做完了,但「取正文」入口缺失 ⇒
组合根拿不到 BodyRenderer、这一档实际关着(当时如实登记)。本片补上:

- internal/capability/browser/render.go:新增 Manager.RenderText(ctx,url)
  (导出的进程内能力)—— 先过 GuardURL(与全仓同一份 SSRF 判定源)、复用
  withSession/session、等 readyState=complete 之后才取 body.innerText;
  渲染失败 = 分类错误,渲染成功但正文为空 = 空串+无错(不冒充成功/失败)。
- cmd/cyrene/wire_body_fallback.go:适配器 + 挂载点,把 Manager 适配成
  io.BodyRenderer 并用 WithBrowserFallback 挂上;web_runtime 里把浏览器
  接线**上移到 io 工具集之前**(顺序颠倒会静默关死这一档)。
- plugins/tools/browser/tool.go:新增 Tool.Manager(),供组合根取驱动句柄。

开关口径(裁决,依据 design/28 §1 M5 / §7 判据 4 / §8 B5):**有可用浏览器
就挂,没有就不挂**,不新增开关 ——
① 设计把 M5/B5 写成要落地生效的降级档,不是永远关着的空壳;
② 设计 §8 的可调项清单里没有这条降级档的开关,`.env` 已冻结 ⇒ 自造开关属
   「自行发挥」;
③ io 库层「不传 = 默认关」的语义一字未改,本片只把组合根从「永远关」改成
   「没来源就关」,并留带来源的 WARN(reason=no-usable-browser)。
⚠️ 若日后要「有浏览器也需显式开启」,得先给设计定开关落点,代码侧一行改回。

验收(主会话复跑 + 亲自变异):go build ./... + 三包 vet 绿;browser/io 两包
测试绿(cmd/cyrene 由子代理跑过全绿);我用 -overlay 把 GuardURL 的判定对象
换成常量 URL(绕过被拒目标)⇒ TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess
变红,证明判定确实**在起任何进程之前**跑。

⚠️ 生产端到端未验:按红线没重启后端、没起真 Chrome ⇒「真页面 → browser 取到
正文」这条链只有假 CDP + 组合根接线级证据。
⚠️ design/28 有 6 处表述与代码现状冲突(归属 web-fetch vs web-search、extract
动作归属、B5 排期、状态过期等),已登记待我同步文档。
This commit is contained in:
AskaEth committed 2026-10-07 09:59:56 +08:00
1 parent 25aa50e532
commit 378faf212e
7 files changed
+685 -31

No files matched your search

+5 -24
View File
@@ -2,7 +2,8 @@ package main
// 本文件装配 io 工具集(plugins/tools/io)的选项:知识检索器、日志、
// **web-search 的后端与超时**(切片 WS1-A 接线 + WS1-B provider 层 + PLG-4 可变源),
// 以及 design/28 §8 B5 的**浏览器降级档开关**(`bodyFallbackOption`,当前 = 关)。
// 以及 design/28 §8 B5 的**浏览器降级档挂载**(`bodyFallbackOption()`,实现与口径在
// `wire_body_fallback.go` —— 挂载条件 = 能力面里确实有可用浏览器,详见那个文件)。
//
// 为什么单独成文件而不是塞在 wire_runtime.go 里:那条装配线本就接近文件行数上限,
// 而这里新增的裁决(端点/provider 关闭 ⇒ 工具不进注册面;选定 provider 未登记/未配置
@@ -91,34 +92,14 @@ func (a *app) ioToolOptions() []iotools.Option {
opts := []iotools.Option{
iotools.WithKnowledgeRetriever(a.retriever),
iotools.WithLogger(a.log),
// B5 的降级档开关(当前 = **关**,理由见 bodyFallbackOption 的文档)。
// B5 的挂载点:有可用浏览器就挂上浏览器渲染取正文,没有就不挂(口径与实现
// 都在 wire_body_fallback.go)。挂载条件必须在**浏览器装配之后**求值 ——
// 这就是 wireRuntime 里 `a.wireBrowser()` 排在 io 工具集之前的原因。
a.bodyFallbackOption(),
}
return append(opts, a.webSearchOptions(webSearchCauseStartup)...)
}
// bodyFallbackOption 是 design/28 §8 B5 的**开关点**:web-fetch 取正文时,直连失败 /
// 判定为动态页要不要改走**浏览器渲染**(`iotools.WithBrowserFallback`,默认关)。
//
// **当前结论 = 关**(本函数返回的就是「不注入渲染器」)。理由在能力面上,不在这个开关上:
// `internal/capability/browser` 目前只导出 `Open`(导航,回报 url/title/readyState)、
// `Screenshot`、`Fingerprint` —— **没有**任何「取页面正文」的入口,`plugins/tools/browser`
// 的工具动作也只有 `open`/`screenshot`/`info`。没有渲染器可注入,这一档打开也只可能
// 如实失败 —— 那不是「降级档」,是给每次取正文加一条注定失败的噪声。
//
// 打开它要等能力面先补一个正文抽取入口(形如
// `Manager.RenderText(ctx, url) (RenderedText, error)`,内部走既有的隔离世界求值 +
// 同一份 `io.ValidateRequestURL` 判定源),届时把这里换成注入该适配器即可(一行)。
// 在此之前**不假装可用** —— 与 web-search「拿不到后端就不注册」同一口径
// (design/28 §7 判据 1、§7 判据 4 的诚实标注)。
//
// 为什么不在这里现场探测浏览器:`ioToolOptions()` 是**只读装配**,探测的副作用
// (起进程 / 连 CDP 端点)不属于「取一份 io 选项」这件事 —— 口径见 `wire_browser.go`
// 顶部第 3 条,且 `wire_browser_test.go` 钉住它不许在这里探测。
func (a *app) bodyFallbackOption() iotools.Option {
return iotools.WithBrowserFallback(nil)
}
// buildWebSearchTool 按**当前生效配置**构造这一把 web-search 工具(面板保存后的热重载用它
// 重建工具,见 websearch_reload.go)。
//