diff --git a/AGENTS.md b/AGENTS.md index 4299662d0..a9747bc36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -303,4 +303,4 @@ Agents poll `eval:bg:*` logs for the `### gstack-detach EXIT= ###` sentine - Every Codex run's verdict goes through `lib/outside-review-result.ts`; its reason codes live in `lib/gate-outcomes.ts`, each with a `docs/troubleshooting.md` anchor. - `test/module-size-ratchet.test.ts` keeps refactored owner modules at or under 800 lines (150 per function) and residual files from growing. - The `claude` CLI binary resolves via `lib/claude-bin.ts` (re-exported from `browse/src/claude-bin.ts` for browse internals; `Bun.which()` + `GSTACK_CLAUDE_BIN` override). Set `GSTACK_CLAUDE_BIN=wsl` plus `GSTACK_CLAUDE_BIN_ARGS='["claude"]'` to run Claude through WSL on Windows. -- Every Bun gstack starts ignores the working directory's `.env` files and `bunfig.toml`: bun shebangs are `#!/usr/bin/env -S bun --no-env-file --config=/dev/null`, shell and skill callers pass `bun --no-env-file --config=/dev/null`, TypeScript spawns use `bunIsolationArgs()` from `lib/bun-isolation.ts`, and `bun build --compile` adds `--no-compile-autoload-dotenv --no-compile-autoload-bunfig`. `test/bun-entry-isolation.test.ts` enforces all four. +- Every Bun gstack starts ignores the working directory's `.env` files and `bunfig.toml`: bun shebangs are `#!/usr/bin/env -S bun --no-env-file --config=/dev/null`, skill prose passes `bun --no-env-file --config=/dev/null`, shipped shell files define `GSTACK_BUN_CONFIG` with the line in `test/bun-entry-isolation.test.ts` (`NUL` under MINGW, MSYS and Cygwin, where native Bun cannot open `/dev/null`, #3415) and pass `bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}"`, TypeScript spawns use `bunIsolationArgs()` from `lib/bun-isolation.ts`, and `bun build --compile` adds `--no-compile-autoload-dotenv --no-compile-autoload-bunfig`. `test/bun-entry-isolation.test.ts` enforces all four. diff --git a/CHANGELOG.md b/CHANGELOG.md index ba97bc0e7..256d0b9f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [Unreleased] + +**Windows: gstack's shell bins stop losing review, question, learning and timeline records when Git Bash path conversion is off (#3415).** Since GSTA-56 every shell bin started Bun with `--config=/dev/null`. Native `bun.exe` cannot open `/dev/null`: it exits 1 with `EINVAL` on Bun 1.3.10, 1.4.2 and 1.4.4 (measured on windows-latest). Git Bash hides this by rewriting the argument to `nul`, but only while MSYS path conversion is on. With it off (`MSYS_NO_PATHCONV` set to any value, even empty; `MSYS2_ARG_CONV_EXCL=*`; or Cygwin), `gstack-review-log`, `gstack-question-log` and `gstack-learnings-log` exited 1 with nothing written, and `gstack-timeline-log` exited 0 with nothing written. + +### Fixed +- **40 shipped shell files** (the bash bins, the Claude hook wrappers, `setup`, `scripts/build-cso.sh` and one migration) define `GSTACK_BUN_CONFIG` once (`NUL` under MINGW, MSYS and Cygwin, `/dev/null` elsewhere) and start Bun with `--config="${GSTACK_BUN_CONFIG:-/dev/null}"`. Bun 1.4.2 and later, gstack's minimum, read `NUL` as an empty config whether or not conversion is on. The `:-/dev/null` default fails closed: a call that loses its definition still passes `/dev/null`, so POSIX is unchanged and Windows fails loudly as before, and never reads the repository's `bunfig.toml`. `bin/gstack-codex-probe` is sourced into the caller's shell, so it defines the variable inside the one function that starts Bun and still runs nothing at source time. +- **`bin/gstack-timeline-log`**: invalid input still exits 0 with nothing logged (the hook-path contract). Its validator now exits 3 for invalid input, and any other non-zero exit, which is Bun itself failing, is reported on stderr and returned instead of being skipped as invalid. +- Unchanged: bun shebangs and skill prose keep `--config=/dev/null`. A shebang cannot name a per-platform path, and with conversion off their POSIX script paths (`/c/Users/...`) fail in `bun.exe` anyway, so they work wherever they worked before. TypeScript spawns keep `bunIsolationArgs()`, and `scripts/ubicloud/` keeps `/dev/null` because it runs on Linux. + +### Tests +- `test/bun-config-shell-bins.test.ts` (new; force-included in the Windows lane): on every platform, the four record bins run through `lib/script-invocation.ts` with conversion off in a repository whose `bunfig.toml` preload and `.env` are hostile. Each writes exactly one record, and neither the preload nor the `.env` state root takes effect. Invalid timeline input still exits 0 silently. On POSIX hosts a simulated Windows bash (uname reports MINGW64, and a `bun` stand-in rejects `/dev/null` with EINVAL as `bun.exe` does) reproduces the bug: on main the four bins write nothing and the timeline control exits 0 (5 failures); with the fix they pass. The controls, with uname reporting Linux, show each bin exits non-zero with nothing written. +- `test/bun-isolation-launch.test.ts`: the shell-file spelling stays isolated with conversion off, and a win32-only control shows that the literal `--config=/dev/null` fails there with EINVAL. +- `test/bun-entry-isolation.test.ts`: shipped shell files must use the fail-closed spelling and define `GSTACK_BUN_CONFIG` with the canonical line before first use. Skill sources keep the literal spelling. +- `test/learnings-injection.test.ts`, `browse/test/learnings-injection.test.ts` and `test/setup-hook-canonical-paths.test.ts` now anchor on the new spelling. Their assertions are unchanged. + +### Gate integrity +- `test/bun-entry-isolation.test.ts` is strengthened. On POSIX the required shell spelling still resolves to `/dev/null`. The literal `--config=/dev/null` in a shipped shell file is now an offender, as is an undefined or redefined `GSTACK_BUN_CONFIG`. The shell-caller detector also skips a `--config` value before deciding whether a call starts a program, so `bun --no-env-file --config= -e` no longer passes unchecked; the tree has no such call. Negative controls: the old spelling, a variable without the default, an empty `--config=`, a missing, late or altered definition, and a reverted bin each fail the check. No threshold, budget, timeout, trial count or case kind changed. + ## [1.91.221.0] - 2026-10-25 **The session-start updater no longer skips because of its own session's workflow lease, and the autostash tests stop failing their first pass in Free Tests.** Since #3385, `gstack-session-update` skips the fast-forward while another session holds a workflow lease, and it means to ignore the session that started it (`--exclude-ancestry-of`). The check runs in the forked background updater, though, and passed the hook's own `$$`, which has exited by then, so the ancestry walk excluded nothing. A Claude Code process that had already run a skill (and so held a lease) and then started a session again in the same process (`/clear`, resume or compaction) logged `SKIP leased pid=` and never updated. `test/session-update-autostash.test.ts` hit the same gap plus a fixture leak: its first two fixtures spawned the updater with the whole parent environment, so it read the test preload's `GSTACK_LEASE_DIR`, which every test file in the same Bun process shares. A lease another file left there gave "Received: SKIP leased pid=3196/1m" where UPDATING or PULL_FAILED was expected, in 13 Free Tests runs since #3385 merged (each passed on the serial retry). diff --git a/autoplan/bin/phase-publication-hook b/autoplan/bin/phase-publication-hook index 2de8c92d9..f7633d29f 100755 --- a/autoplan/bin/phase-publication-hook +++ b/autoplan/bin/phase-publication-hook @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Native Read barrier. A broken install must return deny JSON, never silence. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac _AUTOPLAN_DECIDED='' _autoplan_backstop() { if [ -z "$_AUTOPLAN_DECIDED" ]; then @@ -9,7 +10,7 @@ _autoplan_backstop() { } trap _autoplan_backstop EXIT _AUTOPLAN_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" -_AUTOPLAN_RESULT="$(bun --no-env-file --config=/dev/null "$_AUTOPLAN_HOOK_DIR/phase-publication-hook.ts")" +_AUTOPLAN_RESULT="$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$_AUTOPLAN_HOOK_DIR/phase-publication-hook.ts")" test -n "$_AUTOPLAN_RESULT" printf '%s\n' "$_AUTOPLAN_RESULT" _AUTOPLAN_DECIDED=1 diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index 50f377b0b..8886a84bc 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -77,6 +77,7 @@ BASH_COMPAT=50 set -uo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select @@ -576,7 +577,7 @@ PYEOF CONSENT_TS="$SCRIPT_DIR/../lib/transcript-consent.ts" transcript_policy() { command -v bun >/dev/null 2>&1 || return 2 - GSTACK_STATE_ROOT="$GSTACK_HOME" bun --no-env-file --config=/dev/null run "$CONSENT_TS" "$@" + GSTACK_STATE_ROOT="$GSTACK_HOME" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$CONSENT_TS" "$@" } # 0 when staged transcript pages may sync at all. diff --git a/bin/gstack-browser-ensure b/bin/gstack-browser-ensure index bda9718f5..f64765dfc 100755 --- a/bin/gstack-browser-ensure +++ b/bin/gstack-browser-ensure @@ -24,6 +24,7 @@ # GSTACK_CHROMIUM_NO_SANDBOX=1 for the probe. Bash 3.2 plus POSIX tools. BASH_COMPAT=50 set -u +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" || { echo "gstack-browser-ensure: cannot resolve the install root" >&2; exit 1; } DOCS="https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#browser-ensure-failed" @@ -58,7 +59,7 @@ probe() { else PROBE_OUT=$(cd "$ROOT" && node -e "$PROBE" &1); PROBE_RC=$?; fi elif command -v bun >/dev/null 2>&1; then ENGINE=bun - PROBE_OUT=$(cd "$ROOT" && bun --no-env-file --config=/dev/null -e "$PROBE" &1); PROBE_RC=$? + PROBE_OUT=$(cd "$ROOT" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$PROBE" &1); PROBE_RC=$? else fail "neither node nor bun is on PATH to launch Chromium" "install Node.js (https://nodejs.org) or Bun, then re-run $0" fi diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index a089b8d62..becf3653c 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -157,6 +157,7 @@ _gstack_codex_select_model() { # working-directory context forwarded to the shared role resolver. # The TOML is read by the TypeScript resolver; bash never parses it. local _kind="${1:-exec}" _explicit="${2:-}" _role="${3:-}" _cwd="${4:-}" _line _rc _rest + local GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac case "$_kind" in exec|review) ;; *) echo "ERROR: _gstack_codex_select_model takes exec or review" >&2; return 2 ;; esac case "$_role" in ''|plan-review) ;; *) echo "ERROR: _gstack_codex_select_model role must be plan-review" >&2; return 2 ;; esac if ! command -v bun >/dev/null 2>&1; then @@ -168,7 +169,7 @@ _gstack_codex_select_model() { [ -n "$_role" ] && set -- "$@" --role "$_role" [ -n "$_explicit" ] && set -- "$@" --explicit "$_explicit" [ -n "$_cwd" ] && set -- "$@" --cwd "$_cwd" - _line=$(bun --no-env-file --config=/dev/null run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" "$@") + _line=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$_GSTACK_CODEX_BIN/../scripts/resolve-codex-generation-model.ts" "$@") _rc=$? _GSTACK_CODEX_SEL="${_line%%$'\t'*}" _rest="${_line#*$'\t'}" diff --git a/bin/gstack-codex-session-import b/bin/gstack-codex-session-import index 782ae0550..9138be09c 100755 --- a/bin/gstack-codex-session-import +++ b/bin/gstack-codex-session-import @@ -20,6 +20,7 @@ # Writes via bin/gstack-question-log so source tagging, dedup, and async # derive all apply uniformly. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select @@ -101,7 +102,7 @@ IMPORTED=0 SKIPPED_NO_ANSWER=0 for SESSION_FILE in "${SESSION_FILES[@]}"; do - COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun --no-env-file --config=/dev/null -e ' + COUNT_LINE=$(SESSION_FILE_PATH="$SESSION_FILE" QLOG_BIN="$SCRIPT_DIR/gstack-question-log" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const fs = require("fs"); const path = require("path"); const { spawnSync } = require("child_process"); diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index f5bfd5391..03a466c44 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -28,6 +28,7 @@ # docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/ # question-events.jsonl. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -70,7 +71,7 @@ do_migrate() { TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") trap 'rm -f "$TMPOUT"' EXIT - cat "$LEGACY_FILE" | bun --no-env-file --config=/dev/null -e " + cat "$LEGACY_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const sessions = []; const signalsAcc = {}; @@ -127,7 +128,7 @@ do_migrate() { mv "$LEGACY_FILE" "$LEGACY_FILE.migrated-$TS" local COUNT - COUNT=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config=/dev/null -e "console.log(JSON.parse(require('fs').readFileSync(process.env.PROFILE_FILE_PATH,'utf-8')).sessions.length)" 2>/dev/null || echo "?") + COUNT=$(PROFILE_FILE_PATH="$PROFILE_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "console.log(JSON.parse(require('fs').readFileSync(process.env.PROFILE_FILE_PATH,'utf-8')).sessions.length)" 2>/dev/null || echo "?") echo "MIGRATE: ok — migrated $COUNT sessions from builder-profile.jsonl" } @@ -184,7 +185,7 @@ do_log_session() { fi # Validate: input must be parseable JSON with required fields (date, mode). - if ! printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e " + if ! printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const j = JSON.parse(await Bun.stdin.text()); if (!j.date || !j.mode) process.exit(1); " 2>/dev/null; then @@ -197,7 +198,7 @@ do_log_session() { TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") trap 'rm -f "$TMPOUT"' EXIT - PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config=/dev/null -e " + PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const entry = JSON.parse(process.env.RECORD_INPUT); if (!entry.ts) entry.ts = new Date().toISOString(); @@ -244,7 +245,7 @@ do_reconcile() { TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") trap 'rm -f "$TMPOUT"' EXIT - PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config=/dev/null -e " + PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const path = require('path'); const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8')); @@ -314,7 +315,7 @@ do_reconcile() { # ----------------------------------------------------------------------- do_read() { ensure_profile - cat "$PROFILE_FILE" | bun --no-env-file --config=/dev/null -e " + cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const p = JSON.parse(await Bun.stdin.text()); const sessions = p.sessions || []; @@ -388,7 +389,7 @@ do_profile() { # ----------------------------------------------------------------------- do_gap() { ensure_profile - cat "$PROFILE_FILE" | bun --no-env-file --config=/dev/null -e " + cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const p = JSON.parse(await Bun.stdin.text()); const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; @@ -417,7 +418,7 @@ do_derive() { fi cd "$ROOT_DIR" - PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config=/dev/null -e " + PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " import('./scripts/question-registry.ts').then(async (regmod) => { const sigmod = await import('./scripts/psychographic-signals.ts'); const fs = require('fs'); @@ -504,7 +505,7 @@ do_trace() { return 0 fi cd "$ROOT_DIR" - EVENTS_PATH="$EVENTS" TRACE_DIM="$DIM" bun --no-env-file --config=/dev/null -e " + EVENTS_PATH="$EVENTS" TRACE_DIM="$DIM" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " import('./scripts/question-registry.ts').then(async (regmod) => { const sigmod = await import('./scripts/psychographic-signals.ts'); const fs = require('fs'); @@ -542,7 +543,7 @@ do_trace() { # ----------------------------------------------------------------------- do_check_mismatch() { ensure_profile - cat "$PROFILE_FILE" | bun --no-env-file --config=/dev/null -e " + cat "$PROFILE_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const p = JSON.parse(await Bun.stdin.text()); const declared = p.declared || {}; const inferred = (p.inferred && p.inferred.values) || {}; @@ -586,7 +587,7 @@ do_narrative() { do_vibe() { ensure_profile cd "$ROOT_DIR" - cat "$PROFILE_FILE" | PROFILE_DATA="$(cat "$PROFILE_FILE")" bun --no-env-file --config=/dev/null -e " + cat "$PROFILE_FILE" | PROFILE_DATA="$(cat "$PROFILE_FILE")" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " import('./scripts/archetypes.ts').then(async (mod) => { const p = JSON.parse(process.env.PROFILE_DATA); const dims = (p.inferred && p.inferred.values) || { diff --git a/bin/gstack-distill-apply b/bin/gstack-distill-apply index 9a0a804b0..a45c86bbe 100755 --- a/bin/gstack-distill-apply +++ b/bin/gstack-distill-apply @@ -23,6 +23,7 @@ # gstack-distill-apply --proposal --gbrain-published true # gstack-distill-apply --list # show pending proposals set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select @@ -57,7 +58,7 @@ if [ ! -f "$PROPOSAL_FILE" ]; then fi if [ "$ACTION" = "list" ]; then - PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun --no-env-file --config=/dev/null -e ' + PROPOSAL_FILE_PATH="$PROPOSAL_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const fs = require("fs"); const p = JSON.parse(fs.readFileSync(process.env.PROPOSAL_FILE_PATH, "utf-8")); const proposals = p.proposals || []; @@ -100,7 +101,7 @@ MEMORY_FILE_PATH="$MEMORY_FILE" \ PROFILE_FILE_PATH="$PROFILE_FILE" \ PREF_BIN="$SCRIPT_DIR/gstack-question-preference" \ GBRAIN_PUBLISHED="$GBRAIN_PUBLISHED" \ -bun --no-env-file --config=/dev/null -e ' +bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const fs = require("fs"); const { spawnSync } = require("child_process"); const idx = parseInt(process.env.PROPOSAL_IDX, 10); diff --git a/bin/gstack-distill-free-text b/bin/gstack-distill-free-text index 97e3e1291..d3e7cb569 100755 --- a/bin/gstack-distill-free-text +++ b/bin/gstack-distill-free-text @@ -20,6 +20,7 @@ # auditability via --status when you want it. # Per D6: Anthropic SDK direct call, fail-loud on missing ANTHROPIC_API_KEY. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac # Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a # pipe in the forked child before exec, with no reader on the other end. On @@ -59,7 +60,7 @@ esac # --- Status subcommand -------------------------------------------------- if [ "$MODE" = "status" ]; then - COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun --no-env-file --config=/dev/null -e ' + COST_LOG_PATH="$COST_LOG" SLUG_PATH="$SLUG" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const fs = require("fs"); const slug = process.env.SLUG_PATH; const path = process.env.COST_LOG_PATH; @@ -98,7 +99,7 @@ if [ ! -f "$LOG_FILE" ]; then exit 0 fi -EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config=/dev/null -e ' +EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const fs = require("fs"); const lines = fs.readFileSync(process.env.LOG_FILE_PATH, "utf-8").trim().split("\n").filter(Boolean); const out = []; @@ -119,7 +120,7 @@ EVENTS_JSON=$(LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config=/dev/null -e process.stdout.write(JSON.stringify(out)); ') -EVENT_COUNT=$(printf '%s' "$EVENTS_JSON" | bun --no-env-file --config=/dev/null -e 'const a = JSON.parse(await Bun.stdin.text()); console.log(a.length);') +EVENT_COUNT=$(printf '%s' "$EVENTS_JSON" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'const a = JSON.parse(await Bun.stdin.text()); console.log(a.length);') if [ "$EVENT_COUNT" -eq 0 ]; then echo "NO_FREE_TEXT: nothing to distill" exit 0 @@ -172,7 +173,7 @@ if [ "$MODE" = "dry-run" ]; then echo "$DISTILL_PROMPT" echo echo "=== EVENTS ($EVENT_COUNT) ===" - echo "$EVENTS_JSON" | bun --no-env-file --config=/dev/null -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()), null, 2));' + echo "$EVENTS_JSON" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'console.log(JSON.stringify(JSON.parse(await Bun.stdin.text()), null, 2));' exit 0 fi @@ -196,7 +197,7 @@ fi RESULT=$(EVENTS_JSON="$EVENTS_JSON" DISTILL_PROMPT="$DISTILL_PROMPT" \ PROPOSAL_FILE_PATH="$PROPOSAL_FILE" LOG_FILE_PATH="$LOG_FILE" \ ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ - bun --no-env-file --config=/dev/null --cwd "$ROOT_DIR" -e ' + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" --cwd "$ROOT_DIR" -e ' const fs = require("fs"); const Anthropic = require("@anthropic-ai/sdk").default; const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); diff --git a/bin/gstack-doctor b/bin/gstack-doctor index d088098c6..002eebf8d 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -40,6 +40,7 @@ BASH_COMPAT=50 set -u +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac usage() { sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//' @@ -512,7 +513,7 @@ check_windows_bash() { row warn "windows bash" "not checked: bun not found" "install Bun (see the bun row), then rerun $BIN/gstack-doctor" return fi - out=$(bun --no-env-file --config=/dev/null "$BIN/gstack-windows-bash.ts" ${GSTACK_DOCTOR_BASH_PROBE:+--probe "$GSTACK_DOCTOR_BASH_PROBE"} &1) + out=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$BIN/gstack-windows-bash.ts" ${GSTACK_DOCTOR_BASH_PROBE:+--probe "$GSTACK_DOCTOR_BASH_PROBE"} &1) rc=$? out=$(printf '%s\n' "$out" | tr -d '\000-\010\013-\037') bash=$(printf '%s\n' "$out" | sed -n 's/^WINDOWS_BASH: //p' | head -1) diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index 12fb50a90..1fc94ee59 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -19,6 +19,7 @@ # remote's capy/support.env, `unknown` when its fetch failed, or ""), LIVE. # GSTACK_CAPY_WRAPPER_PATH (optional) names the wrapper a below-minimum fix runs. +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac . "$BIN/gstack-doctor-components.sh" || { echo "gstack-doctor: $BIN/gstack-doctor-components.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } . "$BIN/gstack-install-registry.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-install-registry.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } . "$BIN/gstack-bun-version.sh" 2>/dev/null || { echo "gstack-doctor: $BIN/gstack-bun-version.sh is missing; reinstall with cd $SRC && ./setup" >&2; exit 1; } @@ -213,7 +214,7 @@ _dc_check_browser() { out=$(cd "$ROOT" && { command -v timeout >/dev/null 2>&1 && timeout 90 node -e "$script" &1 || node -e "$script" &1; }); rc=$? elif command -v bun >/dev/null 2>&1; then engine=bun - out=$(cd "$ROOT" && bun --no-env-file --config=/dev/null -e "$script" &1); rc=$? + out=$(cd "$ROOT" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$script" &1); rc=$? else _dc_row FAIL browser "neither node nor bun is on PATH to launch Chromium" "install Node.js (https://nodejs.org) or Bun, then $fix" BROWSER_UNAVAILABLE return @@ -329,7 +330,7 @@ _dc_check_pins() { return fi node=$(node --version /dev/null | clamp 'v0-9.' 20); node="${node#v}" - out=$(bun --no-env-file --config=/dev/null "$BIN/gstack-runtime-pins.ts" --project "$project" ${_dc_bun:+--bun "$_dc_bun"} ${node:+--node "$node"} &1); rc=$? + out=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$BIN/gstack-runtime-pins.ts" --project "$project" ${_dc_bun:+--bun "$_dc_bun"} ${node:+--node "$node"} &1); rc=$? verdict=$(printf '%s\n' "$out" | sed -n 's/^PINS_VERDICT: //p' | tail -1 | clamp 'a-z' 8) summary=$(printf '%s\n' "$out" | sed -n 's/^PIN_SUMMARY: //p' | tr '\n' '|' | sed 's/|$//; s/|/; /g') case "$verdict" in @@ -357,7 +358,7 @@ _dc_check_pins() { fi fi if [ -n "$_dc_ln_dir" ] && [ "$_dc_ln_ver" != "$_dc_bun" ] \ - && bun --no-env-file --config=/dev/null "$BIN/gstack-runtime-pins.ts" --project "$project" --bun "$_dc_ln_ver" ${node:+--node "$node"} /dev/null | grep -q '^PINS_VERDICT: pass'; then + && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$BIN/gstack-runtime-pins.ts" --project "$project" --bun "$_dc_ln_ver" ${node:+--node "$node"} /dev/null | grep -q '^PINS_VERDICT: pass'; then note="; bun $_dc_ln_ver found at $_dc_ln_dir (not on PATH) satisfies them" fix="$_dc_ln_fix" fi _dc_row FAIL pins "$failline$note" "$fix" PROJECT_PIN_MISMATCH ;; diff --git a/bin/gstack-gbrain-install b/bin/gstack-gbrain-install index 9ecdcb4e0..f311a68d6 100755 --- a/bin/gstack-gbrain-install +++ b/bin/gstack-gbrain-install @@ -36,6 +36,7 @@ # 2 — prerequisite missing or invalid argument # 3 — post-install validation failed (PATH shadow, broken binary, etc.) set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac # --- defaults --- # No version pin by default — install the latest default-branch HEAD (#1744). @@ -100,7 +101,7 @@ fi # bun, which this installer already requires. It used to need jq, which it # never checked for, so a successful install reported failure (#1675). pkg_field() { - GSTACK_PKG_JSON="$1" GSTACK_PKG_FIELD="$2" bun --no-env-file --config=/dev/null -e ' + GSTACK_PKG_JSON="$1" GSTACK_PKG_FIELD="$2" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' try { const pkg = JSON.parse(require("fs").readFileSync(process.env.GSTACK_PKG_JSON, "utf8")); const v = process.env.GSTACK_PKG_FIELD.split(".").reduce((o, k) => (o == null ? o : o[k]), pkg); @@ -198,7 +199,7 @@ _bun_exe_hint() { [ "$IS_WINDOWS" -eq 1 ] || return 0 command -v bun.exe >/dev/null 2>&1 && return 0 local real_bun - real_bun=$(bun --no-env-file --config=/dev/null -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true) + real_bun=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'console.log(process.execPath)' 2>/dev/null | tr -d '\r' || true) echo " detected: bun was installed via npm — bun.exe is NOT on %PATH%, and the gbrain.exe shim needs it." >&2 if [ -n "$real_bun" ]; then echo " fix: add bun.exe's directory to PATH (persist it in your shell profile):" >&2 diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index c5f23738c..cb20bc3e4 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -39,6 +39,7 @@ # Depends on: bun (already a gstack dependency), gbrain, git. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" @@ -222,7 +223,7 @@ ensure_worktree() { # is not JSON. source_local_path() { gbrain sources list --json 2>/dev/null \ - | GB_SOURCE_ID="$1" bun --no-env-file --config=/dev/null -e ' + | GB_SOURCE_ID="$1" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const raw = await Bun.stdin.text(); try { const s = (JSON.parse(raw).sources || []).find((x) => x.id === process.env.GB_SOURCE_ID); diff --git a/bin/gstack-hook-check b/bin/gstack-hook-check index e6cc2a73c..c0b2d1b5f 100755 --- a/bin/gstack-hook-check +++ b/bin/gstack-hook-check @@ -37,6 +37,7 @@ # (needed when a hook runs TypeScript) is not found. Set GSTACK_HOOK_CHECK_BUN to # the bun to run (setup passes its own). set -u +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac LIST_ONLY=0 SETUP_FILE="" while [ $# -gt 1 ]; do @@ -173,7 +174,7 @@ if [ -n "$ENTRIES" ]; then # up below (lib/path-spelling.ts shellPath; test/path-spelling.test.ts keeps # the two equal). # shellcheck disable=SC2086 # newline-split list (IFS is set to a newline below) - (cd "$ROOT" && IFS=$NL && "$BUN" --no-env-file --config=/dev/null -e ' + (cd "$ROOT" && IFS=$NL && "$BUN" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const shellPath = (value) => process.platform === "win32" ? String(value).replace(/\\/g, "/").replace(/^([A-Za-z]):(?=\/|$)/, (_, drive) => `/${drive.toLowerCase()}`) : String(value); diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index e500344a8..d8cd779c5 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -31,6 +31,8 @@ # spawned sessions (ambient OpenClaw) only warn. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 # admits anyway and says so. +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac + # gstack_source_root BIN_DIR — the source checkout behind an install: an # installed runtime root's recorded .source-path (setup _link_runtime_dists), # else BIN_DIR/.. resolved. Prints the realpath; returns 1 when unresolvable. @@ -159,7 +161,7 @@ gstack_install_admission() { v_bun=$(gstack_install_verdict_field "$file" bun_path) if [ -z "$detail" ] && [ -n "$v_bun" ]; then running=$(gstack_realpath "$where" 2>/dev/null) - _gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config=/dev/null -p process.execPath /dev/null | head -1) + _gstack_bun_is_shim "$where" && running=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -p process.execPath /dev/null | head -1) if [ -n "$running" ] && [ "$running" != "$v_bun" ]; then _ia_status=runtime code=BUN_RESOLUTION_DRIFT detail="bun $found at $running is running, but the install verdict recorded bun $(gstack_install_verdict_field "$file" bun_version) at $v_bun" diff --git a/bin/gstack-ios-qa-daemon b/bin/gstack-ios-qa-daemon index 391dd225d..1912a58b3 100755 --- a/bin/gstack-ios-qa-daemon +++ b/bin/gstack-ios-qa-daemon @@ -21,6 +21,7 @@ # session tokens are outstanding. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac # The --help heredoc is over 512 bytes; keep it off a pipe (test/heredoc-pipe-deadlock.test.ts). BASH_COMPAT=50 @@ -67,4 +68,4 @@ if ! command -v bun >/dev/null 2>&1; then exit 1 fi -exec bun --no-env-file --config=/dev/null run "$ENTRY" "$@" +exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$ENTRY" "$@" diff --git a/bin/gstack-ios-qa-mint b/bin/gstack-ios-qa-mint index 8270ffbda..0e9610307 100755 --- a/bin/gstack-ios-qa-mint +++ b/bin/gstack-ios-qa-mint @@ -10,6 +10,7 @@ # Allowlist file: ~/.gstack/ios-qa-allowlist.json (mode 0600). set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -25,4 +26,4 @@ if ! command -v bun >/dev/null 2>&1; then exit 1 fi -exec bun --no-env-file --config=/dev/null run "$ENTRY" "$@" +exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$ENTRY" "$@" diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen index b14a86251..917e23190 100755 --- a/bin/gstack-ios-qa-regen +++ b/bin/gstack-ios-qa-regen @@ -3,6 +3,7 @@ # package and the app-owned typed state accessors. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac usage() { cat <<'EOF' @@ -144,7 +145,7 @@ do fi done -bun --no-env-file --config=/dev/null run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR" +bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR" # Stamp only after successful accessor generation. ios-sync uses this marker # to distinguish a complete current install from an interrupted regeneration. diff --git a/bin/gstack-learnings-log b/bin/gstack-learnings-log index ca8bfeb05..d8a03b5a3 100755 --- a/bin/gstack-learnings-log +++ b/bin/gstack-learnings-log @@ -6,6 +6,7 @@ # Append-only storage. Duplicates (same key+type) are resolved at read time # by gstack-learnings-search ("latest winner" per key+type). set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun # on Windows cannot resolve as an ES module specifier in the import below. @@ -27,7 +28,7 @@ INPUT="$1" TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-learnings-log.XXXXXX") || { echo "gstack-learnings-log: could not create a temp file in ${TMPDIR:-/tmp}, so the learning was not recorded. Fix: point TMPDIR at a writable directory." >&2; exit 1; } trap 'rm -f "$TMPERR"' EXIT set +e -VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config=/dev/null -e " +VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const { hasInjection } = await import(process.env.GSTACK_LIB_DIR + '/jsonl-store.ts'); const raw = await Bun.stdin.text(); let j; diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 6f58737a4..d0442bf2d 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -8,6 +8,7 @@ # exits 127 and a failed read exits non-zero with its stderr (#2790): "could # not read" must never look like "nothing recorded". set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } @@ -69,7 +70,7 @@ emit_tagged_file() { emit_tagged_file cross "$f" done fi -} | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun --no-env-file --config=/dev/null -e " +} | GSTACK_SEARCH_TYPE="$TYPE" GSTACK_SEARCH_QUERY="$QUERY" GSTACK_SEARCH_LIMIT="$LIMIT" GSTACK_SEARCH_CROSS="$CROSS_PROJECT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const now = Date.now(); const type = process.env.GSTACK_SEARCH_TYPE || ''; diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 629040674..391323568 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -32,6 +32,7 @@ # Heredoc delivery guard (see bin/gstack-settings-hook for the rationale). BASH_COMPAT=50 set -uo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -374,7 +375,7 @@ status_bridge() { # JSON; a failed query is reported as unknown, never as an empty history. local egress_json if egress_json="$("$EGRESS_BIN" list --sink "$SINK" --json 2>/dev/null)"; then - n="$(printf '%s' "$egress_json" | bun --no-env-file --config=/dev/null -e 'const a=JSON.parse(require("fs").readFileSync(0,"utf8")||"[]");console.log(Array.isArray(a)?a.length:0)' 2>/dev/null)" + n="$(printf '%s' "$egress_json" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'const a=JSON.parse(require("fs").readFileSync(0,"utf8")||"[]");console.log(Array.isArray(a)?a.length:0)' 2>/dev/null)" case "$n" in *[!0-9]*|"") n="unknown (could not parse gstack-egress output)" ;; esac else n="unknown (gstack-egress list failed; run it yourself)" diff --git a/bin/gstack-question-log b/bin/gstack-question-log index e1722f95f..b181e7e76 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -26,6 +26,7 @@ # # Append-only JSONL. Dedup is at read time in gstack-question-sensitivity --read-log. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # Windows git-bash (#1950): pwd yields a POSIX path (/c/Users/...), which Bun # on Windows cannot resolve as an ES module specifier in bun -e imports. @@ -46,7 +47,7 @@ INPUT="$1" TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-question-log.XXXXXX") || { echo "gstack-question-log: could not create a temp file in ${TMPDIR:-/tmp}, so the question was not logged. Fix: point TMPDIR at a writable directory." >&2; exit 1; } trap 'rm -f "$TMPERR"' EXIT set +e -VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config=/dev/null -e " +VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const { hasInjection } = await import(process.env.GSTACK_LIB_DIR + '/jsonl-store.ts'); const path = require('path'); const raw = await Bun.stdin.text(); @@ -218,7 +219,7 @@ LOG_FILE="$GSTACK_HOME/projects/$SLUG/question-log.jsonl" # Lookup is bounded so the bin stays cheap on hot paths. DEDUP_SKIP="" if [ -f "$LOG_FILE" ]; then - DEDUP_SKIP=$(VALIDATED_JSON="$VALIDATED" LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config=/dev/null -e ' + DEDUP_SKIP=$(VALIDATED_JSON="$VALIDATED" LOG_FILE_PATH="$LOG_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const fs = require("fs"); const j = JSON.parse(process.env.VALIDATED_JSON); if (!j.tool_use_id) { console.log(""); process.exit(0); } @@ -238,7 +239,7 @@ if [ -f "$LOG_FILE" ]; then fi if [ "$DEDUP_SKIP" = "dup" ]; then - echo "DEDUP: skipped (source=$(echo "$VALIDATED" | bun --no-env-file --config=/dev/null -e 'const j=JSON.parse(await Bun.stdin.text()); console.log(j.source);'), tool_use_id duplicate)" + echo "DEDUP: skipped (source=$(echo "$VALIDATED" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e 'const j=JSON.parse(await Bun.stdin.text()); console.log(j.source);'), tool_use_id duplicate)" exit 0 fi diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index 2cc2e81fc..4114c2147 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -35,6 +35,7 @@ # --check already ignores those prefs; storing them made --stats lie. # always-ask on a one-way id is fine (it agrees with the safety override). set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -105,7 +106,7 @@ do_check() { fi ensure_file cd "$ROOT_DIR" - PREF_FILE_PATH="$PREF_FILE" GLOBAL_PREF_FILE_PATH="$GLOBAL_PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun --no-env-file --config=/dev/null -e " + PREF_FILE_PATH="$PREF_FILE" GLOBAL_PREF_FILE_PATH="$GLOBAL_PREF_FILE" QID="$QID" QSUMMARY="$QSUMMARY" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " import('./scripts/one-way-doors.ts').then((oneway) => { const fs = require('fs'); const qid = process.env.QID; @@ -201,7 +202,7 @@ do_write() { set +e local RESULT - RESULT=$(cd "$ROOT_DIR" && printf '%s' "$INPUT" | PREF_FILE_PATH="$PREF_FILE" EVENT_FILE_PATH="$EVENT_FILE" FREE_TEXT="$FREE_TEXT" HAS_FREE_TEXT="$HAS_FREE_TEXT" bun --no-env-file --config=/dev/null -e " + RESULT=$(cd "$ROOT_DIR" && printf '%s' "$INPUT" | PREF_FILE_PATH="$PREF_FILE" EVENT_FILE_PATH="$EVENT_FILE" FREE_TEXT="$FREE_TEXT" HAS_FREE_TEXT="$HAS_FREE_TEXT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const raw = await Bun.stdin.text(); let j; @@ -320,7 +321,7 @@ do_clear() { echo '{}' > "$PREF_FILE" echo "OK: cleared all preferences" else - PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun --no-env-file --config=/dev/null -e " + PREF_FILE_PATH="$PREF_FILE" QID="$QID" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8')); if (prefs[process.env.QID] !== undefined) { @@ -339,7 +340,7 @@ do_clear() { # ----------------------------------------------------------------------- do_stats() { ensure_file - (cd "$ROOT_DIR" && PREF_FILE_PATH="$PREF_FILE" bun --no-env-file --config=/dev/null -e " + (cd "$ROOT_DIR" && PREF_FILE_PATH="$PREF_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " import('./scripts/one-way-doors.ts').then((oneway) => { const fs = require('fs'); const prefs = JSON.parse(fs.readFileSync(process.env.PREF_FILE_PATH, 'utf-8')); diff --git a/bin/gstack-relink b/bin/gstack-relink index f280390c2..351cdf294 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -9,6 +9,7 @@ # GSTACK_INSTALL_DIR — override gstack install directory # GSTACK_SKILLS_DIR — override target skills directory set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config" @@ -474,7 +475,7 @@ if { [ "$DISABLED" != " " ] || grep -q '^Disabled on this install' "$_ROUTER" 2> _ROUTER_TMP="$(mktemp -d "${TMPDIR:-/tmp}/gstack-router.XXXXXX")" || _ROUTER_TMP="" _DISABLED_CSV="$(printf '%s' "$DISABLED" | xargs | tr ' ' ',')" if [ -n "$_ROUTER_TMP" ] && command -v bun >/dev/null 2>&1 \ - && (cd "$_GEN_SRC" && bun --no-env-file --config=/dev/null run scripts/gen-skill-docs.ts --host claude --out-dir "$_ROUTER_TMP" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1) \ + && (cd "$_GEN_SRC" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run scripts/gen-skill-docs.ts --host claude --out-dir "$_ROUTER_TMP" ${_DISABLED_CSV:+"--disabled-skills=$_DISABLED_CSV"} ${_PLAN_MODE_ARG:+"$_PLAN_MODE_ARG"} >/dev/null 2>&1) \ && [ -s "$_ROUTER_TMP/SKILL.md" ] && cp "$_ROUTER_TMP/SKILL.md" "$_ROUTER.tmp.$$" && mv "$_ROUTER.tmp.$$" "$_ROUTER"; then _link_root_skill_alias [ -n "$_DISABLED_CSV" ] && echo "Router: disabled skills left out of $_ROUTER" diff --git a/bin/gstack-review-log b/bin/gstack-review-log index 102fb1dc1..ee7265b25 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -17,6 +17,7 @@ # a model read the code. Caller-supplied binding fields are always discarded. # Plan-tier rows retain their legacy binding behavior. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac export GIT_OPTIONAL_LOCKS=0 export GIT_CONFIG_PARAMETERS="${GIT_CONFIG_PARAMETERS:+$GIT_CONFIG_PARAMETERS }'core.fsmonitor=false' 'core.untrackedCache=false'" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -77,7 +78,7 @@ SESSION_KIND=$("$SCRIPT_DIR/gstack-session-kind" 2>/dev/null || echo interactive export GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" GSTACK_STAMP_SESSION_KIND="$SESSION_KIND" if [ "$INPUT" = --start ]; then case "${3:-}" in ''|--fix-range) ;; *) echo 'Usage: gstack-review-log --start SKILL [--fix-range]' >&2; exit 2 ;; esac - GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config=/dev/null -e ' + GSTACK_REVIEW_SKILL="${2:-}" GSTACK_REVIEW_FIX_RANGE="${3:-}" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const { captureReviewStart } = await import(process.env.GSTACK_REVIEW_LIB); try { console.log(captureReviewStart(process.env.GSTACK_REVIEW_SKILL, process.env, process.env.GSTACK_REVIEW_FIX_RANGE === "--fix-range")); } catch (e) { console.error(e.message); process.exit(1); } @@ -85,7 +86,7 @@ if [ "$INPUT" = --start ]; then exit $? fi if [ "$INPUT" = --check-shared-libs ] && [ "$#" -eq 2 ]; then - GSTACK_REVIEW_TOKEN="$2" bun --no-env-file --config=/dev/null -e ' + GSTACK_REVIEW_TOKEN="$2" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const { checkSharedLibsReuse } = await import(process.env.GSTACK_REVIEW_LIB); console.log(JSON.stringify(checkSharedLibsReuse(JSON.parse(await Bun.stdin.text()), process.env.GSTACK_REVIEW_TOKEN))); ' @@ -113,7 +114,7 @@ case "$INPUT" in --*) usage ;; esac # Caller values for the binding keys are dropped before stamping. A binding or # validation failure reaches stderr as its own line (never folded into # "invalid JSON"), and exits 1 with nothing written. -STAMPED=$(printf '%s' "$INPUT" | GSTACK_REVIEW_TOKEN="$TOKEN" GSTACK_REVIEW_FINDINGS="$FINDINGS" bun --no-env-file --config=/dev/null -e " +STAMPED=$(printf '%s' "$INPUT" | GSTACK_REVIEW_TOKEN="$TOKEN" GSTACK_REVIEW_FINDINGS="$FINDINGS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const { bindReview } = await import(process.env.GSTACK_REVIEW_LIB); let rec; try { rec = JSON.parse(await Bun.stdin.text()); } catch (e) { console.error('gstack-review-log: invalid JSON, skipping (' + e.message + ')'); process.exit(1); } diff --git a/bin/gstack-review-read b/bin/gstack-review-read index 8233e2076..3392693d5 100755 --- a/bin/gstack-review-read +++ b/bin/gstack-review-read @@ -9,6 +9,7 @@ # rows using review_freshness; legacy or uncaptured diff evidence cannot fall # back to HEAD equality. Plan rows are unchanged. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) command -v cygpath >/dev/null 2>&1 && SCRIPT_DIR="$(cygpath -m "$SCRIPT_DIR")" ;; @@ -19,7 +20,7 @@ gstack_state_root_select GSTACK_HOME="$_gstack_sr_root" WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown") if [ -f "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" ]; then - GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config=/dev/null -e ' + GSTACK_REVIEW_LIB="$SCRIPT_DIR/../lib/review-evidence.ts" GSTACK_REVIEW_WTREE="$WTREE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ' const { reviewFreshness } = await import(process.env.GSTACK_REVIEW_LIB); for (const line of (await Bun.stdin.text()).split("\n").filter(Boolean)) { try { diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index 9068f57bd..a12ab1e46 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -69,6 +69,7 @@ BASH_COMPAT=50 set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac ACTION="${1:-}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" @@ -307,7 +308,7 @@ function gsWriteIfChanged(path, beforeText, settings, existed) { GSTACK_SETTINGS_INPUT="$SETTINGS_FILE" export GSTACK_SETTINGS_INPUT -SETTINGS_FILE=$(bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { +SETTINGS_FILE=$(bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { process.stdout.write(gsResolveSettingsPath(process.env.GSTACK_SETTINGS_INPUT)); });') || exit $? @@ -405,7 +406,7 @@ case "$ACTION" in fi _acquire_lock || exit 5 _mutation_env - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_HOOK_CMD="$HOOK_CMD" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const hookCmd = process.env.GSTACK_HOOK_CMD; const loaded = gsLoadSettings(settingsPath); @@ -435,7 +436,7 @@ case "$ACTION" in [ -f "$SETTINGS_FILE" ] || exit 1 _acquire_lock || exit 5 _mutation_env - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const loaded = gsLoadSettings(settingsPath); const settings = loaded.settings; @@ -505,7 +506,7 @@ case "$ACTION" in GSTACK_TIMEOUT="$TIMEOUT" \ GSTACK_DIFF_ONLY="$DIFF_ONLY" \ GSTACK_ENSURE="$ENSURE" \ - bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const event = process.env.GSTACK_EVENT; const cmd = process.env.GSTACK_COMMAND; @@ -660,7 +661,7 @@ case "$ACTION" in [ -f "$SETTINGS_FILE" ] || exit 0 _acquire_lock || exit 5 _mutation_env - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" GSTACK_RS_EVENT="$RS_EVENT" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_SOURCE="$SOURCE" GSTACK_RS_EVENT="$RS_EVENT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const source = process.env.GSTACK_SOURCE; const onlyEvent = process.env.GSTACK_RS_EVENT || ""; @@ -763,7 +764,7 @@ case "$ACTION" in GSTACK_PRUNE_ALL="$PRUNE_ALL" \ GSTACK_PT_OPTOUT="$GSTACK_PT_OPTOUT" \ GSTACK_SWEEP_EXCLUDE_SOURCES="${GSTACK_SWEEP_EXCLUDE_SOURCES:-}" \ - bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const settingsPath = process.env.GSTACK_SETTINGS_PATH; const root = (process.env.GSTACK_REPOINT_ROOT || "").replace(/\/+$/, ""); const all = process.env.GSTACK_PRUNE_ALL === "1"; @@ -902,7 +903,7 @@ case "$ACTION" in fi _mutation_env GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_RESTORE_PATH="$LATEST" \ - bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { var fs = require("fs"); var target = process.env.GSTACK_SETTINGS_PATH; var backup = process.env.GSTACK_RESTORE_PATH; @@ -944,7 +945,7 @@ case "$ACTION" in exit 1 fi [ -f "$SETTINGS_FILE" ] || exit 0 - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" GSTACK_LI_EVENT="$LI_EVENT" GSTACK_LI_OWNED_BY="$LI_OWNED_BY" GSTACK_LI_CMD_RE="$LI_CMD_RE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const event = process.env.GSTACK_LI_EVENT; const ownedBy = process.env.GSTACK_LI_OWNED_BY || ""; const reSrc = process.env.GSTACK_LI_CMD_RE || ""; @@ -982,7 +983,7 @@ case "$ACTION" in list-sources) [ -f "$SETTINGS_FILE" ] || { echo "(no settings file)"; exit 0; } - GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'gsMain(function () { + GSTACK_SETTINGS_PATH="$SETTINGS_FILE" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'gsMain(function () { const fs = require("fs"); let settings = {}; try { settings = JSON.parse(fs.readFileSync(process.env.GSTACK_SETTINGS_PATH, "utf8")); } diff --git a/bin/gstack-specialist-stats b/bin/gstack-specialist-stats index af0b3d15e..3c78d1939 100755 --- a/bin/gstack-specialist-stats +++ b/bin/gstack-specialist-stats @@ -8,6 +8,7 @@ # Fix-range passes (scope "fix-range", #3339) are skipped: a narrowed dispatch # says nothing about a specialist's hit rate on a whole branch. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } @@ -33,7 +34,7 @@ if [ -z "$COMBINED" ]; then exit 0 fi -printf '%s' "$COMBINED" | bun --no-env-file --config=/dev/null -e " +printf '%s' "$COMBINED" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const NEVER_GATE = new Set(['security', 'data-migration', 'state-machines']); const stats = {}; diff --git a/bin/gstack-team-init b/bin/gstack-team-init index 901f5999b..8ab51072a 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -18,6 +18,7 @@ BASH_COMPAT=50 set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac MODE="${1:-}" @@ -192,7 +193,7 @@ HOOK_EOF if ! command -v bun >/dev/null 2>&1; then echo " ! bun not found — manually add this PreToolUse hook (matcher Skill|skill, \"onFailure\": \"block\") to .claude/settings.json:" echo " $HOOK_COMMAND" - elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config=/dev/null -e " + elif GSTACK_SETTINGS_PATH="$SETTINGS" GSTACK_HOOK_COMMAND="$HOOK_COMMAND" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const settingsPath = process.env.GSTACK_SETTINGS_PATH; const handler = { type: 'command', command: process.env.GSTACK_HOOK_COMMAND, onFailure: 'block' }; diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index b5eac29b9..94980ca90 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -21,6 +21,7 @@ # # NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero set -uo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" SCRIPT_DIR="$GSTACK_DIR/bin" @@ -205,7 +206,7 @@ ERR_FIELD="null" # look like a JSON string, the whole message becomes null — never raw. ERR_MSG_FIELD="null" if [ -n "$ERROR_MESSAGE" ]; then - ERR_MSG_FIELD="$(printf '%s' "$ERROR_MESSAGE" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config=/dev/null -e " + ERR_MSG_FIELD="$(printf '%s' "$ERROR_MESSAGE" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const { redactFindingSpans } = await import(process.env.GSTACK_LIB_DIR + '/redact-engine.ts'); const input = await Bun.stdin.text(); const out = redactFindingSpans(input, { repoVisibility: 'private' }); diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 2efd343f0..70f6ce12c 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -10,6 +10,7 @@ # Optional: branch, outcome, duration_s, session, ts. # Validation failure → skip silently (non-blocking). set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } @@ -19,17 +20,23 @@ mkdir -p "$GSTACK_HOME/projects/$SLUG" INPUT="$1" -# Validate: input must be parseable JSON with required fields -if ! printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e " - const j = JSON.parse(await Bun.stdin.text()); - if (!j.skill || !j.event) process.exit(1); -" 2>/dev/null; then - exit 0 # skip silently, non-blocking -fi +# Validate: input must be parseable JSON with required fields. Invalid input +# exits 3; any other non-zero exit means Bun itself failed (#3415). +VALIDATE_RC=0 +printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + let j; + try { j = JSON.parse(await Bun.stdin.text()); } catch { process.exit(3); } + if (!j || !j.skill || !j.event) process.exit(3); +" || VALIDATE_RC=$? +case "$VALIDATE_RC" in + 0) ;; + 3) exit 0 ;; # skip silently, non-blocking + *) echo "gstack-timeline-log: bun exited $VALIDATE_RC before the event was validated; nothing was logged" >&2; exit "$VALIDATE_RC" ;; +esac # Inject timestamp if not present -if ! printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then - INPUT=$(printf '%s' "$INPUT" | bun --no-env-file --config=/dev/null -e " +if ! printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "const j=JSON.parse(await Bun.stdin.text()); if(!j.ts) process.exit(1)" 2>/dev/null; then + INPUT=$(printf '%s' "$INPUT" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const j = JSON.parse(await Bun.stdin.text()); j.ts = new Date().toISOString(); console.log(JSON.stringify(j)); diff --git a/bin/gstack-timeline-read b/bin/gstack-timeline-read index c4dd93cfe..6f1e979a7 100755 --- a/bin/gstack-timeline-read +++ b/bin/gstack-timeline-read @@ -10,6 +10,7 @@ # --branch, or an unknown argument exits 2 before any read (#1723); --help # prints the usage line and exits 0. set -euo pipefail +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac USAGE='Usage: gstack-timeline-read [--since "7 days ago"|7d|YYYY-MM-DD] [--limit N] [--branch NAME]' SINCE_FORMS='accepted: " minute|hour|day|week|month[s] ago", "h|d|w", or an ISO date YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ' SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -108,7 +109,7 @@ command -v bun >/dev/null 2>&1 || { exit 127 } -cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE_MS="$SINCE_MS" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun --no-env-file --config=/dev/null -e " +cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE_MS="$SINCE_MS" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); const branch = process.env.GSTACK_TIMELINE_BRANCH || ''; const limit = Number(process.env.GSTACK_TIMELINE_LIMIT); diff --git a/browse/test/learnings-injection.test.ts b/browse/test/learnings-injection.test.ts index de3b09538..fe78e2618 100644 --- a/browse/test/learnings-injection.test.ts +++ b/browse/test/learnings-injection.test.ts @@ -9,14 +9,14 @@ const BIN_DIR = path.join(import.meta.dir, '../../bin'); describe('gstack-learnings-search injection safety', () => { it('must not interpolate variables into JS string literals', () => { - const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config=/dev/null -e')); + const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e')); expect(jsBlock).not.toMatch(/const \w+ = '\$\{/); expect(jsBlock).not.toMatch(/= \$\{[A-Z_]+\};/); expect(jsBlock).not.toMatch(/'\$\{CROSS_PROJECT\}'/); }); it('must use process.env for parameters', () => { - const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config=/dev/null -e')); + const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e')); expect(jsBlock).toContain('process.env'); }); }); diff --git a/gstack-upgrade/migrations/v1.86.0.0.sh b/gstack-upgrade/migrations/v1.86.0.0.sh index fcdbf83cc..e128316eb 100755 --- a/gstack-upgrade/migrations/v1.86.0.0.sh +++ b/gstack-upgrade/migrations/v1.86.0.0.sh @@ -5,8 +5,9 @@ # shared old renders are retired. This post-setup pass repairs missed upgrades. # Idempotent and non-fatal: foreign entries and failed replacements stay intact. set -u +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac INSTALL_DIR="${GSTACK_INSTALL_DIR:-$HOME/.claude/skills/gstack}" if [ -f "$INSTALL_DIR/bin/gstack-migrate-claude-code" ] && command -v bun >/dev/null 2>&1; then - bun --no-env-file --config=/dev/null "$INSTALL_DIR/bin/gstack-migrate-claude-code" --install-dir "$INSTALL_DIR" || true + bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$INSTALL_DIR/bin/gstack-migrate-claude-code" --install-dir "$INSTALL_DIR" || true fi exit 0 diff --git a/hosts/claude/hooks/auq-error-fallback-hook b/hosts/claude/hooks/auq-error-fallback-hook index 4db80b738..586fe06ad 100755 --- a/hosts/claude/hooks/auq-error-fallback-hook +++ b/hosts/claude/hooks/auq-error-fallback-hook @@ -3,5 +3,6 @@ # wrapper makes the TypeScript hook executable via bun. Settings.json # references this file directly. set -e +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" -exec bun --no-env-file --config=/dev/null "$HERE/auq-error-fallback-hook.ts" +exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/auq-error-fallback-hook.ts" diff --git a/hosts/claude/hooks/memorable-user-prompt-hook b/hosts/claude/hooks/memorable-user-prompt-hook index f476ac7f4..bc3ba711a 100755 --- a/hosts/claude/hooks/memorable-user-prompt-hook +++ b/hosts/claude/hooks/memorable-user-prompt-hook @@ -12,8 +12,9 @@ # vendor process running with the prompt on its stdin. Forwarded, the signal # reaches the .ts, which kills the vendor's process group on its way out. # `<&0` keeps stdin: bash hands background jobs /dev/null otherwise. +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0 -bun --no-env-file --config=/dev/null "$HERE/memorable-user-prompt-hook.ts" <&0 & +bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/memorable-user-prompt-hook.ts" <&0 & _child=$! trap 'kill -TERM "$_child" 2>/dev/null' TERM INT HUP wait "$_child" 2>/dev/null || wait "$_child" 2>/dev/null || true diff --git a/hosts/claude/hooks/question-log-hook b/hosts/claude/hooks/question-log-hook index 5b6012cec..cdbb659f5 100755 --- a/hosts/claude/hooks/question-log-hook +++ b/hosts/claude/hooks/question-log-hook @@ -3,5 +3,6 @@ # wrapper makes the TypeScript hook executable via bun. Settings.json # references this file directly. set -e +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" -exec bun --no-env-file --config=/dev/null "$HERE/question-log-hook.ts" +exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/question-log-hook.ts" diff --git a/hosts/claude/hooks/question-preference-hook b/hosts/claude/hooks/question-preference-hook index 8dac5ff3c..95a3f7f4f 100755 --- a/hosts/claude/hooks/question-preference-hook +++ b/hosts/claude/hooks/question-preference-hook @@ -3,5 +3,6 @@ # wrapper makes the TypeScript hook executable via bun. Settings.json # references this file directly. set -e +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" -exec bun --no-env-file --config=/dev/null "$HERE/question-preference-hook.ts" +exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/question-preference-hook.ts" diff --git a/hosts/claude/hooks/timeline-stop-hook b/hosts/claude/hooks/timeline-stop-hook index 74c716649..fdab076a4 100755 --- a/hosts/claude/hooks/timeline-stop-hook +++ b/hosts/claude/hooks/timeline-stop-hook @@ -5,6 +5,7 @@ # # FAIL-OPEN (F5): a Stop-event telemetry repair must never block the session. # Every failure path — bun missing, script crash — still exits 0. +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0 -bun --no-env-file --config=/dev/null "$HERE/timeline-stop-hook.ts" || true +bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/timeline-stop-hook.ts" || true exit 0 diff --git a/lib/bun-isolation.ts b/lib/bun-isolation.ts index 63c9d09ef..bf7b388f6 100644 --- a/lib/bun-isolation.ts +++ b/lib/bun-isolation.ts @@ -4,10 +4,11 @@ * variables, and a `bunfig.toml` preload runs code before the entry point. * gstack runs inside the user's repository, so that directory belongs to the * code under review. Every Bun gstack starts passes --no-env-file and an empty - * --config: shebangs and shell callers spell it - * `bun --no-env-file --config=/dev/null`, TypeScript spawns use - * bunIsolationArgs(). test/bun-entry-isolation.test.ts holds every entry point - * to that form. + * --config: shebangs and skill prose spell it + * `bun --no-env-file --config=/dev/null`, shipped shell files spell + * `--config="${GSTACK_BUN_CONFIG:-/dev/null}"` (NUL under MINGW, MSYS and + * Cygwin, #3415), TypeScript spawns use bunIsolationArgs(). + * test/bun-entry-isolation.test.ts holds every entry point to that form. * * POSIX passes /dev/null. Native Windows has no /dev/null and `--config=NUL` * panicked Bun 1.3.10 while parsing the flag (#3085), so a TypeScript spawn on diff --git a/plan-ceo-review/bin/mode-handoff-hook b/plan-ceo-review/bin/mode-handoff-hook index b0461686f..542ffe643 100755 --- a/plan-ceo-review/bin/mode-handoff-hook +++ b/plan-ceo-review/bin/mode-handoff-hook @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Bash shim — Claude Code runs hook commands through /bin/sh; this runs the # TypeScript hook with bun. +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" -exec bun --no-env-file --config=/dev/null "$HERE/mode-handoff-hook.ts" +exec bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/mode-handoff-hook.ts" diff --git a/scripts/build-cso.sh b/scripts/build-cso.sh index 62be650c4..ff3d4e3f0 100644 --- a/scripts/build-cso.sh +++ b/scripts/build-cso.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -eu +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac CSO_BUILD_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" cd "$CSO_BUILD_ROOT" @@ -227,7 +228,7 @@ cso_build_evaluation_candidate() { cp -R lib scripts "$source_root/" cp -R test/helpers "$source_root/test/" cp "$candidate_catalog" "$source_root/lib/cso/runtime-catalog.json" - (cd "$source_root" && "$BUN_CMD" --no-env-file --config=/dev/null scripts/cso-runtime-promotion.ts check-evaluation lib/cso/runtime-catalog.json) + (cd "$source_root" && "$BUN_CMD" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" scripts/cso-runtime-promotion.ts check-evaluation lib/cso/runtime-catalog.json) (cd "$source_root" && cso_compile_bun lib/cso/cli.ts "$CSO_EVALUATION_UNIT/gstack-cso-core") (cd "$source_root" && cso_compile_bun scripts/cso-eval-producer.ts "$CSO_EVALUATION_UNIT/cso-eval-producer") cso_compile_watchdog "$CSO_EVALUATION_UNIT/gstack-cso-watchdog" diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 8d153facf..8e383e292 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -314,6 +314,7 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ { file: 'test/ship-hook-windows-paths.test.ts', reason: 'runs bin/ helpers through explicit bash and Bun argv with forward-slash paths; never executes a shebang; the path-spelling simulation is skipIf win32' }, { file: 'test/gstack-wtree-windows-paths.test.ts', reason: '#3101: runs bin/gstack-wtree through explicit bash in an os.tmpdir linked worktree with forward-slash paths; never executes a shebang; the drive-letter simulation is skipIf win32' }, { file: 'test/setup-hook-syntax-gate.test.ts', reason: '#3088/#3100: runs bin/gstack-hook-check and the setup hook sections through explicit bash with forward-slash paths; never executes a shebang. Its Bun drive-path spelling is the Windows bug it guards' }, + { file: 'test/bun-config-shell-bins.test.ts', reason: '#3415: runs the record bins through lib/script-invocation.ts (Git Bash on Windows) with MSYS path conversion off, the case it guards; its uname/bun stand-ins are skipIf win32' }, { file: 'test/path-spelling.test.ts', reason: 'sources bin/gstack-path-spelling.sh through explicit bash and checks lib/path-spelling.ts; on Windows it exercises the real Git Bash spellings' }, { file: 'test/state-root-parity.test.ts', reason: 'runs the bash twin and lib/state-root.ts over an env table with PATH empty; no shebang execution, raw-string comparison is platform-neutral' }, { file: 'test/generator-eexist.test.ts', diff --git a/setup b/setup index 10c0ae56c..782551433 100755 --- a/setup +++ b/setup @@ -1,6 +1,7 @@ #!/usr/bin/env bash # gstack setup — build browser binary + register skills with Claude Code / Codex set -e +GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac umask 077 # Restrict new files to owner-only (0o600 files, 0o700 dirs) # Heredoc delivery guard. bash 5.2+ writes a heredoc body <=64KiB through a @@ -362,7 +363,7 @@ _runtime_root_refused() { # --claude-model is validated before anything is written, and persisted once # Claude is known to be selected (below, and in the --refresh-registered parent). if [ "$CLAUDE_MODEL_SET" -eq 1 ]; then - if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun --no-env-file --config=/dev/null run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then + if ! _CLAUDE_MODEL_RESOLVED="$(cd "$SOURCE_GSTACK_DIR" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" run scripts/models.ts claude-overlay "$CLAUDE_MODEL_ARG" 2>&1)"; then printf 'Error: --claude-model %s\n' "$_CLAUDE_MODEL_RESOLVED" | sed '2,$s/^/ /' >&2 echo " Nothing was installed or changed." >&2 exit 1 @@ -1317,7 +1318,7 @@ if [ "$LOCAL_INSTALL" -eq 0 ]; then _CODEX_PREFLIGHT_SELECTED=1 fi fi -bun --no-env-file --config=/dev/null "$SOURCE_GSTACK_DIR/scripts/preflight-codex-overlap.ts" \ +bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$SOURCE_GSTACK_DIR/scripts/preflight-codex-overlap.ts" \ --source "$SOURCE_GSTACK_DIR" --namespace "$CODEX_SKILLS" \ --selected "$_CODEX_PREFLIGHT_SELECTED" --local "$CODEX_REPO_LOCAL" \ --windows "$IS_WINDOWS" --relocation "$HOME/.gstack/repos/gstack" || exit 1 @@ -1584,7 +1585,7 @@ ensure_playwright_browser() { echo "gstack setup failed: Node.js is required on Windows" >&2 return 1 else - probe_cmd="bun --no-env-file --config=/dev/null --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'" + probe_cmd="bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" --eval 'import { chromium } from \"playwright\"; const browser = await chromium.launch(); await browser.close();'" fi ( cd "$SOURCE_GSTACK_DIR" diff --git a/test/bun-config-shell-bins.test.ts b/test/bun-config-shell-bins.test.ts new file mode 100644 index 000000000..86f10038e --- /dev/null +++ b/test/bun-config-shell-bins.test.ts @@ -0,0 +1,161 @@ +/** + * #3415: gstack's shell bins start Bun with + * --config="${GSTACK_BUN_CONFIG:-/dev/null}", and each defines + * GSTACK_BUN_CONFIG as NUL under MINGW, MSYS and Cygwin. Native bun.exe cannot + * open /dev/null (EINVAL), so a Windows bash with MSYS path conversion off + * (MSYS_NO_PATHCONV=1, MSYS2_ARG_CONV_EXCL=*) made the literal spelling fail: + * the record bins wrote nothing, and gstack-timeline-log still exited 0. + * + * On every platform the free lanes run, windows-latest included, the record + * bins run through lib/script-invocation.ts with conversion off in a hostile + * repository: each writes its record, and the repository's bunfig.toml preload + * and .env never load. On POSIX a simulated Windows bash (uname reporting + * MINGW64, a bun that rejects /dev/null the way bun.exe does) reproduces the + * bug; its controls, with uname reporting Linux, show each bin fails loudly + * with nothing written instead of exiting 0. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { runScriptSync } from '../lib/script-invocation'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const IS_WINDOWS = process.platform === 'win32'; + +let tmp = ''; +let repo = ''; +let stateRoot = ''; +let evilRoot = ''; +let preloadMarker = ''; +let fakeWindows = ''; +let fakeBunOnly = ''; + +const BINS: Array<{ bin: string; input: string; log: string }> = [ + { bin: 'gstack-review-log', log: 'main-reviews.jsonl', input: JSON.stringify({ skill: 'plan-eng-review', timestamp: '2026-10-25T00:00:00Z', status: 'clean', unresolved: 0, critical_gaps: 0, issues_found: 0, mode: 'FULL_REVIEW', commit: 'abc1234' }) }, + { bin: 'gstack-question-log', log: 'question-log.jsonl', input: JSON.stringify({ skill: 'ship', question_id: 'ship-test-failure-triage', question_summary: 'Tests failed', options_count: 3, user_choice: 'fix-now', recommended: 'fix-now', session_id: 's1' }) }, + { bin: 'gstack-learnings-log', log: 'learnings.jsonl', input: JSON.stringify({ skill: 'review', type: 'pitfall', key: 'n-plus-one', insight: 'fixture insight', confidence: 8, source: 'observed' }) }, + { bin: 'gstack-timeline-log', log: 'timeline.jsonl', input: JSON.stringify({ skill: 'review', event: 'started', branch: 'main' }) }, +]; + +function env(pathPrefix?: string): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = {}; + for (const [k, v] of Object.entries(process.env)) if (!/^(GSTACK_|MSYS)/.test(k)) out[k] = v; + const PATH = Object.keys(out).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + if (pathPrefix) out[PATH] = pathPrefix + path.delimiter + (out[PATH] ?? ''); + return { ...out, HOME: path.join(tmp, 'home'), GSTACK_STATE_ROOT: stateRoot, MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' }; +} + +function run(bin: string, input: string, pathPrefix?: string) { + return runScriptSync(path.join(ROOT, 'bin', bin), [input], { cwd: repo, env: env(pathPrefix), encoding: 'utf8', timeout: 60_000 }); +} + +function records(log: string): string[] { + const projects = path.join(stateRoot, 'projects'); + if (!fs.existsSync(projects)) return []; + return fs.readdirSync(projects).flatMap(slug => { + const file = path.join(projects, slug, log); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean) : []; + }); +} + +function resetState(): void { + fs.rmSync(stateRoot, { recursive: true, force: true }); + fs.mkdirSync(stateRoot, { recursive: true }); +} + +function writeExec(file: string, text: string): void { + fs.writeFileSync(file, text); + fs.chmodSync(file, 0o755); +} + +beforeAll(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-bunconfig-'))); + repo = path.join(tmp, 'repo'); + stateRoot = path.join(tmp, 'state'); + evilRoot = path.join(tmp, 'evil'); + preloadMarker = path.join(tmp, 'preload-ran'); + for (const d of [repo, stateRoot, path.join(tmp, 'home')]) fs.mkdirSync(d, { recursive: true }); + for (const args of [['init', '-q', '-b', 'main'], ['-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']]) { + expect(spawnSync('git', args, { cwd: repo, timeout: 30_000 }).status).toBe(0); + } + fs.writeFileSync(path.join(repo, '.env'), `GSTACK_STATE_ROOT=${evilRoot}\nGSTACK_HOME=${evilRoot}\n`); + fs.writeFileSync(path.join(repo, 'preload.ts'), `require('fs').writeFileSync(${JSON.stringify(preloadMarker)}, 'ran');\n`); + fs.writeFileSync(path.join(repo, 'bunfig.toml'), 'preload = ["./preload.ts"]\n'); + if (IS_WINDOWS) return; + fakeWindows = path.join(tmp, 'fake-windows'); + fakeBunOnly = path.join(tmp, 'fake-bun-only'); + for (const dir of [fakeWindows, fakeBunOnly]) { + fs.mkdirSync(dir); + writeExec(path.join(dir, 'bun'), [ + '#!/usr/bin/env bash', + 'a=()', + 'for x in "$@"; do', + ' case "$x" in', + ' --config=/dev/null) echo "EINVAL: Invalid argument (open())" >&2; echo "while reading config \\"/dev/null\\"" >&2; exit 1 ;;', + ' --config=NUL) a+=(--config=/dev/null) ;;', + ' *) a+=("$x") ;;', + ' esac', + 'done', + `exec ${JSON.stringify(process.execPath)} "\${a[@]}"`, + '', + ].join('\n')); + } + writeExec(path.join(fakeWindows, 'uname'), '#!/usr/bin/env bash\n[ "$1" = -s ] && { echo MINGW64_NT-10.0-26100; exit 0; }\nexec /usr/bin/uname "$@"\n'); + writeExec(path.join(fakeWindows, 'cygpath'), '#!/usr/bin/env bash\nprintf "%s\\n" "$2"\n'); +}); + +afterAll(() => { if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); }); + +describe('#3415: record bins start Bun on a Windows bash with path conversion off', () => { + test('fixture control: a plain bun in the repository runs its bunfig preload', () => { + const r = spawnSync(process.execPath, ['-e', '0'], { cwd: repo, encoding: 'utf8', timeout: 60_000 }); + expect(r.status).toBe(0); + expect(fs.existsSync(preloadMarker)).toBe(true); + fs.rmSync(preloadMarker); + }); + + for (const { bin, input, log } of BINS) { + test(`${bin} writes its record and ignores the repository bunfig.toml and .env`, () => { + resetState(); + const r = run(bin, input); + expect(r.stderr).not.toContain('EINVAL'); + expect(r.status).toBe(0); + expect(records(log).length).toBe(1); + expect(fs.existsSync(preloadMarker)).toBe(false); + expect(fs.existsSync(evilRoot)).toBe(false); + }); + } + + test('gstack-timeline-log still skips invalid input silently with exit 0', () => { + resetState(); + for (const bad of ['not json', '{"skill":"review"}', 'null']) { + const r = run('gstack-timeline-log', bad); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + } + expect(records('timeline.jsonl')).toEqual([]); + }); +}); + +describe.skipIf(IS_WINDOWS)('#3415: simulated Windows bash (POSIX hosts)', () => { + for (const { bin, input, log } of BINS) { + test(`${bin} passes NUL and writes its record`, () => { + resetState(); + const r = run(bin, input, fakeWindows); + expect(r.stderr).not.toContain('EINVAL'); + expect(r.status).toBe(0); + expect(records(log).length).toBe(1); + expect(fs.existsSync(preloadMarker)).toBe(false); + }); + + test(`control: when Bun rejects the config, ${bin} fails loudly and writes nothing`, () => { + resetState(); + const r = run(bin, input, fakeBunOnly); + expect(r.stderr).toContain('EINVAL'); + expect(r.status).not.toBe(0); + expect(records(log)).toEqual([]); + }); + } +}); diff --git a/test/bun-entry-isolation.test.ts b/test/bun-entry-isolation.test.ts index 71b0eb338..1d025770d 100644 --- a/test/bun-entry-isolation.test.ts +++ b/test/bun-entry-isolation.test.ts @@ -6,9 +6,11 @@ * kinds are held to the isolated form: * 1. bun shebangs: exactly ISOLATED_SHEBANG (`bun run ` ignores the * shebang, so callers carry the flags too); - * 2. shell callers in shipped shell files, skill templates, generated skill - * docs and resolvers: `bun --no-env-file --config=/dev/null` before - * `run`, `-e`/`-p` or a script path; + * 2. shell callers: skill templates, generated skill docs and resolvers + * spell `bun --no-env-file --config=/dev/null` before `run`, `-e`/`-p` + * or a script path; shipped shell files spell SHELL_FLAGS after + * CONFIG_DEF, so a Windows bash with MSYS path conversion off passes + * NUL instead of a /dev/null native Bun cannot open (#3415); * 3. TypeScript spawns of a Bun: bunIsolationArgs() (or browse's * bunChildFlags()) in the argument list; * 4. scriptInvocation, which launches gstack's own bun bins; @@ -27,6 +29,8 @@ import { emptyBunConfig as browseEmptyBunConfig } from '../browse/src/bun-child- const ROOT = path.resolve(import.meta.dir, '..'); const FLAGS = '--no-env-file --config=/dev/null'; const ISOLATED_SHEBANG = `#!/usr/bin/env -S bun ${FLAGS}`; +const SHELL_FLAGS = '--no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}"'; +const CONFIG_DEF = 'GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac'; const TRACKED = spawnSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.split('\0').filter(Boolean) .filter(f => !/(^|\/)test\//.test(f) && fs.existsSync(path.join(ROOT, f)) && fs.statSync(path.join(ROOT, f)).isFile()); @@ -43,7 +47,7 @@ const BUN_WORD = /(^|[\s;&|({`"']|\$\()(bun|"\$\{?BUN(?:_[A-Z]+)?\}?"|\$\{?BUN(? /** Whether the text after a `bun` word starts a program: -e/-p, a script path, `run ` or a bin path. A package script (`bun run build`) is not one. */ function runsCode(rest: string): boolean { - const r = rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+)\s+)*/, ''); + const r = rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+|config(?:=\S*|\s+\S+))\s+)*/, ''); return /^(?:run\s+)?(?:-e|-p|--eval|--print)(?:\s|=|$)/.test(r) || /^(?:run\s+)?["']?[^\s"'|;&)]*\.(?:ts|js|mjs|cjs|tsx)(?:["'\s)|;&\\`]|$)/.test(r) || /^run\s+["']?\$\{?[A-Za-z_]/.test(r) @@ -51,14 +55,14 @@ function runsCode(rest: string): boolean { } /** Bun invocations on one shell line that start a program without the isolation flags. */ -function unisolatedBunCalls(line: string): string[] { +function unisolatedBunCalls(line: string, flags = FLAGS): string[] { if (/^\s*(#|\/\/|\*)/.test(line)) return []; const out: string[] = []; for (const m of line.matchAll(BUN_WORD)) { const at = m.index! + m[1]!.length; if (/\bln\s+-\w+\s+$/.test(line.slice(0, at))) continue; const rest = line.slice(at + m[2]!.length); - if (!rest.startsWith(` ${FLAGS}`) && runsCode(rest)) out.push(line.slice(at, at + 80)); + if (!rest.startsWith(` ${flags}`) && runsCode(rest)) out.push(line.slice(at, at + 80)); } return out; } @@ -69,6 +73,22 @@ const SHELL_EXEMPT: Record = { 'skillify/SKILL.md': 'generated from skillify/SKILL.md.tmpl', }; +/** Run only on Linux (the Ubicloud VM or its Linux-only local fallback), where /dev/null is the spelling; remote command strings expand there. */ +const SHELL_LINUX_ONLY = (f: string) => f.startsWith('scripts/ubicloud/'); + +/** Lines of shell text that use GSTACK_BUN_CONFIG with no CONFIG_DEF line before them, or define it any other way. */ +function configDefinitionGaps(text: string): string[] { + const gaps: string[] = []; + let defined = false; + text.split('\n').forEach((line, i) => { + if (/^\s*(?:local\s+)?GSTACK_BUN_CONFIG=/.test(line)) { + if (line.trim().replace(/^local\s+/, '') === CONFIG_DEF) defined = true; + else gaps.push(`${i + 1}: redefined: ${line.trim()}`); + } else if (!defined && line.includes('${GSTACK_BUN_CONFIG')) gaps.push(`${i + 1}: used before CONFIG_DEF`); + }); + return gaps; +} + const isShell = (f: string) => f.endsWith('.sh') || /^#!.*\b(?:ba|z)?sh\b/.test(firstLine(f)); const isSkillSource = (f: string) => f.endsWith('.tmpl') || /^scripts\/resolvers\/.+\.ts$/.test(f) || /(^|\/)SKILL\.md$/.test(f) || /\/sections\/[^/]+\.md$/.test(f); @@ -116,13 +136,40 @@ describe('GSTA-56: gstack never takes configuration from the audited repository' ]) expect(unisolatedBunCalls(ok)).toEqual([]); }); + test('in shipped shell files the detector requires the Windows-safe, fail-closed config spelling (#3415)', () => { + for (const bad of [ + `printf x | bun ${FLAGS} -e "console.log(1)"`, + 'bun --no-env-file --config="$GSTACK_BUN_CONFIG" -e 1', + 'bun --no-env-file --config= -e 1', + 'bun --no-env-file --config=NUL run "$X/bin/gstack-design-detect.ts"', + ]) expect(unisolatedBunCalls(bad, SHELL_FLAGS)).not.toEqual([]); + for (const ok of [ + `printf x | bun ${SHELL_FLAGS} -e "console.log(1)"`, + `exec bun ${SHELL_FLAGS} "$HERE/question-log-hook.ts"`, + `probe_cmd="bun ${SHELL_FLAGS} --eval 'import x from \\"y\\"'"`, + ]) expect(unisolatedBunCalls(ok, SHELL_FLAGS)).toEqual([]); + }); + + test('the config-definition check flags a missing, late or altered GSTACK_BUN_CONFIG definition', () => { + const use = `bun ${SHELL_FLAGS} -e 1`; + expect(configDefinitionGaps(`#!/usr/bin/env bash\n${CONFIG_DEF}\n${use}\n`)).toEqual([]); + expect(configDefinitionGaps(`f() {\n local ${CONFIG_DEF}\n ${use}\n}\n`)).toEqual([]); + expect(configDefinitionGaps(`#!/usr/bin/env bash\n${use}\n`)).toEqual(['2: used before CONFIG_DEF']); + expect(configDefinitionGaps(`${use}\n${CONFIG_DEF}\n`)).toEqual(['1: used before CONFIG_DEF']); + expect(configDefinitionGaps(`GSTACK_BUN_CONFIG=./bunfig.toml\n${use}\n`)).toEqual(['1: redefined: GSTACK_BUN_CONFIG=./bunfig.toml', '2: used before CONFIG_DEF']); + expect(configDefinitionGaps(`${CONFIG_DEF}\nGSTACK_BUN_CONFIG=\n${use}\n`)).toEqual(['2: redefined: GSTACK_BUN_CONFIG=']); + }); + test('shell callers, skill templates, generated skill docs and resolvers pass the isolation flags', () => { const offenders: string[] = []; for (const f of TRACKED) { if (f.startsWith('.github/') || SHELL_EXEMPT[f] || !(isSkillSource(f) || isShell(f))) continue; - fs.readFileSync(path.join(ROOT, f), 'utf8').split('\n').forEach((line, i) => { - for (const call of unisolatedBunCalls(line)) offenders.push(`${f}:${i + 1}: ${call}`); + const flags = isSkillSource(f) || SHELL_LINUX_ONLY(f) ? FLAGS : SHELL_FLAGS; + const text = fs.readFileSync(path.join(ROOT, f), 'utf8'); + text.split('\n').forEach((line, i) => { + for (const call of unisolatedBunCalls(line, flags)) offenders.push(`${f}:${i + 1}: ${call}`); }); + if (flags === SHELL_FLAGS) for (const gap of configDefinitionGaps(text)) offenders.push(`${f}:${gap}`); } expect(offenders).toEqual([]); }); diff --git a/test/bun-isolation-launch.test.ts b/test/bun-isolation-launch.test.ts index eba3d4862..e3715b411 100644 --- a/test/bun-isolation-launch.test.ts +++ b/test/bun-isolation-launch.test.ts @@ -5,6 +5,9 @@ * --config=nul for the native bun.exe, which Bun 1.4.2 reads as an empty * config; a native Windows spawn has no /dev/null, so TypeScript callers use * bunIsolationArgs() (lib/bun-isolation.ts) instead of spelling the flag. + * With conversion off (MSYS_NO_PATHCONV=1) bun.exe gets /dev/null itself and + * fails with EINVAL, so gstack's shell files resolve GSTACK_BUN_CONFIG to NUL + * there (#3415). */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; @@ -15,6 +18,8 @@ import { bunIsolationArgs } from '../lib/bun-isolation'; import { bashCommandInvocation } from '../lib/script-invocation'; const PRINT = 'console.log("env=" + (process.env.GSTACK_LAUNCH_PROBE ?? "unset"))'; +const CONFIG_DEF = 'GSTACK_BUN_CONFIG=/dev/null; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) GSTACK_BUN_CONFIG=NUL ;; esac'; +const NO_PATHCONV = { MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' }; let project = ''; let preloadMarker = ''; @@ -61,6 +66,18 @@ describe('GSTA-56: isolated Bun launches on this platform', () => { expectIsolated(bash('bun --no-env-file --config=/dev/null run probe.ts')); }); + test('a shipped shell file spelling, with MSYS path conversion off', () => { + expectIsolated(bash(`${CONFIG_DEF}; bun --no-env-file --config="\${GSTACK_BUN_CONFIG:-/dev/null}" -e '${PRINT}'`, NO_PATHCONV)); + expectIsolated(bash(`${CONFIG_DEF}; bun --no-env-file --config="\${GSTACK_BUN_CONFIG:-/dev/null}" run probe.ts`, NO_PATHCONV)); + }); + + test.if(process.platform === 'win32')('control: with MSYS path conversion off, bun.exe cannot open --config=/dev/null', () => { + const r = bash(`bun --no-env-file --config=/dev/null -e '${PRINT}'`, NO_PATHCONV); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('EINVAL'); + expect(fs.existsSync(preloadMarker)).toBe(false); + }); + test('a TypeScript spawn with bunIsolationArgs()', () => { expectIsolated(spawnSync(process.execPath, [...bunIsolationArgs(), 'probe.ts'], { cwd: project, encoding: 'utf8', timeout: 60_000 })); }); diff --git a/test/learnings-injection.test.ts b/test/learnings-injection.test.ts index df387f248..d542c6d01 100644 --- a/test/learnings-injection.test.ts +++ b/test/learnings-injection.test.ts @@ -9,7 +9,7 @@ describe("gstack-learnings-search injection prevention", () => { test("no shell interpolation inside bun -e string", () => { // Extract the bun -e block (everything between `bun -e "` and the closing `"`) - const bunBlock = script.slice(script.indexOf('bun --no-env-file --config=/dev/null -e "')); + const bunBlock = script.slice(script.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "')); // Should NOT contain ${VAR} patterns (shell interpolation) // These are RCE vectors: a malicious learnings entry with '; rm -rf / ;' in the @@ -27,7 +27,7 @@ describe("gstack-learnings-search injection prevention", () => { }); test("uses process.env for all user-controlled values", () => { - const bunBlock = script.slice(script.indexOf('bun --no-env-file --config=/dev/null -e "')); + const bunBlock = script.slice(script.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "')); // Must use process.env for TYPE, QUERY, LIMIT. // SLUG and CROSS are no longer threaded as env vars inside the bun @@ -52,7 +52,7 @@ describe("gstack-learnings-search injection prevention", () => { }); test("current vs cross-project rows distinguished by inline tags, not SLUG env (#1619)", () => { - const bunBlock = script.slice(script.indexOf('bun --no-env-file --config=/dev/null -e "')); + const bunBlock = script.slice(script.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "')); // The bun block must inspect the per-line tag to mark cross-project rows. // The current shape emits `current\t` or `cross\t` from the // upstream pipe (via emit_tagged_file). Inside the bun block, the script diff --git a/test/setup-hook-canonical-paths.test.ts b/test/setup-hook-canonical-paths.test.ts index 8d238185c..0dc955da9 100644 --- a/test/setup-hook-canonical-paths.test.ts +++ b/test/setup-hook-canonical-paths.test.ts @@ -210,8 +210,8 @@ describe('setup: heal-first ordering + visibility', () => { describe('gstack-settings-hook: shared prelude (dedupe key == prune predicate)', () => { test('every bun script call site uses the shared JS prelude concatenation', () => { const codeLines = hookBinSrc.split('\n').filter((l) => !l.trim().startsWith('#')); - const bunCalls = codeLines.filter((l) => l.includes('bun --no-env-file --config=/dev/null -e ')); - const preludeCalls = codeLines.filter((l) => l.includes(`bun --no-env-file --config=/dev/null -e "$_HOOK_JS_PRELUDE"'`)); + const bunCalls = codeLines.filter((l) => l.includes('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e ')); + const preludeCalls = codeLines.filter((l) => l.includes(`bun --no-env-file --config="\${GSTACK_BUN_CONFIG:-/dev/null}" -e "$_HOOK_JS_PRELUDE"'`)); expect(bunCalls.length).toBeGreaterThanOrEqual(6); expect(preludeCalls.length).toBe(bunCalls.length); });