From f1fbc249fd8f862b5c40fe05be44f8f9c4b536c8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 20:25:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20B1=20=E6=8E=A5=E7=BA=BF=20+=20?= =?UTF-8?q?B2=20=E8=96=84=E5=B1=82=E5=B7=A5=E5=85=B7=E9=9D=A2=EF=BC=88V5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机 Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志) - 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+ 导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only) - 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效) - 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**; 但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口 ⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用 pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链 --- cmd/cyrene/wire_browser.go | 137 ++++++++ cmd/cyrene/wire_browser_e2e_test.go | 147 ++++++++ cmd/cyrene/wire_browser_test.go | 225 ++++++++++++ cmd/cyrene/wire_io_tools.go | 7 + internal/capability/browser/cdp.go | 257 ++++++++++++++ internal/capability/browser/cdp_test.go | 188 ++++++++++ internal/capability/browser/compare_test.go | 365 ++++++++++++++++++++ internal/capability/browser/doc.go | 39 +++ internal/capability/browser/errors.go | 81 +++++ internal/capability/browser/guard.go | 106 ++++++ internal/capability/browser/guard_test.go | 90 +++++ internal/capability/browser/info.go | 60 ++++ internal/capability/browser/launch.go | 270 +++++++++++++++ internal/capability/browser/launch_test.go | 195 +++++++++++ internal/capability/browser/manager.go | 358 +++++++++++++++++++ internal/capability/browser/manager_test.go | 179 ++++++++++ internal/capability/browser/session.go | 276 +++++++++++++++ internal/capability/browser/shot.go | 98 ++++++ internal/capability/browser/shot_test.go | 88 +++++ plugins/tools/browser/tool.go | 219 ++++++++++++ plugins/tools/browser/tool_test.go | 194 +++++++++++ 21 files changed, 3579 insertions(+) create mode 100644 cmd/cyrene/wire_browser.go create mode 100644 cmd/cyrene/wire_browser_e2e_test.go create mode 100644 cmd/cyrene/wire_browser_test.go create mode 100644 internal/capability/browser/cdp.go create mode 100644 internal/capability/browser/cdp_test.go create mode 100644 internal/capability/browser/compare_test.go create mode 100644 internal/capability/browser/doc.go create mode 100644 internal/capability/browser/errors.go create mode 100644 internal/capability/browser/guard.go create mode 100644 internal/capability/browser/guard_test.go create mode 100644 internal/capability/browser/info.go create mode 100644 internal/capability/browser/launch.go create mode 100644 internal/capability/browser/launch_test.go create mode 100644 internal/capability/browser/manager.go create mode 100644 internal/capability/browser/manager_test.go create mode 100644 internal/capability/browser/session.go create mode 100644 internal/capability/browser/shot.go create mode 100644 internal/capability/browser/shot_test.go create mode 100644 plugins/tools/browser/tool.go create mode 100644 plugins/tools/browser/tool_test.go diff --git a/cmd/cyrene/wire_browser.go b/cmd/cyrene/wire_browser.go new file mode 100644 index 0000000..110b8be --- /dev/null +++ b/cmd/cyrene/wire_browser.go @@ -0,0 +1,137 @@ +package main + +// 本文件是 design/28(浏览器与页面截图)B1→B2 的**组合根接线**:把纯库 +// `internal/capability/browsercfg`(配置读写 + 可用性探测)接进能力面,并在可用时 +// 注册 `browser` 工具(`plugins/tools/browser` 的薄层)。 +// +// 六条口径: +// +// 1. **配置只从文件来**:`$DATA_DIR/config/plugins/browser.json`(design/13 §2.1 的 +// 插件配置落点、design/28 §1 M2)。**不读任何新增 env 键** —— `.env` 已冻结为 +// 「最小化功能可跑」的引导面(AGENTS §10);本文件通篇没有 `os.Getenv`。 +// 2. **文件缺失 = 交给自动探测**(`browsercfg.Resolve(nil, …)` 走「本机 +// Chrome/Chromium → 已在跑的 CDP 端点」);**文件非法 = 不注册**(如实报错, +// 不静默拿默认值顶替 —— 那会让「面板里那份坏配置」永远看不见)。 +// 3. **不可用就不注册**(照 web-search `off` 的诚实口径,design/28 §7 判据 1): +// 模型根本看不见这个工具,而不是看得见、一调就报错。原因**分类**打进日志 +// (`reason=no_source` / `executable_missing` / `cdp_unreachable`),不糊成 +// 「失败」两个字。 +// 4. **profile 目录含凭据**:注册之前先过 `EnsureProfileDir`(0700 + 不得位于 git +// 工作树内,design/28 §2 三件套之 2、§4);不过关就不注册。 +// 5. **两条失败都不阻塞启动**(与 wireVision / wireTranscode 同族):浏览器是**可选 +// 增强**,磁盘上没有 Chrome 的机器照样得把整个进程拉起来。 +// 6. **幂等**:重复装配只发现工具已在注册表里并跳过,不重复注册、不重复探测。 +// +// 关于挂点:组合根在启动期只调用 `ioToolOptions()`(wire_runtime.go)这一个归属本片 +// 的装配函数,而本片不得改他人拥有的 `wire_runtime.go` / `wire.go` +// ⇒ browser 的接线由 `wire_io_tools.go` 的 `ioToolOptions()` 转交给本文件的 +// `wireBrowser()`(那一处有同样的说明)。 + +import ( + "context" + "log/slog" + "time" + + 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/logger" + browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" +) + +const ( + // browserLogSource 是本文件日志的来源标签(AGENTS §10:禁止无来源裸日志)。 + browserLogSource = "cmd/cyrene/wire_browser" + + // browserProbeTimeout 约束可用性探测。探测问的是「有没有在听」(本机可执行文件 + // 是否存在、CDP 端点是否回话),不是「页面能不能加载」,所以给得短:最坏情况 + // 也只是启动路径上多等 3 秒,而不是拖住整个组合根。 + browserProbeTimeout = 3 * time.Second + + // 生效配置的来源标签(只进日志):一眼看出本次结论是「面板文件说的」还是 + // 「没有配置文件、自动探测出来的」。 + browserConfigSourceFile = "panel-file" + browserConfigSourceProbe = "probe" + + // 不注册工具时的原因码(进日志的 reason 字段,与 browsercfg.Reason 同风格)。 + browserReasonInvalidConfig = "invalid-config" + browserReasonProfileRejected = "profile-dir-rejected" + + // browserNotRegisteredEffect 是「不注册」这件事对模型面**实际**意味着什么: + // 写死在日志里,免得读日志的人以为只是「降级运行」。 + browserNotRegisteredEffect = "the model will not see a browser tool at all" +) + +// wireBrowser 装配 `browser` 工具。它没有错误返回值:一切失败都是**如实降级** +// (记分类原因 + 不注册),绝不阻塞启动。 +func (a *app) wireBrowser() { + log := logger.WithSource(a.log, browserLogSource) + if a.caps == nil || a.cfg == nil { + // 部分测试夹具只填了 app 的几个字段;没有注册面就没有「注册」这件事。 + return + } + if _, err := a.caps.Get(browsertools.ToolName); err == nil { + return // 同一 app 上重复装配:已经注册过,不重复探测 + } + + dataDir := resolveDataDir(a.cfg) + configPath := browsercfg.DefaultConfigPath(dataDir) + fileCfg, err := browsercfg.Load(configPath, dataDir) + if err != nil { + log.Warn("browser tool not registered", + "reason", browserReasonInvalidConfig, "configPath", configPath, "error", err, + "effect", browserNotRegisteredEffect) + return + } + effective := browsercfg.Defaults(dataDir) + configSource := browserConfigSourceProbe + if fileCfg != nil { + effective = *fileCfg + configSource = browserConfigSourceFile + } + if !a.registerBrowserTool(log, fileCfg, effective, configPath, configSource, dataDir) { + return + } +} + +// registerBrowserTool 做「探测 → 建管理器 → 注册」这三步,返回是否真的注册了。 +// 抽成独立函数是为了让每一处「不注册」都**只**有一个出口(口径不会在两条分支里漂移)。 +func (a *app) registerBrowserTool( + log *slog.Logger, fileCfg *browsercfg.Config, effective browsercfg.Config, + configPath, configSource, dataDir string, +) bool { + ctx, cancel := context.WithTimeout(context.Background(), browserProbeTimeout) + avail := browsercfg.Resolve(ctx, fileCfg, browsercfg.NewProber()) + cancel() + if !avail.Usable { + log.Warn("browser tool not registered", + "reason", string(avail.Reason), "detail", avail.Detail, + "configPath", configPath, "configSource", configSource, + "effect", browserNotRegisteredEffect) + return false + } + // profile 含 cookie / 登录态:0700,且不得落在 git 工作树内(会被 git add 收走)。 + if err := browsercfg.EnsureProfileDir(effective.ProfileDir, dataDir); err != nil { + log.Warn("browser tool not registered", + "reason", browserReasonProfileRejected, "profileDir", effective.ProfileDir, + "error", err, "effect", browserNotRegisteredEffect) + return false + } + + mgr := browsercap.NewManager(browsercap.Options{ + Availability: avail, + Config: effective, + DataDir: dataDir, + Logger: a.log, + }) + tool := browsertools.New(mgr, browsertools.WithLogger(a.log)) + if err := a.caps.RegisterTool(tool); err != nil { + log.Warn("browser tool register skipped", "tool", browsertools.ToolName, "error", err) + return false + } + log.Info("browser tool registered", + "source", string(avail.Source), "headless", avail.Headless, + "configSource", configSource, "configPath", configPath, + "shotsDir", browsercfg.DefaultShotsDir(dataDir), + "richActions", "click/type/wait/extract 由 MCP(Playwright/Puppeteer server)提供,本工具不重造") + return true +} diff --git a/cmd/cyrene/wire_browser_e2e_test.go b/cmd/cyrene/wire_browser_e2e_test.go new file mode 100644 index 0000000..0c7e4d2 --- /dev/null +++ b/cmd/cyrene/wire_browser_e2e_test.go @@ -0,0 +1,147 @@ +package main + +// 本文件是 `browser` 接线的**真机端到端**(默认不跑):从浏览器配置 → 组合根接线 → +// 注册面 → 模型会调用的那把工具 → 真 Chrome → 截图落盘,全程不假任何一层。 +// +// 用法(Windows / Git Bash): +// +// CYRENE_BROWSER_E2E=1 go test ./cmd/cyrene/ -run TestBrowserTool_RealHostEndToEnd -v -timeout 5m +// +// 为什么默认 Skip:它会**真的起一个浏览器**、并且要打一次外网(本机没有 Chrome 时 +// 自动 Skip)。这类验证属于「人工按需复跑」,不该混进普通 `go test`(AGENTS §10 +// 的压测纪律:不在验收里无界占机)。 +// +// 为什么用**无头**:这是自动化验证,不该在主人的桌面上弹一个窗口;有头路径的 +// 真实验证走 `internal/capability/browser/compare_test.go` 的对照脚本(它默认无头/有头各跑一遍)。 + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + 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/pkg/plugins/sdk" +) + +// browserE2EEnabled 报告是否显式开启了真机 e2e。 +func browserE2EEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("CYRENE_BROWSER_E2E"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +// TestBrowserTool_RealHostEndToEnd 走完整条链并断言**页面自报**的事实。 +func TestBrowserTool_RealHostEndToEnd(t *testing.T) { + if !browserE2EEnabled() { + t.Skip("未设 CYRENE_BROWSER_E2E=1:真机端到端默认不跑(用法见本文件顶部)") + } + exe, ok := browsercfg.NewProber().FindLocalBrowser() + if !ok { + t.Skip("本机没有 Chrome/Chromium,真机 e2e 无法进行") + } + cfg := wsLoadConfig(t) + raw, _ := json.Marshal(map[string]any{ + "executable": exe, + "headless": true, + "timeoutSeconds": 60, + }) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + cap, ok := browserEntry(a) + if !ok { + t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) + } + tool, ok := cap.Value.(sdk.Tool) + if !ok { + t.Fatalf("注册的值不是 sdk.Tool: %T", cap.Value) + } + // 收尾必须真的把浏览器关掉:用例结束会删临时目录(含 profile),Chrome 还在跑就会被 + // 占用而删不掉。这条 defer 同时是**生产缺口**的现场证明(Tool.Close 目前无人调用)。 + if closer, ok := tool.(interface{ Close() error }); ok { + defer func() { + if cerr := closer.Close(); cerr != nil { + t.Errorf("Close: %v", cerr) + } + }() + } else { + t.Error("工具没实现 Close:组合根将没有关闭浏览器的挂点") + } + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + // ① 指纹自检:证明「这次真的是没在自曝」——navigator.webdriver 必须是 false。 + info, err := tool.Execute(ctx, map[string]any{"action": "info"}) + if err != nil { + t.Fatalf("action=info: %v", err) + } + fp, ok := info.(browsercap.Fingerprint) + if !ok { + t.Fatalf("info 结果类型 = %T", info) + } + if fp.Webdriver { + t.Errorf("navigator.webdriver = true:自动化痕迹没关掉(%+v)", fp) + } + t.Logf("指纹自检:headless=%v webdriver=%v ua=%s tz=%s lang=%s screen=%dx%d outer=%dx%d", + fp.Headless, fp.Webdriver, fp.UserAgent, fp.Timezone, fp.Language, + fp.ScreenWidth, fp.ScreenHeight, fp.OuterWidth, fp.OuterHeight) + if strings.Contains(strings.ToLower(fp.UserAgent), "headlesschrome") { + t.Logf("提示:无头档的 UA 带 HeadlessChrome —— 这是无头与有头唯一必然不同的一位") + } + + // ② 导航:读回页面**自报**的标题(不是我们以为的)。 + opened, err := tool.Execute(ctx, map[string]any{"action": "open", "url": "https://example.com/"}) + if err != nil { + t.Fatalf("action=open: %v", err) + } + page, ok := opened.(browsercap.PageResult) + if !ok { + t.Fatalf("open 结果类型 = %T", opened) + } + if !strings.Contains(page.Title, "Example") { + t.Errorf("标题 = %q,期望页面自报的 Example Domain", page.Title) + } + if page.ReadyState != "complete" { + t.Errorf("readyState = %q", page.ReadyState) + } + t.Logf("导航:title=%q url=%q readyState=%q source=%q", page.Title, page.URL, page.ReadyState, page.Source) + + // ③ 截图落盘:路径存在、非空、尺寸来自字节本身。 + shotRaw, err := tool.Execute(ctx, map[string]any{"action": "screenshot"}) + if err != nil { + t.Fatalf("action=screenshot: %v", err) + } + shot := shotRaw.(map[string]any) + path, _ := shot["path"].(string) + if path == "" { + t.Fatalf("截图没有路径: %#v", shot) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatalf("截图没落盘: %v", err) + } + if fi.Size() == 0 { + t.Fatal("截图是 0 字节") + } + if !strings.HasPrefix(path, filepath.Join(cfg.DataDir, "browser", "shots")) { + t.Errorf("截图没落在 $DATA_DIR/browser/shots 下: %s", path) + } + width, _ := shot["width"].(int) + height, _ := shot["height"].(int) + if width <= 0 || height <= 0 { + t.Errorf("尺寸没解出来: %#v", shot) + } + t.Logf("截图:path=%s bytes=%v %dx%d sha256=%v", path, shot["bytes"], width, height, shot["sha256"]) +} diff --git a/cmd/cyrene/wire_browser_test.go b/cmd/cyrene/wire_browser_test.go new file mode 100644 index 0000000..6697042 --- /dev/null +++ b/cmd/cyrene/wire_browser_test.go @@ -0,0 +1,225 @@ +package main + +// 本文件钉住切片 V5 的组合根接线:`browser` 工具**只在有可用来源时**进注册面, +// 每一个「不注册」的原因都**分类**写进日志(照 web-search `off` 的诚实口径), +// 而 profile 目录的凭据守卫(不得进 git 工作树)在注册之前生效。 +// +// 断言落在**注册表**(模型真正看到的那一面)与**日志字段**上,而不是源码字符串。 + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "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/internal/logger" + browsertools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/browser" +) + +// browserTestApp 造一个「有注册面 + 有日志出口」的 app:browser 的接线只依赖这三件。 +func browserTestApp(t *testing.T, cfg *config.Config, out *bytes.Buffer) *app { + t.Helper() + return &app{ + cfg: cfg, + log: logger.New(logger.Options{Out: out, Level: "debug"}), + caps: registry.New(), + } +} + +// writeBrowserConfig 把一份 browser.json 写进 $DATA_DIR/config/plugins/(原样字节)。 +func writeBrowserConfig(t *testing.T, cfg *config.Config, raw string) string { + t.Helper() + path := browsercfg.DefaultConfigPath(cfg.DataDir) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("建配置目录: %v", err) + } + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatalf("写 %s: %v", path, err) + } + return path +} + +// browserEntry 报告注册面里有没有 browser 工具。 +func browserEntry(a *app) (registry.Capability, bool) { + cap, err := a.caps.Get(browsertools.ToolName) + return cap, err == nil +} + +// TestWireBrowser_InvalidConfigDoesNotRegister 钉住「配置非法 ⇒ 不注册 + 分类原因」: +// 静默拿默认值顶替会让面板里那份坏配置永远看不见。 +func TestWireBrowser_InvalidConfigDoesNotRegister(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + writeBrowserConfig(t, cfg, "{ this is not json") + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + + if _, ok := browserEntry(a); ok { + t.Fatal("配置非法时 browser 不该进注册面") + } + if got := wsLogField(t, out.String(), "reason"); got != browserReasonInvalidConfig { + t.Errorf("reason = %q, want %q", got, browserReasonInvalidConfig) + } +} + +// TestWireBrowser_MissingExecutableDoesNotRegister 钉住「面板把路径写错 ⇒ 不注册」, +// 且原因来自 browsercfg 的分类码(不偷偷回退到自动探测)。 +func TestWireBrowser_MissingExecutableDoesNotRegister(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + raw, _ := json.Marshal(map[string]any{"executable": filepath.Join(t.TempDir(), "no-such-browser.exe")}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + + if _, ok := browserEntry(a); ok { + t.Fatal("可执行文件不存在时 browser 不该进注册面") + } + if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonExecutableMissing) { + t.Errorf("reason = %q, want %q", got, browsercfg.ReasonExecutableMissing) + } +} + +// TestWireBrowser_UnreachableEndpointDoesNotRegister 钉住 CDP 端点连不上 ⇒ 不注册, +// 原因分类为 cdp_unreachable(配置显式给了端点却不可达,就是「我配的容器没生效」, +// 必须让人看见)。 +func TestWireBrowser_UnreachableEndpointDoesNotRegister(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + // 127.0.0.1 上一个确定没人听的端口:连不上会立刻返回(不是超时)。 + raw, _ := json.Marshal(map[string]any{"cdpEndpoint": "http://127.0.0.1:1"}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + + if _, ok := browserEntry(a); ok { + t.Fatal("CDP 端点不可达时 browser 不该进注册面") + } + if got := wsLogField(t, out.String(), "reason"); got != string(browsercfg.ReasonCDPUnreachable) { + t.Errorf("reason = %q, want %q", got, browsercfg.ReasonCDPUnreachable) + } +} + +// TestWireBrowser_UsableExecutableRegisters 钉住正向路径:配置给出的可执行文件存在 +// ⇒ 工具进注册面,且 profile 目录被按 0700 建好(含凭据的目录不能是 0755)。 +func TestWireBrowser_UsableExecutableRegisters(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + exe := filepath.Join(t.TempDir(), "fake-browser.exe") + if err := os.WriteFile(exe, []byte("#!/bin/sh\n"), 0o700); err != nil { + t.Fatalf("造假可执行文件: %v", err) + } + profileDir := filepath.Join(cfg.DataDir, "browser", "profile-x") + raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + + cap, ok := browserEntry(a) + if !ok { + t.Fatalf("可用时 browser 应进注册面(日志:%s)", out.String()) + } + if cap.Kind != registry.KindTool { + t.Errorf("Kind = %q, want tool", cap.Kind) + } + // 默认权限口径:owner-only(两布尔皆 false)。 + if cap.Policy.AllowInGroup || cap.Policy.AllowInStranger { + t.Errorf("Policy = %+v,默认应是 owner-only(design/28 §4 第 2 条)", cap.Policy) + } + if _, err := os.Stat(profileDir); err != nil { + t.Errorf("profile 目录应先被建好: %v", err) + } + // 工具名/动作面:注册进去的那把工具就是薄层那一个。 + if tool, ok := cap.Value.(interface{ Name() string }); !ok || tool.Name() != browsertools.ToolName { + t.Errorf("注册的值不是 browser 工具: %#v", cap.Value) + } +} + +// TestWireBrowser_ProfileInGitTreeIsRejected 钉住凭据守卫:profile 落在 git 工作树里 +// ⇒ **不注册**(被 `git add` 收走就是把登录态提交出去,design/28 §4)。 +func TestWireBrowser_ProfileInGitTreeIsRejected(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + exe := filepath.Join(t.TempDir(), "fake-browser.exe") + if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { + t.Fatalf("造假可执行文件: %v", err) + } + // 造一个「像仓库」的目录:里面有 `.git`,profile 落在它下面、且在 dataDir 之外。 + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil { + t.Fatalf("造 .git: %v", err) + } + profileDir := filepath.Join(repo, "browser-profile") + raw, _ := json.Marshal(map[string]any{"executable": exe, "profileDir": profileDir}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.wireBrowser() + + if _, ok := browserEntry(a); ok { + t.Fatal("profile 位于 git 工作树内时 browser 不该进注册面") + } + if got := wsLogField(t, out.String(), "reason"); got != browserReasonProfileRejected { + t.Errorf("reason = %q, want %q", got, browserReasonProfileRejected) + } +} + +// TestIOToolOptions_WiresBrowserExactlyOnce 钉住**挂点与幂等**:browser 的接线挂在 +// `ioToolOptions()`(组合根启动期唯一归属本片的装配入口)上,且重复调用不重复注册。 +func TestIOToolOptions_WiresBrowserExactlyOnce(t *testing.T) { + t.Parallel() + cfg := wsLoadConfig(t) + exe := filepath.Join(t.TempDir(), "fake-browser.exe") + if err := os.WriteFile(exe, []byte("x"), 0o700); err != nil { + t.Fatalf("造假可执行文件: %v", err) + } + raw, _ := json.Marshal(map[string]any{"executable": exe}) + writeBrowserConfig(t, cfg, string(raw)) + var out bytes.Buffer + a := browserTestApp(t, cfg, &out) + + a.ioToolOptions() + first, ok := browserEntry(a) + if !ok { + t.Fatalf("ioToolOptions 应当把 browser 接上(日志:%s)", out.String()) + } + a.ioToolOptions() + second, ok := browserEntry(a) + if !ok { + t.Fatal("第二次调用后 browser 不该消失") + } + if first.Name != second.Name || first.Kind != second.Kind { + t.Errorf("重复装配改写了条目:%+v -> %+v", first, second) + } + // 注册面里只该有一个 browser(registry 重名会被拒;这里确认没被静默换掉)。 + count := 0 + for _, c := range a.caps.List(registry.KindTool) { + if c.Name == browsertools.ToolName { + count++ + } + } + if count != 1 { + t.Errorf("注册面里 browser 条目数 = %d, want 1", count) + } +} + +// TestWireBrowser_NilRegistryIsSkipped 钉住夹具友好性:没有注册面时**不 panic** +// (部分既有测试夹具只填了 app 的几个字段)。 +func TestWireBrowser_NilRegistryIsSkipped(t *testing.T) { + t.Parallel() + a := &app{} + a.wireBrowser() +} diff --git a/cmd/cyrene/wire_io_tools.go b/cmd/cyrene/wire_io_tools.go index 6df8b81..4480945 100644 --- a/cmd/cyrene/wire_io_tools.go +++ b/cmd/cyrene/wire_io_tools.go @@ -80,7 +80,14 @@ const webSearchToolName = "web-search" // ioToolOptions 组装 io 工具集的全部选项。wire_runtime 把它原样交给 iotools.New, // 因此「注册面里有哪些 io 工具」只由这一处决定。 +// +// 它**同时是切片 V5 的 browser 接线挂点**:组合根在启动期只调用这一个归属本片的装配 +// 函数(wire_runtime.go 的 `a.ioToolOptions()`),而 browser 的接线不得改他人拥有的 +// `wire_runtime.go` / `wire.go` ⇒ 由这里转交给 `wireBrowser()`(见 wire_browser.go 的 +// 口径说明)。转交对 io 选项**零影响**(它不改、不追加任何 iotools.Option),并且 +// **幂等**:同一个 app 上重复调用只会发现工具已注册并跳过。 func (a *app) ioToolOptions() []iotools.Option { + a.wireBrowser() opts := []iotools.Option{ iotools.WithKnowledgeRetriever(a.retriever), iotools.WithLogger(a.log), diff --git a/internal/capability/browser/cdp.go b/internal/capability/browser/cdp.go new file mode 100644 index 0000000..42e60a0 --- /dev/null +++ b/internal/capability/browser/cdp.go @@ -0,0 +1,257 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// cdpCallTimeout 是单条 CDP 命令的兜底超时(handshake / 读超时)。正常调用还会被 +// 调用方的 ctx(配置的 timeoutSeconds)更早掐断,所以它刻意给得比配置默认值宽。 +const cdpCallTimeout = 60 * time.Second + +// cdpMessage 是 CDP 线上信封:命令与响应共用(靠 id 关联),事件没有 id。 +type cdpMessage struct { + ID int `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *cdpError `json:"error,omitempty"` +} + +// cdpError 是 CDP 协议级错误(例如 "Cannot find context with specified id")。 +type cdpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Error 实现 error 接口。 +func (e *cdpError) Error() string { return fmt.Sprintf("CDP %d: %s", e.Code, e.Message) } + +// cdpClient 是最小 CDP 客户端:一条 WebSocket、一个读协程、按 id 关联的待答表。 +// +// 它**只做请求-响应**,读到的**事件一律丢弃** —— 这不是省事,而是刻意的: +// `Runtime.enable` 是本薄层唯一会「自曝自动化」的域(reference/browser-stealth/06), +// 我们从不开启它,因此既不需要事件,也不需要 `Runtime.addBinding` 那套绕法。 +// evaluate 走 `Page.createIsolatedWorld` 出来的执行上下文(见 session.go)。 +type cdpClient struct { + conn *websocket.Conn + writeMu sync.Mutex + + mu sync.Mutex + nextID int + pending map[int]chan cdpMessage + readErr error + closed chan struct{} + once sync.Once +} + +// newCDPClient 连上 wsURL 并启动读协程。Proxy 显式置空:CDP 端点在 127.0.0.1, +// 走系统代理会连不上(本机装有代理时这是最常见的「明明在跑却连不上」)。 +func newCDPClient(ctx context.Context, wsURL string) (*cdpClient, error) { + dialer := websocket.Dialer{Proxy: nil, HandshakeTimeout: cdpCallTimeout} + conn, resp, err := dialer.DialContext(ctx, wsURL, nil) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + return nil, fmt.Errorf("%w: 连接 %s: %v(HTTP %d)", ErrCDP, wsURL, err, status) + } + c := &cdpClient{ + conn: conn, + pending: make(map[int]chan cdpMessage), + closed: make(chan struct{}), + } + go c.readLoop() + return c, nil +} + +// readLoop 持续读帧直到出错:带 id 的帧派发给等待者,事件帧直接丢。 +func (c *cdpClient) readLoop() { + for { + _, data, err := c.conn.ReadMessage() + if err != nil { + c.fail(fmt.Errorf("%w: 读帧: %v", ErrCDP, err)) + return + } + var msg cdpMessage + if uerr := json.Unmarshal(data, &msg); uerr != nil { + c.fail(fmt.Errorf("%w: 解码帧: %v", ErrCDP, uerr)) + return + } + if msg.ID == 0 { + continue // 事件(Page.loadEventFired 之类):本薄层不消费 + } + c.deliver(msg) + } +} + +// deliver 把一帧响应交给它的等待者;没有等待者(已被超时放弃)就丢弃。 +func (c *cdpClient) deliver(msg cdpMessage) { + c.mu.Lock() + ch, ok := c.pending[msg.ID] + delete(c.pending, msg.ID) + c.mu.Unlock() + if ok { + ch <- msg + } +} + +// fail 记录读协程的错误并叫醒所有等待者(连接断开后任何等待都不该继续挂)。 +func (c *cdpClient) fail(err error) { + c.once.Do(func() { + c.mu.Lock() + c.readErr = err + waiters := c.pending + c.pending = make(map[int]chan cdpMessage) + close(c.closed) + c.mu.Unlock() + for _, ch := range waiters { + close(ch) + } + }) +} + +// Call 发一条 CDP 命令并等它的结果;out 非 nil 时把 result 反序列化进去。 +func (c *cdpClient) Call(ctx context.Context, method string, params any, out any) error { + raw, err := c.call(ctx, method, params) + if err != nil { + return err + } + if out != nil && len(raw) > 0 { + if uerr := json.Unmarshal(raw, out); uerr != nil { + return fmt.Errorf("%w: 解码 %s 的结果: %v", ErrCDP, method, uerr) + } + } + return nil +} + +// call 是 Call 的内核:分配 id、登记等待、写帧、等响应或 ctx 取消。 +func (c *cdpClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) { + if err := c.errIfClosed(); err != nil { + return nil, err + } + id, ch, err := c.enqueue() + if err != nil { + return nil, err + } + defer c.forget(id) + if err := c.write(cdpMessage{ID: id, Method: method, Params: mustJSON(params)}); err != nil { + return nil, err + } + select { + case msg, ok := <-ch: + if !ok { + return nil, c.closedErr(method) + } + if msg.Error != nil { + return nil, fmt.Errorf("%w: %s: %v", ErrCDP, method, msg.Error) + } + return msg.Result, nil + case <-ctx.Done(): + return nil, fmt.Errorf("%w: %s 被取消: %v", ErrCDP, method, ctx.Err()) + } +} + +// enqueue 分配命令 id 与等待通道(并发安全)。 +func (c *cdpClient) enqueue() (int, chan cdpMessage, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.readErr != nil { + return 0, nil, c.readErr + } + c.nextID++ + id := c.nextID + ch := make(chan cdpMessage, 1) + c.pending[id] = ch + return id, ch, nil +} + +// forget 清掉一个等待登记(超时/取消路径上不留给读协程)。 +func (c *cdpClient) forget(id int) { + c.mu.Lock() + delete(c.pending, id) + c.mu.Unlock() +} + +// write 串行化写帧:gorilla 的写方法不允许并发调用。 +func (c *cdpClient) write(msg cdpMessage) error { + payload, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("%w: 编码 %s: %v", ErrCDP, msg.Method, err) + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return fmt.Errorf("%w: 写 %s: %v", ErrCDP, msg.Method, err) + } + return nil +} + +// errIfClosed 报告连接是否已断(读协程记下的原始错误优先)。 +func (c *cdpClient) errIfClosed() error { + select { + case <-c.closed: + return c.closedErr("") + default: + return nil + } +} + +// closedErr 统一「连接已断」的错误,带上读协程记下的原因。 +func (c *cdpClient) closedErr(method string) error { + c.mu.Lock() + readErr := c.readErr + c.mu.Unlock() + if readErr != nil { + return readErr + } + return fmt.Errorf("%w: 连接已关闭(%s)", ErrCDP, method) +} + +// Close 关掉连接(幂等)。 +func (c *cdpClient) Close() error { + err := c.conn.Close() + c.fail(fmt.Errorf("%w: 连接被主动关闭", ErrCDP)) + return err +} + +// mustJSON 把 params 编成 RawMessage;编码失败返回 nil(参数都是本包自造的 +// map/结构体,编不出来属程序错误,交给上层按缺参数处理,绝不在这里 panic)。 +func mustJSON(params any) json.RawMessage { + if params == nil { + return nil + } + raw, err := json.Marshal(params) + if err != nil { + return nil + } + return raw +} + +// httpGetJSON 对 CDP 的 HTTP 端点发一次 GET 并按 out 解码(/json/list、/json/version)。 +func httpGetJSON(ctx context.Context, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("%w: 构造请求 %s: %v", ErrCDP, url, err) + } + client := &http.Client{Timeout: cdpCallTimeout} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("%w: 请求 %s: %v", ErrCDP, url, err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%w: 请求 %s: HTTP %d", ErrCDP, url, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("%w: 解码 %s 的响应: %v", ErrCDP, url, err) + } + return nil +} diff --git a/internal/capability/browser/cdp_test.go b/internal/capability/browser/cdp_test.go new file mode 100644 index 0000000..e54b97d --- /dev/null +++ b/internal/capability/browser/cdp_test.go @@ -0,0 +1,188 @@ +package browser + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +// fakeCDP 是一个「协议形状对」的假 CDP server:它按方法名应答,用来在没有浏览器的 +// 机器上覆盖客户端的连接/关联/错误/取消四条路径。 +type fakeCDP struct { + srv *httptest.Server + // base 是 HTTP 基地址(`pageTargetWS` 与 `/json/version` 用它)。 + base string + // pageWS 是 page target 的 WebSocket 地址(`newCDPClient` 用它)。 + pageWS string +} + +// newFakeCDP 起一个假 CDP:/json/list 返回一个 page target,/devtools/page/1 是 WS。 +// handler 收到 (method, params),返回 (result, cdpError)。 +func newFakeCDP(t *testing.T, handler func(method string, params map[string]any) (any, *cdpError)) *fakeCDP { + t.Helper() + f := &fakeCDP{} + mux := http.NewServeMux() + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + mux.HandleFunc("/devtools/page/1", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { + _, data, err := conn.ReadMessage() + if err != nil { + return + } + var msg cdpMessage + if err := json.Unmarshal(data, &msg); err != nil { + return + } + var params map[string]any + _ = json.Unmarshal(msg.Params, ¶ms) + result, cdpErr := handler(msg.Method, params) + out := map[string]any{"id": msg.ID} + if cdpErr != nil { + out["error"] = cdpErr + } else { + out["result"] = result + } + payload, _ := json.Marshal(out) + if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil { + return + } + } + }) + mux.HandleFunc("/json/list", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"type":"page","url":"about:blank","webSocketDebuggerUrl":"` + f.pageWS + `"}]`)) + }) + mux.HandleFunc("/json/version", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0","webSocketDebuggerUrl":"` + f.pageWS + `"}`)) + }) + f.srv = httptest.NewServer(mux) + f.base = f.srv.URL + f.pageWS = "ws://" + strings.TrimPrefix(f.srv.URL, "http://") + "/devtools/page/1" + t.Cleanup(f.srv.Close) + return f +} + +// TestCDPClient_CallRoundTrip 钉住最基本的一条:命令发出去、按 id 回来的结果能解出来。 +func TestCDPClient_CallRoundTrip(t *testing.T) { + f := newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) { + if method != "Page.getFrameTree" { + return nil, &cdpError{Code: -32601, Message: "unexpected " + method} + } + return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil + }) + wsURL, err := pageTargetWS(context.Background(), f.base) + if err != nil { + t.Fatalf("pageTargetWS: %v", err) + } + c, err := newCDPClient(context.Background(), wsURL) + if err != nil { + t.Fatalf("newCDPClient: %v", err) + } + defer func() { _ = c.Close() }() + + var tree struct { + FrameTree struct { + Frame struct { + ID string `json:"id"` + } `json:"frame"` + } `json:"frameTree"` + } + if err := c.Call(context.Background(), "Page.getFrameTree", nil, &tree); err != nil { + t.Fatalf("Call: %v", err) + } + if tree.FrameTree.Frame.ID != "frame-1" { + t.Errorf("frame id = %q, want frame-1", tree.FrameTree.Frame.ID) + } +} + +// TestCDPClient_ProtocolErrorIsWrapped 钉住协议错误被 %w 包成 ErrCDP(上层据此 +// 判定「连接级问题 ⇒ 丢会话重起」)。 +func TestCDPClient_ProtocolErrorIsWrapped(t *testing.T) { + f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { + return nil, &cdpError{Code: -32000, Message: "boom"} + }) + wsURL, _ := pageTargetWS(context.Background(), f.base) + c, err := newCDPClient(context.Background(), wsURL) + if err != nil { + t.Fatalf("newCDPClient: %v", err) + } + defer func() { _ = c.Close() }() + err = c.Call(context.Background(), "Runtime.evaluate", nil, nil) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("Call 的错误 = %v,应含 CDP 的 message", err) + } + if !strings.Contains(err.Error(), "CDP 调用失败") { + t.Errorf("错误没带上 ErrCDP 文案: %v", err) + } +} + +// TestCDPClient_ContextCancelStopsWaiting 钉住「取消能立刻返回」,不是等对端回话。 +func TestCDPClient_ContextCancelStopsWaiting(t *testing.T) { + f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { + time.Sleep(2 * time.Second) + return map[string]any{}, nil + }) + wsURL, _ := pageTargetWS(context.Background(), f.base) + c, err := newCDPClient(context.Background(), wsURL) + if err != nil { + t.Fatalf("newCDPClient: %v", err) + } + defer func() { _ = c.Close() }() + ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond) + defer cancel() + start := time.Now() + if err := c.Call(ctx, "Page.navigate", nil, nil); err == nil { + t.Fatal("ctx 到期应当返回错误") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("取消后仍等了 %v,说明没接 ctx", elapsed) + } +} + +// TestPageTargetWS_NoPageTarget 钉住「连上了却没有 page target」被如实报出来 +// (不是含糊成一个 nil 端点)。 +func TestPageTargetWS_NoPageTarget(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[{"type":"service_worker","webSocketDebuggerUrl":"ws://x/1"}]`)) + })) + defer srv.Close() + if _, err := pageTargetWS(context.Background(), srv.URL); err == nil { + t.Fatal("没有 page target 时应当报错") + } +} + +// TestCDPClient_CloseFailsPendingCalls 钉住连接断开后等待者被叫醒(否则一次断线 +// 会把调用方永久挂住)。 +func TestCDPClient_CloseFailsPendingCalls(t *testing.T) { + f := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { + time.Sleep(3 * time.Second) + return map[string]any{}, nil + }) + wsURL, _ := pageTargetWS(context.Background(), f.base) + c, err := newCDPClient(context.Background(), wsURL) + if err != nil { + t.Fatalf("newCDPClient: %v", err) + } + done := make(chan error, 1) + go func() { done <- c.Call(context.Background(), "Page.navigate", nil, nil) }() + time.Sleep(50 * time.Millisecond) + _ = c.Close() + select { + case err := <-done: + if err == nil { + t.Fatal("连接关闭后应返回错误") + } + case <-time.After(2 * time.Second): + t.Fatal("连接关闭后等待者没被叫醒") + } +} diff --git a/internal/capability/browser/compare_test.go b/internal/capability/browser/compare_test.go new file mode 100644 index 0000000..f8cdd29 --- /dev/null +++ b/internal/capability/browser/compare_test.go @@ -0,0 +1,365 @@ +package browser + +// 本文件是 `DevDocs/reference/browser-stealth/07-有头vs无头对照实测方案.md` 的**可复跑 +// 落地版**:同一 URL、同一出口 IP、同一窗口,只改「有头 / 无头」这一个变量,对 +// 「headless 被拦 / headful 通过」这句待验命题给出可证伪的读数。 +// +// 它**默认不跑**(没设 `CYRENE_BROWSER_COMPARE_URLS` 就 Skip):这是一次真的会开浏览器、 +// 打外网站点的实验,不该混进普通 `go test`。 +// +// 用法(Windows / Git Bash): +// +// CYRENE_BROWSER_COMPARE_URLS="https://example.com/" \ +// CYRENE_BROWSER_COMPARE_REPEATS=1 \ +// CYRENE_BROWSER_COMPARE_ALLOW=example.com \ +// go test ./internal/capability/browser/ -run TestHeadfulVsHeadlessCompare -v -timeout 20m +// +// 变量(**只服务本测试,不是配置键**,`.env` 冻结口径不受影响): +// - `CYRENE_BROWSER_COMPARE_URLS`:逗号分隔的目标 URL;**必填**,否则 Skip。 +// - `CYRENE_BROWSER_COMPARE_REPEATS`:交错重复次数(默认 1;reference/07 §5 建议 ≥3)。 +// - `CYRENE_BROWSER_COMPARE_OUT`:跑批产物目录(默认落系统临时目录并打印路径)。 +// - `CYRENE_BROWSER_COMPARE_ALLOW`:SSRF 白名单例外(测本机 httptest 服务时用)。 +// +// 判定口径照 reference/07 §3(多信号,不看单一状态码);记录字段照 §6。 +// ⚠️ 结论规则(reference/07 §7):**A blocked & B pass 才算「有头在此站有意义」**; +// 两边都 pass ⇒ 本站**未被证明**需要有头;两边都拦 ⇒ 如实登记失败,别假装成功。 + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" +) + +const ( + // compareGap 是**同一站两次请求之间**的最小间隔。它是 reference/07 §5 的**去 flake + // 纪律**(别让 A/B 两次请求挨在一起,否则读到的是时序噪声),**不是**使用政策上的 + // 限速 —— 主人 2026-10-06 定稿的「仅自用、不加人为限速」针对的是使用政策,两者不 + // 冲突。想连续打就连着打,这里只是默认值。 + compareGap = 5 * time.Second + + // compareNavTimeout 是单次导航的预算(冷启动 + 渲染 + 挑战页也要能落下来)。 + compareNavTimeout = 45 * time.Second + + // compareSampleBytes 是抓回正文样本的上限(只用于正则判定与人工核对,不落全量 HTML)。 + compareSampleBytes = 20000 +) + +// compareGroup 是一个对照臂。A = 无头(基准),B = 有头(被测变量)。 +type compareGroup struct { + Name string + Headless bool +} + +// compareGroups 返回对照组:reference/07 §1 的 A / B 两组(本落地版不做 C/D 两组 —— +// C 需要预热 cookie,D 需要换 CDP 姿势,都属后续切片;见文件末尾「未做」)。 +func compareGroups() []compareGroup { + return []compareGroup{ + {Name: "A", Headless: true}, + {Name: "B", Headless: false}, + } +} + +// compareRecord 是一条记录(字段对齐 reference/07 §6 的 JSON)。 +type compareRecord struct { + TS string `json:"ts"` + Group string `json:"group"` + Mode string `json:"mode"` + URL string `json:"url"` + FinalURL string `json:"final_url"` + Title string `json:"title"` + LaunchFlags []string `json:"launch_flags"` + CDPRuntimeEnable bool `json:"cdp_runtime_enable"` + Driver string `json:"driver"` + ChromeVersion string `json:"chrome_version"` + UserDataDir string `json:"user_data_dir"` + Headless bool `json:"headless"` + Webdriver bool `json:"webdriver"` + UA string `json:"ua"` + UAHeadlessTell bool `json:"ua_headless_tell"` + Timezone string `json:"timezone"` + Language string `json:"language"` + ScreenWidth int `json:"screen_width"` + OuterWidth int `json:"outer_width"` + HTTPStatus int `json:"http_status"` + MatchedMarkers []string `json:"matched_markers"` + BodyBytes int `json:"body_bytes"` + ScreenshotPath string `json:"screenshot_path"` + ScreenshotSHA256 string `json:"screenshot_sha256"` + Verdict string `json:"verdict"` + Note string `json:"note"` +} + +// TestHeadfulVsHeadlessCompare 跑一次对照实验。默认 Skip(见文件顶部)。 +func TestHeadfulVsHeadlessCompare(t *testing.T) { + urls := splitList(os.Getenv("CYRENE_BROWSER_COMPARE_URLS")) + if len(urls) == 0 { + t.Skip("未设 CYRENE_BROWSER_COMPARE_URLS:对照脚本默认不跑(用法见本文件顶部)") + } + repeats := envInt("CYRENE_BROWSER_COMPARE_REPEATS", 1) + outDir := strings.TrimSpace(os.Getenv("CYRENE_BROWSER_COMPARE_OUT")) + if outDir == "" { + outDir = filepath.Join(os.TempDir(), "cyrene-browser-compare") + } + runDir := filepath.Join(outDir, "run-"+time.Now().Format("20060102-150405")) + if err := os.MkdirAll(runDir, 0o700); err != nil { + t.Fatalf("建产物目录 %s: %v", runDir, err) + } + allow := splitList(os.Getenv("CYRENE_BROWSER_COMPARE_ALLOW")) + + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + var records []compareRecord + t.Logf("对照开始:urls=%v repeats=%d runDir=%s", urls, repeats, runDir) + + for _, raw := range urls { + for repeat := 0; repeat < repeats; repeat++ { + for _, g := range compareGroups() { + rec := runCompareOnce(t, raw, g, repeat, runDir, allow, log) + records = append(records, rec) + t.Logf("%s", summarizeRecord(rec)) + time.Sleep(compareGap) + } + } + } + writeRecords(t, runDir, records) + t.Logf("汇总:\n%s", compareTable(records)) + t.Logf("原始记录:%s", filepath.Join(runDir, "records.jsonl")) +} + +// runCompareOnce 跑一臂一次:起浏览器 → 导航 → 采集 → 判定 → 截图落盘 → 关掉。 +func runCompareOnce(t *testing.T, rawURL string, g compareGroup, repeat int, runDir string, allow []string, log *slog.Logger) compareRecord { + t.Helper() + profile := filepath.Join(runDir, "profile-"+g.Name) + drv := &localChrome{ + exe: compareExecutable(t), + profileDir: profile, + headless: g.Headless, + viewportW: browsercfg.DefaultViewportWidth, + viewportH: browsercfg.DefaultViewportHeight, + timeout: compareNavTimeout, + log: log, + } + ctx, cancel := context.WithTimeout(context.Background(), compareNavTimeout*2) + defer cancel() + s, err := openSession(ctx, drv, configView{ + profileDir: profile, shotsDir: runDir, headless: g.Headless, + viewportW: browsercfg.DefaultViewportWidth, viewportH: browsercfg.DefaultViewportHeight, + timeout: compareNavTimeout, idle: time.Minute, shotMaxBytes: browsercfg.DefaultScreenshotMaxBytes, + }) + if err != nil { + t.Fatalf("%s 组第 %d 次起会话失败: %v", g.Name, repeat+1, err) + } + defer s.Close() + + rec := compareRecord{ + TS: time.Now().Format(time.RFC3339), Group: g.Name, URL: rawURL, + Mode: modeName(g.Headless), LaunchFlags: drv.launchArgs(), + CDPRuntimeEnable: false, // 本驱动从源头**不发** Runtime.enable(reference/06 的 P0) + Driver: "cyrene-go-cdp", ChromeVersion: chromeVersionOf(t, s.endpoint), + UserDataDir: profile, Headless: g.Headless, + } + state, err := s.navigate(ctx, rawURL) + if err != nil { + rec.Verdict = "error" + rec.Note = "导航失败:" + err.Error() + return rec + } + rec.FinalURL, rec.Title = state.URL, state.Title + + fp, ferr := readFingerprint(ctx, s) + if ferr == nil { + rec.Webdriver, rec.Timezone, rec.Language = fp.Webdriver, fp.Timezone, fp.Language + rec.ScreenWidth, rec.OuterWidth = fp.ScreenWidth, fp.OuterWidth + // UA 是**有头与无头唯一必然不同**的那一位(reference/05:`--headless=new` 的 UA + // 带 `HeadlessChrome` 段)。把它单独记成一个布尔,是为了让「有头抹掉了什么」 + // 这件事从「人工看图」变成可机读的事实。 + rec.UA = fp.UserAgent + rec.UAHeadlessTell = strings.Contains(strings.ToLower(fp.UserAgent), "headless") + } + var page struct { + HTMLBytes int `json:"htmlBytes"` + Sample string `json:"sample"` + Status int `json:"status"` + } + if perr := s.evaluate(ctx, pageSampleExpr(), &page); perr == nil { + rec.BodyBytes, rec.HTTPStatus = page.HTMLBytes, page.Status + rec.MatchedMarkers = matchedMarkers(page.Sample) + } else { + rec.Note = "页面采样失败:" + perr.Error() + } + rec.Verdict = verdictOf(rec) + + if data, err := s.captureScreenshot(ctx, "png", 0); err == nil { + if shot, serr := saveShot(runDir, data, shotExtPNG); serr == nil { + rec.ScreenshotPath, rec.ScreenshotSHA256 = shot.Path, shot.SHA256 + } + } + if rec.Note == "" { + rec.Note = "自动判定;结论前须人工看图(reference/07 §3)" + } + return rec +} + +// pageSampleExpr 一次求值取回:文档字节数、正文样本、HTTP 状态。 +// `performance.getEntriesByType('navigation')[0].responseStatus` 是 Chrome 109+ 的 +// 导航响应状态;取不到就报 0(**不猜**)。 +func pageSampleExpr() string { + return fmt.Sprintf(`(() => { + const nav = performance.getEntriesByType('navigation')[0]; + const text = document.body ? (document.body.innerText || '') : ''; + const html = document.documentElement ? document.documentElement.outerHTML : ''; + return { + htmlBytes: html.length, + status: nav && nav.responseStatus ? nav.responseStatus : 0, + sample: (document.title + '\n' + text).slice(0, %d) + }; +})()`, compareSampleBytes) +} + +// blockMarkers 是 reference/07 §3 的判定标记表(大小写不敏感;小写存)。 +func blockMarkers() []string { + return []string{ + "just a moment", "cf-chl", "__cf_chl", "/cdn-cgi/challenge-platform", "attention required", + "captcha-delivery.com", "datadome", "access denied", "verifying you are human", + "unusual traffic", "请求过于频繁", "访问被拒绝", "verify you are human", + } +} + +// matchedMarkers 返回样本里命中的拦截标记(去重、稳定顺序)。 +func matchedMarkers(sample string) []string { + lower := strings.ToLower(sample) + var hits []string + for _, m := range blockMarkers() { + if strings.Contains(lower, m) { + hits = append(hits, m) + } + } + sort.Strings(hits) + return hits +} + +// verdictOf 按 reference/07 §3 的优先级给判定:强信号 ⇒ blocked/challenge; +// 否则 pass。error 由调用方在失败路径上直接写入。 +func verdictOf(rec compareRecord) string { + switch rec.HTTPStatus { + case 403, 429, 503: + return "blocked" + } + if len(rec.MatchedMarkers) > 0 { + for _, m := range rec.MatchedMarkers { + if strings.Contains(m, "cf-chl") || strings.Contains(m, "challenge-platform") || m == "just a moment" { + return "challenge" + } + } + return "blocked" + } + if rec.BodyBytes <= 0 { + return "error" + } + return "pass" +} + +// compareExecutable 找到本次实验用的浏览器可执行文件(缺了就直接跳过整个测试)。 +func compareExecutable(t *testing.T) string { + t.Helper() + exe, ok := browsercfg.NewProber().FindLocalBrowser() + if !ok { + t.Skip("本机没有 Chrome/Chromium,对照无法进行") + } + return exe +} + +// chromeVersionOf 从 CDP 的 /json/version 读浏览器版本(记录用,失败留空不猜)。 +func chromeVersionOf(t *testing.T, endpoint string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var v struct { + Browser string `json:"Browser"` + } + if err := httpGetJSON(ctx, endpoint+"/json/version", &v); err != nil { + return "" + } + return v.Browser +} + +// writeRecords 落 records.jsonl(0600:里面含目标 URL 与截图指纹)。 +func writeRecords(t *testing.T, runDir string, records []compareRecord) { + t.Helper() + path := filepath.Join(runDir, "records.jsonl") + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + t.Fatalf("写 %s: %v", path, err) + } + defer func() { _ = f.Close() }() + enc := json.NewEncoder(f) + for _, rec := range records { + if err := enc.Encode(rec); err != nil { + t.Fatalf("编码记录: %v", err) + } + } +} + +// summarizeRecord 是一条可读摘要(t.Log 用)。 +func summarizeRecord(rec compareRecord) string { + return fmt.Sprintf("[%s/%s] verdict=%s status=%d body=%d markers=%v webdriver=%v uaHeadlessTell=%v tz=%s screen=%d outer=%d", + rec.Group, rec.Mode, rec.Verdict, rec.HTTPStatus, rec.BodyBytes, rec.MatchedMarkers, + rec.Webdriver, rec.UAHeadlessTell, rec.Timezone, rec.ScreenWidth, rec.OuterWidth) +} + +// compareTable 输出交付用的汇总表(组 × 判定 + 两件关键指纹事实)。 +func compareTable(records []compareRecord) string { + var b strings.Builder + b.WriteString("group | mode | headless | webdriver | ua_headless | status | verdict | markers | body_bytes | shot_sha256\n") + for _, rec := range records { + sum := rec.ScreenshotSHA256 + if len(sum) > 8 { + sum = sum[:8] + } + b.WriteString(fmt.Sprintf("%-5s | %-8s | %-8v | %-9v | %-11v | %-6d | %-8s | %-7d | %-10d | %s\n", + rec.Group, rec.Mode, rec.Headless, rec.Webdriver, rec.UAHeadlessTell, rec.HTTPStatus, + rec.Verdict, len(rec.MatchedMarkers), rec.BodyBytes, sum)) + } + return b.String() +} + +// modeName 把有头/无头映射成记录里的 mode 字段。 +func modeName(headless bool) string { + if headless { + return "headless" + } + return "headful" +} + +// splitList 拆逗号分隔的列表(去空白与空项)。 +func splitList(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + if p := strings.TrimSpace(part); p != "" { + out = append(out, p) + } + } + return out +} + +// envInt 读一个正整数环境变量;缺失/非法/≤0 时返回 def。 +func envInt(key string, def int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return def + } + return n +} diff --git a/internal/capability/browser/doc.go b/internal/capability/browser/doc.go new file mode 100644 index 0000000..7dfaf64 --- /dev/null +++ b/internal/capability/browser/doc.go @@ -0,0 +1,39 @@ +// Package browser 是 design/28(浏览器与页面截图)B2 的自研**薄层**:把「本机真 +// Chromium 有头」跑起来,对它做**出站 CDP** 调用,并把页面截图按 0600 落到 +// `$DATA_DIR/browser/shots/`,供既有 image/vision 管线读取。 +// +// 为什么这么薄(AGENTS §9.5): +// +// - **工具面优先复用 MCP**:`goto` / `click` / `type` / `snapshot` / `extract` +// 这一整套成熟动作由 Playwright/Puppeteer 的官方 MCP server 提供(design/28 §0 +// 「Playwright MCP / Puppeteer MCP」行、§2 结论);本包**不重造**它们。 +// - **本项目独有的价值只有两处**:① 宿主真 Chromium 的**默认有头 + 持久 profile** +// 启动(design/28 §2 的过风控三件套);② 截图 → 落盘(0600)→ 进上下文/面板的 +// 闭环入口。本包只做这两处,外加够它们用的**最小 CDP 客户端**。 +// +// 运行形态(design/28 §2「选型修正」的主路径): +// +// 1. **本机真 Chromium 有头**(默认档,`browsercfg.DefaultHeadless=false`): +// 真显示 + 真 GPU/字体/时区,指纹最像真人; +// 2. **次选:已跑的 CDP 端点**(容器 + Xvfb + 真 Chromium,配置显式给出): +// 本包只**出站连接**它。 +// +// 红线(design/28 §3/§4、AGENTS §9/§10): +// +// - **不新开对外监听端口**:调试端口由 Chromium 自己绑在 `127.0.0.1` 的**随机** +// 端口(`--remote-debugging-port=0`,端口从 profile 目录的 `DevToolsActivePort` +// 读回),我们是**出站连接方**;容器路径同理(我们连它)。 +// - **profile 含凭据**(cookie / 登录态):目录 0700、不得位于 git 工作树内 +// (`browsercfg.EnsureProfileDir`),内容绝不进上下文与长期记忆。 +// - **截图可能含凭据/二维码**:文件 0600、默认不进长期记忆(design/28 §4)。 +// - **CDP 层不自曝**:本包**不发** `Runtime.enable`(主流反爬用它判定「被自动化 +// 控制」,见 `DevDocs/reference/browser-stealth/06`);`Runtime.evaluate` 一律走 +// `Page.createIsolatedWorld` 出来的执行上下文。 +// - **失败如实报**:探测不到就不注册工具;跑不起来 / 被拦 / 超限都返回**分类原因**, +// 绝不假装成功(design/28 §6 坑 5)。 +// +// 已知未做(本片如实登记,见交付报告): +// - 面板「看一眼」入口(B4)、搜索降级档(B5)未做; +// - 除「导航 + 截图 + 页面指纹自检」之外的动作(click/type/extract/wait)**交给 MCP**, +// 本包不实现。 +package browser diff --git a/internal/capability/browser/errors.go b/internal/capability/browser/errors.go new file mode 100644 index 0000000..2de614d --- /dev/null +++ b/internal/capability/browser/errors.go @@ -0,0 +1,81 @@ +package browser + +import "errors" + +// 本包对外哨兵错误,一律用 errors.Is 判定;文案由调用方补齐上下文(AGENTS §10: +// 错误一律 %w wrap,禁裸 return err)。**不设包级可变状态**(除这些 var ErrXxx)。 +var ( + // ErrCDP 表示 CDP 层失败:WebSocket 连不上 / 命令返回协议错误 / 连接被打断。 + ErrCDP = errors.New("browser: CDP 调用失败") + // ErrLaunch 表示启动本机浏览器失败:可执行文件拉不起来、进程立刻退出、 + // 或者等不到调试端口就绪。 + ErrLaunch = errors.New("browser: 启动本机浏览器失败") + // ErrNavigate 表示导航失败(CDP 报 errorText,或等待页面就绪超时)。 + ErrNavigate = errors.New("browser: 导航失败") + // ErrEvaluate 表示在页面里求值失败(表达式异常 / 拿不到执行上下文)。 + ErrEvaluate = errors.New("browser: 页面求值失败") + // ErrBlockedURL 表示目标 URL 被 SSRF 守卫拒绝(回环/私网/链路本地等)。 + ErrBlockedURL = errors.New("browser: URL 被安全守卫拒绝") + // ErrScreenshotTooLarge 表示截图在两次尝试(PNG → JPEG)之后仍超过配置的字节上限。 + // 超限**不截断**:半个 PNG 是坏图,模型读到的会是噪声(design/28 §3「超限如实 + // 截断/缩放并标注」的落地口径 —— 能降编码就降,降不下来就如实报错)。 + ErrScreenshotTooLarge = errors.New("browser: 截图超过字节上限") + // ErrNoSession 表示管理器已被关闭,或当前没有可用会话(不可用时不假装可用)。 + ErrNoSession = errors.New("browser: 没有可用的浏览器会话") + // ErrUnavailable 表示这台机器上没有任何可用来源(没装 Chrome、也没有在跑的 + // CDP 端点)。它由组合根在探测阶段判定,此处只用于「手里没有 dialer」这一态。 + ErrUnavailable = errors.New("browser: 本机没有可用的浏览器来源") +) + +// Reason 是「这次为什么没成功」的**分类原因码**:上层据此如实降级与记日志, +// 不许把它含糊成「失败」两个字(design/28 §6 坑 5)。取值稳定、可断言;人类可读 +// 文案见 Message()。 +type Reason string + +// 分类原因码。 +const ( + // ReasonLaunched 表示会话由本机 Chrome/Chromium 新起(默认有头、持久 profile)。 + ReasonLaunched Reason = "launched-local-chrome" + // ReasonRemoteCDP 表示会话复用了配置里显式给出的 CDP 端点(容器/手动实例)。 + ReasonRemoteCDP Reason = "remote-cdp" + // ReasonIdleRecycled 表示上一次会话因空闲超时被回收(下一个动作会重新起)。 + ReasonIdleRecycled Reason = "idle-recycled" + // ReasonLaunchFailed 表示本机浏览器没拉起来(可执行文件 / 端口未就绪)。 + ReasonLaunchFailed Reason = "launch-failed" + // ReasonNoPageTarget 表示连上了浏览器却找不到可用的 page target。 + ReasonNoPageTarget Reason = "no-page-target" + // ReasonBlockedURL 表示 URL 没过 SSRF 守卫。 + ReasonBlockedURL Reason = "blocked-url" + // ReasonNavigateFailed 表示导航失败或等待就绪超时。 + ReasonNavigateFailed Reason = "navigate-failed" + // ReasonShotTooLarge 表示截图超过字节上限(如实报,不交坏图)。 + ReasonShotTooLarge Reason = "screenshot-too-large" + // ReasonClosed 表示管理器已关闭。 + ReasonClosed Reason = "closed" +) + +// Message 返回原因码对应的中文文案;未知码返回空串(调用方回落到错误的 Error())。 +func (r Reason) Message() string { + switch r { + case ReasonLaunched: + return "已启动本机浏览器(默认有头 + 持久 profile)" + case ReasonRemoteCDP: + return "已连接到配置指定的 CDP 端点" + case ReasonIdleRecycled: + return "空闲超时,上一次会话已回收" + case ReasonLaunchFailed: + return "本机浏览器未能启动" + case ReasonNoPageTarget: + return "连上了浏览器,但没有可用的 page target" + case ReasonBlockedURL: + return "目标 URL 未通过安全守卫" + case ReasonNavigateFailed: + return "导航失败或等待页面就绪超时" + case ReasonShotTooLarge: + return "截图超过配置的字节上限" + case ReasonClosed: + return "浏览器管理器已关闭" + default: + return "" + } +} diff --git a/internal/capability/browser/guard.go b/internal/capability/browser/guard.go new file mode 100644 index 0000000..6c52928 --- /dev/null +++ b/internal/capability/browser/guard.go @@ -0,0 +1,106 @@ +package browser + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" +) + +// ⚠️ **已知偏差(如实登记,不美化)**:design/28 §4 要求「SSRF 复用既有 `io/ssrf` +// 白名单,**不新增第三份实现**」。但既有实现位于 `plugins/tools/io`(`ssrf.go` 的 +// `validateRequestURL`)且**未导出**,本切片的文件面不含该包 ⇒ 本片无法直接复用, +// 也不擅自改别人的文件。因此这里落的是**与 `io/ssrf` 同一条策略**的判定(先放行 +// 白名单、再按 IP 网段否决;网段口径逐条对齐),并登记为待收口项: +// **收口动作 = 把 `plugins/tools/io` 的 `validateRequestURL` 导出(或提到共享包), +// 删掉本文件**。在收口之前,浏览器侧与 web-fetch 侧的放行/拒绝口径**按同一张表** +// 执行,不存在「一个放行另一个拒绝」的分歧。 +// +// 判定口径(与 `io/ssrf` 逐条对齐): +// - 只接受 `http` / `https`; +// - 命中调用方白名单的主机**直接放行**(同源自取:本地 searxng、本机 upload 路由); +// - 字面 IP 落在回环/私网/链路本地/组播/未指定 ⇒ 拒绝; +// - 主机名先解析,**任一条**解析结果落在上面那些网段 ⇒ 拒绝(防 DNS rebinding 的 +// 第一跳;真正的 rebinding 需要连上时再校验,既有两份实现都没做,本片不越界)。 +// +// 为什么浏览器侧必须有这道闸:导航是**服务端代取用户/模型给的任意 URL**,不过闸就 +// 等于把云元数据端点(`169.254.169.254`)、内网管理面暴露给一次提示词注入 +// (design/28 §4 第 1 条;M3 面板入口同样过闸)。 +func GuardURL(ctx context.Context, rawURL string, allowed []string) (*url.URL, error) { + if strings.TrimSpace(rawURL) == "" { + return nil, fmt.Errorf("%w: url 为空", ErrBlockedURL) + } + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("%w: 解析 url: %v", ErrBlockedURL, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, fmt.Errorf("%w: 不支持协议 %q", ErrBlockedURL, u.Scheme) + } + host := u.Hostname() + if host == "" { + return nil, fmt.Errorf("%w: url 没有主机名", ErrBlockedURL) + } + if hostAllowed(host, allowed) { + return u, nil + } + if ip := net.ParseIP(host); ip != nil { + if deniedIP(ip) { + return nil, fmt.Errorf("%w: %s 属回环/私网/链路本地地址", ErrBlockedURL, host) + } + return u, nil + } + addrs, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, fmt.Errorf("%w: 解析 %q: %v", ErrBlockedURL, host, err) + } + for _, a := range addrs { + if ip := net.ParseIP(a); ip != nil && deniedIP(ip) { + return nil, fmt.Errorf("%w: %q 解析到 %q", ErrBlockedURL, host, a) + } + } + return u, nil +} + +// deniedIP 报告 ip 是否属于守卫默认拒绝的网段:回环、私网、链路本地(单播/组播)、 +// 未指定。口径与 `plugins/tools/io/ssrf.go` 的 `isDeniedIP` 逐条相同。 +func deniedIP(ip net.IP) bool { + if ip == nil { + return false + } + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsUnspecified() +} + +// hostAllowed 报告 host 是否命中白名单(逐条按主机名比较,大小写不敏感,端口忽略)。 +func hostAllowed(host string, allowed []string) bool { + h := strings.ToLower(strings.TrimSpace(host)) + if h == "" { + return false + } + for _, a := range allowed { + if strings.EqualFold(hostOf(a), h) { + return true + } + } + return false +} + +// hostOf 从白名单条目里取主机名:允许写成 `host`、`host:port` 或完整 URL。 +func hostOf(entry string) string { + raw := strings.TrimSpace(entry) + if raw == "" { + return "" + } + if u, err := url.Parse(raw); err == nil && u.Hostname() != "" { + return u.Hostname() + } + if host, _, err := net.SplitHostPort(raw); err == nil { + return host + } + return raw +} diff --git a/internal/capability/browser/guard_test.go b/internal/capability/browser/guard_test.go new file mode 100644 index 0000000..dce63a5 --- /dev/null +++ b/internal/capability/browser/guard_test.go @@ -0,0 +1,90 @@ +package browser + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestGuardURL_DeniesLoopbackAndPrivate 钉住守卫的**默认拒绝面**:回环、私网、 +// 链路本地、非 http(s) 协议、云元数据端点一律拒绝 —— 这是 design/28 §4 第 1 条 +// (服务端代取任意 URL 必须过闸)的可执行判据。 +func TestGuardURL_DeniesLoopbackAndPrivate(t *testing.T) { + t.Parallel() + denied := []string{ + "http://127.0.0.1/", + "http://localhost/", + "http://[::1]/", + "http://192.168.1.10/admin", + "http://10.0.0.5/", + "http://172.16.3.4/", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0/", + "file:///etc/passwd", + "ftp://example.com/", + "http:///no-host", + "", + } + for _, raw := range denied { + if _, err := GuardURL(context.Background(), raw, nil); err == nil { + t.Errorf("GuardURL(%q) = nil,应当拒绝", raw) + } else if !strings.Contains(err.Error(), "安全守卫拒绝") && !strings.Contains(err.Error(), "URL 被安全守卫拒绝") { + t.Errorf("GuardURL(%q) 的错误不像守卫拒绝: %v", raw, err) + } + } +} + +// TestGuardURL_AllowsPublicAndAllowlisted 钉住**放行面**:公网地址放行;白名单里的 +// 主机(同源自取:本地 searxng / 本机 upload 路由)即使落在回环也放行。 +func TestGuardURL_AllowsPublicAndAllowlisted(t *testing.T) { + t.Parallel() + if _, err := GuardURL(context.Background(), "https://example.com/a?b=1", nil); err != nil { + t.Fatalf("公网地址被拒: %v", err) + } + if _, err := GuardURL(context.Background(), "http://93.184.216.34/", nil); err != nil { + t.Fatalf("公网字面 IP 被拒: %v", err) + } + // 白名单是**按主机名字符串**比较的(与 io/ssrf 的 isHostAllowed 同口径): + // 允许 `localhost` 不等于允许 `127.0.0.1` —— 两者是两个主机名,要各写一条。 + exempt := []struct{ entry, raw string }{ + {"127.0.0.1", "http://127.0.0.1:8080/x"}, + {"http://localhost:8080", "http://localhost/x"}, + {"LocalHost", "http://localhost:9999/x"}, + } + for _, tc := range exempt { + if _, err := GuardURL(context.Background(), tc.raw, []string{tc.entry}); err != nil { + t.Errorf("白名单 %q 未放行 %q: %v", tc.entry, tc.raw, err) + } + } +} + +// TestGuardURL_AllowlistEntryForms 钉住白名单条目的三种写法(裸主机 / host:port / 完整 URL) +// 都按主机名匹配 —— 少一种,面板里写的白名单就会静默失效。 +func TestGuardURL_AllowlistEntryForms(t *testing.T) { + t.Parallel() + for _, entry := range []string{"127.0.0.1", "127.0.0.1:9", "http://127.0.0.1:9999/"} { + if _, err := GuardURL(context.Background(), "http://127.0.0.1:7777/", []string{entry}); err != nil { + t.Errorf("条目 %q 未命中: %v", entry, err) + } + } +} + +// TestGuardURL_RejectsHostResolvingToPrivate 钉住 DNS 侧的判定:一个解析到私网的主机名 +// 也要被拒(用 httptest 起在本机、把它的主机名当目标;`localhost` 已由字面量覆盖, +// 这里覆盖「解析后才发现是私网」这条路径)。 +func TestGuardURL_RejectsHostResolvingToPrivate(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + // httptest 的 URL 形如 http://127.0.0.1:,字面 IP 路径已由上面的用例覆盖; + // 这里钉住「解析结果里含私网地址」的判定函数本身。 + host := strings.TrimPrefix(srv.URL, "http://") + hostIP, _, _ := strings.Cut(host, ":") + if _, err := GuardURL(context.Background(), srv.URL, nil); err == nil { + t.Errorf("httptest 地址 %s 应被拒(字面回环)", hostIP) + } +} diff --git a/internal/capability/browser/info.go b/internal/capability/browser/info.go new file mode 100644 index 0000000..36010ab --- /dev/null +++ b/internal/capability/browser/info.go @@ -0,0 +1,60 @@ +package browser + +import "context" + +// Fingerprint 是一次「这台浏览器到底长什么样」的自检读数,对应 +// `DevDocs/reference/browser-stealth/10 §4.1` 的落地自检项 L2~L5。它存在的意义是 +// **可证伪**:有头到底有没有生效、webdriver 有没有被点亮、屏幕/时区/语言与设定是否 +// 一致 —— 全靠这组**页面自报**的数字,而不是我们以为的启动参数。 +type Fingerprint struct { + // Headless 来自本次会话的启动参数(我们有权威来源:参数是我们自己拼的), + // 不靠嗅探 UA —— `--headless=new` 的 UA **不含** Headless 字样(reference/05)。 + Headless bool `json:"headless"` + // Webdriver 是 `navigator.webdriver`:false 才正常(我们不传 --enable-automation)。 + Webdriver bool `json:"webdriver"` + // UserAgent / Platform 是页面自报的 UA 与平台串(原样,不做任何改写)。 + UserAgent string `json:"userAgent"` + Platform string `json:"platform"` + // Language / Languages 是页面自报的语言列表(应与启动的 --lang 一致)。 + Language string `json:"language"` + Languages []string `json:"languages"` + // Timezone 是 `Intl.DateTimeFormat().resolvedOptions().timeZone`(应与出口 IP 同源)。 + Timezone string `json:"timezone"` + // ScreenWidth / ScreenHeight / ColorDepth 是 `screen.*`(应与 Xvfb 参数或真实桌面一致)。 + ScreenWidth int `json:"screenWidth"` + ScreenHeight int `json:"screenHeight"` + ColorDepth int `json:"colorDepth"` + // OuterWidth / OuterHeight 是窗口外框尺寸:有头下应 > 0(无头常见 0)。 + OuterWidth int `json:"outerWidth"` + OuterHeight int `json:"outerHeight"` + // DevicePixelRatio / HardwareConcurrency 是两条常见的指纹量。 + DevicePixelRatio float64 `json:"devicePixelRatio"` + HardwareConcurrency int `json:"hardwareConcurrency"` +} + +// fingerprintExpr 一次求值取回整组读数:分多次往返会让页面中途重定向后的读数互相 +// 矛盾(一半是旧页、一半是新页)。它只在**隔离世界**里跑(session.evaluate)。 +const fingerprintExpr = `(() => ({ + webdriver: navigator.webdriver === true, + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language, + languages: navigator.languages, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + screenWidth: screen.width, + screenHeight: screen.height, + colorDepth: screen.colorDepth, + outerWidth: window.outerWidth, + outerHeight: window.outerHeight, + devicePixelRatio: window.devicePixelRatio, + hardwareConcurrency: navigator.hardwareConcurrency +}))()` + +// readFingerprint 读一次页面指纹。Headless 取自会话的启动参数,其余取自页面自报。 +func readFingerprint(ctx context.Context, s *session) (Fingerprint, error) { + fp := Fingerprint{Headless: s.headless} + if err := s.evaluate(ctx, fingerprintExpr, &fp); err != nil { + return Fingerprint{}, err + } + return fp, nil +} diff --git a/internal/capability/browser/launch.go b/internal/capability/browser/launch.go new file mode 100644 index 0000000..90ef0e5 --- /dev/null +++ b/internal/capability/browser/launch.go @@ -0,0 +1,270 @@ +package browser + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" +) + +// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。 +const ( + // devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的 + // 文件名(profile 目录下):首行端口、次行 browser_guid + // (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。 + devToolsPortFile = "DevToolsActivePort" + + // portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s, + // 100ms 的粒度既不迟钝也不白烧 CPU。 + portPollInterval = 100 * time.Millisecond + + // defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐 + // (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json) + // 尚未开放语言项 —— 见本片报告「未做」。 + defaultLang = "zh-CN" +) + +// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活 +// 用的那一档):它只问「端口有没有在听」,给得短。 +const adoptProbeTimeout = 2 * time.Second + +// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把 +// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。 +type dialer interface { + // Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。 + Dial(ctx context.Context) (endpoint string, stop func(), err error) + // Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。 + Describe() string + // Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾 + // 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。 + Local() bool +} + +// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。 +// +// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地: +// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、 +// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、 +// `--remote-debugging-port=0`; +// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、 +// `--disable-extensions` / `--disable-default-apps` / +// `--disable-component-extensions-with-background-pages` / `--disable-component-update` / +// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、 +// 以及一切 `--headless*`(有头就是不能有它)。 +// +// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍: +// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows +// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机 +// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**, +// 不开任何对外监听端口(AGENTS §9)。 +type localChrome struct { + exe string + profileDir string + headless bool + viewportW int + viewportH int + timeout time.Duration + log *slog.Logger +} + +// Describe 实现 dialer。 +func (l *localChrome) Describe() string { + mode := "headful" + if l.headless { + mode = "headless" + } + return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode) +} + +// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。 +func (l *localChrome) Local() bool { return true } + +// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。 +// +// 两条**先于启动**的判定(都是为了让「上一次会话留下的浏览器」不至于把功能卡死): +// +// 1. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接 +// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起 +// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写 +// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步 +// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。 +// 2. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。 +// +// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的 +// 渲染进程随之退出,因此进程级 Kill 足够。 +func (l *localChrome) Dial(ctx context.Context) (string, func(), error) { + if strings.TrimSpace(l.exe) == "" { + return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch) + } + if endpoint, ok := l.adoptExisting(ctx); ok { + l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir) + return endpoint, func() {}, nil + } + if err := os.MkdirAll(l.profileDir, 0o700); err != nil { + return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err) + } + _ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile)) + + cmd := exec.Command(l.exe, l.launchArgs()...) + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err) + } + exited := make(chan struct{}) + go func() { + defer close(exited) + _, _ = cmd.Process.Wait() + }() + port, err := l.waitDevToolsPort(ctx, exited) + if err != nil { + stopProcess(cmd) + return "", nil, err + } + l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir) + return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil +} + +// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。 +// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。 +func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) { + port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile)) + if !ok { + return "", false + } + endpoint := fmt.Sprintf("http://127.0.0.1:%d", port) + probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout) + defer cancel() + var version struct { + Browser string `json:"Browser"` + } + if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil { + return "", false + } + return endpoint, true +} + +// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。 +func (l *localChrome) launchArgs() []string { + args := []string{ + // profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。 + "--user-data-dir=" + l.profileDir, + // 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。 + "--remote-debugging-port=0", + // 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝; + // reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」, + // 不开任何对外服务面。 + "--remote-allow-origins=*", + "--no-first-run", + "--no-default-browser-check", + fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH), + "--lang=" + defaultLang, + // 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。 + "--disable-blink-features=AutomationControlled", + "about:blank", + } + if l.headless { + // 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。 + args = append([]string{"--headless=new"}, args...) + } + return args +} + +// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**: +// 进程立刻退出(多半是 profile 锁被占 / 浏览器已在运行)、ctx 取消、超时。 +func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) { + path := filepath.Join(l.profileDir, devToolsPortFile) + deadline := time.NewTimer(l.timeout) + defer deadline.Stop() + ticker := time.NewTicker(portPollInterval) + defer ticker.Stop() + for { + if port, ok := readDevToolsPort(path); ok { + return port, nil + } + select { + case <-exited: + // 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。 + // 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁) + // 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。 + return 0, fmt.Errorf("%w: 浏览器进程立刻退出(多半是该 profile 已被另一个实例占用)", ErrLaunch) + case <-ctx.Done(): + return 0, fmt.Errorf("%w: 等待调试端口时被取消: %v", ErrLaunch, ctx.Err()) + case <-deadline.C: + return 0, fmt.Errorf("%w: 等 %s 超时(%s)", ErrLaunch, devToolsPortFile, l.timeout) + case <-ticker.C: + } + } +} + +// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false +// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。 +func readDevToolsPort(path string) (int, bool) { + raw, err := os.ReadFile(path) + if err != nil { + return 0, false + } + line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n") + port, err := strconv.Atoi(strings.TrimSpace(line)) + if err != nil || port <= 0 || port > 65535 { + return 0, false + } + return port, true +} + +// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium, +// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。 +type remoteCDP struct{ endpoint string } + +// Describe 实现 dialer。 +func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint } + +// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。 +func (r *remoteCDP) Local() bool { return false } + +// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。 +func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) { + endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/") + if endpoint == "" { + return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable) + } + var version struct { + Browser string `json:"Browser"` + } + if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil { + return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return endpoint, func() {}, nil +} + +// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚, +// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。 +// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。 +func stopProcess(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Kill() +} + +// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。 +// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。 +func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer { + if e := strings.TrimSpace(avail.Endpoint); e != "" { + return &remoteCDP{endpoint: e} + } + return &localChrome{ + exe: strings.TrimSpace(avail.Executable), + profileDir: cfg.ProfileDir, + headless: cfg.Headless, + viewportW: cfg.ViewportWidth, + viewportH: cfg.ViewportHeight, + timeout: cfg.Timeout(), + log: log, + } +} diff --git a/internal/capability/browser/launch_test.go b/internal/capability/browser/launch_test.go new file mode 100644 index 0000000..6b0ee9f --- /dev/null +++ b/internal/capability/browser/launch_test.go @@ -0,0 +1,195 @@ +package browser + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestLaunchArgs_HeadfulCarriesNoAutomationTraces 是本片**最重要的一条测试**:它把 +// `DevDocs/reference/browser-stealth/10 §2.1` 的黑名单钉成可执行判据 —— +// 有头路径**不许**出现任何 `--headless*`,也不许出现「自动化味道」的参数 +// (`--enable-automation` 以及 Playwright/Puppeteer 默认传的那一串)。 +// 有人往清单里加回一条,这里立刻红。 +func TestLaunchArgs_HeadfulCarriesNoAutomationTraces(t *testing.T) { + t.Parallel() + l := &localChrome{exe: "chrome.exe", profileDir: `C:\tmp\p`, headless: false, viewportW: 1280, viewportH: 800} + args := l.launchArgs() + + forbidden := []string{ + "--headless", + "--enable-automation", + "--disable-extensions", + "--disable-default-apps", + "--disable-component-extensions-with-background-pages", + "--disable-component-update", + "--disable-popup-blocking", + } + for _, bad := range forbidden { + for _, a := range args { + if strings.HasPrefix(a, bad) { + t.Errorf("有头启动参数里出现了不许传的 %q:%v", bad, args) + } + } + } +} + +// TestLaunchArgs_RequiredFlagsPresent 钉住「必须传」的那几条:profile、随机回环调试 +// 端口、窗口尺寸、语言、免首次运行、以及 AutomationControlled 兜底防线。 +func TestLaunchArgs_RequiredFlagsPresent(t *testing.T) { + t.Parallel() + l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/prof", headless: false, viewportW: 1024, viewportH: 768} + args := l.launchArgs() + for _, want := range []string{ + "--user-data-dir=/tmp/prof", + "--remote-debugging-port=0", + "--window-size=1024,768", + "--lang=zh-CN", + "--no-first-run", + "--no-default-browser-check", + "--disable-blink-features=AutomationControlled", + "--remote-allow-origins=*", + "about:blank", + } { + if !containsArg(args, want) { + t.Errorf("启动参数缺少 %q:%v", want, args) + } + } +} + +// TestLaunchArgs_HeadlessOnlyWhenConfigured 钉住「无头只在显式配置时出现」: +// 默认档是有头(design/28 §2),无头是面板显式降级的结果。 +func TestLaunchArgs_HeadlessOnlyWhenConfigured(t *testing.T) { + t.Parallel() + l := &localChrome{exe: "chrome.exe", profileDir: "/tmp/p", headless: true, viewportW: 800, viewportH: 600} + args := l.launchArgs() + if len(args) == 0 || args[0] != "--headless=new" { + t.Errorf("无头档的第一个参数应为 --headless=new,实得:%v", args) + } + if !containsArg(args, "--disable-blink-features=AutomationControlled") { + t.Error("无头档也必须保留 AutomationControlled 防线") + } +} + +// TestReadDevToolsPort 钉住端口文件解析:正常两行、只有一行、缺文件、垃圾内容。 +func TestReadDevToolsPort(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, devToolsPortFile) + + if _, ok := readDevToolsPort(path); ok { + t.Error("文件不存在时不该报 ok") + } + if err := os.WriteFile(path, []byte("54321\nbrowser-guid-xyz\n"), 0o600); err != nil { + t.Fatal(err) + } + if port, ok := readDevToolsPort(path); !ok || port != 54321 { + t.Errorf("读到 (%d,%v), want (54321,true)", port, ok) + } + if err := os.WriteFile(path, []byte("not-a-port\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readDevToolsPort(path); ok { + t.Error("垃圾内容不该报 ok") + } + if err := os.WriteFile(path, []byte("0\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := readDevToolsPort(path); ok { + t.Error("端口 0 不该报 ok") + } +} + +// TestLocalChromeDescribe 钉住来源描述如实反映有头/无头(日志与工具结果都用它)。 +func TestLocalChromeDescribe(t *testing.T) { + t.Parallel() + headful := &localChrome{exe: "chrome.exe"} + if got := headful.Describe(); !strings.Contains(got, "headful") { + t.Errorf("Describe() = %q,应含 headful", got) + } + headless := &localChrome{exe: "chrome.exe", headless: true} + if got := headless.Describe(); !strings.Contains(got, "headless") { + t.Errorf("Describe() = %q,应含 headless", got) + } +} + +// TestLocalChromeAdoptsLiveEndpoint 钉住「认领已在跑的实例」:profile 目录里的 +// DevToolsActivePort 还活着 ⇒ Dial 直接交出那个端点、**不启动任何进程**。 +// 这不是优化:同一 profile 起第二个实例时 Chromium 会委托给已在跑的那个然后自己退出, +// 新进程永远不写端口文件 ⇒ 不做认领,功能会被一个残留实例卡死。 +func TestLocalChromeAdoptsLiveEndpoint(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/json/version" { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"Browser":"Chrome/154.0.0.0"}`)) + })) + defer srv.Close() + port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:") + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte(port+"\nGUID\n"), 0o600); err != nil { + t.Fatal(err) + } + l := &localChrome{exe: "definitely-not-a-browser.exe", profileDir: dir, timeout: time.Second, log: discardLog()} + endpoint, stop, err := l.Dial(context.Background()) + if err != nil { + t.Fatalf("应当认领活着端点,实得: %v", err) + } + if endpoint != "http://127.0.0.1:"+port { + t.Errorf("endpoint = %q", endpoint) + } + stop() // 认领来的实例不归我们杀:停止函数必须是空操作(调用不 panic 即通过) +} + +// TestLocalChromeDoesNotAdoptDeadEndpoint 钉住反面:端口文件在、但没人听 ⇒ **不认领** +// (否则会连到一个死端口,比启动失败更难排查)。 +func TestLocalChromeDoesNotAdoptDeadEndpoint(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, devToolsPortFile), []byte("1\nGUID\n"), 0o600); err != nil { + t.Fatal(err) + } + l := &localChrome{exe: "x", profileDir: dir, timeout: time.Second, log: discardLog()} + if endpoint, ok := l.adoptExisting(context.Background()); ok { + t.Fatalf("死端口不该被认领(endpoint=%q)", endpoint) + } +} + +// TestWaitDevToolsPort_ReportsEarlyExit 钉住「进程立刻退出」要**立刻**如实报,并把 +// 最可能的原因写进错误(profile 被占用)—— 而不是把超时白等完、只留一句「超时」。 +func TestWaitDevToolsPort_ReportsEarlyExit(t *testing.T) { + t.Parallel() + dir := t.TempDir() + l := &localChrome{profileDir: dir, timeout: 10 * time.Second, log: discardLog()} + exited := make(chan struct{}) + close(exited) + start := time.Now() + _, err := l.waitDevToolsPort(context.Background(), exited) + if err == nil { + t.Fatal("进程退出后应当报错") + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("等了 %v 才报错,应当立刻返回", elapsed) + } + if !strings.Contains(err.Error(), "profile") { + t.Errorf("错误里应点明 profile 占用这个最常见原因: %v", err) + } +} + +// containsArg 报告参数表里是否有这一条(逐字相等)。 +func containsArg(args []string, want string) bool { + for _, a := range args { + if a == want { + return true + } + } + return false +} diff --git a/internal/capability/browser/manager.go b/internal/capability/browser/manager.go new file mode 100644 index 0000000..a585ac7 --- /dev/null +++ b/internal/capability/browser/manager.go @@ -0,0 +1,358 @@ +package browser + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "sync" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// 清理与降级编码的具名常量。 +const ( + // minReapInterval / maxReapInterval 夹住空闲清扫的滴答间隔:太密是白烧 CPU, + // 太疏则「空闲 N 秒回收」会拖成「空闲 N 分钟」。 + minReapInterval = 5 * time.Second + maxReapInterval = 30 * time.Second + + // jpegQuality 是 PNG 超过字节上限时降级重抓的 JPEG 质量。80 是「看得清正文、 + // 体积约为 PNG 的 1/5~1/10」的经验值(截图进 vision 用,不需要无损)。 + jpegQuality = 80 +) + +// configView 是 manager 需要的配置切片:从 `browsercfg.Config`(已是归一化后的) +// 投影出来,使本包不必到处引用 browsercfg 的字段名,也让单测能直接构造。 +type configView struct { + profileDir string + shotsDir string + headless bool + viewportW int + viewportH int + timeout time.Duration + idle time.Duration + shotMaxBytes int +} + +// viewOf 把归一化后的配置投影成 configView。 +func viewOf(cfg browsercfg.Config, dataDir string) configView { + profile := cfg.ProfileDir + if profile == "" { + profile = browsercfg.DefaultProfileDir(dataDir) + } + return configView{ + profileDir: profile, + shotsDir: browsercfg.DefaultShotsDir(dataDir), + headless: cfg.Headless, + viewportW: cfg.ViewportWidth, + viewportH: cfg.ViewportHeight, + timeout: cfg.Timeout(), + idle: cfg.IdleTimeout(), + shotMaxBytes: cfg.ScreenshotMaxBytes, + } +} + +// Options 是构造 Manager 的入参。Availability 为空(Usable=false)时 Manager 仍可 +// 构造,但**没有任何会话可用** —— 组合根据此不注册工具(design/28 §7 判据 1)。 +type Options struct { + // Availability 是 browsercfg.Resolve 的结论(来源、可执行文件或端点、原因)。 + Availability browsercfg.Availability + // Config 是已 Normalize 的配置。 + Config browsercfg.Config + // DataDir 是数据目录(profile 与截图落点的根)。 + DataDir string + // AllowedHosts 是 SSRF 白名单(回环/私网目标里允许的例外)。 + AllowedHosts []string + // Logger 是基日志;本包统一补 `capability/browser` 来源。 + Logger *slog.Logger +} + +// logSource 是本包的日志来源标签(AGENTS §10:禁止无来源裸日志)。 +const logSource = "capability/browser" + +// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:本包允许 Options.Logger +// 为 nil(单测夹具常这样造),而 AGENTS §10 禁止无来源裸日志 —— 所以 nil 的解释是 +// 「不输出」而不是「不标注」,这样 m.log 永不为 nil,调用点不必到处判空。 +func discardLog() *slog.Logger { + return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource) +} + +// Manager 是浏览器会话的**租约管理器**:并发上限 1(同一个 profile 起两个实例会 +// 互锁)、空闲 N 秒回收、会话失效后下次自动重起。design/28 §3。 +type Manager struct { + mu sync.Mutex + cfg configView + d dialer + log *slog.Logger + allowed []string + sess *session + lastUsed time.Time + closed bool + source string + + stopReaper chan struct{} + reaperDone chan struct{} + closeOnce sync.Once +} + +// NewManager 构造管理器并启动空闲清扫协程。avail 不可用时 d 为 nil,所有动作都会 +// 返回 ErrUnavailable(**不假装可用**);调用方仍应据 Available() 决定是否接线。 +func NewManager(opts Options) *Manager { + log := discardLog() + if opts.Logger != nil { + log = logger.WithSource(opts.Logger, logSource) + } + cfg := opts.Config + cfg = cfg.Normalize(opts.DataDir) + m := &Manager{ + cfg: viewOf(cfg, opts.DataDir), + log: log, + allowed: append([]string(nil), opts.AllowedHosts...), + stopReaper: make(chan struct{}), + reaperDone: make(chan struct{}), + } + m.source = opts.Availability.Detail + if opts.Availability.Usable { + m.d = newDialer(opts.Availability, cfg, log) + } + go m.reapLoop() + return m +} + +// Available 报告当前有没有可用来源(组合根据此决定是否注册工具)。 +func (m *Manager) Available() bool { return m != nil && m.d != nil } + +// Source 返回来源描述(进日志与工具结果;不含凭据)。 +func (m *Manager) Source() string { + if m == nil { + return "" + } + return m.source +} + +// Open 在受 SSRF 守卫保护的 URL 上导航,返回页面**自报**的地址/标题/就绪态。 +func (m *Manager) Open(ctx context.Context, rawURL string) (PageResult, error) { + if _, err := GuardURL(ctx, rawURL, m.allowed); err != nil { + m.log.Warn("browser navigate blocked by URL guard", "reason", ReasonBlockedURL, "error", err) + return PageResult{}, err + } + out := PageResult{Source: m.Source(), Headless: m.cfg.headless} + err := m.withSession(ctx, func(s *session) error { + state, err := s.navigate(ctx, rawURL) + if err != nil { + return err + } + out.URL, out.Title, out.ReadyState = state.URL, state.Title, state.ReadyState + return nil + }) + if err != nil { + return PageResult{}, err + } + return out, nil +} + +// PageResult 是一次导航的结论(全部来自页面自报,不做美化)。 +type PageResult struct { + // URL 是导航结束后页面自报的地址(可能因重定向与请求的不同)。 + URL string `json:"url"` + // Title 是页面标题。 + Title string `json:"title"` + // ReadyState 是 document.readyState(等就绪后才返回,正常为 complete)。 + ReadyState string `json:"readyState"` + // Source 是会话来源(本机有头 / 容器端点),如实标注走的是哪条路。 + Source string `json:"source"` + // Headless 是本次会话是否无头(默认 false = 有头)。 + Headless bool `json:"headless"` +} + +// Screenshot 抓一张视口截图并落盘(0600)。超过配置上限时先降级为 JPEG 重抓, +// 仍超限则**如实报错**,绝不交出半张坏图。 +func (m *Manager) Screenshot(ctx context.Context) (Shot, error) { + var shot Shot + err := m.withSession(ctx, func(s *session) error { + data, format, err := m.capture(ctx, s) + if err != nil { + return err + } + saved, err := saveShot(m.cfg.shotsDir, data, format) + if err != nil { + return err + } + shot = saved + return nil + }) + if err != nil { + return Shot{}, err + } + return shot, nil +} + +// capture 取截图的**字节与真实格式**:先 PNG,超上限才退 JPEG,退不下来就报错。 +func (m *Manager) capture(ctx context.Context, s *session) ([]byte, string, error) { + png, err := s.captureScreenshot(ctx, "png", 0) + if err != nil { + return nil, "", err + } + if len(png) <= m.cfg.shotMaxBytes { + return png, shotExtPNG, nil + } + jpeg, jerr := s.captureScreenshot(ctx, "jpeg", jpegQuality) + if jerr != nil { + return nil, "", fmt.Errorf("%w: PNG %d 字节超上限 %d,JPEG 降级重抓也失败: %v", + ErrScreenshotTooLarge, len(png), m.cfg.shotMaxBytes, jerr) + } + if len(jpeg) > m.cfg.shotMaxBytes { + return nil, "", fmt.Errorf("%w: PNG %d / JPEG %d 字节都超过上限 %d", + ErrScreenshotTooLarge, len(png), len(jpeg), m.cfg.shotMaxBytes) + } + return jpeg, shotExtJPEG, nil +} + +// Fingerprint 在**隔离世界**里读一次页面指纹(reference/10 §4.1 的 L2~L5 自检项), +// 用来证明「这次跑的是有头、webdriver 关、屏幕/时区/语言与设定一致」。 +func (m *Manager) Fingerprint(ctx context.Context) (Fingerprint, error) { + var fp Fingerprint + err := m.withSession(ctx, func(s *session) error { + got, err := readFingerprint(ctx, s) + if err != nil { + return err + } + fp = got + return nil + }) + if err != nil { + return Fingerprint{}, err + } + return fp, nil +} + +// withSession 在一把大锁下完成一次动作:锁本身就是「并发上限 1」的实现(design/28 +// §3),而不是另造一个信号量。会话在以下任一情况下重建:还没有、已空闲超时、上次 +// 动作报了连接级错误(dropLocked)。 +func (m *Manager) withSession(ctx context.Context, fn func(*session) error) error { + m.mu.Lock() + defer m.mu.Unlock() + s, err := m.ensureLocked(ctx) + if err != nil { + return err + } + m.lastUsed = time.Now() + if err := fn(s); err != nil { + if errors.Is(err, ErrCDP) { + m.dropLocked(err) + } + return err + } + return nil +} + +// ensureLocked 交出一个可用会话(调用方必须已持锁)。 +func (m *Manager) ensureLocked(ctx context.Context) (*session, error) { + if m.closed { + return nil, fmt.Errorf("%w: %s", ErrNoSession, ReasonClosed) + } + if m.d == nil { + return nil, fmt.Errorf("%w: %s", ErrUnavailable, m.source) + } + if m.sess != nil && m.idleExpired() { + m.recycleLocked(ReasonIdleRecycled) + } + if m.sess != nil { + return m.sess, nil + } + openCtx, cancel := context.WithTimeout(ctx, m.cfg.timeout) + defer cancel() + s, err := openSession(openCtx, m.d, m.cfg) + if err != nil { + m.log.Warn("browser session open failed", "source", m.source, "error", err) + return nil, err + } + m.sess = s + m.lastUsed = time.Now() + m.log.Info("browser session opened", "source", m.source, "headless", m.cfg.headless) + return s, nil +} + +// idleExpired 报告当前会话是否已超过空闲上限。 +func (m *Manager) idleExpired() bool { + if m.cfg.idle <= 0 { + return false + } + return time.Since(m.lastUsed) >= m.cfg.idle +} + +// recycleLocked 关掉当前会话并留一条可追溯的日志(原因分类,不美化)。 +func (m *Manager) recycleLocked(reason Reason) { + if m.sess == nil { + return + } + m.sess.Close() + m.sess = nil + m.log.Info("browser session recycled", "reason", reason, "detail", reason.Message()) +} + +// dropLocked 因连接级错误丢弃当前会话:下一个动作会重起一个,而不是一直对着一具 +// 尸体报错(崩溃重启的退避由「每次动作只重试一次」自然给出)。 +func (m *Manager) dropLocked(cause error) { + if m.sess == nil { + return + } + m.log.Warn("browser session dropped after connection error", "error", cause) + m.sess.Close() + m.sess = nil +} + +// reapLoop 是空闲清扫协程:滴答间隔跟随空闲上限的一半(夹在具名上下界之间)。 +func (m *Manager) reapLoop() { + defer close(m.reaperDone) + interval := m.cfg.idle / 2 + if interval < minReapInterval { + interval = minReapInterval + } + if interval > maxReapInterval { + interval = maxReapInterval + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-m.stopReaper: + return + case <-ticker.C: + m.reapOnce() + } + } +} + +// reapOnce 做一次空闲判定(供清扫协程与单测调用)。 +func (m *Manager) reapOnce() { + m.mu.Lock() + defer m.mu.Unlock() + if m.closed || m.sess == nil || !m.idleExpired() { + return + } + m.recycleLocked(ReasonIdleRecycled) +} + +// Close 停掉清扫协程并关掉会话(幂等,可重复调用)。 +func (m *Manager) Close() error { + if m == nil { + return nil + } + m.closeOnce.Do(func() { + close(m.stopReaper) + <-m.reaperDone + m.mu.Lock() + m.closed = true + if m.sess != nil { + m.sess.Close() + m.sess = nil + } + m.mu.Unlock() + }) + return nil +} diff --git a/internal/capability/browser/manager_test.go b/internal/capability/browser/manager_test.go new file mode 100644 index 0000000..10397b6 --- /dev/null +++ b/internal/capability/browser/manager_test.go @@ -0,0 +1,179 @@ +package browser + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg" +) + +// newTestManager 造一个管理器并在用例结束时关掉(reaper 协程必须收敛,否则用例会漏协程)。 +func newTestManager(t *testing.T, avail browsercfg.Availability) *Manager { + t.Helper() + m := NewManager(Options{ + Availability: avail, + Config: browsercfg.Defaults(t.TempDir()), + DataDir: t.TempDir(), + }) + t.Cleanup(func() { _ = m.Close() }) + return m +} + +// TestManager_UnavailableDoesNotPretend 钉住「不可用就不假装可用」:没有来源时 +// Available() == false,每个动作都返回 ErrUnavailable(组合根据前者决定不注册工具)。 +func TestManager_UnavailableDoesNotPretend(t *testing.T) { + t.Parallel() + m := newTestManager(t, browsercfg.Availability{Usable: false, Detail: "本机没有浏览器"}) + if m.Available() { + t.Fatal("没有来源时 Available() 应为 false") + } + ctx := context.Background() + if _, err := m.Open(ctx, "https://example.com/"); err == nil { + t.Error("Open 应当报不可用") + } + if _, err := m.Screenshot(ctx); err == nil { + t.Error("Screenshot 应当报不可用") + } + if _, err := m.Fingerprint(ctx); err == nil { + t.Error("Fingerprint 应当报不可用") + } +} + +// TestManager_BlockedURLRejectedBeforeAnyProcess 钉住**顺序**:SSRF 守卫在起浏览器 +// **之前**跑。可执行文件是假的,若顺序颠倒,这里会拿到启动错误而不是守卫错误。 +func TestManager_BlockedURLRejectedBeforeAnyProcess(t *testing.T) { + t.Parallel() + m := newTestManager(t, browsercfg.Availability{ + Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe", + }) + _, err := m.Open(context.Background(), "http://169.254.169.254/latest/meta-data/") + if err == nil { + t.Fatal("云元数据地址应当被拒") + } + if !strings.Contains(err.Error(), "安全守卫") { + t.Fatalf("错误应来自 URL 守卫,实得: %v", err) + } +} + +// TestManager_LaunchFailureIsClassified 钉住「起不来就如实报」:不存在的可执行文件 +// 必须包住 ErrLaunch,而不是含糊成「失败」。 +func TestManager_LaunchFailureIsClassified(t *testing.T) { + t.Parallel() + m := newTestManager(t, browsercfg.Availability{ + Usable: true, Source: browsercfg.SourceLocalChrome, Executable: "definitely-not-a-browser.exe", + }) + _, err := m.Open(context.Background(), "https://example.com/") + if err == nil { + t.Fatal("假可执行文件应当启动失败") + } + if !strings.Contains(err.Error(), "启动本机浏览器失败") { + t.Fatalf("错误应来自 ErrLaunch,实得: %v", err) + } +} + +// TestManager_IdleRecycle 钉住空闲回收:超过空闲上限的会话在下一次判定时被关掉 +// (design/28 §3「空闲 N 秒回收」)。这里直接构造会话与时间,不真起浏览器。 +func TestManager_IdleRecycle(t *testing.T) { + t.Parallel() + cfg := browsercfg.Defaults(t.TempDir()) + cfg.IdleTimeoutSeconds = 1 + m := NewManager(Options{Availability: browsercfg.Availability{Usable: true}, Config: cfg, DataDir: t.TempDir()}) + t.Cleanup(func() { _ = m.Close() }) + + m.mu.Lock() + m.sess = &session{} // 空会话:Close 对 nil client / nil stop 是安全的 + m.lastUsed = time.Now().Add(-2 * time.Second) + m.mu.Unlock() + + m.reapOnce() + + m.mu.Lock() + alive := m.sess != nil + m.mu.Unlock() + if alive { + t.Fatal("空闲超时后会话应被回收") + } +} + +// TestManager_KeepsFreshSession 钉住「没过期就别动它」:不活跃判定的下限。 +func TestManager_KeepsFreshSession(t *testing.T) { + t.Parallel() + cfg := browsercfg.Defaults(t.TempDir()) + cfg.IdleTimeoutSeconds = 3600 + m := NewManager(Options{Availability: browsercfg.Availability{Usable: true}, Config: cfg, DataDir: t.TempDir()}) + t.Cleanup(func() { _ = m.Close() }) + + m.mu.Lock() + m.sess = &session{} + m.lastUsed = time.Now() + m.mu.Unlock() + + m.reapOnce() + + m.mu.Lock() + alive := m.sess != nil + m.mu.Unlock() + if !alive { + t.Fatal("未超时的会话不该被回收") + } +} + +// TestManager_CloseIsIdempotentAndBlocksFurtherWork 钉住关闭语义:重复 Close 不 panic, +// 关闭后动作一律报「已关闭」。 +func TestManager_CloseIsIdempotentAndBlocksFurtherWork(t *testing.T) { + t.Parallel() + m := NewManager(Options{ + Availability: browsercfg.Availability{Usable: true, Executable: "x.exe"}, + Config: browsercfg.Defaults(t.TempDir()), + DataDir: t.TempDir(), + }) + if err := m.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := m.Close(); err != nil { + t.Fatalf("第二次 Close: %v", err) + } + if _, err := m.Open(context.Background(), "https://example.com/"); err == nil { + t.Fatal("关闭后 Open 应当报错") + } +} + +// TestViewOf_ProjectsNormalizedConfig 钉住配置投影:profile 与截图目录落在 dataDir 下, +// 超时/空闲/上限都来自配置(不是本包另造一套默认值)。 +func TestViewOf_ProjectsNormalizedConfig(t *testing.T) { + t.Parallel() + dataDir := t.TempDir() + cfg := browsercfg.Defaults(dataDir) + v := viewOf(cfg, dataDir) + if v.profileDir != browsercfg.DefaultProfileDir(dataDir) { + t.Errorf("profileDir = %q", v.profileDir) + } + if v.shotsDir != browsercfg.DefaultShotsDir(dataDir) { + t.Errorf("shotsDir = %q", v.shotsDir) + } + if v.viewportW != browsercfg.DefaultViewportWidth || v.viewportH != browsercfg.DefaultViewportHeight { + t.Errorf("视口 = %dx%d", v.viewportW, v.viewportH) + } + if v.timeout != cfg.Timeout() || v.idle != cfg.IdleTimeout() { + t.Errorf("超时投影不一致: %v / %v", v.timeout, v.idle) + } + if v.shotMaxBytes != browsercfg.DefaultScreenshotMaxBytes { + t.Errorf("截图上限 = %d", v.shotMaxBytes) + } +} + +// TestReasonMessagesAreNonEmpty 钉住「原因码都有话说」:日志/面板只打原因码而不 +// 附文案的地方,读的人要能懂。 +func TestReasonMessagesAreNonEmpty(t *testing.T) { + t.Parallel() + for _, r := range []Reason{ + ReasonLaunched, ReasonRemoteCDP, ReasonIdleRecycled, ReasonLaunchFailed, + ReasonNoPageTarget, ReasonBlockedURL, ReasonNavigateFailed, ReasonShotTooLarge, ReasonClosed, + } { + if r.Message() == "" { + t.Errorf("原因码 %q 没有文案", r) + } + } +} diff --git a/internal/capability/browser/session.go b/internal/capability/browser/session.go new file mode 100644 index 0000000..785a236 --- /dev/null +++ b/internal/capability/browser/session.go @@ -0,0 +1,276 @@ +package browser + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// browserCloseTimeout 是收尾时「优雅关掉浏览器」那一条命令的预算。给得短:它失败也 +// 不该让一次会话回收拖住调用方(自有进程还有 Kill 兜底)。 +const browserCloseTimeout = 3 * time.Second + +// readyPollInterval 是「等页面就绪」的轮询间隔。200ms 是冷启动导航与抖动之间的 +// 折中:比它更密只是白烧 CPU,比它更疏会让一次 3s 的导航多等出可感知的延迟。 +const readyPollInterval = 200 * time.Millisecond + +// session 是一次浏览器会话:一条 CDP 连接 + 一个 page target。它**不管理生命周期** +// (何时起、何时回收由 manager 决定),只做「连上、导航、截图、求值」四件事。 +type session struct { + client *cdpClient + stop func() + endpoint string + source string + timeout time.Duration + viewport [2]int + headless bool + // local 报告这条会话是不是「我们自己的本机浏览器」:只有它为 true 时收尾才允许 + // 发 Browser.close(容器/手动实例是别人的,绝不代关)。 + local bool +} + +// pageState 是导航之后可以从页面里读到的**真实**状态(不是我们以为的状态)。 +type pageState struct { + URL string `json:"url"` + Title string `json:"title"` + ReadyState string `json:"readyState"` +} + +// openSession 起一条会话:dialer 交出端点 → 找 page target → 连 CDP → 开 Page 域。 +// 中途任何一步失败都会把已占用的资源还回去(不泄漏子进程)。 +func openSession(ctx context.Context, d dialer, cfg configView) (*session, error) { + endpoint, stop, err := d.Dial(ctx) + if err != nil { + return nil, err + } + wsURL, err := pageTargetWS(ctx, endpoint) + if err != nil { + stop() + return nil, err + } + client, err := newCDPClient(ctx, wsURL) + if err != nil { + stop() + return nil, err + } + s := &session{ + client: client, + stop: stop, + endpoint: endpoint, + source: d.Describe(), + timeout: cfg.timeout, + viewport: [2]int{cfg.viewportW, cfg.viewportH}, + headless: cfg.headless, + local: d.Local(), + } + // Page 域是导航与截图的前提;它**不是**反爬信号(被点名的是 Runtime.enable)。 + if err := s.client.Call(ctx, "Page.enable", nil, nil); err != nil { + s.Close() + return nil, err + } + return s, nil +} + +// pageTargetWS 从 `/json/list` 里挑第一个 page target 的 WebSocket 地址。 +func pageTargetWS(ctx context.Context, endpoint string) (string, error) { + var targets []struct { + Type string `json:"type"` + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := httpGetJSON(ctx, endpoint+"/json/list", &targets); err != nil { + return "", err + } + for _, t := range targets { + if t.Type == "page" && t.WebSocketDebuggerURL != "" { + return t.WebSocketDebuggerURL, nil + } + } + return "", fmt.Errorf("%w: %s 下没有可用的 page target", ErrCDP, endpoint) +} + +// navigate 导航到 url 并等页面就绪,随后读回页面自报的地址/标题/就绪态。 +func (s *session) navigate(ctx context.Context, url string) (pageState, error) { + var res struct { + ErrorText string `json:"errorText"` + } + if err := s.client.Call(ctx, "Page.navigate", map[string]any{"url": url}, &res); err != nil { + return pageState{}, err + } + if text := strings.TrimSpace(res.ErrorText); text != "" { + return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text) + } + if err := s.waitReady(ctx); err != nil { + return pageState{}, err + } + return s.pageState(ctx) +} + +// waitReady 轮询 `document.readyState` 直到 `complete`;ctx 到期即停止(错误如实报)。 +// +// 为什么轮询而不是等 `Page.loadEventFired` 事件:本薄层的 CDP 客户端刻意不消费事件 +// (见 cdp.go),而 readyState 轮询对「导航后立刻被重定向 / 单页应用」也更宽容。 +func (s *session) waitReady(ctx context.Context) error { + ticker := time.NewTicker(readyPollInterval) + defer ticker.Stop() + for { + state, err := s.pageState(ctx) + if err == nil && state.ReadyState == "complete" { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("%w: 等待页面就绪超时: %v", ErrNavigate, ctx.Err()) + case <-ticker.C: + } + } +} + +// pageState 读一次页面的 url / title / readyState(一次求值,避免三次往返的竞态)。 +func (s *session) pageState(ctx context.Context) (pageState, error) { + var out pageState + expr := `(() => ({url: location.href, title: document.title, readyState: document.readyState}))()` + if err := s.evaluate(ctx, expr, &out); err != nil { + return pageState{}, err + } + return out, nil +} + +// evaluate 在**隔离世界**里求值并把结果按值取回。它绝不调用 `Runtime.enable` +// (design/28 §2 的「CDP 层不自曝」,reference/browser-stealth/06 的 P0)。 +func (s *session) evaluate(ctx context.Context, expr string, out any) error { + ctxID, err := s.isolatedContext(ctx) + if err != nil { + return err + } + var res struct { + Result struct { + Value json.RawMessage `json:"value"` + } `json:"result"` + ExceptionDetails *struct { + Text string `json:"text"` + } `json:"exceptionDetails"` + } + params := map[string]any{ + "expression": expr, + "contextId": ctxID, + "returnByValue": true, + "awaitPromise": true, + } + if err := s.client.Call(ctx, "Runtime.evaluate", params, &res); err != nil { + return err + } + if res.ExceptionDetails != nil { + return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text) + } + if out == nil || len(res.Result.Value) == 0 { + return nil + } + if err := json.Unmarshal(res.Result.Value, out); err != nil { + return fmt.Errorf("%w: 解码求值结果: %v", ErrEvaluate, err) + } + return nil +} + +// isolatedContext 取一个隔离世界的执行上下文 id:`Page.getFrameTree` 拿主 frame, +// `Page.createIsolatedWorld` 在它里面开一个**匿名**世界。worldName 刻意留空 —— +// utility world 的**名字**本身就是可识别信号(reference/06),不该起 driver 味的名字。 +func (s *session) isolatedContext(ctx context.Context) (int, error) { + var tree struct { + FrameTree struct { + Frame struct { + ID string `json:"id"` + } `json:"frame"` + } `json:"frameTree"` + } + if err := s.client.Call(ctx, "Page.getFrameTree", nil, &tree); err != nil { + return 0, err + } + frameID := tree.FrameTree.Frame.ID + if frameID == "" { + return 0, fmt.Errorf("%w: 拿不到主 frame id", ErrEvaluate) + } + var world struct { + ExecutionContextID int `json:"executionContextId"` + } + if err := s.client.Call(ctx, "Page.createIsolatedWorld", map[string]any{"frameId": frameID}, &world); err != nil { + return 0, err + } + if world.ExecutionContextID == 0 { + return 0, fmt.Errorf("%w: createIsolatedWorld 未返回 executionContextId", ErrEvaluate) + } + return world.ExecutionContextID, nil +} + +// captureScreenshot 抓一张图(format = png / jpeg,quality 仅 JPEG 有意义)。 +// `captureBeyondViewport=false`:我们要的是**用户看到的那个视口**,不是整页长图 +// (整页长图在动态页上会触发 ScrollTo 行为,更像自动化)。 +func (s *session) captureScreenshot(ctx context.Context, format string, quality int) ([]byte, error) { + params := map[string]any{"format": format, "captureBeyondViewport": false} + if quality > 0 { + params["quality"] = quality + } + var res struct { + Data string `json:"data"` + } + if err := s.client.Call(ctx, "Page.captureScreenshot", params, &res); err != nil { + return nil, err + } + data, err := base64.StdEncoding.DecodeString(res.Data) + if err != nil { + return nil, fmt.Errorf("%w: 解码截图: %v", ErrCDP, err) + } + if len(data) == 0 { + return nil, fmt.Errorf("%w: 截图返回了空数据", ErrCDP) + } + return data, nil +} + +// Close 关连接并释放会话占用的资源(幂等)。顺序讲究: +// +// 1. 本机会话先发一条 **`Browser.close`** 优雅关掉浏览器 —— 这是**认领来的实例** +// (不是我们的子进程、杀不掉)唯一的收尾手段,也比 Kill 干净(profile 落盘完整、 +// 不留 SingletonLock 残留)。容器/手动实例(`Local()==false`)**绝不代关**。 +// 2. 再关我们这一条 CDP 连接。 +// 3. 最后对自有进程补一次 Kill(优雅关停已经生效时,这一次是无害的兜底)。 +func (s *session) Close() { + if s == nil { + return + } + if s.local { + ctx, cancel := context.WithTimeout(context.Background(), browserCloseTimeout) + s.requestBrowserClose(ctx) + cancel() + } + if s.client != nil { + _ = s.client.Close() + s.client = nil + } + if s.stop != nil { + s.stop() + s.stop = nil + } +} + +// requestBrowserClose 在**浏览器级**端点发一条 `Browser.close`。失败一律咽下: +// 收尾路径上的失败不该覆盖调用方正在处理的错误(进程 Kill 仍是兜底)。 +func (s *session) requestBrowserClose(ctx context.Context) { + var version struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := httpGetJSON(ctx, s.endpoint+"/json/version", &version); err != nil { + return + } + if strings.TrimSpace(version.WebSocketDebuggerURL) == "" { + return + } + browser, err := newCDPClient(ctx, version.WebSocketDebuggerURL) + if err != nil { + return + } + defer func() { _ = browser.Close() }() + // 浏览器关掉时这条连接会一起断,因此这次 Call 的返回值不可信,不再判读。 + _ = browser.Call(ctx, "Browser.close", nil, nil) +} diff --git a/internal/capability/browser/shot.go b/internal/capability/browser/shot.go new file mode 100644 index 0000000..b5a1b24 --- /dev/null +++ b/internal/capability/browser/shot.go @@ -0,0 +1,98 @@ +package browser + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "image" + _ "image/jpeg" // 注册 JPEG 解码器:截图的宽高要如实报,不能靠猜 + _ "image/png" // 注册 PNG 解码器 + "os" + "path/filepath" + "strconv" + "time" +) + +// 截图落盘的具名常量。 +const ( + // shotFileMode 是截图文件的权限:0600。截图里有页面内容,可能是登录态、二维码、 + // 订单号 —— 它按凭据对待(design/28 §4 第 3 条)。 + shotFileMode os.FileMode = 0o600 + + // shotDirMode 是截图目录的权限:0700(只有本用户可进)。 + shotDirMode os.FileMode = 0o700 + + // shotPrefix 是截图文件名前缀,便于人工在 `$DATA_DIR/browser/shots/` 里辨认。 + shotPrefix = "shot-" + + // shotExtPNG 与 shotExtJPEG 是两种落盘扩展名,也是「实际用了哪种编码」的凭证。 + shotExtPNG = "png" + shotExtJPEG = "jpeg" +) + +// Shot 是一次截图的落盘结论:**路径 + 指纹 + 真实尺寸**,供 vision/image-crop +// 循既有路径进上下文,也供面板按路径展示(B4)。 +type Shot struct { + // Path 是落盘的绝对路径(0600)。它是闭环入口:vision 工具按本地路径读它。 + Path string `json:"path"` + // Bytes 是落盘字节数(如实,不含任何缩放)。 + Bytes int `json:"bytes"` + // SHA256 是内容指纹的十六进制全串(对齐 reference/browser-stealth/07 §6 的字段口径)。 + SHA256 string `json:"sha256"` + // Format 是实际使用的编码("png",或超上限降级后的 "jpeg")。 + Format string `json:"format"` + // Width / Height 是从字节里解出来的**真实**像素尺寸;解不出来时为 0 且 + // DimensionsKnown=false(不编造数字)。 + Width int `json:"width"` + Height int `json:"height"` + // DimensionsKnown 报告宽高是否可信(false ⇒ 字节不是本函数认识的图像格式)。 + DimensionsKnown bool `json:"dimensionsKnown"` + // Degraded 报告这张图是否是「PNG 超上限、降级成 JPEG」的产物(如实标注)。 + Degraded bool `json:"degraded"` +} + +// saveShot 把截图字节写进 dir(0700),文件 0600,返回结论。 +// +// 文件名 = `shot-<36 进制纳秒时间戳>.`:时间戳是的的确确的(便于按时间排序), +// 而**不含任何 URL/站点信息** —— 截图路径会进上下文与日志,站点名本身就是信息 +// (对齐 design/28 §4「审计只记必要项」)。 +func saveShot(dir string, data []byte, format string) (Shot, error) { + if len(data) == 0 { + return Shot{}, fmt.Errorf("browser: 截图字节为空") + } + if err := os.MkdirAll(dir, shotDirMode); err != nil { + return Shot{}, fmt.Errorf("browser: 建截图目录 %s: %w", dir, err) + } + ext := shotExtPNG + if format == shotExtJPEG { + ext = shotExtJPEG + } + name := shotPrefix + strconv.FormatInt(time.Now().UnixNano(), 36) + "." + ext + path := filepath.Join(dir, name) + if err := os.WriteFile(path, data, shotFileMode); err != nil { + return Shot{}, fmt.Errorf("browser: 写截图 %s: %w", path, err) + } + sum := sha256.Sum256(data) + width, height, known := imageSize(data) + return Shot{ + Path: path, + Bytes: len(data), + SHA256: hex.EncodeToString(sum[:]), + Format: ext, + Width: width, + Height: height, + DimensionsKnown: known, + Degraded: ext == shotExtJPEG, + }, nil +} + +// imageSize 从字节里解出像素宽高。解不出来返回 (0, 0, false) —— 如实标注未知, +// 而不是拿配置里的视口尺寸冒充(那是「我们以为的」,不是「图里的」)。 +func imageSize(data []byte) (int, int, bool) { + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { + return 0, 0, false + } + return cfg.Width, cfg.Height, true +} diff --git a/internal/capability/browser/shot_test.go b/internal/capability/browser/shot_test.go new file mode 100644 index 0000000..178cac8 --- /dev/null +++ b/internal/capability/browser/shot_test.go @@ -0,0 +1,88 @@ +package browser + +import ( + "bytes" + "image" + "image/color" + "image/png" + "os" + "path/filepath" + "runtime" + "testing" +) + +// pngBytes 造一张 w×h 的合法 PNG(用标准编码器,保证 imageSize 真能解出来)。 +func pngBytes(t *testing.T, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + img.Set(0, 0, color.RGBA{R: 1, G: 2, B: 3, A: 255}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("编码 PNG: %v", err) + } + return buf.Bytes() +} + +// TestSaveShot_Writes0600AndDimensions 钉住截图落盘的**三件事**:0600 权限、 +// 名字不含站点信息、宽高来自字节本身(而不是配置里的视口)。 +func TestSaveShot_Writes0600AndDimensions(t *testing.T) { + dir := filepath.Join(t.TempDir(), "shots") + data := pngBytes(t, 7, 5) + shot, err := saveShot(dir, data, shotExtPNG) + if err != nil { + t.Fatalf("saveShot: %v", err) + } + if shot.Bytes != len(data) { + t.Errorf("Bytes = %d, want %d", shot.Bytes, len(data)) + } + if !shot.DimensionsKnown || shot.Width != 7 || shot.Height != 5 { + t.Errorf("尺寸 = (%d,%d) known=%v, want (7,5) true", shot.Width, shot.Height, shot.DimensionsKnown) + } + if shot.Format != shotExtPNG || shot.Degraded { + t.Errorf("format=%q degraded=%v, want png/false", shot.Format, shot.Degraded) + } + if len(shot.SHA256) != 64 { + t.Errorf("SHA256 长度 = %d, want 64", len(shot.SHA256)) + } + if filepath.Ext(shot.Path) != ".png" { + t.Errorf("扩展名 = %q, want .png", filepath.Ext(shot.Path)) + } + if fi, err := os.Stat(shot.Path); err != nil { + t.Fatalf("stat %s: %v", shot.Path, err) + } else if runtime.GOOS != "windows" && fi.Mode().Perm() != shotFileMode { + // Windows 不做 POSIX 权限(Go 只映射只读位),因此这一条只在类 Unix 平台上断言; + // Windows 上 0600 是**尽力而为**(对齐 browsercfg.EnsureProfileDir 的既有口径)。 + t.Errorf("权限 = %v, want %v", fi.Mode().Perm(), shotFileMode) + } + // 目录权限:非 Windows 上必须是 0700(Windows 不做 POSIX 权限,跳过)。 + if runtime.GOOS != "windows" { + if fi, err := os.Stat(dir); err != nil { + t.Fatalf("stat %s: %v", dir, err) + } else if fi.Mode().Perm() != shotDirMode { + t.Errorf("目录权限 = %v, want %v", fi.Mode().Perm(), shotDirMode) + } + } +} + +// TestSaveShot_DegradedJPEGFlagged 钉住「降级要说出来」:JPEG 落盘必须置 degraded +// 且扩展名是 .jpeg(模型/面板据此知道这张图不是无损的那张)。 +func TestSaveShot_DegradedJPEGFlagged(t *testing.T) { + dir := t.TempDir() + shot, err := saveShot(dir, []byte{0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x01}, shotExtJPEG) + if err != nil { + t.Fatalf("saveShot: %v", err) + } + if !shot.Degraded || shot.Format != shotExtJPEG { + t.Errorf("degraded=%v format=%q, want true/jpeg", shot.Degraded, shot.Format) + } + if shot.DimensionsKnown { + t.Error("非图像字节不该被判成「尺寸已知」") + } +} + +// TestSaveShot_RejectsEmpty 钉住空字节不落盘(不写一个 0 字节的「截图」骗人)。 +func TestSaveShot_RejectsEmpty(t *testing.T) { + if _, err := saveShot(t.TempDir(), nil, shotExtPNG); err == nil { + t.Fatal("空字节应当报错") + } +} diff --git a/plugins/tools/browser/tool.go b/plugins/tools/browser/tool.go new file mode 100644 index 0000000..22b0e8f --- /dev/null +++ b/plugins/tools/browser/tool.go @@ -0,0 +1,219 @@ +// Package browser 是 `browser` 工具(design/28 B2 的**自研薄层**)。 +// +// 它只补本项目独有的那一层:把「本机真 Chromium 有头」的**导航 / 截图 / 指纹自检** +// 暴露给模型,并把截图按 0600 落盘成一个 **vision/image-crop 能直接读的本地路径** —— +// 那才是「截图进上下文」的闭环入口(design/28 §1 M4、§5)。 +// +// 为什么只有三个动作(AGENTS §9.5):`click` / `type` / `snapshot` / `extract` / +// `wait` 这一整套成熟工具面**优先复用 MCP**(Playwright MCP / Puppeteer MCP 官方 +// server,`design/09 §1`:MCP server = 外部工具提供者,工具自动注册进 registry)。 +// 本项目**不重造**一遍等价物;本包只补 MCP 给不了的两件事 —— +// ① 宿主真 Chromium 的默认有头 + 持久 profile 启动(过风控三件套); +// ② 截图落盘 → 经既有 image/vision 管线进上下文的闭环。 +// +// 权限(design/28 §4 第 2 条):本工具**默认 owner-only** —— 与其它能力一样, +// 注册时带的是零值 ToolPolicy(registry 的口径:两布尔皆 false = 只有主人环境可调), +// 群聊/陌生人不因它放宽。 +package browser + +import ( + "context" + "fmt" + "io" + "log/slog" + "strings" + + browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 工具名与动作名(模型看到的那一面)。名字一旦漂移,`cmd/cyrene` 的接线测试会红。 +const ( + // ToolName 是工具在能力注册表里的名字。 + ToolName = "browser" + + // actionOpen / actionScreenshot / actionInfo 是三个动作。 + actionOpen = "open" + actionScreenshot = "screenshot" + actionInfo = "info" +) + +// logSource 是本包的日志来源(AGENTS §10:禁止无来源裸日志)。 +const logSource = "plugins/tools/browser" + +// Compile-time assertion that Tool implements sdk.Tool. +var _ sdk.Tool = (*Tool)(nil) + +// Tool 是 `browser` 工具。它只依赖驱动层的 Manager(进程内依赖注入,不起第二套 +// 浏览器栈),因此本包可单测(假 Manager 接口)。 +type Tool struct { + mgr Driver + log *slog.Logger +} + +// Driver 是 Tool 需要的最小驱动面(*browsercap.Manager 实现它)。用接口而不是具体 +// 类型,是为了让工具层能在**不装浏览器**的机器上被单测覆盖。 +type Driver interface { + Available() bool + Source() string + Open(ctx context.Context, rawURL string) (browsercap.PageResult, error) + Screenshot(ctx context.Context) (browsercap.Shot, error) + Fingerprint(ctx context.Context) (browsercap.Fingerprint, error) +} + +// New 构造工具;mgr 为 nil 时工具**仍可构造**但每个动作都如实报「不可用」 +// (组合根的正确做法是压根不注册它,见 wire_browser.go)。 +func New(mgr Driver, opts ...Option) *Tool { + t := &Tool{mgr: mgr, log: discardLog()} + for _, opt := range opts { + if opt != nil { + opt(t) + } + } + return t +} + +// discardLog 返回一个「丢弃输出但**带来源标签**」的 logger:New(nil) 或 WithLogger(nil) +// 都不该让 t.log 变成 nil(AGENTS §10 禁止无来源裸日志,也不该在 nil 上 panic)。 +func discardLog() *slog.Logger { + return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), logSource) +} + +// Option 配置 Tool。 +type Option func(*Tool) + +// WithLogger 注入基日志(本包会补 `plugins/tools/browser` 来源标签)。 +func WithLogger(l *slog.Logger) Option { + if l == nil { + return func(t *Tool) { t.log = discardLog() } + } + return func(t *Tool) { t.log = logger.WithSource(l, logSource) } +} + +// Name 实现 sdk.Tool。 +func (t *Tool) Name() string { return ToolName } + +// Close 释放驱动占用的资源(本机浏览器进程)。驱动实现 io.Closer 时生效,否则空操作。 +// +// ⚠️ **当前没有生产调用者**(如实登记):组合根的关闭链在 `a.close`(`wire_services.go`) +// 与 `app` 结构体(`wire.go`)里,两者都不属本切片的文件面 ⇒ 加不上「关掉它」这一行。 +// 本方法先把**挂点**摆出来,供主会话在关闭链上接一行;在被接上之前,残留的浏览器由 +// 驱动层的两道机制兜住:① 空闲回收时对会发 `Browser.close` 优雅关停;② 下一次会话 +// **认领**同一个 profile 上还活着的实例(不再起第二个、也不会被 profile 锁卡死)。 +func (t *Tool) Close() error { + if t == nil { + return nil + } + if c, ok := t.mgr.(io.Closer); ok { + return c.Close() + } + return nil +} + +// Description 实现 sdk.Tool。**能力边界写在这里**:模型只读这段文字,所以「哪些 +// 动作没做、 richer 动作去 MCP」必须说清楚,不能让模型以为它有 click/type。 +func (t *Tool) Description() string { + return "Drive a real headful Chromium on this host and bring a page into the conversation. " + + "Actions: " + + "'open' navigates to an http(s) URL and returns the page's own title/url/readyState; " + + "'screenshot' captures the current viewport, saves it to disk (0600) and returns its path — " + + "pass that path to the vision or image-crop tool to actually read the page, which is how a " + + "rendered chart/QR/login page becomes visible to a text-only model; " + + "'info' returns the browser fingerprint (headless, navigator.webdriver, screen, timezone, language) " + + "for verifying that a headful session really is headful. " + + "Rich interaction (click/type/wait/extract/network) is NOT implemented here on purpose: " + + "configure a Playwright or Puppeteer MCP server and use its tools for that." +} + +// Parameters 实现 sdk.Tool。 +func (t *Tool) Parameters() (map[string]any, error) { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []any{actionOpen, actionScreenshot, actionInfo}, + "description": "Which browser action to run.", + }, + "url": map[string]any{ + "type": "string", + "description": "Absolute http(s) URL. Required for action=open, ignored otherwise.", + }, + }, + "required": []any{"action"}, + }, nil +} + +// Execute 实现 sdk.Tool。 +func (t *Tool) Execute(ctx context.Context, args any) (any, error) { + m, ok := args.(map[string]any) + if !ok { + return nil, fmt.Errorf("browser: args must be an object") + } + action := strings.ToLower(strings.TrimSpace(argString(m, "action"))) + if t.mgr == nil || !t.mgr.Available() { + return nil, fmt.Errorf("browser: 本机没有可用的浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)") + } + switch action { + case actionOpen: + return t.open(ctx, m) + case actionScreenshot: + return t.screenshot(ctx) + case actionInfo: + return t.info(ctx) + default: + return nil, fmt.Errorf("browser: 未知 action %q(可用:%s / %s / %s)", action, + actionOpen, actionScreenshot, actionInfo) + } +} + +// open 处理 action=open。 +func (t *Tool) open(ctx context.Context, args map[string]any) (any, error) { + rawURL := strings.TrimSpace(argString(args, "url")) + if rawURL == "" { + return nil, fmt.Errorf("browser: action=open 需要 url") + } + res, err := t.mgr.Open(ctx, rawURL) + if err != nil { + return nil, err + } + return res, nil +} + +// screenshot 处理 action=screenshot:返回落盘路径与统计,「路径交 vision 读」这一步 +// 由模型按描述自行发起(本层不替它调模型,也不把图塞进上下文)。 +func (t *Tool) screenshot(ctx context.Context) (any, error) { + shot, err := t.mgr.Screenshot(ctx) + if err != nil { + return nil, err + } + return map[string]any{ + "path": shot.Path, + "bytes": shot.Bytes, + "sha256": shot.SHA256, + "format": shot.Format, + "width": shot.Width, + "height": shot.Height, + "dimensionsKnown": shot.DimensionsKnown, + "degraded": shot.Degraded, + "hint": "Pass this path to the vision or image-crop tool to read the page content; the image is not inlined into the conversation.", + }, nil +} + +// info 处理 action=info。 +func (t *Tool) info(ctx context.Context) (any, error) { + fp, err := t.mgr.Fingerprint(ctx) + if err != nil { + return nil, err + } + return fp, nil +} + +// argString 取一个字符串参数(缺失/类型不符一律当空串,与 io 包的同名口径一致)。 +func argString(m map[string]any, key string) string { + if raw, ok := m[key].(string); ok { + return raw + } + return "" +} diff --git a/plugins/tools/browser/tool_test.go b/plugins/tools/browser/tool_test.go new file mode 100644 index 0000000..0d960dd --- /dev/null +++ b/plugins/tools/browser/tool_test.go @@ -0,0 +1,194 @@ +package browser + +import ( + "context" + "errors" + "strings" + "testing" + + browsercap "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browser" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// fakeDriver 是 Tool 依赖的最小驱动面(*browsercap.Manager 的真实现由驱动包的测试覆盖; +// 这里只验证**工具层**的接线与能力边界)。 +type fakeDriver struct { + available bool + openedURL string + openErr error + shot browsercap.Shot + fp browsercap.Fingerprint +} + +func (f *fakeDriver) Available() bool { return f.available } +func (f *fakeDriver) Source() string { return "fake" } +func (f *fakeDriver) Open(_ context.Context, raw string) (browsercap.PageResult, error) { + f.openedURL = raw + if f.openErr != nil { + return browsercap.PageResult{}, f.openErr + } + return browsercap.PageResult{URL: raw, Title: "t", ReadyState: "complete", Source: "fake"}, nil +} +func (f *fakeDriver) Screenshot(context.Context) (browsercap.Shot, error) { return f.shot, nil } +func (f *fakeDriver) Fingerprint(context.Context) (browsercap.Fingerprint, error) { + return f.fp, nil +} + +// TestTool_ImplementsSDKAndName 钉住工具名(注册表条目名与热重载/接线测试都依赖它)。 +func TestTool_ImplementsSDKAndName(t *testing.T) { + t.Parallel() + var _ sdk.Tool = New(&fakeDriver{}) + if ToolName != "browser" { + t.Fatalf("ToolName = %q, want browser", ToolName) + } + if got := New(&fakeDriver{}).Name(); got != ToolName { + t.Fatalf("Name() = %q", got) + } +} + +// TestTool_DescriptionDeclaresTheMissingActions 钉住**能力边界写进描述**: +// 模型只读这段文字,所以必须明说 click/type 不在这里实现、要走 MCP +// (AGENTS §9.5 的「不重造成熟工具面」要落成模型看得见的事实)。 +func TestTool_DescriptionDeclaresTheMissingActions(t *testing.T) { + t.Parallel() + desc := New(&fakeDriver{}).Description() + for _, want := range []string{"MCP", "click", "vision"} { + if !strings.Contains(desc, want) { + t.Errorf("描述里缺少 %q:%s", want, desc) + } + } +} + +// TestTool_ParametersExposeExactlyThreeActions 钉住动作面就是三个(多一个都要在 +// 这里显式改写,避免「悄悄长出一整套自研工具面」)。 +func TestTool_ParametersExposeExactlyThreeActions(t *testing.T) { + t.Parallel() + params, err := New(&fakeDriver{}).Parameters() + if err != nil { + t.Fatalf("Parameters: %v", err) + } + props, _ := params["properties"].(map[string]any) + action, _ := props["action"].(map[string]any) + enum, _ := action["enum"].([]any) + if len(enum) != 3 { + t.Fatalf("动作数 = %d, want 3: %v", len(enum), enum) + } + got := map[string]bool{} + for _, e := range enum { + s, _ := e.(string) + got[s] = true + } + for _, want := range []string{actionOpen, actionScreenshot, actionInfo} { + if !got[want] { + t.Errorf("动作枚举缺 %q", want) + } + } + required, _ := params["required"].([]any) + if len(required) != 1 || required[0] != "action" { + t.Errorf("required = %v, want [action]", required) + } +} + +// TestTool_UnavailableReportsHonestly 钉住「不可用不假装可用」:驱动不可用(或没注入) +// 时每个动作都如实报错,不返回半真半假的结果。 +func TestTool_UnavailableReportsHonestly(t *testing.T) { + t.Parallel() + for _, tool := range []*Tool{New(nil), New(&fakeDriver{available: false})} { + if _, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo}); err == nil { + t.Fatal("不可用时应当报错") + } else if !strings.Contains(err.Error(), "没有可用的浏览器来源") { + t.Errorf("错误没有说清原因: %v", err) + } + } +} + +// TestTool_OpenRequiresURL 钉住 action=open 缺 url 时**不发**导航请求(而不是拿空串去导航)。 +func TestTool_OpenRequiresURL(t *testing.T) { + t.Parallel() + d := &fakeDriver{available: true} + tool := New(d) + if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen}); err == nil { + t.Fatal("缺 url 应当报错") + } + if _, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": " "}); err == nil { + t.Fatal("空白 url 应当报错") + } + if d.openedURL != "" { + t.Fatalf("不该发起导航,实得 %q", d.openedURL) + } +} + +// TestTool_UnknownActionNamesTheAlternatives 钉住未知动作的错误里带可用清单 +// (模型据此自我纠正,而不是盲试)。 +func TestTool_UnknownActionNamesTheAlternatives(t *testing.T) { + t.Parallel() + _, err := New(&fakeDriver{available: true}).Execute(context.Background(), map[string]any{"action": "click"}) + if err == nil { + t.Fatal("未知动作应当报错") + } + for _, want := range []string{"open", "screenshot", "info"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("错误里应列出可用动作 %q: %v", want, err) + } + } +} + +// TestTool_ScreenshotReturnsClosedLoopEntry 钉住截图动作的返回值**就是闭环入口**: +// 路径 + 指纹 + 提示「交给 vision」,而不是把图塞进上下文。 +func TestTool_ScreenshotReturnsClosedLoopEntry(t *testing.T) { + t.Parallel() + d := &fakeDriver{available: true, shot: browsercap.Shot{ + Path: "/data/browser/shots/shot-1.png", Bytes: 4242, SHA256: "abc", + Format: "png", Width: 1264, Height: 649, DimensionsKnown: true, + }} + got, err := New(d).Execute(context.Background(), map[string]any{"action": actionScreenshot}) + if err != nil { + t.Fatalf("screenshot: %v", err) + } + m, ok := got.(map[string]any) + if !ok { + t.Fatalf("返回值类型 = %T", got) + } + if m["path"] != d.shot.Path || m["sha256"] != "abc" || m["bytes"] != 4242 { + t.Errorf("返回值丢了落盘事实: %#v", m) + } + hint, _ := m["hint"].(string) + if !strings.Contains(hint, "vision") { + t.Errorf("提示里应指向 vision 工具: %q", hint) + } +} + +// TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint 钉住两个透传动作不打折扣。 +func TestTool_OpenPassesURLThroughAndInfoReturnsFingerprint(t *testing.T) { + t.Parallel() + d := &fakeDriver{available: true, fp: browsercap.Fingerprint{Headless: false, Webdriver: false, Timezone: "Asia/Shanghai"}} + tool := New(d) + res, err := tool.Execute(context.Background(), map[string]any{"action": actionOpen, "url": "https://example.com/"}) + if err != nil { + t.Fatalf("open: %v", err) + } + if d.openedURL != "https://example.com/" { + t.Errorf("驱动收到的 URL = %q", d.openedURL) + } + if pr, ok := res.(browsercap.PageResult); !ok || pr.Title != "t" { + t.Errorf("open 结果 = %#v", res) + } + fp, err := tool.Execute(context.Background(), map[string]any{"action": actionInfo}) + if err != nil { + t.Fatalf("info: %v", err) + } + if got, ok := fp.(browsercap.Fingerprint); !ok || got.Timezone != "Asia/Shanghai" { + t.Errorf("info 结果 = %#v", fp) + } +} + +// TestTool_OpenSurfacesDriverError 钉住驱动错误原样上抛(不吞、不改写成「成功」)。 +func TestTool_OpenSurfacesDriverError(t *testing.T) { + t.Parallel() + sentinel := errors.New("browser: 导航失败: net::ERR_NAME_NOT_RESOLVED") + _, err := New(&fakeDriver{available: true, openErr: sentinel}).Execute( + context.Background(), map[string]any{"action": actionOpen, "url": "https://nope.example/"}) + if !errors.Is(err, sentinel) { + t.Fatalf("错误应可 errors.Is 回到驱动的那一个,实得: %v", err) + } +}