协议

WebSocket 消息信封、13 种 action 的判别联合、错误码、扩展与后端之间的 protocolVersion 握手

范围

扩展 ↔ 后端所有流量都走一条 WebSocket。每条消息两侧都用同一份 Zod schema 校验,定义在 @zapvol/common/schemas/browser-bridge.ts。客户端和服务端约定一个整数 protocolVersion;不匹配在握手阶段被拒绝,在途消息永远不会用到过期的 shape。

Schema 就是 API——后端、扩展和 agent 工具的 inputSchema 全部从它导入。

握手 (Handshake)

每条连接以 helloack / reject 一来一回开始。扩展在收到 ack 之前不发其他任何消息。

sequenceDiagram participant Ext as Extension participant BE as Backend Note over Ext,BE: WS 连接打开 Ext->>BE: hello (protocolVersion, clientVersion, pairingToken) alt 版本匹配且 pairing token 有效 BE-->>Ext: ack (protocolVersion, serverVersion) Note over Ext,BE: request / response / event 自由流动 else 版本或 token 不匹配 BE-->>Ext: reject (requiredMinProtocolVersion, error) BE->>BE: 关连接 4001 或 4002 end

reject终止性的。扩展把 client 标为 fatal 状态、停止重连,在 popup 里把错误露出来让用户升级扩展或重新配对。

消息信封

顶层判别联合,每个变体用 type 标记:

type方向用途
helloext → server握手:protocolVersionclientVersionpairingToken、capabilities
ackserver → ext握手通过;带 serverVersion
rejectserver → ext握手拒绝;带 requiredMinProtocolVersion + error
requestserver → extAgent action;带唯一 id + action 判别联合
responseext → serverrequest 的响应;带 idresulterror
eventext → server主动事件:session_startedsession_endeddomain_blockedglobal_stop

requestid 由后端生成 UUIDv4,扩展永不自造 id。每个在途请求在池里带按 action 类型选的超时。

Action Schema(对齐 Browser Use)

单个 browser 工具,一次调用一个 action。每个 action 是判别联合中一个变体,各自带参数。

Action参数返回
navigateurl、可选 tabId{ ok: true }
click二选一:{ selector }{ uid },可选 tabId{ ok: true }
type二选一:{ selector }{ uid }text,可选 tabId{ ok: true }
hover二选一:{ selector }{ uid },可选 tabId{ ok: true }
press_keykeyEnterTabEscape、方向键…){ ok: true }
scrolldirectionup / down)、可选 amount{ ok: true }
screenshot可选 fullPage{ dataUrl } base64 JPEG(q=80)
extract可选 selector(省略=全页){ text, markdown, elements, obstacle? } —— 详见下文
evaluateexpression(JS 函数体,扩展端包一层 arrow-IIFE,用 return 返回值),可选 tabId{ type, value? } | { type, description } | { type, truncated, preview }
wait_for二选一:{ selector }{ uid },可选 timeoutMs(上限 60s){ ok: true }
get_tabsArray<{ tabId, url, title, domain }> —— 按 blocklist 过滤(blocklist 域不出现)
open_taburl、可选 focus(默认 false{ tabId, windowId, domain } —— 自动建 session;URL 在 blocklist 则返回 domain_blocked
close_tabtabId{ 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)

扩展用 detectObstacleapps/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.href
  • title —— 小写 document.title
  • markdown —— 前 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(Cloudflare challenges.cloudflare.com、hCaptcha、reCAPTCHA、Turnstile)。

判定是五层堆栈,便宜的先算,第一层命中就短路:

命中条件结论
L1captcha iframe、URL 含 captcha、或 markdown 里出现”我不是机器人”等短语captchahigh
L2403 / forbidden / rate-limit / blocked —— title、URL 或 markdownaccess_deniedhigh
L3密码 role="textbox" 存在,同时 URL 或 title 命中 authauth_wallhigh
L4聚合 ≥ 2 种不同的 auth 信号(password 框 / sign-in 按钮 / sign-up 链接 / 忘记密码链接 /auth_wallhigh
OAuth 提供商按钮 / auth URL / auth title)
…… 或仅 1 种信号但页面稀疏(markdown < 400 字)auth_walllow
none无层命中不填 obstacle 字段

判定偏向漏报优于误报——虚假的终局信号会让 agent 在正常页面上放弃任务(导航栏里的 “Log in” 链接 ≠ 登录墙)。要调时先收紧层内阈值,再考虑放宽。

Agent 契约confidence: "high"终局信号,放弃本计划并上报给调用方;confidence: "low" 是提示,agent 可以再尝试一次备选方案后再放弃。

evaluate 语义

evaluateRuntime.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 / beforeunloadaccept: trueconfirm / promptaccept: 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.errorreject.error 同 shape:

{ code: BrowserBridgeErrorCode, message: string }

错误码:

Code触发时机
domain_blocked目标域在用户的 blocklist (sessionManager.isDomainBlocked)
session_not_found目标没有活跃 session,或多 session 歧义且没传显式 tabId
tab_not_found指定 tabId 已关闭或从未存在
element_not_foundclick / type / hover / extractselector 或活 uid 匹配 0 个元素
element_stale给的 uid 不在扩展的缓存里 —— 页面已导航 / 缓存被清 / 本次还没 extract
timeoutwait_for 超时,或 WS 往返超出 pool 对该 action 的超时
debugger_attach_failedchrome.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 曾经有效但页面已经换了 —— 下一个交互前先 extractinvalid_actionevaluate 上带回 JS 错误信息,agent 应该看着改表达式而非重试。 timeout 仅在根因是瞬态(页面加载慢)时配合 wait_for 重试。

Events

event 消息单向(扩展 → 后端),不请求响应。后端目前记日志;未来版本可能把事件注入到 agent 上下文里(比如在 agent 运行中途注入 session_ended 通知)。

Event字段
session_starteddomaintabIdstartedAt
session_endeddomaintabIdactionCountreason —— 七种之一(详见 Session 模型 → 如何结束
domain_blockeddomainattemptedAction、可选 tabId —— 当 action 打到 blocklist 域时发出
tab_closedtabId —— 审计用途(相关时与 session_ended 配对出现)
global_stopendedCount —— popup 红色 “Stop all” 被按;服务端应把在途请求当作已取消

事件判别联合可扩展:新的事件变体(例如 user_task_trigger 表示页面内触发的 agent 任务)如果是纯添加且非承重,不需要 bump 协议版本就可以加。

版本迭代纪律

BROWSER_BRIDGE_PROTOCOL_VERSION 当前为 4触发 bump 的条件:消息变体 shape 变了、字段变必填、或者语义发生了 扩展在运行时无法感知的改变。

变更记录:

  • v1 → v2extract 返回 { text, html }{ text, markdown, elements, obstacle? }click / type / wait_for 新增 { uid } 作为 { selector } 的二选一;新增 action hoverevaluate;新增错误码 element_stale。服务端旧版本兼容保留一个 release。
  • v2 → v3sessionEndReason 枚举新增 system_idlescreen_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_violationsession_expired 被移除, 引入 domain_blocked。事件 permission_denied 替换为 domain_blocked;新增 global_stop 事件(popup “Stop all” 触发)。session_started 移除 expiresAt(无 TTL),带 startedAtsession_ended 新增 tabId + actionCountsessionEndReason 移除 "expired",新增 "domain_blocked" + "global_stop"硬切 —— 仅内部部署,服务端+扩展 协调升级,不保留 v3 兼容 shim。 v3 服务端会拒绝 v4 扩展握手;v4 服务端也会拒绝 v3 扩展握手。

Bump 时:

  1. @zapvol/common/src/schemas/browser-bridge.ts 里的常量
  2. 内部部署(如 v3 → v4)可以硬切:扩展和服务端协调部署,requiredMinProtocolVersion 直接等于新版本,没有过渡窗口
  3. 公开部署需要服务端保留旧版本支持至少一个 release——接受新旧两个 hello.protocolVersion 并在内部分支,或维护 两份平行 schema。服务端 requiredMinProtocolVersion 设置为前一个支持的版本,旧扩展还能配对;一个 release 后再 把 requiredMinProtocolVersion 提到新版本

纯添加——新 action 变体、新 event 类型、新可选字段——不需要 bump 版本号,前提是旧扩展收到后能安全忽略。

这页有帮助吗?