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 排期、状态过期等),已登记待我同步文档。
260 lines
11 KiB
Go
260 lines
11 KiB
Go
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 的浏览器降级档" +
|
||
"会静默关死(挂载条件读的是注册面)")
|
||
}
|
||
}
|