协议
WebSocket 消息信封、13 种 action 的判别联合、错误码、扩展与后端之间的 protocolVersion 握手
范围
扩展 ↔ 后端所有流量都走一条 WebSocket。每条消息两侧都用同一份 Zod schema 校验,定义在
@zapvol/common/schemas/browser-bridge.ts。客户端和服务端约定一个整数
protocolVersion;不匹配在握手阶段被拒绝,在途消息永远不会用到过期的 shape。
Schema 就是 API——后端、扩展和 agent 工具的 inputSchema 全部从它导入。
握手 (Handshake)
每条连接以 hello → ack / reject 一来一回开始。扩展在收到 ack 之前不发其他任何消息。
reject 是终止性的。扩展把 client 标为 fatal 状态、停止重连,在 popup 里把错误露出来让用户升级扩展或重新配对。
消息信封
顶层判别联合,每个变体用 type 标记:
type | 方向 | 用途 |
|---|---|---|
hello | ext → server | 握手:protocolVersion、clientVersion、pairingToken、capabilities |
ack | server → ext | 握手通过;带 serverVersion |
reject | server → ext | 握手拒绝;带 requiredMinProtocolVersion + error |
request | server → ext | Agent action;带唯一 id + action 判别联合 |
response | ext → server | 对 request 的响应;带 id 和 result 或 error |
event | ext → server | 主动事件:session_started、session_ended、domain_blocked、global_stop 等 |
request 的 id 由后端生成 UUIDv4,扩展永不自造 id。每个在途请求在池里带按 action 类型选的超时。
Action Schema(对齐 Browser Use)
单个 browser 工具,一次调用一个 action。每个 action 是判别联合中一个变体,各自带参数。
| Action | 参数 | 返回 |
|---|---|---|
navigate | url、可选 tabId | { ok: true } |
click | 二选一:{ selector } 或 { uid },可选 tabId | { ok: true } |
type | 二选一:{ selector } 或 { uid },text,可选 tabId | { ok: true } |
hover | 二选一:{ selector } 或 { uid },可选 tabId | { ok: true } |
press_key | key(Enter、Tab、Escape、方向键…) | { ok: true } |
scroll | direction(up / down)、可选 amount | { ok: true } |
screenshot | 可选 fullPage | { dataUrl } base64 JPEG(q=80) |
extract | 可选 selector(省略=全页) | { text, markdown, elements, obstacle? } —— 详见下文 |
evaluate | expression(JS 函数体,扩展端包一层 arrow-IIFE,用 return 返回值),可选 tabId | { type, value? } | { type, description } | { type, truncated, preview } |
wait_for | 二选一:{ selector } 或 { uid },可选 timeoutMs(上限 60s) | { ok: true } |
get_tabs | 无 | Array<{ tabId, url, title, domain }> —— 按 blocklist 过滤(blocklist 域不出现) |
open_tab | url、可选 focus(默认 false) | { tabId, windowId, domain } —— 自动建 session;URL 在 blocklist 则返回 domain_blocked |
close_tab | tabId | { ok: true } |
规则:
- 一次调用一个 action。多步流程 = 多次工具调用。这样重试 / 取消语义简单
- Session 在首次 action 自动创建。没有 Approve 步骤;扩展唯一的前置检查是 domain blocklist。命中 blocklist
返回
domain_blocked(终止)—— 见 Session 模型 → 如何创建 open_tab默认路由到 BUA 窗口 —— 一个独立的、minimized/unfocused 的 Chrome 窗口,确保用户主浏览器不被打扰。只有用户明确要求”看到结果”时才用focus: true。详见 Session 模型 —— BUA 窗口- 同域可同时存在多个 session(用户 tab 一个、BUA tab 一个)。Session 的主键是
tabId,非 navigate 类 action 应当带上open_tab返回的tabId。不传tabId时扩展优先挑 BUA 自己的 session——详见 Session 身份 - Agent 必须在发出前用同一份 schema 校验输入。后端工具在 AI SDK 内经
zodSchema调用browserActionSchema.parse;扩展用完整信封 schema 再校验一次入站消息
元素寻址 —— uid vs selector
click / type / hover / wait_for 接受二选一:selector(CSS 字符串)或
uid(形如 "e7" 的短缓存键)。Schema 的 refinement 在校验阶段就会拒绝”两个都给”或”一个都不给”。
extract 会在 result.elements 里为每个 interactive 可交互可访问性树节点生成一条:
{ uid: string; role: string; name?: string; value?: string; visible?: boolean }
uid 按文档顺序从 e0 / e1 / … 生成,存入扩展的 per-tab 缓存。紧接下一次 action 引用元素时,优先用 uid:
- 短(2–4 字符),相比 CSS selector 非常省 token
- 类名变了也不坏 —— 缓存项解析成 CDP
backendNodeId,不是 selector 字符串 - 点击 / hover 经
DOM.scrollIntoViewIfNeeded+DOM.getBoxModel;type 经DOM.focus—— 不走页面querySelector这一层间接
selector 在以下情况仍然有效:elements 里没有的元素(非交互 anchor、自定义 role),或本次还没
extract 过。
失效:Page.frameNavigated(主框架导航)触发时,或者显式调用 navigate 时,uid 缓存被清空。SPA
路由变化(不重载文档)不会清掉缓存。缓存失效后仍然使用旧 uid 会得到 error.code = "element_stale",agent
必须重新 extract。
extract 返回结构
{
text: string; // 原始 innerText(上限 50KB,用于逐字引用)
markdown: string; // DOM → Markdown,剥去 nav/footer/aside/fixed overlay(≤30KB)
elements: BrowserAxElement[]; // 可交互 AX 树节点 + uid(≤200 个)
obstacle?: BrowserObstacle; // 页面级阻拦信号,见下
}
markdown 是信息密度最高的部分,LLM 应优先读它。text 保留给需要原文引用的场景。v1 里的 html 不再返回
—— uid 方案取代了”让 agent 自己编 selector”。
阻拦检测 (Obstacle detection)
扩展用 detectObstacle(apps/bua/src/lib/obstacle-detect.ts — 纯函数,不依赖 DOM / CDP)对 extract
期间收集的输入做判定,信号越过阈值时填入 obstacle:
{
type: "auth_wall" | "captcha" | "access_denied";
confidence: "low" | "high";
message: string; // 简短、人可读的原因
}
分类器看到的输入(都在 markdown 抽取的同一次 Runtime.evaluate 里收集):
url—— 小写location.hreftitle—— 小写document.titlemarkdown—— 前 4K 字被扫描(阻拦标志大多在”折叠线以上”)elements—— agent 也能看到的那份 AX 树列表;密码输入框检测针对role="textbox"/role="searchbox"且 accessible name 命中/\b(password|passcode|pwd)\b/i。这样 Material、Radix、Headless UI 这类把 input 包成自定义组件的现代设计系统都能被捕获 —— 粗暴的input[type=password]查询会漏掉它们。captchaFrame—— 页面侧布尔值;当存在已知 CAPTCHA iframe 时为 true(Cloudflarechallenges.cloudflare.com、hCaptcha、reCAPTCHA、Turnstile)。
判定是五层堆栈,便宜的先算,第一层命中就短路:
| 层 | 命中条件 | 结论 |
|---|---|---|
| L1 | captcha iframe、URL 含 captcha、或 markdown 里出现”我不是机器人”等短语 | captcha、high |
| L2 | 403 / forbidden / rate-limit / blocked —— title、URL 或 markdown | access_denied、high |
| L3 | 密码 role="textbox" 存在,同时 URL 或 title 命中 auth | auth_wall、high |
| L4 | 聚合 ≥ 2 种不同的 auth 信号(password 框 / sign-in 按钮 / sign-up 链接 / 忘记密码链接 / | auth_wall、high |
| OAuth 提供商按钮 / auth URL / auth title) | ||
| …… 或仅 1 种信号但页面稀疏(markdown < 400 字) | auth_wall、low | |
| none | 无层命中 | 不填 obstacle 字段 |
判定偏向漏报优于误报——虚假的终局信号会让 agent 在正常页面上放弃任务(导航栏里的 “Log in” 链接 ≠ 登录墙)。要调时先收紧层内阈值,再考虑放宽。
Agent 契约:confidence: "high" 是终局信号,放弃本计划并上报给调用方;confidence: "low"
是提示,agent 可以再尝试一次备选方案后再放弃。
evaluate 语义
evaluate 走 Runtime.evaluate,参数固定 returnByValue: true + awaitPromise: true + userGesture: true,CDP
侧 10s 超时。扩展把 expression 包进 (() => { ... })() —— agent 必须用 return 返回值。不带 return
的裸表达式(例如 document.title)会被解析成 undefined。
返回形态:
{ type: "string"|"number"|"boolean"|…, value }—— 可 JSON 序列化的结果{ type, description }—— 不可序列化(DOM 节点、函数、Map),description是 CDP 的字符串描述{ type, truncated: true, preview }—— 序列化长度超 8KB,preview 末尾带…[truncated N chars]
表达式抛异常时响应 { error: { code: "invalid_action", message } } —— 这是调用方 bug(写错 API
名、拼错变量),不是平台失败。Agent 应当读报错改表达式,不要原样重试。
Dialog 自动处理:evaluate 调用期间,目标 tab 的 Page.javascriptDialogOpening 事件会被自动接管(alert /
beforeunload → accept: true;confirm / prompt → accept: false),防止 agent 写的表达式触发 dialog
后 CDP 死锁。evaluate 之外的时间里,页面触发的 dialog 不受干扰,以便用户能看到。
反自动化对抗
扩展在 chrome.debugger.attach 时通过 Page.addScriptToEvaluateOnNewDocument 注入一段脚本,每次新文档加载时都把
navigator.webdriver 改写为 false。对 Cloudflare 前置、票务、银行这类常见的自动化检测够用。我们特意不伪造其他信号(chrome.runtime、plugins、canvas
指纹)—— 每多伪造一项就多一个被指纹的面,而 CDP isTrusted 输入事件本身已经给了最高信号的真实性。仍然被拦的站点当作
out-of-scope 处理,不打指纹对抗的军备竞赛。
错误
response.error 和 reject.error 同 shape:
{ code: BrowserBridgeErrorCode, message: string }
错误码:
| Code | 触发时机 |
|---|---|
domain_blocked | 目标域在用户的 blocklist (sessionManager.isDomainBlocked) |
session_not_found | 目标没有活跃 session,或多 session 歧义且没传显式 tabId |
tab_not_found | 指定 tabId 已关闭或从未存在 |
element_not_found | click / type / hover / extract 的 selector 或活 uid 匹配 0 个元素 |
element_stale | 给的 uid 不在扩展的缓存里 —— 页面已导航 / 缓存被清 / 本次还没 extract |
timeout | wait_for 超时,或 WS 往返超出 pool 对该 action 的超时 |
debugger_attach_failed | chrome.debugger.attach 拒绝(同 tab 开着 DevTools、另一个调试器已挂) |
invalid_action | 消息校验失败、未知 action,或 evaluate 表达式抛异常 |
internal_error | 兜底——发送失败、扩展崩溃、意外状态 |
每个错误码都意味着不要原样重试。domain_blocked 是终止信号 —— 不要换 tab / 换 action 重试(被拦是有意的,
应告知用户)。session_not_found 通常意味着 agent 在多候选情况下没传 tabId,改成显式传入即可。element_not_found
说明 selector/uid 对当前 DOM 不对 —— 重新 extract 挑一个新目标。element_stale 说明 uid 曾经有效但页面已经换了
—— 下一个交互前先 extract。invalid_action 在 evaluate 上带回 JS 错误信息,agent 应该看着改表达式而非重试。
timeout 仅在根因是瞬态(页面加载慢)时配合 wait_for 重试。
Events
event
消息单向(扩展 → 后端),不请求响应。后端目前记日志;未来版本可能把事件注入到 agent 上下文里(比如在 agent 运行中途注入
session_ended 通知)。
| Event | 字段 |
|---|---|
session_started | domain、tabId、startedAt |
session_ended | domain、tabId、actionCount、reason —— 七种之一(详见 Session 模型 → 如何结束) |
domain_blocked | domain、attemptedAction、可选 tabId —— 当 action 打到 blocklist 域时发出 |
tab_closed | tabId —— 审计用途(相关时与 session_ended 配对出现) |
global_stop | endedCount —— popup 红色 “Stop all” 被按;服务端应把在途请求当作已取消 |
事件判别联合可扩展:新的事件变体(例如 user_task_trigger
表示页面内触发的 agent 任务)如果是纯添加且非承重,不需要 bump 协议版本就可以加。
版本迭代纪律
BROWSER_BRIDGE_PROTOCOL_VERSION 当前为 4。触发 bump 的条件:消息变体 shape 变了、字段变必填、或者语义发生了
扩展在运行时无法感知的改变。
变更记录:
- v1 → v2:
extract返回{ text, html }→{ text, markdown, elements, obstacle? };click/type/wait_for新增{ uid }作为{ selector }的二选一;新增 actionhover与evaluate;新增错误码element_stale。服务端旧版本兼容保留一个 release。 - v2 → v3:
sessionEndReason枚举新增system_idle与screen_locked,让审计日志能区分”用户走开了”vs “用户锁屏了”(此前两者都映射到expired)。v2 服务端收到携带新 reason 的session_ended会 Zod 拒绝 —— bump 握手版本让不兼容在连接时就失败,避免静默丢审计记录。 - v3 → v4:UX 优先重构。移除 consent 层:per-domain allowlist → domain blocklist,移除 TTL(session 在
tab-close / idle / detach / stop / blocklist 时终止)。错误码
scope_violation与session_expired被移除, 引入domain_blocked。事件permission_denied替换为domain_blocked;新增global_stop事件(popup “Stop all” 触发)。session_started移除expiresAt(无 TTL),带startedAt;session_ended新增tabId+actionCount。sessionEndReason移除"expired",新增"domain_blocked"+"global_stop"。硬切 —— 仅内部部署,服务端+扩展 协调升级,不保留 v3 兼容 shim。 v3 服务端会拒绝 v4 扩展握手;v4 服务端也会拒绝 v3 扩展握手。
Bump 时:
- 改
@zapvol/common/src/schemas/browser-bridge.ts里的常量 - 内部部署(如 v3 → v4)可以硬切:扩展和服务端协调部署,
requiredMinProtocolVersion直接等于新版本,没有过渡窗口 - 公开部署需要服务端保留旧版本支持至少一个 release——接受新旧两个
hello.protocolVersion并在内部分支,或维护 两份平行 schema。服务端requiredMinProtocolVersion设置为前一个支持的版本,旧扩展还能配对;一个 release 后再 把requiredMinProtocolVersion提到新版本
纯添加——新 action 变体、新 event 类型、新可选字段——不需要 bump 版本号,前提是旧扩展收到后能安全忽略。