Skip to content

fix(codex-bridge): give the identity lease a start token on Windows - #2

Open
joelmitz wants to merge 2 commits into
mainfrom
fix/windows-bridge-start-token
Open

fix(codex-bridge): give the identity lease a start token on Windows#2
joelmitz wants to merge 2 commits into
mainfrom
fix/windows-bridge-start-token

Conversation

@joelmitz

@joelmitz joelmitz commented Aug 23, 2026

Copy link
Copy Markdown
Owner

何が壊れていたか

Windows では codex bridge が一度も起動できていませんでした。起動のたびに writeLease() が落ち、run/codex-bridge.<team>.<agent>.log に同じ行が20回以上並んでいました。

codex-bridge: cannot determine process start token for identity lease

startToken() の取得経路は2つで、Windows はそのどちらも満たしません。

経路 Windows での結果
/proc/<pid>/stat ENOENT。Windows に /proc は無い
ps -o lstart= ps: unknown option -- o。PATH に載りうる ps は MSYS のもので、-o を劣化ではなく拒否する

どちらも空トークンを返し、writeLease() は空トークンで fail closed します。これは fujibee#906 の設計(列挙可能な lease を publish できない bridge は network を arm してはならない。さもないと lease が防ごうとしている回収不能な孤児そのものになる)どおりで正しい挙動です。欠けていたのは Windows 向けのソースのほうでした。

変更内容

codex-bridge.jsstartToken()codex-bridge-launcher.sh_start_token の両方に、Process.StartTime.Ticks を PowerShell 経由で取る Windows 経路を追加し、lease スキーマに pwsh を許可しました。

候補リストではなく単一ソースにした理由

WMIC の CreationDate のほうが約2倍速く(実測 0.36 秒 対 0.83 秒)、最初はそちらを第一候補にしていました。しかし WMIC は非推奨で、Feature-on-Demand を外した Windows 11 では既に存在しません。

問題は「WMIC が無いかもしれない」ことではありません。各側が独立に「WMIC、無ければ PowerShell」と選ぶと、片側だけ wmic.exe に届く状況で書き手と reaper が同じプロセスに対して別のソースを解決してしまうことです。トークンは形式ごと変わるため、reaper は生きている bridge の lease を別プロセスのものと読み、回収すべき孤児を永久に回収しません。

これは机上の懸念ではなく再現しました。片側だけ wmic を潰した状態で、同一 pid に対して両側が返した値です。

node: src=wmic token=20260824045224.102163+540
bash: src=pwsh token=639231439441021632

Ticks は両側が常に一致できる唯一の値なので、WMIC の速度は分岐のリスクに見合わないと判断し、使用をやめました。

powershell.exe から pwsh へのフォールバックが安全なのは、WMIC には当てはまらない理由によります。両者は同一 pid に対して同じ Ticks を返す(実測)ため、src ラベルは実行ファイルではなく形式を指します。powershell.exe に到達できないときも両側が pwsh へ落ちて一致することを確認しました。

Windows で /proc 分岐に入ってはいけない

Windows 経路は /proc 分岐より先に置いたのではなく、/proc 分岐の代わりに通します。

MSYS と Cygwin は動作する /proc を持ちます(field 22 まで取れます)。しかしそれはエミュレーション層自身の pid 空間で索かれており、lease に記録されるのは codex-bridge.jsprocess.pid として見る Windows pid です。MINGW64 での実測では、同一シェルが MSYS pid 3065729 かつ winpid 1456 でした。

/proc を優先させると、その番号に居合わせた無関係な MSYS プロセスの起動時刻を黙って返します。これはトークンが防ごうとしている recycled-pid の取り違えそのもので、しかも何もエラーにならない分だけ気付きにくい形です。

検証

環境は Windows 11 26200 / MINGW64 / node 22 (win32) / codex-cli 0.149.0 です。

  • node の startToken() とシェルの _start_token が、同一の生存 Windows pid に対して pwsh<TAB>639231441791462826 をバイト単位で一致して返す。powershell.exe に到達できず両側が pwsh へ落ちる場合も一致する
  • _read_lease は8件すべて期待どおり。pwsh の整数を受理、pwsh の非整数と空を拒否、WMIC 形式を拒否、proc / ps の回帰は受理を維持、proc の非数値と未知 src を拒否
  • 実地の疎通。bridge が起動して armed になり、start=639231452158110466 startsrc=pwsh の lease を publish し、agmsg でロールへ送ったメッセージがターンを駆動して応答が返る。この変更前は同じ手順がトークンエラーしか出さなかった

検証していないこと

  • tests/test_codex_bridge_launcher.bats は、この変更の有無にかかわらず結果がバイト単位で同一でした(12件中10件が両方で失敗。このスイートは MSYS では動きません)。したがって「このマシンで回帰を出していない」ことは言えますが、テストが通ったとは言えません
  • Linux と macOS では検証していません。 POSIX 側の分岐は触っていませんが、それは論拠であって実測ではありません

別件として残る観測

この修正で bridge は起動し配送も成立しますが、bridge が bind する thread が生きている TUI セッションと一致しない場面を Windows で観測しています。配送されたメッセージは TUI に現れず、切り離された thread 側で応答されます。トークンとは無関係の別問題なので、この PR には含めていません。


追記(レビュー指摘を受けた訂正)

codex-win のレビューで、上の「reaper が生きている bridge の lease を別プロセスのものと読む」という説明が Windows の現状に対して不正確だと指摘され、確認したところそのとおりでした。訂正します。

_reap_orphan_bridges() は先頭で早期 return します。

_reap_orphan_bridges() {
  case "${MSYSTEM:-}" in MINGW*|MSYS*|CLANGARM*) return 0 ;; esac

コメントにも "Non-Windows only (the pid is only killable in this shell's pid namespace; Windows is the fujibee#458 mismatch)" とあります。lease を node が書き、bash が _start_token で再計算して突き合わせるのはこの関数だけなので、node と bash の一致要求は Windows では現時点で潜在的です。fujibee#458 が解かれて reaper が有効化されたときに効きます。

一方で、指摘を検算する過程で、_start_token が reaper 以外からも呼ばれることが分かりました。_spawn_rate_ok() の 762 行と 797 行で、こちらに MSYSTEM ガードはありません。

797 行の判定はこうなっています。

_nk_now="$(_start_token "$need_kill" 2>/dev/null || true)"
if [ -z "$need_kill_token" ] || [ -z "$_nk_now" ] || [ "$_nk_now" = "$need_kill_token" ]; then
  poll_sleep
  continue
fi

修正前の Windows では _start_token が常に空を返すため _nk_now が空になり、常に continue します。つまり食い違った live bridge を退役させて張り直す経路も、Windows では成立していませんでした。この PR はそちらも通します。

したがって正確には次のとおりです。

  • 単一ソースにした設計判断は維持する。 分岐は起きてはならないものであり、reaper が有効化された時点で効く
  • ただし「今 Windows で reaper が誤判定する」とは書けない。 reaper は Windows で走らない
  • bash 側の変更は死にコードではない。 _spawn_rate_ok の置き換え経路で現に使われる

この訂正は本文と commit message の説明に対するものです。コードは変更していません。

The codex bridge could not start on Windows at all. Every launch died in
writeLease() with "cannot determine process start token for identity lease",
20+ times in a row in run/codex-bridge.<team>.<agent>.log.

startToken() had exactly two sources and Windows satisfies neither:

  /proc/<pid>/stat   ENOENT -- Windows has no /proc
  ps -o lstart=      "ps: unknown option -- o" -- the only ps likely to be on
                     PATH there is MSYS's, and it rejects -o outright rather
                     than degrading

Both yield an empty token, and writeLease() fails closed on an empty token by
design (fujibee#906: a bridge that cannot publish an enumerable lease must not go on
to arm its network, or it becomes exactly the unreapable orphan the lease
exists to prevent). So the fail-closed was correct; the missing source was not.

Adds Process.StartTime.Ticks via PowerShell as the Windows source, in both
codex-bridge.js startToken() and codex-bridge-launcher.sh _start_token, and
admits "pwsh" in the lease schema.

ONE source, not a preference list
---------------------------------

WMIC's CreationDate is ~2x cheaper (0.36s vs 0.83s measured) and was the first
implementation, but it is deprecated and already absent from Windows 11 installs
that have dropped the Feature-on-Demand.

The problem is not that WMIC may be missing. It is that a per-side "WMIC, else
PowerShell" order lets the writer and the reaper resolve DIFFERENT sources for
the SAME process whenever only one of the two can reach wmic.exe. Their tokens
then differ by FORMAT, so the reaper reads a live bridge's lease as some other
process's and never collects the orphan. This was reproduced, not theorised:
with wmic shadowed on one side only, the two sides returned

  node: src=wmic token=20260824045224.102163+540
  bash: src=pwsh token=639231439441021632

for one pid. Ticks is the one value both sides can always agree on, so WMIC's
speed is not worth the divergence and it is not used at all.

Falling back from powershell.exe to pwsh is safe for a reason that does not
apply to WMIC: both return the SAME Ticks for a given pid (measured), so the
src label names the format, not the executable. Verified that when
powershell.exe is unreachable, both sides fall through to pwsh and still agree.

Windows must not take the /proc branch
--------------------------------------

The Windows branch is taken INSTEAD of the /proc branch, not merely before it.
MSYS and Cygwin do expose a working /proc -- field 22 and all -- but it is keyed
by the emulation layer's own pid space, while a lease records the Windows pid
codex-bridge.js sees as process.pid. Measured on MINGW64: the same shell is MSYS
pid 3065729 and winpid 1456. Letting /proc win would silently return the start
time of whatever unrelated MSYS process sits at that number, which is the
recycled-pid confusion the token exists to prevent, only harder to notice
because nothing errors.

Verification
------------

Windows 11 26200, MINGW64, node 22 (win32), codex-cli 0.149.0.

- node startToken() and shell _start_token return byte-identical
  "pwsh<TAB>639231441791462826" for the same live Windows pid; likewise when
  powershell.exe is unreachable and both fall through to pwsh
- _read_lease: 8 cases pass -- pwsh integer accepted, pwsh non-integer and
  empty rejected, wmic-format rejected, proc/ps regressions still accepted,
  proc non-numeric and an unknown src still rejected
- end to end: bridge starts (alive, armed), publishes
  start=639231452158110466 startsrc=pwsh, and an agmsg message sent to the role
  drives a turn and gets an answer back. Before this change the same sequence
  produced only the token error
- tests/test_codex_bridge_launcher.bats gives byte-identical results with and
  without this change on this machine (10 of 12 fail either way; the suite does
  not run on MSYS). NOT validated on Linux or macOS -- the POSIX branches are
  untouched, but that is an argument, not a measurement
…rim the rationale to the PR

Adds the regression tests the previous commit described but did not commit, and
moves the evidence behind the WMIC decision out of the code and into the PR.

Tests
-----

`_read_lease` is the reaper's only gate on a lease, so its accept/reject set is
the contract that widening `proc|ps` to `proc|ps|pwsh` changes. The six cases
exercise it directly -- the pattern tests/test_remote.bats already uses for
`_remote_endpoint_display` -- rather than through the reaper, which needs a
spawnable bridge and a live pid. That is exactly what does not work on Git Bash
(fujibee#567), and the schema question depends on neither.

Only the case that actually runs PowerShell carries `windows-native` in its name,
so it lands on the Windows leg; the schema cases carry no such marker and run on
every leg.

What each case is for, stated plainly rather than as "6 tests pass":

| case | with the change | without it |
|---|---|---|
| a pwsh lease parses | ok | **fails** |
| windows-native: a live pid yields an integer pwsh token | ok | **fails** |
| a pwsh token that is not an integer is rejected | ok | ok |
| an unrecognised startsrc is rejected | ok | ok |
| proc and ps still parse; proc non-numeric still rejected | ok | ok |
| POSIX: a live pid yields a proc or ps token | skip on Windows | skip |

Two detect the change; four are guards. The `wmic` label is in the rejection case
deliberately and not as an arbitrary bad value: it pins the decision not to adopt
WMIC, so reintroducing it fails loudly.

Comments
--------

The WMIC rationale ran 22 lines in codex-bridge.js and 20 in the launcher. The
conclusion belongs in the code; the measurements behind it (the reproduced
divergence, MSYS pid 3065729 vs winpid 1456) belong in the PR, where a reader
looking for *why* will be. Trimmed to 10 and 14 lines with no fact dropped from
the argument itself.

Verification
------------

Windows 11 26200, MINGW64, bats-core 1.14.0 from a clone.

- the six cases: all pass; re-run against `dbb9c2c2`'s codex/ tree, the two
  change-detecting cases fail as tabled above
- NOT `bats tests/` green on this machine, and that is not achievable here:
  .github/workflows/tests.yml shards the suite over `[ubuntu-latest,
  macos-latest]` only, and the Windows leg runs `filter: "windows-native"`. The
  full file has never been expected to pass under Git Bash
- `launcher: windows-native starts the bridge (fujibee#567)` fails here with and without
  this change. It asserts the half of fujibee#567 that is not fixed: on Git Bash the
  parent-liveness probe asks `tasklist` about an MSYS pid. Measured on this
  machine -- the same shell is MSYS pid 3994449 and winpid 19568; `tasklist`
  finds only the latter, `kill -0` only the former. The workflow itself lists
  `windows runtime (fujibee#567)` among its known intermittent reds
- ubuntu/macos results are NOT from this machine and are not claimed here
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant