browser-use:给 Buffin 加上 Agent 浏览器自动化能力
buffin-ai/buffin · 3d1545a3...browser-use · 2026-08-01 · 自包含,读完即弃
写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。
1TL;DR
Buffin 现在可以用内嵌的 Electron WebContentsView 跑浏览器自动化任务了。Desktop 进程充当 Browser Host(提供真实浏览器页面),通过 tRPC WebSocket 连接 daemon 中新增的 Browser Coordinator(命令排队、租约管理);外部 Agent 或 CLI 通过 browser.* tRPC 过程下发点击/填充/截取 accessibility snapshot 等操作。实际页面操作由捆绑的 agent-browser 0.33.1 二进制通过 CDP 页面网关完成。
整个体系是一个 command-queue 模式:外部调用方 → daemon coordinator(排队 + 选 host)→ desktop wire(轮询取件)→ adapter(调 agent-browser 进程)→ 回报结果。
2变更地图(称重)
| 子系统 | 设计重心(要细读) | 可放心略过 |
|---|---|---|
| apps/desktop | browser-control-adapter, browser-host-wire, browser-host, inspect/* 系统, BrowserPanel.tsx | browser-bridge(纯 IPC 胶水), preload 注册 |
| packages/api | browser.ts(完整合约定义 511 行), trpc.ts 的 browserRouter | errors/* 的 12 个 code 注册(机械) |
| apps/daemon | browser/coordinator.ts(内存命令队列) | index.ts/testing 的 wireServices 注入行 |
| apps/cli | browser/binary.ts(multi-platform 二进制寻址) | commands/browser.ts 的 pass-through wrapper |
| docs/ | browser-profile-import-research.md(竞品对比决策) | zh-Hans 镜像 |
3架构一图流
以前 · 无浏览器通道
现在 · Browser Host 回路
4数据与状态先行
合约层形状(packages/api/src/browser.ts)
整个合约围绕三个坐标:browserId(逻辑 target 标识,对应一个 WebContentsView)、epoch(导航代次,每次 main-frame navigation 递增;epoch 改变意味着旧 accessibility ref 失效)、leaseId(Host 持有的单租约凭证,30 秒过期需心跳续命)。
export const browserIdSchema = idSchema // UUID v7 标识一个浏览器 tab
export const browserEpochSchema = z.number().int().nonnegative().safe() // 导航代次
export const browserRefSchema = z.string().trim().min(1).max(256) // accessibility 节点的不透明引用
export const browserLeaseIdSchema = idSchema // Host 租约凭证
Action 是一个 discriminated union——只有五种受控操作(navigate / click / fill / press / scroll),每个 action 的输入是 ref(不透明引用)而非 CSS selector,也无法注入脚本。
export const browserActionSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('navigate'), url: browserUrlSchema }).strict(),
z.object({ kind: z.literal('click'), ref: browserRefSchema }).strict(),
z.object({ kind: z.literal('fill'), ref: browserRefSchema, value: z.string().max(100_000) }).strict(),
z.object({ kind: z.literal('press'), ref: browserRefSchema, key: z.string().trim().min(1).max(64) }).strict(),
z.object({ kind: z.literal('scroll'), ref: browserRefSchema,
deltaX: z.number().finite().min(-100_000).max(100_000),
deltaY: z.number().finite().min(-100_000).max(100_000),
}).strict(),
])
Accessibility snapshot 有结构化的 ref-graph 验证(唯一性、rootRef 存在、parentRef 合法)和文本预算(MAX_BROWSER_SNAPSHOT_TEXT = 100,000 字符)。
Coordinator 内存状态(apps/daemon/src/browser/coordinator.ts)
type HostRecord = {
hostId: string // Host 运行时的稳定 id
leaseId: string // 当前租约(独占)
expiresAt: number // 30 秒后过期
capabilities: BrowserCapabilities
targets: Map<string, number> // browserId → epoch,多 tab 独立
}
type CommandRecord = {
command: BrowserCommand
state: 'offered' | 'accepted' | 'terminal'
isWrite: boolean // accepted 后 Host 掉线,write → outcome_unknown
result?: BrowserCommandResult
waiter?: { resolve, reject, kind } // describe/snapshot/verify 的同步等待者
}
5底座:命令生命周期
一条命令从入队到完成经过固定四态:offered → accepted → terminal。写操作(action)在 accepted 后 Host 断连会进入 outcome_unknown——表示页面可能已经被修改了,读操作(describe / snapshot / verify)则直接 fail closed。
packages/api/src/browser.ts 加 action variant + input schema ② 在 browserRouter (trpc.ts) 注册 procedure ③ daemon coordinator 的 requireCapability 加映射 ④ desktop adapter 的 actionCommands() 映射 agent-browser CLI 命令 ⑤ 错误码注册到 errors/
6旅程 A:Agent 下发一个"点击按钮"命令
这是最典型的端到端路径:外部调用方想让浏览器点击某个元素引用。走通后掌握所有层如何串联。
trpc.ts→ Coordinator
coordinator.ts→ Wire 轮询
browser-host-wire.ts→ Adapter 执行
browser-control-adapter.ts→ CDP Gate
BrowserPageGate→ agent-browser 进程
A.1API 入口:browser.action
调用方传入 browserId + epoch + action。Router 做 zod 校验后委托 BrowserService.action()。
action: publicProcedure
.input(browserActionInputSchema)
.output(browserCommandReceiptSchema)
.mutation(({ ctx, input }) =>
callService(() => requireBrowserService(ctx).action(input)),
),
如果 daemon 进程没有注入 BrowserService(desktop 没启动),requireBrowserService 抛 browser.hostUnavailable(HTTP 409)。
A.2Coordinator 接收排队
Coordinator 验证 epoch 一致、capability 允许,然后创建一条 CommandRecord 进入 offered 状态,返回 receipt。
action(input) {
const parsed = browserActionInputSchema.parse(input)
const active = requireActiveHost()
requireExactEpoch(active, parsed.browserId, parsed.epoch)
requireCapability(active.capabilities, { kind: 'action', action: parsed.action, ... })
const command = createCommand({
browserId: parsed.browserId, epoch: parsed.epoch,
request: { kind: 'action', action: parsed.action, ... },
isWrite: true, // 点击是写操作——Host 断连后命令结果未知
})
return { browserId: command.browserId, epoch: command.epoch,
commandId: command.commandId, state: 'offered' }
}
A.3Wire 轮询取件并执行
Desktop 进程的 BrowserHostWireRuntime 通过定期 poll(idle 1s / active 50ms)调 browser.commands.offer 拿到命令,先 accept,再分发给 adapter。
private async processCommand(command: BrowserCommand, generation: number) {
// ...
await this.client?.accept(acceptedInput) // 通知 coordinator 已接手
this.acceptedCommand = { commandId, generation }
const controller = new AbortController()
this.activeCommand = { commandId, generation, controller }
const outcome = await this.commandOutcome(command, preAcceptanceFailure, target, controller.signal)
await this.client?.complete({ ...acceptedInput, outcome }) // 上报结果
this.completedCommandIds.add(command.commandId)
}
A.4Adapter 落地执行
Control Adapter 把 { kind: 'click', ref: 'button-1' } 映射为 agent-browser CLI 参数 ['click', '@button-1'],然后通过 CDP Gate 转发给实际页面。
function actionCommands(action: BrowserAction): string[][] {
switch (action.kind) {
case 'navigate': return [['navigate', action.url]]
case 'click': return [['click', `@${action.ref}`]]
case 'fill': return [['fill', `@${action.ref}`, action.value]]
case 'press': return [['focus', `@${action.ref}`], ['press', action.key]]
case 'scroll': // ... deltaX / deltaY 拆成 left/right/up/down 命令
}
}
每次调用前,Gate 会 issue() 一个只活在命令执行期间的 capability URL,之后立即 revoke()。这个 URL 是 agent-browser 进程唯一能拿到的 CDP 连接方式——env 变量 BUFFIN_BROWSER_GATE_URL 只在子进程生存期内有效。
排查路标 · 旅程 A
| 症状 | 从哪下手 |
|---|---|
Action 返回 browser.hostUnavailable | trpc.ts → requireBrowserService():检查 desktop 是否启动并注入了 browser service |
Action 返回 browser.staleEpoch | coordinator.ts → requireExactEpoch():target 导航已发生,旧 epoch 不接受 |
| Action 超时无响应 | browser-host-wire.ts:检查 pollTimer 是否在跑、daemon WS 是否连通 |
browser.actionFailed | browser-control-adapter.ts → runEngine():agent-browser 进程非零退出,看 stderr |
| CDP 连接被拒 | BrowserPageGate.handleUpgrade():capability/query 匹配逻辑 |
7旅程 B:读取 Accessibility Snapshot
Agent 调用 browser.snapshot 获取页面当前可交互元素的结构化表示。与旅程 A 的区别:snapshot 是同步 await 风格——调用方的 RPC 会一直挂着直到 Host 完成 snapshot 并回报结果。
trpc.ts→ enqueueWaiter
coordinator.ts→ Wire 执行
browser-host-wire.ts→ convertSnapshot
browser-control-adapter.ts
B.1同步 await 风格
Coordinator 内部用 enqueueWaiter() 创建一个 Promise——命令入队同时把 resolve/reject 存在 CommandRecord 的 waiter 字段上。Host 完成后 settleWaiter() 把 snapshot 结果直接 resolve 给等待者,调用方的 tRPC query 才返回。
snapshot(input) {
// ...
return enqueueWaiter({
browserId: parsed.browserId, epoch: parsed.epoch,
request: { kind: 'snapshot', ... },
kind: 'snapshot',
}) as Promise<BrowserSnapshot> // tRPC 调用者 await 此 promise 直到 Host 回报
}
B.2Snapshot 转换
agent-browser 返回的 snapshot 是纯文本 + refs map 格式。Adapter 的 convertSnapshot() 逐行解析文本,映射 ref 到结构化节点,最后用 browserSnapshotSchema.parse() 做边界校验(唯一性、节点数上限、文本预算)。
排查路标 · 旅程 B
| 症状 | 从哪下手 |
|---|---|
| Snapshot 挂着不返回 | coordinator.ts 的 waiter:如果 Host 不回报 completeCommand,waiter 永远挂。检查 wire 是否断了 |
| 返回空 nodes / truncated: true | browser-control-adapter.ts → convertSnapshot():maxNodes / maxText 预算 |
| Snapshot ref 格式错误 | parseSnapshotNodes():文本解析逻辑 |
8旅程 C:Host 掉线与恢复
Desktop 进程重启或 WebSocket 断开时,wire 执行以下恢复路径:
- Wire 检测到 tRPC
connectionState变为connecting + error ≠ null,触发reconnect() - 如果有
activeCommand,abort 它的 AbortController(adapter 中的进程被 SIGTERM) - 尝试 disconnect 旧 lease(best-effort)
- 关闭旧 client,exponential backoff(250ms → 10s 上限)后重新
connect() - 新 client ready 后重新
attach(),coordinator 给出新 lease,恢复 heartbeat + poll
Coordinator 侧对旧 lease 的处理:clearHost() 遍历 commandOrder——offered 态的命令留着供新 Host 重新取件;accepted 态的写命令标 outcome_unknown;accepted 态的读命令标 failed: targetDisconnected。
排查路标 · 旅程 C
| 症状 | 从哪下手 |
|---|---|
| Host 重连后旧命令全部 outcome_unknown | coordinator.ts → clearHost():只有 accepted + isWrite 的命令走这条路径 |
| 重连循环不收敛 | browser-host-wire.ts → scheduleRetry():backoff 有 10s 上限;检查 daemon 是否存活 |
| Browser Host 掉线后 snapshot 调用者 reject | coordinator.ts → clearHost():waiter.reject 被调用 |
9计划 vs 实现偏差
| 计划 | 实际 | 为什么变 |
|---|---|---|
| 设计文档列出四步路线图:① Buffin-owned profiles ② cookie JSON import ③ storage-state import ④ direct browser import | 本 PR 只交付了"自动化控制层"(从 daemon 到 BrowserView 的完整命令通路),Profile 相关完全未触碰 | Profile import 是身份复用,自动化控制是操作能力——两者正交,先交付控制层让 Agent 可以在用户手动登录的 partition 里工作 |
| 设计文档提到"不支持 Chrome user-data 目录复制"作为决策 | 实现用了单一固定 partition persist:buffin-browser,所有浏览器 tab 共享 |
多 profile 隔离是文档路线图 Step 1,但本 PR scope 只到"能跑起来" |
| commit 历史可见 3 个 fix 围绕"browser session 隔离 / recovery" | 最终设计:每个 WebContentsView 绑定一个独立 browserId,Wire 按 webContents 实例隔离 session,navigation 递增 epoch 使旧 ref 失效 |
初版可能试过跨 tab 共享 session,后发现 epoch/ref 跨 tab 混用导致 stale 错误 |
10心智模型补丁
browser.*),Desktop 作为 Host 主动连回 daemon
browser.snapshot + browser.action,操作对象是 opaque ref 而非 selector
Services 接口只有必选字段
browser 是第一个可选的 service(browser?: BrowserService),desktop 不运行时合法缺失
client + ready + close
新增 connectionState observable,Browser Wire 用来检测 transport loss
agent-browser 0.33.1 二进制(multi-platform resolution 逻辑见 browser/binary.ts)
11新词表
| Browser 自动化域 | |
|---|---|
Browser Host | 持有实际 Electron 页面的运行时(Desktop 进程),向 daemon 注册并接收命令 |
Browser Coordinator | daemon 内存中的调度器——管理 Host 租约、命令排队、失联解决 |
Browser Wire | Desktop main 里负责 WS 连接、heartbeat、offer 轮询的状态机 |
Control Adapter | Desktop main 里做实际页面操作的层——管 agent-browser 进程生命周期和 CDP 桥接 |
CDP Gate | 一个 loopback WebSocket server,为每次 engine 调用临时发放 capability URL 供 CDP 连接 |
epoch | 导航代次——main-frame navigation 发生一次加 1,旧 epoch 的 ref 全部失效 |
ref | accessibility 节点的不透明引用,只在同一 epoch 内有效 |
lease | Host 的独占凭证,30 秒过期需心跳续期,同时只有一个 Host |
outcome_unknown | 写命令在已被 accepted 后 Host 断连——页面可能已变但无法确认 |
| 桌面 UI | |
BrowserPanel | Renderer 侧浏览器 tab 的 React 组件——toolbar + native surface 占位 |
Inspect | 人工模式:用户点选页面元素获取结构化证据(locator / screenshot)供 prompt 使用 |
12测试与风险地图
| 有兜底的行为 | 测试文件 |
|---|---|
| 合约层 schema 边界(ref 图完整性、action 注入拦截、epoch drift 拒绝、size bound) | packages/api/src/browser.test.ts(417 行) |
| Coordinator 租约互斥、heartbeat、epoch 一致性、命令 replay、disconnect 处理、waiter 解决 | apps/daemon/src/browser/coordinator.test.ts(401 行) |
| Wire 连接生命周期(attach / reconnect / dispose / heartbeat cadence / command serial) | apps/desktop/src/main/browser-host-wire.test.ts(805 行) |
| Adapter session 隔离、timeout、abort、capability check、snapshot/action/verify 端到端 | apps/desktop/src/main/browser-control-adapter.test.ts(713 行) |
| BrowserHost 表面生命周期、Inspect 流程 | apps/desktop/src/main/browser-host.test.ts(222 行) |
| Multi-platform binary resolution | apps/cli/tests/browser-binary.test.ts(52 行) |
- 🟠Coordinator 无持久化——daemon 重启丢失所有 in-flight 命令和 Host 注册;当前 OK(单机)但扩展到多 daemon 时需要重新设计
- 🟠snapshot 文本解析——agent-browser 输出格式改变会默默产出空节点;无 integration test 跑真实 agent-browser 二进制
- 🟡所有 browser tab 共享单 partition
persist:buffin-browser——跨 tab 的登录状态相互可见,无隔离 - 🟡Wire 的 offer poll 是 busy-wait(ACTIVE_POLL_MS = 50ms)——命令密集时产生每秒 20 次 RPC 调用
- ⚪CDP allowlist 只有文件名引用 (
cdp-allowlist.ts),本 diff 未含该文件内容——验证其覆盖面需要读源
13验收提示
BrowserService在Services接口上是optional——这是有意的:daemon 只在 desktop 连接后才有 browser 能力,CLI-only 场景合法缺失- docs/ 的两篇文档(profile-import design + research)描述的功能本 PR 尚未实现——它们是路线图文档,不是功能声明
browser.js文件是 Node source TypeScript loader 的运行时 shim(export * from './browser.ts'),不是手写逻辑electron.vite.config.ts的重写是为了把 daemon URL 注入 main bundle 的__BUFFIN_MAIN_DAEMON_URL__define——不是格式化
14覆盖声明
主力全量精读了 packages/api/src/browser.ts(511 行)、packages/api/src/trpc.ts diff、所有 error 注册、apps/daemon/src/browser/coordinator.ts(519 行)及其测试、apps/desktop/src/main/browser-host.ts、browser-control-adapter.ts(前 990 行)、browser-host-wire.ts(全文 731 行)、shared/browser.ts(全文)、browser-bridge.ts(全文)、preload/index.ts diff、BrowserPanel.tsx 前 80 行、CLI browser 相关全部 diff、docs 两篇(前 30 行 + 结构)。
未精读:browser-control-adapter.ts 末尾的辅助解析函数(约 240 行)、inspect/ 子目录四个文件的实现细节、BrowserPanel.tsx 后半部分的 JSX 渲染。这些属于终端实现,不影响架构理解。