From 5b59f72d2b577154df9c216e1f03bfaa33ac89d6 Mon Sep 17 00:00:00 2001 From: cropsgg Date: Thu, 21 May 2026 11:07:41 +0000 Subject: [PATCH 1/3] fix(gbrain-sync): make stage timeouts configurable via env (#1611) `/sync-gbrain --full` on ~100k-page brains reliably exceeded the hard-coded 35-minute timeout, SIGTERMed mid-import, and lost the staging checkpoint. Set GSTACK_SYNC_MEMORY_TIMEOUT_MS (or _CODE_ for the code stage) to override; bad input falls back to the 35-min default with a stderr warning so a typo can't silently disable the safety net. --- bin/gstack-gbrain-sync.ts | 39 +++++++++++++++- test/gstack-gbrain-sync.test.ts | 79 +++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index a3071337d2..c6eac08c76 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -80,6 +80,35 @@ const STATE_PATH = join(GSTACK_HOME, ".gbrain-sync-state.json"); const LOCK_PATH = join(GSTACK_HOME, ".sync-gbrain.lock"); const STALE_LOCK_MS = 5 * 60 * 1000; +// Per-stage timeouts. Default 35 minutes is the honest budget for a first-run +// full sync of a ~30-page-per-second brain (~70k pages). Brains with 100k+ +// pages or slow IO need more headroom — issue #1611. The env knobs accept an +// integer in milliseconds; non-positive or non-numeric values fall back to the +// default with a stderr warning so a typo doesn't silently extend a stage +// indefinitely. +const DEFAULT_STAGE_TIMEOUT_MS = 35 * 60 * 1000; + +function parseTimeoutEnv(name: string): number { + const raw = process.env[name]; + if (!raw) return DEFAULT_STAGE_TIMEOUT_MS; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + console.warn( + `[gstack-gbrain-sync] ignoring ${name}=${JSON.stringify(raw)} — expected a positive integer (milliseconds); using default ${DEFAULT_STAGE_TIMEOUT_MS} ms`, + ); + return DEFAULT_STAGE_TIMEOUT_MS; + } + return Math.floor(n); +} + +export function codeStageTimeoutMs(): number { + return parseTimeoutEnv("GSTACK_SYNC_CODE_TIMEOUT_MS"); +} + +export function memoryStageTimeoutMs(): number { + return parseTimeoutEnv("GSTACK_SYNC_MEMORY_TIMEOUT_MS"); +} + // ── CLI ──────────────────────────────────────────────────────────────────── function printUsage(): void { @@ -100,6 +129,12 @@ Options: Stages run in order: code → memory ingest → curated git push. Each stage failure is non-fatal; subsequent stages still run. + +Environment: + GSTACK_SYNC_CODE_TIMEOUT_MS Override code stage timeout (default 35 min). + GSTACK_SYNC_MEMORY_TIMEOUT_MS Override memory ingest timeout (default 35 min). + Set higher (e.g. 5400000 = 90 min) when --full + import on large brains exceeds the default. `); } @@ -603,7 +638,7 @@ async function runCodeImport(args: CliArgs): Promise { const syncResult = spawnGbrain(syncArgs, { stdio: args.quiet ? ["ignore", "ignore", "ignore"] : ["ignore", "inherit", "inherit"], - timeout: 35 * 60 * 1000, + timeout: codeStageTimeoutMs(), baseEnv: gbrainEnv, }); @@ -757,7 +792,7 @@ function runMemoryIngest(args: CliArgs): StageResult { // internally and must see the DATABASE_URL from gbrain's own config. const result = spawnSync("bun", ingestArgs, { encoding: "utf-8", - timeout: 35 * 60 * 1000, + timeout: memoryStageTimeoutMs(), env: buildGbrainEnv({ announce: false }), }); diff --git a/test/gstack-gbrain-sync.test.ts b/test/gstack-gbrain-sync.test.ts index 19a9bac4eb..08e3b27fb8 100644 --- a/test/gstack-gbrain-sync.test.ts +++ b/test/gstack-gbrain-sync.test.ts @@ -18,6 +18,8 @@ import { planHostnameFoldMigration, sourceLocalPath, _resetGbrainSupportsRenameCache, + codeStageTimeoutMs, + memoryStageTimeoutMs, } from "../bin/gstack-gbrain-sync"; const SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-sync.ts"); @@ -863,3 +865,80 @@ describe("sourceLocalPath", () => { expect(sourceLocalPath("missing-id", envWithBindir(bindir))).toBeNull(); }); }); + +// ────────────────────────────────────────────────────────────────────────── +// Stage timeout overrides (issue #1611) +// +// `/sync-gbrain --full` on a ~100k-page brain blew past the hard-coded +// 35-min timeout, SIGTERMed mid-import, and lost the staging checkpoint. +// codeStageTimeoutMs / memoryStageTimeoutMs read env knobs so users with +// slow IO or huge brains can extend the budget; bad inputs fall back to +// the 35-min default so a typo doesn't silently disable the safety net. +// ────────────────────────────────────────────────────────────────────────── + +describe("stage timeout overrides (issue #1611)", () => { + const DEFAULT_MS = 35 * 60 * 1000; + const saved: Record = {}; + const KEYS = ["GSTACK_SYNC_CODE_TIMEOUT_MS", "GSTACK_SYNC_MEMORY_TIMEOUT_MS"]; + + beforeEach(() => { + for (const k of KEYS) { + saved[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it("defaults to 35 minutes when no env knob is set", () => { + expect(codeStageTimeoutMs()).toBe(DEFAULT_MS); + expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS); + }); + + it("honors GSTACK_SYNC_MEMORY_TIMEOUT_MS for memory ingest", () => { + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "5400000"; // 90 min + expect(memoryStageTimeoutMs()).toBe(5_400_000); + // Code stage stays on default — env knobs are independent. + expect(codeStageTimeoutMs()).toBe(DEFAULT_MS); + }); + + it("honors GSTACK_SYNC_CODE_TIMEOUT_MS independently", () => { + process.env.GSTACK_SYNC_CODE_TIMEOUT_MS = "7200000"; // 2 hr + expect(codeStageTimeoutMs()).toBe(7_200_000); + expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS); + }); + + it("rejects non-numeric input and falls back to default", () => { + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "ninety minutes"; + expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS); + }); + + it("rejects zero / negative values and falls back to default", () => { + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "0"; + expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS); + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "-1"; + expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS); + }); + + it("floors fractional ms to an integer", () => { + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = "1234.9"; + expect(memoryStageTimeoutMs()).toBe(1234); + }); + + it("treats empty string as unset (falls back to default)", () => { + process.env.GSTACK_SYNC_MEMORY_TIMEOUT_MS = ""; + expect(memoryStageTimeoutMs()).toBe(DEFAULT_MS); + }); + + it("--help mentions the env knobs", () => { + const r = runScript(["--help"]); + expect(r.exitCode).toBe(0); + expect(r.stderr).toContain("GSTACK_SYNC_MEMORY_TIMEOUT_MS"); + expect(r.stderr).toContain("GSTACK_SYNC_CODE_TIMEOUT_MS"); + }); +}); From a9f347a5ea7cf601331702fee4fd91ae800f51cf Mon Sep 17 00:00:00 2001 From: cropsgg Date: Thu, 21 May 2026 11:07:50 +0000 Subject: [PATCH 2/3] fix(codex): inject -m gpt-5.2 on ChatGPT-account auth (#1628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex CLI defaults to gpt-5.2-codex, which OpenAI's ChatGPT-account entitlement filter rejects with a 400 ("model is not supported when using Codex with a ChatGPT account"). Every skill that shells out to codex — /codex, /autoplan, /plan-eng-review, /ship, /plan-ceo-review, /plan-design-review — broke for ChatGPT-only auth users. Add _gstack_codex_account_kind (apikey | chatgpt | none) and _gstack_codex_default_model_args to bin/gstack-codex-probe. Templates expand $_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) before every codex invocation; API-key users get nothing extra, ChatGPT users get `-m gpt-5.2`. $GSTACK_CODEX_MODEL overrides; set to "default" to opt out of injection entirely. Wired at all 9 callsites across codex/SKILL.md.tmpl (5) and autoplan/SKILL.md.tmpl (4). A static template guard test catches a future edit that drops the variable. --- autoplan/SKILL.md | 12 +- autoplan/SKILL.md.tmpl | 12 +- bin/gstack-codex-probe | 91 +++++++++++- codex/SKILL.md | 31 +++-- codex/SKILL.md.tmpl | 31 +++-- test/codex-hardening.test.ts | 260 +++++++++++++++++++++++++++++++++++ 6 files changed, 409 insertions(+), 28 deletions(-) diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index a39b60bbd8..989437b21e 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -1118,7 +1118,8 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Codex CEO voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. You are a CEO/founder advisor reviewing a development plan. Challenge the strategic foundations: Are the premises valid or assumed? Is this the @@ -1235,7 +1236,8 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Codex design voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. Read the plan file at . Evaluate this plan's UI/UX design decisions. @@ -1316,7 +1318,8 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Codex eng voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. Review this plan for architectural issues, missing edge cases, and hidden complexity. Be adversarial. @@ -1437,7 +1440,8 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." **Codex DX voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. Read the plan file at . Evaluate this plan's developer experience. diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 888cddabbc..18967434b9 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -290,7 +290,8 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Codex CEO voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. You are a CEO/founder advisor reviewing a development plan. Challenge the strategic foundations: Are the premises valid or assumed? Is this the @@ -407,7 +408,8 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Codex design voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. Read the plan file at . Evaluate this plan's UI/UX design decisions. @@ -488,7 +490,8 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Codex eng voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. Review this plan for architectural issues, missing edge cases, and hidden complexity. Be adversarial. @@ -609,7 +612,8 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." **Codex DX voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - _gstack_codex_timeout_wrapper 600 codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. + _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) + _gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. Read the plan file at . Evaluate this plan's developer experience. diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 940dacf842..75d22d43ce 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -3,10 +3,13 @@ # Sourced from template bash blocks; never execute directly. # # 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_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback -# _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/ +# _gstack_codex_auth_probe — multi-signal auth check (env + file) +# _gstack_codex_account_kind — classify auth as apikey | chatgpt | none +# _gstack_codex_default_model_args — emit `-m ` when ChatGPT auth needs +# a non-default model to avoid 400s (#1628) +# _gstack_codex_version_check — warn on known-bad Codex CLI versions +# _gstack_codex_timeout_wrapper — gtimeout -> timeout -> unwrapped fallback +# _gstack_codex_log_event — telemetry emission to ~/.gstack/analytics/ # # Hygiene rules (enforced by test/codex-hardening.test.ts): # - Never set -e / set -u / trap / IFS= / PATH= in this file. @@ -33,6 +36,86 @@ _gstack_codex_auth_probe() { return 1 } +# --- Account kind classifier ------------------------------------------------ +# +# Echoes one of: apikey | chatgpt | none. +# +# apikey → CODEX_API_KEY or OPENAI_API_KEY is set (non-empty, non-whitespace). +# Codex CLI bills the OpenAI Platform key and the user is entitled +# to every published model, including gpt-5.2-codex. +# chatgpt → no api-key env vars, but ${CODEX_HOME:-~/.codex}/auth.json exists. +# Codex CLI authenticates as the user's ChatGPT account. As of +# March 2026 the ChatGPT entitlement set excludes gpt-5.2-codex +# (returns "model is not supported when using Codex with a +# ChatGPT account") and only includes the base gpt-5.2 family — +# see #1628. +# none → no auth signal at all; the caller should fail fast. + +_gstack_codex_account_kind() { + 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" ]; then + echo "apikey" + return 0 + fi + local _codex_home="${CODEX_HOME:-$HOME/.codex}" + if [ -f "$_codex_home/auth.json" ]; then + echo "chatgpt" + return 0 + fi + echo "none" +} + +# --- Default model args ----------------------------------------------------- +# +# Echoes the codex CLI flags that should be injected before the prompt / +# subcommand. The two callsite patterns are: +# +# _CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +# _gstack_codex_timeout_wrapper 330 codex review $_CODEX_MODEL_ARGS "..." ... +# +# Or as a `bash -c` style array: +# +# read -r -a _CMA <<<"$(_gstack_codex_default_model_args)" +# +# Behaviour: +# - $GSTACK_CODEX_MODEL set → emit `-m $GSTACK_CODEX_MODEL` regardless of +# auth kind. Power-user override, e.g. for testing gpt-5.1-codex-max. +# - $GSTACK_CODEX_MODEL = "" or "default" → emit nothing (let Codex pick). +# - account = chatgpt → emit `-m gpt-5.2` so the Codex CLI's default +# gpt-5.2-codex selection doesn't trip OpenAI's ChatGPT entitlement +# filter and 400 the request (issue #1628). +# - account = apikey → emit nothing; API-key users have full entitlement. +# - account = none → emit nothing; let the auth probe handle the error. +# +# The output is a single line containing zero or two whitespace-separated +# tokens, suitable for unquoted interpolation in a codex command line. + +_gstack_codex_default_model_args() { + local _override="${GSTACK_CODEX_MODEL:-}" + case "$_override" in + "") + # Unset / empty → fall through to account-based auto-detection below. + ;; + "default") + # Power-user opt-out: bypass injection entirely so the Codex CLI picks + # its own model. Useful if/when ChatGPT-account entitlement changes and + # the chatgpt path stops being needed. + return 0 + ;; + *) + printf -- '-m %s' "$_override" + return 0 + ;; + esac + local _kind + _kind=$(_gstack_codex_account_kind) + if [ "$_kind" = "chatgpt" ]; then + printf -- '-m gpt-5.2' + fi +} + # --- Version check ---------------------------------------------------------- _gstack_codex_version_check() { diff --git a/codex/SKILL.md b/codex/SKILL.md index dbc6bbcb63..9d7347102c 100644 --- a/codex/SKILL.md +++ b/codex/SKILL.md @@ -947,7 +947,11 @@ _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" cd "$_REPO_ROOT" # 330s (5.5min) is slightly longer than the Bash 300s so the shell wrapper # only fires if Bash's own timeout doesn't. -_gstack_codex_timeout_wrapper 330 codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. Do NOT modify agents/openai.yaml. Stay focused on repository code only. +# $_CODEX_MODEL_ARGS forces `-m gpt-5.2` on ChatGPT-account auth so Codex's +# default `gpt-5.2-codex` doesn't 400 against the ChatGPT entitlement filter +# (issue #1628). On API-key auth it's empty and the default model wins. +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 330 codex review $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. Do NOT modify agents/openai.yaml. Stay focused on repository code only. Review the changes on this branch against the base branch . Run git diff origin/...HEAD 2>/dev/null || git diff ...HEAD to see the diff and review only those changes." -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR" _CODEX_EXIT=$? @@ -987,7 +991,8 @@ _PROMPT_FILE=$(mktemp "$TMP_ROOT/codex-prompt-XXXXXX.txt") git diff "...HEAD" 2>/dev/null printf '\nDIFF_END\n' } > "$_PROMPT_FILE" -_gstack_codex_timeout_wrapper 330 codex exec -s read-only "$(cat "$_PROMPT_FILE")" -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR" +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 330 codex exec $_CODEX_MODEL_ARGS -s read-only "$(cat "$_PROMPT_FILE")" -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR" _CODEX_EXIT=$? rm -f "$_PROMPT_FILE" if [ "$_CODEX_EXIT" = "124" ]; then @@ -1215,7 +1220,8 @@ fi # Fix 1+2: wrap with timeout (gtimeout/timeout fallback chain via probe helper), # capture stderr to $TMPERR for auth error detection (was: 2>/dev/null). TMPERR=${TMPERR:-$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")} -_gstack_codex_timeout_wrapper 600 codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " import sys, json turn_completed_count = 0 for line in sys.stdin: @@ -1370,7 +1376,8 @@ if [ -z "$PYTHON_CMD" ]; then exit 1 fi # Fix 1: wrap with timeout (gtimeout/timeout fallback chain via probe helper) -_gstack_codex_timeout_wrapper 600 codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " import sys, json for line in sys.stdin: line = line.strip() @@ -1424,7 +1431,8 @@ if [ -z "$PYTHON_CMD" ]; then fi cd "$_REPO_ROOT" || exit 1 # Fix 1: wrap with timeout (gtimeout/timeout fallback chain via probe helper) -_gstack_codex_timeout_wrapper 600 codex exec resume "" -c 'sandbox_mode="read-only"' -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 600 codex exec resume $_CODEX_MODEL_ARGS "" -c 'sandbox_mode="read-only"' -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " " # Fix 1: same hang detection pattern as new-session block @@ -1483,9 +1491,16 @@ The reason must engage with a specific Codex insight and compare against an alte ## Model & Reasoning -**Model:** No model is hardcoded — codex uses whatever its current default is (the frontier -agentic coding model). This means as OpenAI ships newer models, /codex automatically -uses them. If the user wants a specific model, pass `-m` through to codex. +**Model:** Codex picks the default model unless one is required. Two cases inject `-m`: +1. **ChatGPT-account auth (no `$CODEX_API_KEY` / `$OPENAI_API_KEY`):** the + probe injects `-m gpt-5.2` so Codex's default `gpt-5.2-codex` doesn't trip + OpenAI's ChatGPT-account entitlement filter and return 400 (issue #1628). +2. **`$GSTACK_CODEX_MODEL` set:** that exact model is injected; set it to + `"default"` or unset it to let Codex decide. + +API-key users hit no injection — they're entitled to every published model. +If the user passes their own `-m` in the slash command, thread it through; +the last `-m` on the codex command line wins. **Reasoning effort (per-mode defaults):** - **Review (2A):** `high` — bounded diff input, needs thoroughness but not max tokens diff --git a/codex/SKILL.md.tmpl b/codex/SKILL.md.tmpl index 333de7d8d5..7bd2184add 100644 --- a/codex/SKILL.md.tmpl +++ b/codex/SKILL.md.tmpl @@ -175,7 +175,11 @@ _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" cd "$_REPO_ROOT" # 330s (5.5min) is slightly longer than the Bash 300s so the shell wrapper # only fires if Bash's own timeout doesn't. -_gstack_codex_timeout_wrapper 330 codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. Do NOT modify agents/openai.yaml. Stay focused on repository code only. +# $_CODEX_MODEL_ARGS forces `-m gpt-5.2` on ChatGPT-account auth so Codex's +# default `gpt-5.2-codex` doesn't 400 against the ChatGPT entitlement filter +# (issue #1628). On API-key auth it's empty and the default model wins. +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 330 codex review $_CODEX_MODEL_ARGS "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. Do NOT modify agents/openai.yaml. Stay focused on repository code only. Review the changes on this branch against the base branch . Run git diff origin/...HEAD 2>/dev/null || git diff ...HEAD to see the diff and review only those changes." -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR" _CODEX_EXIT=$? @@ -215,7 +219,8 @@ _PROMPT_FILE=$(mktemp "$TMP_ROOT/codex-prompt-XXXXXX.txt") git diff "...HEAD" 2>/dev/null printf '\nDIFF_END\n' } > "$_PROMPT_FILE" -_gstack_codex_timeout_wrapper 330 codex exec -s read-only "$(cat "$_PROMPT_FILE")" -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR" +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 330 codex exec $_CODEX_MODEL_ARGS -s read-only "$(cat "$_PROMPT_FILE")" -c 'model_reasoning_effort="high"' --enable web_search_cached < /dev/null 2>"$TMPERR" _CODEX_EXIT=$? rm -f "$_PROMPT_FILE" if [ "$_CODEX_EXIT" = "124" ]; then @@ -336,7 +341,8 @@ fi # Fix 1+2: wrap with timeout (gtimeout/timeout fallback chain via probe helper), # capture stderr to $TMPERR for auth error detection (was: 2>/dev/null). TMPERR=${TMPERR:-$(mktemp "$TMP_ROOT/codex-err-XXXXXX.txt")} -_gstack_codex_timeout_wrapper 600 codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " import sys, json turn_completed_count = 0 for line in sys.stdin: @@ -491,7 +497,8 @@ if [ -z "$PYTHON_CMD" ]; then exit 1 fi # Fix 1: wrap with timeout (gtimeout/timeout fallback chain via probe helper) -_gstack_codex_timeout_wrapper 600 codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 600 codex exec $_CODEX_MODEL_ARGS "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " import sys, json for line in sys.stdin: line = line.strip() @@ -545,7 +552,8 @@ if [ -z "$PYTHON_CMD" ]; then fi cd "$_REPO_ROOT" || exit 1 # Fix 1: wrap with timeout (gtimeout/timeout fallback chain via probe helper) -_gstack_codex_timeout_wrapper 600 codex exec resume "" -c 'sandbox_mode="read-only"' -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " +_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args) +_gstack_codex_timeout_wrapper 600 codex exec resume $_CODEX_MODEL_ARGS "" -c 'sandbox_mode="read-only"' -c 'model_reasoning_effort="medium"' --enable web_search_cached --json < /dev/null 2>"$TMPERR" | PYTHONUNBUFFERED=1 "$PYTHON_CMD" -u -c " " # Fix 1: same hang detection pattern as new-session block @@ -604,9 +612,16 @@ The reason must engage with a specific Codex insight and compare against an alte ## Model & Reasoning -**Model:** No model is hardcoded — codex uses whatever its current default is (the frontier -agentic coding model). This means as OpenAI ships newer models, /codex automatically -uses them. If the user wants a specific model, pass `-m` through to codex. +**Model:** Codex picks the default model unless one is required. Two cases inject `-m`: +1. **ChatGPT-account auth (no `$CODEX_API_KEY` / `$OPENAI_API_KEY`):** the + probe injects `-m gpt-5.2` so Codex's default `gpt-5.2-codex` doesn't trip + OpenAI's ChatGPT-account entitlement filter and return 400 (issue #1628). +2. **`$GSTACK_CODEX_MODEL` set:** that exact model is injected; set it to + `"default"` or unset it to let Codex decide. + +API-key users hit no injection — they're entitled to every published model. +If the user passes their own `-m` in the slash command, thread it through; +the last `-m` on the codex command line wins. **Reasoning effort (per-mode defaults):** - **Review (2A):** `high` — bounded diff input, needs thoroughness but not max tokens diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index f1c00031a4..3c409b6a03 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -166,6 +166,266 @@ describe('gstack-codex-probe: auth probe', () => { }); }); +// --- Group 1b: Account kind classifier + default model args (issue #1628) --- +// ChatGPT-account auth blocks gpt-5.2-codex (Codex CLI's default). The probe +// classifies the active auth and emits `-m gpt-5.2` so the SKILL.md templates +// can inject it before the prompt and avoid the 400. + +describe('gstack-codex-probe: account kind classifier (issue #1628)', () => { + test('CODEX_API_KEY set → apikey', () => { + const home = tempHome(); + try { + const r = runProbe({ + snippet: '_gstack_codex_account_kind', + env: { CODEX_API_KEY: 'sk-test' }, + home, + }); + expect(r.stdout.trim()).toBe('apikey'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('OPENAI_API_KEY set → apikey', () => { + const home = tempHome(); + try { + const r = runProbe({ + snippet: '_gstack_codex_account_kind', + env: { OPENAI_API_KEY: 'sk-test' }, + home, + }); + expect(r.stdout.trim()).toBe('apikey'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('only auth.json present → chatgpt', () => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const r = runProbe({ snippet: '_gstack_codex_account_kind', home }); + expect(r.stdout.trim()).toBe('chatgpt'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('whitespace-only api keys + auth.json → chatgpt (env keys ignored)', () => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const r = runProbe({ + snippet: '_gstack_codex_account_kind', + env: { CODEX_API_KEY: ' ', OPENAI_API_KEY: '\t\n' }, + home, + }); + expect(r.stdout.trim()).toBe('chatgpt'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('no env keys + no auth.json → none', () => { + const home = tempHome(); + try { + const r = runProbe({ snippet: '_gstack_codex_account_kind', home }); + expect(r.stdout.trim()).toBe('none'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('alternate $CODEX_HOME with auth.json → chatgpt', () => { + const home = tempHome(); + const altCodex = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-alt-codex-')); + try { + fs.writeFileSync(path.join(altCodex, 'auth.json'), '{}'); + const r = runProbe({ + snippet: '_gstack_codex_account_kind', + env: { CODEX_HOME: altCodex }, + home, + }); + expect(r.stdout.trim()).toBe('chatgpt'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(altCodex, { recursive: true, force: true }); + } + }); +}); + +describe('gstack-codex-probe: default model args (issue #1628)', () => { + test('chatgpt account → emits "-m gpt-5.2"', () => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const r = runProbe({ snippet: '_gstack_codex_default_model_args', home }); + expect(r.stdout.trim()).toBe('-m gpt-5.2'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('api-key account → emits nothing (default model)', () => { + const home = tempHome(); + try { + const r = runProbe({ + snippet: '_gstack_codex_default_model_args', + env: { CODEX_API_KEY: 'sk-test' }, + home, + }); + expect(r.stdout.trim()).toBe(''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('no auth at all → emits nothing (auth probe handles the error)', () => { + const home = tempHome(); + try { + const r = runProbe({ snippet: '_gstack_codex_default_model_args', home }); + expect(r.stdout.trim()).toBe(''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('GSTACK_CODEX_MODEL override wins even on api-key auth', () => { + const home = tempHome(); + try { + const r = runProbe({ + snippet: '_gstack_codex_default_model_args', + env: { CODEX_API_KEY: 'sk-test', GSTACK_CODEX_MODEL: 'gpt-5.1-codex-max' }, + home, + }); + expect(r.stdout.trim()).toBe('-m gpt-5.1-codex-max'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('GSTACK_CODEX_MODEL=default unset the injection on chatgpt auth', () => { + // Escape hatch: power user with a beta entitlement on ChatGPT can opt out + // of the injection without unsetting their auth.json. + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const r = runProbe({ + snippet: '_gstack_codex_default_model_args', + env: { GSTACK_CODEX_MODEL: 'default' }, + home, + }); + expect(r.stdout.trim()).toBe(''); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('empty GSTACK_CODEX_MODEL treated as unset (chatgpt still gets -m gpt-5.2)', () => { + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const r = runProbe({ + snippet: '_gstack_codex_default_model_args', + env: { GSTACK_CODEX_MODEL: '' }, + home, + }); + expect(r.stdout.trim()).toBe('-m gpt-5.2'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('output word-splits cleanly into two argv tokens', () => { + // Critical invariant: callsites use `codex review $_CMA "prompt"` with + // unquoted expansion, relying on word-splitting to turn "-m gpt-5.2" + // into two argv tokens. If the helper ever printf'd "-mgpt-5.2" or + // similar, the codex CLI would parse it as one flag value and break. + const home = tempHome(); + try { + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + fs.writeFileSync(path.join(home, '.codex', 'auth.json'), '{}'); + const r = runProbe({ + snippet: + 'set -- $(_gstack_codex_default_model_args); echo "ARGS=$#"; echo "T1=[$1]"; echo "T2=[$2]"', + home, + }); + expect(r.stdout).toContain('ARGS=2'); + expect(r.stdout).toContain('T1=[-m]'); + expect(r.stdout).toContain('T2=[gpt-5.2]'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); + + test('empty output word-splits to zero tokens (no spurious arg on api-key auth)', () => { + const home = tempHome(); + try { + const r = runProbe({ + snippet: + 'set -- $(_gstack_codex_default_model_args); echo "ARGS=$#"', + env: { CODEX_API_KEY: 'sk-test' }, + home, + }); + expect(r.stdout).toContain('ARGS=0'); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + }); +}); + +// --- Group 1c: Template wiring guard (issue #1628) -------------------------- +// Static check: every `codex exec`/`codex review` line in the codex + autoplan +// templates must thread $_CODEX_MODEL_ARGS so ChatGPT-account users don't +// re-encounter the 400 if a future edit drops the variable. + +describe('SKILL.md.tmpl: every codex invocation threads $_CODEX_MODEL_ARGS (issue #1628)', () => { + const TEMPLATES = ['codex/SKILL.md.tmpl', 'autoplan/SKILL.md.tmpl']; + + for (const relPath of TEMPLATES) { + test(`${relPath}: every \`_gstack_codex_timeout_wrapper codex …\` line carries $_CODEX_MODEL_ARGS`, () => { + const content = fs.readFileSync(path.join(ROOT, relPath), 'utf-8'); + const lines = content.split('\n'); + const offending: { line: number; text: string }[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!/_gstack_codex_timeout_wrapper\s+\d+\s+codex\b/.test(line)) continue; + if (!line.includes('$_CODEX_MODEL_ARGS')) { + offending.push({ line: i + 1, text: line.trim() }); + } + } + expect(offending).toEqual([]); + }); + + test(`${relPath}: $_CODEX_MODEL_ARGS is assigned via _gstack_codex_default_model_args just before each codex invocation`, () => { + const content = fs.readFileSync(path.join(ROOT, relPath), 'utf-8'); + const lines = content.split('\n'); + const offending: number[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!/_gstack_codex_timeout_wrapper\s+\d+\s+codex\b/.test(line)) continue; + // Walk back up to 5 lines to find the assignment. The template idiom + // is a single bash block, so the assignment is always within a few + // lines of the invocation. + let assigned = false; + for (let j = i - 1; j >= Math.max(0, i - 6); j--) { + if (lines[j].includes('_CODEX_MODEL_ARGS=$(_gstack_codex_default_model_args)')) { + assigned = true; + break; + } + } + if (!assigned) offending.push(i + 1); + } + expect(offending).toEqual([]); + }); + } +}); + // --- Group 2: Version check ------------------------------------------------- // Stub `codex --version` by putting a fake `codex` executable on PATH. function tempStubCodex(versionOutput: string, bool_command_fails = false): { From e7b23f2e012b0d88903b5f9c97a74f726a255807 Mon Sep 17 00:00:00 2001 From: cropsgg Date: Thu, 21 May 2026 11:07:57 +0000 Subject: [PATCH 3/3] test(build): execute helper scripts in regression test (#1602) Issue #1602's package.json fix landed in #1594, but the regression test was string-matching only. Exercise scripts/build.sh and scripts/write-version-files.sh via `bash -n` (syntax check) and a real invocation against tmpdir targets; also expand the package.json guard to cover every bunsh-incompatible construct (subshells-with-redirection, multi-redirection, brace groups, process substitution) in one place. Catches a future edit that re-introduces any of them without needing a Windows runner. --- test/build-script-shell-compat.test.ts | 88 +++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/test/build-script-shell-compat.test.ts b/test/build-script-shell-compat.test.ts index 6b39f5b3e8..360a4c91bd 100644 --- a/test/build-script-shell-compat.test.ts +++ b/test/build-script-shell-compat.test.ts @@ -1,12 +1,16 @@ 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'; const ROOT = path.resolve(import.meta.dir, '..'); const PKG = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')) as { scripts: Record; }; -const BUILD_SCRIPT = fs.readFileSync(path.join(ROOT, 'scripts', 'build.sh'), 'utf-8'); +const BUILD_SCRIPT_PATH = path.join(ROOT, 'scripts', 'build.sh'); +const WRITE_VERSION_PATH = path.join(ROOT, 'scripts', 'write-version-files.sh'); +const BUILD_SCRIPT = fs.readFileSync(BUILD_SCRIPT_PATH, 'utf-8'); // Strip single-quoted strings so JS code emitted as `echo '{ ... }'` doesn't // trip the shell-brace-group check. Conservative: only `'...'` segments. @@ -52,3 +56,85 @@ describe('package.json build scripts — POSIX shell compat (D-1460)', () => { expect(BUILD_SCRIPT).toContain('bash scripts/write-version-files.sh'); }); }); + +// ── Issue #1602: Windows build hardening ────────────────────────────────── +// Static string-matching catches the package.json regression. These tests +// also exercise the helper scripts so a hand-written bash bug (missing +// shebang, broken loop, syntax error) is caught at `bun test` time on every +// platform — no need to fully build the project to find a syntax error. + +describe('Windows build hardening (issue #1602)', () => { + test('scripts/build.sh is bash-syntax-clean (bash -n)', () => { + const r = spawnSync('bash', ['-n', BUILD_SCRIPT_PATH], { timeout: 5000 }); + expect(r.status).toBe(0); + expect((r.stderr ?? '').toString()).toBe(''); + }); + + test('scripts/write-version-files.sh is bash-syntax-clean (bash -n)', () => { + const r = spawnSync('bash', ['-n', WRITE_VERSION_PATH], { timeout: 5000 }); + expect(r.status).toBe(0); + expect((r.stderr ?? '').toString()).toBe(''); + }); + + test('scripts/write-version-files.sh writes one .version file per argument', () => { + // The build chain calls this with three paths (browse/dist/.version, + // design/dist/.version, make-pdf/dist/.version). Validate the loop body + // by invoking with controlled targets in a tmpdir — catches a future + // refactor that loses the `for/do/done` shape or drops `mkdir -p`. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-version-files-')); + try { + const a = path.join(dir, 'a', 'dist', '.version'); + const b = path.join(dir, 'b', 'dist', '.version'); + const r = spawnSync('bash', [WRITE_VERSION_PATH, a, b], { + timeout: 5000, + env: { ...process.env, PATH: process.env.PATH ?? '' }, + }); + expect(r.status).toBe(0); + expect(fs.existsSync(a)).toBe(true); + expect(fs.existsSync(b)).toBe(true); + // Both files share the same git_head value, derived once at the top + // of the script. + expect(fs.readFileSync(a, 'utf-8')).toBe(fs.readFileSync(b, 'utf-8')); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('scripts/write-version-files.sh tolerates being run outside a git repo', () => { + // The script's git rev-parse is wrapped in `if … then : else git_head=""`. + // Catches a regression where that fallback gets stripped and the script + // dies under `set -e` when run from a tarball. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-version-no-git-')); + try { + const target = path.join(dir, 'dist', '.version'); + const r = spawnSync('bash', [WRITE_VERSION_PATH, target], { + timeout: 5000, + cwd: dir, // outside any git repo + env: { + ...process.env, + PATH: process.env.PATH ?? '', + GIT_DIR: '/nonexistent', // force git rev-parse to fail + }, + }); + expect(r.status).toBe(0); + expect(fs.existsSync(target)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('package.json build entry contains no constructs the Bun Windows shell rejects', () => { + // Comprehensive guard collecting every known bunsh-incompatible pattern + // in one place. Catches a future package.json edit that re-introduces + // any of them without needing a Windows runner. + const build = PKG.scripts.build ?? ''; + // Subshells `( ... )` — bunsh rejects when paired with redirection. + expect(build).not.toMatch(/\([^)]*\)\s*[<>]/); + // Multiple redirections — `cmd >a 2>b` form. Bunsh rejects ">" "2>" pairs. + expect(build).not.toMatch(/>\s*\S+\s+2>/); + // Bash brace groups `{ cmd; }`. + expect(stripSingleQuoted(build)).not.toMatch(/\{\s+[^}]*;\s*\}/); + // Process substitution `<(cmd)` and `>(cmd)`. + expect(build).not.toMatch(/[<>]\([^)]+\)/); + }); +});