题记:一次浏览器替换,最值得留下的不是换了哪个内核,而是没有把两种“点击”冒充成同一种能力。

1. 工具叫 click,不代表它们做的是同一件事⌗

浏览器 Agent 的接口往往很整齐:读取页面、获取快照、点击引用、输入文字。整齐的是参数表,不一定是底下的行为。

扩展可以找到一个元素,再调用页面侧方法或派发合成事件;自动化驱动则可以通过浏览器输入通道执行鼠标、键盘操作。两条路线都可能让按钮发生变化,但不能因此认定它们在事件来源、焦点、遮挡与默认行为上完全等价。

于是,一个看起来很体贴的设计出现了:驱动正常时用驱动,驱动掉线时自动改走扩展。上层仍然调用 click,没有任何参数变化。

这不是单纯换了传输通道,而是可能换了操作的含义。

这次落地把运行浏览器替换为 Camoufox,但更值得讨论的是另一件事:保留原有扩展桥的读取与引用体系,把输入交给独立驱动,并把“不准静默退回合成事件”写进桥的路由。

以下机制依据落地版本的 bridge-server.ts、serializer.js、background.js 与 supervisor.py;测试部分讨论的是测试代码覆盖的规格,不把它当作所有网站上的成功率报告。

2. 不重写眼睛,只换执行动作的手⌗

这套设计没有因为换了浏览器,就把全部工具改造成另一套自动化 API。

                  Agent 的 browser 工具
                           |
                       扩展桥 server
                           |
          +----------------+----------------+
          |                                 |
    读取 / 快照 / 导航                 输入动作,wait 除外
          |                                 |
          v                                 v
       浏览器扩展                    先向扩展解析目标
          |                          {frame token, CSS}
          v                                 |
     DOM / 标签页                            v
                                    独立 input driver
                                            |
                                            v
                                   Playwright / 浏览器输入

扩展继续承担擅长的工作:读取 DOM、序列化页面、管理标签页、维护元素引用。驱动承担另一类工作:定位到目标 frame 和元素,执行点击、悬停、输入、按键与拖拽等动作。

这里的拆分不是“读操作走 A,写操作走 B”这么机械。导航仍走扩展;wait 也不需要输入驱动。真正的分界是:哪些操作依赖浏览器输入语义。

驱动由 supervisor 承载,与浏览器生命周期一起管理;它以 role=driver 连接桥,并按 device 注册。桥仍然需要相应扩展在线,因为输入前的目标解析也要经过扩展。它不是一条能绕开扩展独立完成全部工作的备用通道。

我的判断是,这比全量重写工具层更有价值:读取输出、引用格式和上层调用方式可以延续,输入实现则在明确的接缝上替换。迁移的单位是职责,而不是整套工具。

3. 最难的接缝:扩展知道的元素,驱动怎么找到?⌗

扩展的快照给 Agent 一个短引用,例如 e7。引用背后是扩展隔离环境里的元素对象,不是一个天然可交给 Playwright 的地址。

此外,扩展与驱动对 frame 的标识也不天然相同。仅仅把 tabId 和 ref 发给驱动,还没有解决“到底在哪一层文档里,操作哪一个元素”。

实现把交接分成两段。

3.1 先认 frame⌗

supervisor 为 frame 的文档根节点写入一个 data-mcjug 标记,并维护 token 与 Playwright Frame 的映射。扩展收到 page.jugglerTarget 后,在引用所属的 frame 里读出这个标记。

这给双方提供了一个共享坐标:

扩展侧的 frame ── 读 DOM 标记 ── token ── 驱动侧的 Frame

frame 导航时会安排重新盖章,注册新 token 时移除旧映射;页面关闭或崩溃时也会清理相应映射。驱动找不到 token,就报错并要求重新获取快照,而不是自己猜一个 frame。

但这不是跨进程事务。导航事件、盖章与动作请求并非原子执行,不能据此宣称已经消灭所有导航竞态。这里建立的是失效检测与重新定位的基础,不是“一切旧请求都绝不可能碰到新页面”的数学保证。

3.2 再认元素⌗

扩展的 __mcLocatorFor 不让模型重新描述目标,而是从 ref 对应的活元素出发,现算 CSS 定位符:

情况处理
当前 root 内有唯一 ID用该 ID 截短路径
没有合适的唯一 ID用标签名与 nth-of-type 组成路径
跨 open shadow root分段构造,以 >> 连接
ref 不存在或元素已脱离文档返回明确错误
位于 closed shadow root拒绝生成可信输入路径

驱动收到定位符后,要求它恰好命中一个元素,再执行需要元素目标的动作。

这条交接链避免了第二次语义猜测:模型已经在快照里选过目标,没有必要再让另一层按文本猜一次。

它也不是永久稳定的元素身份证。CSS 从生成到执行之间,DOM 仍可能变化;唯一命中是必要检查,不等于对象身份在任意页面更新下都不变。设计的收益是让普通路径可解释,让已识别的失败显式出现,而不是承诺动态页面从此静止。

4. 驱动掉线,能力承诺不能跟着失忆⌗

桥里有两份不同性质的状态:

  • drivers:当前在线的驱动连接。
  • requiredTrustedInput:哪些 device 的输入必须经过驱动。

这两份状态不能合并。

驱动上线时,桥不仅登记连接,还把对应 device 加入要求集合;驱动断开时,只删除在线连接,不删除要求。桥构造时还会读取 BRIDGE_REQUIRE_TRUSTED_INPUT,让指定设备的要求不依赖本次进程里是否已经见过驱动上线。

核心逻辑可以压缩成下面的示意,而不是源码逐字摘录:

如果是输入动作,且该设备要求 trusted input:
    驱动在线 → 扩展解析目标 → 驱动执行
    驱动离线 → 报错,不发送扩展合成动作
否则:
    保持原有扩展路径

这里最重要的不是多了一个集合,而是把两个问题分开了:

“现在能不能执行”,不能决定“允许用什么方式执行”。

因此,系统仍允许从未声明此能力、也未被配置要求的设备使用原有合成路径。它没有把所有设备强行升级;它只是不让已经作出的能力承诺因一次断线而自动撤销。

相比“连接断了就用另一条路”,这是一种更诚实的可用性取舍:宁可明确暂停,也不拿不同语义凑出一个表面成功。

5. 看得见却点不了,是必须承认的能力差⌗

这次改动里最有说明力的限制,是 closed shadow root。

扩展可以凭借浏览器扩展特权读取其中的元素,并在快照里产生 ref;新的 Playwright 定位路径却不能按同样方式穿透它。

所以会出现一个看起来很别扭、但实际上正确的结果:

快照:这里有一个按钮,引用是 e7。
动作:这个引用位于 closed shadow root,当前驱动无法定位。

如果只追求界面上“工具都能用”,这时最容易偷偷回到扩展合成事件。但那恰好取消了前面建立的输入约束。实现选择报错。

观察能力不是操作能力的授权书,也不是操作能力的证明。

这里的 trusted input 也需要收窄理解。它讨论的是浏览器输入通道与事件来源,不是用户授权,不是操作结果已达成,更不是“网站一定接受这次交互”。代码中点击使用 locator 的 actionability 检查,输入通过键盘通道完成,而 select 使用的是 select_option。不能把这些不同动作笼统包装成“每个事件都等同于真人逐步操作”。

同样,humanize 轨迹与输入间隔是实现选项,不是通用成功率证明。这次材料没有给出跨网站对照实验,文章也不替它补一份想象中的成绩单。

6. 测试真正钉住了什么?⌗

相比一句“支持可信输入”,测试提供了更具体的边界。

测试位置覆盖的规格不应外推成什么
bridge-server.test.ts:驱动在线先向扩展解析 token、CSS,再把动作转给假驱动;普通输入不走扩展合成路径已验证真实页面收到的全部事件
同文件:驱动断开返回拒绝回退错误,扩展连目标解析请求也不收到已覆盖所有重连与并发时序
同文件:无驱动设备保持原有扩展动作路径所有设备都具有相同输入能力
同文件:wait仍由扩展处理所有 page.act 都必须依赖驱动
serializer-locator.browser.test.ts普通 DOM、重复标签、唯一 ID、open shadow root 的定位;closed root、未知和 detached ref 的错误动态 DOM 永远不会在交接期间变化
tools.test.tsschema 不再接受旧的 marionette_mode新驱动已经没有任何生命周期成本

其中定位符测试使用真实浏览器 DOM 与 Playwright locator,而桥的路由测试使用假扩展、假驱动。两者各守一段,不是同一种证据。

closed root 测试还特意手动把元素放入 ref 表,以模拟扩展已能观察到它的情形。它验证的是“定位层必须拒绝”,并不是让测试浏览器凭空获得扩展特权。

旧的深度模式入口则直接从工具 schema 退役。上层不必一边使用新路径,一边仍被旧开关诱导去切换另一种运行方式。减掉一个入口,有时比再写一页使用说明更有效。

7. 真正值得复用的,不是浏览器名字⌗

代价并不小:多了 supervisor、驱动连接、frame 标记和定位符转换;驱动在线也不代表目标一定能定位,能定位也不代表点击后的业务结果已经完成。

但职责边界清楚了:

  • 扩展负责描述页面和解析引用。
  • 驱动负责执行约定的输入路径。
  • 桥负责守住设备的能力要求。
  • 上层负责观察动作之后的结果,而不是把执行返回当作任务完成。

我认为这次设计比“换一个更强的浏览器”多走了一步:它把输入方式从临时实现细节,提升成不可静默降级的工具契约。

浏览器名字以后还会换。值得保留的是这条原则:一个工具可以失败,但不能为了显得没失败,偷偷变成另一个工具。