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 排期、状态过期等),已登记待我同步文档。
This commit is contained in:
1 parent
25aa50e532
commit
378faf212e
7 files changed
+685
-31
No files matched your search
@@ -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 的浏览器降级档" +
|
||||
"会静默关死(挂载条件读的是注册面)")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user