Podman上のCodex CLIでapply_patchがbwrapエラーになる問題を解決した話

Podman上のCodex CLIでapply_patchがbwrapエラーになる問題を解決した話

Windows上のPodmanを使ったdevcontainer環境でCodex CLIを動かしていたところ、apply_patchを使用したファイル編集だけが失敗する問題に遭遇しました。

通常のファイル編集はできるため致命的ではなかったのですが、Codexが別の方法へフォールバックして編集している状態であり、毎回エラーが出るのも気になります。

調べていくと、原因はCodexそのものというより、CodexがLinux sandboxとして利用するBubblewrap(bwrap)と、Podmanコンテナに付与していたLinux capabilityの組み合わせでした。

さらに、いったん解決したあとCodex CLIが自動アップデートされたことで問題が再発するというオチまであったので、最終的な対策までまとめておきます。

環境

今回の環境は少し特殊で、ざっくり以下のような構成です。

Windows
└─ WSL2
   └─ Podman
      └─ devcontainer
         └─ node user
            └─ npm版 Codex CLI

devcontainer自体をCodex用のsandboxとして使っており、コンテナ内では通信制限のためにiptablesも設定しています。

そのため、devcontainer.jsonには以下の設定が入っていました。

"runArgs": [
  "--cap-add=NET_ADMIN",
  "--userns=keep-id"
]

NET_ADMINはiptablesによるfirewall設定のために必要です。

症状

Codex CLI自体は普通に動作し、ファイルの読み書きもできます。
しかし、Codexがapply_patchを使用しようとしたときだけbwrap関連のエラーが発生していました。

Codexは別の方法を使えばファイルを編集できるため作業そのものは継続できますが、sandbox周辺で何かがおかしいことは明らかです。

まずbwrap単体を調べる

コンテナ内の状態を確認しました。

codex --version
command -v bwrap
bwrap --version

id
cat /proc/self/uid_map
cat /proc/self/gid_map

grep -E 'NoNewPrivs|Seccomp|Cap(Eff|Bnd)' /proc/self/status

unshare -Ur true
echo "unshare rc=$?"

bwrap --unshare-user --ro-bind / / true
echo "bwrap-user rc=$?"

bwrap --unshare-user --unshare-net --ro-bind / / true
echo "bwrap-net rc=$?"

この時点では以下のような状態でした。

codex-cli 0.147.0

/usr/bin/bwrap
bubblewrap 0.8.0

uid=1000(node) gid=1000(node)

CapEff: 0000000000001000
NoNewPrivs: 0
Seccomp: 2

unshare rc=0

unshare -Urが成功しているため、Podmanコンテナ内からさらにuser namespaceを作れないことが原因ではありませんでした。

問題はbwrapを直接実行すると出てきました。

bwrap: Unexpected capabilities but not setuid, old file caps config?
bwrap-user rc=1

原因はNET_ADMINだった

ポイントはこの値です。

CapEff: 0000000000001000

0x1000CAP_NET_ADMINです。

つまり、devcontainerに指定していた

--cap-add=NET_ADMIN

によって、Codexを実行しているnodeユーザーにもNET_ADMIN capabilityが残っていました。

一方、non-setuidのBubblewrapは、呼び出し元が予期しないcapabilityを保持している状態を拒否します。

構造としては以下のようになっていました。

Podman container
    │
    ├─ CAP_NET_ADMIN
    │
    ▼
node
    │
    ▼
Codex CLI
    │
    ▼
bwrap
    │
    └─ Unexpected capabilities...

ではNET_ADMINを消せばいいのか

単純なCodex用コンテナならそれでもよいのですが、今回の環境ではそうはいきません。

devcontainer起動時にiptablesを設定しているためです。

iptables -P INPUT DROP
iptables -P OUTPUT DROP
iptables -P FORWARD DROP

iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT

iptables -A OUTPUT -p tcp --dport 80 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -j ACCEPT

この操作にはNET_ADMINが必要なので、devcontainerから単純に削除することはできません。

setuid bwrapも試したがPodmanではうまくいかなかった

次にBubblewrapをsetuid rootとして動作させる方法を試しました。

RUN chown root:root /usr/bin/bwrap \
    && chmod 4755 /usr/bin/bwrap

ところが、今度は以下のエラーになります。

bwrap: capset failed: Operation not permitted

SETUIDSETGIDを追加したり、seccompを無効化したりして切り分けましたが、結果は変わりませんでした。

CapEff: 00000000000010c0
NoNewPrivs: 0
Seccomp: 0

bwrap: capset failed: Operation not permitted

今回のようなrootless Podmanと--userns=keep-idを組み合わせた環境では、setuid bwrapを無理に成立させるより別の方法を採った方がよさそうです。

解決策:Codexを起動するときだけcapabilityを落とす

ここで考え方を変えました。

NET_ADMINが必要なのはコンテナ起動時のfirewall設定です。
Codex自身がNET_ADMINを持っている必要はありません。

ならば、

Podman container
    │
    ├─ NET_ADMINあり
    │    └─ iptables設定
    │
    └─ Codex起動
         │
         └─ capabilityをすべてdrop
              │
              ▼
             bwrap

とすればよいことになります。

Linuxのsetprivを使って試しました。

setpriv \
  --bounding-set=-all \
  --inh-caps=-all \
  --ambient-caps=-all \
  bwrap --unshare-user --ro-bind / / true

echo "bwrap-user rc=$?"

結果は、

bwrap-user rc=0

ネットワークnamespaceを含む場合も、

setpriv \
  --bounding-set=-all \
  --inh-caps=-all \
  --ambient-caps=-all \
  bwrap --unshare-user --unshare-net --ro-bind / / true

で正常終了しました。

つまり、PodmanやWSL2でbwrapそのものが動かないわけではありません。

Codexに引き継がれていたコンテナのcapabilityがbwrapと衝突していたことが原因でした。

Codex用のラッパーを作る

毎回setpriv付きでCodexを起動するのは面倒なので、Codex用のラッパースクリプトを用意することにしました。

例えば以下のようなスクリプトです。

#!/usr/bin/env bash
set -euo pipefail

exec setpriv \
  --bounding-set=-all \
  --inh-caps=-all \
  --ambient-caps=-all \
  "$HOME/.npm-global/bin/codex" "$@"

これを~/.local/bin/codexとして配置します。

PATHは、

ENV NPM_CONFIG_PREFIX=/home/$USERNAME/.npm-global
ENV PATH=/home/$USERNAME/.local/bin:/home/$USERNAME/.npm-global/bin:$PATH

として、~/.local/binをnpm globalより前にします。

これで、

codex

と普通に実行した場合でも、

~/.local/bin/codex
    │
    └─ setpriv
         │
         └─ capabilityをdrop
              │
              ▼
~/.npm-global/bin/codex
              │
              ▼
          Codex CLI
              │
              ▼
            bwrap

という経路になります。

最初はnpmのcodex自体を置き換えていた

実は最初の対策では、npmが生成したcodexcodex-realへリネームしていました。

mv ~/.npm-global/bin/codex ~/.npm-global/bin/codex-real

そして同じ~/.npm-global/bin/codexにラッパーを置いていました。

この方法でも動作します。

しかし後日、Codex CLIのアップデートが入りました。

コンテナ内でCodexが更新されるとnpmによって~/.npm-global/bin/codexが再生成され、ラッパーが上書きされます。

すると当然ながら、

Codex
  ↓
NET_ADMINを保持したままbwrap
  ↓
Unexpected capabilities...

となり、bwrapエラーが再発しました。

ラッパーはnpm管理外へ置く

そこで最終的には、npm管理下のファイルを変更しない構成にしました。

~/.local/bin/codex
    ↑
    │ PATHでこちらを優先
    │
    └─ 自作ラッパー

~/.npm-global/bin/codex
    ↑
    └─ npmが管理する本物のCodex CLI

これならCodex CLIがアップデートされても、更新されるのは

~/.npm-global/bin/codex

だけです。

~/.local/bin/codexのラッパーはnpmの管理外なので残り続けます。

確認するときは、

type -a codex

で、

codex is /home/node/.local/bin/codex
codex is /home/node/.npm-global/bin/codex

の順になっていれば意図した状態です。

最終的な構成

devcontainer.jsonでは、firewallに必要なNET_ADMINは残します。

"runArgs": [
  "--cap-add=NET_ADMIN",
  "--userns=keep-id"
]

Bubblewrapにはsetuidやfile capabilityを設定しません。

RUN chown root:root /usr/bin/bwrap \
    && chmod 0755 /usr/bin/bwrap

Codex CLI本体は通常どおりnpmでインストールします。

RUN npm install -g @openai/codex@${AGENT_VERSION}

そしてnpm管理外にラッパーを配置します。

ENV NPM_CONFIG_PREFIX=/home/$USERNAME/.npm-global
ENV PATH=/home/$USERNAME/.local/bin:/home/$USERNAME/.npm-global/bin:$PATH

COPY --chown=$USERNAME:$USERNAME codex-wrapper.sh /home/$USERNAME/.local/bin/codex

USER root
RUN chmod 0755 /home/$USERNAME/.local/bin/codex
USER $USERNAME

codex-wrapper.shは以下です。

#!/usr/bin/env bash
set -euo pipefail

exec setpriv \
  --bounding-set=-all \
  --inh-caps=-all \
  --ambient-caps=-all \
  "$HOME/.npm-global/bin/codex" "$@"

まとめ

今回の問題は、最初はCodex CLIのapply_patchの不具合に見えました。

実際には、

  1. devcontainerのfirewall用にNET_ADMINを付与していた
  2. そのcapabilityがnodeユーザーにも引き継がれていた
  3. CodexがBubblewrapを起動した
  4. non-setuid bwrapがcapabilityを保持したプロセスからの起動を拒否した

という流れでした。

かといってNET_ADMINを削除するとiptablesによるfirewallが使えません。
そこでコンテナにはNET_ADMINを残し、Codexを起動するときだけcapabilityをすべてdropすることで両立させました。

また、npmが管理するcodexを直接ラッパーに置き換えると、Codex CLIのアップデート時に元へ戻ってしまいます。

そのため、

~/.local/bin/codex          ← capabilityを落とすラッパー
~/.npm-global/bin/codex     ← npm管理のCodex CLI本体

と分離し、PATHでラッパーを優先する形にしています。

Podman、devcontainer、Linux namespace、capability、Bubblewrapと複数レイヤーが絡むため原因の切り分けには少し時間がかかりましたが、最終的にはCodex側へ特別な変更を加えることなく解決できました。

同じように「Codex CLIは動くのにapply_patchだけbwrapで失敗する」という場合は、bwrapそのものを疑う前に、

grep -E 'CapEff|CapBnd|NoNewPrivs|Seccomp' /proc/self/status

でCodexプロセスに不要なLinux capabilityが引き継がれていないか確認してみると、原因を見つけやすいかもしれません。

Podman上のCodex CLIでapply_patchがbwrapエラーになる問題を解決した話にコメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です