Rebuilt against main so the branch carries the Computer Use work and no other divergence. Three unrelated efforts had been sitting uncommitted in this worktree and were swept into an earlier commit; they are preserved on cu-worktree-full-backup and belong on their own branches — adapter control credentials, Electron asar sealing, and the sidecar code-loading audit. Every file outside Computer Use now matches main exactly. The engine A Swift helper drives apps through the accessibility tree, with coordinate actuation for the Chromium and Electron apps whose tree is a bare window frame. Ten primitives matching the shape Codex uses, so an app's guidance and the model's habits transfer. Coordinate actions resolve their target window once and refuse when none can be named. The unbound event they used to fall back to is discarded by custom renderers, so a minimized target produced a whole session of "Action completed" with nothing behind it. Input acceptance is established for typing and key presses as well as clicks: each MCP call is seconds apart, so the keyboard cannot inherit the focus a click established. The synthetic focus notification is gated on the target not already being active — sent unconditionally it names window 0 at an app that already owns a key window, and nine window-bound clicks were discarded with the traffic lights fully lit. State the model can trust An off-screen target says so, and says which tools still reach it: element actions need no on-screen geometry, so an app with a real tree can still be driven from the Dock. A fully covered window is recovered once, then left alone — burying it again is the user wanting their screen back. A repeated capture is reported with the cause that actually applies rather than both, because coverage is something we compute. Signing The helper is signed under a stable identity before electron-builder sees it, and excluded from re-signing: macOS ties Accessibility and Screen Recording grants to the signing identity, so rotating it drops both on every update. Discoverability The desktop slash menu falls back to a directory scan while a session's CLI has not started, which is when the menu is first opened. Built-ins and bundled skills live in the binary, so /computer-use was absent until after the first message.
10 KiB
Computer Use 原生重构 —— 真机验证清单
这份清单只列自动化测不到、必须真人在真机上做的项。 已自动化的部分(3000+ 单测、185 个 Swift 测试、平台路由、失败关闭、签名契约)见提交说明,不重复。
前置:
bash native/cu-helper/build.sh能出.app;或走cd desktop && bun run build:sidecars。
0. 准备(每次重装都要重做一遍)
-
构建 helper:
bash native/cu-helper/build.sh记下最后一行
built: <路径>。 -
必须用安装版
.app测授权。re-sign 过的 debug 二进制是新的 cdhash,TCC 不认它继承任何授权 —— "我明明授权了却还是没权限" 十有八九是这个。 -
daemon 必须用 LaunchServices 起,不要裸 exec(hardened runtime 的 release
.app裸 exec 会被 SIGKILL):open -n "<built 路径>" -
确认签名身份没有轮换(轮换会让老用户重新授权一遍):
codesign -dv --verbose=4 "<built 路径>" 2>&1 | grep -E "Identifier|Authority" cat native/cu-helper/.build/.cu-helper.signidIdentifier必须是dev.cchaha.cu-helper。 -
三者同证书 —— 打包版最容易踩的坑,先查这个再查别的。
helper 的客户端核验(
ClientAttestation.swift)要求调用链能被密码学地认定为本应用:host、sidecar、helper 必须签在同一张证书上(同 team、同 leaf),且 sidecar 的 identifier 必须精确等于com.claude-code-haha.desktop.sidecar。任何一环对不上,helper 对所有命令返回unauthorized_client,表现是设置页权限一直"检测中…"、授权卡片点了没反应。APP="desktop/build-artifacts/macos-arm64/Claude Code Haha.app" B="$APP/Contents/Resources/app.asar.unpacked/src-tauri/binaries" for P in "$APP" "$B/claude-sidecar-aarch64-apple-darwin" "$B/cc-haha-computer-use.app"; do codesign -dv "$P" 2>&1 | grep -E "^(Identifier|TeamIdentifier)=" done期望:三个
TeamIdentifier完全相同,且 identifier 分别是com.claude-code-haha.desktop/com.claude-code-haha.desktop.sidecar/dev.cchaha.cu-helper。ad-hoc 构建(
SIGN_BUILD=0,或机器上没有签名证书)没有证书可言,Computer Use 在其上必然不可用 —— 这不是 bug,是 attestation 的固有前提。要测 Computer Use 就必须用签名构建(默认即是)。
1. TCC 授权(双主体,两条独立)
只有两项:辅助功能 + 屏幕录制。「输入监控」永远不该出现在任何提示里。 历史事故:物理输入监控最初用 listen-only event tap 实现,它需要第三个 TCC 权限(输入监控), 而授权卡片从未请求过它 → 新装机上
ForegroundLease.acquire对每个动作抛focus_isolation_unavailable,整个 Computer Use 不可用,且虚拟光标永不出现(光标动画在 lease 之后才跑)。现已改为读 HID 系统计数器 (CGEventSource.counterForEventType(.hidSystemState, …)):零权限、不可失效、postToPid注入实测不污染计数器。若真机上动作再次全部报focus_isolation_unavailable,说明有人把 tap 加了回来——按本节头两行回退。
| # | 步骤 | 期望 |
|---|---|---|
| 1.1 | 首次运行 helper,弹出原生授权卡片 | 卡片出现,不需要用户敲任何 shell 命令 |
| 1.2 | 点卡片上的「打开辅助功能」 | 直接跳到 System Settings ▸ 隐私与安全性 ▸ 辅助功能 |
| 1.3 | 把 helper 从卡片拖进辅助功能列表 | 拖拽落点有效,条目出现并可勾选 |
| 1.4 | 勾选后回到卡片 | 卡片上的状态实时翻绿(不需要重启 app) |
| 1.5 | 同样流程走一遍屏幕录制 | 同上 |
| 1.6 | 重新构建 helper 后再看权限 | 两项授权依然在(稳定签名身份跨 rebuild 存活)。若掉了,看 §0.4 |
⚠️ 屏幕录制的主体是路径上最外层的 .app,辅助功能的主体是进程自身。会出现"辅助功能授给了 helper、屏幕录制记在宿主名下"的错位——排障时分开看,别当成一个开关。
2. 命门复验(改任何感知层代码前都要重跑)
| # | 靶子 | 步骤 | 期望 |
|---|---|---|---|
| 2.1 | VS Code | 后台(不置前)读一次 AX 树 | 能读到完整树(几百个元素),不是十几个壳元素。 后台 enableEnhancedAX 应当已生效 |
| 2.2 | VS Code | 置前再读一次 | 元素数量与 2.1 同量级 |
| 2.3 | 网易云音乐(CEF116) | 读网页区 AX 树 | 恒为 0 —— 这是预期。用它验证坐标兜底路径可用:按坐标点击能命中 |
| 2.4 | 任意 App | 观察操作全程 | 真鼠标全程不动,虚拟光标平滑滑到目标 |
❗ 若发现任何"找不到目标就兜底到 frontmost"的行为,那是 bug —— 它会变成操作宿主自己。
2.5 CEF/Electron 注入契约(改注入层前必读)
网易云音乐、VS Code、Slack、Discord 这类 Chromium/CEF 应用按事件声明的窗口做路由。裸 CGEvent 的 windowNumber 是 0,这类应用会把它静默丢弃——动作返回成功、界面毫无变化。这是本项目栽过最久的一个坑(三轮误判:先怀疑权限、再怀疑坐标、再怀疑 AX 树)。
点击必须带窗口身份(WindowTargetedEvent.swift):
NSEvent.mouseEvent(...windowNumber: 目标窗口ID...)再取.cgEvent—— CGEvent 没有公开 API 设窗口号,这是唯一途径- field 91/92 = 窗口 ID
CGEventSetWindowLocation(cg, 窗口内坐标)—— 私有 SPI,dlsym;坐标 = 全局点 − 窗口原点,左上原点不翻转
目标在后台时还要切前台并等沉降(focusSettleMs = 800)。SLPSSetFrontProcessWithOptions 是真的前台切换(不是只给 key focus),切换后首击会被当成激活点击吞掉——等 250ms 全失败、等 800ms 全成功。
键盘不需要这一套:裸 postToPid 即可,完全后台可用、不动指针、不切前台。
| # | 步骤 | 期望 |
|---|---|---|
| 2.5.1 | 目标 App 在前台,点击一个文本框并输入 | 光标出现、字进去了 |
| 2.5.2 | 目标 App 切到后台(前台放别的 App),重复上一步 | 同样成功;目标会被带到前台,这是预期 |
| 2.5.3 | 目标完全后台时只发键盘(不点击) | 字进去了,目标不被带到前台、指针不动 |
| 2.5.4 | 全程观察真实鼠标指针 | 从不移动 |
任何一项失败,先查事件有没有窗口身份,别去查权限/坐标/AX 树。
判定纪律:只认截图前后对比,不认 API 返回值——"返回成功但什么都没发生"正是这个 bug 的形状。多人/多 agent 同时在一个 App 上做注入实验会互相污染出假阳性,用唯一标记串隔离。
3. 跨 App 语义操控闭环
| # | 步骤 | 期望 |
|---|---|---|
| 3.1 | get_app_state 一个 App |
返回带 element handle 的 AX 树 + 该窗口截图(不是整屏) |
| 3.2 | 树里的 handle 形如 g17:4 |
不是裸数字。裸数字应当被拒绝 |
| 3.3 | VS Code 按 handle 点一个面板开关 | Value 0→1;再点回来复原 |
| 3.4 | TextEdit 里 set_value 写入文本 |
文本落入字段,返回 before/after |
| 3.5 | TextEdit 里 select_text 选一段 |
用独立 AppleScript 读 AXSelectedText 校验逐字一致:osascript -e 'tell app "System Events" to tell process "TextEdit" to get value of attribute "AXSelectedText" of ...' |
| 3.6 | select_text 用 selection_type=cursor_after |
光标折叠成 length 0 |
| 3.7 | 变更动作的返回体 | 是固定回执 Action completed. Call \get_app_state`…`,不带截图(零隐式快照) |
| 3.8 | 换一个进程再用旧 handle | 报 not found —— 这是预期,不是 bug |
4. 视觉层
| # | 步骤 | 期望 |
|---|---|---|
| 4.1 | 操控某个 App 时看它的窗口 | 窗口外缘一圈柔和光晕,亮度不刺眼 |
| 4.2 | 拖动被控窗口 | 光晕跟随窗口移动 |
| 4.3 | 隐藏/最小化被控窗口 | 光晕随之消失 |
| 4.4 | 截图里检查 overlay | 虚拟光标和光晕不出现在截图里,也不吃鼠标事件 |
| 4.5 | 多屏后台操作:目标 App 单独放一块屏,自己在另一块屏上工作(前台是别的 App),让 agent 操作目标 | 虚拟光标与光晕照常出现在目标那块屏上——可见性判据是「目标窗口在动作点是否露出」,不是「目标是否前台」 |
| 4.6 | 把目标窗口用其他窗口完全盖住,再让 agent 操作 | 光标/光晕不出现(盖在别人窗口上会误导指向);动作本身照常执行 |
5. 安全边界(真机确认,测试已覆盖逻辑)
| # | 步骤 | 期望 |
|---|---|---|
| 5.1 | 让 agent 操控 Claude Code Haha 自己 | 拒绝:Computer Use is not allowed to use the app '…' for safety reasons. |
| 5.2 | 操控 Terminal / iTerm / Chrome | 同样拒绝 |
| 5.3 | 未开 systemKeyCombos 时按 cmd+q |
拒绝,提示 Enable system key combinations in Computer Use settings, then retry. |
| 5.4 | 开了之后再按 | 放行 |
| 5.5 | 人手动碰鼠标/键盘 | helper 让位,不与真人抢 |
6. 自动化 smoke(可选,但建议跑一次)
bun run check:computer-use-live-smoke
靶子是 TextEdit,走完整 daemon NDJSON 链路,断言:
- AX 无变化时返回
There has been no change in the accessibility tree for Window: "这条精确前缀 - 物理指针未被移动
- held input 未被污染
它会拿 Computer Use 会话锁,跑完确认锁已释放。
7. 打包形态
bun run check:native # 含 swift test + build:sidecars + 打包 + smoke
打完包后确认:
codesign -dv --verbose=4 "<app>/Contents/Resources/**/cc-haha-computer-use.app" 2>&1 | grep Identifier
必须仍是 dev.cchaha.cu-helper —— 如果变了,说明 electron-builder 重签了它,用户的两项授权会全掉。