Cua Driver 接入核对与离线动作验收

先核对驱动、窗口和会话的接入条件,再用可复制的离线模拟器练习旧观察失效、输入投递与保存结果验收。真实桌面操作仍需另行验证。

你能带走什么
  • 确认持有系统权限的实际进程
  • 坐标绑定窗口与返回图像
  • 动作回执之后读取应用结果
  • 取消、超时和正常结束都执行清理

1. 从一个能验收的桌面动作开始

接入 Cua Driver 前,可以先把两件事分开核对:真实驱动需要哪些进程、系统权限和会话条件;应用收到动作后,怎样确认结果。本文前半部分按官方接口说明整理接入检查,后半部分用自编 Python 模拟器练习“观察输入框、输入文字、保存、重新读回”的闭环。离线演练会故意使用一次旧观察,让你看到拒绝原因,再用新观察完成保存;它不操作真实窗口,也不生成截图。

本文讨论 trycua/cua 仓库中的 Cua Driver。它提供桌面观察和输入接口,可通过 CLI、MCP 或 SDK 接入;模型调用和任务循环由上层程序负责。旧文章的 macOS 场景在这里继续展开,其他平台只说明会影响迁移的边界。通用的观察闭环可接着读 Computer Use。

下文分成官方接口资料审查和离线契约演练。资料核验于 2026-10-05,锁定仓库提交 1fe6ce5;生成式 CLI 与工具参考标注版本 0.33.4。本文实际运行的是自编 Python 模拟器,未安装 Cua Driver,未接入真实 Mac,也没有调用模型。文中的七张图都是原创结构示意。

图 01 / 原创结构示意 · 非产品截图
Agent 负责模型与计划,Cua Driver 负责 UI 观察和输入,应用决定结果。本文只测试离线模型。
Agent 负责模型与计划,Cua Driver 负责 UI 观察和输入,应用决定结果。本文只测试离线模型。

2. 连接之前,先找到持有权限的进程

以 macOS 为例,官方入门页要求 macOS 14 或更新版本,并且需要可访问的图形桌面。CuaDriver.app 持有辅助功能和屏幕录制授权。前者用于读取可访问性树与输入,后者用于截图。看得见应用列表,并不代表已经能拿到图像;设置里出现一个同名应用,也不能证明当前运行的就是它。

准备真实环境时,应先阅读官方安装入口,核对应用来源,再由操作者完成安装与系统授权。下面的命令假定已经装好驱动,且测试机允许启动它。应用启动后,在另一个终端检查状态。如果 permissions status 返回 unknown,先核对 app-owned daemon 是否存在。不要通过关闭系统保护来省掉这一步。

官方命令形状:本次未执行

# Only on an installed, authorized macOS test machine.
open -n -g -a CuaDriver --args serve
cua-driver status
cua-driver permissions status
cua-driver doctor
cua-driver call list_apps

daemon 的归属会改变接入方式。macOS 的常规 MCP 路径代理到 CuaDriver.app;Windows 与 Linux 的直接 MCP 进程通常拥有自己的运行时。SDK 内嵌又由宿主应用持有运行时。把这三种情况混用,很容易把权限授给终端,却让另一个进程去截图。平台差异应对照 MCP 与 CLI 参考检查。

图 02 / 原创结构示意 · 非产品截图
macOS 环境就绪后分别检查应用身份、系统授权和 MCP 注册。空应用列表不能用反复点击修复。
macOS 环境就绪后分别检查应用身份、系统授权和 MCP 注册。空应用列表不能用反复点击修复。

测试账户里尽量只放这轮验收需要的数据,提前关闭不相关窗口。屏幕通知、密码管理器和聊天内容都有机会进入截图。若任务需要在用户日常桌面运行,应约定前台切换时间,并让使用者知道如何停止当前任务。本文没有替读者开启远程访问,也没有创建长期后台服务。

3. 把 Codex 接到确定的驱动实例

官方连接指南提供 mcp-config,用本机实际安装路径生成客户端配置。对 Codex 执行下面的只读命令,检查打印出的命令或配置,再决定是否注册。安装位置发生变化时重新生成,避免沿用另一台机器的绝对路径。注册会改变客户端配置,应保留已有服务器条目。

官方只读检查命令:本次未执行

# Print and review the configuration; this does not register it.
cua-driver mcp-config --client codex
cua-driver list-tools
cua-driver describe get_window_state
cua-driver describe click

重新开启 Codex 会话后,先确认 Cua Driver 的工具可见,再读取应用和窗口列表。窗口标题只适合帮助人辨认目标,后续调用要保留实际返回的 pid 与 window_id。弹出的保存面板可能由系统服务进程持有;若返回 window_owner_pid_mismatch,应重新确认面板归属,不要用原应用的进程号强行操作。

第一次接入可以只做观察。下面是一条独立的截图调用:先输入已经核实的进程号和窗口号,再把该窗口的图片写到本地。每次一次性 cua-driver call 都使用临时 session;即使连接同一 daemon、填写相同标签,也不能据此复用前一次调用的快照与元素令牌。这段命令不建立后文动作所需的持续会话。before.png 可能包含桌面内容,不应顺手上传到公开问题单。若没有 PNG,检查响应里的降级原因和权限状态,不能把空图当成黑色窗口继续定位。

官方截图调用:仅用于已核实目标,本次未执行

# Enter verified PID and window ID, separated by a space.
read -r APP_PID WINDOW_ID
printf '{"pid":%s,"window_id":%s,"session":"calc-smoke"}\n' \
  "$APP_PID" "$WINDOW_ID" |
  cua-driver call get_window_state --screenshot-out-file before.png

同一机器上有多个驱动进程时,先记录当前连接的实例。显式 socket 适合由部署方管理的共享 daemon,但它的路径和生命周期要一起交接。换了驱动实例就重新发现窗口,上一轮进程号可能已经被系统复用。复制旧日志里的目标继续执行,无法证明操作仍落在同一个应用。

4. 每个动作都要携带能追溯的观察

get_window_state默认把指定窗口的可访问性树与截图放在同一次返回里。当前参考的 tree_format 默认是 markdown;需要结构化 elements 时,显式选择 tree_format: "elements",或用 "both" 同时返回两种表示。elements 记录角色、标签、值和元素令牌,便于检查准备操作哪个控件。截图可以补足画布、自绘内容和布局信息。接口提供缩减树、跳过图像或只取图像的选项,使用前要想清楚下一步需要哪一种证据。

对有可访问性信息的按钮,优先读取当前存活会话内最新快照的 element_token。在该会话内,重新读取同一窗口的可访问性树会产生新 snapshot_id 并使旧令牌失效;但按当前参考,include_accessibility_tree: false 的只截图读取会保留当前元素行与令牌,不能把它视为刷新了可访问性树。只取树(include_screenshot: false)在窗口尺寸未变时保留上一张截图的像素坐标基准;只截图且 display_only: true 的预览不注册动作截图,不能用于定位输入。会话结束或空闲过期后,旧快照与令牌也不能继续使用。过期错误发生后重新观察并选取目标即可,不要将缓存里的数字索引当成永久控件编号。对截图定位,还要留住 capture_id、图像尺寸和窗口边界。

我会把观察、动作回执、后续观察放进同一条任务记录。日志保存目标标识、时间、结果状态和必要的错误码,正文或图像只在确有调试需要时保留。截图本身可能比日志字段更敏感,保存位置、保留时间和是否允许分享,需要在测试前说清楚。

图 03 示意的是显式请求结构化 elements 并重新读取可访问性树的情况;图中“下一次同窗口快照”指树刷新,不包括保留元素令牌的只截图读取。预览图像能否用于动作定位,仍须按上文的 display_only 规则判断。

图 03 / 原创结构示意 · 非产品截图
同一窗口观察包含元素与图像。元素 token 和像素坐标不能脱离本次观察,失效后必须重新取证。
同一窗口观察包含元素与图像。元素 token 和像素坐标不能脱离本次观察,失效后必须重新取证。

树和图片出现矛盾时,暂停当前动作。控件标签能读到,却可能已经被弹窗遮住;列表里的行还在,但屏幕位置可能滚出了视口。把两份信息交叉核对,必要时缩小到单个窗口重新获取。截取范围一旦扩大到整个桌面,记录范围和隐私风险也随之改变,需要让任务范围覆盖这一步。

需要连续观察和点击时,应由同一持续的 MCP 连接或 SDK 运行时,在同一存活 session 内完成 get_window_state → 选择元素 → click → 重新观察并验收结果。使用命名 session 时,每个支持该字段的调用都传同一标签;不要把前文独立 CLI 调用返回的令牌带到新调用里。中途重连、会话结束或过期后,必须重新观察。点击会发出真实输入,执行前仍须核实目标与任务授权;本文未运行真实会话。

点击字段只作说明,不提供可直接执行的跨 CLI 管道:目标使用已核实的进程与窗口标识,element_token 从同一存活 session 的当前快照中对应按钮记录取得,session 使用该连接内的任务标签。具体请求形状以安装版本的 describe click 或 MCP 工具 schema 为准。

5. 窗口像素、桌面坐标和焦点分别处理

click 的 macOS 参考把窗口像素定义为 get_window_state 返回 PNG 内的坐标,左上角是原点。驱动会处理 Retina backing scale 和图像缩放。调用方若先按印象除以二,再交给驱动处理,位置就会偏移。窗口移动、图片裁剪、缩略图缩放都值得单独检查。

离线模型故意采用最简单的几何条件:窗口逻辑尺寸 800 × 600,图片倍率固定为二,桌面原点为 100、80。图片中的 80、80 对应桌面上的 140、120。这个算式只解释示例的坐标契约,实际接口应直接传所返回图片中的坐标,并按当前 schema 绑定窗口与 capture_id。

焦点也要进入验收条件。点击输入框之后重新观察,再输入文本;若期间切换了窗口,旧观察应失效。后台投递是否可用取决于应用和平台。遇到 background_unavailable 或经过核验的无效果结果,先检查官方建议,再由任务授权决定能否尝试前台动作。前台动作会影响使用者,不能在循环里悄悄扩大操作范围。

图 04 / 原创结构示意 · 非产品截图
固定双倍缩放下,图片坐标 80,80 对应逻辑 40,40。实际 Cua Driver 接收图片坐标,自行逆变换,调用者不要重复换算。
固定双倍缩放下,图片坐标 80,80 对应逻辑 40,40。实际 Cua Driver 接收图片坐标,自行逆变换,调用者不要重复换算。

本文模拟器只接受整数像素,明确拒绝负数、超出图片边界的点,以及布尔值和 NaN。这个限制属于示例自己的契约,官方接口的坐标参数类型以 schema 为准。边界测试采用右侧和底部不包含的区间,因此宽度为 1600 的图像里,最大合法横坐标是 1599。

6. 从回执继续走到应用结果

官方运行机制把动作效果分为 confirmed、unverifiable、suspected_noop、partial 和 refused 等状态。verified 为真表示可访问性读回层面的确认。业务上的完成条件仍需自己定义,例如内容已经保存、计算器显示正确结果,或者导出的文件可以重新打开。

示例里点击保存时,回执立即返回 delivered,但 saved 字段要过两个虚拟时钟单位才更新。等待函数不断取得新观察,核对文本和 pending 状态,在截止点仍做最后一次检查。预期不一致会超时;取消会打断等待。这个安排把异步结果从一次函数返回中拆出来,测试可以稳定复现。

超时后的处理要看动作有没有可能生效。保存、发送或付款等操作不能因为没有收到确认就重复派发。先获取新状态,记录能确认到哪一步,再决定恢复方案。真实接入还需区分传输断开、驱动拒绝、应用忙碌和结果未知;本文的单线程模型没有实现进程故障恢复。

图 05 / 原创结构示意 · 非产品截图
动作、观察、应用结果是三个层次。等待处理截止时间、取消和结果不一致,避免把调用成功当作保存成功。
动作、观察、应用结果是三个层次。等待处理截止时间、取消和结果不一致,避免把调用成功当作保存成功。

等待预算最好写进调用方,错误信息带上已经检查过的条件。真实程序可以使用单调时钟衡量超时,避免系统时间校正影响截止点。演练为了让测试可重复,使用手动推进的虚拟时钟,没有 sleep。用户取消和应用完成恰好相遇时,真实适配器还需重新获取末次状态,说明结果是否已经落地。

7. 一轮工作结束时,把 session 收好

当前 session 接口管理生命周期、光标和录制等附属状态。在同一持续的 MCP 连接或 SDK 运行时中,start_session 可选;支持 session 的后续调用重复使用同一短标签。会话的可见性与清理受已认证传输的作用域约束,另起一次 CLI 调用并填写同名标签不能接管前一次会话。它不把目标窗口固定在会话里;窗口或桌面目标仍由每次动作选择。旧 capture_scope 相关入口已标记为兼容字段,新接入不要围绕它设计。

任务正常结束、异常中断或用户取消,都应走到清理分支。真实客户端需要停止派发新动作,再通过持有该会话的连接调用 end_session,并检查清理结果。共享 daemon 可能还服务其他任务,结束自己的会话后不应顺便停止它。只有确认本次独占实例时,才按 daemon 参考执行 stop 并复查 status。

模拟器使用上下文管理器保证异常时执行 close,释放按住的键并丢弃待完成任务;重复 close 只清理一次。真实应用中取消已经派发的动作,未必能撤销应用产生的变化。取消记录应包含最后一次观察,以及需要人工核对的未决结果,不能把本地停止等同于远端回滚。

图 06 / 原创结构示意 · 非产品截图
每次调用选择窗口或桌面目标。结束任务后结束自己的会话;停止共享 daemon 需要另外确认影响。
每次调用选择窗口或桌面目标。结束任务后结束自己的会话;停止共享 daemon 需要另外确认影响。

会话标签适合使用 calc-smoke 这类短名字,不放用户名、文件内容或密钥。标签不是认证凭据,也不提供桌面隔离:不同 session 仍共享屏幕、键盘和焦点。原生输入逐个动作准入,也不代表整轮任务独占桌面;需要协调其他任务和人的输入。结束 session 不等于退出目标应用或回滚已产生的变化,关闭应用或删除测试文件属于另外的操作。

清理字段说明:在持有本轮会话的持续连接中调用 end_session,session 指向该连接内本轮使用的标签。不要另起 cua-driver call end_session 来清理先前独立调用的同名会话。本文未验证真实 GUI、MCP 或 SDK 会话的清理结果。

8. 认证与动作权限分别验收

客户端能够连上驱动,只说明连接这一层成立。系统是否允许截图、当前任务是否允许输入到某个应用,还要分别检查。官方权限指南说明运行时在触碰桌面前做授权检查,由可信启动方选择模式;正在运行的 daemon 不能通过普通工具调用重新配置。

standard 是默认模式,允许的桌面输入范围较宽。无人值守场景应仔细研究 bounded 模式与能力清单,把工具和资源范围收紧;能力清单需要被审阅和批准。接入已登录浏览器配置、读取截图后向外发送、修改系统设置,都应按实际任务单独考虑。配置文件中的口令或令牌不应出现在文章、示例日志和问题报告里。

模拟器把 authenticated、observe_allowed 与 input_allowed 分开。测试会证明认证通过的会话仍可能被拒绝输入,权限撤销后已有观察也不能继续使用。这是接口层的教学断言。真正的认证、安全策略、并发竞争和跨进程隔离需要可信运行时提供,几个 Python 布尔值无法承担这些职责。

图 07 / 原创结构示意 · 非产品截图
客户端身份、系统能力和任务授权解决不同问题。模拟器验证认证成功仍可被拒绝输入,不实现真实安全边界。
客户端身份、系统能力和任务授权解决不同问题。模拟器验证认证成功仍可被拒绝输入,不实现真实安全边界。

9. 运行离线 driver 契约演练

本页附上两份完整文件,分别保存为 driver_contract.py 和 test_driver_contract.py,放在同一个目录。使用 Python 3.10 或更新版本即可运行,无需安装第三方包。程序只改动进程内的对象;observe 返回 metadata_only 记录,不生成截图,也不伪装成 Cua Driver 的 SDK。

离线演练命令:本次已执行

python3 driver_contract.py
python3 -m unittest -v

演练会先点击模拟输入框,再故意拿旧观察输入,得到 stale_capture。重新观察后输入 driver contract,点击保存并等待读回相同内容,最后输出 closed 和一次清理记录。后面的测试覆盖错误窗口、尺寸变化、焦点切换、重复使用观察、边界像素、权限撤销、超时与取消。

本次实测 Python 3.12.14,40 项测试通过(普通与 -O 模式均通过)。测试证明的是这些自编断言在当前离线实现中成立。GUI 图像识别、真实输入法、多显示器、网络断线、平台后台能力以及 Codex 的真实 MCP 注册都未执行。接入真实环境时,仍应在一次性测试数据上重做观察与结果验收。

等待预算也包含 on_tick 回调通过 advance 消耗的虚拟时间。旧实现会在回调后无条件再推进一格:预算为 1、回调推进 1 格时,原本要在第 2 格完成的保存会被误报为按时成功。现在回调返回后先检查会话状态,到达截止点便直接重新观察;截止点上的完成可以通过,超过截止点的观察则返回 timeout。新增四项测试覆盖用尽预算、恰在截止点完成、超时后已完成和截止点取消。超时不代表保存已撤销,测试会继续读回结果,而不会再次点击保存。这个同步回调不能被等待函数抢占,也不会被回滚;若它本身推进过多或不返回,不能把此虚拟预算当作真实进程的硬超时。

实际输出字段摘要:完整 stdout 在复现包 qa/demo-output.json

{
  "kind": "offline_metadata_simulation",
  "stale_error": "stale_capture",
  "receipt": {"delivered": true, "effect": "unverifiable"},
  "result": {"saved": "driver contract", "tick": 2},
  "closed": "closed",
  "cleanup_count": 1
}
完整文件:driver_contract.py

driver_contract.py · Python 标准库

"""Offline teaching contract; no Cua imports, pixels, OS input, or network."""
from dataclasses import asdict, dataclass
import json
from itertools import count


SESSION_IDS = count(1)


class DriverError(RuntimeError):
    pass


@dataclass(frozen=True)
class Observation:
    session: str
    capture: int
    revision: int
    window: int
    origin: tuple
    size: tuple
    scale: int
    focus: str
    text: str
    saved: str
    pending: bool
    tick: int
    kind: str = "metadata_only"


class MockDriver:
    """Single-threaded deterministic fixture, not a vendor-compatible driver."""

    def __init__(self, *, authenticated=True, observe=True, input_allowed=True):
        self.authenticated = authenticated
        self.observe_allowed = observe
        self.input_allowed = input_allowed
        self.state = "new"
        self.session = f"mock-session-{next(SESSION_IDS)}"
        self.window = 7
        self.origin = (100, 80)
        self.size = (800, 600)
        self.scale = 2
        self.focus = "none"
        self.text = ""
        self.saved = ""
        self.pending = None
        self.tick = 0
        self.revision = 0
        self.capture = 0
        self.latest = None
        self.held_keys = set()
        self.events = []
        self.cleanup_count = 0

    def connect(self):
        if self.state != "new":
            raise DriverError("bad_lifecycle")
        if not self.authenticated:
            raise DriverError("unauthenticated")
        self.state = "open"
        self.events.append({"event": "connect"})
        return self

    def __enter__(self):
        return self.connect()

    def __exit__(self, *_):
        self.close()

    def _live(self):
        if self.state != "open":
            raise DriverError(self.state)

    def _changed(self):
        self.revision += 1
        self.latest = None

    def observe(self):
        self._live()
        if not self.observe_allowed:
            raise DriverError("observation_denied")
        self.capture += 1
        self.latest = Observation(
            self.session, self.capture, self.revision, self.window,
            self.origin, self.size, self.scale, self.focus, self.text,
            self.saved, self.pending is not None, self.tick,
        )
        return self.latest

    def _admit(self, observation, window):
        self._live()
        if not self.input_allowed:
            raise DriverError("input_denied")
        if type(window) is not int or window != self.window:
            raise DriverError("wrong_window")
        if not isinstance(observation, Observation):
            raise DriverError("invalid_observation")
        if observation != self.latest or observation.revision != self.revision:
            raise DriverError("stale_capture")
        if self.tick - observation.tick > 2:
            raise DriverError("expired_capture")

    def _consume(self, event):
        self._changed()
        self.events.append(event)
        return {"delivered": True, "effect": "unverifiable"}

    def click(self, observation, x, y, *, window=7):
        self._admit(observation, window)
        if type(x) is not int or type(y) is not int:
            raise DriverError("integer_pixels_required")
        w, h = self.size
        if not (0 <= x < w * self.scale and 0 <= y < h * self.scale):
            raise DriverError("outside_image")
        if self.pending is not None:
            raise DriverError("busy")
        lx, ly = x / self.scale, y / self.scale
        target = "none"
        if 20 <= lx < 300 and 20 <= ly < 70:
            target = "input"
        elif 320 <= lx < 420 and 20 <= ly < 70:
            target = "save"
        self.focus = target
        if target == "save":
            self.pending = (self.tick + 2, self.text)
        return self._consume({
            "event": "click", "target": target,
            "screen_point": [self.origin[0] + lx, self.origin[1] + ly],
        })

    def type_text(self, observation, text, *, window=7):
        self._admit(observation, window)
        if self.focus != "input":
            raise DriverError("wrong_focus")
        if type(text) is not str or len(text) > 100:
            raise DriverError("invalid_text")
        self.text += text
        return self._consume({"event": "type", "characters": len(text)})

    def key_down(self, observation, key, *, window=7):
        self._admit(observation, window)
        if type(key) is not str or key not in {"Shift", "Control"}:
            raise DriverError("unsupported_key")
        self.held_keys.add(key)
        return self._consume({"event": "key_down", "key": key})

    def external_focus(self, name):
        self._live()
        self.focus = name
        self._changed()

    def resize(self, width, height, *, scale=2):
        self._live()
        if any(type(v) is not int or v <= 0 for v in (width, height, scale)):
            raise DriverError("invalid_geometry")
        self.size, self.scale = (width, height), scale
        self._changed()

    def advance(self, ticks=1):
        self._live()
        if type(ticks) is not int or ticks < 0:
            raise DriverError("invalid_ticks")
        self.tick += ticks
        if self.pending is not None and self.tick >= self.pending[0]:
            self.saved = self.pending[1]
            self.pending = None
            self._changed()

    def wait_saved(self, expected, *, budget=4, on_tick=None):
        self._live()
        if type(budget) is not int or budget < 0:
            raise DriverError("invalid_budget")
        deadline = self.tick + budget
        while True:
            observed = self.observe()
            if observed.tick > deadline:
                raise DriverError("timeout")
            if observed.saved == expected and not observed.pending:
                return observed
            if self.tick >= deadline:
                raise DriverError("timeout")
            if on_tick:
                on_tick(self)
                self._live()
                if self.tick >= deadline:
                    continue
            self.advance()

    def cancel(self):
        self._live()
        self.state = "cancelled"
        self.pending = None
        self.latest = None
        self.events.append({"event": "cancel"})

    def close(self):
        if self.state == "closed":
            return
        self.held_keys.clear()
        self.pending = None
        self.latest = None
        self.state = "closed"
        self.cleanup_count += 1
        self.events.append({"event": "close"})


def demo():
    driver = MockDriver()
    with driver:
        initial = driver.observe()
        driver.click(initial, 80, 80)
        stale_error = None
        try:
            driver.type_text(initial, "not admitted")
        except DriverError as error:
            stale_error = str(error)
        driver.type_text(driver.observe(), "driver contract")
        receipt = driver.click(driver.observe(), 700, 80)
        result = driver.wait_saved("driver contract")
    return {
        "kind": "offline_metadata_simulation",
        "stale_error": stale_error,
        "receipt": receipt,
        "result": asdict(result),
        "closed": driver.state,
        "cleanup_count": driver.cleanup_count,
        "events": driver.events,
    }


if __name__ == "__main__":
    print(json.dumps(demo(), indent=2, ensure_ascii=False))
完整文件:test_driver_contract.py

test_driver_contract.py · Python 标准库

"""Run with: python3 -m unittest -v. Standard library only."""
from dataclasses import replace
import unittest

from driver_contract import DriverError, MockDriver, demo


class DriverContractTests(unittest.TestCase):
    def setUp(self):
        self.d = MockDriver().connect()
        self.addCleanup(self.d.close)

    def reject(self, code, fn):
        with self.assertRaisesRegex(DriverError, "^" + code + "$"):
            fn()

    def focus_input(self):
        self.d.click(self.d.observe(), 80, 80)

    def start_save(self, text="note"):
        self.focus_input()
        self.d.type_text(self.d.observe(), text)
        return self.d.click(self.d.observe(), 700, 80)

    def test_demo(self):
        result = demo()
        self.assertEqual(result["result"]["saved"], "driver contract")
        self.assertEqual(result["cleanup_count"], 1)
        self.assertEqual(result["closed"], "closed")

    def test_requires_authentication(self):
        self.reject("unauthenticated", MockDriver(authenticated=False).connect)

    def test_auth_does_not_grant_input(self):
        with MockDriver(input_allowed=False) as d:
            self.reject("input_denied", lambda: d.click(d.observe(), 80, 80))

    def test_observation_permission(self):
        self.d.observe_allowed = False
        self.reject("observation_denied", self.d.observe)

    def test_observation_metadata_only(self):
        o = self.d.observe()
        self.assertEqual(o.kind, "metadata_only")
        self.assertFalse(hasattr(o, "png"))
        self.assertEqual((o.size, o.scale), ((800, 600), 2))

    def test_click_changes_focus_and_revision(self):
        o = self.d.observe()
        self.d.click(o, 80, 80)
        n = self.d.observe()
        self.assertEqual(n.focus, "input")
        self.assertGreater(n.revision, o.revision)

    def test_coordinate_conversion(self):
        self.focus_input()
        self.assertEqual(self.d.events[-1]["screen_point"], [140.0, 120.0])

    def test_old_capture_after_new_observation(self):
        old = self.d.observe()
        self.d.observe()
        self.reject("stale_capture", lambda: self.d.click(old, 80, 80))

    def test_consumed_capture(self):
        old = self.d.observe()
        self.d.click(old, 80, 80)
        self.reject("stale_capture", lambda: self.d.click(old, 80, 80))

    def test_other_session_capture(self):
        current = self.d.observe()
        with MockDriver() as other:
            foreign = other.observe()
            self.assertNotEqual(current.session, foreign.session)
            self.reject("stale_capture", lambda: self.d.click(foreign, 80, 80))

    def test_tampered_metadata(self):
        o = replace(self.d.observe(), scale=1)
        self.reject("stale_capture", lambda: self.d.click(o, 80, 80))

    def test_wrong_window(self):
        o = self.d.observe()
        self.reject("wrong_window", lambda: self.d.click(o, 80, 80, window=8))

    def test_bool_is_not_a_window_id(self):
        o = self.d.observe()
        self.reject("wrong_window", lambda: self.d.click(o, 80, 80, window=True))

    def test_outside_image(self):
        for x, y in [(-1, 80), (1600, 80), (80, 1200)]:
            o = self.d.observe()
            self.reject("outside_image", lambda: self.d.click(o, x, y))

    def test_reject_noninteger_pixels(self):
        for x in [True, 80.5, float("nan"), "80"]:
            o = self.d.observe()
            self.reject("integer_pixels_required", lambda: self.d.click(o, x, 80))

    def test_expired_capture(self):
        o = self.d.observe()
        self.d.advance(3)
        self.reject("expired_capture", lambda: self.d.click(o, 80, 80))

    def test_resize_invalidates(self):
        o = self.d.observe()
        self.d.resize(400, 300, scale=1)
        self.reject("stale_capture", lambda: self.d.click(o, 80, 80))

    def test_bad_geometry(self):
        self.reject("invalid_geometry", lambda: self.d.resize(0, 300))

    def test_focus_change_invalidates(self):
        self.focus_input()
        o = self.d.observe()
        self.d.external_focus("other-window")
        self.reject("stale_capture", lambda: self.d.type_text(o, "hello"))
        self.assertEqual(self.d.text, "")

    def test_fresh_observation_wrong_focus(self):
        self.reject("wrong_focus", lambda: self.d.type_text(self.d.observe(), "x"))

    def test_type_changes_visible_metadata(self):
        self.focus_input()
        self.d.type_text(self.d.observe(), "draft")
        self.assertEqual(self.d.observe().text, "draft")

    def test_invalid_text_no_mutation(self):
        self.focus_input()
        self.reject("invalid_text", lambda: self.d.type_text(self.d.observe(), 4))
        self.assertEqual(self.d.text, "")

    def test_receipt_does_not_mean_saved(self):
        receipt = self.start_save()
        self.assertTrue(receipt["delivered"])
        self.assertEqual(receipt["effect"], "unverifiable")
        self.assertEqual(self.d.observe().saved, "")

    def test_wait_completes_at_deadline(self):
        self.start_save()
        o = self.d.wait_saved("note", budget=2)
        self.assertEqual(o.saved, "note")
        self.assertEqual(o.tick, 2)

    def test_busy_save_not_replayed(self):
        self.start_save()
        self.reject("busy", lambda: self.d.click(self.d.observe(), 700, 80))
        self.assertEqual(sum(e["event"] == "click" for e in self.d.events), 2)

    def test_wait_timeout(self):
        self.start_save()
        self.reject("timeout", lambda: self.d.wait_saved("note", budget=1))
        self.assertEqual(self.d.tick, 1)

    def test_wrong_expected_result_times_out(self):
        self.start_save()
        self.reject("timeout", lambda: self.d.wait_saved("wrong", budget=3))

    def test_zero_budget_observes_once(self):
        self.reject("timeout", lambda: self.d.wait_saved("x", budget=0))
        self.assertEqual(self.d.tick, 0)

    def test_cancel_during_wait(self):
        self.start_save()
        self.reject("cancelled", lambda: self.d.wait_saved(
            "note", on_tick=lambda d: d.cancel()))
        self.assertIsNone(self.d.pending)
        self.reject("cancelled", self.d.observe)

    def test_cleanup_on_exception(self):
        d = MockDriver()
        with self.assertRaises(ValueError):
            with d:
                d.key_down(d.observe(), "Shift")
                raise ValueError("test")
        self.assertEqual(d.held_keys, set())
        self.assertEqual(d.cleanup_count, 1)

    def test_idempotent_close(self):
        self.d.close()
        self.d.close()
        self.assertEqual(self.d.cleanup_count, 1)
        self.reject("closed", self.d.observe)

    def test_closed_cannot_reconnect(self):
        self.d.close()
        self.reject("bad_lifecycle", self.d.connect)

    def test_permission_revoked_before_action(self):
        o = self.d.observe()
        self.d.input_allowed = False
        self.reject("input_denied", lambda: self.d.click(o, 80, 80))
        self.assertEqual(self.d.focus, "none")

    def test_bad_budget_and_ticks(self):
        self.reject("invalid_budget", lambda: self.d.wait_saved("", budget=True))
        self.reject("invalid_ticks", lambda: self.d.advance(-1))

    def test_unhashable_key_is_contract_error(self):
        for key in [[], {}]:
            with self.subTest(key=key):
                self.reject("unsupported_key", lambda: self.d.key_down(
                    self.d.observe(), key))

    def test_unsupported_key(self):
        self.reject("unsupported_key", lambda: self.d.key_down(
            self.d.observe(), "Escape"))


    def test_callback_consumes_last_tick_without_extra_advance(self):
        self.start_save()
        with self.assertRaisesRegex(DriverError, '^timeout$'):
            self.d.wait_saved('note', budget=1, on_tick=lambda d: d.advance(1))
        self.assertEqual(self.d.tick, 1)
        self.assertEqual(self.d.saved, '')
        self.assertIsNotNone(self.d.pending)
        self.d.advance(1)
        self.assertEqual(self.d.wait_saved('note', budget=0).saved, 'note')
        self.assertEqual(sum(e['event'] == 'click' for e in self.d.events), 2)

    def test_callback_completion_at_deadline_is_accepted(self):
        self.start_save()
        result = self.d.wait_saved('note', budget=2, on_tick=lambda d: d.advance(2))
        self.assertEqual(result.tick, 2)
        self.assertEqual(result.saved, 'note')

    def test_callback_completion_after_deadline_is_not_timely_success(self):
        self.start_save()
        with self.assertRaisesRegex(DriverError, '^timeout$'):
            self.d.wait_saved('note', budget=1, on_tick=lambda d: d.advance(3))
        self.assertEqual(self.d.tick, 3)
        self.assertEqual(self.d.observe().saved, 'note')
        self.assertIsNone(self.d.pending)

    def test_callback_cancel_at_deadline_is_not_success(self):
        self.start_save()
        def cancel_after_completion(d):
            d.advance(2)
            d.cancel()
        with self.assertRaisesRegex(DriverError, '^cancelled$'):
            self.d.wait_saved('note', budget=2, on_tick=cancel_after_completion)
        self.assertEqual(self.d.tick, 2)
        self.assertEqual(self.d.saved, 'note')

if __name__ == "__main__":
    unittest.main()

源码中的 Observation 是不可变数据记录。每次 observe 都产生新的 capture 序号,动作检查它是否仍是当前记录。点击、输入、尺寸变更和外部焦点切换都会推进 revision。这样可以用很少的代码检验旧观察被误用的路径。它采用更保守的每动作重新观察规则,没有试图复刻厂商全部缓存语义。

10. 排查时先缩小故障位置

应用列表为空,先检查驱动是否进入正确的图形桌面;拿到树却没有图片,检查屏幕录制授权;元素令牌失效,取新快照;动作有回执但内容没变,检查 effect 与新观察。官方故障排查页还提示 diagnose 可能含用户名、路径和进程号,分享前应清理敏感信息。

macOS 的不同 Space、最小化窗口和自绘应用会影响观察或后台输入。Linux 下还要区分 X11、XWayland 与具体 Wayland 合成器,不能把某个环境通过的结果当成整个 Linux 的保证。跨平台迁移之前,先核对 平台支持矩阵,再安排对应的实机验收。

我倾向于保留一份很短的接入记录:驱动版本与启动方式、应用版本、目标窗口、授权范围、最后一次可确认结果。这样升级后出现回归,可以回到同一条路径复查。文章里的资料日期、完整源码和测试输出也为这个目的保留;真实 GUI 结果只有在实际完成那轮操作后才能填写。

11. 公开资料与复现文件

本文依据 Cua 官方文档和 trycua/cua 公开仓库编写。仓库核验快照为 1fe6ce53688c;在线文档会继续更新,实际安装版本的 describe 输出应纳入接入检查。完整源码已经直接列在本页,复制两份文件即可复现,不依赖额外下载地址。

运行本文代码会操作电脑吗?

不会。代码只更新内存状态,不连接驱动、网络、模型或桌面。

这些测试证明 Cua Driver 兼容我的应用吗?

不能。它们验证自编契约模型;真实应用、平台和 GUI 输入还需要独立验收。

Cua Driver 需要自己选择模型吗?

模型和任务循环由接入的 agent harness 负责,驱动处理桌面观察与动作。

窗口截图里的坐标要先换算 Retina 倍率吗?

按当前 click schema 传返回图片中的像素坐标,由驱动处理对应变换,避免重复缩放。