fix: sourced helpers share one self-locate block and fail loudly when they cannot locate themselves (#3024 rework)

The codex probe and the egress lib now carry one byte-identical block:
BASH_SOURCE under bash or %x under zsh, made absolute with $PWD, else a set
GSTACK_ROOT that holds the helper. Anything else (dash, sh, a shell that
cannot name the file) prints "gstack: cannot locate <helper> (shell: ...)"
with the docs/troubleshooting.md#sourced-helper-location URL and returns
before any bash- or zsh-only syntax. The Claude-only default path and the
$(pwd) fallback are gone.

The generated Codex preflight dot-sources the probe and, when it cannot load,
reports CODEX_MODE: helper_unavailable with the helper's own line instead of
falling through to not_authed. The class guard enumerates nested sources,
makes real resolver, auth, state-root and egress-home calls under bash and
zsh (absolute, relative then cd, bare name via PATH), covers dash and an
unknown shell, runs the full preflight, and lints BASH_SOURCE to the shared
block plus other bash-only constructs.

Co-authored-by: marcosdetry <marcosdetry@users.noreply.github.com>
Co-authored-by: akshaykalia <akshaykalia@users.noreply.github.com>
Co-authored-by: scottgigante-hubflow <scottgigante-hubflow@users.noreply.github.com>
Co-authored-by: woodsonl <woodsonl@users.noreply.github.com>
Co-authored-by: bezdekt <bezdekt@users.noreply.github.com>
This commit is contained in:
committed 2026-10-06 20:41:50 +00:00
1 parent a08cb03782
commit 5884117c02
16 files changed
+344 -74

No files matched your search

+5 -1
View File
@@ -757,13 +757,16 @@ Review skills will load at each phase entry. Starting full review pipeline with
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -790,6 +793,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."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
+41 -15
View File
@@ -18,27 +18,53 @@
# - 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 and the one
# self-location assignment below, which is parameter expansion only).
# - No command execution at source time (only function defs and the shared
# self-location block below, which is parameter expansion only; 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) -------------------------------------------
# Template blocks source this file into whatever shell the host runs; on macOS
# that is zsh (Claude Code's Bash tool included). BASH_SOURCE is bash-only, so
# under zsh every "${BASH_SOURCE[0]%/*}/..." path became "/..." and Codex
# model selection failed (#3024). zsh names the sourced file with %x; the eval
# keeps bash from parsing zsh-only syntax. Resolved once, at source time,
# because inside a function zsh's %x names the function, not the file.
_GSTACK_CODEX_BIN=""
# 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_CODEX_BIN="${BASH_SOURCE[0]}"
_gstack_helper_shell=bash
_gstack_helper_dir="${BASH_SOURCE[0]}"
elif [ -n "${ZSH_VERSION:-}" ]; then
eval '_GSTACK_CODEX_BIN="${(%):-%x}"'
_gstack_helper_shell=zsh
eval '_gstack_helper_dir="${(%):-%x}"'
fi
case "$_GSTACK_CODEX_BIN" in
*/*) _GSTACK_CODEX_BIN="${_GSTACK_CODEX_BIN%/*}" ;;
?*) _GSTACK_CODEX_BIN="$PWD" ;;
*) _GSTACK_CODEX_BIN="$HOME/.claude/skills/gstack/bin" ;;
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=<install dir>. 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
# <<< gstack self-locate <<<
_GSTACK_CODEX_BIN="$_gstack_helper_dir"
unset _gstack_helper _gstack_helper_dir _gstack_helper_shell
# --- Auth probe -------------------------------------------------------------
+36 -12
View File
@@ -36,22 +36,46 @@
# their own EXIT traps and a trap set by a sourced library would clobber
# the caller's. All temp handling is immediate, per call.
# Self-locate without dirname (builtins only), so the lib works even under
# a stripped test PATH. BASH_SOURCE is bash-only; under zsh (the macOS
# default, sourced from skill blocks) %x names this file, read through eval
# so bash never parses it. Without it the lib resolved to the caller's cwd
# and looked for its siblings in the user's project (#3024).
_gstack_egress_self=""
# Self-locate with builtins only (no dirname), so the lib works under a
# stripped test PATH and under zsh, where it used to resolve to the caller's
# cwd and look for its siblings in the user's project.
_gstack_helper=gstack-egress-lib.sh
# >>> 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_egress_self="${BASH_SOURCE[0]}"
_gstack_helper_shell=bash
_gstack_helper_dir="${BASH_SOURCE[0]}"
elif [ -n "${ZSH_VERSION:-}" ]; then
eval '_gstack_egress_self="${(%):-%x}"'
_gstack_helper_shell=zsh
eval '_gstack_helper_dir="${(%):-%x}"'
fi
case "$_gstack_egress_self" in
*/*) _gstack_egress_lib_dir="$(cd "${_gstack_egress_self%/*}" && pwd)" ;;
*) _gstack_egress_lib_dir="$(pwd)" ;;
case "$_gstack_helper_dir" in
/*) _gstack_helper_dir="${_gstack_helper_dir%/*}" ;;
*/*) _gstack_helper_dir="$PWD/${_gstack_helper_dir%/*}" ;;
?*) _gstack_helper_dir="$PWD" ;;
esac
unset _gstack_egress_self
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=<install dir>. 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
# <<< gstack self-locate <<<
_gstack_egress_lib_dir="$_gstack_helper_dir"
unset _gstack_helper _gstack_helper_dir _gstack_helper_shell
# State root from the shared twin (bin/gstack-state-root.sh, builtins only),
# sourced lazily on first use.
+5 -1
View File
@@ -476,7 +476,7 @@ invalid or unavailable choice stops with a repair message, never the default.
```bash
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
source ~/.claude/skills/gstack/bin/gstack-codex-probe
source ~/.claude/skills/gstack/bin/gstack-codex-probe || { echo "HELPER_UNAVAILABLE"; exit 1; }
# GSTACK_ACTIVE_HOST names the harness, never the model.
if { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; then
@@ -499,6 +499,10 @@ _gstack_codex_version_check # warns if known-bad, non-blocking
If the runtime guard reports a harness mismatch, stop. Outside coverage is unavailable. Repair with `./setup --host codex`; do not silently substitute another provider or force a same-harness invocation.
If the output contains `HELPER_UNAVAILABLE`, stop: the gstack helper could not load in
this shell. Relay its `gstack: cannot locate ...` line verbatim; it names the shell and
links the fix.
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."
+5 -1
View File
@@ -77,7 +77,7 @@ invalid or unavailable choice stops with a repair message, never the default.
```bash
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
source ~/.claude/skills/gstack/bin/gstack-codex-probe
source ~/.claude/skills/gstack/bin/gstack-codex-probe || { echo "HELPER_UNAVAILABLE"; exit 1; }
{{OUTSIDE_SELF_GUARD:codex}}
if ! _gstack_codex_auth_probe >/dev/null; then
@@ -91,6 +91,10 @@ _gstack_codex_version_check # warns if known-bad, non-blocking
If the runtime guard reports a harness mismatch, stop. Outside coverage is unavailable. Repair with `./setup --host codex`; do not silently substitute another provider or force a same-harness invocation.
If the output contains `HELPER_UNAVAILABLE`, stop: the gstack helper could not load in
this shell. Relay its `gstack: cannot locate ...` line verbatim; it names the shell and
links the fix.
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."
+23
View File
@@ -31,6 +31,29 @@ P0 or P1 finding blocks exactly like a native P0/P1. `unverified` and
`unavailable` are missing coverage: /ship and /review continue, show the gap in
the readiness dashboard and the PR body, and never count it as a pass.
<a id="sourced-helper-location"></a>
### `gstack: cannot locate gstack-codex-probe (shell: ...)` / `CODEX_MODE: helper_unavailable`
**Meaning.** Skill blocks load gstack's shell helpers (`gstack-codex-probe`,
`gstack-egress-lib.sh`) into the shell your agent runs. A helper finds its own
directory from bash (`BASH_SOURCE`) or zsh (`%x`). In any other shell (dash,
sh), or when the shell cannot say which file it is reading, the helper stops
instead of guessing a path. The message names the shell it saw.
**Fix.** Run the skill from bash or zsh (the macOS and Linux defaults). If
the shell cannot be changed, tell the helper where gstack is installed:
```bash
export GSTACK_ROOT=~/.claude/skills/gstack # your install dir; it holds bin/
```
If the message says `cannot load ...`, the helper file is missing: re-run
`./setup` from your gstack checkout.
**Expected result.** `zsh -c 'source ~/.claude/skills/gstack/bin/gstack-codex-probe && _gstack_codex_select_model exec'`
prints `CODEX_MODEL: <model> (exec; source: ...)`, and preflights print a
`CODEX_MODE` other than `helper_unavailable`.
<a id="codex-sandbox-unavailable"></a>
### `Codex outside review unavailable: Codex's sandbox could not start here (...)`
+5 -1
View File
@@ -225,13 +225,16 @@ health summary and continue to Step 9. Ship-owned children already stopped at St
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -258,6 +261,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`."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
+5
View File
@@ -68,6 +68,11 @@ export const GATE_OUTCOMES = {
summary: "Codex refused the call for this account's usage or rate limit",
fix: "wait for the reset time in Codex's message above, or add credits or a higher plan for that account; the model choice is fine",
},
helper_unavailable: {
state: 'unavailable', anchor: 'sourced-helper-location',
summary: 'the gstack Codex helper could not be loaded into this shell',
fix: 'run the skill from bash or zsh, or export GSTACK_ROOT=<install dir>; if the helper file is missing, re-run ./setup (https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location)',
},
probe_inconclusive: {
state: 'ran', anchor: 'codex-mode-unverified',
summary: 'the Codex model check timed out or hit a network error, so readiness is unverified',
+5 -1
View File
@@ -360,13 +360,16 @@ review. The user turns this off only by asking explicitly
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -393,6 +396,7 @@ echo "CODEX_MODE: $_CODEX_MODE"
Branch on the echoed `CODEX_MODE`:
- **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the reviewer invocation; record disabled coverage as directed below; 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`."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and construct the prompt below, then follow **Native fallback**. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
@@ -299,13 +299,16 @@ review. The user turns this off only by asking explicitly
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -332,6 +335,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`."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
+5 -1
View File
@@ -805,13 +805,16 @@ review. The user turns this off only by asking explicitly
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -838,6 +841,7 @@ echo "CODEX_MODE: $_CODEX_MODE"
Branch on the echoed `CODEX_MODE`:
- **`disabled`** — the user turned Codex reviews off (`codex_reviews=disabled`). Skip the reviewer invocation; record disabled coverage as directed below; 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`."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Fall back to the Claude subagent path.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Fall back to the Claude subagent path.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and construct the prompt below, then follow **Native fallback**. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Fall back to the Claude subagent path.
+5 -1
View File
@@ -21,13 +21,16 @@ echo "DIFF_SIZE: $DIFF_TOTAL"
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -55,6 +58,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."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
+11 -4
View File
@@ -103,15 +103,18 @@ On any error: continue — ${feature} is informational, not a gate.`;
* fenced block — CLAUDE.md: each block is a fresh shell, so functions sourced
* here do NOT persist to later blocks). It:
* 1. reads the `codex_reviews` master switch,
* 2. sources `gstack-codex-probe`,
* 2. sources `gstack-codex-probe` with POSIX `.`, so even sh/dash reach the
* helper's own diagnostic (`CODEX_MODE: helper_unavailable`),
* 3. runs `command -v codex` (literal — keeps the e2e substring assertion),
* then `_gstack_codex_auth_probe`, then `_gstack_codex_version_check`,
* 4. logs the relevant `_gstack_codex_log_event` for each non-ready outcome,
* 5. sets ONE canonical mode var and echoes `CODEX_MODE: <mode>` so the agent
* gates later blocks on the echoed value.
*
* Mode values: `disabled` (config off) | `not_installed` | `not_authed` |
* `broken_install` | `sandbox_unavailable` | `model_unusable` | `quota_exhausted` | `unverified` | `ready`.
* Mode values: `disabled` (config off) | `helper_unavailable` (the probe could
* not be sourced; its own diagnostic is echoed) | `not_installed` | `not_authed` |
* `broken_install` | `sandbox_unavailable` | `model_unusable` | `quota_exhausted` |
* `unverified` | `ready`.
* The path is host-rewritten at gen-skill-docs time (pathRewrites), so the
* literal `~/.claude/skills/gstack` is correct here and becomes `$GSTACK_ROOT`
* etc. for non-Claude hosts.
@@ -135,13 +138,16 @@ export function codexPreflight(opts: { modeVar?: string; disabledBehavior: 'skip
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="\${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
${m}="disabled"
elif { [ -n "\${CODEX_THREAD_ID:-}" ] || [ -n "\${CODEX_SANDBOX:-}" ] || [ "\${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
${m}="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
${m}="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -169,6 +175,7 @@ echo "CODEX_MODE: $${m}"
Branch on the echoed \`CODEX_MODE\`:
- **\`disabled\`** — the user turned Codex reviews off (\`codex_reviews=disabled\`). ${disabledLine}
- **\`helper_unavailable\`** — the helper could not load; relay the line above (cause and fix). ${nativeRoute}
- **\`not_installed\`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: \`npm install -g @openai/codex\`." ${nativeRoute}
- **\`under_codex\`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **\`not_authed\`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run \`codex login\` or set \`$CODEX_API_KEY\`." ${nativeRoute}
+5 -1
View File
@@ -21,13 +21,16 @@ echo "DIFF_SIZE: $DIFF_TOTAL"
# Codex preflight: one block (functions sourced here don't persist to later blocks).
_TEL=$(~/.claude/skills/gstack/bin/gstack-config get telemetry 2>/dev/null || echo off)
_CODEX_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || echo enabled)
source ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || true
_gstack_helper_error=""
. ~/.claude/skills/gstack/bin/gstack-codex-probe 2>/dev/null || _gstack_helper_error="${_gstack_helper_error:-gstack: cannot load gstack-codex-probe; re-run ./setup. https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location}"
if [ "$_CODEX_CFG" = "disabled" ]; then
_CODEX_MODE="disabled"
elif { [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_SANDBOX:-}" ] || [ "${GSTACK_ACTIVE_HOST:-}" = codex ]; }; 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 [ -n "$_gstack_helper_error" ]; then
_CODEX_MODE="helper_unavailable"; echo "$_gstack_helper_error"
elif ! _gstack_codex_auth_probe >/dev/null 2>&1; then
_CODEX_MODE="not_authed"; _gstack_codex_log_event "codex_auth_failed" 2>/dev/null || true
else
@@ -55,6 +58,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."
- **`helper_unavailable`** — the helper could not load; relay the line above (cause and fix). Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`not_installed`** — Codex CLI absent. Print: "Codex not installed; outside coverage unavailable. Install: `npm install -g @openai/codex`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
- **`under_codex`** — stale artifact selected its own harness. Print: "Codex outside review unavailable: harness mismatch; no outside process started. Missing coverage. Repair: setup --host codex." Skip the outside invocation and follow the workflow's native-review instructions below. Conflicting inherited harness markers are not grounds to guess another provider.
- **`not_authed`** — installed but no credentials. Print: "Codex not authenticated; outside coverage unavailable. Run `codex login` or set `$CODEX_API_KEY`." Keep the required Claude adversarial pass; do not dispatch a duplicate.
+3 -3
View File
@@ -166,7 +166,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// wave's headline capability) grows the union to 1.195x. Deliberate:
// the section is on-demand (loads only for Apple store targets), so
// per-invocation cost for non-iOS ships is one manifest line.
maxSizeRatio: 1.432, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06).
maxSizeRatio: 1.434, // Shared advisory identity/dedup + critical-severity validation: 248,065 union bytes / 187,706 baseline = 1.3216 (2026-09-17). + test value bar in the lazy Step 7 section (value cards, weak paths, gate table, base control, machine checks; ~13.6KB): measured 1.396 (2026-09-29). + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.401 (2026-09-30). + the shared QA review step's plan-check timing rule (plan checks and their revalidation run on --timeout-ms after smoke expiry); measured 1.4022 (2026-09-30). + the shared TEST_BOOTSTRAP owned-change undo rules (s03 H1-H3, M1) and the value-bar coverage wording (s03 M2/M3); measured 1.4044 (2026-10-02). + severe fix wave ship-review lane: G1 NO_VERSION dispatch, title and CHANGELOG arms (versionless repos ship without inventing a version), F1 parent-side plan binding before the audit dispatch (binding, docs/designs candidates, exact not-run line) and D3's data-not-instructions line; +2,126 union bytes, measured 1.4135 (2026-10-03). + INV-1 consumer lines (outside review `unverified`/`unavailable` is missing coverage in the readiness note and PR body); measured 1.4148 (2026-10-03). + v1.91.19.0 severe fix wave: verdict-form outside-review callers with sandbox preflight and first-use notice (INV-1/B1/Q2), P0/P1 gate and unverified wording, bound-plan discovery (F1), visible LEARNINGS failures (B5), carved external pointers; measured 267,752 / 187,706 = 1.4264 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 267,874 / 187,706 = 1.4271 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051) and quoted-heredoc issue template (#3046); measured 268,199 / 187,706 = 1.4288 (2026-10-06). + Step 10's inline no-PR check before the Greptile dispatch (#3020); measured 268,655 / 187,706 = 1.4313 (2026-10-06). + A1 rework: the preflight's helper_unavailable branch (the sourced helper's own diagnostic with its doc URL); measured 269,141 / 187,706 = 1.4338 (2026-10-06).
},
'plan-ceo-review': {
skill: 'plan-ceo-review',
@@ -221,7 +221,7 @@ export const CARVE_GUARDS: Record<string, CarveGuard> = {
// 1.08 → 1.10: the scope-gate exceptions block (+ its adversarial-review
// hardening: host-anchored mode signal, precedence, passing-mention
// guards) and the plan-mode preamble reword land the union at 1.092.
maxSizeRatio: 1.175, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01).
maxSizeRatio: 1.176, // + clarity rules for saved decisions/setup gates + the Aside probe's failure reason; measured 1.1504. + test value bar and Tests to Retire in the lazy Test review section (~2.6KB); measured 1.168 + W1 guarded state-root resolution (`eval gstack-paths; : "${GSTACK_STATE_ROOT:?…}"`) in the Context Recovery preamble, the eureka log and each state-writing bash block; measured 1.173 (2026-09-30). + v1.91.12.0 merge of #2999 (headless rule: a disallowed question tool never qualifies) with #3002; measured 1.1741 (2026-10-01). + Oct 6 fix wave: quota_exhausted and helper_unavailable preflight branches; measured 146,404 / 124,597 = 1.1750 (2026-10-06).
},
'plan-design-review': {
skill: 'plan-design-review',
@@ -485,7 +485,7 @@ do not launch the downstream skill or open a browser.`,
maxSkeletonBytes: 74_881, // Shared-code identity/skip/action rules + critical-severity validation; measured 74,493 (2026-09-17). + v1.91.12.0 merge of #2999 (review clarity repairs: await reads, research alongside dispatch, /review deadline and setup authority, findings sources) with #3002 (guarded state-root lines, plan-check checkpoints); each fit alone; measured 74,881 (2026-10-01).
minUnionBytes: 89_000, // Phase 4 wave 1; measured union 93,357
mustContain: ['confidence', 'P1', 'P2', 'Review Army', 'adversarial'],
maxSizeRatio: 1.202, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06).
maxSizeRatio: 1.206, // Shared-code feature + critical-severity validation: 128,042 union bytes / 108,523 baseline = 1.1799; preserves content floors. + v1.91.12.0 merge of #2999 (above, plus plan-completion fallback intent and specialist checklist-by-path) with #3002; measured 1.1843 (2026-10-01). + v1.91.19.0 wave: verdict-form Codex callers with sandbox preflight (INV-1/B1), P0/P1 structured gate (B1b), bound-plan discovery and data-not-instructions guard (F1/D3), visible LEARNINGS failures (B5); measured 129,941 / 108,523 = 1.1974 (2026-10-03). + v1.91.18.0 (#3023) merged into the v1.91.19.0 wave; measured 130,025 / 108,523 = 1.1981 (2026-10-04). + Oct 6 fix wave: quota_exhausted preflight branch (#3051); measured 130,359 / 108,523 = 1.2012 (2026-10-06). + A1 rework: helper_unavailable preflight branch; measured 130,845 / 108,523 = 1.2057 (2026-10-06).
},
codex: {
skill: 'codex',
@@ -7,20 +7,34 @@
* "/../scripts/...".
*
* Class guard: the helper list is derived from the generated skills and
* resolvers (not a hand list), and each helper is sourced under bash and zsh
* from an unrelated cwd. CI installs zsh (free-tests.yml), so a missing zsh
* there fails instead of skipping.
* resolvers, plus every helper those helpers source in turn (not a hand list).
* Each helper is sourced under bash and zsh from an unrelated cwd, by absolute
* path, by relative path followed by `cd`, and by bare name through PATH, with
* one real call each to the model resolver, the auth probe, the state root and
* the egress home. Any other shell, or a shell that cannot name the file, gets
* the helper's own message instead of a guessed path or a syntax error. CI
* installs zsh (free-tests.yml, Dockerfile.ci), so a missing zsh there fails
* instead of skipping.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { codexPreflight } from '../scripts/resolvers/constants';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
const PROBE = path.join(BIN, 'gstack-codex-probe');
const EGRESS = path.join(BIN, 'gstack-egress-lib.sh');
const HAS_ZSH = Boolean(Bun.which('zsh'));
const HAS_DASH = Boolean(Bun.which('dash'));
const ZSH_REQUIRED = Boolean(process.env.CI) && process.platform !== 'win32';
const DASH_REQUIRED = Boolean(process.env.CI) && process.platform === 'linux';
const ANCHOR = 'https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#sourced-helper-location';
const SELF_LOCATE = /# >>> gstack self-locate[^\n]*\n[\s\S]*?# <<< gstack self-locate <<<\n/;
if (!HAS_ZSH && !ZSH_REQUIRED) console.warn('zsh not installed: install zsh to run the sourced-helper guard (CI requires it)');
function walk(dir: string, keep: (file: string) => boolean, out: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -32,7 +46,13 @@ function walk(dir: string, keep: (file: string) => boolean, out: string[] = []):
return out;
}
/** Every bin/ helper a generated skill or resolver loads with `source` or `.`. */
const code = (text: string) => text.split('\n').filter(line => !/^\s*#/.test(line)).join('\n');
/**
* Every bin/ helper a generated skill or resolver loads with `source` or `.`,
* plus every bin/ file those helpers source in turn (for example
* gstack-state-root.sh, sourced by the codex probe and the egress lib).
*/
function sourcedHelpers(): string[] {
const files = [
...walk(path.join(ROOT, 'scripts', 'resolvers'), f => f.endsWith('.ts')),
@@ -43,6 +63,7 @@ function sourcedHelpers(): string[] {
const names = new Set<string>();
const direct = /(?:^|[\s;&|({]|then\s)(?:source|\.)\s+"?[^"\s;)<]*\bbin\/([A-Za-z0-9._-]+)/g;
const viaVar = /\/(gstack-[A-Za-z0-9._-]+\.sh)"; \[ -r "\$[A-Z_]+" \] && \. "\$[A-Z_]+"/g;
const nested = /(?:^|[\s;&|({]|then\s)(?:source|\.)\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?\/([A-Za-z0-9._-]+)/g;
for (const file of files) {
const text = fs.readFileSync(file, 'utf8');
for (const re of [direct, viaVar]) {
@@ -51,47 +72,93 @@ function sourcedHelpers(): string[] {
}
}
}
const queue = [...names];
while (queue.length) {
const text = code(fs.readFileSync(path.join(BIN, queue.shift()!), 'utf8'));
for (const re of [direct, nested]) {
for (const m of text.matchAll(re)) {
if (!names.has(m[1]!) && fs.existsSync(path.join(BIN, m[1]!))) { names.add(m[1]!); queue.push(m[1]!); }
}
}
}
return [...names].sort();
}
function run(shell: 'bash' | 'zsh', script: string, env: Record<string, string> = {}) {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-cwd-'));
const args = shell === 'zsh' ? ['-f', '-c', script] : ['--noprofile', '--norc', '-c', script];
function run(shell: string, script: string, env: Record<string, string> = {}, cwd?: string) {
const dir = cwd ?? fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-cwd-'));
const args = shell === 'zsh' ? ['-f', '-c', script] : shell === 'bash' ? ['--noprofile', '--norc', '-c', script] : ['-c', script];
const r = spawnSync(shell, args, {
cwd,
env: { PATH: process.env.PATH ?? '', HOME: cwd, ...env },
cwd: dir,
env: { PATH: process.env.PATH ?? '', HOME: dir, ...env },
encoding: 'utf8',
timeout: 20_000,
});
fs.rmSync(cwd, { recursive: true, force: true });
return { code: r.status, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
if (!cwd) fs.rmSync(dir, { recursive: true, force: true });
return { code: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '', out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
}
/** A HOME whose ~/.claude/skills/gstack is this checkout, a stub codex that answers OK, and Codex credentials. */
function preflightHome(install: string = ROOT) {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-preflight-'));
fs.mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true });
fs.symlinkSync(install, path.join(home, '.claude', 'skills', 'gstack'));
fs.mkdirSync(path.join(home, 'stub-bin'));
fs.writeFileSync(path.join(home, 'stub-bin', 'codex'), '#!/usr/bin/env bash\n[ "$1" = sandbox ] && exit 0\necho OK\n', { mode: 0o755 });
fs.mkdirSync(path.join(home, '.codex'));
fs.writeFileSync(path.join(home, '.codex', 'config.toml'), 'model = "gpt-test-model"\n');
fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}');
const env = {
PATH: `${path.join(home, 'stub-bin')}:${process.env.PATH ?? ''}`,
CODEX_HOME: path.join(home, '.codex'),
GSTACK_HOME: path.join(home, '.gstack'),
};
return { home, env };
}
const PREFLIGHT = codexPreflight({ disabledBehavior: 'skip-all' }).match(/```bash\n([\s\S]*?)\n```/)![1]!;
const SHELLS = ['bash', 'zsh'] as const;
const skipShell = (shell: string) => shell === 'zsh' && !HAS_ZSH && !ZSH_REQUIRED;
const skipDash = !HAS_DASH && !DASH_REQUIRED;
describe('#3024: sourced helpers work under bash and zsh', () => {
const helpers = sourcedHelpers();
test('zsh is present wherever CI runs this suite', () => {
test('zsh (and dash on Linux) is present wherever CI runs this suite', () => {
if (ZSH_REQUIRED) expect(HAS_ZSH).toBe(true);
if (DASH_REQUIRED) expect(HAS_DASH).toBe(true);
});
test('the derived helper list covers the known sourced helpers', () => {
for (const known of ['gstack-codex-probe', 'gstack-egress-lib.sh', 'gstack-gbrain-lib.sh']) {
test('the derived helper list covers the known sourced helpers, including nested sources', () => {
for (const known of ['gstack-codex-probe', 'gstack-egress-lib.sh', 'gstack-gbrain-lib.sh', 'gstack-state-root.sh']) {
expect(helpers).toContain(known);
}
});
test('no sourced helper uses bash-only self-location or indirection without a zsh branch', () => {
test('BASH_SOURCE appears only inside the shared self-locate block, byte for byte in every helper', () => {
const blocks = new Map<string, string>();
for (const name of helpers) {
const text = fs.readFileSync(path.join(BIN, name), 'utf8')
.split('\n').filter(line => !/^\s*#/.test(line)).join('\n');
if (text.includes('BASH_SOURCE')) {
expect({ name, zshBranch: text.includes('ZSH_VERSION') }).toEqual({ name, zshBranch: true });
expect({ name, rawUse: /\$\{BASH_SOURCE\[0\]%/.test(text) }).toEqual({ name, rawUse: false });
}
expect({ name, indirect: /\$\{![A-Za-z_]/.test(text) }).toEqual({ name, indirect: false });
const text = fs.readFileSync(path.join(BIN, name), 'utf8');
const block = text.match(SELF_LOCATE)?.[0];
if (block) blocks.set(name, block);
expect({ name, outside: code(text.replace(SELF_LOCATE, '')).includes('BASH_SOURCE') }).toEqual({ name, outside: false });
}
expect([...blocks.keys()]).toEqual(expect.arrayContaining(['gstack-codex-probe', 'gstack-egress-lib.sh']));
const [first] = [...blocks.values()];
for (const [name, block] of blocks) expect({ name, block }).toEqual({ name, block: first! });
});
test('no sourced helper uses other bash-only constructs', () => {
const bashOnly: Array<[string, RegExp]> = [
['${!name} indirection', /\$\{![A-Za-z_]/],
['declare -n', /\bdeclare\s+-[A-Za-z]*n/],
['mapfile/readarray', /\b(?:mapfile|readarray)\b/],
['BASH_REMATCH', /\bBASH_REMATCH\b/],
['[[ =~ ]]', /\[\[[^\]\n]*=~/],
];
for (const name of helpers) {
const text = code(fs.readFileSync(path.join(BIN, name), 'utf8'));
const found = bashOnly.filter(([, re]) => re.test(text)).map(([label]) => label);
expect({ name, found }).toEqual({ name, found: [] });
}
});
@@ -107,11 +174,7 @@ describe('#3024: sourced helpers work under bash and zsh', () => {
test.skipIf(skipShell(shell))(`codex probe resolves its own directory and selects the model (${shell})`, () => {
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-codex-'));
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-test-model"\n');
const r = run(
shell,
`. '${path.join(BIN, 'gstack-codex-probe')}' && echo "BIN=$_GSTACK_CODEX_BIN" && _gstack_codex_select_model exec`,
{ CODEX_HOME: codexHome },
);
const r = run(shell, `. '${PROBE}' && echo "BIN=$_GSTACK_CODEX_BIN" && _gstack_codex_select_model exec`, { CODEX_HOME: codexHome });
fs.rmSync(codexHome, { recursive: true, force: true });
expect(r.out).toContain(`BIN=${BIN}\n`);
expect(r.out).toContain('CODEX_MODEL: gpt-test-model (exec;');
@@ -119,10 +182,39 @@ describe('#3024: sourced helpers work under bash and zsh', () => {
expect(r.code).toBe(0);
});
test.skipIf(skipShell(shell))(`relative-path and bare-name (PATH) sources keep working after cd (${shell})`, () => {
// realpath: macOS's temp root is a symlink, and `..` walks the physical path.
const cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-rel-')));
const codexHome = path.join(cwd, 'codex-home');
fs.mkdirSync(codexHome);
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-test-model"\n');
const env = { CODEX_HOME: codexHome, GSTACK_HOME: path.join(cwd, 'state') };
const after = `cd / && _gstack_codex_select_model exec && _gstack_egress_home`;
try {
for (const [label, load] of [
['relative', `. '${path.relative(cwd, PROBE)}' && . '${path.relative(cwd, EGRESS)}'`],
['bare name', `PATH='${BIN}':"$PATH" && . gstack-codex-probe && . gstack-egress-lib.sh`],
] as const) {
const r = run(shell, `${load} && ${after}`, env, cwd);
expect({ label, model: r.out.includes('CODEX_MODEL: gpt-test-model (exec;'), home: r.stdout.trim() === env.GSTACK_HOME, code: r.code })
.toEqual({ label, model: true, home: true, code: 0 });
}
} finally { fs.rmSync(cwd, { recursive: true, force: true }); }
});
test.skipIf(skipShell(shell))(`a set (unexported) GSTACK_ROOT locates a helper the shell cannot name; nothing else is guessed (${shell})`, () => {
const located = run(shell, `GSTACK_ROOT='${ROOT}'; eval "$(cat '${PROBE}')" && echo "BIN=$_GSTACK_CODEX_BIN"`);
expect(located.out).toContain(`BIN=${BIN}\n`);
const lost = run(shell, `eval "$(cat '${PROBE}')"; echo "after=$?"`);
expect(lost.out).toContain(`gstack: cannot locate gstack-codex-probe (shell: ${shell}). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}`);
expect(lost.out).not.toContain('after=');
expect(lost.code).toBe(1);
});
test.skipIf(skipShell(shell))(`codex auth probe reads a custom provider's env_key (${shell})`, () => {
const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-auth-'));
fs.writeFileSync(path.join(codexHome, 'config.toml'), '[model_providers.local]\nenv_key = "LOCAL_PROVIDER_KEY"\n');
const r = run(shell, `. '${path.join(BIN, 'gstack-codex-probe')}' && _gstack_codex_auth_probe`, {
const r = run(shell, `. '${PROBE}' && _gstack_codex_auth_probe`, {
CODEX_HOME: codexHome,
LOCAL_PROVIDER_KEY: 'present',
});
@@ -131,9 +223,66 @@ describe('#3024: sourced helpers work under bash and zsh', () => {
expect(r.code).toBe(0);
});
test.skipIf(skipShell(shell))(`egress lib resolves its own directory, not the caller's cwd (${shell})`, () => {
const r = run(shell, `. '${path.join(BIN, 'gstack-egress-lib.sh')}' && echo "DIR=$_gstack_egress_lib_dir"`);
expect(r.out).toContain(`DIR=${BIN}\n`);
test.skipIf(skipShell(shell))(`state root and egress home resolve through the helpers, not the caller's cwd (${shell})`, () => {
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-state-'));
try {
const root = run(shell, `. '${path.join(BIN, 'gstack-state-root.sh')}' && gstack_state_root`, { GSTACK_HOME: state });
expect(root.out.trim()).toBe(state);
const egress = run(shell, `. '${EGRESS}' && echo "DIR=$_gstack_egress_lib_dir" && _gstack_egress_home`, { GSTACK_HOME: state });
expect(egress.out).toContain(`DIR=${BIN}\n`);
expect(egress.stdout).toBe(`DIR=${BIN}\n${state}`);
} finally { fs.rmSync(state, { recursive: true, force: true }); }
});
test.skipIf(skipShell(shell))(`the full generated Codex preflight reaches CODEX_MODE: ready (${shell})`, () => {
const { home, env } = preflightHome();
try {
const r = run(shell, PREFLIGHT, env, home);
expect(r.out).toContain('CODEX_MODEL: gpt-test-model (exec;');
expect(r.out).toContain('CODEX_MODE: ready');
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
}
test.skipIf(skipDash)('dash reaches each helper\'s message, never a syntax error, even with GSTACK_ROOT set', () => {
for (const name of ['gstack-codex-probe', 'gstack-egress-lib.sh']) {
for (const env of [{}, { GSTACK_ROOT: ROOT }] as Record<string, string>[]) {
const r = run('dash', `. '${path.join(BIN, name)}'; echo "rc=$?"`, env);
expect([r.stderr, r.stdout]).toEqual([`gstack: cannot locate ${name} (shell: dash). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}\n`, 'rc=1\n']);
}
}
});
test('an unknown shell (no BASH_VERSION or ZSH_VERSION) fails loudly instead of guessing', () => {
const r = run('bash', `unset BASH_VERSION; . '${PROBE}'; echo "rc=$? bin=\${_GSTACK_CODEX_BIN:-unset}"`, { GSTACK_ROOT: ROOT });
expect(r.out).toContain(`gstack: cannot locate gstack-codex-probe (shell: bash). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}`);
expect(r.out).toContain('rc=1 bin=unset');
});
for (const [label, shell, prefix, expected] of [
['dash', 'dash', '', `gstack: cannot locate gstack-codex-probe (shell: dash). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}`],
['an unknown shell', 'bash', 'unset BASH_VERSION\n', `gstack: cannot locate gstack-codex-probe (shell: bash). Source it from bash or zsh, or export GSTACK_ROOT=<install dir>. ${ANCHOR}`],
] as const) {
test.skipIf(shell === 'dash' && skipDash)(`the full generated preflight under ${label} reports helper_unavailable with the helper's line`, () => {
const { home, env } = preflightHome();
try {
const r = run(shell, `${prefix}${PREFLIGHT}`, env, home);
expect(r.out).toContain(`${expected}\nCODEX_MODE: helper_unavailable\n`);
expect(r.out).not.toContain('not_authed');
expect(r.out).not.toMatch(/Syntax error|not found/);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
}
test('the full generated preflight with the helper missing reports helper_unavailable, not not_authed', () => {
const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'zsh-helpers-empty-install-'));
const { home, env } = preflightHome(empty);
try {
const r = run('bash', PREFLIGHT, env, home);
expect(r.out).toContain(`gstack: cannot load gstack-codex-probe; re-run ./setup. ${ANCHOR}\nCODEX_MODE: helper_unavailable\n`);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(empty, { recursive: true, force: true });
}
});
});