From ae47c29b82f8b1e402dff12b4c15630ca35d7982 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 16 Aug 2026 10:44:18 -0700 Subject: [PATCH] fix(review): skip nested codex spawns when already running under a Codex host (#2519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /review executed inside a Codex host spawned the codex specialist passes anyway — the same model reviewing itself, at multiplied cost (observed: 15M tokens for a single /review). Detection per maintainer decision 7: a presence probe of the Codex session env. A live Codex session exports CODEX_THREAD_ID and CODEX_SANDBOX into every shell it spawns — verified during implementation against a live `codex exec 'env | grep -i codex'` capture on codex 0.147.0 (CODEX_THREAD_ID, CODEX_SANDBOX=seatbelt, CODEX_SANDBOX_NETWORK_DISABLED=1, CODEX_CI=1). The shared codexPreflight in scripts/resolvers/constants.ts (consumed by all three review.ts army blocks: adversarial, codex plan review, codex doc review) now yields CODEX_MODE=under_codex and instructs exactly one printed notice — '[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]'. The override env var forces the nested passes for users who really want them. codex/SKILL.md.tmpl Step 0.5 gains the same probe: /codex under a Codex host stops with a one-line notice, since its whole value is a SECOND model's opinion. test/codex-under-codex-detection.test.ts runs the rendered preflight bash under all four env combinations (thread-id only, sandbox only, forced, clean) and asserts the probe + notice render in the three preflight consumers and the codex skill. Fixes #2519 Co-Authored-By: Claude Fable 5 --- codex/SKILL.md | 13 ++- codex/SKILL.md.tmpl | 13 ++- document-release/sections/release-body.md | 9 ++ plan-ceo-review/sections/review-sections.md | 9 ++ plan-devex-review/sections/review-sections.md | 9 ++ plan-eng-review/sections/review-sections.md | 9 ++ review/SKILL.md | 9 ++ scripts/resolvers/constants.ts | 9 ++ ship/sections/adversarial.md | 9 ++ test/codex-under-codex-detection.test.ts | 104 ++++++++++++++++++ test/fixtures/golden/factory-ship-SKILL.md | 9 ++ 11 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 test/codex-under-codex-detection.test.ts diff --git a/codex/SKILL.md b/codex/SKILL.md index 8d0b40261..f171320b1 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -895,7 +895,11 @@ known-bad list. Sourcing `gstack-codex-probe` loads the shared helpers that both _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off) source ~/.claude/skills/gstack/bin/gstack-codex-probe -if ! _gstack_codex_auth_probe >/dev/null; then +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns. +if [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + echo "UNDER_CODEX" +elif ! _gstack_codex_auth_probe >/dev/null; then _gstack_codex_log_event "codex_auth_failed" echo "AUTH_FAILED" else @@ -904,6 +908,13 @@ fi _gstack_codex_version_check # warns if known-bad, non-blocking ``` +If the output contains `UNDER_CODEX`, stop with exactly one line: +"[running under Codex — /codex would nest the same model at multiplied token +cost; skipped. Set `GSTACK_FORCE_CODEX_REVIEW=1` to force.]" The whole value +of this skill is a SECOND model's opinion; inside a Codex host it is the same +model reviewing itself, and nested spawns have burned 15M tokens in one +/review (#2519). + If the output contains `AUTH_FAILED`, stop and tell the user: "No Codex authentication found. Run `codex login` or set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, then re-run this skill." diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index bb54dca12..e641fe283 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -68,7 +68,11 @@ known-bad list. Sourcing `gstack-codex-probe` loads the shared helpers that both _TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off) source ~/.claude/skills/gstack/bin/gstack-codex-probe -if ! _gstack_codex_auth_probe >/dev/null; then +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns. +if [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + echo "UNDER_CODEX" +elif ! _gstack_codex_auth_probe >/dev/null; then _gstack_codex_log_event "codex_auth_failed" echo "AUTH_FAILED" else @@ -77,6 +81,13 @@ fi _gstack_codex_version_check # warns if known-bad, non-blocking ``` +If the output contains `UNDER_CODEX`, stop with exactly one line: +"[running under Codex — /codex would nest the same model at multiplied token +cost; skipped. Set `GSTACK_FORCE_CODEX_REVIEW=1` to force.]" The whole value +of this skill is a SECOND model's opinion; inside a Codex host it is the same +model reviewing itself, and nested spawns have burned 15M tokens in one +/review (#2519). + If the output contains `AUTH_FAILED`, stop and tell the user: "No Codex authentication found. Run `codex login` or set `$CODEX_API_KEY` / `$OPENAI_API_KEY`, then re-run this skill." diff --git a/document-release/sections/release-body.md b/document-release/sections/release-body.md index f240f4d25..1a5d07844 100644 --- a/document-release/sections/release-body.md +++ b/document-release/sections/release-body.md @@ -377,6 +377,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -392,6 +400,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip this section entirely; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below. diff --git a/plan-ceo-review/sections/review-sections.md b/plan-ceo-review/sections/review-sections.md index 55c51c95c..fb81b92cd 100644 --- a/plan-ceo-review/sections/review-sections.md +++ b/plan-ceo-review/sections/review-sections.md @@ -270,6 +270,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -285,6 +293,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip this section entirely; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below. diff --git a/plan-devex-review/sections/review-sections.md b/plan-devex-review/sections/review-sections.md index 4e65ed858..daebf5910 100644 --- a/plan-devex-review/sections/review-sections.md +++ b/plan-devex-review/sections/review-sections.md @@ -256,6 +256,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -271,6 +279,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip this section entirely; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below. diff --git a/plan-eng-review/sections/review-sections.md b/plan-eng-review/sections/review-sections.md index cb60b5015..5ef77c7c8 100644 --- a/plan-eng-review/sections/review-sections.md +++ b/plan-eng-review/sections/review-sections.md @@ -351,6 +351,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -366,6 +374,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip this section entirely; do NOT fall back to a Claude subagent — disabled means no extra review step. Print: "Codex review skipped (codex_reviews disabled). Re-enable: `gstack-config set codex_reviews enabled`." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below. diff --git a/review/SKILL.md b/review/SKILL.md index e6aec2d17..550533299 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -1681,6 +1681,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -1696,6 +1704,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the Codex passes only; the Claude adversarial subagent below STILL runs (it is free and fast). Print: "Codex passes skipped (codex_reviews disabled) — running Claude adversarial only." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below. diff --git a/scripts/resolvers/constants.ts b/scripts/resolvers/constants.ts index f0e0c9eaa..5d2b967ea 100644 --- a/scripts/resolvers/constants.ts +++ b/scripts/resolvers/constants.ts @@ -118,6 +118,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then ${m}="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live \`codex exec 'env | grep -i codex'\` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "\${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "\${CODEX_THREAD_ID:-}" ] || [ -n "\${CODEX_SANDBOX:-}" ]; }; then + ${m}="under_codex" elif ! command -v codex >/dev/null 2>&1; then ${m}="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -133,6 +141,7 @@ echo "CODEX_MODE: $${m}" Branch on the echoed \`CODEX_MODE\`: - **\`disabled\`** — the user turned Codex reviews off (\`codex_reviews=disabled\`). ${disabledLine} - **\`not_installed\`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: \`npm install -g @openai/codex\`." Fall back to the Claude subagent path. +- **\`under_codex\`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **\`not_authed\`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run \`codex login\` or set \`$CODEX_API_KEY\`." Fall back to the Claude subagent path. - **\`model_unusable\`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale \`model =\` pin in \`~/.codex/config.toml\`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; \`[notice.model_migrations]\` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to \`ready\`. - **\`ready\`** — run the Codex pass below.`; diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 8cf99a5b4..1161fc536 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -23,6 +23,14 @@ _CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/ source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -38,6 +46,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the Codex passes only; the Claude adversarial subagent below STILL runs (it is free and fast). Print: "Codex passes skipped (codex_reviews disabled) — running Claude adversarial only." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below. diff --git a/test/codex-under-codex-detection.test.ts b/test/codex-under-codex-detection.test.ts new file mode 100644 index 000000000..b2428244e --- /dev/null +++ b/test/codex-under-codex-detection.test.ts @@ -0,0 +1,104 @@ +/** + * Running-under-Codex detection (#2519, maintainer decision 7). + * + * /review executed inside a Codex host used to spawn nested codex + * specialists — the same model reviewing itself at multiplied token cost + * (observed: 15M tokens for one /review). A live Codex session exports + * CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified + * against a live `codex exec 'env | grep -i codex'` capture on codex + * 0.147.0: CODEX_THREAD_ID, CODEX_SANDBOX=seatbelt, + * CODEX_SANDBOX_NETWORK_DISABLED=1, CODEX_CI=1). The shared codexPreflight + * presence-probes those vars and yields CODEX_MODE=under_codex, skipping + * nested spawns with a one-line notice; GSTACK_FORCE_CODEX_REVIEW=1 + * overrides. + */ +import { describe, test, expect } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { codexPreflight } from '../scripts/resolvers/constants'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +/** Extract the runnable bash from the rendered preflight (strip fences/prose). */ +function preflightBash(): string { + const rendered = codexPreflight({ disabledBehavior: 'codex-only' }); + const start = rendered.indexOf('```bash') + '```bash'.length; + const end = rendered.indexOf('```', start); + return rendered.slice(start, end); +} + +function runPreflight(env: Record): string { + const result = spawnSync('bash', ['-c', `set +e\n${preflightBash()}`], { + env: { + // Minimal PATH without codex so the not_installed branch is reachable + // and no real gstack-config/codex runs. The block's fallbacks + // (`|| echo enabled`) keep it self-contained. + PATH: '/usr/bin:/bin', + HOME: '/nonexistent-home', + ...env, + }, + timeout: 10000, + }); + return (result.stdout ?? '').toString(); +} + +describe('under-codex detection bash (#2519)', () => { + test('CODEX_THREAD_ID present -> under_codex', () => { + const out = runPreflight({ CODEX_THREAD_ID: '01a00ba9-ff91-7143-b424-c2d9b0cc89ff' }); + expect(out).toContain('CODEX_MODE: under_codex'); + }); + + test('CODEX_SANDBOX present (no thread id) -> under_codex', () => { + const out = runPreflight({ CODEX_SANDBOX: 'seatbelt' }); + expect(out).toContain('CODEX_MODE: under_codex'); + }); + + test('GSTACK_FORCE_CODEX_REVIEW=1 overrides the presence probe', () => { + const out = runPreflight({ + CODEX_THREAD_ID: '01a00ba9-ff91-7143-b424-c2d9b0cc89ff', + CODEX_SANDBOX: 'seatbelt', + GSTACK_FORCE_CODEX_REVIEW: '1', + }); + expect(out).not.toContain('CODEX_MODE: under_codex'); + // With codex absent from the restricted PATH, the forced probe falls + // through to the ordinary availability chain. + expect(out).toContain('CODEX_MODE: not_installed'); + }); + + test('no CODEX_* env -> ordinary availability chain', () => { + const out = runPreflight({}); + expect(out).not.toContain('CODEX_MODE: under_codex'); + expect(out).toContain('CODEX_MODE: not_installed'); + }); +}); + +describe('under-codex wiring renders (#2519)', () => { + test('rendered adversarial section carries the probe + override + notice', () => { + const rendered = fs.readFileSync( + path.join(ROOT, 'ship', 'sections', 'adversarial.md'), + 'utf-8', + ); + expect(rendered).toContain('CODEX_THREAD_ID'); + expect(rendered).toContain('GSTACK_FORCE_CODEX_REVIEW'); + expect(rendered).toContain('under_codex'); + expect(rendered).toContain('nested codex passes skipped'); + }); + + test('rendered codex skill stops with the one-line notice when under codex', () => { + const rendered = fs.readFileSync(path.join(ROOT, 'codex', 'SKILL.md'), 'utf-8'); + expect(rendered).toContain('UNDER_CODEX'); + expect(rendered).toContain('GSTACK_FORCE_CODEX_REVIEW=1'); + }); + + test('all three codexPreflight consumers render the probe', () => { + for (const file of [ + path.join(ROOT, 'ship', 'sections', 'adversarial.md'), + path.join(ROOT, 'plan-ceo-review', 'sections', 'review-sections.md'), + path.join(ROOT, 'document-release', 'sections', 'release-body.md'), + ]) { + const rendered = fs.readFileSync(file, 'utf-8'); + expect(rendered).toContain('under_codex'); + } + }); +}); diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 66b1f8cb6..1bdf9f5e9 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -2497,6 +2497,14 @@ _CODEX_CFG=$($GSTACK_ROOT/bin/gstack-config get codex_reviews 2>/dev/null || ech source $GSTACK_ROOT/bin/gstack-codex-probe 2>/dev/null || true if [ "$_CODEX_CFG" = "disabled" ]; then _CODEX_MODE="disabled" +# Running-under-Codex presence probe (#2519): a live Codex session exports +# CODEX_THREAD_ID / CODEX_SANDBOX into every shell it spawns (verified +# against a live `codex exec 'env | grep -i codex'` capture, codex 0.147.0). +# Nested codex spawns from inside a Codex host multiply token burn +# (observed: one /review = 15M tokens). GSTACK_FORCE_CODEX_REVIEW=1 forces +# the nested passes anyway. +elif [ "${GSTACK_FORCE_CODEX_REVIEW:-0}" != "1" ] && { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ]; }; then + _CODEX_MODE="under_codex" elif ! command -v codex >/dev/null 2>&1; then _CODEX_MODE="not_installed"; _gstack_codex_log_event "codex_cli_missing" 2>/dev/null || true elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then @@ -2512,6 +2520,7 @@ echo "CODEX_MODE: $_CODEX_MODE" Branch on the echoed `CODEX_MODE`: - **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the Codex passes only; the Claude adversarial subagent below STILL runs (it is free and fast). Print: "Codex passes skipped (codex_reviews disabled) — running Claude adversarial only." - **`not_installed`** — Codex CLI absent. Print: "Codex not installed — using Claude subagent. Install for cross-model coverage: `npm install -g @openai/codex`." Fall back to the Claude subagent path. +- **`under_codex`** — this session is already running INSIDE a Codex host, so spawning codex again is the same model reviewing itself at multiplied token cost (#2519). Print exactly one line: "[running under Codex — nested codex passes skipped; set GSTACK_FORCE_CODEX_REVIEW=1 to force]" and skip the codex invocations below; run the section's free in-host pass instead if it defines one. - **`not_authed`** — installed but no credentials. Print: "Codex installed but not authenticated — using Claude subagent. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path. - **`model_unusable`** — authed but the account cannot use its configured model (#2477: HTTP 400 on every call, usually a stale `model =` pin in `~/.codex/config.toml`). Relay the probe's HINT lines, tell the user the one-line fix (update the pin; `[notice.model_migrations]` names the replacement), and fall back to the Claude subagent path. The ~10s round trip is cached for 1h; timeouts fail open to `ready`. - **`ready`** — run the Codex pass below.