架构
运行时拓扑(后端 / 扩展 / CDP 目标 tab)、共享 pool 抽象、镜像 @zapvol/app 的扩展 5 层 UI、端到端数据流
端到端流程 (End-to-End Flow)
一次 browser 工具调用穿过 5 层 —— 从主 agent 通过 task 委派,经 schema 校验的 WebSocket 信封,进入扩展的同意闸口,沿着匹配动作的 CDP 分支下到真实的 Chrome tab。底部单独列出三项承重机制(安全不变量、uid 缓存生命周期、障碍检测),因为它们跨层。
从上到下读,就是”当 agent 调 click({ uid: 'e3' }) 时,发生了什么”;左右横着读底部三张卡,就是”哪些不变量让整条链路安全且韧性”。本页其余部分逐层放大。
运行时拓扑 (Runtime Topology)
每次 browser 工具调用都牵涉三个区域:agent 后端 (backend)(server 或 desktop)、Chrome 扩展 (MV3 service
worker)、目标 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 | 认证 |
|---|---|---|
| Server | wss://<host>/ws/browser | Pairing token,由 /api/browser-extension/pairing-token 返回 |
| Desktop | ws://127.0.0.1:48123 | Pairing token 文件,放在 Electron 的 userData 目录 |
扩展 UI 分层 (5 层)
扩展的 popup 和 options 严格镜像 @zapvol/app 的 Contract → Service → Context → Hooks → UI 模式。UI 组件永不 直接调
chrome.runtime.* 或 chrome.storage.*。
| 层 | 路径 | 角色 |
|---|---|---|
| 契约 (Contract) | src/contracts/bridge-service.ts | BridgeService 接口 + BridgeState 类型——唯一事实源 |
| 服务 (Services) | src/services/bridge-service-local.ts | Background 侧:组装 SessionManager + WsClient 成服务 |
src/services/bridge-service-runtime.ts | UI 侧:每个契约方法经 chrome.runtime.sendMessage 代理 | |
| 上下文 (Context) | src/context/bridge-context.tsx | React provider + useBridgeService() 注入 hook |
| 钩子 (Hooks) | src/hooks/use-bridge-state.ts | 经 subscribeState + getState 实时订阅 BridgeState |
src/hooks/use-bridge-config.ts | 配置读 / 存 / 清空 + loading / error 状态 | |
src/hooks/use-blocklist.ts | Blocklist list / add / remove + 自动刷新 | |
| 界面 (UI) | src/entrypoints/popup/App.tsx | 活动监视器 —— session 流 / 单 Stop / 全局 “Stop all” |
src/entrypoints/options/App.tsx | Connection + 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)。
- Browser subagent 产出工具调用:
browser({ action: { type: "click", uid: "e3", tabId: 42 } })。subagent 的uid: "e3"来自上一轮extract返回的elements列表。 browser工具的execute转发给context.browserBridge.request(action, abortSignal)。- 按用户的 bridge 调
pool.request(userId, action)—— 生成 UUID,发{ type: "request", id, action },存一个带 pool 超时和 abort-listener 清理的 pending Promise。 - 扩展
ws-client收到消息,经browserBridgeMessageSchema校验,路由到background.ts注册的 request handler。 action-dispatcher跑buildTarget({ uid: "e3" })→{ uid: "e3" },再走resolveTabAndDomain(tabId 已显式给出,直接命中),再调sessionManager.isDomainBlocked(domain)(blocklist 检查) →sessionManager.ensureSession(tabId, domain)(首次创建,后续复用;中途导航静默更新session.domain)。debuggerController.click(tabId, { uid: "e3" })。Controller 查uidCaches.get(tabId).get("e3")→backendNodeId: 1829;DOM.scrollIntoViewIfNeeded({ backendNodeId: 1829 })+DOM.getBoxModel({ backendNodeId: 1829 })得到中心 (x, y);在该点发Input.dispatchMouseEventmousePressed → mouseReleased。动作结束后 dispatcher 调sessionManager.recordAction(tabId)累加actionCount+ 刷新lastActionAt。- CDP 返回;dispatcher 回
{ result: { ok: true } };扩展发{ type: "response", id, result: { ok: true } }回 WS。 - Pool 解析 pending Promise;
browser工具的executepush 一条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传一个AbortSignal。browser工具把它透传给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.frameNavigated 或 navigate 时清空 |
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 爆炸