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 排期、状态过期等),已登记待我同步文档。
188 lines
7.4 KiB
Go
188 lines
7.4 KiB
Go
package browser
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
|
||
)
|
||
|
||
// renderTestManager 造一个「导航目标已过白名单」的管理器:白名单里有 example.com,
|
||
// 于是 RenderText 的 SSRF 判定走白名单短路(不依赖外网 DNS,离线可跑)。
|
||
func renderTestManager(t *testing.T, avail browsercfg.Availability) *Manager {
|
||
t.Helper()
|
||
m := NewManager(Options{
|
||
Availability: avail,
|
||
Config: browsercfg.Defaults(t.TempDir()),
|
||
DataDir: t.TempDir(),
|
||
AllowedHosts: []string{"example.com"},
|
||
})
|
||
t.Cleanup(func() { _ = m.Close() })
|
||
return m
|
||
}
|
||
|
||
// okBrowserCDP 是「导航得通、正文取得回」的假 CDP:它按方法名应答,并把页面自报的
|
||
// state(url/title/readyState)与正文 text 分别交给 pageState 与 innerText 两次求值。
|
||
// 两个求值靠表达式区分(一个读 readyState,一个读 body.innerText)。
|
||
func okBrowserCDP(t *testing.T, text string) *fakeCDP {
|
||
t.Helper()
|
||
return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||
switch method {
|
||
case "Page.enable", "Page.navigate":
|
||
return map[string]any{}, nil
|
||
case "Page.getFrameTree":
|
||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||
case "Page.createIsolatedWorld":
|
||
return map[string]any{"executionContextId": 1}, nil
|
||
case "Runtime.evaluate":
|
||
expr, _ := params["expression"].(string)
|
||
if strings.Contains(expr, "readyState") {
|
||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||
"url": "https://example.com/final", "title": "示例页", "readyState": "complete",
|
||
}}}, nil
|
||
}
|
||
if !strings.Contains(expr, "innerText") {
|
||
return nil, &cdpError{Code: -32601, Message: "unexpected expression " + expr}
|
||
}
|
||
return map[string]any{"result": map[string]any{"value": text}}, nil
|
||
}
|
||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||
})
|
||
}
|
||
|
||
// cdpEndpointAvail 把假 CDP 的地址包装成「已配 CDP 端点」的探测结论(走 remoteCDP,
|
||
// 不起任何本机进程)。
|
||
func cdpEndpointAvail(f *fakeCDP) browsercfg.Availability {
|
||
return browsercfg.Availability{
|
||
Usable: true, Source: browsercfg.SourceCDP, Endpoint: f.base, Detail: "假 CDP 端点 " + f.base,
|
||
}
|
||
}
|
||
|
||
// ① 渲染成功 ⇒ 取回渲染后的可见正文(innerText,不是 HTML 源码),并如实带出来源与
|
||
// 页面自报的地址/标题。
|
||
func TestManager_RenderTextReturnsVisibleText(t *testing.T) {
|
||
t.Parallel()
|
||
f := okBrowserCDP(t, "渲染后的正文:Hello 世界")
|
||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||
|
||
got, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if err != nil {
|
||
t.Fatalf("RenderText: %v", err)
|
||
}
|
||
if got.Text != "渲染后的正文:Hello 世界" {
|
||
t.Errorf("Text = %q", got.Text)
|
||
}
|
||
if got.URL != "https://example.com/final" || got.Title != "示例页" {
|
||
t.Errorf("URL/Title 应取页面自报值,实得 %q / %q", got.URL, got.Title)
|
||
}
|
||
if !strings.Contains(got.Source, "假 CDP 端点") {
|
||
t.Errorf("Source = %q,必须如实标注来源", got.Source)
|
||
}
|
||
}
|
||
|
||
// ② 渲染**成功但正文为空** ⇒ 空串 + nil error:它是读数不是失败,由上层按自己的口径
|
||
// 判(io 的降级档把空正文按 ErrEmptyRenderedBody 处理)。这里钉住本层不许把它变成 error,
|
||
// 也不许拿空串冒充「取到了失败」。
|
||
func TestManager_RenderTextEmptyBodyIsNotAnError(t *testing.T) {
|
||
t.Parallel()
|
||
f := okBrowserCDP(t, " \n ")
|
||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||
|
||
got, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if err != nil {
|
||
t.Fatalf("正文为空不该是错误(它是读数),实得 %v", err)
|
||
}
|
||
if strings.TrimSpace(got.Text) != "" {
|
||
t.Errorf("Text = %q, want 空/空白", got.Text)
|
||
}
|
||
}
|
||
|
||
// ③ 导航失败(CDP 的 errorText)⇒ 如实报错,**不返回空串冒充成功**:判据是
|
||
// errors.Is(err, ErrNavigate) 且正文那一次求值根本没发生过。
|
||
func TestManager_RenderTextNavigateFailureIsHonestError(t *testing.T) {
|
||
t.Parallel()
|
||
f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) {
|
||
switch method {
|
||
case "Page.enable":
|
||
return map[string]any{}, nil
|
||
case "Page.navigate":
|
||
return map[string]any{"errorText": "net::ERR_NAME_NOT_RESOLVED"}, nil
|
||
}
|
||
return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method}
|
||
})
|
||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||
|
||
got, err := m.RenderText(context.Background(), "https://example.com/")
|
||
if err == nil {
|
||
t.Fatal("导航失败必须报错,不许返回空正文冒充成功")
|
||
}
|
||
if !errors.Is(err, ErrNavigate) {
|
||
t.Errorf("err = %v, want ErrNavigate", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "ERR_NAME_NOT_RESOLVED") {
|
||
t.Errorf("错误里应带上 CDP 的原始原因: %v", err)
|
||
}
|
||
if got.Text != "" || got.Source != "" {
|
||
t.Errorf("失败时不该交出半个结论: %+v", got)
|
||
}
|
||
}
|
||
|
||
// ④ 页面求值异常(正文那一次)⇒ 归类为 ErrEvaluate,同样不许冒充「取到了空正文」。
|
||
func TestManager_RenderTextEvaluateFailureIsHonestError(t *testing.T) {
|
||
t.Parallel()
|
||
f := newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) {
|
||
switch method {
|
||
case "Page.enable", "Page.navigate":
|
||
return map[string]any{}, nil
|
||
case "Page.getFrameTree":
|
||
return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil
|
||
case "Page.createIsolatedWorld":
|
||
return map[string]any{"executionContextId": 1}, nil
|
||
case "Runtime.evaluate":
|
||
expr, _ := params["expression"].(string)
|
||
if strings.Contains(expr, "readyState") {
|
||
return map[string]any{"result": map[string]any{"value": map[string]any{
|
||
"url": "https://example.com/", "title": "t", "readyState": "complete",
|
||
}}}, nil
|
||
}
|
||
return map[string]any{"exceptionDetails": map[string]any{"text": "TypeError: body is gone"}}, nil
|
||
}
|
||
return nil, &cdpError{Code: -32601, Message: "unexpected " + method}
|
||
})
|
||
m := renderTestManager(t, cdpEndpointAvail(f))
|
||
|
||
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrEvaluate) {
|
||
t.Fatalf("err = %v, want ErrEvaluate", err)
|
||
}
|
||
}
|
||
|
||
// ⑤ SSRF 守卫在**起浏览器之前**跑:云元数据地址被拒,且没有任何 CDP 请求发生
|
||
// (假 CDP 只被 /json/version 碰过的话就不是这条路径 —— 这里直接用不可达的可执行文件,
|
||
// 若顺序颠倒会拿到启动错误而不是守卫错误)。
|
||
func TestManager_RenderTextBlockedURLRejectedBeforeAnyProcess(t *testing.T) {
|
||
t.Parallel()
|
||
m := renderTestManager(t, browsercfg.Availability{
|
||
Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe",
|
||
})
|
||
|
||
_, err := m.RenderText(context.Background(), "http://169.254.169.254/latest/meta-data/")
|
||
if !errors.Is(err, ErrBlockedURL) {
|
||
t.Fatalf("err = %v, want ErrBlockedURL", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "安全守卫") {
|
||
t.Errorf("错误应来自 URL 守卫: %v", err)
|
||
}
|
||
}
|
||
|
||
// ⑥ 没有可用来源时不假装可用:ErrUnavailable(组合根据此**不挂**这条降级档)。
|
||
func TestManager_RenderTextUnavailableDoesNotPretend(t *testing.T) {
|
||
t.Parallel()
|
||
m := renderTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"})
|
||
|
||
if _, err := m.RenderText(context.Background(), "https://example.com/"); !errors.Is(err, ErrUnavailable) {
|
||
t.Fatalf("err = %v, want ErrUnavailable", err)
|
||
}
|
||
}
|