diff --git a/scripts/drivers/types/codex/codex-bridge-launcher.sh b/scripts/drivers/types/codex/codex-bridge-launcher.sh index 98fb410ef..4eac7ed41 100755 --- a/scripts/drivers/types/codex/codex-bridge-launcher.sh +++ b/scripts/drivers/types/codex/codex-bridge-launcher.sh @@ -407,9 +407,44 @@ _REAP_WAIT_TICKS=50 # recycled pid is always distinguishable from the one we leased. # else -> `ps -o lstart=` (second precision). Echoes ""; # returns non-zero (indeterminable) so the caller fails closed. +# win32 -> Process.StartTime.Ticks via PowerShell -- the same single source +# codex-bridge.js startToken() writes, so both sides always agree. +# WMIC is deprecated and already absent from some Windows 11 installs; +# a per-side "WMIC, else PowerShell" order would let the two record +# differently-FORMATTED tokens for the same process. powershell.exe +# and pwsh return identical Ticks, so falling back between those two +# binaries introduces no such divergence. +# +# The Windows branch is taken INSTEAD of the /proc branch, not merely before it: +# MSYS/Cygwin do expose a working /proc, 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. Letting /proc win would return the start time of whatever +# unrelated MSYS process sits at that number -- the recycled-pid confusion this +# token exists to prevent, with nothing to signal it. +_agmsg_is_windows() { + case "${_AGMSG_UNAME_S:=$(uname -s 2>/dev/null || echo unknown)}" in + MINGW*|MSYS*|CYGWIN*|CLANGARM*) return 0 ;; + *) return 1 ;; + esac +} + _start_token() { - local pid="$1" s r tok + local pid="$1" s r tok bin local -a a + if _agmsg_is_windows; then + for bin in powershell.exe pwsh; do + # `tr -d '\r'` because PowerShell writes CRLF and Node's .trim() on the + # writer side strips it there, so both end up with the bare digits. + tok="$("$bin" -NoProfile -NonInteractive -Command \ + "(Get-Process -Id $pid).StartTime.Ticks" 2>/dev/null | tr -d '\r' | head -n 1)" + tok="${tok#"${tok%%[![:space:]]*}"}" + tok="${tok%"${tok##*[![:space:]]}"}" + case "$tok" in ''|*[!0-9]*) continue ;; esac + printf 'pwsh\t%s' "$tok" + return 0 + done + return 1 + fi if [ -r "/proc/$pid/stat" ]; then s="$(cat "/proc/$pid/stat" 2>/dev/null)" || return 1 r="${s##*)}" @@ -433,7 +468,7 @@ _start_token() { # Parse a lease under an EXACT v=1 schema and fail closed on anything else. Sets # lproj/lpairs/lhost/lpid/lstart/lstartsrc and returns 0 only when the file is # precisely the seven expected keys, each once, no unknown or duplicate or extra -# line, hashes 40-hex, pid numeric, startsrc proc|ps. A malformed, truncated, or +# line, hashes 40-hex, pid numeric, startsrc proc|ps|pwsh. A malformed, truncated, or # tampered lease returns non-zero, so the reaper never kills on a doubtful one. _read_lease() { local file="$1" line k v nlines=0 lv="" @@ -460,10 +495,16 @@ _read_lease() { case "$lproj" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#lproj}" -eq 40 ] || return 1 case "$lpairs" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#lpairs}" -eq 40 ] || return 1 case "$lpid" in *[!0-9]*|"") return 1 ;; esac - case "$lstartsrc" in proc|ps) ;; *) return 1 ;; esac + case "$lstartsrc" in proc|ps|pwsh) ;; *) return 1 ;; esac [ -n "$lhost" ] || return 1 [ -n "$lstart" ] || return 1 - if [ "$lstartsrc" = proc ]; then case "$lstart" in *[!0-9]*|"") return 1 ;; esac; fi + # proc's starttime ticks and .NET's StartTime.Ticks are both bare integers, so + # a lease carrying anything else under either label fails closed here. ps stays + # exempt: its lstart is a human date string whose punctuation varies by + # platform, and writer and reader only ever compare it byte for byte. + case "$lstartsrc" in + proc|pwsh) case "$lstart" in *[!0-9]*|"") return 1 ;; esac ;; + esac return 0 } diff --git a/scripts/drivers/types/codex/codex-bridge.js b/scripts/drivers/types/codex/codex-bridge.js index 9cda16b3d..3f0aacf3e 100755 --- a/scripts/drivers/types/codex/codex-bridge.js +++ b/scripts/drivers/types/codex/codex-bridge.js @@ -1087,7 +1087,34 @@ class CodexBridge { // and the only victim would be a same-(project,pair) bridge in the // sub-ms window before it overwrites this pid's lease, self-corrected // by the launcher respawning it. + // win32 -> Process.StartTime.Ticks, read through PowerShell. Windows has no + // /proc, and MSYS's `ps` rejects -o outright, so both POSIX sources + // yield an empty token and no lease can be published at all. + // ONE source, not a preference list: WMIC is deprecated and already + // absent from some Windows 11 installs, and letting each side choose + // between WMIC and PowerShell independently would let this writer and + // _start_token (codex-bridge-launcher.sh) record differently- + // FORMATTED tokens for the same process. powershell.exe and pwsh + // return identical Ticks, so falling back between those two binaries + // is safe: the src label names the format, not the executable. startToken() { + if (process.platform === "win32") { + for (const bin of ["powershell.exe", "pwsh"]) { + const r = spawnSync( + bin, + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${process.pid}).StartTime.Ticks`, + ], + { encoding: "utf8" }, + ); + const ticks = (r.status === 0 ? (r.stdout || "") : "").trim(); + if (/^\d+$/.test(ticks)) return { src: "pwsh", token: ticks }; + } + return { src: "pwsh", token: "" }; + } try { const stat = fs.readFileSync(`/proc/${process.pid}/stat`, "utf8"); const after = stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/); diff --git a/tests/test_codex_bridge_launcher.bats b/tests/test_codex_bridge_launcher.bats index 0e8de0398..81437f0e9 100644 --- a/tests/test_codex_bridge_launcher.bats +++ b/tests/test_codex_bridge_launcher.bats @@ -697,3 +697,100 @@ _fake_alice_lease() { # sets FAKE_PID once its lease file exists kill -0 "$victim" kill "$victim" "$disp" "$parent" 2>/dev/null || true; wait "$disp" 2>/dev/null || true; wait "$victim" 2>/dev/null || true } + +# --- Windows start token: the lease schema must admit the source +# codex-bridge.js writeLease() records on Windows, where /proc does not exist and +# the only `ps` likely to be on PATH (MSYS's) rejects -o outright, so both POSIX +# sources yield an empty token and the bridge can never publish a lease at all. +# +# _read_lease is the reaper's ONLY gate on a lease, so its accept/reject set is +# the contract. These exercise it directly -- the pattern test_remote.bats uses +# for _remote_endpoint_display -- rather than through the reaper: the reaper +# needs a spawnable bridge and a live pid, which is exactly what does not work on +# Git Bash (#567), and the schema question has nothing to do with either. Kept +# out of the `windows-native` filter deliberately: nothing here runs PowerShell, +# so these belong on every leg, not only the Windows one. --- +_lease_verdict() { # -> prints accept|reject + local h40=0123456789abcdef0123456789abcdef01234567 + printf 'v=1\nproject=%s\npairs=%s\nhost=h\npid=123\nstart=%s\nstartsrc=%s\n' \ + "$h40" "$h40" "$2" "$1" > "$TEST_SKILL_DIR/lease-under-test" + bash -c ' + pattern="/^_read_lease() {/,/^}/p" + eval "$(sed -n "$pattern" "$1")" + _read_lease "$2" && echo accept || echo reject + ' _ "$LAUNCHER" "$TEST_SKILL_DIR/lease-under-test" 2>/dev/null +} + +@test "launcher: the lease schema admits a pwsh start token" { + [ "$(_lease_verdict pwsh 639231441791462826)" = accept ] +} + +@test "launcher: a pwsh lease whose token is not an integer is rejected, fail-closed" { + # .NET Ticks is a bare integer. Anything else under that label is a lease this + # side did not write, and a doubtful lease must never authorise a kill. + [ "$(_lease_verdict pwsh 6392314.5)" = reject ] + [ "$(_lease_verdict pwsh '')" = reject ] +} + +@test "launcher: an unrecognised startsrc is rejected, fail-closed" { + # wmic is here on purpose, not as an arbitrary bad value: WMIC's CreationDate + # was the faster candidate and was deliberately NOT adopted, because a per-side + # "WMIC, else PowerShell" order lets the writer and the reaper resolve different + # sources for the same process whenever only one of them can reach wmic.exe. + # Rejecting the label pins that decision, so reintroducing it fails loudly. + [ "$(_lease_verdict wmic 20260824041348.411807+540)" = reject ] + [ "$(_lease_verdict bogus 123)" = reject ] +} + +@test "launcher: proc and ps leases still parse (start-token regression)" { + [ "$(_lease_verdict proc 396341883)" = accept ] + [ "$(_lease_verdict ps 'Sun Aug 24 04:00:00 2026')" = accept ] + # ps stays exempt from the integer check (its token is a human date string + # whose punctuation varies by platform); proc does not. + [ "$(_lease_verdict proc abc)" = reject ] +} + +_run_start_token() { # -> runs _start_token in a subshell + run bash -c ' + pattern="/^_agmsg_is_windows() {/,/^}/p;/^_start_token() {/,/^}/p" + eval "$(sed -n "$pattern" "$1")" + _start_token "$2" + ' _ "$LAUNCHER" "$1" +} + +@test "launcher: a live pid yields a proc or ps start token on POSIX" { + skip_on_windows "Windows has its own source; see the windows-native case" + _run_start_token $$ + [ "$status" -eq 0 ] + local tab; tab=$(printf '\t') + case "${output%%"$tab"*}" in proc|ps) ;; *) false ;; esac + [ -n "${output#*"$tab"}" ] +} + +@test "launcher: CLANGARM uname selects the Windows start token path" { + local stubdir="$TEST_SKILL_DIR/clangarm-bin" + mkdir -p "$stubdir" + printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" CLANGARM64_NT-10.0' > "$stubdir/uname" + printf '%s\n' '#!/usr/bin/env bash' 'printf "%s\n" 639231441791462826' > "$stubdir/powershell.exe" + chmod +x "$stubdir/uname" "$stubdir/powershell.exe" + + PATH="$stubdir:$PATH" _run_start_token 123 + [ "$status" -eq 0 ] + [ "$output" = $'pwsh\t639231441791462826' ] +} + +@test "launcher: windows-native a live pid yields an integer pwsh start token" { + skip_unless_windows "PowerShell and the Windows pid space are the point" + # The pid must be the WINDOWS one. MSYS/Cygwin number processes in their own + # space -- the same shell is MSYS pid 3994449 and winpid 19568 on our runner -- + # and Get-Process only knows the latter, which is also the pid + # codex-bridge.js records as process.pid. + local winpid; winpid="$(cat /proc/$$/winpid)" + [ -n "$winpid" ] + _run_start_token "$winpid" + [ "$status" -eq 0 ] + local tab; tab=$(printf '\t') + [ "${output%%"$tab"*}" = pwsh ] + local tok="${output#*"$tab"}" + case "$tok" in ''|*[!0-9]*) false ;; esac +}