
私の環境には Codex の Computer Use プラグインを入れてあり、しばらく使ってみたところ、App の操作はこれまで見たものの中でもかなり安定していました。クリックは正確で、スクロールも適切、空振りもほとんどありません。そこで 2 つの考えが同時に浮かびました。なぜここまで安定しているのか。そして、ほかの agent、たとえば毎日起動している Claude Code でもこれを使えないか。そこで分解してみたところ、最終的に接続もできました。
解析の過程は、基本的にはシンボルと文字列を静的に読み、読みきれない部分だけ逆アセンブルを見る、というものです。実際にやったことは 3 つあります。公式の Codex を一度本当に動かすこと、それを理解しながら再現版を書くこと、そして最後に Claude Code へ接続することです。以下ではこの順番で、Computer Use がどう動くのか、OpenAI の署名だけを認める門がどう他者を止めているのか、そして私がどうその門を通過して Claude Code に接続したのかを説明します。
まず結論:これは「画像を見て座標をクリック」ではない
世の中の computer use の多くは、この流れです:スクリーンショット → モデルが画像を見る → ピクセル座標を出力する → クリックする。純粋な視覚ベースの方式の欠点も明白です。座標はずれるし、小さなコントロールは正確にクリックしにくいし、スクロールすると無効になり、画像は token も食います。
Codex は別の道を取っています:アクセシビリティツリー(Accessibility Tree)を優先し、スクリーンショットは最後の保険。その主要ツールの自己説明の文字列には、かなり率直にこう書かれています。
必要ならアプリ使用セッションを開始し、そのうえでアプリのキーウィンドウの状態を取得し、スクリーンショットとアクセシビリティツリーを返す。アプリを操作する前に、assistant の各ターンで一度これは呼ばれなければならない。
毎ターン、手を動かす前に、まず一度状態を取り、スクリーンショットとアクセシビリティツリーを同時に得ます。構造化されたコントロールがあればツリー内の番号でクリックする(正確、安定、安い)。どうしてもなければ、ピクセル指定のクリックへ戻る(汎用)。この「二本足」の設計こそ、体感のよさの根っこです。
二つのプロセス、Sky というコードネーム
これはスクリプトではなく、ネイティブな macOS アプリ一式です。arm64 Swift で、最低 macOS 14.4。.app を開いてみると、二つの実行ファイルが見えます。
SkyComputerUseClient(11MB):MCP フロントエンド。Codex はこれを通じてツールを公開し、会話ターンに合わせて立ち上がったり落ちたりする、短命なプロセス。SkyComputerUseService(17MB):常駐エンジン。スクリーンショット、ツリーの走査、入力の注入を実際に担う。
両者の間は XPC + Mach port で通信します(転送クラスは ComputerUseIPCXPCTransport、チャネル名は CodexComputerUseIPC-1)。また、同じ App Group を共有して生存状態を保ちます。さらに小さな App が二つ付属しています。ひとつは自動化の間にロック画面を防ぐもの(CUALockScreenGuardian)、もうひとつは権限付与を案内するものです。
内部コードネームは Sky。スクリーンショットにツリーを加えたそのスナップショットを Skyshot、観察録画の機能を Skysight、入力抽象のプレフィックスを SAI と呼んでいます。
なぜ二つのプロセスに分けるのか? ターンとターンの間で状態を生かしておく必要があるからです。あのツリー、イベントストリーム、権限認可はいずれも service 側に残しておく必要があり、フロントエンドはプロトコル変換だけを行います。これこそが、後で「増分 diff」が成り立つ前提でもあります。

Skyshot:同一フレームの中に、1枚のスクリーンショットと1本のツリー
Skyshot は、この仕組み全体の中で最も真似する価値がある点です。
スクリーンショット側は ScreenCaptureKit を使い、取得したら JPEG に圧縮し、かなり小さくします。実際に TextEdit ウィンドウを撮らせてみると、返ってきた画像は 586×488、23.5KB でした。小さい画像は token を節約できますが、本当の情報の担い手は画像ではなく、あのツリーです。
ツリー側は、システムのアクセシビリティ API で辿れる実際のコントロールを、モデルが読めるテキストへ投影したものです。そのクラス名はそのまま LMReadableElement(LM = Language Model)です。スクリーンショットはモデルに「ちらっと見て確認」させるだけで、どこをクリックするか、どの値を読むかは、すべてツリーに依存しています。
モデルに見せるあのツリーはどんな形か
言葉だけでは伝わらないので、TextEdit ウィンドウの状態を一度取らせて、実際に返ってきたものを抜き出しました(一部):
Computer Use state (CUA App Version: 857)
<app_state>
App=/System/Applications/TextEdit.app/ (bundleID com.apple.TextEdit, pid 71221)
Window: "Untitled", App: TextEdit.
0 standard window Untitled, Secondary Actions: Raise
1 scroll area Secondary Actions: Scroll Left, Scroll Right, Scroll Up, Scroll Down
2 text entry area (settable, string) Value: <本文>, ID: First Text View
21 container
22 checkbox bold, Help: Bold text, Value: 0
28 pop up button typeface, Help: Choose the typeface, Value: Helvetica
The focused UI element is 2 text entry area (settable, string) …
</app_state>
このフォーマットこそが、それの「インターフェース言語」です。フィールドごとに分解して見ると:
| 要素 | 例 | 意味 |
|---|---|---|
| 行頭の整数 | 2 text entry area … | 要素番号。click / set_value / scroll はすべてこれで位置を特定します |
| インデント(Tab) | 子ノードは親ノードより一段深い | ツリーの深さをエンコード |
| ロール | standard window、pop up button | 平易な言葉で、モデルがそのまま理解できる |
| 括弧内の特徴 | (settable, string)、(disabled, settable, float) | 値を設定できるか、どんな型か、無効かどうか |
| フィールド | Value:、Help:、ID: | 現在値、ホバー説明、コントロール識別子 |
| Secondary Actions | Raise、Show color panel | 追加のアクセシビリティアクション。別のツールで起動します |
| 末尾のフォーカス | The focused UI element is 2 … | 現在のフォーカスを直接示し、モデルの推論を一回省きます |
リッチなコントロールを含む TextEdit ウィンドウ全体でも、シリアライズ後はわずか 3.6KB でした。純テキスト、行頭の番号、Tab インデント、平易なロール。token を節約でき、diff にも優しく、モデルの学習コストもゼロです。(settable) という括弧がとても巧妙で、これは「ここは変更できるか」をモデルに直接教えているのと同じで、その後の set_value と差分 diff までひと続きにつないでいます。
10 個のツール、2 種類のアドレッシング
フロントエンドのバイナリからは、完全なツール表を引き出せます。全部で 10 個です。中心になるものは次のとおりです。
get_app_state:状態を取得します(スクリーンショット + ツリー)。各ターンで必ず最初に 1 回呼び出す必要があります。click:番号でクリックするか、スクリーンショット上のピクセル座標でクリックします。type_text/press_key:文字入力、ショートカットキーの押下。scroll:ある要素を「ページ」単位でスクロールします。小数ページにも対応しています。set_value:設定可能な値コントロールの値を直接設定します。1 文字ずつ打ちません。perform_secondary_action:上に出てきた Secondary Actions を実行します。
click には細かいポイントがあります。element_index と x/y の両方を受け取るのです。これは二者択一の場当たり的なものではなく、意図されたフォールバックの連鎖です。構造を使えるなら、ピクセルは決して使いません。set_value も同じです。キーを 1 つずつ入力するシミュレーションより、コントロールの値を目標値に直接設定するほうが、速く、安定し、入力メソッドにも邪魔されません。さらにモデルには、「ツリー内のそのテキスト自体」を使ってカーソル位置を特定させ、テキストが一意でない場合だけ前後の接頭辞・接尾辞で曖昧さを解消させます。
変化した部分だけ送る:増分 diff
これはいちばんコストを節約できる一方で、いちばん見落とされやすい点でもあります。バイナリには、モデル向けの次のような文言がそのまま入っています。
The following is a diff from the previous accessibility tree
The following is a cumulative diff from the initial accessibility tree
There has been no change in the accessibility tree for …
初回は完全なツリーを渡し、その後の各ターンでは差分だけを送ります。前のターンとの差分、または初期状態からの累積差分です。変化がなければ、単に「変化なし」と伝えます。大型 App の完全なツリーは数万 token になることもあり、毎ターン再送するのはコストを燃やしているようなものです。
diff が可能になる前提は、その要素番号がターンをまたいで安定していることです。これは常駐 service が、トランザクション化され、無効化可能なツリー構造を維持しているから実現できます(クラス名は UIElementTreeTransaction、UIElementTreeInvalidationMonitor)。純粋な視覚ベースでステートレスな方式では、これはできません。まさにこれこそが、双プロセスのステートフルなアーキテクチャによって得られる見返りです。
入力はどう打ち込まれるのか、そして目立たないガードレール
入力は脆い AppleScript ではなく、Core Graphics のイベント合成を通ります。CGEvent / CGEventSource でマウスとキーボードを合成し、文字列を仮想キー列に変換します(修飾キー込み、キーボード配列非依存)。対応しているコントロールには、直接 AXPress を送ることもできます。
本当に差がつくのはガードレールです。こういうものは demo ではまったく要りませんが、長時間走らせると痛みがわかります。
- フォーカス横取り防止(
SystemFocusStealPreventer、SyntheticAppFocusEnforcer):合成入力で最大の落とし穴は、入力中にフォーカスが別のウィンドウや通知に奪われ、キー入力が間違った App に落ちることです。対象を前面に固定するための専用サブシステムがあります。 - ロック画面防止(
CUALockScreenGuardian):ロック画面では、macOS はアクセシビリティに対してすべての App ウィンドウを隠します。これは後で私が再現したとき、身をもってぶつかった点です。 - Electron を点灯させる(
AXManualAccessibility):Chromium / Electron はデフォルトでは DOM ツリーをアクセシビリティに公開しません。この種の App にAXManualAccessibility=trueを設定し、その場で Chromium にツリーを構築させます。だから Codex はブラウザ、VS Code、Slack でも、単なるスクリーンショットではなく構造化要素を取得できます。 - キャプチャ前ハイライト:スクリーンショットの前に画面上へハイライト枠を描画します(IPC メッセージには
CaptureAnimation系列が含まれます)。これにより、「AI がここを見ている」ことが目に見えます。
自分で止まる:確認ポリシー
プラグインに同梱されている SKILL.md には、かなり抑制の効いた確認ポリシーが埋め込まれています。UI 操作には実際の副作用(データ削除、送金、メッセージ送信)があるため、リスクのある操作を 4 段階に分けています:ユーザー自身に任せる、実行前に毎回確認する、初期 prompt の承認で可とする、常に許可する。クラウド上のデータ削除、新しいソフトウェアのインストール、金融取引、認証コードの解読などは「必ず確認」に分類されています。
また、「ユーザーが自分で入力した指示」と「第三者コンテンツに紛れ込んだ指示」も区別しています。後者(Web ページ、PDF、貼り付けたテキスト)は一律で潜在的に悪意あるものとして扱い、決して承認とは見なしません。
この仕組みがその場で動くのを、私は実際に見ました。公式 Codex に Computer Use で WeChat の連絡先へメッセージを送らせたところ、会話まで移動し、入力欄にフォーカスしたあと、自分から停止して、「第三者にメッセージを送るには確認が必要です。確認してください」と返しました。ポリシーはドキュメント上の飾りではなく、本当に止めに入ります。
-10000 の門:どうやって「OpenAI かどうか」を見分けるのか

私は一時期、Codex から離れて、自分で直接その MCP を呼ぼうとしました。ハンドシェイクと tools/list はどちらも通るのですが、実際のアクションを呼ぶと、service はこう返してきました。
Computer Use server error -10000: Sender process is not authenticated
この門こそが、その中核となる防衛線です。OpenAI 自身が署名した App だけに駆動を許す。私は検証ロジックをアセンブリ層まで掘り下げました。チェーン全体はこうです。
① 相手側の身元を取得する。カーネルレベルで、偽造不可。 getsockopt(fd, …, LOCAL_PEERTOKEN) で socket から相手側の audit token を取り、さらに audit_token_to_pid で pid に変換します。audit token はカーネルが socket 経由で提供するもので、呼び出し側が「pid を偽って申告する」ことでなりすますことはできません。
② responsible プロセスまで貫通する。 直結しているのは CLI ヘルパー(com.openai.sky.CUAService.cli。それ自体はホワイトリストにありません)です。そのため service は dlopen + dlsym でプライベート API を呼び出し、「このヘルパーに本当に責任を持つ App」、つまり Codex / ChatGPT / Atlas を解析します。対応するキーは senderParentResponsibleIdentity です。このステップによって、「自分で殻を作ってヘルパーを包む」手口は潰されます。
③ カーネルが裏書きしたコードオブジェクトを取得する。 audit token を使って SecCodeCopyGuestWithAttributes(call site は 0x10012b5bc 付近)を呼び、システムのコード署名サブシステムによって検証済みの SecCode を取得します。
④ 署名情報を読む。 SecCodeCopySigningInformation(0x10012a2cc)のあと、2 つのフィールドを取り出します。kSecCodeInfoTeamIdentifier(Apple チーム ID)と kSecCodeInfoIdentifier(bundle / 署名識別子)です。
⑤ 照合する。 チーム ID は 2DC432GLL2(OpenAI の開発者チーム)と一致していなければならず、bundle id はホワイトリスト内に収まっている必要があります。com.openai.codex(alpha/beta/dev/nightly を含む)、com.openai.chat、com.openai.atlas、com.openai.sky.* です。ヒットすれば通過、そうでなければ例の -10000 になります。
この門がなぜ堅牢なのかは、単独で述べる価値があります。これは「IPC サービスに呼び出し元認証をどう実装するか」の良い手本だからです。
- audit token はカーネル由来であり、呼び出し側から送られてきたデータではないため、なりすませません。
- responsible プロセスの解析により、「殻をかぶせる」だけでは通用しません。
- SecCode は audit token から取得され、カーネルに裏書きされています。未署名 / ad-hoc / 再署名されたバイナリでは、チーム ID
2DC432GLL2を提示できません。
ただし第 ④ ステップには、この門における唯一の弱点が隠れています。次の節では、そこから通り抜けます。
門をくぐる:自分で読み出したその文字列を差し替える
前節の ① ② はカーネルが与えるもので、偽造できません。誰が接続しているのか、誰がそれに責任を持つのか、この 2 点は audit token 経由でカーネルに固定されます。しかし、最後に照合されるチーム ID は、そのプロセス自身の中で、普通の動的リンク関数 SecCodeCopySigningInformation を使って読み出されている。カーネルが担当するのは「どのプロセスを調べるべきか」を見分けるところまでで、「そのプロセスの署名に含まれる team id は何か」という文は、クライアント自身のアドレス空間内で、差し替え可能なシンボルによって返されています。
だから私は署名を偽造せず、カーネル側の 2 ステップにも触りません。署名情報を読み終えた後、照合される前に、その辞書の中の team id だけを書き換えます。手段は DYLD interpose、30 行ほどの dylib です。
static OSStatus my_SecCodeCopySigningInformation(
SecStaticCodeRef code, SecCSFlags flags, CFDictionaryRef *info) {
OSStatus st = SecCodeCopySigningInformation(code, flags, info); // まず本物を呼ぶ
if (st == errSecSuccess && info && *info) {
// 返された辞書内の team id を OpenAI の 2DC432GLL2 に書き換える
CFMutableDictionaryRef m = CFDictionaryCreateMutableCopy(NULL, 0, *info);
CFDictionarySetValue(m, kSecCodeInfoTeamIdentifier, CFSTR("2DC432GLL2"));
CFRelease(*info);
*info = m;
}
return st;
}
// __DATA,__interpose セクションで、本物の関数を上の替え玉に向ける
DYLD_INSERT_LIBRARIES=team_hook.dylib でプロセスを起動すると、それ以降、署名を読むたびに team id は 2DC432GLL2 になります。実測では、この 1 フィールドを変えるだけで門を通過できました。注入前は list_apps が -10000 を返し、注入後はそのまま実際の app 一覧を返します。
これは前節で仕込んだ伏線の裏づけにもなります。SecCodeCheckValidityWithErrors に designated requirement を渡して「これは 2DC432GLL2 によって署名されたものか」をカーネルに判定させているのではなく、自分で team id を文字列として読み出してから比較している。前者なら判定はシステム内に残り、私は手を出せません。後者では、最後の一文を、私が差し替えられるユーザー態関数に委ねています。信頼のアンカーはあのチーム ID に見えますが、本当のアンカーは「誰がそれを読むのか」であり、そこで相手は自分自身で読むことを選んでいました。
(ついでに、初期の誤判定も訂正しておきます。一時期、バイナリ内の 3 本の分岐を NOP 化すれば回避できると思っていました。その 3 本は 0x100019a00 にあり、逆アセンブルすると NSError の description getter でした。書き換えてもエラーメッセージが乱れるだけで、権限は 1 つも gate していません。本当のスイッチは上のフックです。)
Claude Code に接続する

門を通ったら、残るのは Claude Code に接続することです。Claude Code は MCP server としてツールを読み込みます。そして SkyComputerUseClient mcp 自体が stdio で動く MCP server であり、公開しているのはまさに get_app_state / click / type_text などの 10 個のツールです。1 行登録すればよく、落とし穴は 1 つだけです。
computer-use という名前は Claude Code 内では予約名で、読み込みが黙って拒否されます。 別の名前、たとえば mac-computer-use にします。
claude mcp add mac-computer-use --scope user \
-e DYLD_INSERT_LIBRARIES="$HOME/.codex/computer-use/team_hook.dylib" -- \
"…/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient" mcp
-e でフックを持ち込み、Claude Code を再起動すると、その 10 個のツールが現れます。私はこれで WhatsApp に対して get_app_state を一度呼びました(注入前なら -10000 になるあの呼び出しです)。すると完全なアクセシビリティツリーがそのまま返り、続いて要素番号で送信ボタンをクリックしたところ、メッセージが送信されました。使っているのはアクセシビリティツリーで、最初から最後までスクリーンショットは撮っていません。
パッチ適用、フックのコンパイル、MCP 登録の数ステップは install.sh にまとめてあります。リポジトリは leeguooooo/computer-use で、curl … | sh の 1 行でインストールできます。境界も明確にしておきます。これは自分のマシンで、自分が入れた Codex を、自分が普段使う別の agent と相互運用させるためのものです。システムレベルの権限(アクセシビリティ / 画面収録)は今でも macOS の TCC が管理しており、迂回できませんし、迂回すべきでもありません。パッチ後の初回起動では、これまでどおりシステム権限のダイアログが出るので、許可を押せばそれで済みます。
それに倣って、opencua というものを書いた

接続する前に、もうひとつ検証したいことがありました。この一式の中核は、公開されている macOS API だけで再現できるのか。そこで自分の理解に沿って、およそ 600 行の Swift で clean-room 版を書きました。名前は opencua。同じようにアクセシビリティツリー + スクリーンショット + CGEvent を使い、OpenAI のコードはゼロ、自分の署名で動かしています。
シンプルなネイティブ App では、ほぼ 1:1 でした。TextEdit の状態を取らせると、出力されたツリーは公式版のその時の結果と行単位で対応していました。
| 公式 Codex | 自作の opencua | |
|---|---|---|
| ルートノード | 0 standard window Untitled | 0 standard window "Untitled 3" |
| テキスト領域 | 2 text entry area (settable, string) Value: … | 2 text entry area (settable) value=… |
| フォーカス | The focused UI element is 2 | focused element index = 2 |
スクリーンショット、クリック、文字入力もすべて通りました。これを使って TextEdit に «» を含む文章を入力し(非 ASCII も注入できます)、さらに bold のチェックボックスをクリックして、読み戻した値が 0 から 1 に変わったことも確認しました。観察 → 操作 → 再観察、という閉ループが動いたわけです。
分解している途中で、逆向きに立てた結論が現実にその場で反証されたことが 2 つありました。ひとつは Chrome の状態を取ったとき、ウィンドウ数が 0 で、メインウィンドウとして返ってきたのも App 自身だったことです。これはまさに、Chromium が AXManualAccessibility を有効にしない限りツリーを公開しない、という挙動でした。もうひとつは、しばらくのあいだ、どの App の状態を取ってもウィンドウが 0 個と返ってきたことです。かなり調べた末に、マシンがロック画面になっていたと分かりました。ロック中の macOS はアクセシビリティに対して全ウィンドウを隠します。これで、なぜ CUALockScreenGuardian を持っているのかも、ちょうど説明がつきました。
そして、現実世界の壁にぶつかります。opencua に WeChat を操作させて、連絡先を検索し、メッセージを送らせてみました。検索ボックスにはフォーカスでき、文字も入力できます。しかし検索結果は仮想化されたセルの列で、名前はレンダリングされる前にはそもそもアクセシビリティに入ってきません。私のシリアライザが取れたのは、空の virtual_cell の山だけでした。一方、公式版は連絡先名を安定して読み取れます。差はここにあります。
今回の実測は、むしろ分解時の判断を裏づけるものでした。この一式の堀はアルゴリズムにはありません。中核の数百行は再現できます。本当に難しいのは、目立たない残り 20% のプロダクション級の磨き込みです。仮想化リスト、フォーカス管理、タイミングのリトライ、各社の自前描画コントロールへの個別対応。プロトタイプでは削り出せない部分で、オリジナル版はそこを長い時間かけて磨いているのです。
分解し終えて覚えていること
もし自分で macOS computer use を作るなら、次の点はそのまま真似すると思います。
- アクセシビリティツリーを優先し、スクリーンショットで補完する。各操作は二重にアドレス指定する(番号 / ピクセル)。これが「正確にクリックできる」根っこです。
- 状態を持つ二重プロセス。短命のフロントエンド + 常駐エンジン。ターン間でツリー状態を保つことが、差分 diff と安定した番号付けの前提になります。
- ツリーは増分 diff で送る。初回は全量、その後は差分だけ。長いタスクの token コストは、ここが分水嶺です。
- モデルに渡すツリーは純テキスト + 行頭番号 + Tab インデントにする。「変更できるかどうか」を括弧の中に埋め込みます。
AXManualAccessibilityで Chromium / Electron を点灯させる。そうしないとブラウザや多くの Electron アプリはスクリーンショット頼みになります。- ガードレールがプロダクションと demo の境界。フォーカスの横取り防止、ロック画面対策、キャプチャ前のハイライト、アプリごとの権限付与。
- 認証で最後の一言をユーザー空間に渡さない。audit token で身元を取り、responsible プロセスを解決するところまでは正しいのですが、最後に自分で team id を読み出して文字列比較するなら、ローカルで注入できる人は interpose 一発で通れてしまいます。
SecCodeCheckValidityWithErrorsに designated requirement を渡してカーネルに判定させるか、この弱点を認めるしかありません。私が Claude Code に接続したとき、通ったのはまさにそこでした。
それが「最良」なのは、モデルが強いからではなく、engineering が accessibility という API 群を食い尽くしているからです。スクリーンショットはあくまで保険で、本当の媒体は diff 可能でモデルが読めるアクセシビリティツリーです。そこにフォーカス、ロック画面、ハイライト、段階的確認といった目立たない要素を重ねて、信頼性を支えています。堀は prompt ではなく、そういう場所にあります。あの署名の門も同じエンジニアリングの質感を持っています。ただ最後の一歩をユーザー空間に置いたために、この文章の後半が生まれたのです。

微信
支付宝
コメント
コメントは即時公開されますが、ポリシー違反時は非表示になる場合があります。