こんにちは、エンジニアのオオバです。
タスク管理アプリで「AI」にチェックしたタスクを、各PCのAIが拾ってPRまで出してくれる自作ランナー(Python)を使っています。
もともとは Claude Code 専用でした。
これを Codex CLI にも対応させて、タスクごとに「Claude で書くか、GPT で書くか」とモデル・エフォートを選べるようにしたわけです。
「コマンドを差し替えるだけでしょ?」って思いますよね。
正直、僕もそう思っていました。
結果、非対話で Codex を動かすところで4回ハマっています。
codex execは--output-last-messageで最終回答をファイルに出し、GitHub操作はghに任せ、sandboxはbypassし、Codexの実体はnode_modules配下を除いて選ぶ
先に結論をまとめます。
①GitHubコネクタ(MCP)は非対話だと承認できずuser cancelled MCP tool callになる → プロンプトでgh pr createを使わせる
②workspace-writeのsandboxではネットワークもUnityのソケットも閉じる → --dangerously-bypass-approvals-and-sandboxが要る
③npm版の古いCodexは新モデルをChatGPTアカウントでは使えないと拒否した → CLIの場所を設定で持ち、standalone版を優先する
④Path.is_fileはシンボリックリンクの先を見る → resolve後のパスにnode_modulesを含むものを飛ばして単体版を選ぶ
ざっくり言うと、前半がランナーの作りとCodexの呼び方、後半がハマった4つです。
では本編に入っていきます。
→11万文字で徹底解説した「DOTweenの教科書」Unityアニメーションの超効率化ツールはこちら
やりたかったこと:タスクごとにClaudeとGPTを選ぶ
ランナーの流れはシンプルです。
各PCのランナーがサーバーにタスクを claim して、AIに作業させ、PRを出します。
ここに「どのAIで、どのモデルで、どのエフォートで動かすか」をタスク単位で選べる項目を足しました。
modelSourceで「タスクで選んだ値」かを区別した
最初にぶつかったのは、ランナーより手前の設計の問題です。
claim が返す model だけだと、その値が「タスクで選んだ値」なのか「そのマシンの既定値」なのかを区別できませんでした。
しかも Mac mini では、もともと「ダッシュボードの設定を優先する」仕様で動かしています。
タスクで GPT を選んでも、Mac mini のダッシュボード設定で上書きされてしまうということ。
そこでサーバー側に modelSource(task / machine)を足しました。
task のときだけ、タスクで選んだ値を最優先にしています。
machine のときは、これまでどおりマシン側の設定が勝ちます。
例えるなら、レストランの「おまかせ」と「指名」の違いです。
指名が入ったときだけ指名を通し、それ以外は店(マシン)の判断に任せる形になります。
Codexの呼び方:codex execに全部渡す
Codex は、既存の Mac mini の自動化と同じ呼び方に揃えました。
codex exec \
--cd <作業ツリー> \
--add-dir <git共通dir> \
--json \
--output-last-message <file> \
--dangerously-bypass-approvals-and-sandbox \
-m <model> \
-c model_reasoning_effort=<effort> \
-
それぞれの役割は次のとおりです。
--cd: 作業ツリー(git worktree)を作業ディレクトリにする--add-dir: git の共通ディレクトリも書き込めるようにする。worktree でコミットするときに要る--json: 進行をJSONで出す--output-last-message: 最終回答をファイルに書き出す--dangerously-bypass-approvals-and-sandbox: 承認と sandbox を切る(理由は後述)-m: モデルを指定する-c model_reasoning_effort=: エフォートを指定する- 最後の
-: プロンプトを標準入力から読む
最終回答はファイルから読む
ランナーは、AIの最終回答を受け取って次の処理に使います。
Claude Code は stream-json の result から最終回答を取っていました。
Codex では --output-last-message で書き出したファイルから読むようにしています。
出力を1行ずつパースするより、ファイルを1つ読む方がずっと楽なんですよね。
落とし穴1:GitHubのMCPがuser cancelled MCP tool callで止まる
最初に止まったのは、PRを作るところでした。
Codex は GitHub コネクタ(MCP)で PR を作ろうとします。
ところが非対話の codex exec では、その MCP の呼び出しを承認する人がいません。
結果として user cancelled MCP tool call になって止まっていました。
誰もキャンセルしていないのに「キャンセルされた」と出るので、かなり紛らわしいです。
対策として、プロンプトで gh pr create を使うように指示しました。
gh はシェルのコマンドなので、MCP の承認を通りません。
落とし穴2:workspace-writeのsandboxではネットワークが閉じる
次は sandbox です。
workspace-write の sandbox で動かすと、ネットワークが閉じます。
git push も gh pr create も通りません。
さらに、Unity を操作するための uloop のソケットも閉じていました。
ランナーのタスクには Unity を触るものもあるので、ソケットが使えないと仕事になりません。
そのため --dangerously-bypass-approvals-and-sandbox で sandbox ごと外しています。
落とし穴3:npm版の古いCodexが新モデルを拒否する
MacBook で動かしたら、今度はモデルで弾かれました。
npm で入れた Codex(0.153.4)は、新しいモデルを「ChatGPT アカウントでは使えない」と拒否したんです。
同じモデルでも、standalone 版(0.163.0)だと通りました。
つまり、PATH で最初に見つかる codex が古いと、それだけでタスクが落ちるということ。
そこで Codex CLI の場所を設定(config.json の codexBin)で持ち、standalone 版を優先させるようにしました。
落とし穴4:is_fileでは「単体版か」を判定できない
ここからは後日談です。
MacBook Air にランナーを入れ直したとき、セットアップスクリプト(setup.py)が古い Codex を選びかけました。
起きたこと
setup.py は、~/.local/bin/codex が「ファイルとしてあれば」単体版とみなして優先していました。
Air では、そこに6月に npm で入れた Codex 0.142.0 が残っていたんです。
中身は node_modules/@openai/codex/bin/codex.js へのシンボリックリンク。
そのまま入れ直すと、ChatGPT アプリ同梱の 0.163.0 から、古い 0.142.0 へ切り替わるところでした。
気づけたのは、--dry-run で出した設定と今の config.json を diff したからです。
入れ直したあと、codexBin だけ元に戻しました。
原因:is_fileはリンクの先を見る
Python の Path.is_file() は、シンボリックリンクの先をたどって判定します。
リンク先の codex.js はファイルなので、npm 版へのリンクでも True が返る わけです。
「単体版か」を見分けるつもりが、「何かファイルがあるか」しか見ていませんでした。
symlink をたどって想定外の場所に届く話は、以前 GitHub Actions の self-hosted ランナーでも踏んでいます。
👉 GitHub Actionsのself-hostedランナーがdotfilesの.gitconfigにsafe.directoryを140行書き足していた
解決:resolveしたパスにnode_modulesがあれば飛ばす
setup.py に find_codex を足しました(PR #1508)。
候補を次の順に見ていきます。
~/.local/bin/codex- ChatGPT アプリ同梱の Codex
- PATH 上の
codex
このうち Path(candidate).resolve().parts に node_modules を含むものを飛ばします。
npm や yarn で入れた版は、リンクをたどると必ず node_modules の下に行き着くからです。
考え方だけ書くと、次のような形になります。
from pathlib import Path
def is_package_manager_install(candidate: str) -> bool:
# npm・yarn で入れた版はリンクの先が node_modules の下にある
return "node_modules" in Path(candidate).resolve().parts
3台で確かめた結果
手元の3台を調べると、MacBook Pro の PATH には nodebrew の npm 版 0.153.4 と、yarn 版 0.1.2504161551 まで残っていました。
古い Codex が思った以上に散らばっていたわけです(笑)。
find_codex で選ばれたのは次のとおり。
- Mac mini: standalone 0.164.0
- MacBook Pro: ChatGPT 同梱 0.163.0
- MacBook Air: ChatGPT 同梱 0.163.0
どれも既存の設定と一致しました。
おまけ:ネタのメモでバッククォートが実行されかけた
このネタをメモとして登録するとき、二重引用符の中に書いたバッククォートを、シェルがコマンドとして実行しかけました。
codex exec のコマンド例をそのまま書いたのが原因です。
コマンドを含むメモを渡すときは、単一引用符のヒアドキュメント(<<'EOF')か --stdin を使うのが安全です。
検証のしかた
検証は Mac mini 上で行いました。
ランナーが組み立てたコマンドをそのまま使い捨てのリポジトリで流して、PRまで出ることを確かめています。
組み立て部分だけをテストしても、sandbox や MCP の承認のような実行時の挙動は見えません。
本物のコマンドを本物の環境で1回流すのが、いちばん早く落とし穴に当たる方法だと思います。
まとめ
①タスクで選んだモデルか、マシンの既定かをmodelSourceで区別し、taskのときだけ最優先にする
②codex execは--output-last-messageで最終回答をファイルに出し、-c model_reasoning_effortでエフォートを渡す
③非対話ではMCPの承認ができないのでghを使わせ、sandboxはbypassする
④Codexの実体はresolve後のパスにnode_modulesを含むものを除いて選ぶ。is_fileは使えない
Claude Code と Codex は、どちらもCLIから呼べるので、切り替えは簡単そうに見えます。
ところが非対話で動かすと、承認・sandbox・CLIのバージョンという、対話では気にしなかった部分で止まりがちです。
同じように AI をスクリプトから回そうとしている人の近道になればうれしいです。
関連記事

この記事が気に入ったらフォローしよう
- Claude Code 2.1
- Codex CLI 0.163(standalone)/ 0.153(npm)
- Python 3.14

