Article
用 Orca 拆开 ChatGPT Computer Use:从 node_repl 到 macOS 原生服务
前言
ChatGPT 可以打开一个 Mac 应用,读出窗口里的按钮和文本,再完成点击、输入、滚动和拖拽。这套能力在产品里叫 Computer Use。
我更关心它在本机是怎么接起来的:模型看到的工具从哪里来,element_index 如何变成原生 UI 操作,截图和 Accessibility Tree 由谁采集,为什么动作完成后必须重新读取界面。
这次分析是用 Orca 完成的。我把 ChatGPT.app 安装包和 computer-use@openai-bundled 插件目录交给 Orca,让它围绕几个明确问题收集证据:
- 插件如何注册和加载;
- JavaScript API 如何进入原生进程;
- Client 与 Service 之间使用什么协议;
- 应用授权、系统权限和动作确认分别位于哪一层;
- 哪些结论能从源码和运行环境直接确认,哪些只能停留在推断。
Orca 的工作包括搜索文件,也包括把 manifest、JavaScript 入口、类型声明、签名信息、Unix Socket、动态库依赖和二进制字符串串成同一条证据链,再用本机 SDK 和编译器交叉检查 API availability。逆向分析很容易被零散字符串带偏,这种交叉验证比单独找到某个符号更重要。
分析范围
本文分析的本机版本为:
| 组件 | 版本 |
|---|---|
| ChatGPT.app | 26.707.72221,Build 5307 |
computer-use 插件 | 1.0.1000387 |
| Codex Computer Use.app | 26.710.1000387 |
@oai/sky | 0.4.20 |
主要取证位置:
/Applications/ChatGPT.app/Contents/Resources/
├── plugins/openai-bundled/plugins/computer-use/
│ ├── .codex-plugin/
│ ├── .mcp.json
│ ├── scripts/computer-use-client.mjs
│ ├── skills/computer-use/SKILL.md
│ └── Codex Computer Use.app
└── cua_node/lib/node_modules/@oai/sky/
另外检查了实际运行后创建的目录:
~/Library/Group Containers/
└── 2DC432GLL2.com.openai.sky.CUAService/
└── IPC/
├── computeruse.sock
└── computeruse.sock.lock
本文只讨论安装包中可以直接读取的文件、签名元数据、本机运行痕迹和二进制静态信息。没有注入运行中进程,也没有做网络抓包。

Orca 对插件目录、模型侧 API 和 NodeREPL -> Unix Socket -> 原生服务调用链的阶段性整理。
先划清证据边界
后面的结论分成三类:
- 直接验证:来自 manifest、JavaScript、类型声明、签名信息、文件系统或可复现命令;
- 高可信推断:有多个二进制符号、动态库依赖和调用关系相互支持,但没有原始 Swift 源码;
- 尚未确认:仅凭安装包无法回答,需要动态调试、网络抓包或更完整的反编译。
看到 ScreenCaptureKit 依赖,可以确认原生服务具备相关实现;却不能仅凭一个字符串还原完整截图算法。看到 telemetry 事件定义,也不能直接推导每次运行时实际发送的完整网络载荷。
插件本身很薄
plugin.json 给出的定位很直接:Computer Use 可以控制本地 Mac 应用,包括浏览器和用户允许访问的文件;工作时可能读取截图或页面内容。
插件目录里真正可读的代码不多:
computer-use/
├── .codex-plugin/
│ ├── plugin.json
│ └── computer-use-node-repl.md
├── .mcp.json
├── scripts/
│ └── computer-use-client.mjs
├── skills/computer-use/
│ └── SKILL.md
└── Codex Computer Use.app/
它像一个装配层:
plugin.json描述插件、技能和界面信息;SKILL.md告诉模型何时使用 Computer Use,以及高风险动作的确认规则;computer-use-client.mjs把@oai/sky加载进持久的 Node REPL;.mcp.json提供另一条 MCP 入口;Codex Computer Use.app承担 macOS 原生能力和系统权限。
真实逻辑分别落在 @oai/sky 和签名后的原生应用里。
两条入口同时存在
一开始只看 .mcp.json,很容易把整个系统理解成普通 MCP 插件:
{
"mcpServers": {
"computer-use": {
"command": "./Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient",
"args": ["mcp"],
"cwd": "."
}
}
}
这条路径确实存在:SkyComputerUseClient mcp 可以作为 stdio MCP Server 启动。
但当前插件给模型的操作指南使用的是另一条路径。computer-use-client.mjs 从 ChatGPT 自带的 cua_node 运行时加载 @oai/sky,然后把一个冻结后的 sky 对象挂到持久 node_repl:
const sky = Object.freeze(createClient({ target: 'mac' }))
globalThis.sky = sky
因此这套插件至少有两个前端:
flowchart LR
A["Agent"] --> B["node_repl"]
B --> C["computer-use-client.mjs"]
C --> D["@oai/sky"]
A2["MCP Host"] --> E["SkyComputerUseClient mcp"]
D --> F["Native Pipe"]
E --> F
F --> G["SkyComputerUseService"]
G --> H["macOS Apps"]
当前模型侧主要使用 node_repl + sky。MCP Client 仍在插件包里,并且二进制中同时出现了 LEGACY_MCP 与 NODE_REPL 两种 runtime 标识。这解释了为什么只看 .mcp.json 会漏掉一半结构。
@oai/sky 是模型与操作系统之间的兼容层
包内 README 对自己的定义只有一句:Model-facing computer use API。@oai/sky 不负责在 JavaScript 里直接调用 Accessibility API,它负责把模型容易生成的参数变成当前操作系统能够执行的请求。
入口导出的 sky 是一个惰性 Proxy。导入包时不会创建 Client,第一次读取 sky.click、sky.get_app_state 等属性时,才根据配置选择平台实现:
switch (target) {
case 'mac':
return createMacClient()
case 'windows':
return createWindowsClient()
case 'linux':
return createLinuxClient(options)
}
默认 target 来自 process.platform。如果设置了 OAI_SKY_CONFIG_PATH,它会同步读取指定的 JSON 文件,并用其中的 target 和平台参数创建 Client。README 还特别说明,模型生成的代码不应该自行选择 target;平台决定权留在宿主运行时。
包里其实有三套不同的 API:
| target | 类型模型 | 观察范围 | 传输方式 |
|---|---|---|---|
mac | window | 目标应用当前窗口 | Unix Socket + 原生 macOS Service |
windows | window2 | 显式绑定的某个窗口 | 子进程 stdin/stdout |
linux | full-desktop | 整个桌面和显示器截图 | 每次调用一个 Linux CLI |
它们共享 sky 这个入口,却没有强行伪装成完全一致的能力。macOS 用应用标识定位窗口,Windows 把 { app, id } 窗口对象放进每个动作,Linux 则主要依赖整张桌面截图和坐标。
macOS 的十个模型 API
@oai/[email protected] 的 macOS Client 公开十个 snake_case 方法:
| 方法 | 用途 |
|---|---|
list_apps | 列出可使用的应用 |
get_app_state | 获取目标应用的 Accessibility 文本和截图引用 |
click | 按元素编号或窗口坐标点击 |
type_text | 向当前焦点输入文本 |
press_key | 发送按键或组合键 |
scroll | 在指定元素上滚动 |
drag | 在两个窗口坐标之间拖拽 |
set_value | 设置可写 Accessibility 元素的值 |
select_text | 选择文本或放置光标 |
perform_secondary_action | 调用元素暴露的辅助 Accessibility 动作 |
这些函数不是原生 Client 的直接导出。以 click 为例,模型调用的参数使用 click_count、element_index 和 mouse_button;包装层把它们转换成原生 Client 使用的 clickCount、elementIndex 和 mouseButton。这个看似普通的命名转换中间还插入了应用策略。
sky.click({ app, element_index })
↓
withComputerUsePolicy("click", input, callback)
↓
MacComputerUseClient.click({ app, elementIndex })
↓
ComputerUseIPCAppPerformActionRequest
list_apps 不指定目标应用,因此不需要应用批准。其余九个方法都会先进入 withComputerUsePolicy()。
应用策略在动作编码之前执行
withComputerUsePolicy() 做的事情比弹出一个确认框多:
- 检查 input 必须是普通对象,并且
app必须是非空的数据属性; - 拒绝 getter、setter 等访问器属性,再创建不可写、不可配置的冻结副本;
- 调用
ComputerUseIPCAppPolicyRequest,让原生服务解析真实应用; - 处理
allowed、denied、forbidden三种策略结果; - 通过
nodeRepl.createElicitation请求本次用户批准或读取已持久化批准; - 用原生服务返回的规范化
appPath替换模型传入的 app; - 记录 tool call,并在
nodeRepl.withSuspendedTimeout中执行真正动作。
进入原生 Client 的对象已经被冻结,目标应用也不再只是模型写下的字符串,而是策略服务解析后的路径。这样可以避免批准时检查一个应用、实际执行时又换成另一个字符串。
批准过程还会通过 nodeRepl.setResponseMeta 写入当前应用的 bundle identifier,供宿主标记这次响应属于 Computer Use。用户批准完成后,Computer Use 调用在 withSuspendedTimeout 回调中执行。这个 API 如何调整宿主超时由 node_repl 实现,@oai/sky 包内没有更多定义。
这段 JavaScript 能证明应用级批准位于代码路径中。删除文件、发送消息等动作级确认仍由 SKILL.md 的语义规则负责。
参数如何变成原生 action
策略通过后,九种目标应用操作被压缩成两类业务请求和一个 action union:
| 模型方法 | 原生 request type | 核心 payload |
|---|---|---|
get_app_state | ComputerUseIPCAppGetSkyshotRequest | app、disableDiff |
click | ComputerUseIPCAppPerformActionRequest | click |
drag | ComputerUseIPCAppPerformActionRequest | drag |
press_key | ComputerUseIPCAppPerformActionRequest | pressKey |
scroll | ComputerUseIPCAppPerformActionRequest | scroll |
set_value | ComputerUseIPCAppPerformActionRequest | setValue |
select_text | ComputerUseIPCAppPerformActionRequest | selectText |
type_text | ComputerUseIPCAppPerformActionRequest | type |
perform_secondary_action | ComputerUseIPCAppPerformActionRequest | performSecondaryAction |
另外两个请求用于发现与授权:ComputerUseIPCListAppsRequest 和 ComputerUseIPCAppPolicyRequest。Client 类型里还存在 ComputerUseIPCAppStartRequest,但当前十个模型 API 没有直接暴露对应方法。
元素点击的核心业务数据类似下面这样:
{
"app": "/Applications/Notes.app",
"action": {
"click": {
"at": { "elementID": { "_0": "42" } },
"clickCount": 1,
"mouseButton": 0
}
}
}
如果没有 element_index,at 会改成窗口坐标 [x, y]。JavaScript 在发送前会检查坐标必须是有限数字、element_index 必须是整数、滚动页数必须大于 0,并把左右中键归一化成 0、1、2。undefined 字段会递归删除,避免它们进入 JSON。
模型看到的 element_index 最终被转换成字符串 elementID。JavaScript 不负责根据编号寻找 AX 节点;它只保留这个标识,查找和动作执行都发生在原生服务。
get_app_state 还会整理返回结果
原生服务先返回包含 skyshot、应用元数据和可选 appSpecificInstructions 的内部结果。window_result.js 再把它压缩成模型看到的公开结构:
type AppState = {
app: string
screenshot: { url: string } | null
text: string
}
这层会严格检查 skyshot、截图 URL 和 Accessibility 文本的类型。应用专用操作说明只会在一个 Client 实例中第一次访问该应用时加到 text 前面,格式为 <app_specific_instructions>。后续状态只返回界面内容,减少重复上下文。
其中 screenshot.url 在当前 macOS 环境里是本地 file:// URL,需要由 node_repl 读取并作为图片内容交给模型。图片字节没有直接塞进 Socket 的 JSON 字符串。
text 是原生服务生成的 Accessibility 表达。二进制字符串可以确认它维护:
fullTreediffFromPreviousaccessibilityInspectorPayloadscreenshotNeededForContext
模型指南明确要求每个 assistant turn 至少先调用一次 get_app_state,动作完成后再次读取状态,不要继续使用旧的 element_index。默认状态会尽量返回相对上一次 Accessibility Tree 的 diff;只有需要完整上下文时,才传入 disableDiff: true。
UI 元素编号会随界面变化失效,重新观察可以减少对旧目标的误操作。完整 Tree 可能很大,diff 则能降低模型上下文消耗。后面会继续追进原生二进制,说明编号、修订和 diff 实际维护到了什么程度。
原生服务是怎样被拉起来的
macOS transport 依赖 globalThis.nodeRepl 提供的受信任能力。普通 Node.js 进程即使安装了 @oai/sky,也没有 nodeRepl.nativePipe、launchServices、createElicitation 和 withSuspendedTimeout,不能直接复刻 ChatGPT.app 中的完整执行环境。
首次请求会经历下面几步:
- 在 250ms 的快速窗口内尝试连接默认 Socket;
- 连接失败后,优先通过
NODE_REPL_HOST_SERVICES_PIPE_PATH请求宿主执行ensureService("computer-use"); - 没有 host-services pipe 时,调用
nodeRepl.launchServices.openApplication; - 服务路径依次考虑
SKY_CUA_SERVICE_PATH、$CODEX_HOME/computer-use/Codex Computer Use.app,最后使用 bundle idcom.openai.sky.CUAService; - 服务拉起后,在 5 秒窗口内重新连接;
- 连接成功立即发送
ping,确认双方都使用CodexComputerUseIPC-2。
这条启动链说明 @oai/sky 只是 Client,真正持有 TCC 权限的是独立原生应用。宿主负责找到并启动它,Client 再通过本地 Socket 使用它。
一次调用如何进入原生服务
以 get_app_state 为例,完整调用顺序如下:
sequenceDiagram
participant Agent
participant REPL as node_repl
participant Sky as @oai/sky wrapper
participant Client as MacComputerUseClient
participant Pipe as MacNativePipeTransport
participant Service as SkyComputerUseService
Agent->>REPL: sky.get_app_state({ app })
REPL->>Sky: withComputerUsePolicy()
Sky->>Client: getAppPolicy(app)
Client->>Service: ComputerUseIPCAppPolicyRequest
Service-->>Sky: decision + canonical target
Sky->>REPL: createElicitation()
REPL-->>Sky: accept / decline / persisted state
Sky->>Client: getAppState({ appPath, disableDiff })
Client->>Pipe: request(requestType, payload)
Pipe->>Service: length-prefixed JSON-RPC
Service-->>Pipe: skyshot + app metadata
Pipe-->>Sky: decoded result
Sky-->>Agent: { app, screenshot, text }
每个业务请求带独立 id、deadline、Client API 版本和可选的 Codex turn metadata。同一个 Client 的请求被串到一条 Promise 链上,前一个结束后才会发送下一个。这里没有并发操作同一桌面的语义,顺序执行也能减少多个输入动作互相干扰。
IPC 协议可以直接读出来
@oai/sky 发布包保留了 native-pipe.js,帧编码和解码都是可读代码:
┌──────────────────────┬───────────────────────────────┐
│ 4 bytes LE u32 │ N bytes UTF-8 JSON │
│ payload length │ JSON-RPC 2.0 message │
└──────────────────────┴───────────────────────────────┘
JSON-RPC 外层大致是:
{
"id": 7,
"jsonrpc": "2.0",
"method": "request",
"params": {
"clientApiVersion": "CodexComputerUseIPC-2",
"deadlineUnixMilliseconds": 1780000000000,
"codexTurnMetadata": {},
"requestType": "ComputerUseIPCAppGetSkyshotRequest",
"request": { "app": "/Applications/Notes.app" }
}
}
另一种 method 是连接后使用的 ping。响应必须同时包含数字 id、jsonrpc: "2.0",并且只能在 result 和 error 中二选一,否则 Client 会把它视为传输错误。
关键帧实现可以概括为:
const MAX_FRAME_SIZE = 8_388_608
const length = buffer.readUInt32LE(offset)
header.writeUInt32LE(payload.length, 0)
因此可以直接确认:
- 长度头为 4 字节小端无符号整数;
- 帧体是 UTF-8 JSON;
- 消息遵循 JSON-RPC 2.0;
- 单帧最大值是 8 MiB;
- 默认 Socket 位于 App Group 的
IPC/computeruse.sock; SKY_CUA_NATIVE_PIPE_PATH可以覆盖 Socket 路径。
最后这个环境变量也提供了动态分析入口:在受控环境中把 Client 指向协议记录器或假服务,就能观察真实请求并构造边界响应,而不需要先修改签名后的 macOS 应用。
这里也修正了我最初分析中的一个错误:旧稿写成了 256KB,native-pipe.js 给出的实际最大帧是 8 MiB。静态二进制本身只能看到 maximumFrameSize 这个名称。
错误码也是协议的一部分
errors.js 保存了原生服务错误码到语义名称的完整映射。比较关键的几组是:
| 范围 | 错误名 |
|---|---|
| 身份协议 | senderProcessNotAuthenticated、incompatibleClientVersion |
| 应用目标 | appNotAllowed、runningApplicationNotFound、invalidApp、ambiguousApp |
| 权限会话 | permissionsNotGranted、permissionsPending、noActiveSession |
| 安全状态 | blockedURL、userIntervened、screenLocked、userStoppedSession |
| AX 执行 | accessibilityError、unhandledEvent、unknownError |
这些名称说明原生层不只返回“点击失败”。它区分发送进程身份、API 版本、TCC 权限、应用解析、浏览器 URL、用户介入和锁屏状态。JavaScript 会把 JSON-RPC error 转成 SkyComputerUseError,同时保留数值 code 和可读的 errorName;连接失败、帧非法和超时则使用独立的 SkyComputerUseTransportError。
原生服务承担了哪些能力
Codex Computer Use.app 是一个独立签名、带 hardened runtime 和 notarization ticket 的 macOS 应用,最低系统版本为 macOS 14.4。主进程名是 SkyComputerUseService。
通过 otool -L 可以看到它链接了:
ApplicationServicesAppKitCoreGraphicsScreenCaptureKitScriptingBridgeNetworkSecurityWebKit
二进制符号与字符串进一步表明它包含:
- Accessibility 元素遍历与交互;
- ScreenCaptureKit 截图;
- 窗口焦点、前台应用和窗口顺序观察;
- 鼠标键盘事件投递;
- Accessibility Tree diff;
- 应用启动与权限状态处理;
- app-specific instructions;
- 锁屏状态处理;
- telemetry 和 Statsig 接入。
主程序并未完全去掉 Swift 符号。nm 和 swift-demangle 可以恢复大量类型名、方法签名和字段关系,因此能继续把 AX Tree、编号修订和动作执行拆得更细。不过,符号能证明对象和调用入口存在,不能替代被优化掉的方法体。

Orca 通过 strings 与 swift-demangle 恢复 IPC 类型、截图链和 Skysight 子系统;这些符号用于界定能直接确认与仍需动态验证的部分。
AX Tree 如何遍历、裁剪和序列化
原生观察链的入口是 SystemSelectionExtractor.extract(...)。它接收 FocusedUIElementContext,其中同时保存前台应用、窗口、焦点元素、ApplicationWindow 和对应的 CGWindow。随后构造 UIElementTree,并在递归添加节点时维护 IndexPath。
这不是无条件展开整棵 AX Tree。二进制保留了下面几组明确的方法:
childrenPreferringVisible
visibleChildrenIfChildrenExceedsThreshold
visibleElementsCalculatingSubset
allChildrenFilteringVisibleElementsByPosition
partialArrayValue(for:maxCount:)
可以据此还原出裁剪流程:
- 从前台应用和目标窗口建立根节点,并单独保留焦点上下文;
- 深度优先读取子元素;
- 子节点数量超过阈值时,优先使用 AX 提供的 visible children;
- 如果仍需裁剪,则按屏幕位置选择可见元素,并在上、下文保留有限数量的邻近节点;
- 用
AXTruncationDescriptor保存原始总数和availableRange,节点上另有truncationRange,因此模型看到的不是一个假装完整的数组; - 将 AX 元素转换为
TransformedUIElement,再生成适合模型读取的行文本。
转换层会读取 role、subrole、title、description、value、placeholder、help、identifier、URL、DOM class、可执行 actions、selection、focus、enabled、disclosure 和 attributed string 等属性。lmReadableDescription 负责产生最终描述;当整棵树中的 URL 总长度过大时,shortenURLsIfNeeded(totalLength:) 还会统一缩短 URL。焦点元素如果被裁剪掉,日志会明确报告它“likely pruned from the tree”。
这里仍有两个未恢复的常量:默认最大子节点数和邻近元素保留数量。符号证明了裁剪策略的形状,却不足以写出每个阈值的数值。
element_index 是树修订 ID,不是永久节点 ID
渲染树通过 setElementIDs() 和 nextAvailableElementIDIterator() 分配整数 ID。每个渲染节点同时保存底层 AX 对象身份、可选 elementID 和 sourcePath。真正维持生命周期的是 UIElementTreeRevision:
UIElementTreeRevision
├── lineageID
├── previousRevision
├── ids: UIElementTreeTable<Int>
├── changes
├── tree / renderTree
└── focusTree / focusRenderTree
第一次观察创建 root revision;后续观察通过 appending(tree:focusTree:context:) 追加到同一 lineage,并把上一修订和变化保存下来。UIElementTreeTable 同时支持 AX 元素、整数 ID 和 IndexPath 之间的反向查询。这就是 diff 可以继承旧编号、动作又能从编号找回 AX 元素的基础。
动作执行前还有一层 RefetchableSkyshotAXTree。它监听两类失效信号:
- 整体布局发生变化:
layoutChanged; - 某个 AX 对象被销毁:
destroyedElements: Set<AXUIElementRef>。
如果原对象仍有效,就继续使用;如果失效,则重新抓取 Tree,再寻找“等价元素”。只有旧树和新树中的候选都唯一时才替换成功。找不到候选,或任一侧出现多个等价候选,都会返回“重新读取屏幕内容”的错误,而不是猜一个节点继续点击。
因此 element_index 的准确含义是:某条 AX Tree 修订 lineage 中,由渲染层分配、可在后续修订中继承,并在动作前接受失效校验的短期节点 ID。 二进制没有留下“等价元素”谓词的完整实现,尚不能断言它具体比较 title、role、value、路径和 bounds 中的哪些字段。把它称为永久 DOM ID 或简单数组下标都不准确。
动作执行不是统一的 AX → 坐标 → HID 回退
原生方法签名显示,元素动作和坐标动作从入口开始就是两条路径:
| 动作 | 已确认的主要路径 | 没有证据支持的说法 |
|---|---|---|
| 元素点击 | refetch ID → 定位/滚动元素 → UIElementProtocol.click(...) | 所有控件都固定先 AXPress,失败再点中心 |
| 坐标点击、拖拽 | SynthesizedEvent.click → 向目标进程投递 CGEvent | 先寻找 AX 元素 |
| secondary action | 将 action 名映射为 UIElementAction,调用 AX perform | 自动退回坐标点击 |
set_value | 对 refetch 后的元素调用 AX setValue | 失败后模拟键盘输入 |
select_text | 依赖可写的 selected text range | 失败后鼠标拖选 |
type_text、press_key | 找到应用键盘事件目标,再合成键盘事件 | 通过 AXValue 写入所有文本 |
| 滚动 | 元素级 page action 与合成 scroll event 两套能力都存在 | 每种元素都使用相同顺序 |
点击是最特殊的一类。UIElementProtocol.click 的签名同时接收 alwaysSimulateClick、目标 window/app、focus enforcer 和 virtual cursor;功能开关 computerUseAlwaysSimulateClick 的描述是“Prefer simulating physical clicks over Accessibility actions”。这说明语义 AX click 与合成鼠标 click 的选择会受元素能力和 feature flag 影响,而不是一个永远不变的顺序。
坐标输入最终使用的是 CoreGraphics 事件:二进制中可以看到 CGEventAPI.createKeyboardEvent、postToPid、SynthesizedEvent.mouseEvent、scroll 和 moveMouse。没有找到 IOHID/uinput 一类 macOS HID 注入依赖。因此本文把它称为 CGEvent 合成输入,不写成“HID 最终兜底”。
这个动作表也解释了为什么不同 API 的失败行为不同。set_value 和 select_text 依赖 AX 属性可写;坐标点击不需要节点编号;元素点击则会先验证旧编号并尝试重新定位。不能用一个统一的三段式 fallback 概括所有动作。
screenshot 何时是 neededForContext
原生 SystemSelection 和内部 ComputerUseSkyshotAttachment 都有 screenshotNeededForContext: Bool。判定入口是:
SystemSelection.updateScreenshotNeededUsingClassifier(screenshot: CGImage)
它受 feature/skyshotClassifier 控制,功能描述明确写着“determine if Skyshot contains image or not”。反汇编还能确认:该 feature flag 关闭时,方法直接把 screenshotNeededForContext 设为 false;开启时才把截图交给 SkyshotClassifier。观察入口另有 alwaysIncludeScreenshot,捕获层也有 includeScreenshot 和 skipScreenshot,说明“是否采集截图”和“截图是否是理解上下文所需”是两个不同判断。
安装包里没有独立模型文件,SkyshotClassifier 的核心方法也被优化为匿名函数。当前证据只能把规则写到这里:分类器判断 Skyshot 是否包含图像内容,并用布尔值标记;特性关闭时该标记为 false。 具体用了像素统计、视觉模型还是区域规则,以及“包含图像”的精确定义,都无法从现有符号确认。
还有一个容易误读的接口边界:内部 attachment 保存这个布尔值,但公开 IPC 的 ComputerUseIPCSkyshot 只有 text 和可选 screenshot,JavaScript 返回值也只有截图 URL。也就是说,模型侧 API 看不到 screenshotNeededForContext 字段,不能把它理解成模型每次主动传回的判断结果。
AX diff 如何匹配和控制体积
diff 层以旧、新 UIElementRenderTree 为输入,生成 UIElementRenderDifference.Change。每个 change 保存 path 和 offset,可以 inheritElementID(),随后由 buffer 做 depth-first 排序,再渲染成行。
公开文本中的标记规则可以直接从二进制字符串确认:
~ changed
+ added
- removed
当 axTreeDiffingRemovedElementIDRanges 开启时,被删除的节点不会逐棵输出 subtree,而是压缩成 Removed element IDs: ... 的连续 ID 范围。渲染层还有四个独立选项:隐藏 element ID、隐藏 detail text、隐藏 insertion subtree、隐藏 removal subtree。baseline 既可以是 previous revision,也可以是 initial revision,所以系统同时支持“相对上次”和“相对最初”的累计 diff。
体积控制不是一个固定 token 数,而是拿完整树的行数当预算。日志中有两条明确的回退分支:
Skip AX tree diff render because removed element summary exceeded full-tree line budget
Skip AX tree diff render because difference exceeded full-tree line budget
删除摘要或完整 diff 只要比 full tree 还长,就放弃 diff,直接使用完整树。调用方还能通过 ignoreDifferenceLineBudget 绕过这层预算。
能够确认的是修订、路径、ID 继承、变更排序、删除范围压缩和 full-tree budget。UIElementRenderDifference 内部如何判定两个渲染节点“相同、修改或替换”的全部字段比较仍未恢复;文章不把 source path 当作唯一匹配键。
应用授权、系统权限和动作确认是三件事
应用授权
每个需要目标应用的方法都会先进入 withComputerUsePolicy()。JavaScript Client 向原生服务请求 app policy,服务返回三种 decision:
allowed
denied
forbidden
如果应用允许使用,Client 仍可能通过 nodeRepl.createElicitation 请求用户批准。批准范围支持 session,部分应用还允许持久化为 always。
应用级访问控制有明确的代码路径,包括 policy request、组织策略拒绝、安全禁止和用户批准流程。
系统权限
原生服务需要 macOS Accessibility 和 Screen Recording 权限。它们属于 TCC 授权,不是靠关闭代码签名保护获得的。
我用 codesign -d --entitlements :- 读取当前官方 app,实际签名 entitlements 只有:
com.apple.application-identifier
com.apple.developer.team-identifier
com.apple.security.application-groups
keychain-access-groups
没有 disable-library-validation,也没有 disable-executable-page-protection。应用通过稳定的签名身份、App Group 和 TCC 完成授权与进程协作。
动作确认
删除数据、发送消息、安装软件、金融交易等高风险动作的分类规则写在 SKILL.md 中。模型需要根据动作语义,在真正产生影响之前请求确认。
这层和应用授权解决的问题不同:
- 应用授权回答“Computer Use 能否访问这个应用”;
- 系统权限回答“原生服务能否读取屏幕和操作 UI”;
- 动作确认回答“当前这一步是否需要用户再次确认”。
从可读 JavaScript 能确认前两层存在代码路径。动作级语义判断主要由模型按照技能规则执行。本文不进一步评价它的安全强度,因为那需要专门的对抗测试。
LockScreenGuardian 的 fail-closed 状态机
插件包中包含独立的 CUALockScreenGuardian.app。主服务内部则有 LockScreenAutoUnlockCoordinator 和 LockScreenGuardianCoordinator,两边通过 XPC 与 Mach bootstrap rendezvous 协作。状态不是简单的 locked/unlocked 二值,而是围绕 Codex thread 维护 active、pending 和 auto-unlocked 三组集合。
根据方法签名和完整日志,可以还原出下面的状态机:
正常自动解锁流程
flowchart TB
U["Unlocked"] -->|"系统进入锁屏"| L["LockedEligible"]
L -->|"prepareForRequest(threadID)"| G["GuardPending"]
G -->|"guard 生效,解锁通过"| A["AutoUnlocked"]
G -.->|"失败或预算耗尽"| LF["LockedEligible<br/>保持锁定"]
A -->|"全部 lease 结束"| R["RelockSettling"]
R -->|"锁屏界面稳定"| LE["LockedEligible<br/>重新锁定"]
用户介入与故障恢复
flowchart TB
A["AutoUnlocked"] -->|"物理输入或 Guardian 断连"| F["立即重锁"]
F --> S["Suppressed"]
S -->|"用户手动解锁"| U["Unlocked"]
U -->|"后续锁屏 episode"| L["LockedEligible"]
AutoUnlocked 中为其他 thread 继续保留 lease 不会改变状态,因此不再画成自循环。解锁尝试预算、overlay 等待 lock UI settled,以及 authorization attempt 的 consumed、revoked 和 timeout,继续放在正文中说明。
关键转换有直接日志支持:
prepareForRequest(threadID:)注册 active thread;如果屏幕已锁且允许自动解锁,进入 guarded unlock;beginUnlockGuard先把 thread 放入 pending 集合,完成后由completeUnlockGuard(threadID:didUnlock:)移出;- 成功解锁后按 thread 保留 auto-unlocked lease;多个 thread 可以同时持有,某个 turn 结束只释放自己的 lease;
- 每次 locked episode 记录尝试次数,达到
maxAttemptsPerLockedEpisode后停止继续尝试; - 自动解锁期间检测到真实键盘或鼠标输入,立即锁回桌面,并把自动解锁置为 suppressed;
- Guardian 或主服务连接中断同样立即重锁,属于 fail-closed;
- 重锁过程中 overlay 会保持可见,直到 lock UI settled;即使系统迟迟没有报告 locked,也会在 fallback 时隐藏 overlay,避免把用户永久困在遮罩后;
- suppression 的用户提示明确要求“手动解锁后再继续”,说明自动重试不会自行解除这个状态。
实际解锁并不是读取用户密码。SystemLockScreenController 先通过 AX 定位 loginwindow 的密码字段或“显示密码输入框”的提示,把密码字段设为空,再通过本地 authorization plug-in 消费一次授权 attempt,最后合成 Return 键并检查系统是否仍处于 locked。attempt 支持 consumed、revoked 和 timeout,Socket 位于:
/tmp/com.openai.sky.CUAService/LockScreenLoginAuthorization.sock
这次没有主动锁屏、切换用户或模拟 Guardian 崩溃,因此上面是由符号和日志闭环得到的静态状态机。各 delay、attempt 上限和 race condition 仍需要受控动态实验。
遥测有 JavaScript 和原生两套路径
@oai/sky 依赖 @statsig/js-client,computer-use-telemetry.js 中能直接看到几类事件:
CodexComputerUseMcpServerLaunched
CodexComputerUseMcpAppApprovalRequested
CodexComputerUseMcpAppApprovalResolved
CodexComputerUseMcpToolCalled
虽然事件名沿用了 Mcp,当前 JavaScript 写入的 runtime 是 CODEX_COMPUTER_USE_MCP_RUNTIME_NODE_REPL。这与插件同时保留 MCP 和 node_repl 两条入口的历史结构一致。
代码中可以直接确认的事件参数包括:
| 事件 | 参数 |
|---|---|
| Client 创建 | transport: "stdio" |
| 请求应用批准 | bundle identifier、tool name、创建时间 |
| 完成应用批准 | bundle identifier、tool name、批准结果、持久化范围 |
| 调用工具 | tool name、bundle identifier、model、reasoning effort |
model 和 reasoning effort 来自 x-codex-turn-metadata。Statsig Client 的自定义属性还包括 Codex App 版本和操作系统平台。初始化成功后,代码会请求 https://chatgpt.com/backend-api/me;如果响应包含用户 id 和 email,就用它们更新 Statsig user。
网络端点配置为 https://ab.chatgpt.com/v1,请求通过受信任运行时提供的 nodeRepl.fetch 发出。以下任一环境变量为 1 时,这个模块会跳过初始化和事件记录:
NODE_REPL_DISABLE_ANALYTICS
BROWSER_USE_DISABLE_AMBIENT_NETWORK
这段模块没有把截图、Accessibility Tree 或输入文本传给 logEvent()。它能证明 tool、app、approval、model 和用户标识相关字段的发送逻辑存在。
原生服务还有另一套 EventLogger。Swift protobuf 类型表明它会记录 app startup、started/ended、idle timeout、权限请求与完成、权限窗口显示、MCP/Node REPL 生命周期,以及 IPC request failure。失败事件可包含 request type、error code 和发送进程的 team ID、bundle ID、signing ID、executable;CodexComputerUseTelemetryContext 还会从 turn metadata 保存 model、reasoning effort、session ID、turn ID、turn 开始时间和 turn 内是否请求过用户输入。
本机运行痕迹能再往前确认一步:
com.openai.sky.CUAService.plist已有 Statsig stable ID 和本地配置缓存,说明原生 Statsig 至少初始化过;- App Group 下的
Analytics.db包含distinct_id、alias 和待发送 event 三张表; - 检查时 distinct ID 为 1 条,待发送 event 为 0 条;
- 运行中的服务当时没有可见的活跃 TCP 连接。
待发送队列为空,可能是从未上报,也可能是事件已经发送并清理。代码中存在网络传输和上传成功日志,但事件是否到达服务端还取决于账号配置。
产品文案中的“是否允许截图用于训练”、ChatGPT 账号的数据控制选项、Computer Use 事件遥测是三件不同的事。可读 JavaScript 只发现两个环境变量开关,没有发现它读取账号训练设置;原生服务是否接收了宿主下发的账号级 telemetry policy,静态证据也没有闭环。本文没有切换账号设置或做 TLS 抓包,因此不能声称“所有账号默认全量上报”,也不能声称关闭训练就会关闭上述事件。
截图、页面文本和模型请求的数据边界
把 Computer Use telemetry 和模型上下文分开后,数据流会清楚很多:
flowchart LR
A["AXUIElement"] --> B["Transformed AX text"]
C["ScreenCaptureKit"] --> D["local screenshot file"]
B --> E["ComputerUseIPCSkyshot"]
D --> F["file URL"]
F --> E
E --> G["@oai/sky window_result"]
G --> H["node_repl tool result"]
H --> I["ChatGPT host request assembly"]
I --> J["model context"]
K["tool/app/model metadata"] --> L["Statsig / native EventLogger"]
macOS 原生服务把截图写成本地文件,并在 JSON-RPC 中返回 URL;AX 内容以 text 字段返回。window_result.js 只做类型检查、加一次 app-specific instructions,再返回 { app, screenshot: { url }, text }。它没有调用 emitImage,也没有在 @oai/sky 内构造模型 API 的 input_image。
因此可以确认到的边界是:截图 URL 和 AX 文本进入了 node_repl 工具结果,随后由 ChatGPT 宿主组装进模型上下文。模型请求的具体 multipart/JSON 格式、图片是否重编码、是否根据 screenshotNeededForContext 省略图片,不在插件或 @oai/sky 的可读代码里。
Windows 路径更直接:get_window_state 收到 data URL 后会显式调用 nodeRepl.emitImage,兼容分支构造 { type: "input_image", image_url, detail: "original" }。这说明三平台连“如何把截图交给宿主”也不完全相同。
原生包里的 Skysight event stream 和 memory summarizer 属于另一套被动活动记录能力。要得到模型请求的最后一跳,仍需在隔离账号和代理环境中做网络观测。
三个平台不是同一套实现
@oai/sky 发布包同时带有 macOS JavaScript Client、Linux arm64/x64 原生二进制和 Windows codex-computer-use.exe。三端的主要差异可以压缩成下面这张表:
| 维度 | macOS window | Windows window2 | Linux full-desktop |
|---|---|---|---|
| 目标 | 应用标识 | { app, id } 窗口对象 | 整个桌面 |
| 观察 | 截图 + AX 文本,可返回 diff | 可选截图、可选结构化 AX,一次可返回多张图 | 显示器截图数组 |
| 元素动作 | 支持 element_index | 支持 element_index,同时校验窗口身份 | 没有 AX 元素 API |
| 坐标动作 | 应用窗口坐标 | 窗口坐标,可绑定 screenshot id | 桌面坐标 |
| transport | 长度前缀 JSON-RPC + Unix Socket | 换行分隔 JSON + 长驻 helper | 每个操作启动一次 CLI |
| 应用批准 | 原生 policy + node_repl elicitation | helper approval request + node_repl elicitation | JavaScript 层未看到应用策略 |
统一入口只负责选择正确 Client。每个平台仍保留符合自身窗口系统的观察与控制方式。
Linux:全桌面 CLI
Linux Client 每次操作都会执行 sky_linux_${process.arch},也可以通过 OAI_SKY_LINUX_BIN 覆盖二进制路径。命令名作为参数传入,具体 input 和 options 以 JSON 写到 stdin:
sky_linux_x64 click < JSON input
sky_linux_x64 get_screenshot < JSON input
它公开 get_screenshot、click、drag、move、press_key、scroll 和 type_text。截图命令先返回文件路径,JavaScript 再读取 JPEG 字节,同时生成 Uint8Array、本地路径和 data URL。每个输入动作结束后默认等待 100ms,让桌面完成处理和重绘;这个值可以通过 post_action_sleep_ms 调整。
包内两个 Linux 文件分别是 x86-64 和 arm64 ELF PIE,动态链接且没有 strip。Rust 符号、源码路径和动态依赖把实现限定得很清楚:当前版本是 X11-only,不是 Wayland portal 实现。
截图路径会连接 $DISPLAY,同时检查 XAUTHORITY 和 /tmp/.X11-unix/X*。它读取 X11 屏幕像素,通过 XFixes GetCursorImage 取得真实光标,再叠加包内的 mouse overlay,最后用 Rust image/JPEG encoder 写出 JPEG。找不到可用 X server 时,错误会明确提示设置 DISPLAY=:0 和检查 XAUTHORITY。
输入路径主要由两套机制组成:
- 鼠标移动、点击、拖拽和滚动使用 XTEST
FakeInput;拖拽和移动还有move_path,不是只发送起点和终点; - 键盘通过 Xlib 的
XKeysymToKeycode和 press/release 事件实现; type_text使用临时 X11 clipboard,模拟粘贴后恢复原剪贴板,而不是逐字符寻找所有键位。
从 ELF 的 NEEDED 可以看到它只动态依赖 libX11.so.6、libc、libm 和 libgcc;X11RB、XFixes、XTEST、图片编码和 clipboard 代码被静态编进二进制。本文所在的 Mac 无法执行 ELF,且没有连接 Linux 图形会话,所以这里是静态实现确认,不是端到端运行验证。
Windows:显式窗口与长驻 helper
Windows Client 启动 codex-computer-use.exe --parent-pid <pid>,用一行一条 JSON 的 stdin/stdout 协议保持通信。多个 Client 可以共享同一个 transport,引用计数归零时才关闭 helper。
window2 不再只传应用名。模型先从 list_apps 或 list_windows 得到窗口对象,后续每个动作都携带同一个 { app, id }:
type Window = {
app: string
id: number
title?: string
}
get_window_state 可以分别控制是否读取截图和 Accessibility。结果包含结构化 AX 状态以及多张有 id、尺寸、屏幕原点和 z-index 的截图,用来表达目标窗口及其上方的临时 UI。坐标点击、滚动和拖拽还能携带 screenshotId,让 helper 检查动作是否仍然对应缓存过的画面。
应用批准也由协议闭环。helper 可以返回 approvalRequest,JavaScript 调用 createElicitation;用户接受后,同一请求会附加 x-oai-cua-approved-app 再发送一次,批准结果无须依赖模型自行记忆。
Windows transport 还把一次 Codex turn 当作明确生命周期:turn 变化时先发送 end_turn,请求携带预算和 conversation/turn metadata。如果用户按下物理 Escape,helper 以退出码 130 终止,并在 $CODEX_HOME/cache/computer-use/interrupts/ 写入 turn 标记;同一 turn 后续请求会直接拒绝。
Windows 二进制是未 strip 的 Rust PE,内部路径和错误分支可以把三个状态机拆开。
窗口快照缓存。 get_window_state 生成 snapshotRevision 和 accessibilityRevision,缓存窗口的 process/app ID、root/input HWND、bounds、AX snapshot 和若干 capture session。每张截图还有独立 screenshotId。坐标点击、拖拽或滚动带上这个 ID 后,helper 会按下面的顺序检查:
- 目标窗口是否仍对应缓存的 app、process 和 HWND;
- 窗口当前 bounds 是否与捕获时一致;
screenshotId是否存在于这个窗口的 capture cache;- 自上次 snapshot 后是否检测到用户输入。
任一条件失败都会要求 call get_window_state before continuing,而不是把旧坐标应用到新窗口。元素动作同样从最新 AX cache 解析 element_index;元素不再存在、可用 pattern 改变或 cached secondary actions 消失时会失败。诊断结果还公开 accessibilitySnapshotCount、captureCachedSessionCount 和 lastCaptureInvalidationReason。
URL policy。 helper 识别 Edge、Chrome、Brave、Opera、Internet Explorer 和 Firefox 进程,读取当前 browser URL 后调用 /backend-api/aura/site_status。状态至少分为 allow、明确 blocked、验证失败、无法高置信读取 URL、当前浏览器不支持策略四类。除 allow 外都终止当前 turn,并返回不同的停止文案。这里采用 fail-closed:用户自己导航到 blocked URL 也不会让 Computer Use 继续。
用户介入。 每次 guarded input 前后,helper 监听目标窗口的真实用户输入,并重新核对 foreground window 与 bounds。检测到普通输入时,本次动作失效并要求重新观察;检测到物理 Escape 时状态升级为 turn interrupted:helper 退出码为 130,JavaScript 在 $CODEX_HOME/cache/computer-use/interrupts/<conversation>/<turn> 写标记,同一 turn 即使重启 helper 也会立即拒绝。只有进入新 turn 才清除此内存阻断;旧 turn 的磁盘标记仍是持久停止证据。
截图实现使用 Windows Graphics Capture 和 D3D11,并缓存 capture session;Accessibility 使用 UI Automation cache request;键鼠输入最终调用 SendInput。这些静态分支已经给出完整的守卫顺序,但本文没有 Windows 运行环境,无法验证事件竞争、浏览器 URL 提取准确率和缓存淘汰时间。
因此,当前 computer-use@openai-bundled 是 @oai/sky 的 macOS 装配方式;@oai/sky 自身则是一个让模型面对不同桌面环境时仍能使用受控 API 的跨平台适配层。
局限性
本文主要依据安装包、可读 JavaScript、签名信息、本机运行痕迹和二进制静态信息,仍有几类问题无法仅靠这些材料确认:
- AX 节点等价判断的全部字段、树裁剪阈值、截图分类器算法和部分 feature flag 分支;
- ChatGPT 宿主最终如何把 AX 文本、截图引用和
neededForContext编入模型请求; - 不同账号和数据控制设置下,telemetry 的实际请求、载荷与服务端响应;
- LockScreenGuardian 的并发竞争,以及 Linux、Windows 实现的动态时序和兼容性。
这些问题需要受控 AX 测试应用、隔离账号、TLS 代理和三个平台的可重复运行环境继续验证。
总结
ChatGPT Computer Use 由几层职责明确的能力组合起来:
模型操作指南
↓
node_repl / MCP 前端
↓
@oai/sky 参数与策略层
↓
长度前缀 JSON-RPC + Unix Socket
↓
签名后的 macOS 原生服务
↓
Accessibility + 截图 + 输入事件
get_app_state 把当前应用整理成模型能消费的状态,Accessibility Tree 提供语义目标,截图补充视觉信息,diff 控制上下文大小,应用授权与 TCC 建立本地权限边界。动作之后重新观察,则让模型尽量基于新状态继续操作。
用 Orca 做这次逆向,最有价值的地方是它能持续追问证据来自哪里:manifest 能证明什么,JavaScript 能证明什么,签名信息能证明什么,二进制字符串又只能支持到哪一步。最终产物是一张可以继续验证的系统地图。
本文结论基于 ChatGPT.app 26.707.72221、[email protected]、Codex Computer Use.app 26.710.1000387 和 @oai/[email protected]。版本更新后,目录、协议和行为都可能变化。
动手复刻:blade-computer-use
为了验证这套最小闭环,我写了一个可以直接安装的 macOS 版本:blade-computer-use。它通过标准 stdio MCP 暴露 list_apps、observe、click、type_text、press_key 和 scroll 六个工具,Codex、Claude Code、Orca 以及其他 MCP Client 共用同一套服务。
codex plugin marketplace add echoVic/blade-computer-use
codex plugin add blade-computer-use@blade-computer-use

Codex 可以从 marketplace 识别并安装 Blade Computer Use 0.1.1,插件页同时列出 MCP Server 和 Computer Use 技能。
实现分为两层:TypeScript MCP Server 负责参数校验、应用 allowlist、一次性 revision 和截图响应;常驻 Swift helper 负责 AX tree、ScreenCaptureKit 窗口截图和 CGEvent 输入。observe 产生的 revision 只能执行一个动作,动作之后必须重新观察。远程安装包默认只允许 TextEdit,首次启动会在本机编译原生 helper。第一次调用 observe 时,helper 会请求 Accessibility 和 Screen Recording 权限,用户授权后再重试即可。

真实 TextEdit 测试会执行 observe → type_text → 拒绝复用旧 revision → observe,并从新的 AX Tree 中核对输入结果。
仓库同时提供 .claude-plugin/plugin.json 和 Orca 的 [[mcp_servers]] 配置示例。当前版本只支持 macOS,没有实现 AX diff、跨帧节点身份、LockScreenGuardian、URL policy 和物理用户介入检测,也没有 telemetry。它是根据本文分析写出的独立 MVP,不是 OpenAI 私有代码的复刻,也不具备官方 Computer Use 的完整安全模型。
Keep Reading