架构

运行时拓扑(后端 / 扩展 / CDP 目标 tab)、共享 pool 抽象、镜像 @zapvol/app 的扩展 5 层 UI、端到端数据流

端到端流程 (End-to-End Flow)

一次 browser 工具调用穿过 5 层 —— 从主 agent 通过 task 委派,经 schema 校验的 WebSocket 信封,进入扩展的同意闸口,沿着匹配动作的 CDP 分支下到真实的 Chrome tab。底部单独列出三项承重机制(安全不变量、uid 缓存生命周期、障碍检测),因为它们跨层。

BUA 端到端架构 一次浏览器动作穿越 5 层 —— 从 agent 意图到真实 Chrome 页面 1 · 调用方 (Caller) — packages/backend Agent 决定做什么;后端把它转成经 schema 校验的 request Main Agent browser 工具经 INTERNAL_ONLY_TOOLS_SET 剥离 用 task(subagent_type: "browser") 委派 Browser Subagent toolKeys: [browser, complete] 独立 ZapvolContext;继承 parent.browserBridge 跑 ReAct 循环,返回一份 summary browser.tool.ts inputSchema: zodSchema(browserActionSchema) compact() + toClientOutput() 塑形返回 abortSignal 转交 bridge.request BrowserBridge pool 按用户维度的 WebSocket · 生成 msg.id (UUIDv4) 池超时 30s · abortSignal → reject + 清理 id 匹配的 response 解析 promise WS 发送 · type="request" · { id, action } · protocolVersion = 2 2 · 协议信封 (Protocol Envelope) @zapvol/common/schemas/browser-bridge.ts — 单一 Zod 联合,两端都校验 hello / ack / reject request { id, action } response { id, result | error } event (session_*, permission_denied) pairing · apex-domain 校验 3 · 扩展 Background SW — 闸口 (the gate) 同意 (consent) 与会话不变量在这里强制,先于任何 CDP 调用;越界动作直接拒 ActionDispatcher resolveTabAndDomain · 显式 tabId 优先 · 兜底:优先 BUA 自己的 session · 多用户 tab 歧义 → 拒绝 buildTarget({selector} | {uid}) mapError → 协议错误码 SessionManager checkScope(domain, tabId) · allowlist 命中 (per-domain TTL) · Date.now() < session.expiresAt · chrome.idle + 屏幕锁守卫 incrementActionCount → TTL 滚动续期 chrome.alarms: 主动到期 BuaWindowManager 专用最小化 Chrome 窗口 · 首次 open_tab 时懒创建 · focused: false、state: "minimized" · 为空时自动收起 withOpenTabLock → 不留孤儿窗口 windowId 跨 SW 重启持久化 DebuggerController .attach chrome.debugger.attach (CDP 1.3) enable: Page · DOM · Runtime · AX Page.addScriptToEvaluateOnNewDocument → navigator.webdriver 覆写 监听: Page.frameNavigated 监听: Page.javascriptDialogOpening Scope 通过 → chrome.debugger.sendCommand({ tabId }, …) 发 CDP 命令 4 · DebuggerController — CDP 分派,每类 action 一条路径 apps/bua/src/debugger-controller.ts — 真正的浏览器操作都在这里 navigate Page.navigate({ url }) 预清 uidCaches.delete(tabId) Page.frameNavigated 事件 → 再清一次 (双保险) SPA 路由变化 (不 reload) 不清 uidCache click / hover resolveTargetCenter(target) uid: scrollIntoViewIfNeeded + DOM.getBoxModel selector: querySelector + rect click: dispatchMouseEvent mousePressed + mouseReleased hover: 单次 mouseMoved → event.isTrusted = true uid 失效 → element_stale type 聚焦目标元素 uid: DOM.focus(backendNodeId) selector: evaluate 内 el.focus() Input.insertText({ text }) 不合成键盘事件 (接受 Unicode,类似粘贴) press_key 走单独的 Input.dispatchKeyEvent 路径 用 KEY_MAP 映射具名键 extract Runtime.evaluate buildExtractScript · 克隆 DOM → Markdown walker · 剥 nav/footer/aside/浮层 · obstacle 信号 (内联) Accessibility.getFullAXTree · 按 INTERACTIVE_ROLES 过滤 · 分配 e0/e1/… (上限 200) → uidCaches.set(tabId, map) detectObstacle(url, elements, …) evaluate 表达式包 arrow-IIFE (() => { expression })() Runtime.evaluate returnByValue + awaitPromise evaluateInProgress.add(tabId) 期间 dialog 自动处理 值 >8KB → preview + truncated JS 抛错 → invalid_action finally 里 .delete (不泄漏) 5 · Chrome Tab — 真实页面、真实事件 站点看到的是真实用户输入;SW 监听反馈信号以保持缓存和 dialog 同步 isTrusted CDP input 鼠标 + 键盘事件看起来像真人 通过 event.isTrusted 校验 登录表单 / 拖拽 / 框架 gate 均过 Anti-webdriver shim navigator.webdriver = false 每个新文档都安装一次 不做更广的指纹伪造 Page.frameNavigated ↑ 主框架导航 → SW 清 uidCache 下一次按 uid 的动作抛 element_stale — agent 须重新 extract Page.javascriptDialogOpening ↑ 若 evaluateInProgress.has(tabId): alert / beforeunload → accept confirm / prompt → dismiss ↑ response { id, result | error } 沿栈回传 · 池解析 promise · 工具把结果交回 agent 三项承重机制 A · 安全不变量 (Security invariants) 由 SessionManager 强制;设计上无法绕过 按域名 allowlist 每个站点显式用户授权 TTL + 滚动续期 默认 15 分钟 · 每次 action 刷新 空闲 / 锁屏守卫 system_idle 30m · screen_locked 立即 BUA 窗口 agent 的 tab 最小化 · 不抢焦点 审计日志 sessionHistory 只追加 · 上限 1000 条 B · uid 缓存生命周期 uidCaches: Map<tabId, Map<uid, backendNodeId>> ① extract 填充 AX 树遍历 → e0/e1/… → Map 写入 ② click / type / hover / wait_for 使用 lookupUid → backendNodeId → 按 id 发 CDP ③ navigate 或 Page.frameNavigated uidCaches.delete(tabId) — 该 tab 的 map 清空 ④ 过期 uid → element_stale agent 必须重新 extract 再交互 C · 障碍检测 (Obstacle detection) lib/obstacle-detect.ts · 对 extract 输入的纯函数 输入 (extract round-trip 内采集) url · title · markdown · captchaFrame elements[] — AX 树能识别设计系统的密码输入框 detectObstacle —— 5 层堆栈 L1 captcha · L2 denied · L3 password+auth · L4 ≥2 signals 第一层命中即短路 · 偏向漏报优于误报 extract.obstacle? = { type, confidence, message } high = 终局 · low = 提示 · compact 保留此字段

从上到下读,就是”当 agent 调 click({ uid: 'e3' }) 时,发生了什么”;左右横着读底部三张卡,就是”哪些不变量让整条链路安全且韧性”。本页其余部分逐层放大。

运行时拓扑 (Runtime Topology)

每次 browser 工具调用都牵涉三个区域:agent 后端 (backend)(server 或 desktop)、Chrome 扩展 (MV3 service worker)、目标 tab(用户已登录的页面)。

Browser Use Agent — 运行时拓扑 (Runtime Topology) Backend ↔ Extension ↔ CDP 目标 后端 (Backend) server 或 desktop browser tool action 判别联合 context.browserBridge 按用户注入 BrowserBridgePool userId → ws WS endpoint Hono /ws/browser 或 ws://127.0.0.1:48123 pairing token 认证 Chrome 扩展 (Extension) background service worker ws-client 握手 + 重连 session-manager allowlist + TTL 门禁 action-dispatcher 作用域校验 → CDP 调用 debugger-controller Input.dispatchMouseEvent Page.captureScreenshot... popup + options 仅薄 UI 目标 Tab (Target Tab) 用户已登录页面 DOM + cookie 真实 event.isTrusted Gmail · 内部系统 · SaaS request response event WebSocket hello / ack / reject chrome.debugger CDP 命令 agent 发起动作 扩展校验作用域 CDP 在真实 tab 执行

  • 后端持有 agent 循环。browser 工具收到 action,查 context.browserBridge,调 pool.request(userId, action)。Pool 把请求序列化经活跃 WebSocket 发给扩展。
  • 扩展持有执行点。ws-client 收到请求;session-manager 对目标 tab 当前域名检查 blocklist,首次 action 自动 建 session;action-dispatcher 把 action 映射到 CDP 命令,经 debugger-controller 执行。
  • 目标 Tab 是用户真正控制的 Chrome tab。CDP Input.dispatchMouseEvent 发出 event.isTrusted = true 的真实事件——这是 Gmail、Google Accounts、多数企业 SaaS 所要求的。

只有 background service worker 持有 WebSocket。popup、options、content script 都经 chrome.runtime.sendMessage 中转,从不直连。

共享 Pool —— 一份实现,两个平台

后端是跨平台的。连接池、按用户 bridge 工厂、BrowserBridgeSocket 抽象都放在 @zapvol/backend/infra/browser-bridge-{pool,bridge}.ts

export interface BrowserBridgeSocket {
  send(data: string): void;
  close(code?: number, reason?: string): void;
}

export interface BrowserBridgePool {
  isConnected(userId: string): boolean;
  request(userId: string, action: BrowserAction): Promise<BrowserBridgeActionResult>;
  attach(userId: string, ws: BrowserBridgeSocket): void;
  detach(userId: string, reason?: string): void;
  handleMessage(userId: string, raw: string): void;
  getStats(): { connections: number; inFlight: number };
}

Hono 的 WSContext (server) 和 ws 包的 WebSocket (desktop Electron 主进程) 结构上都满足 BrowserBridgeSocket——不用写适配器。只有握手路由因平台不同:

平台Endpoint认证
Serverwss://<host>/ws/browserPairing token,由 /api/browser-extension/pairing-token 返回
Desktopws://127.0.0.1:48123Pairing token 文件,放在 Electron 的 userData 目录

扩展 UI 分层 (5 层)

扩展的 popup 和 options 严格镜像 @zapvol/app 的 Contract → Service → Context → Hooks → UI 模式。UI 组件永不 直接调 chrome.runtime.*chrome.storage.*

扩展 UI 分层 (Extension UI Layering) 镜像 @zapvol/app — Contract → Service → Context → Hooks → UI 契约 (Contract) src/contracts/bridge-service.ts BridgeService interface + BridgeState —— 唯一事实源 服务 (Services) src/services/ bridge-service-local.ts background 侧 —— 直接调用依赖 bridge-service-runtime.ts UI 侧 —— chrome.runtime 代理 上下文 (Context) src/context/bridge-context.tsx BridgeProvider + useBridgeService() 钩子 (Hooks) src/hooks/ use-bridge-state 实时订阅状态 use-bridge-config 读 / 存 / 清空配置 use-allowlist 列 / 加 / 删域名 界面 (UI) src/entrypoints/{popup,options}/App.tsx 永不直接调 chrome.* —— 全走 hooks

路径角色
契约 (Contract)src/contracts/bridge-service.tsBridgeService 接口 + BridgeState 类型——唯一事实源
服务 (Services)src/services/bridge-service-local.tsBackground 侧:组装 SessionManager + WsClient 成服务
src/services/bridge-service-runtime.tsUI 侧:每个契约方法经 chrome.runtime.sendMessage 代理
上下文 (Context)src/context/bridge-context.tsxReact provider + useBridgeService() 注入 hook
钩子 (Hooks)src/hooks/use-bridge-state.tssubscribeState + getState 实时订阅 BridgeState
src/hooks/use-bridge-config.ts配置读 / 存 / 清空 + loading / error 状态
src/hooks/use-blocklist.tsBlocklist list / add / remove + 自动刷新
界面 (UI)src/entrypoints/popup/App.tsx活动监视器 —— session 流 / 单 Stop / 全局 “Stop all”
src/entrypoints/options/App.tsxConnection + blocklist + audit log

跨进程消息(popup ↔ background)被两个文件包在协议边界上:

  • src/runtime-protocol.ts —— 有类型的 request / broadcast shape,用 kind: "bridge_request" | "bridge_broadcast" 标记,避免与扩展内其他消息总线冲突
  • src/runtime-handler.ts —— background 侧 chrome.runtime.onMessage 监听器,分派到本地服务并把状态变更广播回 UI

端到端数据流 —— 一次 click({ uid }) action

具体走查,补充顶部主图。标出每条边界上穿越的类型和 v2 的 uid 路径(selector 路径控制流一样,只是坐标解析走 querySelector 而不是 DOM.getBoxModel)。

  1. Browser subagent 产出工具调用:browser({ action: { type: "click", uid: "e3", tabId: 42 } })。subagent 的 uid: "e3" 来自上一轮 extract 返回的 elements 列表。
  2. browser 工具的 execute 转发给 context.browserBridge.request(action, abortSignal)
  3. 按用户的 bridge 调 pool.request(userId, action) —— 生成 UUID,发 { type: "request", id, action },存一个带 pool 超时和 abort-listener 清理的 pending Promise。
  4. 扩展 ws-client 收到消息,经 browserBridgeMessageSchema 校验,路由到 background.ts 注册的 request handler。
  5. action-dispatcherbuildTarget({ uid: "e3" }){ uid: "e3" },再走 resolveTabAndDomain(tabId 已显式给出,直接命中),再调 sessionManager.isDomainBlocked(domain)(blocklist 检查) → sessionManager.ensureSession(tabId, domain)(首次创建,后续复用;中途导航静默更新 session.domain)。
  6. debuggerController.click(tabId, { uid: "e3" })。Controller 查 uidCaches.get(tabId).get("e3")backendNodeId: 1829DOM.scrollIntoViewIfNeeded({ backendNodeId: 1829 }) + DOM.getBoxModel({ backendNodeId: 1829 }) 得到中心 (x, y);在该点发 Input.dispatchMouseEvent mousePressed → mouseReleased。动作结束后 dispatcher 调 sessionManager.recordAction(tabId) 累加 actionCount + 刷新 lastActionAt
  7. CDP 返回;dispatcher 回 { result: { ok: true } };扩展发 { type: "response", id, result: { ok: true } } 回 WS。
  8. Pool 解析 pending Promise;browser 工具的 execute push 一条 task_milestone 事件,向 subagent loop 返回 { ok: true, action: "click", result: { ok: true } }

会在哪里断链。 第 5 步可能出 domain_blocked(目标域在 blocklist)/ tab_not_found;第 6 步可能出 element_stale(uid 不在缓存 —— agent 必须先 extract)或 element_not_found(缓存里的 backendNodeId 指向的节点已从 DOM 里消失)。所有失败共享同一套 response envelope;agent 按 Session 模型 → 错误处理契约 对应处理。

取消 (Cancellation)

两条独立的中止路径都能干净地终止在途的 BUA action:

  • 扩展侧(用户主动):popup 单 Stop now、全局红色 Stop all、或 Chrome 调试条 Cancel → 扩展 detach 调试器、发 session_ended(Stop all 还额外发 global_stop);该 tab 上后续 action 会拿到 session_not_found。 把目标域加入 blocklist 同样走结束路径,之后该域的 action 全部被 domain_blocked 拒绝。
  • 后端侧(agent run 被取消):AI SDK 给每个工具的 execute 传一个 AbortSignalbrowser 工具把它透传给 bridge.request(action, signal)BrowserBridgePool.request 给 pending-request 挂 abort 监听。信号触发时,pool 立刻把 pending promise resolve 成 { error: { code: "internal_error", message: "aborted by caller" } },清 timer、摘监听。扩展后续晚到的 response 在 pool 侧作为 stray 日志一下丢弃。

两条路径都走同一个错误 envelope,所以子 agent 的错误处理契约统一适用——agent loop 里没有新的代码分支。

状态归属

状态归属方存储
配对配置扩展chrome.storage.local key bridge_config
域名 blocklist 条目扩展chrome.storage.local key blocklist
活跃 session(以 tabId 为主键,同域可多个)扩展chrome.storage.local key activeSessions
BUA 窗口 id扩展chrome.storage.local key buaWindowId
Session 审计日志(≤ 1000 条)扩展chrome.storage.local key sessionHistory
在途 WS 请求Pool (后端, 内存)Map<userId, Map<requestId, Pending>>
每用户连接Pool (后端, 内存)Map<userId, WSContext | WebSocket>
CDP 挂载集合debugger-controller内存 Set<tabId>
uid → backendNodeId 缓存(per tab)debugger-controller内存 Map<tabId, Map<uid, backendNodeId>> —— Page.frameNavigatednavigate 时清空
evaluateInProgress 集合(dialog 自动处理)debugger-controller内存 Set<tabId>
SessionEvent 订阅者session-manager 模块作用域内存 Set<listener>(SW 唤醒时重新挂载)

Background service worker 随时会被 Chrome 终止。重启时 bridge client 重连,扩展侧状态从 chrome.storage.local 水合回来。SW 被终止时在途的 agent 请求在后端侧会被拒绝为 internal_error —— agent 感知失败后决定是否重试或中止。

为什么是这个形状

  • 一份契约、两种实现 —— 让 popup/options 对依赖诚实。测试时换成 mock 实现只是改一次工厂调用
  • Pool 在共享后端 —— @zapvol/server@zapvol/desktop 拿到完全一致的授权校验行为。修一个超时 bug 两边同时生效
  • 作用域校验在扩展而不是后端 —— 只有扩展能看到实时 tab 状态,它能在派发前原子地校验”域名不在 blocklist 且 tab 仍然打开”。后端信任扩展的答复,把错误展示给 agent
  • 一个工具 + action 枚举 —— 对齐 Anthropic computer-use 的形态,让 prompt 保持紧凑(13 个 action 在一个 JSON schema 里,LLM 易懂)。每个 action 一个工具会让 prompt 爆炸
这页有帮助吗?