| 1 | # CDP 浏览器后端 |
| 2 | |
| 3 | [English](BROWSER_CDP.md) |
| 4 | |
| 5 | [桌面浏览器](DESKTOP_BROWSER.zh-CN.md)由 Electron 外壳提供,用户和 agent 共用同一个 |
| 6 | Chromium 界面。CLI、`reasonix serve` 和 headless 会话背后没有外壳,于是同一批 |
| 7 | `browser_*` 工具注册之后无人应答,只能失败关闭。 |
| 8 | |
| 9 | 这个后端是宿主中立的 `browser.Executor` 的第三个实现(前两个是 Electron 外壳和 |
| 10 | SSH broker):它通过 DevTools 协议驱动一个外部 Chrome。工具、描述和 schema 都没有 |
| 11 | 变化,变化的只是谁来应答。 |
| 12 | |
| 13 | ## 启用 |
| 14 | |
| 15 | ```toml |
| 16 | [browser] |
| 17 | enabled = true |
| 18 | ``` |
| 19 | |
| 20 | 普通路径到此为止。第一次调用浏览器工具时,Reasonix 才会用一次性 profile 启动一个 |
| 21 | 自己持有的 Chrome,并随会话一起结束。会话启动时不做任何事:没碰过浏览器的会话不为 |
| 22 | 浏览器付出任何代价。 |
| 23 | |
| 24 | 要驱动已经在运行的 Chrome,先用调试端口启动它,再把 endpoint 指过去: |
| 25 | |
| 26 | ```bash |
| 27 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 |
| 28 | ``` |
| 29 | |
| 30 | ```toml |
| 31 | [browser] |
| 32 | enabled = true |
| 33 | endpoint = "http://127.0.0.1:9222" |
| 34 | ``` |
| 35 | |
| 36 | | 配置项 | 含义 | |
| 37 | | --- | --- | |
| 38 | | `enabled` | 默认关闭。打开后工具才会拿到真实浏览器。 | |
| 39 | | `endpoint` | 运行中 Chrome 的 DevTools 端点。留空则自行启动一个。 | |
| 40 | | `allow_remote_endpoint` | 允许非 loopback 端点,默认关闭。 | |
| 41 | | `chrome_path` | 浏览器可执行文件。留空则按常见位置查找 Chrome、Chromium 和 Edge,再读 `REASONIX_CHROME` 与 `CHROME_PATH`。 | |
| 42 | | `chrome_args` | 额外启动参数,例如代理。 | |
| 43 | | `user_data_dir` | 自行启动时使用的 profile。留空使用一次性目录,登录态不会比会话活得更久。 | |
| 44 | | `headless` | 无窗口启动。 | |
| 45 | |
| 46 | 桌面端忽略这一节:它本来就持有浏览器,宿主自带的浏览器由宿主继续持有。 |
| 47 | |
| 48 | ## agent 能碰到什么 |
| 49 | |
| 50 | 只有这个后端自己打开的标签页。被 attach 的 Chrome 通常还开着用户自己的已登录标签页, |
| 51 | `browser_tabs` 从不枚举它们,因此 agent 既读不到也驱动不了用户没有交给它的页面。 |
| 52 | `temporary` 标签页使用独立的浏览器 context,不共享任何 cookie,并随标签页一起丢弃。 |
| 53 | |
| 54 | ## 这个后端负责的拒绝语义 |
| 55 | |
| 56 | 裸浏览器不会记录 agent 让它做过什么,所以 Electron 外壳里由 ledger 提供的保证,在 |
| 57 | 这里由本后端自己负责: |
| 58 | |
| 59 | - **`operationId` 只能用一次。** 重放的 id 被永久拒绝。上一次尝试的效果(包括未知 |
| 60 | 的效果)已经成立,因此模型被告知重新读取页面,而不是再试一次。 |
| 61 | - **`documentToken` 把 ref 绑定到一个文档版本。** 每次 `browser_snapshot` 铸造一个 |
| 62 | 新的不透明 token;导航、页面替换和用户接管都会让它失效。携带失效 token 的写操作 |
| 63 | 按 stale 拒绝,而不是对着模型没见过的页面重放。 |
| 64 | - **未知结果既少见又诚实。** 在任何东西到达页面之前失败,报告为未执行,模型可以据此 |
| 65 | 改写计划。只有在输入**已经**落到页面之后失败——点击的第二个事件、页面脚本执行到 |
| 66 | 一半抛错——才报告未知结果,而未知结果永远不得重试。 |
| 67 | |
| 68 | ### 这里的接管检测是近似的 |
| 69 | |
| 70 | 外壳能知道人碰了页面,是因为它的 guest preload 能看到并非自己合成的可信输入。 |
| 71 | CDP 派发的输入一旦到达 DOM,就和真人敲键盘无法区分,所以这个后端在自己每次派发前后 |
| 72 | 标记一个短窗口,把窗口之外的可信输入算作用户的。落在窗口内的人工点击会被漏掉。 |
| 73 | 其失效模式是快照过期,而绝不会是静默重放,因为每次写操作仍然携带一次性的 |
| 74 | `operationId`。 |
| 75 | |
| 76 | ref 与接管计数器都活在按文档创建的隔离世界里,页面脚本既读不到 agent 的 ref,也伪造 |
| 77 | 不了计数器。 |
| 78 | |
| 79 | ## 产物 |
| 80 | |
| 81 | 截图和下载落在一个随会话删除的私有目录里。下载保留服务端建议的文件名,经过清洗以保证 |
| 82 | 文件名无法逃出该目录,并且绝不覆盖已存在的文件。 |
| 83 | |
| 84 | ## 安全 |
| 85 | |
| 86 | DevTools 端点等于把该浏览器以及它能读到的所有文件的完全控制权交出去。因此除非显式 |
| 87 | 设置 `allow_remote_endpoint`,非 loopback 的 `endpoint` 一律拒绝。 |
| 88 | |
| 89 | `browser_upload` 只能读取会话的写根目录(工作区及附加目录)以及本执行器自己的产物 |
| 90 | 目录,这样 agent 刚下载的文件仍然可以上传。符号链接在包含性检查之前就被解析,因此 |
| 91 | 工作区里的链接无法把文件输入框指向工作区之外的密钥。其他路径一律带原因拒绝,不会交给 |
| 92 | 页面:页面是不可信的,而文件输入框就是一条上传通道。 |
| 93 | |
| 94 | ## 缓存 |
| 95 | |
| 96 | 这里没有任何东西对 provider 可见。工具只进 registry,通过 `use_capability` 触达, |
| 97 | 因此无论有没有挂上浏览器,请求里的工具数组和系统提示词前缀都逐字节一致。守卫测试是 |
| 98 | `internal/boot` 中的 `TestConfiguredBrowserBackendStaysOffTheProviderSurface`。 |
| 99 | |
| 100 | ## 验证改动 |
| 101 | |
| 102 | ```bash |
| 103 | go test ./internal/browser/... ./internal/boot/ |
| 104 | ``` |
| 105 | |
| 106 | 单元测试跑在一个脚本化的 DevTools 服务器上。注入页面的 helper——快照遍历、ref 表、 |
| 107 | 接管监听——只有对着真实浏览器才算真正被执行过: |
| 108 | |
| 109 | ```bash |
| 110 | REASONIX_LIVE_CHROME=1 go test ./internal/browser/cdp -run '^TestLiveChrome$' -v -count=1 |
| 111 | ``` |
| 112 | |
| 113 | 该测试需要本机安装 Chrome,否则保持跳过。 |
| 114 | |
| 115 | ## 限制 |
| 116 | |
| 117 | - 仅支持 Chrome、Chromium 和基于 Chromium 的 Edge。 |
| 118 | - agent 的标签页只属于它自己,没有办法把用户的标签页交给它。 |
| 119 | - `browser_select` 通过 DOM 驱动 `<select>`,因为原生下拉由平台渲染,无法用合成鼠标 |
| 120 | 事件操作。其余写操作都使用真实输入事件。 |
| 121 | - 单次快照最多 2000 个节点;用 `selector` 把范围缩到一个子树。 |
| 122 |