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,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user