From 1c74a7aaf19fee9d39ced8eb59e59e74df6987f8 Mon Sep 17 00:00:00 2001 From: Tony Raimond Date: Wed, 3 Jun 2026 19:32:07 -0600 Subject: [PATCH] feat(codex-probe): add update-check helper alongside known-bad warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _gstack_codex_update_check compares the locally installed Codex CLI to npm's @openai/codex `latest` tag and prints one INFO line when an upgrade is available. Wired into the same detection blocks as _gstack_codex_version_check so /ship Step 11, /autoplan, /codex, and the review-army design voice all surface a freshness signal on top of the existing known-bad-versions deny-list. Results are cached for 24h at ${GSTACK_HOME:-$HOME/.gstack}/.codex-version-check so /ship invocations don't hammer the npm registry. Network call is best-effort (`-m 5` curl timeout, all failure modes silent) — never blocks the workflow. Tony triggered this during /release-sync after PR #194 landed: codex-cli 0.122.0 → 0.136.0 silently rendered the 0.122.0 hang workaround obsolete because /ship had no codex-freshness signal — only the static known-bad list (0.120.0/1/2) caught regressions. Adds 8 tests in test/codex-hardening.test.ts covering the fresh-cache happy path, identity (local == latest), pre-release (local ahead of latest), broken codex binary, network failure (no cache written on failure), stale-cache (>24h mtime) re-fetch, independence from _gstack_codex_version_check, and the cache file write side-effect. Co-Authored-By: Claude Opus 4.7 (1M context) --- autoplan/SKILL.md | 1 + autoplan/SKILL.md.tmpl | 1 + bin/gstack-codex-probe | 43 ++++++- codex/SKILL.md | 6 +- codex/SKILL.md.tmpl | 6 +- review/SKILL.md | 6 + scripts/resolvers/design.ts | 3 + scripts/resolvers/review.ts | 6 + ship/sections/adversarial.md | 6 + ship/sections/review-army.md | 3 + test/codex-hardening.test.ts | 212 +++++++++++++++++++++++++++++++++++ 11 files changed, 290 insertions(+), 3 deletions(-) diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index 57299734a2..784e28ef0e 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -1062,6 +1062,7 @@ elif ! _gstack_codex_auth_probe >/dev/null; then _CODEX_AVAILABLE=false else _gstack_codex_version_check # non-blocking warn if known-bad + _gstack_codex_update_check # non-blocking INFO if npm 'latest' is ahead _CODEX_AVAILABLE=true fi ``` diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 888cddabbc..d6ecd0969a 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -257,6 +257,7 @@ elif ! _gstack_codex_auth_probe >/dev/null; then _CODEX_AVAILABLE=false else _gstack_codex_version_check # non-blocking warn if known-bad + _gstack_codex_update_check # non-blocking INFO if npm 'latest' is ahead _CODEX_AVAILABLE=true fi ``` diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 940dacf842..8bcd51dfcf 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -5,6 +5,7 @@ # Functions (all prefixed with _gstack_codex_ for namespace hygiene): # _gstack_codex_auth_probe — multi-signal auth check (env + file) # _gstack_codex_version_check — warn on known-bad Codex CLI versions +# _gstack_codex_update_check — INFO when an npm `latest` is available (24h cache) # _gstack_codex_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback # _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/ # @@ -49,6 +50,46 @@ _gstack_codex_version_check() { fi } +# --- Update check ----------------------------------------------------------- + +_gstack_codex_update_check() { + # Compare installed Codex CLI against the npm `latest` tag. Print one INFO + # line when an upgrade is available. Cached for 24h at + # ${GSTACK_HOME:-$HOME/.gstack}/.codex-version-check so /ship invocations + # don't hammer the registry. Best-effort: any failure (no codex, no curl, + # no jq, offline, slow registry, malformed JSON) is silent — never blocks. + local _local _latest _cache _cache_dir _now _mtime _age + _local=$(codex --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + [ -z "$_local" ] && return 0 + _cache_dir="${GSTACK_HOME:-$HOME/.gstack}" + _cache="$_cache_dir/.codex-version-check" + _now=$(date +%s 2>/dev/null || echo 0) + if [ -f "$_cache" ]; then + _mtime=$(stat -c %Y "$_cache" 2>/dev/null || stat -f %m "$_cache" 2>/dev/null || echo 0) + _age=$((_now - _mtime)) + if [ "$_age" -lt 86400 ]; then + _latest=$(head -1 "$_cache" 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + fi + fi + if [ -z "$_latest" ]; then + command -v curl >/dev/null 2>&1 || return 0 + command -v jq >/dev/null 2>&1 || return 0 + _latest=$(curl -fsSL -m 5 https://registry.npmjs.org/@openai/codex/latest 2>/dev/null \ + | jq -r '.version' 2>/dev/null \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + [ -z "$_latest" ] && return 0 + mkdir -p "$_cache_dir" 2>/dev/null + printf '%s\n' "$_latest" > "$_cache" 2>/dev/null || true + fi + # If local already wins (or ties) sort -V, there's nothing to upgrade to. + local _winner + _winner=$(printf '%s\n%s\n' "$_local" "$_latest" | sort -V 2>/dev/null | tail -1) + if [ -n "$_winner" ] && [ "$_winner" != "$_local" ]; then + echo "INFO: Codex CLI $_local available: $_latest. Upgrade: \`npm install -g @openai/codex@latest\`" + _gstack_codex_log_event "codex_update_available" + fi +} + # --- Timeout wrapper -------------------------------------------------------- _gstack_codex_timeout_wrapper() { @@ -72,7 +113,7 @@ _gstack_codex_log_event() { # Emit a telemetry event to ~/.gstack/analytics/skill-usage.jsonl. # Gated on $_TEL != "off" (caller sets this from gstack-config). # Event types: codex_timeout, codex_auth_failed, codex_cli_missing, - # codex_version_warning. + # codex_version_warning, codex_update_available. # Payload schema: {skill, event, duration_s, ts}. NEVER includes prompt # content, env var values, or auth tokens. local _event="$1" diff --git a/codex/SKILL.md b/codex/SKILL.md index 111f1ebc8d..1897f01c81 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -822,13 +822,17 @@ if ! _gstack_codex_auth_probe >/dev/null; then echo "AUTH_FAILED" fi _gstack_codex_version_check # warns if known-bad, non-blocking +_gstack_codex_update_check # INFO if npm 'latest' is ahead, non-blocking ``` 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." If the version check printed a `WARN:` line, pass it through to the user verbatim -(non-blocking — Codex may still work, but the user should upgrade). +(non-blocking — Codex may still work, but the user should upgrade). If +`_gstack_codex_update_check` printed an `INFO:` line, pass that through too — it +means a newer Codex CLI is on npm and the user can run `npm install -g +@openai/codex@latest` to pick it up. The probe multi-signal auth logic accepts: `$CODEX_API_KEY` set, `$OPENAI_API_KEY` set, or `${CODEX_HOME:-~/.codex}/auth.json` exists. Avoids false-negatives for diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index 333de7d8d5..827734528f 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -72,13 +72,17 @@ if ! _gstack_codex_auth_probe >/dev/null; then echo "AUTH_FAILED" fi _gstack_codex_version_check # warns if known-bad, non-blocking +_gstack_codex_update_check # INFO if npm 'latest' is ahead, non-blocking ``` 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." If the version check printed a `WARN:` line, pass it through to the user verbatim -(non-blocking — Codex may still work, but the user should upgrade). +(non-blocking — Codex may still work, but the user should upgrade). If +`_gstack_codex_update_check` printed an `INFO:` line, pass that through too — it +means a newer Codex CLI is on npm and the user can run `npm install -g +@openai/codex@latest` to pick it up. The probe multi-signal auth logic accepts: `$CODEX_API_KEY` set, `$OPENAI_API_KEY` set, or `${CODEX_HOME:-~/.codex}/auth.json` exists. Avoids false-negatives for diff --git a/review/SKILL.md b/review/SKILL.md index a2e1d00d65..d435fae9f5 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -1593,12 +1593,18 @@ DIFF_INS=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ insertion' DIFF_DEL=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) command -v codex >/dev/null 2>&1 && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" +# Surface known-bad versions and any pending npm 'latest' upgrade. Both +# non-blocking — they print one line each, or stay silent on a clean install. +source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null \ + && { _gstack_codex_version_check; _gstack_codex_update_check; } || true # Legacy opt-out — only gates Codex passes, Claude always runs OLD_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || true) echo "DIFF_SIZE: $DIFF_TOTAL" echo "OLD_CFG: ${OLD_CFG:-not_set}" ``` +If either probe line surfaces (WARN for known-bad, INFO for upgrade-available), pass it through to the user verbatim. Neither blocks the workflow. + If `OLD_CFG` is `disabled`: skip Codex passes only. Claude adversarial subagent still runs (it's free and fast). Jump to the "Claude adversarial subagent" section. **User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the Codex structured review regardless of diff size. diff --git a/scripts/resolvers/design.ts b/scripts/resolvers/design.ts index 9f31b36197..2fe3a0e969 100644 --- a/scripts/resolvers/design.ts +++ b/scripts/resolvers/design.ts @@ -11,6 +11,9 @@ export function generateDesignReviewLite(ctx: TemplateContext): string { \`\`\`bash command -v codex >/dev/null 2>&1 && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" +# Surface known-bad versions + pending npm upgrade. Both non-blocking. +source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null \\ + && { _gstack_codex_version_check; _gstack_codex_update_check; } || true \`\`\` If Codex is available, run a lightweight design check on the diff: diff --git a/scripts/resolvers/review.ts b/scripts/resolvers/review.ts index 0c7cb8230f..4230ef2a18 100644 --- a/scripts/resolvers/review.ts +++ b/scripts/resolvers/review.ts @@ -472,12 +472,18 @@ DIFF_INS=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ insertion' DIFF_DEL=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) command -v codex >/dev/null 2>&1 && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" +# Surface known-bad versions and any pending npm 'latest' upgrade. Both +# non-blocking — they print one line each, or stay silent on a clean install. +source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null \\ + && { _gstack_codex_version_check; _gstack_codex_update_check; } || true # Legacy opt-out — only gates Codex passes, Claude always runs OLD_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || true) echo "DIFF_SIZE: $DIFF_TOTAL" echo "OLD_CFG: \${OLD_CFG:-not_set}" \`\`\` +If either probe line surfaces (WARN for known-bad, INFO for upgrade-available), pass it through to the user verbatim. Neither blocks the workflow. + If \`OLD_CFG\` is \`disabled\`: skip Codex passes only. Claude adversarial subagent still runs (it's free and fast). Jump to the "Claude adversarial subagent" section. **User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the Codex structured review regardless of diff size. diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 4e6ad76ba0..75e06050f2 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -12,12 +12,18 @@ DIFF_INS=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ insertion' DIFF_DEL=$(git diff "$DIFF_BASE" --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) command -v codex >/dev/null 2>&1 && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" +# Surface known-bad versions and any pending npm 'latest' upgrade. Both +# non-blocking — they print one line each, or stay silent on a clean install. +source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null \ + && { _gstack_codex_version_check; _gstack_codex_update_check; } || true # Legacy opt-out — only gates Codex passes, Claude always runs OLD_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || true) echo "DIFF_SIZE: $DIFF_TOTAL" echo "OLD_CFG: ${OLD_CFG:-not_set}" ``` +If either probe line surfaces (WARN for known-bad, INFO for upgrade-available), pass it through to the user verbatim. Neither blocks the workflow. + If `OLD_CFG` is `disabled`: skip Codex passes only. Claude adversarial subagent still runs (it's free and fast). Jump to the "Claude adversarial subagent" section. **User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the Codex structured review regardless of diff size. diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index f7943d2956..611b42968a 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -111,6 +111,9 @@ Substitute: TIMESTAMP = ISO 8601 datetime, STATUS = "clean" if 0 findings or "is ```bash command -v codex >/dev/null 2>&1 && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" +# Surface known-bad versions + pending npm upgrade. Both non-blocking. +source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null \ + && { _gstack_codex_version_check; _gstack_codex_update_check; } || true ``` If Codex is available, run a lightweight design check on the diff: diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index f1c00031a4..463344c489 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -307,6 +307,218 @@ fi }); }); +// --- Group 3.5: Update check ------------------------------------------------ +// _gstack_codex_update_check compares the installed Codex CLI to npm `latest` +// and prints one INFO line when an upgrade is available. 24h cache lives at +// ${GSTACK_HOME:-$HOME/.gstack}/.codex-version-check. +// +// All tests pre-warm the cache so no network is needed — the cache hit path +// is also the path /ship hits 99% of the time in practice (first /ship per +// day pays the 5s curl tax; everything else reads the cache). + +function writeCache(home: string, latest: string): string { + const dir = path.join(home, '.gstack'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, '.codex-version-check'); + fs.writeFileSync(file, `${latest}\n`); + return file; +} + +describe('gstack-codex-probe: update check', () => { + test('stale local + fresh cache → INFO line with both versions', () => { + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.100.0\n'); + try { + writeCache(home, '0.140.0'); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { PATH: `${stub.pathEntry}:${process.env.PATH}`, GSTACK_HOME: path.join(home, '.gstack') }, + home, + }); + expect(r.stdout).toContain('INFO:'); + expect(r.stdout).toContain('0.100.0'); + expect(r.stdout).toContain('0.140.0'); + expect(r.stdout).toContain('npm install -g @openai/codex@latest'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + } + }); + + test('local == latest → silent (no INFO)', () => { + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.140.0\n'); + try { + writeCache(home, '0.140.0'); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { PATH: `${stub.pathEntry}:${process.env.PATH}`, GSTACK_HOME: path.join(home, '.gstack') }, + home, + }); + expect(r.stdout).not.toContain('INFO:'); + expect(r.stdout.trim()).toBe(''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + } + }); + + test('local ahead of cached latest (pre-release dev build) → silent', () => { + // If a user is running a dev build that's ahead of npm latest, don't badger + // them. sort -V puts them on top, so update check is silent. + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.200.0\n'); + try { + writeCache(home, '0.140.0'); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { PATH: `${stub.pathEntry}:${process.env.PATH}`, GSTACK_HOME: path.join(home, '.gstack') }, + home, + }); + expect(r.stdout).not.toContain('INFO:'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + } + }); + + test('codex --version fails (binary present but unusable) → silent', () => { + // tempStubCodex(..., true) writes a stub that exits 1, simulating a broken + // / corrupt / unauthenticated codex binary. _local stays empty → silent + // return. Equivalent observable outcome to "no codex on PATH" without + // breaking the bash environment by narrowing PATH on Windows. + const home = tempHome(); + const stub = tempStubCodex('', true); + try { + writeCache(home, '0.140.0'); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { PATH: `${stub.pathEntry}:${process.env.PATH}`, GSTACK_HOME: path.join(home, '.gstack') }, + home, + }); + expect(r.stdout).not.toContain('INFO:'); + expect(r.status).toBe(0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + } + }); + + test('network fetch fails (curl exits non-zero) → silent + no cache written', () => { + // Stub curl to exit 1 (offline, DNS fail, registry 5xx). The function must + // not write a bogus cache, and must not print an INFO line. The stub takes + // precedence over the real curl via PATH ordering. + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.100.0\n'); + const curlStubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-curl-fail-')); + try { + const curlBin = path.join(curlStubDir, 'curl'); + fs.writeFileSync(curlBin, '#!/bin/bash\nexit 1\n'); + fs.chmodSync(curlBin, 0o755); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { + PATH: `${curlStubDir}:${stub.pathEntry}:${process.env.PATH}`, + GSTACK_HOME: path.join(home, '.gstack'), + }, + home, + }); + expect(r.stdout).not.toContain('INFO:'); + expect(r.status).toBe(0); + // Critical: a failed fetch must NOT create a cache file. Otherwise the + // next run would read empty/garbage and think it's a valid version. + const cacheFile = path.join(home, '.gstack/.codex-version-check'); + expect(fs.existsSync(cacheFile)).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + fs.rmSync(curlStubDir, { recursive: true, force: true }); + } + }); + + test('stale cache (>24h old mtime) is ignored — re-fetches from network', () => { + // Pre-warm cache with 0.140.0, then backdate mtime to 48h ago. Stub curl + // to fail so the function can't re-fetch. If the function trusted the + // stale cache, it would print INFO (0.100.0 vs 0.140.0). It must not. + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.100.0\n'); + const curlStubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-curl-stale-')); + try { + const cache = writeCache(home, '0.140.0'); + const past = (Date.now() / 1000) - 48 * 3600; + fs.utimesSync(cache, past, past); + const curlBin = path.join(curlStubDir, 'curl'); + fs.writeFileSync(curlBin, '#!/bin/bash\nexit 1\n'); + fs.chmodSync(curlBin, 0o755); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { + PATH: `${curlStubDir}:${stub.pathEntry}:${process.env.PATH}`, + GSTACK_HOME: path.join(home, '.gstack'), + }, + home, + }); + expect(r.stdout).not.toContain('INFO:'); + expect(r.status).toBe(0); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + fs.rmSync(curlStubDir, { recursive: true, force: true }); + } + }); + + test('version_check + update_check are independent — bad version AND outdated both fire', () => { + // 0.120.0 hits the known-bad list AND is older than the cached 0.140.0. + // Both messages must surface; one does not eat the other. + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.120.0\n'); + try { + writeCache(home, '0.140.0'); + const r = runProbe({ + snippet: '_gstack_codex_version_check; _gstack_codex_update_check', + env: { PATH: `${stub.pathEntry}:${process.env.PATH}`, GSTACK_HOME: path.join(home, '.gstack') }, + home, + }); + expect(r.stdout).toContain('WARN:'); + expect(r.stdout).toContain('INFO:'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + } + }); + + test('cache miss writes the fetched version to the cache file', () => { + // Stub curl to return canned npm registry JSON; stub jq stays the real one. + // Verifies the side effect (file write) the cache-hit tests assume. + const home = tempHome(); + const stub = tempStubCodex('codex-cli 0.100.0\n'); + const curlStubDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-curl-stub-')); + try { + const curlBin = path.join(curlStubDir, 'curl'); + // Fake curl ignores all args and prints the smallest npm 'latest' JSON. + fs.writeFileSync(curlBin, '#!/bin/bash\nprintf \'{"version":"0.150.0"}\'\n'); + fs.chmodSync(curlBin, 0o755); + const r = runProbe({ + snippet: '_gstack_codex_update_check', + env: { + PATH: `${curlStubDir}:${stub.pathEntry}:${process.env.PATH}`, + GSTACK_HOME: path.join(home, '.gstack'), + }, + home, + }); + expect(r.stdout).toContain('INFO:'); + expect(r.stdout).toContain('0.150.0'); + const cacheFile = path.join(home, '.gstack/.codex-version-check'); + expect(fs.existsSync(cacheFile)).toBe(true); + expect(fs.readFileSync(cacheFile, 'utf-8').trim()).toBe('0.150.0'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(stub.dir, { recursive: true, force: true }); + fs.rmSync(curlStubDir, { recursive: true, force: true }); + } + }); +}); + // --- Group 4: Telemetry event emission -------------------------------------- describe('gstack-codex-probe: telemetry event emission', () => {