青雲的博客

Article

用 Orca 拆开 ChatGPT Computer Use:从 node_repl 到 macOS 原生服务

· 43 分钟阅读

前言

ChatGPT 能打开 Mac 应用,读窗口里的按钮和文本,再点、输、滚、拖。产品名是 Computer Use。我想弄清的是本机接线:模型侧工具从哪来,element_index 怎么变成原生 UI 动作,截图和 Accessibility Tree 谁采,为什么动作后必须再读一遍界面。

分析主要靠 Orca。我把 ChatGPT.app 和 computer-use@openai-bundled 插件目录丢给它,盯着几个问题取证:插件怎么注册加载;JS API 怎么进原生进程;Client 与 Service 用什么协议;应用授权、系统权限、动作确认各在哪一层;哪些能直接确认,哪些只能推断。

Orca 不只搜文件,还把 manifest、JS 入口、类型声明、签名、Unix Socket、动态库依赖和二进制字符串串成一条链,再用本机 SDK / 编译器交叉看 API availability。逆向最容易被零散字符串带跑,交叉验证比单独捞到某个符号有用。

分析范围

本机版本:

组件版本
ChatGPT.app26.707.72221,Build 5307
computer-use 插件1.0.1000387
Codex Computer Use.app26.710.1000387
@oai/sky0.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 对 computer-use 插件目录、调用链和模型侧 API 的阶段性分析

Orca 对插件目录、模型侧 API 和 NodeREPL -> Unix Socket -> 原生服务调用链的阶段性整理。

证据分三档

后面结论按可靠度分开写:

  • 直接验证:manifest、JavaScript、类型声明、签名、文件系统或可复现命令
  • 高可信推断:多个二进制符号、动态库依赖和调用关系互相支撑,但没有原始 Swift 源码
  • 尚未确认:只靠安装包答不了,需要动态调试、抓包或更完整反编译

看到 ScreenCaptureKit 依赖,只能确认原生服务链了相关实现,不能凭一个字符串还原完整截图算法。telemetry 事件定义同样推不出每次运行实际发出的完整网络载荷。

插件本身很薄

plugin.json 定位很直白:控制本地 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_MCPNODE_REPL 两种 runtime 标识——只看 .mcp.json 会漏掉一半。

@oai/sky:模型参数到 OS 请求

README 就一句话:Model-facing computer use API。它不在 JS 里直接调 Accessibility API,而是把模型好写的参数,变成当前 OS 能执行的请求。

入口的 sky 是惰性 Proxy。import 时不建 Client;第一次读 sky.clicksky.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类型模型观察范围传输方式
macwindow目标应用当前窗口Unix Socket + 原生 macOS Service
windowswindow2显式绑定的某个窗口子进程 stdin/stdout
linuxfull-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;包装层转成原生的 clickCount / elementIndex / mouseButton。命名转换中间还插了应用策略:

sky.click({ app, element_index })

withComputerUsePolicy("click", input, callback)

MacComputerUseClient.click({ app, elementIndex })

ComputerUseIPCAppPerformActionRequest

list_apps 不指定目标应用,因此不需要应用批准。其余九个方法都会先进入 withComputerUsePolicy()

应用策略在动作编码之前执行

withComputerUsePolicy() 做的事情比弹出一个确认框多:

  1. 检查 input 必须是普通对象,并且 app 必须是非空的数据属性;
  2. 拒绝 getter、setter 等访问器属性,再创建不可写、不可配置的冻结副本;
  3. 调用 ComputerUseIPCAppPolicyRequest,让原生服务解析真实应用;
  4. 处理 alloweddeniedforbidden 三种策略结果;
  5. 通过 nodeRepl.createElicitation 请求本次用户批准或读取已持久化批准;
  6. 用原生服务返回的规范化 appPath 替换模型传入的 app;
  7. 记录 tool call,并在 nodeRepl.withSuspendedTimeout 中执行真正动作。

进原生 Client 的对象已冻结;目标应用也不再是模型随手写的字符串,而是策略服务解析后的路径——避免批准查的是 A、执行换成 B。

批准时还会 nodeRepl.setResponseMeta 写上当前 app 的 bundle identifier,让宿主标记这次响应属于 Computer Use。用户批完后,真正动作跑在 withSuspendedTimeout 回调里;超时怎么调由 node_repl 实现,@oai/sky 包内没有更多定义。

这段 JS 能证明应用级批准在代码路径里。删文件、发消息这类动作级确认,仍归 SKILL.md 语义规则。

参数如何变成原生 action

策略通过后,九种目标应用操作被压缩成两类业务请求和一个 action union:

模型方法原生 request type核心 payload
get_app_stateComputerUseIPCAppGetSkyshotRequestappdisableDiff
clickComputerUseIPCAppPerformActionRequestclick
dragComputerUseIPCAppPerformActionRequestdrag
press_keyComputerUseIPCAppPerformActionRequestpressKey
scrollComputerUseIPCAppPerformActionRequestscroll
set_valueComputerUseIPCAppPerformActionRequestsetValue
select_textComputerUseIPCAppPerformActionRequestselectText
type_textComputerUseIPCAppPerformActionRequesttype
perform_secondary_actionComputerUseIPCAppPerformActionRequestperformSecondaryAction

另外两个请求用于发现与授权:ComputerUseIPCListAppsRequestComputerUseIPCAppPolicyRequest。Client 类型里还存在 ComputerUseIPCAppStartRequest,但当前十个模型 API 没有直接暴露对应方法。

元素点击的核心业务数据类似下面这样:

{
  "app": "/Applications/Notes.app",
  "action": {
    "click": {
      "at": { "elementID": { "_0": "42" } },
      "clickCount": 1,
      "mouseButton": 0
    }
  }
}

没有 element_index 时,at 改成窗口坐标 [x, y]。发送前 JS 会查:坐标有限、element_index 为整数、滚动页数 > 0,左右中键归一成 0/1/2undefined 字段递归删掉,不进 JSON。

模型侧的 element_index 最终变成字符串 elementID。JS 不按编号找 AX 节点,只传标识;查找和执行都在原生服务。

get_app_state 还会整理返回结果

原生服务先返回包含 skyshot、应用元数据和可选 appSpecificInstructions 的内部结果。window_result.js 再把它压缩成模型看到的公开结构:

type AppState = {
  app: string
  screenshot: { url: string } | null
  text: string
}

这层会严查 skyshot、截图 URL 和 AX 文本类型。应用专用操作说明只在某个 Client 实例第一次访问该 app 时,以 <app_specific_instructions> 前缀塞进 text;之后只回界面内容,少占上下文。

当前 macOS 上 screenshot.url 是本地 file://,要由 node_repl 读出再当图片交给模型——图片字节不直接塞进 Socket 的 JSON。

text 是原生服务生成的 AX 表达。二进制字符串能确认它维护:

  • fullTree
  • diffFromPrevious
  • accessibilityInspectorPayload
  • screenshotNeededForContext

模型指南要求每个 assistant turn 至少先 get_app_state 一次,动作后再读状态,别继续用旧 element_index。默认尽量回相对上次 AX Tree 的 diff;要完整上下文才传 disableDiff: true。编号会随界面变失效,重观察能少点错目标;完整树可能很大,diff 省上下文。下面进原生二进制,看编号、修订和 diff 实际维护到哪。

原生服务怎么被拉起来

macOS transport 依赖 globalThis.nodeRepl 的受信任能力。普通 Node 就算装了 @oai/sky,也没有 nodeRepl.nativePipe / launchServices / createElicitation / withSuspendedTimeout,复刻不了 ChatGPT.app 里的完整环境。

首次请求大致几步:

  1. 在 250ms 的快速窗口内尝试连接默认 Socket;
  2. 连接失败后,优先通过 NODE_REPL_HOST_SERVICES_PIPE_PATH 请求宿主执行 ensureService("computer-use")
  3. 没有 host-services pipe 时,调用 nodeRepl.launchServices.openApplication
  4. 服务路径依次考虑 SKY_CUA_SERVICE_PATH$CODEX_HOME/computer-use/Codex Computer Use.app,最后使用 bundle id com.openai.sky.CUAService
  5. 服务拉起后,在 5 秒窗口内重新连接;
  6. 连接成功立即发送 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 协议可读

发布包里的 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" }
  }
}

连接后还有 ping。响应必须有数字 id、jsonrpc: "2.0",且 result / error 二选一,否则当传输错误。

帧实现要点:

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 可覆盖路径

最后一个环境变量也是动态分析入口:受控环境里把 Client 指到协议记录器或假服务,就能看真实请求、造边界响应,不必先改签过名的 macOS app。

顺带改掉我旧稿的一处错:原来写 256KB,native-pipe.js 实际是 8 MiB。静态二进制里往往只能看到 maximumFrameSize 这个名字。

错误码也是协议的一部分

errors.js 有原生错误码到语义名的完整映射。关键几组:

范围错误名
身份协议senderProcessNotAuthenticatedincompatibleClientVersion
应用目标appNotAllowedrunningApplicationNotFoundinvalidAppambiguousApp
权限会话permissionsNotGrantedpermissionsPendingnoActiveSession
安全状态blockedURLuserIntervenedscreenLockeduserStoppedSession
AX 执行accessibilityErrorunhandledEventunknownError

原生层不只有「点击失败」一种说法,会区分发送进程身份、API 版本、TCC、应用解析、浏览器 URL、用户介入、锁屏。JS 把 JSON-RPC error 转成 SkyComputerUseError(保留数值 code 和 errorName);连接失败、帧非法、超时另走 SkyComputerUseTransportError

原生服务能干啥

Codex Computer Use.app 独立签名,带 hardened runtime 和 notarization ticket,最低 macOS 14.4,主进程 SkyComputerUseServiceotool -L 能看到它链了:

  • ApplicationServices
  • AppKit
  • CoreGraphics
  • ScreenCaptureKit
  • ScriptingBridge
  • Network
  • Security
  • WebKit

符号和字符串还能看出大致能力:

  • AX 元素遍历与交互
  • ScreenCaptureKit 截图
  • 窗口焦点、前台应用、窗口顺序
  • 鼠标键盘事件投递
  • AX Tree diff
  • 应用启动与权限状态
  • app-specific instructions
  • 锁屏处理
  • telemetry / Statsig

主程序没完全剥掉 Swift 符号。nm + swift-demangle 能恢复不少类型名、方法签名和字段关系,方便继续拆 AX Tree、编号修订和动作执行。符号证明对象和入口存在,替代不了被优化掉的方法体。

Orca 使用 strings 与 swift-demangle 恢复 Computer Use IPC 和 Skysight 类型

Orca 通过 stringsswift-demangle 恢复 IPC 类型、截图链和 Skysight 子系统;这些符号用于界定能直接确认与仍需动态验证的部分。

AX Tree:遍历、裁剪、序列化

观察链入口是 SystemSelectionExtractor.extract(...),吃 FocusedUIElementContext(前台应用、窗口、焦点元素、ApplicationWindowCGWindow),再构造 UIElementTree,递归加节点时维护 IndexPath

不是无条件展开整棵树。二进制里能看到明确的裁剪相关方法:

childrenPreferringVisible
visibleChildrenIfChildrenExceedsThreshold
visibleElementsCalculatingSubset
allChildrenFilteringVisibleElementsByPosition
partialArrayValue(for:maxCount:)

裁剪流程大致是:

  1. 从前台应用和目标窗口建根,单独留焦点上下文
  2. DFS 读子元素
  3. 子节点超阈值时优先用 AX 的 visible children
  4. 还要裁就按屏幕位置选可见元素,并在上/下文保留有限邻近节点
  5. AXTruncationDescriptor 记原始总数和 availableRange,节点上还有 truncationRange——模型看到的不是假装完整的数组
  6. AX 元素转 TransformedUIElement,再生成模型可读的行文本

转换层读 role、subrole、title、description、value、placeholder、help、identifier、URL、DOM class、actions、selection、focus、enabled、disclosure、attributed string 等。lmReadableDescription 出最终描述;URL 总长过大时 shortenURLsIfNeeded(totalLength:) 统一缩短。焦点被裁掉时,日志会写 “likely pruned from the tree”。

两个常量还没恢复:默认最大子节点数、邻近元素保留数。符号能证明策略形状,写不出具体阈值。

element_index:树修订 ID,不是永久节点 ID

渲染树用 setElementIDs() / nextAvailableElementIDIterator() 分整数 ID。每个渲染节点带着底层 AX 身份、可选 elementIDsourcePath。管生命周期的是 UIElementTreeRevision

UIElementTreeRevision
├── lineageID
├── previousRevision
├── ids: UIElementTreeTable<Int>
├── changes
├── tree / renderTree
└── focusTree / focusRenderTree

第一次观察建 root revision;之后用 appending(tree:focusTree:context:) 挂到同一 lineage,并保存上一修订和 changes。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_textpress_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,取决于元素能力和 flag,没有永远固定的顺序。

坐标输入最终走 CoreGraphics:CGEventAPI.createKeyboardEventpostToPidSynthesizedEvent.mouseEvent / scroll / moveMouse。没找到 IOHID/uinput 一类 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”。flag 关时方法直接把 screenshotNeededForContextfalse;开着才把图交给 SkyshotClassifier。观察入口还有 alwaysIncludeScreenshot,捕获层有 includeScreenshot / skipScreenshot——「采不采图」和「图是不是理解上下文所需」是两件事。

包里没有独立模型文件,SkyshotClassifier 核心方法也被优化成匿名函数。证据只能写到:分类器判断 Skyshot 是否含图像内容并打布尔;特性关则标记为 false。 像素统计、视觉模型还是区域规则,「含图像」精确定义,符号里都拿不到。

接口也容易误读:内部 attachment 存这个布尔,公开 IPC 的 ComputerUseIPCSkyshot 只有 text 和可选 screenshot,JS 返回也只有截图 URL。模型侧 API 看不到 screenshotNeededForContext,别当成模型每次主动传回的判断。

AX diff:匹配与体积

diff 吃旧/新 UIElementRenderTree,产出 UIElementRenderDifference.Change(带 pathoffset,可 inheritElementID()),buffer DFS 排序后再渲成行。

公开文本标记能从二进制字符串直接确认:

~ changed
+ added
- removed

axTreeDiffingRemovedElementIDRanges 开着时,删除节点不逐棵出 subtree,压成 Removed element IDs: ... 的连续 ID 范围。渲染层还有四个独立选项:藏 element ID、藏 detail text、藏 insertion subtree、藏 removal subtree。baseline 可以是 previous 或 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。两个渲染节点「相同 / 修改 / 替换」的全部字段比较还没恢复;下面把 source path 当唯一匹配键。

应用授权、系统权限、动作确认是三件事

应用授权

需要目标 app 的方法都先走 withComputerUsePolicy()。JS Client 问原生 app policy,结果三种:

allowed
denied
forbidden

应用允许用,Client 仍可能 nodeRepl.createElicitation 要用户批。范围可 session,部分 app 还能持久成 always。policy request、组织策略拒绝、安全禁止、用户批准,代码路径都在。

系统权限

原生服务要 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 能不能碰这个 app
  • 系统权限:原生服务能不能读屏、操 UI
  • 动作确认:这一步要不要用户再点一次

前两层在可读 JS 里有路径。动作级语义主要靠模型按技能规则执行。安全强度这里不评,那要专门对抗测试。

LockScreenGuardian:fail-closed 状态机

包里有独立 CUALockScreenGuardian.app。主服务里是 LockScreenAutoUnlockCoordinatorLockScreenGuardianCoordinator,经 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 结束只放自己的
  • 每个 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 上限和竞态仍要受控动态实验。

遥测:JS 一条,原生一条

@oai/sky 依赖 @statsig/js-clientcomputer-use-telemetry.js 里能直接看到:

CodexComputerUseMcpServerLaunched
CodexComputerUseMcpAppApprovalRequested
CodexComputerUseMcpAppApprovalResolved
CodexComputerUseMcpToolCalled

事件名还带着 Mcp,当前 JS 写的 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 自定义属性还有 Codex App 版本和 OS 平台。初始化成功后会请求 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

这段模块没有把截图、AX 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 / bundle / signing ID、executable;CodexComputerUseTelemetryContext 还会从 turn metadata 存 model、reasoning effort、session/turn ID、turn 开始时间、turn 内是否要过用户输入。

本机痕迹还能往前一步:

  • com.openai.sky.CUAService.plist 已有 Statsig stable ID 和本地配置缓存 → 原生 Statsig 至少初始化过
  • App Group 下 Analytics.dbdistinct_id、alias、待发送 event 三张表
  • 检查时 distinct ID 1 条,待发送 event 0 条
  • 当时运行中的服务没有可见活跃 TCP

队列空可能是从没报,也可能报完清了。代码里有传输和上传成功日志,是否到服务端还看账号配置。

产品文案里的「截图能否用于训练」、账号数据控制、Computer Use 事件遥测是三件事。可读 JS 只看到两个环境变量开关,没看到读账号训练设置;原生是否接宿主下发的账号级 telemetry policy,静态证据也没闭环。没切账号、没做 TLS 抓包,所以既不能说「所有账号默认全量上报」,也不能说「关训练就关上述事件」。

截图、页面文本、模型请求的边界

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 走 textwindow_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 JS Client、Linux arm64/x64 原生二进制、Windows codex-computer-use.exe。差异压缩成表:

维度macOS windowWindows window2Linux 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 elicitationhelper approval request + node_repl elicitationJavaScript 层未看到应用策略

统一入口只负责选对 Client;各平台仍按自己的窗口系统观察和控制。

Linux:全桌面 CLI

每次操作跑 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_screenshotclickdragmovepress_keyscrolltype_text。截图先回路径,JS 再读 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 写出。没有可用 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

启动 codex-computer-use.exe --parent-pid <pid>,stdin/stdout 一行一条 JSON。多个 Client 可共享同一 transport,引用计数归零才关 helper。

window2 不只传应用名。模型先从 list_apps / list_windows 拿窗口对象,后续每个动作都带同一个 { app, id }

type Window = {
  app: string
  id: number
  title?: string
}

get_window_state 可分别控制是否读截图和 AX。结果里有结构化 AX,以及多张带 id / 尺寸 / 屏幕原点 / z-index 的截图,用来表达目标窗和它上面的临时 UI。坐标点、滚、拖还能带 screenshotId,让 helper 核对动作是否还对应缓存画面。

应用批准也走协议:helper 可回 approvalRequest,JS 调 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 后续请求直接拒。

二进制是未 strip 的 Rust PE,内部路径和错误分支能拆出三套状态机。

窗口快照缓存。 get_window_state 生成 snapshotRevision / accessibilityRevision,缓存 process/app ID、root/input HWND、bounds、AX snapshot 和若干 capture session;每张图有独立 screenshotId。坐标点/拖/滚带上 ID 后,helper 按序查:

  1. 目标窗是否仍对应缓存的 app、process、HWND
  2. 当前 bounds 是否与捕获时一致
  3. screenshotId 是否在该窗 capture cache 里
  4. 上次 snapshot 后是否检测到用户输入

任一失败都要求 call get_window_state before continuing,不会把旧坐标甩到新窗上。元素动作从最新 AX cache 解析 element_index;元素没了、可用 pattern 变了或 cached secondary actions 没了都会失败。诊断还公开 accessibilitySnapshotCountcaptureCachedSessionCountlastCaptureInvalidationReason

URL policy。 helper 认 Edge / Chrome / Brave / Opera / IE / Firefox,读当前 browser URL 后调 /backend-api/aura/site_status。状态至少:allow、明确 blocked、验证失败、无法高置信读 URL、浏览器不支持策略。除 allow 外都停当前 turn,文案不同。fail-closed:用户自己导航到 blocked URL 也不会让 Computer Use 继续。

用户介入。 每次 guarded input 前后,helper 听目标窗真实输入,并重核 foreground 与 bounds。普通输入 → 本次动作作废并要求重观察;物理 Escape → turn interrupted:helper 退出码 130,JS 写 $CODEX_HOME/cache/computer-use/interrupts/<conversation>/<turn>,同一 turn 即使重启 helper 也立刻拒。只有进新 turn 才清内存阻断;旧 turn 磁盘标记仍是持久停证。

截图走 Windows Graphics Capture + D3D11,并缓存 capture session;AX 用 UI Automation cache request;键鼠最终 SendInput。静态分支给出守卫顺序,但这台机器没有 Windows 运行环境,事件竞态、浏览器 URL 提取准确率、缓存淘汰时间都没法验。

所以:computer-use@openai-bundled@oai/sky 在 macOS 上的装配方式;@oai/sky 本身是跨平台适配层——让模型面对不同桌面时仍能走受控 API。

局限性

材料主要是安装包、可读 JS、签名、本机痕迹、二进制静态信息。下面几类还不够:

  • AX 等价判断的全部字段、树裁剪阈值、截图分类器算法、部分 feature flag 分支
  • ChatGPT 宿主最终如何把 AX 文本、截图引用、neededForContext 编进模型请求
  • 不同账号 / 数据控制下 telemetry 的实际请求、载荷和服务端响应
  • LockScreenGuardian 并发竞态,以及 Linux / Windows 动态时序和兼容性

要继续,得有受控 AX 测试应用、隔离账号、TLS 代理,以及三平台可重复环境。

收束

栈可以缩成:

模型操作指南

node_repl / MCP 前端

@oai/sky 参数与策略层

长度前缀 JSON-RPC + Unix Socket

签名后的 macOS 原生服务

Accessibility + 截图 + 输入事件

get_app_state 把当前应用收成模型能吃的状态;AX 给语义目标,截图补视觉,diff 压上下文;应用授权 + TCC 划本机权限。动作后再观察,尽量让模型基于新状态继续,而不是抱着过期 element_index 点。

用 Orca 做这次拆解,最有用的是逼自己标证据档:manifest 能证明什么、JS 能证明什么、签名能证明什么、二进制字符串只能撑到哪。最后留下的是一张还能继续挖的系统图,不是一份「已经全看懂了」的结论。

版本基线: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_appsobserveclicktype_textpress_keyscroll 六个工具;Codex、Claude Code、Orca 和其他 MCP Client 共用同一套服务。

codex plugin marketplace add echoVic/blade-computer-use
codex plugin add blade-computer-use@blade-computer-use

Codex 中的 Blade Computer Use 插件详情页,包含 MCP Server、Computer Use 技能与 0.1.1 版本信息

Codex 可以从 marketplace 识别并安装 Blade Computer Use 0.1.1,插件页同时列出 MCP Server 和 Computer Use 技能。

实现两层:TypeScript MCP Server 做参数校验、app allowlist、一次性 revision 和截图响应;常驻 Swift helper 做 AX tree、ScreenCaptureKit 窗截图、CGEvent 输入。observe 给出的 revision 只能执行一个动作,之后必须再观察。远程包默认只允许 TextEdit;首次启动在本机编原生 helper。第一次 observe 会要 Accessibility 和 Screen Recording,用户授权后重试即可。

Blade Computer Use 在 TextEdit 中完成真实文本输入测试

真实 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

相关文章

评论