#!/usr/bin/env bash # gstack-codex-probe: shared helper for /codex, /autoplan and every outside # review. Run it as a command, one subcommand per probe (`gstack-codex-probe # help`): it prints KEY: value lines on stdout, human status lines where they # went before, and sets nothing in the caller's shell. # # Subcommands (each wraps one function below): # select-model [--model ] CODEX_SEL:, CODEX_SEL_KIND:, CODEX_SANDBOX: # check-effort [--request ] # CODEX_EFFORT:; exit 0, or 1 on an invalid setting # check-auth exit 0 authed, 1 not # show-sandbox CODEX_SANDBOX: # check-sandbox exit 0, or 3 with the reason on stderr # probe-model [--model ] CODEX_PROBE_STATE:; exit 0, 1, 2 or 4 # role-ready [--model ] [--cwd ] # plan-review select, auth, sandbox and probe once: # CODEX_SEL:, CODEX_SEL_KIND:, CODEX_SANDBOX:, # CODEX_PROBE_STATE:; exit 0, 1, 2, 3 or 4 # check-version warnings on stderr; exit 0 # show-first-use-notice notice on stderr; exit 0 # run-with-timeout [--user-invoked] [--effort [--request-effort ]] # the command's exit code; 124 on timeout; # 3 when codex_reviews refuses the send; # --effort appends the resolved # model_reasoning_effort to a codex command; # --request-effort is /codex --effort (empty: none) # log-event [detail] silent; exit 0 # log-hang silent; exit 0 # help usage; an unknown subcommand exits 64 # # One deadline: an exported _CODEX_DEADLINE (epoch seconds) caps every # supervised command at the time left, and one that would start after it does # not run (exit 124). role-ready spends at most half of that time. # # Sourcing (compatibility window): skills rendered before the executed form # source this file and call the functions directly. That still works, prints # one deprecation line on stderr, and is removed in a release on or after # 2026-10-21 together with the zsh self-locate block below. # # Functions (all prefixed with _gstack_codex_ for namespace hygiene): # _gstack_codex_auth_probe — multi-signal auth check (env + file) # _gstack_codex_select_model — resolve + print the runtime model (#2914) # _gstack_codex_sandbox_mode — read-only, or full access only for GSTACK_CODEX_NO_SANDBOX=1 # _gstack_codex_sandbox_preflight — free `codex sandbox` check before paid calls (Linux) # _gstack_codex_first_use_notice — once per machine: provider, account, how to turn off # _gstack_codex_consent — codex_reviews gate before run-with-timeout sends # _gstack_codex_model_probe — round-trip probe of the selected model (#2477); # exit 4 = the account's Codex usage limit # _gstack_codex_lock_acquire — bounded mkdir lock with abandoned-owner recovery # _gstack_codex_role_ready — plan-review selection, auth, sandbox and model probe (role-ready) # _gstack_codex_version_check — warn on known-bad Codex CLI versions # _gstack_codex_timeout_wrapper — gtimeout -> timeout -> bash-native watchdog # _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/ # # Hygiene rules for the sourced form (enforced by test/codex-hardening.test.ts): # - Never change -e / -u / traps / IFS / PATH in the caller shell. # - All internal vars prefix with _GSTACK_CODEX_. # - All functions prefix with _gstack_codex_. # - No command execution at source time (only function defs, the shared # self-location block below, which is parameter expansion only, and the # deprecation line; its _gstack_helper* variables are the one exception to # the prefix rule and are unset once _GSTACK_CODEX_BIN is set). # --- Self-location (bash and zsh) ------------------------------------------- # Under zsh BASH_SOURCE is empty, so every "$dir/../scripts/..." path became # "/..." and Codex model selection failed. The shared block below sets # _gstack_helper_dir; inside a function zsh's %x names the function, not the # file, so it is copied once here. _gstack_helper=gstack-codex-probe # === gstack self-locate (shared, byte-identical in every sourced helper) === # Skill blocks source helpers into the host's shell: bash, or zsh on macOS # (Claude Code's Bash tool included). Resolved once at source time, using # parameter expansion only: BASH_SOURCE under bash, %x under zsh (inside eval # so bash never parses it), made absolute with $PWD; else $GSTACK_ROOT/bin # when it holds this helper. Any other case, and any other shell, fails loudly # instead of guessing; the message and the return come before any bash- or # zsh-only syntax, so dash and sh print it instead of a syntax error. _gstack_helper_dir="" _gstack_helper_shell="" if [ -n "${BASH_VERSION:-}" ]; then _gstack_helper_shell=bash _gstack_helper_dir="${BASH_SOURCE[0]}" elif [ -n "${ZSH_VERSION:-}" ]; then _gstack_helper_shell=zsh eval '_gstack_helper_dir="${(%):-%x}"' fi case "$_gstack_helper_dir" in /*) _gstack_helper_dir="${_gstack_helper_dir%/*}" ;; */*) _gstack_helper_dir="$PWD/${_gstack_helper_dir%/*}" ;; ?*) _gstack_helper_dir="$PWD" ;; esac if [ -n "$_gstack_helper_shell" ] && [ ! -f "$_gstack_helper_dir/$_gstack_helper" ]; then _gstack_helper_dir="" if [ -n "${GSTACK_ROOT:-}" ] && [ -f "$GSTACK_ROOT/bin/$_gstack_helper" ]; then _gstack_helper_dir="$GSTACK_ROOT/bin" fi fi if [ -z "$_gstack_helper_shell" ] || [ -z "$_gstack_helper_dir" ]; then _gstack_helper_error="gstack: cannot locate $_gstack_helper (shell: ${_gstack_helper_shell:-${0##*/}}). Source it from bash or zsh, or export GSTACK_ROOT=. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location" printf '%s\n' "$_gstack_helper_error" >&2 return 1 2>/dev/null || exit 1 fi # === end gstack self-locate === _GSTACK_CODEX_BIN="$_gstack_helper_dir" unset _gstack_helper _gstack_helper_dir _gstack_helper_shell # --- Auth probe ------------------------------------------------------------- _gstack_codex_auth_probe() { # Multi-signal: env vars OR auth file OR a custom provider's env_key. Avoids # false negatives for env-auth users (CI, platform engineers, self-hosted # OpenAI-compatible providers) that a file-only check would reject. local _codex_home="${CODEX_HOME:-$HOME/.codex}" # Use `-n` which returns true only for non-empty non-whitespace. Bash's [ -n ] # alone allows whitespace; pair with a whitespace strip for robustness. local _k1 _k2 _k1=$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]') _k2=$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]') if [ -n "$_k1" ] || [ -n "$_k2" ] || [ -f "$_codex_home/auth.json" ]; then echo "AUTH_OK" return 0 fi # Custom OpenAI-compatible providers (#2192): a [model_providers.] # table names its credential variable in env_key. Only keys inside those # tables count, comments are ignored, and the name must be a plain shell # identifier before it is dereferenced. local _env_key _resolved _missing="" if [ -f "$_codex_home/config.toml" ]; then while IFS= read -r _env_key; do case "$_env_key" in ''|[0-9]*|*[!A-Za-z0-9_]*) continue ;; esac # Indirect read through eval, not ${!name}: zsh rejects bash's # indirection, and _env_key is a validated identifier by this point. eval "_resolved=\${$_env_key:-}" _resolved=$(printf '%s' "$_resolved" | tr -d '[:space:]') if [ -n "$_resolved" ]; then echo "AUTH_OK" return 0 fi _missing="${_missing:+$_missing, }$_env_key" done < <(awk '/^[[:space:]]*#/ { next } /^[[:space:]]*\[/ { in_provider = ($0 ~ /^[[:space:]]*\[model_providers\.[^]]+\][[:space:]]*(#.*)?$/); next } in_provider && /^[[:space:]]*env_key[[:space:]]*=/ { v = $0; sub(/^[^=]*=[[:space:]]*/, "", v); sub(/[[:space:]]*#.*$/, "", v); gsub(/["\047[:space:]]/, "", v); print v }' "$_codex_home/config.toml" 2>/dev/null) fi echo "AUTH_FAILED" [ -n "$_missing" ] && echo "HINT: $_codex_home/config.toml names a custom provider key ($_missing), but it is not set in this shell. Export it, or run codex login." >&2 return 1 } # --- Runtime model selection (#2914) --------------------------------------- _gstack_codex_select_model() { # One selection record per invocation kind, shared by the probe and the # dispatch flag in the same block: explicit request ($2), GSTACK_CODEX_MODEL, # Codex config.toml (review_model first for native review), gstack default. # With $3 = plan-review the plan-review policy ranks the choice instead and # its record carries role, tier, source and source-aware repair; $4 names the # working-directory context forwarded to the shared role resolver. # The TOML is read by the TypeScript resolver; bash never parses it. local _kind="${1:-exec}" _explicit="${2:-}" _role="${3:-}" _cwd="${4:-}" _line _rc _rest case "$_kind" in exec|review) ;; *) echo "ERROR: _gstack_codex_select_model takes exec or review" >&2; return 2 ;; esac case "$_role" in ''|plan-review) ;; *) echo "ERROR: _gstack_codex_select_model role must be plan-review" >&2; return 2 ;; esac if ! command -v bun >/dev/null 2>&1; then echo "CODEX_MODEL: unresolved — bun is not on PATH, so gstack cannot read the Codex model choice. Reinstall gstack with ./setup; no Codex call was made." >&2 return 1 fi # Function-local argv: each value stays one literal argument in bash and zsh. set -- --runtime "$_kind" [ -n "$_role" ] && set -- "$@" --role "$_role" [ -n "$_explicit" ] && set -- "$@" --explicit "$_explicit" [ -n "$_cwd" ] && set -- "$@" --cwd "$_cwd" _line=$(bun run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" "$@") _rc=$? _GSTACK_CODEX_SEL="${_line%%$'\t'*}" _rest="${_line#*$'\t'}" _GSTACK_CODEX_SEL_TIER="" _GSTACK_CODEX_SEL_REPAIR="" if [ -n "$_role" ]; then _GSTACK_CODEX_SEL_SRC="${_rest%%$'\t'*}" _rest="${_rest#*$'\t'}" _GSTACK_CODEX_SEL_TIER="${_rest%%$'\t'*}" _GSTACK_CODEX_SEL_REPAIR="${_rest#*$'\t'}" else _GSTACK_CODEX_SEL_SRC="$_rest" fi case "$_GSTACK_CODEX_SEL" in ''|*[!A-Za-z0-9._:/-]*) _rc=1 ;; esac if [ "$_rc" -ne 0 ] || [ "${#_GSTACK_CODEX_SEL}" -gt 100 ]; then _GSTACK_CODEX_SEL="" _GSTACK_CODEX_SEL_SRC="" _GSTACK_CODEX_SEL_ROLE="" echo "CODEX_MODEL: invalid — Codex outside review unavailable; missing coverage. No Codex call was made. Fix the model choice above; gstack never substitutes its default for it." >&2 return 1 fi _GSTACK_CODEX_SEL_KIND="$_kind" _GSTACK_CODEX_SEL_ROLE="$_role" _GSTACK_CODEX_SEL_CWD="$_cwd" if [ -n "$_role" ]; then echo "CODEX_MODEL: $_GSTACK_CODEX_SEL ($_kind; role: $_role, tier: $_GSTACK_CODEX_SEL_TIER; source: $_GSTACK_CODEX_SEL_SRC)" >&2 else echo "CODEX_MODEL: $_GSTACK_CODEX_SEL ($_kind; source: $_GSTACK_CODEX_SEL_SRC)" >&2 fi _gstack_codex_sandbox_mode } _gstack_codex_role_ready() { # $1 exec|review, $2 target repository, $3 a model named for this request. # Resolves the plan-review model once and checks auth, the sandbox and that # model; the caller dispatches the same record. Under _CODEX_DEADLINE these # checks get the first half of the time left, so the dispatch keeps the rest. local _end="${_CODEX_DEADLINE:-}" _now case "$_end" in ''|*[!0-9]*) local _CODEX_DEADLINE="" ;; *) _now=$(date +%s 2>/dev/null || echo 0); local _CODEX_DEADLINE=$(( _now + (_end - _now) / 2 )) ;; esac _gstack_codex_select_model "$1" "${3:-}" plan-review "${2:-}" || return 1 _gstack_codex_auth_probe || return 1 _gstack_codex_sandbox_preflight || return $? _gstack_codex_version_check _gstack_codex_model_probe "$1" || return $? [ -z "${_GSTACK_CODEX_SEL_REPAIR:-}" ] || echo "HINT: If the review rejects the selected model, repair its winning source: $_GSTACK_CODEX_SEL_REPAIR." return 0 } # --- Sandbox (B1) ----------------------------------------------------------- _gstack_codex_sandbox_mode() { # Every gstack Codex command passes -s / sandbox_mode from this variable, so # one place decides it. Codex has no read-only mode without its sandbox, so # the escape hatch is full access: honored only for exactly 1, only from the # shell environment (never a dotenv file or config), and announced each use. if [ "${GSTACK_CODEX_NO_SANDBOX:-}" = "1" ]; then _GSTACK_CODEX_SANDBOX="danger-full-access" echo "WARNING: GSTACK_CODEX_NO_SANDBOX=1: Codex runs this review without a sandbox and can read and write anything your user can. Use it only inside a container you trust." >&2 else _GSTACK_CODEX_SANDBOX="read-only" fi } _gstack_codex_sandbox_preflight() { # Free check before the first paid call: `codex sandbox` starts the same # bubblewrap sandbox with no model. Only a recognized sandbox failure blocks # (exit 3, printed as missing coverage); success, a timeout, or an older CLI # without the subcommand defers to the post-run classifier. Linux only, and # skipped when the user turned the sandbox off. Fixtures: # test/fixtures/codex-sandbox/. [ "${GSTACK_CODEX_NO_SANDBOX:-}" = "1" ] && return 0 [ "$(uname -s 2>/dev/null)" = "Linux" ] || return 0 local _out _code _line _out=$(_gstack_codex_timeout_wrapper 10 codex sandbox -c 'sandbox_mode="read-only"' true &1) _code=$? { [ "$_code" -eq 0 ] || [ "$_code" -eq 124 ]; } && return 0 _line=$(printf '%s\n' "$_out" | grep -m1 -iE '^[[:space:]]*bwrap: |bubblewrap is unavailable|landlock.{0,60}(fail|error|not supported|unsupported)|seccomp.{0,60}(fail|error)|user namespaces?.{0,80}(not (allowed|permitted|supported)|denied|disabled)' | sed 's/^[[:space:]]*//' | cut -c1-240) [ -n "$_line" ] || return 0 echo "CODEX_SANDBOX: unavailable" echo "Codex outside review unavailable: Codex's sandbox could not start here ($_line). No review ran; this is missing coverage, not a pass. Fix: enable unprivileged user namespaces for this container, or set GSTACK_CODEX_NO_SANDBOX=1." >&2 _gstack_codex_log_event "codex_sandbox_unavailable" 2>/dev/null || true return 3 } # --- First-use notice (#965) ------------------------------------------------ _gstack_codex_first_use_notice() { # Outside reviews are on by default and use whatever Codex login exists, so # the first one on a machine says where the code goes, with which account, # and how to turn reviews off. Shown once per state root; never blocks and # never prints a credential, only where it comes from. local _root _marker _codex_home="${CODEX_HOME:-$HOME/.codex}" _account _provider _root="$(. "$_GSTACK_CODEX_BIN/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || return 0 [ -n "$_root" ] || return 0 _marker="$_root/.codex-review-notice-shown" [ -e "$_marker" ] && return 0 if [ -n "$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]')" ]; then _account="the API key in CODEX_API_KEY" elif [ -n "$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]')" ]; then _account="the API key in OPENAI_API_KEY" elif grep -q '"auth_mode"[[:space:]]*:[[:space:]]*"chatgpt"' "$_codex_home/auth.json" 2>/dev/null; then _account="the ChatGPT account Codex is logged in with (see: codex login status)" elif [ -f "$_codex_home/auth.json" ]; then _account="the API key saved by codex login in $_codex_home/auth.json" else _account="the credentials your Codex config.toml provider names" fi _provider=$(sed -n 's/^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"\([A-Za-z0-9_.-]*\)".*/\1/p' "$_codex_home/config.toml" 2>/dev/null | head -1) echo "NOTICE: gstack outside reviews send the review prompt and code to Codex (provider: ${_provider:-openai}) using $_account. Reviews stay on; this notice shows once per machine. To turn them off: gstack-config set codex_reviews disabled" >&2 mkdir -p "$_root" 2>/dev/null && : > "$_marker" 2>/dev/null return 0 } # --- Send consent (#3102) ---------------------------------------------------- _gstack_codex_consent() { # The send itself honors codex_reviews, whatever the skill prose did: a # disabled switch, an unreadable config or a gstack-config that cannot run # refuses before any CLI starts (exit 3, not_run consent_missing). Unset is # the default, enabled, and shows the one-time notice. $1=1 is a review the # user asked for by name (/codex), which runs regardless. The line matches # gateOutcomeLine('outside review', 'consent_missing', ...) in # lib/gate-outcomes.ts (test/outside-consent.test.ts). # Same read as `gstack-config get --stored codex_reviews`, in a subshell # with the state-root helper's builtins only, so no caller state changes. [ "${1:-0}" = 1 ] && return 0 local _out _detail _out=$( . "$_GSTACK_CODEX_BIN/gstack-state-root.sh" 2>/dev/null || { printf 'unreadable:the gstack state-root helper could not be loaded'; exit 0; } gstack_state_root_select; gstack_legacy_root_select for _r in "$_gstack_sr_root" "$_gstack_legacy_root"; do [ -n "$_r" ] || continue if [ -e "$_r/config.yaml" ] && { [ ! -f "$_r/config.yaml" ] || [ ! -r "$_r/config.yaml" ]; }; then printf 'unreadable:cannot read %s/config.yaml' "$_r"; exit 0 fi done gstack_config_select codex_reviews || true printf '%s' "${_gstack_cfg_value:-unset}" ) case "$_out" in enabled) return 0 ;; unset) _gstack_codex_first_use_notice; return 0 ;; disabled) _detail="codex_reviews=disabled" ;; unreadable:*) _detail="${_out#unreadable:}" ;; *) _detail="codex_reviews is '$_out', which is neither enabled nor disabled" ;; esac echo "outside review: not run (outside reviews are turned off (codex_reviews=disabled) or the gstack config could not be read, so no repository content was sent ($_detail)). Fix: gstack-config set codex_reviews enabled (or make config.yaml readable), then re-run; /codex and /claude-code still run when you ask for them (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#outside-review-consent-missing)." >&2 echo "OUTSIDE_CONSENT: consent_missing" >&2 return 3 } # --- Model round-trip probe (#2477) ------------------------------------------ _gstack_codex_model_probe() { # Auth-exists is a weaker signal than the auth probe implies: a ChatGPT # account can be valid while the model gstack will request is unavailable. # A short real round trip with the selected model catches model rejection # and entitlement changes in one shot (#2477). The model comes from # _gstack_codex_select_model ($1 = exec|review, default exec), the same # record the dispatch flag uses, so the probe never checks a different model. # # Contract: # MODEL_OK (exit 0) — round trip succeeded; cached 1h. # MODEL_UNUSABLE (exit 1) — deterministic model rejection (400, or a # 404 from a retired model or a wrong custom-provider base_url) or an # invalid model choice; hints name the cause. The 400 is cached 15 min: re-probing every # preflight charged the affected user a 30s round trip + real tokens per # review section, forever. Changing the model or config.toml changes the # cache signature and re-probes immediately; the short TTL covers # server-side entitlement recovery the signature can't see. # MODEL_UNUSABLE_INSTALL (exit 2) — the CLI cannot execute at all (spawn # ENOENT, non-executable binary, missing vendor payload). Deterministic, # so fail-open is wrong: retrying never helps. Never cached — a reinstall # fixes it and must be picked up on the very next probe (#2742). # MODEL_QUOTA_EXHAUSTED (exit 4) — Codex's usage-limit or insufficient_quota # error on a failed, non-timeout call: the account, not the model. Codex's # own line (with its reset time) is relayed verbatim; cached 15 min under # the same signature as MODEL_UNUSABLE, so later fresh-shell blocks in the # run make no Codex call. GSTACK_CODEX_PROBE_RETRY=1 skips both negative # cache entries for that probe. # MODEL_PROBE_RATE_LIMITED (exit 0) — a plain 429 / rate-limit error: often # gone in seconds, so the review still runs, like the inconclusive case # (_GSTACK_CODEX_PROBE_STATE=rate_limited, CODEX_MODE: unverified # (rate_limited)). Codex's line is relayed verbatim; never cached. # MODEL_PROBE_INCONCLUSIVE (exit 0) — timeout/transient; the review still # runs so a slow network never wedges codex mode, but readiness is # unverified (_GSTACK_CODEX_PROBE_STATE=inconclusive, CODEX_MODE: # unverified) and the review's own classification decides. Never cached. # # Quota and rate-limit signatures read only Codex's own trailing error lines # (ERROR: / stream error), never the whole output: it echoes the prompt. # # Only call this AFTER _gstack_codex_auth_probe passes — probing without # auth just measures the auth failure again. local _kind="${1:-exec}" if [ -z "${_GSTACK_CODEX_SEL:-}" ] || [ "${_GSTACK_CODEX_SEL_KIND:-}" != "$_kind" ]; then if ! _gstack_codex_select_model "$_kind" "" "${_GSTACK_CODEX_SEL_ROLE:-}" "${_GSTACK_CODEX_SEL_CWD:-}"; then echo "MODEL_UNUSABLE" echo "HINT: the requested Codex model is not a valid model id; fix it as described above." return 1 fi fi local _model="$_GSTACK_CODEX_SEL" local _codex_home="${CODEX_HOME:-$HOME/.codex}" # State root from the shared twin (sourced in a subshell, so the caller's # shell is untouched). A missing twin only disables the cache. local _gstack_home _cache="" _gstack_home="$(. "$_GSTACK_CODEX_BIN/gstack-state-root.sh" 2>/dev/null && gstack_state_root)" || _gstack_home="" [ -n "$_gstack_home" ] && _cache="$_gstack_home/.codex-model-probe" # Cache signature (#2787): everything that can change the round trip's # outcome without the hour passing — the model, the Codex home and its # config.toml mtime, the auth mode (which env var or auth.json, plus its # mtime; never a credential value), and the resolved codex binary and its # mtime (an upgrade or PATH swap re-probes). Folded through cksum so paths # with spaces stay one field of the cache line. # GNU-first stat order + numeric validation (the #2195 pattern): on GNU # stat, `-f` means FILESYSTEM mode, so the BSD-first form emitted a # multi-line filesystem block on Linux — the signature then never matched # its own cache line and the cache missed on every read. BSD stat rejects # `-c` cleanly, so GNU-first degrades correctly on macOS. local _cfg_m _auth_m _auth_mode="" _bin _bin_m _sig _cfg_m=$(stat -c %Y "$_codex_home/config.toml" 2>/dev/null || stat -f %m "$_codex_home/config.toml" 2>/dev/null || echo 0) _auth_m=$(stat -c %Y "$_codex_home/auth.json" 2>/dev/null || stat -f %m "$_codex_home/auth.json" 2>/dev/null || echo 0) case "$_cfg_m" in ''|*[!0-9]*) _cfg_m=0 ;; esac case "$_auth_m" in ''|*[!0-9]*) _auth_m=0 ;; esac [ -n "$(printf '%s' "${CODEX_API_KEY:-}" | tr -d '[:space:]')" ] && _auth_mode="${_auth_mode}env-codex," [ -n "$(printf '%s' "${OPENAI_API_KEY:-}" | tr -d '[:space:]')" ] && _auth_mode="${_auth_mode}env-openai," [ -f "$_codex_home/auth.json" ] && _auth_mode="${_auth_mode}file," _bin=$(command -v codex 2>/dev/null || echo none) _bin=$(readlink -f "$_bin" 2>/dev/null || printf '%s' "$_bin") _bin_m=$(stat -c %Y "$_bin" 2>/dev/null || stat -f %m "$_bin" 2>/dev/null || echo 0) case "$_bin_m" in ''|*[!0-9]*) _bin_m=0 ;; esac _sig=$(printf '%s\n' "$_model" "$_codex_home" "$_cfg_m" "$_auth_mode" "$_auth_m" "$_bin" "$_bin_m" | cksum | tr -d ' ') local _now _now=$(date +%s 2>/dev/null || echo 0) local _docs="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md" # Up to 16 signatures share one cache file. Same-signature misses serialize # on a bounded lock and recheck the cache, so concurrent callers pay for one # round trip. A lock timeout or unreadable cache is unverified readiness, # never a duplicate paid probe; the review's own result still decides. local _lock="" _rc _wait="${_GSTACK_CODEX_LOCK_WAIT:-45}" # Under a deadline, wait only while the 30-second round trip still fits. case "${_CODEX_DEADLINE:-}" in ''|*[!0-9]*) ;; *) [ $((_CODEX_DEADLINE - _now - 30)) -lt "$_wait" ] && _wait=$((_CODEX_DEADLINE - _now - 30)) [ "$_wait" -ge 0 ] || _wait=0 ;; esac if [ -n "$_cache" ]; then _gstack_codex_probe_cache_hit; _rc=$? [ "$_rc" -lt 10 ] && return "$_rc" if [ "$_rc" -eq 11 ]; then _gstack_codex_probe_unverified "probe cache $_cache is unreadable"; return 0; fi _lock="$_gstack_home/.codex-model-probe.locks/$_sig" if ! mkdir -p "$_gstack_home/.codex-model-probe.locks" 2>/dev/null; then _gstack_codex_probe_unverified "cannot create the probe lock under $_gstack_home"; return 0 fi _gstack_codex_lock_acquire "$_lock" "$_wait" "${_GSTACK_CODEX_LOCK_STALE:-90}"; _rc=$? if [ "$_rc" -eq 1 ]; then _gstack_codex_probe_unverified "another probe of this model still holds its lock"; return 0; fi if [ "$_rc" -ne 0 ]; then _gstack_codex_probe_unverified "cannot record ownership of the probe lock under $_gstack_home"; return 0; fi _now=$(date +%s 2>/dev/null || echo "$_now") _gstack_codex_probe_cache_hit; _rc=$? if [ "$_rc" -lt 10 ] || [ "$_rc" -eq 11 ]; then _gstack_codex_lock_release "$_lock" [ "$_rc" -eq 11 ] && { _gstack_codex_probe_unverified "probe cache $_cache is unreadable"; return 0; } return "$_rc" fi fi _gstack_codex_probe_round_trip; _rc=$? [ -n "$_lock" ] && _gstack_codex_lock_release "$_lock" return "$_rc" } _gstack_codex_probe_round_trip() { # The paid round trip and its classification. Called only by # _gstack_codex_model_probe; reads its locals (_model, _sig, _cache, ...). local _out _code _out=$(_gstack_codex_timeout_wrapper 30 codex exec --skip-git-repo-check -s read-only -c "model=\"$_model\"" -c 'skills.include_instructions=false' "reply OK" &1) _code=$? if [ "$_code" -eq 0 ]; then _gstack_codex_probe_cache_store MODEL_OK echo "MODEL_OK" return 0 fi # A usage limit is the account, not the model or the network: Codex says so # plainly ("You've hit your usage limit ... try again at