fix(capy-install): read project pins with an isolated Bun (GST-1747) (#4115)

* fix(capy-install): read project pins with an isolated Bun (GST-1747)

bin/gstack-capy-install runs bin/gstack-runtime-pins.ts from the caller's
project directory. Its three calls (read_pins, pins_accept, node_accept)
now pass --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}", so the
project's bunfig.toml and .env do not load into the installer.

test/bun-entry-isolation.test.ts also checks Bun runners held in a
variable: any variable at a command position launching a TypeScript file,
and any Bun-named variable running -e or a script.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(capy-install): note that the pins parser runs with an isolated Bun (GST-1747)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-11-07 06:35:20 +00:00
1 parent 94392a61ec
commit 40071a4237
5 files changed
+57 -6

No files matched your search

+4 -3
View File
@@ -52,6 +52,7 @@
# Exit: 0 ok, 1 a component failed, 2 usage, 3 preflight stopped before ./setup.
set -u
BASH_COMPAT=50
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
SELF="$(cd "$(dirname "$0")/.." && pwd -P)"
ROOT="${GSTACK_CAPY_ROOT:-$SELF}"
@@ -190,7 +191,7 @@ PINS_OUT="" PINS_MODE=none RUNNER="" PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS=""
read_pins() {
RUNNER="$1" PINS_OUT="" PINS_MODE=none PIN="" PIN_SRC="" PIN_EXACT="" PIN_ALTS=""
[ -n "$PROJECT" ] && [ "$PROFILE" -eq 0 ] || return 0
[ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null)
[ -n "$RUNNER" ] && [ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && PINS_OUT=$("$RUNNER" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null)
if printf '%s\n' "$PINS_OUT" | grep -q '^PINS_VERDICT: '; then
PINS_MODE=parser
_exact=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: bun \([^ ]*\) \([^ ]*\)$/\1 \2/p')
@@ -229,7 +230,7 @@ read_pins() {
# pins_accept VERSION — the resolver's eligibility hook: VERSION satisfies every project pin.
pins_accept() {
case "$PINS_MODE" in
parser) "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$' ;;
parser) "$RUNNER" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" 2>/dev/null | grep -Eq '^PINS_VERDICT: (pass|none)$' ;;
jq) case "$PIN" in
'>='*) _gstack_bun_below "$1" "${PIN#>=}"; [ $? -eq 1 ] ;;
[0-9]*.[0-9]*.[0-9]*) [ "$1" = "$PIN" ] ;;
@@ -352,7 +353,7 @@ fi
# satisfies the pins, and never installs.
NODE_STORE=$(gstack_node_store "$GSTACK_STATE_ROOT")
NODE_BIN="" NODE_VER="" NODE_RULE="" NODE_WANT=""
node_accept() { ! "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --node "$1" 2>/dev/null | grep -q '^PIN_FAIL: node '; }
node_accept() { ! "$RUNNER" --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --node "$1" 2>/dev/null | grep -q '^PIN_FAIL: node '; }
node_pins_text() { printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_SUMMARY: //p' | grep '^node' | tr '\n' ';' | sed 's/;$//; s/;/; /g'; }
if [ "$PINS_MODE" = parser ] && printf '%s\n' "$PINS_OUT" | grep -q '^PIN: node '; then
_nreq=$(printf '%s\n' "$PINS_OUT" | sed -n 's/^PIN_EXACT_REQUIRED: node \([^ ]*\) \([^ ]*\)$/\1 \2/p')
@@ -0,0 +1,4 @@
---
important: security
---
- **The Capy installer reads project pins with an isolated Bun.** `bin/gstack-capy-install` runs `bin/gstack-runtime-pins.ts` from the caller's project directory to read its Bun and Node pins. Those three calls now pass `--no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}"` like every other Bun gstack starts, so a project's `bunfig.toml` and `.env` no longer load into the installer. `test/bun-entry-isolation.test.ts` now also checks Bun runners held in a variable (`"$RUNNER" x.ts`, and any variable whose name says Bun running `-e` or a script) in shipped shell files. (GST-1747)
+3 -1
View File
@@ -295,7 +295,9 @@ uses it to refuse a base merge under an open run
runs; the pins, the doctor and the install verdict all use the resolved
directory) with the doctor's own parser (`bin/gstack-runtime-pins.ts`: engines,
packageManager, `.tool-versions`, `.bun-version` and CI `bun-version:` lanes
for this platform). CI exact pins for this platform are alternatives: a
for this platform), run with an isolated Bun (`--no-env-file
--config="${GSTACK_BUN_CONFIG:-/dev/null}"`) so the project's `bunfig.toml`
and `.env` never load into the installer. CI exact pins for this platform are alternatives: a
repository may test several Bun versions (gstack's own CI pins 1.4.2 and
runs one job on 1.4.4), so a Bun equal to any one of them satisfies CI;
CI ranges and every non-CI pin stay required. One resolver picks the Bun (`gstack_bun_resolve` in
+27 -2
View File
@@ -43,11 +43,16 @@ function firstLine(file: string): string {
} finally { fs.closeSync(fd); }
}
const BUN_WORD = /(^|[\s;&|({`"']|\$\()(bun|"\$\{?BUN(?:_[A-Z]+)?\}?"|\$\{?BUN(?:_[A-Z]+)?\}?)(?=[ \t])/g;
const BUN_WORD = /(^|[\s;&|({`"']|\$\()(bun|"\$\{?\w*(?:BUN|[Bb]un)\w*\}?"|\$\{?\w*(?:BUN|[Bb]un)\w*\}?)(?=[ \t])/g;
/** Any variable at a command position (`"$RUNNER" x.ts`, `OUT=$("$RUNNER" ...)`, `case) "$RUNNER" ...`); it is a Bun when it launches a TypeScript file. */
const VAR_COMMAND = /(^\s*|[;&|({)`!]\s*|\$\(\s*|\b(?:then|do|else|exec)\s+)("\$\{?[A-Za-z_]\w*\}?"|\$\{?[A-Za-z_]\w*\}?)(?=[ \t])/g;
const afterFlags = (rest: string) => rest.replace(/^\s+/, '').replace(/^(?:--(?:no-env-file|cwd[= ]\S+|config(?:=\S*|\s+\S+))\s+)*/, '');
const runsTypeScript = (rest: string) => /^(?:run\s+)?["']?[^\s"'|;&)]*\.(?:ts|tsx|mts)(?:["'\s)|;&\\`]|$)/.test(afterFlags(rest));
/** 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+|config(?:=\S*|\s+\S+))\s+)*/, '');
const r = afterFlags(rest);
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)
@@ -64,6 +69,12 @@ function unisolatedBunCalls(line: string, flags = FLAGS): string[] {
const rest = line.slice(at + m[2]!.length);
if (!rest.startsWith(` ${flags}`) && runsCode(rest)) out.push(line.slice(at, at + 80));
}
for (const m of line.matchAll(VAR_COMMAND)) {
const at = m.index! + m[1]!.length;
if (new RegExp(BUN_WORD.source).test(m[2]!)) continue;
const rest = line.slice(at + m[2]!.length);
if (!rest.startsWith(` ${flags}`) && runsTypeScript(rest)) out.push(line.slice(at, at + 80));
}
return out;
}
@@ -126,6 +137,12 @@ describe('GSTA-56: gstack never takes configuration from the audited repository'
'"$BUN" -e \'1\'',
'bun --no-env-file run "$X/bin/gstack-design-detect.ts"',
'running=$(bun -p process.execPath)',
'PINS_OUT=$("$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" 2>/dev/null)',
' parser) "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT" --bun "$1" | grep -q x ;;',
'node_accept() { ! "$RUNNER" "$ROOT/bin/gstack-runtime-pins.ts" --node "$1"; }',
'"$RUNNER" run "$ROOT/bin/gstack-runtime-pins.ts"',
'v=$("$REC_BUN" -e "console.log(1)")',
'"$_bun" run "$X/bin/gstack-design-detect"',
]) expect(unisolatedBunCalls(bad)).not.toEqual([]);
for (const ok of [
`bun ${FLAGS} run ~/.claude/skills/gstack/bin/gstack-render.ts x.html`,
@@ -136,6 +153,13 @@ describe('GSTA-56: gstack never takes configuration from the audited repository'
'# bun run scripts/x.ts in a comment',
'say "installing bun $WANT from its release archive"',
'ln -sf bun "$HOME/.bun/bin/bunx"',
`PINS_OUT=$("$RUNNER" ${FLAGS} "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT")`,
'[ -f "$ROOT/bin/gstack-runtime-pins.ts" ] && echo yes',
'cp "$SRC" "$DEST/pins.ts"',
'echo "$X" a.ts',
'"$NODE" -e "console.log(1)"',
'_got=$("$RUNNER" --version 2>/dev/null | head -1)',
'out=$("$GSTACK_BUN_CONFIG")',
]) expect(unisolatedBunCalls(ok)).toEqual([]);
});
@@ -150,6 +174,7 @@ describe('GSTA-56: gstack never takes configuration from the audited repository'
`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\\"'"`,
`PINS_OUT=$("$RUNNER" ${SHELL_FLAGS} "$ROOT/bin/gstack-runtime-pins.ts" --project "$PROJECT")`,
]) expect(unisolatedBunCalls(ok, SHELL_FLAGS)).toEqual([]);
});
+19
View File
@@ -1372,6 +1372,25 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the project
expect(JSON.parse(fs.readFileSync(onPath.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1], 'utf8'))).toMatchObject({ node_path: path.join(f.stub, 'node'), node_rule: 'PATH', launch_path: '' });
});
test('the pins parser ignores the project\'s bunfig.toml: install and --check read the Bun and Node pins from the project directory without running its preload', () => {
const f = fixture();
withPins(f);
realBun(path.join(f.stub, 'bun'), '1.4.2');
fakeNode(path.join(f.stub, 'node'), '24.21.0');
const nvmNode = path.join(f.home, '.nvm/versions/node/v24.18.0/bin/node');
fakeNode(nvmNode, '24.18.0');
const project = nodeProject(f, '24.18.0');
const marker = path.join(f.base, 'preload-ran.log');
write(path.join(project, 'bunfig.toml'), 'preload = ["./preload.ts"]\n');
write(path.join(project, 'preload.ts'), `require('fs').appendFileSync(${JSON.stringify(marker)}, process.argv.slice(2).join(' ') + '\\n');\n`);
const r = run(f, ['--for', 'review'], {}, project);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(r.stdout).toContain(`node 24.18.0 at ${nvmNode} (nvm; satisfies the project's Node pins`);
const c = run(f, ['--check', '--for', 'review'], {}, project);
expect(c.status, c.stdout + c.stderr).toBe(0);
expect(fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8') : '').toBe('');
});
test('no installed Node satisfies the pin: --check installs nothing; install downloads it into the runtime store checked against SHASUMS256.txt; a mismatch installs nothing', () => {
const f = fixture();
withPins(f);