Files
Cyrene_v2/cmd/cyrene/wire_body_fallback_test.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

260 lines
11 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
// 本文件钉住切片 Z5 解封的那条接线(design/28 §1 M5 / §8 B5):把浏览器能力面的
// 「渲染取正文」适配成 web-fetch 的降级档,并在组合根决定**挂不挂**。
//
// 三件事必须有测试拦(它们删掉之后运行期都没有症状):
//
// 1. 能力面里**没有**可用浏览器 ⇒ 这一档**不挂**,且如实留一条带来源的原因;
// 2. 能力面里**有**可用浏览器 ⇒ 这一档真的被挂上、被调用(判据是浏览器那一档的
// 真实遭遇出现在返回体里,而不是「某个函数被调过」);
// 3. 适配器在「取正文时已经没有可用浏览器」时如实报错,**不返回空串冒充成功**。
//
// 另外两条是接线形状的钉子(照 `TestWireRuntime_CallsWireBrowser` 的先例):挂载点不能
// 从生产组装里消失,装配顺序不能把浏览器排在 io 工具集之后。
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser"
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
)
// webFetchToolName 是 web-fetch 在注册面里的名字(io 包里的字面量,这里按同一份契约引用)。
const webFetchToolName = "web-fetch"
// serveSkeletonPage 起一个「直连能成功、但只有前端框架挂载点」的页面:它是这条降级档的
// 入口条件之一(直连成功却 `dynamicSuspect`)。
func serveSkeletonPage(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><body><div id="app"></div><script src="/x.js"></script></body></html>`))
}))
t.Cleanup(srv.Close)
return srv
}
// endpointHost 是白名单条目的写法:host:port(判定源会剥端口比对)。
func endpointHost(srv *httptest.Server) string { return strings.TrimPrefix(srv.URL, "http://") }
// fallbackFetchTool 造一把**走挂载点**的 web-fetch:选项里带 `bodyFallbackOption()`,
// 测试服务放进 SSRF 白名单(否则直连那一档先被守卫拒掉,降级档根本不会被触发)。
//
// 选项走**生产路径**:`a.ioToolOptions()`(挂载点就在里面),而不是只把 `bodyFallbackOption()`
// 单独拿出来 —— 这样两个用例证明的就是「启动期真正交出去的那份选项」。
func fallbackFetchTool(t *testing.T, a *app, srv *httptest.Server) sdk.Tool {
t.Helper()
opts := append(a.ioToolOptions(),
iotools.WithAllowedHosts(endpointHost(srv)), iotools.WithHTTPClient(srv.Client()))
set, err := iotools.New(a.cfg, opts...)
if err != nil {
t.Fatalf("iotools.New: %v", err)
}
for _, tool := range set {
if tool.Name() == webFetchToolName {
return tool
}
}
t.Fatalf("工具集里没有 %s", webFetchToolName)
return nil
}
// execFetch 跑一次 web-fetch 并把它返回的 map 取出来。
func execFetch(t *testing.T, tool sdk.Tool, rawURL string) map[string]any {
t.Helper()
got, err := tool.Execute(context.Background(), map[string]any{"url": rawURL})
if err != nil {
t.Fatalf("web-fetch Execute: %v", err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("web-fetch 返回的不是对象: %#v", got)
}
return m
}
// newWiredBrowserManager 造一个**报告可用**的浏览器管理器:探测结论说「本机有 Chrome」,
// 但可执行文件故意不存在 —— `Available()` 为 true(于是降级档会被挂上),而任何一次真实
// 会话都会以 ErrLaunch 如实失败。
func newWiredBrowserManager(t *testing.T, cfg *config.Config, allowedHost string) *browsercap.Manager {
t.Helper()
mgr := browsercap.NewManager(browsercap.Options{
Availability: browsercfg.Availability{
Usable: true, Source: browsercfg.SourceLocalChrome,
Executable: "definitely-not-a-browser.exe", Detail: "假来源(本用例不真起浏览器)",
},
Config: browsercfg.Defaults(cfg.DataDir),
DataDir: cfg.DataDir,
AllowedHosts: []string{allowedHost},
})
t.Cleanup(func() { _ = mgr.Close() })
return mgr
}
// ① 没有可用浏览器 ⇒ 这一档不挂:直连的结论照实交出来,并如实说明「没有浏览器第二眼」;
// 日志里留下分类原因码与来源标签。
func TestBodyFallback_NoBrowserMeansTierNotWired(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out) // 注册面里没有 browser 工具 = 这台机器没有可用来源
srv := serveSkeletonPage(t)
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
if m["fetchedBy"] != iotools.FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q(浏览器没取到,来源仍是直连)", m["fetchedBy"], iotools.FetchedByDirect)
}
if m["dynamicSuspect"] != true {
t.Errorf("dynamicSuspect = %v,骨架页必须被判成可疑", m["dynamicSuspect"])
}
be, _ := m["browserError"].(string)
if !strings.Contains(be, "browser fallback disabled") {
t.Errorf("这一档没挂时必须如实说明(browserError = %q)", be)
}
if !strings.Contains(out.String(), "reason="+browserFallbackReasonNoSource) {
t.Errorf("日志里必须留下分类原因码 reason=%s(实得:%s)", browserFallbackReasonNoSource, out.String())
}
if !strings.Contains(out.String(), "source=cmd/cyrene/wire_io_tools") {
t.Errorf("这条日志必须带来源标签(AGENTS §10):%s", out.String())
}
}
// ② 有可用浏览器 ⇒ 这一档真的挂上并被调用:浏览器那一档的**真实遭遇**(这里是不存在的
// 可执行文件 ⇒ ErrLaunch)出现在返回体里,而不是被吞掉。
func TestBodyFallback_AvailableBrowserIsWiredAndHonest(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
srv := serveSkeletonPage(t)
if err := a.caps.RegisterTool(browsertools.New(newWiredBrowserManager(t, cfg, endpointHost(srv)))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
m := execFetch(t, fallbackFetchTool(t, a, srv), srv.URL)
be, _ := m["browserError"].(string)
if !strings.Contains(be, "启动本机浏览器失败") {
t.Errorf("浏览器这一档被挂上后就该被真调用,并把失败如实写进结果;browserError = %q", be)
}
if m["fetchedBy"] != iotools.FetchedByDirect {
t.Errorf("fetchedBy = %v, want %q(浏览器没取到正文)", m["fetchedBy"], iotools.FetchedByDirect)
}
if !strings.Contains(out.String(), "web-fetch browser fallback wired") {
t.Errorf("挂上时应有带来源的 INFO:%s", out.String())
}
}
// ③ 适配器在「取正文时已经没有可用浏览器」时如实报错,不返回空串冒充成功。
func TestBrowserBodyRenderer_NoUsableBrowserIsNotSilentSuccess(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
withUnavailable := registry.New()
if err := withUnavailable.RegisterTool(browsertools.New(
newUnavailableBrowserManager(t, cfg))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
cases := []struct {
name string
caps *registry.Registry
}{
{"注册面为 nil", nil},
{"注册面里没有 browser", registry.New()},
{"工具在但驱动不可用", withUnavailable},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := &browserBodyRenderer{caps: tc.caps}
got, err := r.RenderBody(context.Background(), "https://example.com/")
if err == nil {
t.Fatalf("没有可用浏览器时必须报错(实得 %+v)", got)
}
if !strings.Contains(err.Error(), "no usable browser") {
t.Errorf("err = %v,应说清是「这一档没有来源」", err)
}
if got.Text != "" || got.Source != "" {
t.Errorf("失败时不该交出半个结论: %+v", got)
}
})
}
}
// newUnavailableBrowserManager 造一个「探测不到来源」的管理器(Available() == false)。
func newUnavailableBrowserManager(t *testing.T, cfg *config.Config) *browsercap.Manager {
t.Helper()
mgr := browsercap.NewManager(browsercap.Options{
Availability: browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"},
Config: browsercfg.Defaults(cfg.DataDir),
DataDir: cfg.DataDir,
})
t.Cleanup(func() { _ = mgr.Close() })
return mgr
}
// ④ 适配器跟的是**注册面里的当前那一把**,不是装配时的那一个:面板保存 browser 配置会
// 用 `ReplaceTool` 换掉工具与它背后的管理器,抓着旧指针会让这条降级档在一次保存之后变成
// 一具尸体(而且只在「取正文时」才暴露)。
func TestBrowserBodyRenderer_FollowsRegistryReplacement(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t)
var out bytes.Buffer
a := browserTestApp(t, cfg, &out)
if err := a.caps.RegisterTool(browsertools.New(
newWiredBrowserManager(t, cfg, "example.com"))); err != nil {
t.Fatalf("注册 browser 工具: %v", err)
}
r := &browserBodyRenderer{caps: a.caps}
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
strings.Contains(err.Error(), "no usable browser") {
t.Fatalf("替换前应当用**可用的**那一把(真去渲染,因假 exe 而失败);实得 %v", err)
}
// 面板保存 → 换掉注册面里的那一把(新的管理器不可用)。
if err := a.caps.ReplaceTool(browsertools.New(newUnavailableBrowserManager(t, cfg))); err != nil {
t.Fatalf("替换 browser 工具: %v", err)
}
if _, err := r.RenderBody(context.Background(), "https://example.com/"); err == nil ||
!strings.Contains(err.Error(), "no usable browser") {
t.Fatalf("替换后适配器必须改口成「没有可用来源」,实得 %v", err)
}
}
// ④ 挂载点不能从生产组装里消失:`ioToolOptions()` 是启动期唯一把降级档交给 io 包的地方,
// 它被删掉之后编译照过、启动照起,只有模型那边少一次「浏览器第二眼」。
func TestIOToolOptions_IncludesBodyFallback(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_io_tools.go")
if !strings.Contains(src, "\t\ta.bodyFallbackOption(),") {
t.Fatal("wire_io_tools.go 里找不到 `a.bodyFallbackOption()` —— 浏览器降级档不会被挂上" +
"(J5 在 wire_body_fallback.go,挂载点必须留在生产组装里)")
}
}
// ⑤ 装配顺序:浏览器必须在 io 工具集**之前**装配。降级档的挂载条件读的就是注册面,
// 反过来写会让它永远读到「没有浏览器」⇒ 静默关死(没有任何运行期症状)。
func TestWireRuntime_BrowserWiredBeforeIoTools(t *testing.T) {
t.Parallel()
src := readSource(t, "wire_runtime.go")
browse := strings.Index(src, "\ta.wireBrowser()")
tools := strings.Index(src, "ioTools, ioErr := iotools.New(a.cfg, a.ioToolOptions()...")
if browse < 0 || tools < 0 {
t.Fatalf("wire_runtime.go 里找不到装配点(browser=%d, ioTools=%d)", browse, tools)
}
if browse > tools {
t.Fatal("wire_runtime.go 把浏览器装配排在了 io 工具集之后 —— web-fetch 的浏览器降级档" +
"会静默关死(挂载条件读的是注册面)")
}
}