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

+83
View File
@@ -0,0 +1,83 @@
package browser
import (
"context"
"unicode/utf8"
)
// bodyTextExpr 取**渲染完成后**页面的可见正文:`document.body.innerText`。
//
// 为什么不是 `documentElement.innerHTML` / `outerHTML`(design/28 §6 坑 4):那是
// **源码**不是正文 —— 动态页的源码里只有骨架与脚本,模型拿到等于没拿到。innerText
// 是浏览器按布局算出来的可见文本(display:none 的内容不算),这才是「渲染后」的读数。
//
// body 可能为 null(about:blank / 空白文档):那种情况如实交空串,由**上层**判空,
// 本层不把它当错误(见 RenderText 的口径)。
const bodyTextExpr = `(() => { const b = document.body; return b ? b.innerText : ""; })()`
// RenderedText 是一次「渲染取正文」的结论。
type RenderedText struct {
// Text 是渲染后页面的可见正文(纯文本,已去标签)。
// **空串是合法读数**:它表示「渲染走通了,但这个页面确实没有可见正文」,不是失败
// —— 是不是成功由上层按它的口径判(io 的降级档把空正文按 `ErrEmptyRenderedBody` 处理)。
Text string `json:"text"`
// Source 是会话来源描述(本机有头 / 已连的 CDP 端点),如实标注、不含凭据。
Source string `json:"source"`
// URL / Title 是页面**自报**的最终地址与标题(可能因重定向与请求的不同)。
URL string `json:"url"`
Title string `json:"title"`
}
// RenderText 导航到 rawURL,等页面就绪后取回可见正文(design/28 §1 M5 / §8 B5 的
// 「浏览器渲染取正文」能力面;消费方是 web-fetch 的降级档,见 plugins/tools/io)。
//
// 三条口径,与 io.BodyRenderer 的红线一一对应:
//
// - **过同一份 SSRF 判定**:走既有的 `GuardURL`(它就是 `io.ValidateRequestURL` 的
// 门面,design/28 §4-1:判定源只有一份),与本包 Open 同一条闸;
// - **不新造第二套 CDP 客户端**:与 Open / Screenshot / Fingerprint 同一条路线
// (`withSession` → `session` → 隔离世界求值,不发 `Runtime.enable`);
// - **渲染完成后才取正文**:等待条件的显式落点是 `session.navigate` → `waitReady`
// (轮询 `document.readyState` 直到 `complete`,见 session.go);就绪之后才求
// `body.innerText`。
//
// 「渲染失败」与「渲染成功但正文为空」在返回值上是**两件事**:前者是 error
// (`ErrBlockedURL` / `ErrUnavailable` / `ErrNavigate` / `ErrEvaluate` / `ErrCDP` 分类),
// 后者是 `Text == ""` 且 `err == nil`。既不把空正文当失败,也不拿空文本冒充成功。
func (m *Manager) RenderText(ctx context.Context, rawURL string) (RenderedText, error) {
if _, err := GuardURL(ctx, rawURL, m.allowed); err != nil {
m.log.Warn("browser text render blocked by URL guard", "reason", ReasonBlockedURL, "error", err)
return RenderedText{}, err
}
out := RenderedText{Source: m.Source()}
err := m.withSession(ctx, func(s *session) error {
state, err := s.navigate(ctx, rawURL)
if err != nil {
return err
}
text, err := s.bodyText(ctx)
if err != nil {
return err
}
out.URL, out.Title, out.Text = state.URL, state.Title, text
return nil
})
if err != nil {
return RenderedText{}, err
}
// 只记来源与规模,**不记 URL / 标题 / 正文**:页面内容可能含凭据,日志不该成为
// 它的第二个副本(AGENTS §10)。没有字符数是「渲染成功但正文为空」唯一的排查线索,
// 所以留着它(0 = 空正文,不是错误)。
m.log.Debug("browser text rendered", "source", out.Source, "chars", utf8.RuneCountInString(out.Text))
return out, nil
}
// bodyText 在**隔离世界**里取一次 `document.body.innerText`(见 bodyTextExpr)。
// 它是 session 的一次普通求值 —— 复用既有的上下文与按值取回路径,不另开会话。
func (s *session) bodyText(ctx context.Context) (string, error) {
var text string
if err := s.evaluate(ctx, bodyTextExpr, &text); err != nil {
return "", err
}
return text, nil
}
+187
View File
@@ -0,0 +1,187 @@
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)
}
}