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

+259
View File
@@ -0,0 +1,259 @@
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 的浏览器降级档" +
"会静默关死(挂载条件读的是注册面)")
}
}