Files
Cyrene_v2/cmd/cyrene/wire_body_fallback.go
T
AskaEth 93c5a4c46f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(browser): 启动失败要说出原因;预算耗尽 ≠ 浏览器坏了
真机现象:主人看到 Chrome 窗口内有横幅(他读作「不支持接受的参数」),日志只有
一句「等待调试端口时被取消: context deadline exceeded」,看不出是谁的错。

三件(本片):
- 接住子进程 stdout/stderr(有界:前 4 KiB / 上报 12 行,只缓存不输出)⇒ 失败时
  打一条带 exe / argv / chrome_output / chrome_output_bytes 的 WARN;成功只记字节数
  (不刷屏)。
- 三种启动失败在错误里分开:① 进程立刻退出(profile 被占用)② 等端口超时
  ③ 调用方预算耗尽(ctx 已结束 ⇒ **根本不 spawn**)。③ 与 ② 用两个截止时间比大小
  区分 —— 顺带修掉一个老缺陷:原来 ctx 一结束就报「等待调试端口时被取消」,而那
  几乎总是我们自己的 30s 用满,现被正确归到 ②。
- 降级档不再被**已耗尽**的轮预算连坐:如实报「预算耗尽 ⇒ 未尝试浏览器渲染,
  这不是浏览器故障」,**不许把"没试"说成"试了失败"**。plugins/tools/io/bodyfetch.go
  一字未动(fetchedBy / browserError 语义原样),只在**发起方**决定要不要发起。

对主人那条横幅的结论(有据,非推测):那是 Chrome 的 **bad-flags 警告栏**,触发者
是 `--disable-blink-features=AutomationControlled` —— 它明确在 Chromium 的 kBadFlags
名单里(bad_flags_prompt.cc 列了 kDisableBlinkFeatures/kEnableBlinkFeatures),本机
Chrome 154 zh-CN 的文案是「您使用的是不受支持的命令行标记:$1。稳定性和安全性会
有所下降。」;而「不支持接受的参数」在 Chrome 的 zh-CN/en-US 资源与二进制里**零命中**
⇒ 是意译。⚠️ 该参数是 design/28 §2 要求的(掩盖 navigator.webdriver)⇒ 横幅是
**设计取舍的视觉副作用**,去掉它才消失(但会暴露自动化特征)。登记待主人裁决。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;browser 与 cmd/cyrene
两包测试绿;我用 -overlay 去掉 cmd.Stderr 接线 ⇒ TestDial_ReportsChromeOutputAndArgvOnLaunchFailure
变红(chrome_output="" chrome_output_bytes=0)。

⚠️ 未部署:跑着的产物不含本片,要等下次 build + restart。
2026-10-07 10:37:33 +08:00

160 lines
9.5 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"
"log/slog"
"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, log: log})
}
// browserFallbackReasonBudget 是「这一档**没有发起**」的原因码:调用方这一轮的预算已经
// 耗尽。它与 `no-usable-browser` 是两件事(一个是「没时间了」,一个是「没有浏览器」),
// 混成一句会把人带偏(真机就出现过:窗口上是浏览器的抱怨,日志里像浏览器坏了)。
const browserFallbackReasonBudget = "budget-exhausted"
// browserFallbackBudgetNote 写清这一刻的**实际**含义:浏览器没坏、这一档没被尝试。
// 它进错误文本(顺 web-fetch 的 browserError 面出来),因此必须一眼可读、不含猜测。
const browserFallbackBudgetNote = "本轮工具预算已耗尽(调用方上下文已结束)⇒ 未尝试浏览器渲染,这不是浏览器故障"
// 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
// log 是挂载点的日志(已带来源)。可为 nil(单测直接构造):那就不记,不做替代语义。
log *slog.Logger
}
// RenderBody 实现 iotools.BodyRenderer。
//
// **预算口径**(design/28 §1 M5「降级档」+ §7 判据 4「如实标注」+ §6 坑 5「失败要如实报」,
// 设计没有为这一档规定独立预算 ⇒ 取最小的诚实口径):ctx 已经结束(这一轮的工具预算耗尽 /
// 会话被取消)时**不发起**,如实报「预算耗尽 ⇒ 未尝试」,而**不是**报一个看起来像
// 「浏览器坏了」的错误。三条理由:
//
// - 这一档要冷启动真浏览器(秒级),启动所需的预算此刻已经不存在;
// - 工具派发层在 ctx 结束时就要把结果丢掉(`internal/runtime/tool` 的 callTool 在
// ctx.Done 与结果之间二选一,且 ctx 结束时返回 ctx.Err())⇒ 硬起一个浏览器只会留下
// 一个被立刻杀掉的窗口,**结果没人读**(真机踩过:窗口上跳出浏览器的抱怨);
// - 没试过就不能报成「试了失败」:`fetchedBy` / `browserError` 的语义(见
// `plugins/tools/io/bodyfetch.go`)一字不动,本适配器只交「没发起」这个事实。
//
// ctx 还活着时**不动**它的预算(不另造一个第二预算):浏览器会话可能是热的(同一次
// 会话里刚渲染过),渲染只要一两秒;真有取消,浏览器侧会把它如实分类成「预算耗尽」
// (见 `internal/capability/browser` 的分类标记),不会再冒充「浏览器坏了」。
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")
}
if err := ctx.Err(); err != nil {
if r.log != nil {
r.log.Info("web-fetch browser fallback not attempted",
"reason", browserFallbackReasonBudget, "error", err)
}
return iotools.RenderedBody{}, fmt.Errorf("web-fetch browser fallback: %s: %s: %w",
browserFallbackReasonBudget, browserFallbackBudgetNote, err)
}
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()
}