From 4b623a490d4eefb5870c7450afe2f334fcb14736 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 23:28:12 +0000 Subject: [PATCH] fix(capy-install): the launch file appends the checkout's bin/ to PATH (GSTA-167) A /ship child that sourced only the launch file got 'gstack-ship-budget: command not found' (exit 127). The file now appends /bin once, after everything already on PATH, so it shadows nothing. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-capy-install | 6 +++++- docs/capy-install.md | 3 +++ test/gstack-capy-install.test.ts | 18 ++++++++++-------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/bin/gstack-capy-install b/bin/gstack-capy-install index 93b51b75c..6ccfc52c8 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -656,13 +656,16 @@ WARN behind — installed $INSTALLED is ${GSTACK_CAPY_BEHIND:-?} commits behind [ -n "$NOTES" ] && TRAILER="$TRAILER ($NOTES)" ;; esac # The launch file: the same assignments as a sourceable script, mode 0600, -# beside the verdict. `. ` applies them without ever printing them; the +# beside the verdict. It also appends the checkout's bin directory to PATH +# (after everything already there, so it shadows nothing): a skill's bare +# `gstack-ship-budget` resolves in a shell that sourced only this file. `. ` applies them without ever printing them; the # line carries no secret, but a caller that re-typed it as `export $(...)` # ran a bare `export` when the substitution came back empty and dumped the # whole environment, API keys included, into its tool output (GSTA-96). The # file is part of the ok verdict: a passing run that cannot write it fails, # so no caller is left with only the line and the re-typing path. LAUNCH_FILE="${VERDICT%.json}.env" +BIN_DIR=$(cd "$ROOT/bin" 2>/dev/null && pwd -P) write_launch_file() { local tmp mkdir -p "$(dirname "$LAUNCH_FILE")" || return 1 @@ -672,6 +675,7 @@ write_launch_file() { echo "# Apply it with: . " echo "# Never print it or the environment: no \`export \$(...)\`, \`env\`, \`printenv\` or bare \`set\` in a tool fence." [ -n "$LAUNCH_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$LAUNCH_DIR"):*) ;; *) PATH=$(shq "$LAUNCH_DIR"):\"\$PATH\" ;; esac; export PATH" + [ -n "$BIN_DIR" ] && echo "case \":\$PATH:\" in *:$(shq "$BIN_DIR"):*) ;; *) PATH=\"\${PATH:+\$PATH:}\"$(shq "$BIN_DIR") ;; esac; export PATH" echo "export GSTACK_SESSION_KIND=unattended" echo "export GSTACK_STATE_ROOT=$(shq "$GSTACK_STATE_ROOT")" echo "export GSTACK_EPHEMERAL=$(shq "${GSTACK_EPHEMERAL:-1}")" diff --git a/docs/capy-install.md b/docs/capy-install.md index 7de24710e..ed00f9ed8 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -423,6 +423,9 @@ GSTACK_LAUNCH_FILE: .env `GSTACK_LAUNCH_FILE` names the same assignments as a sourceable script (mode 0600, beside the verdict under the state root; the PATH prepend is idempotent). +The file also appends the checkout's `bin/` directory to PATH, after everything +already there and only once, so a shell that sourced only this file runs a bare +`gstack-ship-budget` (GSTA-167: a /ship child got `command not found`, exit 127). The file is part of the ok verdict: a passing run that cannot write it exits 1 with `gstack: fail launch-file — could not write the launch file ; fix: make writable, or set GSTACK_STATE_ROOT to a writable directory diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index 937eaf57c..30af9c378 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -524,15 +524,17 @@ describe('gstack-capy-install: launch PATH, Bun reuse, CI pins, setup skip, verd expect(r.stdout).toContain(`GSTACK_LAUNCH_FILE: ${files[0].replace(/\.json$/, '.env')}`); expect(fs.readFileSync(files[0].replace(/\.json$/, '.env'), 'utf8')).toContain(`export GSTACK_INSTALL_VERDICT='${files[0]}'`); // /ship Step 7 value card. protects: with the certified Bun first on PATH (no GSTACK_LAUNCH_PATH) - // the launch file carries no PATH line, sources under POSIX sh, and leaves PATH exactly as it was; - // --check rewrites and prints it too. fails_when: the PATH guard is dropped (an empty prepend - // leaves ":"-led PATH), the file uses a bashism, or --check stops printing GSTACK_LAUNCH_FILE. - // why_existing_misses: the PATH-bearing case above is sourced by bash only, and the --check run - // below asserted only the GSTACK_LAUNCH line. seam=none. - const sourced = spawnSync('sh', ['-c', '. "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT"', '_', files[0].replace(/\.json$/, '.env')], + // the launch file prepends nothing, sources under POSIX sh, and only appends the checkout's bin + // directory once (GSTA-167: a /ship child that sourced only the launch file got + // `gstack-ship-budget: command not found`, exit 127); --check rewrites and prints it too. + // fails_when: the PATH guard is dropped (an empty prepend leaves ":"-led PATH, a second source + // appends the bin directory twice), the file uses a bashism, the bin directory is not on PATH, + // or --check stops printing GSTACK_LAUNCH_FILE. why_existing_misses: the PATH-bearing case above + // is sourced by bash only, and the --check run below asserted only the GSTACK_LAUNCH line. seam=none. + const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_SESSION_KIND" "$GSTACK_CAPY" "$GSTACK_INSTALL_VERDICT" "$(command -v gstack-paths)"', '_', files[0].replace(/\.json$/, '.env')], { encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } }); expect(sourced.status, sourced.stderr).toBe(0); - expect(sourced.stdout.trimEnd().split('\n')).toEqual(['/usr/bin:/bin', 'unattended', '1', files[0]]); + expect(sourced.stdout.trimEnd().split('\n')).toEqual([`/usr/bin:/bin:${f.root}/bin`, 'unattended', '1', files[0], path.join(f.root, 'bin', 'gstack-paths')]); const v = JSON.parse(fs.readFileSync(files[0], 'utf8')); expect(v).toMatchObject({ schema_version: 2, checkout: f.root, head: sh('git rev-parse HEAD', f.root).stdout.trim(), for: 'autoplan', ok: true, trailer: 'gstack: ok 1.2.3 for=autoplan', project: '', bun_path: path.join(f.stub, 'bun'), bun_version: '1.4.2', bun_rule: 'PATH' }); @@ -975,7 +977,7 @@ describe.skipIf(process.platform !== 'linux')('gstack-capy-install: the per-dire const sourced = spawnSync('sh', ['-c', '. "$1" && . "$1" && printf "%s\\n" "$PATH" "$GSTACK_STATE_ROOT" "$GSTACK_EPHEMERAL" "$GSTACK_INSTALL_VERDICT"', '_', launchFile], { encoding: 'utf8', timeout: 30_000, env: { PATH: '/usr/bin:/bin' } }); expect(sourced.status, sourced.stderr).toBe(0); - expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]); + expect(sourced.stdout.trimEnd().split('\n')).toEqual([`${state}/runtimes/bun/${BUN_DEFAULT}:/usr/bin:/bin:${f.root}/bin`, state, '0', launchFile.replace(/\.env$/, '.json')]); const r = fresh(f, f.base, 'bun --version && bunx --version', { GSTACK_BUN_SHIM_EXPLAIN: '1' }); expect(r.status, r.stderr).toBe(0); expect(r.stdout.trim().split('\n')[0]).toBe(BUN_DEFAULT);