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 排期、状态过期等),已登记待我同步文档。
122 lines
6.8 KiB
Go
122 lines
6.8 KiB
Go
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()
|
||
}
|