browser-use:给 Buffin 加上 Agent 浏览器自动化能力

buffin-ai/buffin · 3d1545a3...browser-use · 2026-08-01 · 自包含,读完即弃

17 commits
83 文件
+10,640 / −192 行
35% 是测试代码

写法说明:本文按「逐跳走读」展开——每个机制都给真实代码片段(取自分支、经裁剪,青色斜体注释为解读所加,灰色斜体是源码原注释的保留或意译),每段代码标注所在文件。每条旅程结尾有一张「排查路标」:将来出问题时,症状对应去哪个文件看哪个函数。

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
7,559 行 · 70%
packages/api
1,208 行 · 11%
apps/daemon
929 行 · 9%
docs/
882 行 · 8%
apps/cli
230 行 · 2%
子系统设计重心(要细读)可放心略过
apps/desktopbrowser-control-adapter, browser-host-wire, browser-host, inspect/* 系统, BrowserPanel.tsxbrowser-bridge(纯 IPC 胶水), preload 注册
packages/apibrowser.ts(完整合约定义 511 行), trpc.ts 的 browserRoutererrors/* 的 12 个 code 注册(机械)
apps/daemonbrowser/coordinator.ts(内存命令队列)index.ts/testing 的 wireServices 注入行
apps/clibrowser/binary.ts(multi-platform 二进制寻址)commands/browser.ts 的 pass-through wrapper
docs/browser-profile-import-research.md(竞品对比决策)zh-Hans 镜像

3架构一图流

以前 · 无浏览器通道

Agent / CLI
tRPC WS
Daemon
Renderer
IPC
Electron Main

现在 · Browser Host 回路

Agent / CLI
browser.action / browser.snapshot
Daemon Coordinator
Daemon Coordinator
offer → accept → complete
Desktop Wire
Desktop Wire
adapter.action()
Control Adapter
Control Adapter
CDP Gate (loopback WS)
agent-browser CLI
Renderer
IPC browser:*
BrowserHost (main)

4数据与状态先行

合约层形状(packages/api/src/browser.ts)

整个合约围绕三个坐标:browserId(逻辑 target 标识,对应一个 WebContentsView)、epoch(导航代次,每次 main-frame navigation 递增;epoch 改变意味着旧 accessibility ref 失效)、leaseId(Host 持有的单租约凭证,30 秒过期需心跳续命)。

packages/api/src/browser.ts核心坐标
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,也无法注入脚本。

packages/api/src/browser.ts受控 action 联合
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)

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。

写命令 + Host 断连
offered(等待 Host 取件)
↓
accepted(Host 开始执行)
↓ Host 断连
outcome_unknown(不知道页面是否已被改)
读命令 + Host 断连
offered(等待 Host 取件)
↓
accepted(Host 开始执行)
↓ Host 断连
failed: targetDisconnected(安全丢弃)
新增一条 browser 操作的标准步骤:① 在 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 下发一个"点击按钮"命令

这是最典型的端到端路径:外部调用方想让浏览器点击某个元素引用。走通后掌握所有层如何串联。

全景 · 涉及 6 个文件
API 入口
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()。

packages/api/src/trpc.tsbrowserRouter 片段
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。

apps/daemon/src/browser/coordinator.tsaction 实现
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。

apps/desktop/src/main/browser-host-wire.tsprocessCommand 片段
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 转发给实际页面。

apps/desktop/src/main/browser-control-adapter.tsactionCommands 映射
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.hostUnavailabletrpc.ts → requireBrowserService():检查 desktop 是否启动并注入了 browser service
Action 返回 browser.staleEpochcoordinator.ts → requireExactEpoch():target 导航已发生,旧 epoch 不接受
Action 超时无响应browser-host-wire.ts:检查 pollTimer 是否在跑、daemon WS 是否连通
browser.actionFailedbrowser-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 并回报结果。

全景 · 涉及 4 个文件
browser.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 才返回。

apps/daemon/src/browser/coordinator.tswaiter 模式
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: truebrowser-control-adapter.ts → convertSnapshot():maxNodes / maxText 预算
Snapshot ref 格式错误parseSnapshotNodes():文本解析逻辑

8旅程 C:Host 掉线与恢复

Desktop 进程重启或 WebSocket 断开时,wire 执行以下恢复路径:

  1. Wire 检测到 tRPC connectionState 变为 connecting + error ≠ null,触发 reconnect()
  2. 如果有 activeCommand,abort 它的 AbortController(adapter 中的进程被 SIGTERM)
  3. 尝试 disconnect 旧 lease(best-effort)
  4. 关闭旧 client,exponential backoff(250ms → 10s 上限)后重新 connect()
  5. 新 client ready 后重新 attach(),coordinator 给出新 lease,恢复 heartbeat + poll

Coordinator 侧对旧 lease 的处理:clearHost() 遍历 commandOrder——offered 态的命令留着供新 Host 重新取件;accepted 态的写命令标 outcome_unknown;accepted 态的读命令标 failed: targetDisconnected。

排查路标 · 旅程 C
症状从哪下手
Host 重连后旧命令全部 outcome_unknowncoordinator.ts → clearHost():只有 accepted + isWrite 的命令走这条路径
重连循环不收敛browser-host-wire.ts → scheduleRetry():backoff 有 10s 上限;检查 daemon 是否存活
Browser Host 掉线后 snapshot 调用者 rejectcoordinator.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心智模型补丁

Buffin 只通过 tRPC 管 task / session / agent 业务 tRPC 现在还承载浏览器命令调度(browser.*),Desktop 作为 Host 主动连回 daemon
Desktop ← daemon 方向是新增的反向通道(Desktop 主动 attach + poll)
Desktop 的 Electron main 只服务于 renderer 的 IPC 请求 main 进程同时作为 daemon 的 Browser Host 客户端运行一个独立的 WebSocket 连接 + heartbeat 循环
Agent 操作浏览器需要自带 Playwright/Puppeteer 进程 Agent 只需调 browser.snapshot + browser.action,操作对象是 opaque ref 而非 selector
安全边界:API 合约拒绝 selector / script / 非 HTTP URL,操作粒度受限于五种 action
daemon 的 Services 接口只有必选字段 browser 是第一个可选的 service(browser?: BrowserService),desktop 不运行时合法缺失
packages/client 的 Node tRPC 只暴露 client + ready + close 新增 connectionState observable,Browser Wire 用来检测 transport loss
没有随 Buffin 分发的第三方 native binary CLI 和 Desktop 都捆绑 agent-browser 0.33.1 二进制(multi-platform resolution 逻辑见 browser/binary.ts)

11新词表

Browser 自动化域
Browser Host持有实际 Electron 页面的运行时(Desktop 进程),向 daemon 注册并接收命令
Browser Coordinatordaemon 内存中的调度器——管理 Host 租约、命令排队、失联解决
Browser WireDesktop main 里负责 WS 连接、heartbeat、offer 轮询的状态机
Control AdapterDesktop main 里做实际页面操作的层——管 agent-browser 进程生命周期和 CDP 桥接
CDP Gate一个 loopback WebSocket server,为每次 engine 调用临时发放 capability URL 供 CDP 连接
epoch导航代次——main-frame navigation 发生一次加 1,旧 epoch 的 ref 全部失效
refaccessibility 节点的不透明引用,只在同一 epoch 内有效
leaseHost 的独占凭证,30 秒过期需心跳续期,同时只有一个 Host
outcome_unknown写命令在已被 accepted 后 Host 断连——页面可能已变但无法确认
桌面 UI
BrowserPanelRenderer 侧浏览器 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 resolutionapps/cli/tests/browser-binary.test.ts(52 行)
薄冰地带:

13验收提示

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 渲染。这些属于终端实现,不影响架构理解。