用 Vision + macOS 自动化消灭 Browser MCP 的最后一个手动断点

Eliminating the Last Manual Breakpoint in Browser MCP with Vision-Driven Automation

凌晨两点,我的 Agent 又停了。

不是模型挂了,不是网络断了,不是 Token 用完了。是 Browser MCP 的 WebSocket 连接断了,而那个该死的 Chrome 扩展弹窗里,「Connect」按钮正安静地等着有人去点一下。

我在跑 --watch 模式——Agent 监控一个内部系统,页面一变就自动抓取数据、做分析、写报告。整条链路全自动,唯独 Browser MCP 的连接需要手动点。每次断连,我就得从床上爬起来,点开 Chrome 工具栏那个小图标,点一下 Connect。

Agent 操作浏览器的九条路线 里,我把 Browser MCP 归到「真实浏览器 + 扩展 + CDP + MCP」那一类,给了它四颗星的企业推荐度。但那篇文章没写的是: 它有一个致命的手动断点,而这个断点在自动化场景下足以毁掉整条链路。

这篇文章记录我怎么用 Vision 大模型 + macOS 原生自动化,把这个断点彻底消灭。不是 Demo,是从 v1.0 迭代到 v1.4.2、踩了六个坑之后的生产方案。

值得一提的是:整个项目的所有代码——从第一行 screencapture 调用到最后的 LaunchAgent plist——都是 Opus 4.8 写的。我做的事情是描述需求、测试、报错、再描述。这个过程本身就是一个有趣的发现:当你把「我需要一个能自动点击 Chrome 扩展按钮的脚本」这种模糊需求扔给一个足够强的模型,它给出的第一版方案往往方向正确但细节全错——而每一处「错」的背后,都藏着一个 macOS 平台特有的坑。

Vision + macOS 自动化消灭 Browser MCP 手动断点

问题:一个按钮卡住整条自动化链路

Browser MCP 的架构很优雅:

Agent
MCP Server(本地 WebSocket)
Chrome Extension
真实浏览器(带登录态)
# generated by hugo AI

它操作的是你 真实的 Chrome——有 Cookie、有登录态、有历史记录。不需要像 Playwright 那样开一个干净的浏览器实例,不需要重新过 SSO 和 MFA。对企业 Agent 来说,这个特性价值巨大。

但架构里有一个设计决策:扩展弹窗(popup)里的 Connect 按钮,是建立 WebSocket 连接的 唯一入口。没有 CLI 命令,没有 API,没有配置文件可以设置自动连接。

这意味着:

  • Agent 每次启动,需要人点一下
  • 连接断了(网络抖动、Chrome 重启),需要人点一下
  • --watch 模式检测到断连想重连,还是得等人点一下

在自动化链路里,这是 唯一的手动断点。而「唯一」这个词很要命——99% 都自动了,剩下 1% 的手动步骤,就足以让整条链路在凌晨两点停下来等你。

为什么常规 UI 自动化行不通

我跟 Opus 说:「写个 AppleScript,自动点击 Chrome 扩展弹窗里的 Connect 按钮。」

它很自信地给了一个标准答案。macOS 上自动化 GUI,System Events 是教科书做法:

tell application "System Events"
    tell process "Google Chrome"
        click button "Connect" of window 1
    end tell
end tell
-- generated by hugo AI

跑一下,报错。

Chrome 扩展弹窗不是标准的 macOS UI 元素。它是 Chrome 自己渲染的一个浮层,不在 Accessibility Tree 里。System Events 能看到 Chrome 的窗口、菜单栏、工具栏,但看不到弹窗内部的按钮。

cliclick 试试?cliclick c:x,y 可以在指定坐标点击。但问题是: 你不知道按钮在哪。 扩展图标的位置取决于工具栏有多少个扩展、窗口宽度、是否 pin 到工具栏。弹窗出现的位置又取决于图标的位置。每次都不一样。

CDP 呢?Chrome DevTools Protocol 能控制页面内容,但扩展弹窗(popup)运行在独立的 Extension Process 里,CDP 的 PageDOM 域够不到它。

三条路全堵死。这个按钮,就是设计成「只能人点」的。

到这里,Opus 给了一个很有意思的建议:「既然结构化方案都不可达,不如试试 Vision——截图给我看,我来告诉你按钮在哪。」

说实话,第一反应是觉得杀鸡用牛刀。但转念一想,这恰好是 Vision 最擅长的场景:一个人类一眼就能找到、但程序无法通过 API 定位的 GUI 元素。

解法:截图 → Vision 定位 → 精准点击

既然结构化方案全部失效,那就退到最原始的层面: 像人一样,用眼睛找到按钮,然后用手指点它。

screencapture(截图)
Vision 大模型(识别按钮坐标)
cliclick(点击坐标)
# generated by hugo AI

技术栈:

组件工具作用
截图macOS screencapture整屏截图,拿到当前画面
缩放计算sips + Finder 桌面 bounds推算 Retina 缩放因子(物理像素 → 逻辑点)
视觉定位Gemini(OpenAI 兼容接口)识别按钮位置,返回归一化坐标 JSON
精准点击cliclick在逻辑坐标上执行鼠标点击
窗口控制osascript激活 Chrome、控制窗口状态

主流程 10 步:

 1. 确保 Chrome 就绪(未运行则启动,等 15s 让界面渲染稳定)
 2. 整屏截图 + 计算 Retina 缩放因子
 3. 先判是否已连接(避免误点断开)
 4. 定位并点开扩展图标
 5. 在弹窗截图里定位 Connect 按钮
 6. cliclick 点击
 7. 复判:再截图确认状态变为 connected;失败则重试
 8. 自动学习:连接成功后裁图回存为参考模板
 9. 收起弹窗(再点一次扩展图标)
10. --watch 模式:定时检查,断开自动重连
# generated by hugo AI

第 3 步和第 7 步是关键: 先判再点,点完复判。 不做前置判断,已连接状态下再点一下 Connect 会变成 Disconnect——你兴冲冲地自动化,结果把好的连接给断了。不做复判,你不知道点击到底有没有生效。

但如果你以为「截图 → Vision → 点击」就完事了,那你还没遇到真正的问题。

v1.0 跑通的那个晚上,我挺得意。然后第二天早上发现,它点不中了。

核心设计:学习 + 校准机制

纯 Vision 猜坐标有一个固有缺陷: 不准。

这个「不准」不是偶尔不准,是结构性不准。Opus 第一版给的 prompt 是「请找出截图中 Connect 按钮的位置,返回归一化坐标」。模型很配合地返回了 (0.82, 0.15)。我拿这个坐标去点——点到了地址栏。

大模型看一张 2752×1536 的截图,告诉你「Connect 按钮大概在 (0.82, 0.15) 的位置」。这个「大概」在像素级别可能是 20-30 个像素的偏差。对一个大按钮来说可能够用,但对 Chrome 扩展弹窗里那个小按钮来说,20 像素可能已经点到隔壁去了。

而且每次截图的窗口位置、大小、工具栏布局都可能不同。同一个按钮,今天的归一化坐标和昨天的不一样。

解法不是「换一个更准的模型」,而是 建立一套学习和校准机制,让系统越用越准。

这个思路也是 Opus 提出的。我当时问它:「Vision 定位不准怎么办?」它没有建议换模型或调 prompt,而是说:「让我先看看按钮长什么样,下次我就能找得更准。」这句话本质上就是 few-shot learning 的朴素直觉——给模型一个参考样本,比让它从零猜要靠谱得多。

三级定位策略

resolve_target(target_name):
    ├── Level 1: 已学坐标优先
    │   meta.json 里有学过的归一化坐标
    │   → 在该处裁块,和参考模板一起交给模型「主动对比」确认
    │   → 确认通过 → 直接用
    ├── Level 2: 带模板的 few-shot Vision 定位
    │   把参考模板图 + 当前截图一并送模型
    │   「目标长这样,在截图里找它」
    │   → 返回候选坐标
    └── Level 3: 对比校准循环
        拿到候选坐标后裁块,再和模板对比
        不符 → 重新定位
        最多 CAL_TRIES 次
# generated by hugo AI

核心思想是: 不要让模型从零开始猜,给它一个参考模板,让它做「找不同」而不是「凭空想象」。

人工示教:--calibrate

第一次使用,跑 --calibrate

python3 autoconnect.py --calibrate connect_button
# generated by hugo AI

脚本会提示:「请把鼠标悬停到 Connect 按钮上,然后按 Enter。」

你用鼠标指一下,cliclick p 读取真实坐标,脚本在该坐标处裁一块图,存到 refs/ 目录作为参考模板,同时把归一化坐标写进 meta.json

一次示教,终身受用。 之后每次定位,模型不是在看一张陌生截图猜按钮在哪,而是拿着「按钮长这样」的参考图,在当前截图里做匹配。这比纯 prompt 定位的准确率高出一个档次。

快路:verified 坐标直接点

学习 + 校准解决了准确性问题,但还有一个问题: 成本。

每次连接都走完整 Vision 流程(截图 → 定位 → 对比 → 点击 → 复判),需要 4-6 次 Vision API 调用。对 --watch 模式来说,每 30 秒检查一次,一天下来调用量不小。

解法是 快路(fast_connect)

首次连接:完整 Vision 流程
    ↓ 连接成功
坐标标记为 verified
后续连接:快路
    ├── 直接点已验证坐标(0 次 Vision)
    ├── 截图读状态(1 次 Vision)
    └── 复判(1 次 Vision,仅在状态异常时)
    ↓ 快路失败 / 换屏
自动回退完整流程
# generated by hugo AI

成本分层:

阶段Vision 调用次数触发条件
示教(--calibrate0人工操作,一次性
首次完整流程4-6 次学到 verified 坐标
快路1-2 次日常使用
回退完整流程4-6 次快路失败 / 换屏

学一次,之后每次只花 1-2 次 Vision 调用。 这和 模型正在吞噬 Agent 框架 里说的趋势一致:模型能力在变强、推理在变便宜,但工程上的成本优化不会因此变得不重要——恰恰相反,当 Agent 7×24 运行时,每次调用省下的 Token 会累积成显著的账单差异。

远程触发:SSH 和 LaunchAgent 的坑

本地跑通了,下一个需求是: 能不能从另一台机器 SSH 过来触发连接?

我的 Agent 跑在服务器上,Chrome 开在 Mac 上。服务器通过 SSH 连 Mac,执行 autoconnect.py

直接跑,截图是黑的。

原因:SSH 会话没有 WindowServer 连接。macOS 的 screencapture 需要访问图形会话,SSH 进程不在 Aqua 会话里,拿不到屏幕内容。cliclick 同理——没有 WindowServer,点击事件发不出去。

解法是 用户级 LaunchAgent。LaunchAgent 在 gui/ 域运行,属于已登录用户的 Aqua 会话,天然有 WindowServer 访问权限。SSH 端只负责触发:

# 方式一:kickstart
launchctl kickstart -k gui/$(id -u)/com.hugozhu.browsermcp-autoconnect

# 方式二:WatchPaths 触发
touch ~/.browsermcp/trigger
# generated by hugo AI

LaunchAgent plist 里配置 WatchPaths,监控 ~/.browsermcp/trigger 文件。SSH 端 touch 一下,LaunchAgent 就在本地图形会话里执行脚本。

TCC 授权归属:最隐蔽的坑

LaunchAgent 跑起来了,但截图还是黑的。

这是整个项目里最让人抓狂的一个坑。因为报错信息是零——screencapture 返回 exit code 0,文件也生成了,打开一看,纯黑。Opus 第一反应是怀疑 Retina 缩放算错了,我也跟着查了半小时坐标。直到我手动在 Mac 上跑了一遍 screencapture /tmp/test.png,截图正常。说明不是代码的问题,是运行环境的问题。

macOS 的 TCC(Transparency, Consent, and Control)权限模型规定:屏幕录制权限授予的是 责任进程(responsible process)。在 launchd 下,责任进程的判定逻辑和你在终端里直接跑不一样。

我最初的 setup 是:LaunchAgent 启动 /usr/bin/python3,python3 调用 screencapture。在系统偏好设置里给 python3 授了屏幕录制权限。但不生效。

原因是 /usr/bin/python3 是 Xcode Command Line Tools 的一个软链接。TCC 按 bundle identifier 识别进程,CLT 的 python3 没有独立的 bundle id,授权经常匹配不上。

解法: 打一个带独立 bundle id 的 .app 当责任进程。

# make-app.sh 的核心逻辑
mkdir -p BrowserMCPAutoConnect.app/Contents/MacOS
cp autoconnect.py BrowserMCPAutoConnect.app/Contents/MacOS/
# Info.plist 里写独立的 CFBundleIdentifier
codesign --force --deep --sign - BrowserMCPAutoConnect.app
# generated by hugo AI

ad-hoc 签名(--sign -)就够了,不需要 Apple Developer 证书。关键是让 TCC 看到一个有独立身份的进程,而不是一个面目模糊的 /usr/bin/python3 软链接。

授权只需要给这个 .app 一次,它启动的所有子进程(screencapturecliclickosascript)都继承权限。

这个坑和 Agent 时代的权限治理 里讨论的问题本质相同: 权限模型是为「人操作 GUI」设计的,当操作者变成 Agent(或 launchd 下的自动化进程),身份、委托链、授权归属全部需要重新理解。

踩坑记录

六个坑,每个都花了至少一个小时。有意思的是,这六个坑里没有一个 Opus 提前预见到了——它写的代码逻辑都是对的,但 macOS 平台层的行为不在任何模型的训练数据里「显式存在」。这些知识散落在 Stack Overflow 的角落、Apple 开发者文档的脚注、以及无数人踩坑后的博客里。模型能写出正确的 screencapture 调用,但它不知道 Apple Silicon 会把未签名的 cliclick 直接 SIGKILL。

1. Retina 缩放:坐标差一倍

screencapture 输出的是物理像素。一台 14 寸 MacBook Pro 的屏幕,逻辑分辨率是 1512×982,物理分辨率是 3024×1964。Vision 模型看到的截图是物理像素,返回的坐标也是物理像素。但 cliclick 接受的是逻辑点。

坐标必须除以 backingScaleFactor。 缩放因子通过 sips 读截图尺寸、再用 Finder 桌面的逻辑 bounds 推算:

scale_factor = screenshot_width / desktop_logical_width
click_x = vision_x / scale_factor
click_y = vision_y / scale_factor
# generated by hugo AI

忘了除?点击位置偏移一倍,永远点不中。

2. 弹窗一失焦即关闭

Chrome 扩展弹窗的行为和原生窗口不同: 一旦失去焦点,立刻关闭。 没有动画,没有延迟,直接消失。

这意味着脚本的流程必须是:点开扩展图标 → 立刻 截图 → 立刻 定位 → 立刻 点击。中间不能有任何窗口切换、不能弹对话框、不能让 Chrome 失去前台焦点。

我在第 4 步和第 5 步之间加了一个 time.sleep(0.5) 等弹窗渲染,结果弹窗在这 0.5 秒里被系统通知抢了焦点,直接关了。改成 sleep(0.3) 并在截图前强制 osascript 激活 Chrome,才稳定下来。

3. 权限静默失败

macOS 的 TCC 权限拒绝不报错。screencapture 没授屏幕录制权限?返回一张纯黑图片,exit code 0。cliclick 没授辅助功能权限?点击事件发出去但被系统吞掉,不报错。

两个权限都必须授,授完之后必须重启终端进程。 因为 TCC 的权限检查发生在进程启动时,已经运行的进程不会感知到权限变更。

调试黑屏截图的那个小时,我一直以为是 Retina 缩放算错了。

4. 扩展图标没 pin 到工具栏

Chrome 扩展默认藏在拼图图标(Extensions menu)里。Vision 在整屏截图里找的是工具栏上的独立图标——如果图标没 pin 出来,它根本不在截图里。

解决:手动把 Browser MCP 扩展 pin 到工具栏。这是一次性操作,但忘了做的话,Vision 会非常自信地告诉你「我在截图中没有找到目标按钮」,然后你花半小时怀疑模型能力。

5. cliclick 被 SIGKILL

Apple Silicon 上,从网上下载的未签名二进制文件会被 Gatekeeper 隔离。cliclick 通过 Homebrew 安装后,第一次运行直接被内核 SIGKILL,没有任何错误信息。

xattr -d com.apple.quarantine $(which cliclick)
codesign --force --sign - $(which cliclick)
# generated by hugo AI

去隔离 + ad-hoc 自签名,两步搞定。但这个坑在 Intel Mac 上不存在,迁移到 Apple Silicon 时才会遇到。

6. 换屏后坐标全废

已学坐标是按当时的物理屏幕尺寸记录的。换一台显示器、改一下分辨率、甚至换一个 Dock 位置,归一化坐标就变了。

解法:快路失败时自动回退完整 Vision 流程,重新学习。--calibrate 也随时可以重跑。这不是 bug,是设计—— 坐标是缓存,不是真理。缓存失效就重建。

版本演进

从第一行代码到生产可用,经历了六个版本:

版本核心变更解决的问题
v1.0.0osascript + screencapture + Vision + cliclick基本链路跑通
v1.1.0学习 + 主动对比校准,--calibrate 人工示教Vision 定位不准
v1.2.0快路(verified 坐标直接点,1-2 次 Vision)调用成本太高
v1.3.0冷启动等界面稳定 + 连接后收起弹窗Chrome 刚启动时截图不稳定;弹窗挡视线
v1.4.0LaunchAgent 远程 / SSH 触发只能本机操作
v1.4.2.app 责任进程解决 TCC 授权归属launchd 下权限不生效

每个版本都是被一个真实问题逼出来的。没有一个是「提前设计」的。

回看这个过程,我和 Opus 的分工很清晰:它负责写代码、提方案、做推理;我负责跑代码、看现象、报错误。它不知道 macOS 的 TCC 权限在 launchd 下会归属到责任进程,但它能在得知这个事实后,三分钟内给出 .app 封装方案。它不知道 Chrome 扩展弹窗失焦即关,但它能在得知后立刻调整时序逻辑。 模型的能力边界不是「写不出代码」,而是「不知道你的平台会怎么欺负这段代码」。

总结:Vision 不只是兜底

九条路线 那篇文章里,我把 Vision Computer Use 放在最后一层,评价是「慢、贵、精度不如结构化控制」。这个判断在宏观层面仍然成立——你不会用 Vision 去做 Playwright 能做的 DOM 操作。

但这个实践让我修正了一个认知: 在「结构化方案完全不可达」的窄场景里,Vision 配合学习校准机制,可以做到精准、低成本、生产可用。

三个关键 insight:

1. 当传统 UI 自动化失效时,Vision 是通用兜底——但「兜底」不等于「粗糙」。 Chrome 扩展弹窗不在 Accessibility Tree 里,CDP 够不到,AppleScript 看不见。这种「结构性不可达」的场景,Vision 是唯一选择。但选择 Vision 不意味着接受粗糙——通过参考模板 + 对比校准,可以把定位精度从「大概对」提升到「稳定对」。

2. 纯 Vision 猜坐标不够准,需要「学习 + 校准」机制。 示教一次,学到参考模板和归一化坐标。之后每次定位,模型做的是「拿着模板找目标」而不是「凭空猜位置」。对比校准循环兜住模型偶尔的抽风。这套机制可以迁移到任何 Vision 定位场景——不只是 Chrome 扩展按钮,任何「GUI 上有一个小东西需要点」的问题都适用。

3. 成本优化的本质是缓存:学一次,之后走快路。 verified 坐标就是缓存。快路就是缓存命中。回退完整流程就是缓存失效重建。想清楚这个类比,就知道什么时候该走快路、什么时候该重建——和你在任何缓存系统里做的决策一模一样。

macOS 的 TCC 权限模型在 launchd 下的坑,则是另一个层面的教训: 操作系统的权限模型是为「人坐在屏幕前」设计的。当操作者变成 Agent、变成 launchd 下的后台进程,身份归属、授权链路、进程树继承全部需要重新理解。 这个问题在 Agent 时代会越来越普遍。

还有一个意外收获:这个项目让我重新理解了「人 + AI 协作写代码」的分工。Opus 4.8 写了全部代码,但没有写对任何一个平台层的坑。不是它不够聪明——是这类知识本质上就是「在场知识」,你必须在那台 Mac 上、用那个版本的 macOS、跑那个命令,才能遇到。人的价值不是写代码,是 当那个在场的人:跑代码、看现象、把「截图是黑的」这四个字反馈给模型。模型的价值不是知道答案,是 在你给出线索后,三分钟内把六个可能的原因排好序,然后逐一验证。


你的 Agent 自动化链路里,有没有类似的「最后一个手动断点」?欢迎留言讨论。


See also