diff --git a/AGENTS.md b/AGENTS.md index 0c248edda..ac84585d1 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`, 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` when `OSTYPE` is `msys` or `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. +- 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` when `OSTYPE` is `msys` or `cygwin` and `cygpath` is on `PATH`, 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 40d3a733f..38e22797d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,21 +2,30 @@ ## [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. +**Windows: gstack's record 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` when `OSTYPE` is `msys` or `cygwin`, as in Git Bash, MSYS2 and Cygwin, the check `gstack-doctor` already uses; `/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. +- **40 shipped shell files** (the bash bins, the Claude hook wrappers, `setup`, `scripts/build-cso.sh` and one migration) define `GSTACK_BUN_CONFIG` once and start Bun with `--config="${GSTACK_BUN_CONFIG:-/dev/null}"`. The value is `NUL` only when `OSTYPE` is `msys` or `cygwin` (Git Bash, MSYS2 and Cygwin, the check `gstack-doctor` already uses) and `cygpath` is on `PATH`; everywhere else it is `/dev/null`. The `cygpath` check is a builtin `command -v`, so it forks nothing. It means an `OSTYPE=msys` inherited by a Linux or macOS bash keeps `/dev/null`, instead of passing a relative `NUL` that Bun there resolves to a file in the repository. 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 covers a call that loses its definition: it 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`** and **`gstack-developer-profile --log-session`**: invalid input still exits 0 with nothing logged (the hook-path contract). Their validators now exit 3 for invalid input. 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`, because a shebang cannot name a per-platform path. TypeScript spawns keep `bunIsolationArgs()`, and `scripts/ubicloud/` keeps `/dev/null` because it runs on Linux. +- Not covered: callers that hand `bun.exe` a POSIX script path built from `pwd` or `$HOME` (the Claude hook wrappers, `autoplan/bin/phase-publication-hook`, `plan-ceo-review/bin/mode-handoff-hook`, and some `gstack-doctor`, `gstack-codex-probe`, `setup` and migration calls) only got the config spelling. With path conversion off they may still fail on the `/c/...` script path itself. The record bins convert their own paths with `cygpath -m`; the others were not verified on Windows with conversion off. ### 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 (`OSTYPE=msys`, 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, without `OSTYPE=msys`, show each bin exits non-zero with nothing written. +- `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 (`OSTYPE=msys`, a `cygpath`, and a `bun` stand-in that 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, without `OSTYPE=msys`, show each bin exits non-zero with nothing written. + - An inherited `OSTYPE=msys` with no `cygpath`, in a repository that commits a file named `NUL` with a preload, leaves each bin on `/dev/null`: the record is written and the preload never runs. This fails on the first version of this fix (4 failures). + - `gstack-developer-profile --log-session` reports a rejected config on stderr and exits non-zero, and still skips invalid input silently. This fails on main. - `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. +- `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. The two learnings-injection files also check that the anchored `bun -e` block exists. Before, a missing anchor sliced the script's last character and the negative assertions passed without checking anything. ### 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. +- `test/bun-entry-isolation.test.ts` is strengthened. On POSIX the required shell spelling still resolves to `/dev/null`. + - Now offenders: the literal `--config=/dev/null` in a shipped shell file, and 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, each of which fails the check: the old spelling, a variable without the default, an empty `--config=`, a missing, late or altered definition, and a reverted bin. + - No threshold, budget, timeout, trial count or case kind changed. ## [1.91.223.0] - 2026-10-25 diff --git a/autoplan/bin/phase-publication-hook b/autoplan/bin/phase-publication-hook index 0bde64eb7..c88b95e29 100755 --- a/autoplan/bin/phase-publication-hook +++ b/autoplan/bin/phase-publication-hook @@ -1,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac _AUTOPLAN_DECIDED='' _autoplan_backstop() { if [ -z "$_AUTOPLAN_DECIDED" ]; then diff --git a/bin/gstack-brain-sync b/bin/gstack-brain-sync index de78eed05..0c5d07afb 100755 --- a/bin/gstack-brain-sync +++ b/bin/gstack-brain-sync @@ -77,7 +77,7 @@ BASH_COMPAT=50 set -uo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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 diff --git a/bin/gstack-browser-ensure b/bin/gstack-browser-ensure index f50d04533..764995fbb 100755 --- a/bin/gstack-browser-ensure +++ b/bin/gstack-browser-ensure @@ -24,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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" diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 11f65f58b..0292e5674 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -157,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac + local GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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 diff --git a/bin/gstack-codex-session-import b/bin/gstack-codex-session-import index 88443d389..73543e799 100755 --- a/bin/gstack-codex-session-import +++ b/bin/gstack-codex-session-import @@ -20,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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 diff --git a/bin/gstack-developer-profile b/bin/gstack-developer-profile index de10bb92e..2380beb44 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -28,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -176,7 +176,8 @@ EOF # and update aggregates (signals_accumulated, resources_shown, topics). # Fix for #1671: the writer side of the v1.0.0.0 migration. Reader and # writer now share the same file. -# Silent skip on invalid input (matches gstack-timeline-log:22-26 pattern). +# Invalid input is skipped silently; a Bun failure is reported and returned, +# as in gstack-timeline-log (#3415). # ----------------------------------------------------------------------- do_log_session() { local INPUT="${1:-}" @@ -185,12 +186,18 @@ do_log_session() { fi # Validate: input must be parseable JSON with required fields (date, mode). - 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 - return 0 - fi + # Invalid input exits 3; any other non-zero exit means Bun itself failed. + local 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.date || !j.mode) process.exit(3); + " || VALIDATE_RC=$? + case "$VALIDATE_RC" in + 0) ;; + 3) return 0 ;; + *) echo "gstack-developer-profile: bun exited $VALIDATE_RC before the session was validated; nothing was logged" >&2; return "$VALIDATE_RC" ;; + esac ensure_profile diff --git a/bin/gstack-distill-apply b/bin/gstack-distill-apply index 013998183..769445d1c 100755 --- a/bin/gstack-distill-apply +++ b/bin/gstack-distill-apply @@ -23,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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 diff --git a/bin/gstack-distill-free-text b/bin/gstack-distill-free-text index 85b87b743..2c4f50f67 100755 --- a/bin/gstack-distill-free-text +++ b/bin/gstack-distill-free-text @@ -20,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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 diff --git a/bin/gstack-doctor b/bin/gstack-doctor index e6ebdba85..9e18a5a38 100755 --- a/bin/gstack-doctor +++ b/bin/gstack-doctor @@ -40,7 +40,7 @@ BASH_COMPAT=50 set -u -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac usage() { sed -n '3,13p' "$0" | sed 's/^# \{0,1\}//' diff --git a/bin/gstack-doctor-check.sh b/bin/gstack-doctor-check.sh index 870607490..8cc9e2e34 100644 --- a/bin/gstack-doctor-check.sh +++ b/bin/gstack-doctor-check.sh @@ -19,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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; } diff --git a/bin/gstack-gbrain-install b/bin/gstack-gbrain-install index c4b6fb269..4b67a12c6 100755 --- a/bin/gstack-gbrain-install +++ b/bin/gstack-gbrain-install @@ -36,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac # --- defaults --- # No version pin by default — install the latest default-branch HEAD (#1744). diff --git a/bin/gstack-gbrain-source-wireup b/bin/gstack-gbrain-source-wireup index ab216c58d..97cbb1b4a 100755 --- a/bin/gstack-gbrain-source-wireup +++ b/bin/gstack-gbrain-source-wireup @@ -39,7 +39,7 @@ # Depends on: bun (already a gstack dependency), gbrain, git. set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" CONFIG_BIN="$SCRIPT_DIR/gstack-config" diff --git a/bin/gstack-hook-check b/bin/gstack-hook-check index bffd2448b..a399d2ba6 100755 --- a/bin/gstack-hook-check +++ b/bin/gstack-hook-check @@ -37,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac LIST_ONLY=0 SETUP_FILE="" while [ $# -gt 1 ]; do diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 4fff5c06c..3e244e830 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -31,7 +31,7 @@ # spawned sessions (ambient OpenClaw) only warn. GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1 # admits anyway and says so. -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac # gstack_source_root BIN_DIR — the source checkout behind an install: an # installed runtime root's recorded .source-path (setup _link_runtime_dists), diff --git a/bin/gstack-ios-qa-daemon b/bin/gstack-ios-qa-daemon index 366f2c6b7..0a2f713c7 100755 --- a/bin/gstack-ios-qa-daemon +++ b/bin/gstack-ios-qa-daemon @@ -21,7 +21,7 @@ # session tokens are outstanding. set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac # The --help heredoc is over 512 bytes; keep it off a pipe (test/heredoc-pipe-deadlock.test.ts). BASH_COMPAT=50 diff --git a/bin/gstack-ios-qa-mint b/bin/gstack-ios-qa-mint index 8192c88a6..44cbb8ebf 100755 --- a/bin/gstack-ios-qa-mint +++ b/bin/gstack-ios-qa-mint @@ -10,7 +10,7 @@ # Allowlist file: ~/.gstack/ios-qa-allowlist.json (mode 0600). set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" GSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen index ea4451cc5..33e6c95b9 100755 --- a/bin/gstack-ios-qa-regen +++ b/bin/gstack-ios-qa-regen @@ -3,7 +3,7 @@ # package and the app-owned typed state accessors. set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac usage() { cat <<'EOF' diff --git a/bin/gstack-learnings-log b/bin/gstack-learnings-log index 117e5b68b..f5fa21713 100755 --- a/bin/gstack-learnings-log +++ b/bin/gstack-learnings-log @@ -6,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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. diff --git a/bin/gstack-learnings-search b/bin/gstack-learnings-search index 8e45cd768..8e19da27c 100755 --- a/bin/gstack-learnings-search +++ b/bin/gstack-learnings-search @@ -8,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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; } diff --git a/bin/gstack-memorable b/bin/gstack-memorable index 035723886..558767b14 100755 --- a/bin/gstack-memorable +++ b/bin/gstack-memorable @@ -32,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" diff --git a/bin/gstack-question-log b/bin/gstack-question-log index 1de42718d..6661ba8a8 100755 --- a/bin/gstack-question-log +++ b/bin/gstack-question-log @@ -31,7 +31,7 @@ # # Append-only JSONL. Dedup is at read time in gstack-question-sensitivity --read-log. set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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. diff --git a/bin/gstack-question-preference b/bin/gstack-question-preference index 905e46677..5d0d1af40 100755 --- a/bin/gstack-question-preference +++ b/bin/gstack-question-preference @@ -35,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" diff --git a/bin/gstack-relink b/bin/gstack-relink index f4633fb38..f7443deba 100755 --- a/bin/gstack-relink +++ b/bin/gstack-relink @@ -9,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" GSTACK_CONFIG="${SCRIPT_DIR}/gstack-config" diff --git a/bin/gstack-review-log b/bin/gstack-review-log index c77b8882a..b60832497 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -17,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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)" diff --git a/bin/gstack-review-read b/bin/gstack-review-read index 2ebef6282..6be765749 100755 --- a/bin/gstack-review-read +++ b/bin/gstack-review-read @@ -9,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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")" ;; diff --git a/bin/gstack-settings-hook b/bin/gstack-settings-hook index c5ca45a3b..239be557f 100755 --- a/bin/gstack-settings-hook +++ b/bin/gstack-settings-hook @@ -69,7 +69,7 @@ BASH_COMPAT=50 set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac ACTION="${1:-}" SETTINGS_FILE="${GSTACK_SETTINGS_FILE:-${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json}" diff --git a/bin/gstack-specialist-stats b/bin/gstack-specialist-stats index 144d1c317..0c5c66532 100755 --- a/bin/gstack-specialist-stats +++ b/bin/gstack-specialist-stats @@ -8,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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; } diff --git a/bin/gstack-team-init b/bin/gstack-team-init index b93cf25f6..d284ad04c 100755 --- a/bin/gstack-team-init +++ b/bin/gstack-team-init @@ -18,7 +18,7 @@ BASH_COMPAT=50 set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac MODE="${1:-}" diff --git a/bin/gstack-telemetry-log b/bin/gstack-telemetry-log index 9d518da7b..a662e350f 100755 --- a/bin/gstack-telemetry-log +++ b/bin/gstack-telemetry-log @@ -21,7 +21,7 @@ # # NOTE: Uses set -uo pipefail (no -e) — telemetry must never exit non-zero set -uo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" SCRIPT_DIR="$GSTACK_DIR/bin" diff --git a/bin/gstack-timeline-log b/bin/gstack-timeline-log index 6bf54ceda..894c18082 100755 --- a/bin/gstack-timeline-log +++ b/bin/gstack-timeline-log @@ -10,7 +10,7 @@ # Optional: branch, outcome, duration_s, session, ts. # Validation failure → skip silently (non-blocking). set -euo pipefail -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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; } diff --git a/bin/gstack-timeline-read b/bin/gstack-timeline-read index 361af6f19..35db5fb46 100755 --- a/bin/gstack-timeline-read +++ b/bin/gstack-timeline-read @@ -10,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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)" diff --git a/browse/test/learnings-injection.test.ts b/browse/test/learnings-injection.test.ts index fe78e2618..2d202dca1 100644 --- a/browse/test/learnings-injection.test.ts +++ b/browse/test/learnings-injection.test.ts @@ -6,17 +6,22 @@ import { spawnSync } from 'child_process'; const SCRIPT_PATH = path.join(import.meta.dir, '../../bin/gstack-learnings-search'); const SCRIPT = fs.readFileSync(SCRIPT_PATH, 'utf-8'); const BIN_DIR = path.join(import.meta.dir, '../../bin'); +const BUN_BLOCK_AT = SCRIPT.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e'); describe('gstack-learnings-search injection safety', () => { + it('finds the bun -e block the checks below read', () => { + expect(BUN_BLOCK_AT).toBeGreaterThanOrEqual(0); + }); + it('must not interpolate variables into JS string literals', () => { - const jsBlock = SCRIPT.slice(SCRIPT.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e')); + const jsBlock = SCRIPT.slice(BUN_BLOCK_AT); 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="${GSTACK_BUN_CONFIG:-/dev/null}" -e')); + const jsBlock = SCRIPT.slice(BUN_BLOCK_AT); 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 133b3e9e4..ceb589c23 100755 --- a/gstack-upgrade/migrations/v1.86.0.0.sh +++ b/gstack-upgrade/migrations/v1.86.0.0.sh @@ -5,7 +5,7 @@ # 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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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="${GSTACK_BUN_CONFIG:-/dev/null}" "$INSTALL_DIR/bin/gstack-migrate-claude-code" --install-dir "$INSTALL_DIR" || true diff --git a/hosts/claude/hooks/auq-error-fallback-hook b/hosts/claude/hooks/auq-error-fallback-hook index 555e6cb55..7c351fd9b 100755 --- a/hosts/claude/hooks/auq-error-fallback-hook +++ b/hosts/claude/hooks/auq-error-fallback-hook @@ -3,6 +3,6 @@ # wrapper makes the TypeScript hook executable via bun. Settings.json # references this file directly. set -e -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" 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 3e03ed734..9a7d07069 100755 --- a/hosts/claude/hooks/memorable-user-prompt-hook +++ b/hosts/claude/hooks/memorable-user-prompt-hook @@ -12,7 +12,7 @@ # 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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0 bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$HERE/memorable-user-prompt-hook.ts" <&0 & _child=$! diff --git a/hosts/claude/hooks/question-log-hook b/hosts/claude/hooks/question-log-hook index d39baa681..c70484a90 100755 --- a/hosts/claude/hooks/question-log-hook +++ b/hosts/claude/hooks/question-log-hook @@ -3,6 +3,6 @@ # wrapper makes the TypeScript hook executable via bun. Settings.json # references this file directly. set -e -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" 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 7feb65c79..a910c3cf4 100755 --- a/hosts/claude/hooks/question-preference-hook +++ b/hosts/claude/hooks/question-preference-hook @@ -3,6 +3,6 @@ # wrapper makes the TypeScript hook executable via bun. Settings.json # references this file directly. set -e -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" 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 71c75ae32..9cddf1fe5 100755 --- a/hosts/claude/hooks/timeline-stop-hook +++ b/hosts/claude/hooks/timeline-stop-hook @@ -5,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" || exit 0 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 c1e514a80..426b6dd3f 100644 --- a/lib/bun-isolation.ts +++ b/lib/bun-isolation.ts @@ -7,7 +7,8 @@ * --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 when OSTYPE is msys or - * cygwin, #3415), TypeScript spawns use bunIsolationArgs(). + * cygwin and cygpath is on PATH, #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` diff --git a/plan-ceo-review/bin/mode-handoff-hook b/plan-ceo-review/bin/mode-handoff-hook index a2656d6f1..58ad95c08 100755 --- a/plan-ceo-review/bin/mode-handoff-hook +++ b/plan-ceo-review/bin/mode-handoff-hook @@ -1,6 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac HERE="$(cd "$(dirname "$0")" && pwd)" 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 c492bd920..303d03e6a 100644 --- a/scripts/build-cso.sh +++ b/scripts/build-cso.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -eu -GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac CSO_BUILD_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" cd "$CSO_BUILD_ROOT" diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index 8e383e292..36dae63ac 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -314,7 +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/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 bun and cygpath stand-ins and simulated-OSTYPE cases 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 43db8ec89..50225c258 100755 --- a/setup +++ b/setup @@ -1,7 +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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac +GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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 diff --git a/test/bun-config-shell-bins.test.ts b/test/bun-config-shell-bins.test.ts index 01968aab6..a8d60f577 100644 --- a/test/bun-config-shell-bins.test.ts +++ b/test/bun-config-shell-bins.test.ts @@ -1,8 +1,8 @@ /** * #3415: gstack's shell bins start Bun with * --config="${GSTACK_BUN_CONFIG:-/dev/null}", and each defines - * GSTACK_BUN_CONFIG as NUL when OSTYPE is msys or cygwin (Git Bash, MSYS2, - * Cygwin). Native bun.exe cannot + * GSTACK_BUN_CONFIG as NUL when OSTYPE is msys or cygwin and cygpath is on PATH + * (Git Bash, MSYS2, 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. @@ -10,10 +10,12 @@ * 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 (OSTYPE=msys, a bun - * that rejects /dev/null the way bun.exe does) reproduces the bug; its - * controls, without OSTYPE=msys, show each bin fails loudly with nothing - * written instead of exiting 0. + * and .env never load. On POSIX a simulated Windows bash (OSTYPE=msys, a + * cygpath, a bun that rejects /dev/null the way bun.exe does) reproduces the + * bug; its controls, without OSTYPE=msys, show each bin fails loudly with + * nothing written instead of exiting 0. An OSTYPE=msys inherited by a POSIX + * bash with no cygpath keeps /dev/null, so a file named NUL in the repository + * is never read as Bun's config. */ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; @@ -47,8 +49,8 @@ function env(pathPrefix?: string, extra: Record = {}): NodeJS.Pr return { ...out, HOME: path.join(tmp, 'home'), GSTACK_STATE_ROOT: stateRoot, MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*', ...extra }; } -function run(bin: string, input: string, pathPrefix?: string, extra: Record = {}) { - return runScriptSync(path.join(ROOT, 'bin', bin), [input], { cwd: repo, env: env(pathPrefix, extra), encoding: 'utf8', timeout: 60_000 }); +function run(bin: string, input: string, pathPrefix?: string, extra: Record = {}, rest: string[] = []) { + return runScriptSync(path.join(ROOT, 'bin', bin), [input, ...rest], { cwd: repo, env: env(pathPrefix, extra), encoding: 'utf8', timeout: 60_000 }); } function records(log: string): string[] { @@ -99,6 +101,7 @@ beforeAll(() => { `exec ${JSON.stringify(process.execPath)} "\${a[@]}"`, '', ].join('\n')); + writeExec(path.join(fakeBun, 'cygpath'), '#!/usr/bin/env bash\nprintf \'%s\\n\' "${@: -1}"\n'); }); afterAll(() => { if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); }); @@ -135,6 +138,20 @@ describe('#3415: record bins start Bun on a Windows bash with path conversion of }); describe.skipIf(IS_WINDOWS)('#3415: simulated Windows bash (POSIX hosts)', () => { + test('gstack-developer-profile --log-session reports a Bun failure instead of skipping it, and still skips invalid input silently', () => { + resetState(); + const session = JSON.stringify({ date: '2026-10-25', mode: 'startup' }); + const failed = run('gstack-developer-profile', '--log-session', fakeBun, {}, [session]); + expect(failed.stderr).toContain('EINVAL'); + expect(failed.stderr).toContain('nothing was logged'); + expect(failed.status).not.toBe(0); + for (const bad of ['not json', '{"date":"2026-10-25"}', 'null']) { + const r = run('gstack-developer-profile', '--log-session', undefined, {}, [bad]); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + } + }); + for (const { bin, input, log } of BINS) { test(`${bin} passes NUL and writes its record`, () => { resetState(); @@ -145,6 +162,21 @@ describe.skipIf(IS_WINDOWS)('#3415: simulated Windows bash (POSIX hosts)', () => expect(fs.existsSync(preloadMarker)).toBe(false); }); + test(`an inherited OSTYPE=msys without cygpath keeps /dev/null: ${bin} never reads a repository file named NUL`, () => { + resetState(); + const nul = path.join(repo, 'NUL'); + fs.writeFileSync(nul, 'preload = ["./preload.ts"]\n'); + try { + const r = run(bin, input, undefined, { OSTYPE: 'msys' }); + expect(r.status).toBe(0); + expect(records(log).length).toBe(1); + expect(fs.existsSync(preloadMarker)).toBe(false); + } finally { + fs.rmSync(nul, { force: true }); + fs.rmSync(preloadMarker, { force: true }); + } + }); + test(`control: when Bun rejects the config, ${bin} fails loudly and writes nothing`, () => { resetState(); const r = run(bin, input, fakeBun); diff --git a/test/bun-entry-isolation.test.ts b/test/bun-entry-isolation.test.ts index 15ad346fc..64b35ce4a 100644 --- a/test/bun-entry-isolation.test.ts +++ b/test/bun-entry-isolation.test.ts @@ -30,7 +30,7 @@ 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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac'; +const CONFIG_DEF = 'GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; 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()); diff --git a/test/bun-isolation-launch.test.ts b/test/bun-isolation-launch.test.ts index 8ed07db1f..d56a943bf 100644 --- a/test/bun-isolation-launch.test.ts +++ b/test/bun-isolation-launch.test.ts @@ -18,7 +18,7 @@ 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 "${OSTYPE:-}" in msys*|cygwin*) GSTACK_BUN_CONFIG=NUL ;; esac'; +const CONFIG_DEF = 'GSTACK_BUN_CONFIG=/dev/null; case "${OSTYPE:-}" in msys*|cygwin*) if command -v cygpath >/dev/null 2>&1; then GSTACK_BUN_CONFIG=NUL; fi ;; esac'; const NO_PATHCONV = { MSYS_NO_PATHCONV: '1', MSYS2_ARG_CONV_EXCL: '*' }; let project = ''; let preloadMarker = ''; diff --git a/test/learnings-injection.test.ts b/test/learnings-injection.test.ts index d542c6d01..89515fe79 100644 --- a/test/learnings-injection.test.ts +++ b/test/learnings-injection.test.ts @@ -6,10 +6,15 @@ const SCRIPT = path.join(import.meta.dir, "..", "bin", "gstack-learnings-search" describe("gstack-learnings-search injection prevention", () => { const script = readFileSync(SCRIPT, "utf-8"); + const bunBlockAt = script.indexOf('bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "'); + + test("the bun -e block the checks below read is present", () => { + expect(bunBlockAt).toBeGreaterThanOrEqual(0); + }); 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="${GSTACK_BUN_CONFIG:-/dev/null}" -e "')); + const bunBlock = script.slice(bunBlockAt); // Should NOT contain ${VAR} patterns (shell interpolation) // These are RCE vectors: a malicious learnings entry with '; rm -rf / ;' in the @@ -27,7 +32,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="${GSTACK_BUN_CONFIG:-/dev/null}" -e "')); + const bunBlock = script.slice(bunBlockAt); // Must use process.env for TYPE, QUERY, LIMIT. // SLUG and CROSS are no longer threaded as env vars inside the bun @@ -52,7 +57,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="${GSTACK_BUN_CONFIG:-/dev/null}" -e "')); + const bunBlock = script.slice(bunBlockAt); // 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