Skip to content

fix(setup): refuse to replace a runtime root that holds the source checkout (#3084) - #3128

Closed
szsunyuan wants to merge 1 commit into
garrytan:mainfrom
szsunyuan:fix/setup-runtime-root-holds-source
Closed

szsunyuan wants to merge 1 commit into
garrytan:mainfrom
szsunyuan:fix/setup-runtime-root-holds-source

Conversation

@szsunyuan

@szsunyuan szsunyuan commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Why (in your own words)

If your gstack checkout lives in a host's skills directory, such as ~/.cursor/skills/gstack (the reporter's checkout, set up on 1.31), ./setup --host cursor deletes it. That is the exact command ./setup --status suggests for such an install ("register it: cd ~/.cursor/skills/gstack && ./setup --host cursor"). _activate_runtime_root builds the new runtime root beside the old one, moves the old root aside and deletes it with rm -rf. When the old root is the checkout, the checkout goes with it, along with every host that pointed at it (the reporter's ~/.claude/skills/gstack symlink broke too). The install summary still says "its previous install was left in place", and its retry command points at a setup that no longer exists.

It is not Cursor-only. Every arm that swaps its root through _activate_runtime_root with no source check of its own has it: Cursor, Factory, OpenCode, Copilot and agy. (Codex moves such a checkout out first.) The Copilot and agy arms check _sidecar_root_user_owned first, but a checkout's own root SKILL.md carries the AUTO-GENERATED from banner, so that check passes it through. On those two hosts the run even reports installed and exits 0.

Fixes #3084.

Why this shape

This is @andrewkjankowski's first suggested fix: "Refuse in _activate_runtime_root, or in the Cursor arm, when the resolved runtime root equals or contains SOURCE_GSTACK_DIR, as the Codex arm does." It goes in _activate_runtime_root, not the Cursor arm, because all five arms share the swap.

The check mirrors the Codex refusal at setup:568-576: skip a symlinked root, resolve with pwd -P, then case "$SOURCE" in "$runtime"/*. It also matches equality, and it resolves the source as well. Like the function's other error paths it returns 1 before anything moves, so the summary's "left in place" is now true.

The tests extend the two harnesses that already cover this function and these hosts. test/setup-skill-copies.test.ts slices _activate_runtime_root out of setup, and test/host-conformance.test.ts runs the real ./setup in a clean HOME.

CONTRIBUTING's wave rule: if two PRs fix the same thing, keep the smaller one. This is 4 files on current main (one function in setup, two test files, one troubleshooting entry), and fine to absorb into a wave.

Not followed:

  • The issue's alternative fix: moving the checkout to ~/.gstack/repos/gstack, the way migrate_direct_codex_install does for Codex. Moving a user's checkout is a product call, and fix(setup): relocate direct Claude install out of the skill-discovery root (#1202, #1694) #1819 was closed in favor of fixing the install footprint "without relocating the install".
  • The issue's second and third bullets (the --status hint and the failure-summary wording), and its note about unprefixed Cursor folders. They are real but separate; see below.

What it does

  • setup, _activate_runtime_root: before the leftover sweep and the staged build, it checks the root. If the root is a real directory (not a symlink) and its resolved path is, or contains, the resolved source, it prints refusing to replace <root>: it holds the gstack checkout setup is running from (<source>)… and returns 1. The function's header comment says why.
  • test/setup-skill-copies.test.ts, refusal: three layouts are refused with the checkout unchanged. The root is the checkout, contains it, or is the checkout reached through a symlinked parent directory.
  • test/setup-skill-copies.test.ts, control: a root that is a symlink to the checkout is still replaced, as it is today, and the checkout it pointed at survives. Only the link is removed.
  • test/host-conformance.test.ts: one case per installable host, run from a checkout at that host's runtime root. The real ./setup --host <host> exits 1 with the refusal, and the checkout keeps every entry and its setup. Three hosts are excluded:
    • Claude, whose checkout location is its standard install.
    • Codex, which moves such a checkout to ~/.gstack/repos/gstack.
    • Kiro, which builds its root in place instead of swapping it.
  • docs/troubleshooting.md: an entry for the new message, next to the entry for this function's other error (could not back up an edited SKILL.md copy).

What it deliberately does not do

  • No relocation or migration of the checkout (above).
  • No change to the --status hint (bin/gstack-install-registry.sh:272). It still suggests the command, which now gets a refusal that says what to do, instead of losing the checkout.
  • No change to the failure summary's retry line, which still repeats the refused command.
  • No cleanup of the old unprefixed Cursor folders (the issue's "Related" section).
  • No change to the Kiro arm (see "Found and left alone"), the Codex arm or any skill template; no regenerated files.
  • VERSION / CHANGELOG / README / ETHOS untouched; /ship not run (the wave stamps the version).

AI was used for assistance.

Live evidence

Isolation: each run gets a fresh temp HOME with the revision's tree (git archive) at the host's runtime root. The compiled binaries are stubbed, bun run build is refused, and GSTACK_SKIP_PLAYWRIGHT / GSTACK_SETUP_SKIP_CSO_BUILD / GSTACK_SKIP_FONTS are set, with --no-plan-tune-hooks, the way test/helpers/install-fixture.ts runs setup. The exit= lines are the run's exit status. Paths are shortened to ~, and trimmed output is marked ....

Before (upstream/main at 5d647455f):

$ ls -A ~/.cursor/skills/gstack | wc -l
110
$ cd ~/.cursor/skills/gstack && ./setup --host cursor --no-plan-tune-hooks
...
error: Script not found "gen:skill-docs"
  cursor    experimental     global   failed     1.91.79.0                   ~/.cursor/skills  (source: ~/.cursor/skills/gstack)
            setup stopped (exit 1) while installing cursor; its previous install was left in place. Retry: cd ~/.cursor/skills/gstack && ./setup --host cursor
exit=1
$ ls -A ~/.cursor/skills/gstack
.source-path ETHOS.md SKILL.md VERSION bin browse careful design design-html docs freeze gstack-upgrade lib make-pdf office-hours plan-design-review plan-devex-review review scripts supabase
$ test -f ~/.cursor/skills/gstack/setup && test -f ~/.cursor/skills/gstack/VERSION && echo 'checkout intact' || echo 'checkout gone'
checkout gone

# the same layout under Copilot, in a fresh HOME
$ cd ~/.copilot/skills/gstack && ./setup --host copilot --no-plan-tune-hooks
...
  copilot   experimental     global   installed  1.91.79.0                   ~/.copilot/skills  (source: ~/.copilot/skills/gstack)
exit=0
$ test -f ~/.copilot/skills/gstack/setup && test -f ~/.copilot/skills/gstack/VERSION && echo 'checkout intact' || echo 'checkout gone'
checkout gone

After (this branch, 4c08ce486):

$ ls -A ~/.cursor/skills/gstack | wc -l
110
$ cd ~/.cursor/skills/gstack && ./setup --host cursor --no-plan-tune-hooks
...
  error: refusing to replace ~/.cursor/skills/gstack: it holds the gstack checkout setup is running from (~/.cursor/skills/gstack). The checkout is unchanged; move it out of ~/.cursor/skills/gstack and run setup from there.
  cursor    experimental     global   failed     1.91.79.0                   ~/.cursor/skills  (source: ~/.cursor/skills/gstack)
            setup stopped (exit 1) while installing cursor; its previous install was left in place. Retry: cd ~/.cursor/skills/gstack && ./setup --host cursor
exit=1
$ ls -A ~/.cursor/skills/gstack
.cursor .env.example .gitattributes .github .gitignore .gitlab-ci.yml .gstack .gstack-installed-hosts .osv-scanner.toml AGENTS.md ...
$ test -f ~/.cursor/skills/gstack/setup && test -f ~/.cursor/skills/gstack/VERSION && echo 'checkout intact' || echo 'checkout gone'
checkout intact

# the same layout under Copilot, in a fresh HOME
$ cd ~/.copilot/skills/gstack && ./setup --host copilot --no-plan-tune-hooks
...
  error: refusing to replace ~/.copilot/skills/gstack: it holds the gstack checkout setup is running from (~/.copilot/skills/gstack). The checkout is unchanged; move it out of ~/.copilot/skills/gstack and run setup from there.
  copilot   experimental     global   failed     1.91.79.0                   ~/.copilot/skills  (source: ~/.copilot/skills/gstack)
            setup stopped (exit 1) while installing copilot; its previous install was left in place. Retry: cd ~/.copilot/skills/gstack && ./setup --host copilot
exit=1
$ test -f ~/.copilot/skills/gstack/setup && test -f ~/.copilot/skills/gstack/VERSION && echo 'checkout intact' || echo 'checkout gone'
checkout intact

Scope

  • Changed: setup (+13/−2), test/setup-skill-copies.test.ts (+49), test/host-conformance.test.ts (+19), docs/troubleshooting.md (+11). No VERSION, no CHANGELOG, no generated files.
  • Verified live by:
    • New tests fail on upstream/main (the "red on tip" check), with the branch's test files copied into a clean worktree:
      • All 5 host cases fail. Cursor, Factory and OpenCode print no refusal and lose the checkout; Copilot and agy exit 0.
      • The function-level refusal case fails.
      • The symlinked-root control passes, as it should.
    • The same tests pass on this branch.
    • Mutations, each one caught:
      • Dropping the symlink exemption: the control fails.
      • Matching equality only: the nested layout fails.
      • Comparing the unresolved root: the linked-parent layout fails.
      • Returning 0: the refusal case fails.
    • Every test file that runs setup or slices functions out of it (61 files, each in its own process): 1,430 pass, 1 fail. The failure is the pre-existing C3 ordering case described below; with LC_ALL=C that file passes 4 of 4.
    • bun run typecheck is clean, and bun run typecheck:test reports "865 known diagnostics, none new".
    • The full bun run test is not green on this Linux box, on upstream/main either. Chromium can't launch here, there is no Xvfb, and the /cso suites need bun run build:cso and Docker.
      • It ran as 16 concurrent shards while another workload shared the machine, and failed 293 tests in 51 files.
      • Those files: 25 browse, 20 /cso, design/test/feedback-roundtrip, gstack-capy-install (exit 127: a tool this box lacks), office-hours-attempt, qa-only-cleanup, test-free-shards-settlement-lifecycle, and the C3 locale case.
      • I re-ran exactly those 51 files one at a time, on upstream/main and on this branch: 122 and 121 failures. No failure occurs only on this branch; the extra one on upstream/main is a flaky browser-settlement case.
      • The 12 files that failed only under that load pass when run alone on this branch.
      • CI's Linux free suite is the gate.
    • bin/gstack-redact --repo-visibility public on the diff reports one MEDIUM pii.ip_public. It is on an unchanged context line (toBe('1.91.12.0'), a version string); the added lines alone scan clean.
    • slop-scan on upstream/main and on this branch: 640 findings each, none in the changed files.
    • bun run eval:select --base upstream/main puts this diff on the full-fallback profile, because setup is a shared runtime input. Those paid lanes need secrets a fork PR doesn't get.
  • Did NOT test:
    • The Cursor, Factory, OpenCode, Copilot and agy apps themselves; the bug is entirely in setup's file layout.
    • macOS.
    • Windows. Both new tests are skipIf(win32), like the rest of their files.

Found and left alone:

  • Kiro with the same layout. It doesn't delete the checkout, but ./setup --host kiro links bin, lib, browse/dist and browse/bin onto themselves inside it (bin/bin -> ~/.kiro/skills/gstack/bin). It then stops with ln: '…/ETHOS.md' and '…/ETHOS.md' are the same file (exit 1). That is a different mechanism (Kiro builds its root in place), so it is not in this PR.
  • The existing C3 case in test/setup-skill-copies.test.ts. It fails under a non-C collation (en_US.UTF-8 here), on upstream/main too, because _record_skill_copies lists gstack-upgrade/SKILL.md SKILL.md in that order. It passes with LC_ALL=C, and CI runs C.UTF-8.

Liveness proof (required for external contributors)

image

Checklist

Test plan

  • bun test test/setup-skill-copies.test.ts test/host-conformance.test.ts -t 3084: red on upstream/main, green here
  • Mutation checks and every test file that runs or slices setup (above)
  • Full bun run test: the same environment failures as upstream/main, none only on this branch
  • CI free tests on this PR (both new tests run on the Linux lane)

Fork PRs do not get eval secrets; the free file above is the gate.

Fixes #3084

…eckout (garrytan#3084)

An older install can keep its gstack checkout where a host's runtime root
goes, such as ~/.cursor/skills/gstack, and ./setup --status tells that user
to run ./setup --host cursor from there. _activate_runtime_root then moved
the checkout aside, swapped the new runtime root in and deleted the old one.
The checkout and every host that pointed at it were gone, while the summary
said the previous install was left in place. Cursor, Factory, OpenCode,
Copilot and agy all activate through it; Copilot and agy even reported
"installed" and exited 0.

Refuse before anything moves when the root's resolved path is, or contains,
the resolved source, as the Codex arm already refuses a runtime directory
that contains the source checkout. A symlinked root still loses only the
link, so older symlinked installs keep upgrading. The new message gets a
troubleshooting entry beside this function's other error, and the real
./setup is tested from a checkout at each of the five hosts' runtime roots.

Fixes garrytan#3084

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Oct 11, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

garrytan added a commit that referenced this pull request Oct 11, 2026
… checkout; pin the symlinked-root control (#3128)

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

capy-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Superseded by #3140, merged in v1.91.89.0 (e6edff0). Setup refuses to replace a runtime root that is or contains its checkout, on every host that swaps a root, Kiro included. You confirmed #3128's five end-to-end layouts are all refused there. Credited in CHANGELOG (Thanks @szsunyuan (#3108, #3128)) and as co-author on the merged commit. Thanks!

@capy-ai capy-ai Bot closed this Oct 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

setup --host cursor deletes the gstack checkout when it lives at ~/.cursor/skills/gstack

1 participant