深入解析 trycua/cua:给 AI agent 一台真电脑的工程栈怎么搭
💬 评论92 万行 Rust 的 monorepo,6 块组件拼成 computer-use 栈。spacesd 单 port 装 12 个 gRPC + rcdp v2 媒体平面,跨平台 driver 同形异构(15.5 万行 platform 代码),proto COMMENTS lint 强注释。
这是一篇学习笔记,不是技术综述。我不打算把 6 块组件的每个 RPC 都列一遍(那会写成 API 文档),而是挑我读完觉得”这个设计很工程”的地方写下来——为什么 monorepo 要分这么多 crate、为什么 media plane 单独走 rcdp 而不是 gRPC、为什么 driver 的核心要在 macOS/Win/Linux 上写三遍。下面这些是我自己读 AGENTS.md、几份 README、几个核心 crate 的入口、以及 proto 契约文件后留下的笔记。
一、agent 为什么”没有电脑”是个真问题#
我刚打开 README 第一眼看到的不是产品功能,而是这句话:“Give AI agents computers they can use.”
这不是营销话术,是问题定义。
LLM/agent 这两年突飞猛进,但”让 agent 在真机上做点事”一直很尴尬:
- 浏览器自动化(Playwright/Selenium):只能动 web,碰不到原生 app
- OS 脚本(AppleScript/Bash/PowerShell):可以动原生,但没视觉——agent 看不见屏幕,只能按代码定义好的接口走
- 虚拟机 + VNC:能看到屏幕了,但 VNC 是给人用的,延迟、cursor 抖动、键盘映射全是大坑,而且需要自己实现整套 agent 基础设施(VM 生命周期、文件传输、剪贴板、远程进程)
Cua 想解决的”第三条路”,简单说就是:给 agent 一台真电脑,且这台电脑是 agent-friendly 的。
具体做法:在 Apple Silicon 上用 Virtualization.Framework 起 macOS VM(Lume),VM 里跑一个 daemon(cua-spacesd)接住 agent 的所有操作,屏幕截图 + 视频流 + 键鼠输入 + 文件系统 + 进程管理 + 剪贴板 + presence(多 cursor 协同),全部走标准 gRPC + 自研 media 协议。agent 在 host 这边通过 SDK/CLI/MCP 接,跟本地编程没两样。
我看完第一反应是——这事的工程量大得离谱,所以一上来就翻仓库看规模。
二、规模与结构:这是个 monorepo,不是 demo#
1650 .rs files / 915041 lines (Rust 92 万行)
1239 .py files / 361062 lines (Python 36 万行)
464 .ts files / 175765 lines (TypeScript 17 万行)
506 .swi files / 237580 lines (Swift 23 万行)
24 .kt files / 61585 lines (Kotlin 6 万行)plaintext这 6 块组件在 libs/ 下散得很整齐:
cua—— SDK + CLI + daemon(Python/TS 主体,Swift/Kotlin 绑定)cua-driver—— 跨平台桌面自动化,所有平台用 Rustlume—— Apple Silicon 本地 VM 引擎(Python 包装 + Rust 核心)cua-spacesd—— sandbox 内的服务端 daemon,所有平台用 Rustcua-s1—— System 1 决策模型(主要是 Python 训练/评测代码)cua-bench—— 评测框架(Python)- 还有
fleetkasmlumierxfcepythontypescriptspaces-app-swiftspaces-sdk-swift等辅助
光是 cua-driver 那个 crate 就裂成 15 个子 crate,最重的 cua-driver-core 86K Rust,平台 adapter platform-macos/linux/windows 加起来 15.5 万行。这不是”项目”,是产品。
架构一张图#
我把这 6 块画成了一张分层图——agent 在最上面,通过 libs/cua 入口进来,往下分四条线:
- Driver(蓝色):跨平台手脚,接 MCP/SDK 来的 tool call
- Lume(绿色):VM 引擎,负责”造一台电脑”
- Spaces(紫色):macOS app 包装层,给用户看,不是给 agent
- CUA-S1(橙色):可选的小型决策模型,agent 可以用也可以不用
底下铺 cua-spacesd(sandbox 内 daemon,17K Rust)和 Cua Bench(评测),最底层是平台 adapter 三件套 + 契约层(proto)。
看到这张图你大概能 get 一点:这不是”做一个 driver 给 agent 用”,而是”把 agent → 电脑 这条链路上的所有空白都填了”。
三、cua-driver:跨平台契约怎么落#
cua-driver 是 6 块里我最想拆的——它是 agent 真”动手”的部分,跨 OS 的复杂度全在这。
3.1 15 个 crate 怎么裂的#
cua-driver/
├── cua-driver/ 35.7K 顶层 CLI / MCP / serve
├── cua-driver-core/ 86.2K 共享 state/geometry/timing/protocol
├── cua-driver-contract/ 6.7K 平台中性 typed contract
├── cua-driver-sdk/ 13.7K 编程 SDK(供 bindings 用)
├── cua-driver-testkit/ 12.2K 测试工具集
├── cua-driver-bindgen/ 55 bindgen 脚本
├── cua-driver-uia/ 574 UIA 工具
├── cua-driver-e2e/ 6 端到端测试
├── platform-macos/ 47.7K AX / ScreenCaptureKit / TCC
├── platform-linux/ 69.7K AT-SPI / Wayland / X11 / proc_fs
├── platform-windows/ 38.1K UIA / MSAA / WGC / Virtual Desk
├── cua-perception/ 3.0K omniparser 屏元素识别
├── cua-cursor-motion/ 2.7K cursor 移动曲线
├── cursor-overlay/ 8.0K cursor 主题叠加
├── cursor-theme-cli/ 1.2K cursor 主题命令行
└── pip-preview/ 276 picture-in-pictureplaintext我第一次看这个布局的反应是——Core + Contract + SDK + Testkit 这种分层我熟,但平台 adapter 单独裂三个 crate 而不是放一个 platform 文件夹里,这是有意识的设计**。**
AGENTS.md 把这事讲明白了:
Treat user-visible Cua Driver behavior as a cross-platform contract. Implement shared state, geometry, timing, and protocol semantics in the common crates whenever possible, then keep each macOS, Windows, X11, and Wayland adapter thin. Do not silently land a platform-specific cursor or interaction change as though it were universal.
翻译一下:**共享逻辑必须放 common crates,平台 adapter 只做平台特定部分;**任何”顺手在 macOS 上加个 cursor 效果”都不行,因为这会无声地破坏跨平台契约。
所以 cua-driver-core 86K 装的是:session 生命周期、permission state、capture/runtime、cursor sampler、history、recording、daemon 协议——所有”语义”。platform-macos 47K 装的是:AX 节点遍历、ScreenCaptureKit 截屏、TCC 权限观测、LaunchServices app 启动——所有”调用 OS API”。平台 adapter 反而是更薄、更被 core 框住的那一层。
3.2 平台 adapter 是”同形异构”#
三个平台 adapter 我都翻了一下顶层目录,名字一致性非常强:
platform-macos/ platform-linux/ platform-windows/
├── capture.rs ├── capture.rs ├── capture.rs
├── history.rs ├── history.rs ├── history.rs
├── input/ ├── input/ ├── input/
├── pip/ ├── pip/ ├── pip/
├── pointer_shape.rs├── pointer_shape.rs ├── pointer_shape.rs
├── terminal.rs ├── terminal.rs ├── terminal.rs
├── recording_hooks.rs├── recording_hooks.rs├── recording_hooks.rs
├── tools/ ├── tools/ ├── tools/
├── session.rs ├── session_bus.rs ├── (inlined in lib.rs)
├── (none) ├── x11/ wayland/ ├── win32/ uia/
├── ax/ apps/ ├── atspi/ ├── msaa.rs wgc.rs
├── permissions/ ├── xauth.rs ├── launch_uwp.rs
├── video_sckit.rs ├── video_wayland.rs ├── (wgc 内置)plaintextcapture.rs / history.rs / pointer_shape.rs / terminal.rs / recording_hooks.rs / tools/ 三个平台都有同名模块。 core 看到的是 trait/接口,每个平台 crate 实现自己的版本。platform-macos 走 ax/(Apple Accessibility)和 ScreenCaptureKit,platform-linux 走 atspi/ + wayland/ + x11/ + proc_fs.rs 调内核,platform-windows 走 uia/(UI Automation)和 wgc.rs(Windows Graphics Capture)+ virtualdesk.rs(虚拟桌面)+ launch_uwp.rs(UWP app 启动)。
这是教科书级的”ports & adapters”——同形异构,核心逻辑只用写一次,平台特定的脏活让 OS-specific crate 干。而我之所以强调”工程化”,是因为 15.5 万行 platform 代码不是堆出来的,是被 86K core + 6.7K contract 框出来的。
3.3 cua-driver-contract:契约是单独的 crate#
这个细节我之前拆别的项目没见过。
cua-driver-contract 6.7K,顶层 doc comment 写得很直白:
Canonical, platform-neutral contract declarations for cua-driver clients. This crate deliberately contains no transport or platform implementation. The native driver remains the only execution engine; this package owns the typed inputs/results and versioned declarations used by the live runtime and to generate experimental client SDKs.
翻译:这个 crate 只有契约,不装实现。driver 仍然是唯一执行引擎,这个 crate 只装 typed inputs/results 和版本化声明,供 live runtime 用,也供生成实验性 client SDK 用。
这是非常聪明的设计:
- client SDK 跟 driver 实现解耦——client 引用 contract,contract 不引用 driver,driver 引用 contract
- contract 可以独立 versioned——breaking change 影响 SDK 升级,但不强制 driver 跟着改
- 可以用 schemars 自动生成 JSON Schema 给 client(contract 顶部确实引了 schemars 2020-12)
如果让我用一句话总结这个项目最像”老牌系统工程”的地方——就是这个 6.7K 的 contract crate。小而纯,只装契约,让所有平台和语言的 binding 围着它转。
四、cua-spacesd:单 port 装双平面#
sandbox 内跑的那个 daemon 我觉得是整个项目里最值得拆的部分——它把”agent 怎么跟一台真电脑说话”这件事真的工程化了。
cua-spacesd-server 17K Rust,单 port 装 12 个 gRPC service:
// cua-spacesd-server/src/server.rs
pub const CORE_SERVICES: &[&str] = &[
"cua.env.v1.SystemService",
"cua.env.v1.ProcessService",
"cua.env.v1.FilesystemService",
"cua.env.v1.DriverService",
"cua.env.v1.TeleportService",
"cua.env.v1.TunnelService",
"cua.env.v1.VolumeService",
];rust加 stream / presence / computer / windows / diagnose / accessibility 是 12 个。axum + hyper-util + tower-http,HTTP/1.1 + h2c(原生 gRPC / gRPC-Web / WebSockets / plain HTTP)同一 port,graceful shutdown。
4.1 gRPC 控制平面 + media plane 媒体平面#
proto/MEDIA.md 写得很清楚:视频不通过 gRPC。StreamService 负责 discover / negotiate / mint media ticket,然后帧走 rcdp wire v2,通道两条:
- WebSocket on port 3211(穿 Fleet gateway / relay)
- QUIC on UDP port 3212(能直连 guest 时)
我把这套画成下面这张图,左边是 client(SDK / Spaces app),中间是 3211 单 port 装的三类协议(① gRPC 控制 ② rcdp media ③ presence datagram),右边是外部接入端(Spaces app / cua SDK / Fleet)+ 14 个 proto 文件 + v1→v2 迁移清单。
4.2 v1 → v2 切干净:media plane 的演化#
MEDIA.md 头一句就强调:
Only cua-spacesd speaks v2. It does not accept v1 connections.
v1 时代,所有事情都在一个 media socket 上做(打开会话、列窗口、传输剪贴板、cursor 同步、视频帧)。问题很明显:控制命令和视频帧混在同一管道,既要可靠(命令)又要实时(视频),互相掐。
v2 把控制平面拆出来,放到 gRPC(走 HTTP/1.1 + h2c):
ListWindows/GetAppIcon/PickWindow/RestoreWindow/ListApps/LaunchApp→StreamService.ListTargets和WindowsService。media socket 直接返回error{code: unsupported_operation}。OpenSession→StreamService.OpenMedia。session 打开后 socket 再 attach。CloseSession→StreamService.CloseMedia,或者直接关 socket。关 socket 不关 session——ticket 可以 reattach 到过期。Join/Cursor/Joined/Presence/RemoteCursor→PresenceServiceover gRPC。整个 presence plane 从 media socket 移走。GetClipboard*/SetClipboard*→ComputerService.GetClipboard/SetClipboard。也从 media plane 移走。
媒体 socket 上剩下:video / Lifecycle / InteractiveInput / control JSON message({"type": ..., "payload": ...})。
MEDIA.md 还把每次变更都打了 (F*n*) 标签,链接到 FRICTION-rcdp.md 的具体修复条目——v1 → v2 不是”想改就改”,是有可追溯的 friction 列表。
4.3 Presence:cursor 协同的工程化#
PRESENCE.md 写 PresenceService 的几条规则我特别喜欢:
- 自己的 cursor 本地画,server 不回发。如果不这样,你画一下 → 传给 server → server 回发给你 → 你再画,延迟就翻倍。
- 远端 cursor 插值渲染。客户端 buffer 一段位置,渲染时往过去插值
interp_delay——用”延迟一点渲染”换平滑,网络抖动和丢一帧都看不出来。 - Latest wins。Cursor 是临时态,不重传、不保证可靠。Latest 直接覆盖。
- Rate 调过:
- client send:33 ms throttle,终位立刻发,hide/show 永远不限速
- server tick:50 ms(20 Hz),每个 cursor 单独 tick,10 ms 检查一次
- datagram 通道:33 ms(30 Hz),完整 keyframe 每 1 s 一次
- RosterHeartbeat:5 s
- shape probe:每 participant 100 ms,只在 cursor 离开上次 probe 区域时
cursor_batches 一个 tick 把所有”动了的 cursor”打包,减少 RPC 头开销。
这套设计是典型的”实时系统设计”——分通道、分速率、不保证可靠但保证新鲜。光读这一段 PRESENCE.md 我就觉得这个项目值回票价。
五、嵌入式安全设计:只减权,绝不加权#
cua-driver-core/src/lib.rs 顶部有一段很短的代码,讲嵌入式模式:
/// Embedded mode (`CUA_DRIVER_EMBEDDED=1` / `--embedded`): the daemon runs as
/// a direct child of a host app and stays in its TCC responsibility chain —
/// no disclaim re-exec, standalone-app relaunch, or permission prompts.
///
/// Caller-controlled, which is safe only because embedded mode strictly
/// REMOVES capability claims; it must never feed into the `driver-daemon`
/// attribution decision (`permission_source` in platform-macos).
pub const EMBEDDED_ENV: &str = "CUA_DRIVER_EMBEDDED";rust我读完愣了一下——注释里直接写”must never feed into”某个决策。这是非常强的安全断言:嵌入式模式只减少能力声明,不能用来增加。
为什么会这样?因为:
- standalone 模式:driver 是独立 app,OS 给它一个 TCC 责任链(Apple 的权限模型),能申请屏幕录制、辅助功能等敏感权限
- 嵌入式模式:driver 是宿主 app 的 child,借用宿主 app 的 TCC 责任链
如果嵌入式模式能被用来”借宿主 app 的权限去做更危险的事”,那 driver 就变成了权限放大器。所以嵌入式模式的语义必须是减法——去掉”disclaim 重启”、“独立 app 弹权限”等会破坏宿主责任链的能力,只保留”我就在宿主 app 里被托管”这一条。
类似的:PARENT_LIVENESS_STDIN_ENV 只在嵌入式模式 + 直接子进程时生效,含义是”stdin EOF 说明宿主死了,我也该退”。普通 MCP proxy 用 stdin 跑 JSON-RPC,永远不设置这个变量——文档里写”Requiring both sentinels prevents an ambient variable from changing ordinary standalone or MCP stdin behavior”。双 sentinel 防御环境变量被恶意设上。
启动期 CLI 还有个 --dangerously-bypass-approvals,注释里写:
The alarming CLI flag is both the unrestricted-mode selector and the user’s explicit launch-time risk acknowledgement.
故意叫”dangerous”,故意的 risk acknowledgement。不像别家产品叫什么 --yolo --trust-me-bro 然后真就这么干,这里是”name is the policy”——你看到 dangerous 就知道你在干嘛。
六、proto 契约:lint 强制每行注释#
libs/cua/proto/buf.yaml 写得很短:
lint:
use:
- STANDARD
# Every service, rpc, message, field, enum, enum value and oneof must carry
# a leading comment. This is a public contract; undocumented surface is a
# lint failure, not a review nit.
- COMMENTS
enum_zero_value_suffix: _UNSPECIFIED
service_suffix: Service
breaking:
use:
- FILE
- WIRE_JSONyamlproto 注释不是 review nit,是 lint 失败硬性门。每个 service、每个 rpc、每个 message、每个 field、每个 enum、每个 enum value、每个 oneof 都要带 leading comment。
breaking 那一块走 FILE + WIRE_JSON 两个 breaking detection 策略,意味着字段重命名、字段类型变更、rpc 签名变化都会被检测到。AGENTS.md 反复强调:“The .proto files in libs/cua/proto are the contract. Changes are additive and pass buf breaking.”
加上 enum_zero_value_suffix: _UNSPECIFIED 和 service_suffix: Service——零值必须有 UNSPECIFIED 后缀,service 必须以 Service 结尾。这两个是 protobuf 社区用工具名强迫你写出兼容代码的强制约束。
我看 proto/cua/daemon/v1/ 下面就 4 个文件,proto/cua/env/v1/ 下面 14 个文件。50+ RPC,每个 RPC 都有 contract 注释。
这套约束在我看过的项目里属于”想得最清楚”那一档——proto 一旦发布就是公开契约,改起来下游都痛,所以把约束放在工具层 lint 阶段,而不是 review 阶段。
七、Spaces + Teleport:产品层的工程#
代码层讲完了,最后讲产品层。apps/cua-spaces-macos/ 是 macOS app(SwiftUI 包装),核心功能 3 个:
7.1 Teleport#
把 host 上已登录的 app(Chrome、Slack 等)迁入 Space——app 状态保留(cookies、token、登录态),只是换成在 Space 里跑。
实现细节在 cua-spacesd-teleport/ 那个 crate,9 个文件:importers/ 装各家浏览器/应用的导入器,cookies.rs 导 Chrome 密码 cookies,firefox_items.rs 导 Firefox 登录项,keychain.rs 解 macOS Keychain,logins.rs 通用登录项提取,host.rs host 协议,receiver.rs 接收端,ledger.rs 记账(谁迁了什么、什么时候)。
Keyvault 是 host 端的加密存储——agent 没有 host 的 master key,没法自己读 cookies,必须通过 host app 手动 approve。这是典型的”把用户审核环节放在自动化最关键的安全门”。
7.2 Multiplayer#
多 cursor 协同——人和 agent 在同一桌面工作,各自有 cursor。背后是 PresenceService 那个 presence 协议。PRESENCE.md 里写的 33ms throttle、interpolation、RosterHeartbeat 5s 全部服务于这个场景。
7.3 Agent-ready images#
Spaces 的镜像里预装了 cua-spacesd(端口 3211)。这就省掉了”装好系统 → 装 daemon → 装好各种 agent 工具 → 测一遍”这条链,Spaces 创建出来就是 agent-ready。
八、原图集#
我读这个项目的时候存了几张原图,放在这里方便对照:
Spaces hero(README 主图)#

Driver demo(Calc + Inkscape)#

Lume#

Cua Bench#

实际跑起来(fleets 多机协同)#

Sandbox 创建#

全产品总览#

九、几句感想#
写到这里,我自己记一下几条 takeaway:
-
monorepo + 多 crate 不是炫技,是工程约束。
cua-driver15 个 crate 不是为了让”看上去专业”,是为了让”core 和 platform 不能互相污染”在 Cargo.toml 层面就是 impossible。platform crate 想反向依赖 core 行,想反向被 core 依赖,编译器不让你。 -
contract 单独装一个 crate,跨所有 binding。
cua-driver-contract6.7K 是整个项目的结构核心。client SDK 围着它转,binding 围着它转,driver 实现也围着它转。契约稳定,实现才能演化。 -
media plane 切走是对的。v1 那种”所有东西都在一个 socket 上”看着省事,实际控制命令和视频帧互掐。v2 切干净后,gRPC 走 HTTP/2 多路复用 + 可靠,media 走 latest-wins 不重传,各取所需。
-
嵌入式安全 = “只减权”是核心约束。不是简单地”加一个 sandbox 模式”,而是写进注释、写进
EMBEDDED_ENV语义、写进”must never feed into”的硬性断言。注释不是文档,是规约。 -
proto COMMENTS lint 是大项目才用得上的规约。小项目没必要(几个 RPC 手写注释就行),大项目缺它就乱——50+ RPC 几个月后你自己都忘了哪个是哪个。
buf breaking配COMMENTS配enum_zero_value_suffix是”工具即规范”的代表。 -
Cua-S1 的”System 1”是工程类比,不是学术分类。README 写:“It is not a strict classification of model architectures or a replacement for a general-purpose agent’s planning and reasoning.” —— 坦诚。这不是”我们用 Kahneman 框架重新发明了 AI”,是”我们做的是 form 评分,System 1 这个词只是好记”。我欣赏这种说法。
-
“Bring your own agent, Cua provides the computer” 是项目最重要的产品哲学。Cua 不卖 agent,不绑 Claude 或 GPT,只造电脑。这跟”做 agent 的公司”思路完全相反——后者会被”agent 不断变化”拖着改,前者只需要把”电脑”做稳。
参考#
- 仓库 github.com/trycua/cua ↗
- 文档 cua.ai/docs ↗
- 协议文档
libs/cua/proto/MEDIA.mdrcdp wire v2 - 协议文档
libs/cua/proto/PRESENCE.mdpresence / cursor - 工程规约
AGENTS.md(cross-platform contract、proto 是契约、libs/fleet 是只读) - buf lint 配置
libs/cua/proto/buf.yaml