From 923fc0a6b80e154b2a5de7fe635b06fdf52e0b21 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 17:07:06 -0800 Subject: [PATCH] v1.93.32.0 fix(setup): bin/dev-setup works in a fresh gstack clone (GST-768) (#3789) * fix(setup): bin/dev-setup succeeds in a fresh gstack clone (GST-768) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for GST-768 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> --- CHANGELOG.md | 4 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- setup | 9 +++- test/dev-setup-fresh-clone.test.ts | 84 ++++++++++++++++++++++++++++++ 6 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 test/dev-setup-fresh-clone.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d30449f6..130324758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [1.93.32.0] - 2026-11-03 + +- **`bin/dev-setup` works in a fresh clone again.** It installed dependencies, then setup refused with "project-local Codex destination overlaps the source tree" and exited 1, because setup's overlap guard read gstack's own `.agents/skills` as a foreign project's skills directory pointing into the checkout. Setup now allows that one layout, where the project is the gstack checkout itself and its Codex skills directory is that checkout's own `.agents/skills`. Every other overlap is still refused. That includes a user project whose `.agents/skills` leads into a gstack checkout, a checkout whose `.agents/skills` points at its own root, and `--host codex` run from a dev checkout, which the Codex preflight still stops before any write. test/dev-setup-fresh-clone.test.ts covers the fresh clone and all three refusals (GST-768). + ## [1.93.31.0] - 2026-11-03 **A CSO hardening test now fails at the Git step that broke instead of passing without testing anything.** `fixtureGit` in `test/cso-git-hardening.test.ts` threw only on a non-zero exit status. When Git was killed by its 30-second timeout or could not start, `spawnSync` returned a `null` status, and the helper treated that as success. If that happened to the `git replace` step, the test that checks a security snapshot ignores replaced history would pass without a replacement in place. The helper now throws on a spawn error or any status other than 0, and the message names the Git arguments, the status and the signal. The stricter helper showed that the file's warm-up step had been calling `/usr/bin/git` on Windows, where it doesn't exist, on every run. It was passing only because the error was swallowed. Every test in the file already skips on Windows, so the warm-up now skips there too. diff --git a/VERSION b/VERSION index 3052c83aa..22147dc7f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.93.31.0 +1.93.32.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4868f536b..c1aa084d3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.93.31.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.93.32.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index 3aae4874d..ec148db25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.93.31", + "version": "1.93.32", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/setup b/setup index 5bd5937ff..81acc11a4 100755 --- a/setup +++ b/setup @@ -641,10 +641,15 @@ if [ "$CODEX_REPO_LOCAL" -eq 1 ]; then "$_codex_project"|"$_codex_project"/*) ;; *) echo "Refusing: project-local Codex destination resolves outside the project: $CODEX_SKILLS -> $_codex_parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2; exit 1 ;; esac + _codex_self_host=0 + if [ "$_codex_project" = "$SOURCE_GSTACK_DIR" ] && [ "$_codex_parent" = "$SOURCE_GSTACK_DIR/.agents/skills" ]; then + _codex_self_host=1 + fi _codex_payload="$_codex_parent" while :; do - if [ "$_codex_payload" = "$SOURCE_GSTACK_DIR" ] \ - || { [ -f "$_codex_payload/setup" ] && [ -f "$_codex_payload/VERSION" ] && [ -f "$_codex_payload/bin/gstack-relink" ]; }; then + if { [ "$_codex_payload" = "$SOURCE_GSTACK_DIR" ] \ + || { [ -f "$_codex_payload/setup" ] && [ -f "$_codex_payload/VERSION" ] && [ -f "$_codex_payload/bin/gstack-relink" ]; }; } \ + && ! { [ "$_codex_self_host" -eq 1 ] && [ "$_codex_payload" = "$SOURCE_GSTACK_DIR" ]; }; then echo "Refusing: project-local Codex destination overlaps the source tree at $_codex_payload: $CODEX_SKILLS -> $_codex_parent. Use a separate project-local skills directory; setup will not relink it automatically." >&2 exit 1 fi diff --git a/test/dev-setup-fresh-clone.test.ts b/test/dev-setup-fresh-clone.test.ts new file mode 100644 index 000000000..73d48c756 --- /dev/null +++ b/test/dev-setup-fresh-clone.test.ts @@ -0,0 +1,84 @@ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { readlinkSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanupFixtures, cleanupSeed, fixtureMkdirSync, makeFixture, makeSource, put, tree } from './helpers/install-fixture'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +const stubBun = (f: ReturnType) => put(join(f.commands, 'bun'), `#!/usr/bin/env bash +case "$*" in + install|'install --production --frozen-lockfile') exit 0 ;; + 'build --help') echo 'Fixture Bun has no CSO compile flags'; exit 0 ;; + 'run build') echo 'Unexpected build in a dev-setup fixture' >&2; exit 90 ;; + *) exec '${process.execPath}' "$@" ;; +esac +`, 0o755); + +const freshClone = (rel = 'gstack') => { + const f = makeFixture(); + stubBun(f); + const source = makeSource(f, join(f.dir, rel)); + const init = spawnSync('git', ['init', '-q', source], { encoding: 'utf8', timeout: 30_000 }); + if (init.status !== 0) throw new Error(init.stderr); + return { f, source }; +}; + +describe.skipIf(process.platform === 'win32')('bin/dev-setup in a fresh gstack clone', () => { + test('links gstack into its own .claude and .agents skills and exits 0', () => { + const { f, source } = freshClone(); + for (let run = 0; run < 2; run++) { + const result = spawnSync('bash', [join(source, 'bin/dev-setup')], { cwd: source, env: f.env, encoding: 'utf8', timeout: 120_000, input: '' }); + expect(result.stderr).not.toContain('overlaps the source tree'); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(readlinkSync(join(source, '.claude/skills/gstack'))).toBe(source); + expect(readlinkSync(join(source, '.agents/skills/gstack'))).toBe(source); + } + }, 240_000); + + test('a user project whose .agents/skills resolves to the checkout\'s own .agents/skills is still refused', () => { + const { f, source } = freshClone('project/vendor/gstack'); + const project = join(f.dir, 'project'); + fixtureMkdirSync(join(source, '.agents/skills'), { recursive: true }); + fixtureMkdirSync(join(project, '.claude/skills'), { recursive: true }); + fixtureMkdirSync(join(project, '.agents')); + symlinkSync(source, join(project, '.claude/skills/gstack'), 'dir'); + symlinkSync(join(source, '.agents/skills'), join(project, '.agents/skills'), 'dir'); + const before = tree(f.dir); + const result = spawnSync('bash', [join(project, '.claude/skills/gstack/setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], { + cwd: project, env: f.env, encoding: 'utf8', timeout: 60_000, input: '', + }); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stderr).toContain(`overlaps the source tree at ${source}`); + expect(tree(f.dir)).toEqual(before); + }, 90_000); + + test('the checkout itself is still refused when its .agents/skills aliases the source root', () => { + const { f, source } = freshClone(); + fixtureMkdirSync(join(source, '.claude/skills'), { recursive: true }); + fixtureMkdirSync(join(source, '.agents')); + symlinkSync(source, join(source, '.claude/skills/gstack'), 'dir'); + symlinkSync(source, join(source, '.agents/skills'), 'dir'); + const before = tree(f.dir); + const result = spawnSync('bash', [join(source, '.claude/skills/gstack/setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], { + cwd: source, env: f.env, encoding: 'utf8', timeout: 60_000, input: '', + }); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stderr).toContain('overlaps the source tree'); + expect(tree(f.dir)).toEqual(before); + }, 90_000); + + test('setup --host codex from the checkout\'s own layout is still refused by the Codex preflight, without writes', () => { + const { f, source } = freshClone(); + const devSetup = spawnSync('bash', [join(source, 'bin/dev-setup')], { cwd: source, env: f.env, encoding: 'utf8', timeout: 120_000, input: '' }); + expect(devSetup.status, devSetup.stdout + devSetup.stderr).toBe(0); + const before = tree(f.dir); + const result = spawnSync('bash', [join(source, '.claude/skills/gstack/setup'), '--host', 'codex', '--no-team', '--no-plan-tune-hooks', '--no-timeline-stop-hook'], { + cwd: source, env: f.env, encoding: 'utf8', timeout: 60_000, input: '', + }); + expect(result.status, result.stdout + result.stderr).toBe(1); + expect(result.stderr).toContain(`Refusing: Codex sidecar root write overlaps source or escapes its namespace: ${join(source, '.agents/skills/gstack')}`); + expect(tree(f.dir)).toEqual(before); + }, 240_000); +});