こんにちは、エンジニアのオオバです。

お悩みさん
お悩みさん
  • codex execを非対話でスクリプトから呼びたい
  • Claude CodeとCodexをタスクごとに切り替えたい
  • Codexがuser cancelled MCP tool callで止まる
  • オオバ
    オオバ
    本記事ではこれらの悩みを解決します。

    タスク管理アプリで「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配下を除いて選ぶ

    先に結論をまとめます。

    codex execを非対話のランナーから呼ぶときの落とし穴

    ①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> \  
      -  
    

    それぞれの役割は次のとおりです。

    最終回答はファイルから読む

    ランナーは、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 ごと外しています。

    ※名前のとおり危険なオプションです。ランナーは自分のPC上の、タスク専用の作業ツリーでだけ動かしています

    落とし穴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)。

    候補を次の順に見ていきます。

    1. ~/.local/bin/codex
    2. ChatGPT アプリ同梱の Codex
    3. 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 で選ばれたのは次のとおり。

    どれも既存の設定と一致しました。

    おまけ:ネタのメモでバッククォートが実行されかけた

    このネタをメモとして登録するとき、二重引用符の中に書いたバッククォートを、シェルがコマンドとして実行しかけました。

    codex exec のコマンド例をそのまま書いたのが原因です。

    コマンドを含むメモを渡すときは、単一引用符のヒアドキュメント(<<'EOF')か --stdin を使うのが安全です。

    検証のしかた

    検証は Mac mini 上で行いました。

    ランナーが組み立てたコマンドをそのまま使い捨てのリポジトリで流して、PRまで出ることを確かめています。

    組み立て部分だけをテストしても、sandbox や MCP の承認のような実行時の挙動は見えません。

    本物のコマンドを本物の環境で1回流すのが、いちばん早く落とし穴に当たる方法だと思います。

    まとめ

    Claude CodeとCodex CLIを切り替えるランナーで分かったこと

    ①タスクで選んだモデルか、マシンの既定かを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 をスクリプトから回そうとしている人の近道になればうれしいです。

    関連記事

    オススメ記事
    検証環境