Files
Cyrene_v2/cmd/cyrene/wire_body_fallback.go
T
AskaEth 378faf212e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 取正文能力 + web-fetch 浏览器降级档接线(解封 Z5/B5)
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 排期、状态过期等),已登记待我同步文档。
2026-10-07 09:59:56 +08:00

122 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// 本文件是 design/28 §1 M5 / §8 B5「浏览器渲染取正文」降级档的**组合根挂载点** ——
// 切片 Z5 把降级链做好了、但生产接不通(能力面没有取正文入口 ⇒ 没东西可注入 ⇒
// 这一档实际关着),本文件就是那次收口留下的缺口的解封。
//
// 六条口径:
//
// 1. **挂载条件 = 能力面里确实有可用浏览器**(注册面里那把 browser 工具的驱动
// `Available() == true`)。这条判据来自 design/28 §7 判据 1 的诚实口径
// 「都没有 ⇒ 工具不注册(不假装可用)」:拿不到来源就不该把一条注定走不通的档挂上。
// 2. **不新增开关**。设计**没有**为这条降级档定义配置项(§8 的配置面清单是「端点、
// 有头/无头、视口、超时、并发、截图上限」,§8 末的配置面约束又写死「`.env` 已冻结、
// 可调项只落配置文件 + 面板」)⇒ 自造一个 `bodyFallback: bool` 字段属于 AGENTS §1 的
// 「自行发挥」。挂载条件因此由能力面自身给出,而不是第二处开关。
// 3. **io 包的「默认关」口径没有被改写**:`iotools.WithBrowserFallback` 不传 / 传 nil
// 仍然是关(`plugins/tools/io` 的库默认)。本文件只把「确实有来源」的那一种情形
// 打开 —— 从「永远关」变成「没来源就关」,这正是 design/28 §2 把 M5 列进推荐组合、
// §8 把 B5 列为实施项的意思。
// 4. **每次调用现取管理器**:理由见 browserBodyRenderer 的文档(面板保存会换掉那一把
// 工具与它背后的管理器)。
// 5. **只读装配**:本文件不发请求、不起进程、不注册工具 —— `ioToolOptions()` 是只读
// 装配,探测的副作用归 `wireBrowser()`(`wire_browser_test.go` 钉住不许在这里探测)。
// 6. **失败如实**:挂不上只记一条**带来源**的 WARN(原因码 + 这一档对模型面的实际含义),
// 不阻断启动 —— 浏览器是可选增强(design/28 §8 逐片落地状态)。
import (
"context"
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// 挂载点的文案常量(进日志与结果,不美化)。
const (
// browserFallbackReasonNoSource 是「这一档没挂上」的原因码:能力面里没有可用浏览器。
// 分类原因码而不是「失败」两个字,照 web-search「拿不到后端就不注册」的口径。
browserFallbackReasonNoSource = "no-usable-browser"
// browserFallbackTrigger 写清这一档在什么条件下被走(进启动日志,免得「它到底管什么」
// 只能靠读代码)。
browserFallbackTrigger = "direct fetch failed or the page was judged dynamic"
// browserFallbackOffEffect 写清「没挂」对模型面的**实际**含义:不是说结果会假,
// 而是说这一档没有第二次机会(直连的真实遭遇仍然照实报出来)。
browserFallbackOffEffect = "web-fetch keeps the direct path only; the real outcome is still reported (dynamicSuspect / directError), there is just no browser second look"
)
// bodyFallbackOption 是 design/28 §8 B5 的**挂载点**,落地本文件顶部的六条口径。
//
// 两种去处都只有一个出口,口径不会在分支里漂移:
// - 没有可用浏览器 ⇒ 不挂(`WithBrowserFallback(nil)`)+ 一条带来源的 WARN,说明原因;
// - 有可用浏览器 ⇒ 挂上适配器,并记一条 INFO 说明这一档什么时候会被走。
func (a *app) bodyFallbackOption() iotools.Option {
log := logger.WithSource(a.log, ioLogSource)
mgr := browserManagerOf(a.caps)
if mgr == nil || !mgr.Available() {
log.Warn("web-fetch browser fallback not wired",
"reason", browserFallbackReasonNoSource,
"effect", browserFallbackOffEffect)
return iotools.WithBrowserFallback(nil)
}
log.Info("web-fetch browser fallback wired", "trigger", browserFallbackTrigger)
return iotools.WithBrowserFallback(&browserBodyRenderer{caps: a.caps})
}
// browserBodyRenderer 把浏览器能力面(`*browsercap.Manager.RenderText`)适配成 io 包的
// `iotools.BodyRenderer` —— 这是「进程内接口 + 依赖注入」的适配器,不引入第二个浏览器栈、
// 不新开任何监听端口(AGENTS §9)。
//
// **为什么每次调用现取管理器**:面板保存 browser 配置会重建那把工具并换掉它背后的管理器
// (`wire_browser_panel.go` 的热重载 + `ReplaceTool`),装配时抓住的旧指针会在一次保存之后
// 变成一具尸体 —— 那条降级档会静默退化成「每次都如实失败」,而这是最难查的一类失效。
// 从**注册面**取当前那一把,拿到的就是正在跑的那一个。
//
// 红线(与 `iotools.BodyRenderer` 的注释同源):只做对外连接;SSRF 判定由浏览器侧用
// **同一份** `io.ValidateRequestURL` 自己再过一遍(本适配器不替它免检,原始 URL 原样交过去);
// 正文在渲染完成后才取(由 `Manager.RenderText` 保证)。
//
// 空正文**不在这里**判:`RenderText` 的「渲染成功但正文为空」是读数不是错误,而 io 的
// 降级档有自己的口径(`ErrEmptyRenderedBody`)—— 本适配器原样交出去,不替上层下结论。
type browserBodyRenderer struct{ caps *registry.Registry }
// RenderBody 实现 iotools.BodyRenderer。
func (r *browserBodyRenderer) RenderBody(ctx context.Context, rawURL string) (iotools.RenderedBody, error) {
mgr := browserManagerOf(r.caps)
if mgr == nil || !mgr.Available() {
// 诚实降级:此时是「这一档没有来源」,**不是**「取到了空正文」。
return iotools.RenderedBody{}, errors.New("web-fetch browser fallback: no usable browser available")
}
rendered, err := mgr.RenderText(ctx, rawURL)
if err != nil {
return iotools.RenderedBody{}, fmt.Errorf("browser render: %w", err)
}
return iotools.RenderedBody{Text: rendered.Text, Source: rendered.Source}, nil
}
// browserManagerOf 从能力面取回**当前**的浏览器管理器;没有可用来源(没注册 / 注册的
// 不是这把工具 / 驱动不是本机的管理器,例如单测夹具)时返回 nil。
//
// 它**只读**注册面:不发请求、不起进程、不注册工具 —— 本文件被 `ioToolOptions()` 调用,
// 那是一条只读装配路径(口径 5)。
func browserManagerOf(caps *registry.Registry) *browser.Manager {
if caps == nil {
return nil
}
entry, err := caps.Get(browsertools.ToolName)
if err != nil {
return nil
}
tool, ok := entry.Value.(*browsertools.Tool)
if !ok {
return nil
}
return tool.Manager()
}