fix(windows): shell bins pass NUL as Bun's empty config when MSYS path conversion is off (#3415)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-25 23:31:55 +00:00
1 parent c23d7d48fe
commit 27e5dce493
50 files changed
+395 -103

No files matched your search

+1 -1
View File
@@ -303,4 +303,4 @@ Agents poll `eval:bg:*` logs for the `### gstack-detach EXIT=<code> ###` 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.
+18
View File
@@ -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=<anything> -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=<its own 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).
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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" </dev/null 2>&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" </dev/null 2>&1); PROBE_RC=$?
PROBE_OUT=$(cd "$ROOT" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$PROBE" </dev/null 2>&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
+2 -1
View File
@@ -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'}"
+2 -1
View File
@@ -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");
+12 -11
View File
@@ -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) || {
+3 -2
View File
@@ -23,6 +23,7 @@
# gstack-distill-apply --proposal <N> --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);
+6 -5
View File
@@ -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 });
+2 -1
View File
@@ -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"} </dev/null 2>&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"} </dev/null 2>&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)
+4 -3
View File
@@ -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" </dev/null 2>&1 || node -e "$script" </dev/null 2>&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" </dev/null 2>&1); rc=$?
out=$(cd "$ROOT" && bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e "$script" </dev/null 2>&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 2>/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"} </dev/null 2>&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"} </dev/null 2>&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 2>/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 2>/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 ;;
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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);
+3 -1
View File
@@ -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 2>/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 2>/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"
+2 -1
View File
@@ -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" "$@"
+2 -1
View File
@@ -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" "$@"
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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 || '';
+2 -1
View File
@@ -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)"
+4 -3
View File
@@ -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
+5 -4
View File
@@ -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'));
+2 -1
View File
@@ -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"
+4 -3
View File
@@ -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); }
+2 -1
View File
@@ -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 {
+10 -9
View File
@@ -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")); }
+2 -1
View File
@@ -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 = {};
+2 -1
View File
@@ -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' };
+2 -1
View File
@@ -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' });
+16 -9
View File
@@ -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));
+2 -1
View File
@@ -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: "<N> minute|hour|day|week|month[s] ago", "<N>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);
+2 -2
View File
@@ -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');
});
});
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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"
@@ -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
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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
+5 -4
View File
@@ -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
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+1
View File
@@ -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',
+4 -3
View File
@@ -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"
+161
View File
@@ -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([]);
});
}
});
+55 -8
View File
@@ -6,9 +6,11 @@
* kinds are held to the isolated form:
* 1. bun shebangs: exactly ISOLATED_SHEBANG (`bun run <file>` 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 <var>` 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<string, string> = {
'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([]);
});
+17
View File
@@ -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 }));
});
+3 -3
View File
@@ -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<json>` or `cross\t<json>` from the
// upstream pipe (via emit_tagged_file). Inside the bun block, the script
+2 -2
View File
@@ -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);
});