Skip to content

fix(codex): a CLI that cannot execute no longer reports CODEX_MODE: ready - #2745

Draft
ukheni50 wants to merge 2 commits into
garrytan:mainfrom
ukheni50:fix/codex-preflight-broken-install
Draft

fix(codex): a CLI that cannot execute no longer reports CODEX_MODE: ready#2745
ukheni50 wants to merge 2 commits into
garrytan:mainfrom
ukheni50:fix/codex-preflight-broken-install

Conversation

@ukheni50

@ukheni50 ukheni50 commented Aug 31, 2026

Copy link
Copy Markdown

Fixes #2742.

Why (in your own words)

Codex passes can be skipped for months without anyone noticing. @openai/codex was installed and on my PATH, but node_modules/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/codex/ was an empty directory — the binary had been removed some time after install while its sibling vendor/.../path/rg stayed intact. Every invocation died with spawn ... ENOENT, and gstack reported CODEX_MODE: ready throughout. /ship, /review and /autoplan skipped their Codex passes silently and reported themselves complete. Reviews I believed were cross-model were single-model for two months.

The cause of the deletion was local and isn't the point. The detection gap is: #2477 added _gstack_codex_model_probe precisely so "auth exists" would stop being mistaken for "Codex works", and it does a real round trip — but its final branch is the else of a "model 400" grep. A spawn ENOENT has neither a 400 nor the word "model", so it landed in the bucket whose comment reads "Timeout (124) or transient failure: fail-open... not to gate on network luck." That reasoning is right for a timeout. A missing binary is deterministic: retrying never helps.

This adds a broken_install state, keeps fail-open for genuine transients, and stops the version check reading a broken CLI as healthy.

Live evidence

Before — unmodified main (253d1df), with a codex on PATH that cannot execute:

$ bash /tmp/codex-evidence.sh <main's bin/gstack-codex-probe>
MODEL_PROBE_INCONCLUSIVE (exit 1) — proceeding; if invocations fail with a model 400, see the codex skill's Error Handling entry.
CODEX_MODE: ready

A CLI that cannot run resolves to ready, and the one hint printed points the user at their model configuration.

After — this branch, same shim, same chain:

$ bash /tmp/codex-evidence.sh <this branch's bin/gstack-codex-probe>
MODEL_UNUSABLE_INSTALL
Error: spawn /opt/.../vendor/aarch64-apple-darwin/codex/codex ENOENT
  errno: -2, code: 'ENOENT'
HINT: the Codex CLI is on PATH but cannot run — its binary or vendor payload is missing.
HINT: reinstall with: npm install -g @openai/codex
CODEX_MODE: broken_install

The shim reproduces the real failure exactly (node's spawn dump on stderr, non-zero exit) and the chain is the one from scripts/resolvers/constants.ts, run verbatim.

All three original probes passed on the broken install — this is what the issue reproduces, and why nothing caught it:

AUTH_OK                             auth: 0
MODEL_PROBE_INCONCLUSIVE (exit 1)   model: 0   ← fails open
(silent)                            version: 0

Tests — the suites that cover the changed files:

$ bun test test/codex-model-probe.test.ts test/codex-hardening.test.ts \
    test/codex-web-search-flag.test.ts test/codex-under-codex-detection.test.ts \
    test/setup-codex-model.test.ts test/gen-skill-docs.test.ts \
    test/skill-validation.test.ts test/skill-cross-model-recommendation-emit.test.ts
 845 pass
 0 fail
 8051 expect() calls

$ bun test test/host-config.test.ts        # golden fixtures
 76 pass
 0 fail

The fix caught its own regression. My first version of the version-check warned whenever $_ver was empty, which broke the existing empty output → OK (silent, no crash) case — that behavior is deliberate, so the condition is now narrowed to a non-zero exit only. The real broken CLI exits non-zero, so the detection is unaffected.

Scope

  • Changed:
    • bin/gstack-codex-probe_gstack_codex_model_probe classifies deterministic install failures (exit 126/127, or stderr matching ENOENT|ENOEXEC|EACCES|cannot execute binary file|no such file or directory|permission denied) as MODEL_UNUSABLE_INSTALL, exit 2, never cached so a reinstall is picked up on the next probe. Exit 124 and genuine transients still fail open. _gstack_codex_version_check captures codex's own exit code instead of head's (codex --version 2>/dev/null | head -1 reported the pipeline's last status) and warns on non-zero; 2>/dev/null also discarded the only diagnostic, so stderr is kept.
    • scripts/resolvers/constants.ts — the chain captures the probe's code rather than testing truthiness, so exit 2 routes to a new broken_install mode whose remedy is npm install -g @openai/codex, not "check your model pin". Regenerated the six section files and the factory ship golden.
    • test/codex-hardening.test.ts — 6 tests using the existing runProbe harness.
  • Verified live by: reproducing the failure with a PATH shim and running the real preflight chain before/after; running every suite that touches the changed files; and by the original incident on my own machine (two months of silently skipped Codex passes, found only when a doc-sync subagent reported it couldn't reach Codex).
  • Did NOT test: a real partially-deleted npm install (simulated via PATH shim instead — the shim reproduces the observed stderr and exit code); Windows and Linux paths; whether broken_install should also be surfaced by /autoplan's own preflight copy, if it has one.

On the full suite — the measurement, and two wrong explanations I gave before it.

Full-suite numbers on this machine:

pass fail tests time
unmodified main (253d1df) 8474 6 8944 622s
this branch 8360 115 8939 1200s

Every one of the 115 is a browse test. I twice guessed at a mechanism and was wrong both times — first "two suites running concurrently", disproved when a non-concurrent run failed the same way; then "orphaned browse daemons holding the lock", disproved when I reaped every one of them (pgrep -f <clone> → 0) and a clean run still failed 115. I am not offering a third guess.

Here is the controlled comparison instead. Same browse suite, both clones, back to back, daemons killed between runs:

$ cd gstack-base && bun test browse/test/commands.test.ts     # unmodified main
 242 pass
 2 fail
Ran 244 tests across 1 file. [30.62s]

$ cd gstack-pr && bun test browse/test/commands.test.ts       # this branch
 242 pass
 2 fail
Ran 244 tests across 1 file. [30.65s]

Identical, to the test and to a third of a second. The same two fail on both — Path traversal prevention > eval rejects path traversal with .. and > cookie-import rejects path traversal — so they are pre-existing on main, not from this change.

So: browse behaves the same with and without this diff when measured in isolation. The full-suite difference is variance in how this machine runs all 592 files together; whatever drives it, it is not this change, and I would rather show you the isolation comparison than assert a cause I have not proven. Worth noting the suite is already known to be non-deterministic here (#2536, #2597).

What the diff can actually reach is green. It is 10 files — bin/gstack-codex-probe, 6 generated section .mds, scripts/resolvers/constants.ts, test/codex-hardening.test.ts, and the factory golden — nothing in browse imports any of them, and every suite that covers them passes:

845 pass / 0 fail   the 8 suites touching the changed files
 76 pass / 0 fail   test/host-config.test.ts (golden fixtures)
 52 pass / 0 fail   test/codex-hardening.test.ts (incl. the 6 new)

The one non-browse failure across all my runs, setup: ensure_emoji_font ... (color=False), passes in isolation on both this branch and main.

I am flagging the corrections rather than quietly editing them, since a maintainer may have read the earlier versions.

Liveness proof (required)

⚠️ Not yet attached — the repo owner will add it before this leaves draft.

This PR was prepared with an AI coding agent on the author's machine. The liveness screenshot exists to confirm a human opened the PR, so it would defeat its own purpose for the agent to produce one; it is deliberately left for the human author rather than faked. Marked as a draft until then.

Checklist

Udhdhav kheni added 2 commits August 31, 2026 15:33
…eady

Follow-up to garrytan#2477. The model probe it added does a real round trip, but its
final branch is the `else` of a "model 400" grep, so it swallowed spawn ENOENT,
non-executable binaries and missing vendor payloads alongside genuine network
timeouts. All three are deterministic — retrying never helps — yet they landed
in the fail-open bucket and resolved to `ready`, so every Codex pass was
skipped in silence and the review reported itself complete.

Observed live: @openai/codex was on PATH with an empty
vendor/aarch64-apple-darwin/codex/ directory. gstack said `ready` for two
months while no Codex pass ran.

Three changes:

- `_gstack_codex_model_probe` classifies deterministic install failures (exit
  126/127, or stderr matching ENOENT/ENOEXEC/EACCES/"cannot execute binary
  file") as MODEL_UNUSABLE_INSTALL, exit 2, never cached — a reinstall is
  picked up on the next probe. Exit 124 and genuine transients still fail open,
  which is what garrytan#2477 intended.

- The preflight chain captures the probe's code instead of testing it for
  truthiness, so exit 2 routes to a new `broken_install` mode whose remedy is
  `npm install -g @openai/codex` rather than "check your model pin". A missing
  binary and an unusable model are different problems with different fixes.

- `_gstack_codex_version_check` no longer reads a broken CLI as healthy. It ran
  `codex --version 2>/dev/null | head -1`, which captures head's status, not
  codex's — and 2>/dev/null discarded the one diagnostic available. It now
  captures the real exit code and warns on non-zero. Empty-but-successful
  output stays silent, per the existing "empty output → OK" case.

Tests: 6 added to test/codex-hardening.test.ts covering both broken-install
shapes, the exit-2 contract, no caching, the transient still failing open, the
model 400 still classifying as MODEL_UNUSABLE, and the version-check warning.
845 pass / 0 fail across all 8 suites touching the changed files.

Closes garrytan#2742
The preflight chain is embedded in the ship skill, so changing it changes the
rendered SKILL.md. Only the factory golden moved; the claude golden tracks the
committed render (already regenerated) and the codex one was unaffected.

The diff is exactly the new branch and its mode documentation.
@trunk-io

trunk-io Bot commented Aug 31, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted 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.

Codex preflight reports CODEX_MODE: ready when the codex binary is missing — all three probes fail open on spawn ENOENT

1 participant