fix(setup): never link over, copy over, or reap a skill gstack does not own (#2119)

The relink gate alone left three destructive sites open:

- link_claude_skill_dirs runs BEFORE relink on every ./setup and used
  `ln -snf` (Linux replaces a user's real SKILL.md with a symlink into
  gstack) or, on Windows, rm -rf + cp followed by a marker that made the
  user's directory "ours" on the next flip. It and _install_alias_skill_md
  now consult _claude_entry_is_ours first and skip loudly.
- cleanup_prefixed_claude_symlinks kept a bare name-match deletion and a
  `*gstack*` substring match. Symlink arms use anchored `gstack/` segment
  patterns; the Windows real-file arm proves provenance (marker,
  byte-identity with our source, or the full two-line gen-skill-docs banner
  within the first 40 lines, never a one-line substring another generator
  could emit). cleanup_old_claude_symlinks uses the same banner rule.
- gstack-relink's fast path judged absolute targets before canonicalizing,
  so `/x/gstack/../foreign/SKILL.md` counted as ours; dot-segment targets
  now canonicalize first. Its banner rule matches setup's.

The `.gstack-owned` marker records the owning payload's realpath. Entries
skipped by setup or relink are listed in the final setup summary.

Chromium bootstrap refinements from the pre-landing review: an INT/TERM
trap kills the installer's process tree; the Windows npm chain no longer
masks an install failure; GSTACK_SKIP_PLAYWRIGHT=1 is reported as a choice
rather than a failure and sends no telemetry; the timeout knob is
normalized (0, 000, non-numeric, or more than nine digits fall back to the
600s default instead of killing on the first poll or never killing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-04 17:28:00 +00:00
co-authored by Claude Fable 5.1
parent b8d347df35
commit 2548634d96
6 changed files with 949 additions and 51 deletions
+29 -2
View File
@@ -24,7 +24,7 @@ function extractFn(name: string): string {
}
function cleanupBody(): string {
return extractFn('cleanup_old_claude_symlinks');
return extractFn('_gstack_generated_header') + extractFn('cleanup_old_claude_symlinks');
}
describe('setup: cleanup_old_claude_symlinks — static (#2204)', () => {
@@ -68,6 +68,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
const script = [
'set -e',
`IS_WINDOWS=${opts.isWindows ?? '0'}`,
extractFn('_gstack_generated_header'),
extractFn('cleanup_old_claude_symlinks'),
`cleanup_old_claude_symlinks "${gstackArg}" "${skills}"`,
].join('\n');
@@ -264,7 +265,7 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
// marker, a byte-identical copy of the payload source, or gen-skill-docs'
// AUTO-GENERATED header (legacy copies made before the marker existed).
test('Windows real-file leftover is removed only when provably gstack-owned', () => {
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n# ship\n';
const generated = '---\nname: ship\n---\n<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->\n<!-- Regenerate: bun run gen:skill-docs -->\n# ship\n';
const r = runCleanup({
isWindows: '1',
payload: true,
@@ -296,4 +297,30 @@ describe.skipIf(process.platform === 'win32')('setup: cleanup_old_claude_symlink
fs.rmSync(r.tmp, { recursive: true, force: true });
}
});
// The Windows arm is the ONLY path that touches a real-file SKILL.md. On
// Unix a same-name real-file skill must survive even when the payload names
// it and even when its bytes are identical to the payload source.
test('Unix (IS_WINDOWS=0): a same-name real-file skill is never reaped, even if byte-identical to the payload', () => {
const r = runCleanup({
isWindows: '0',
payload: true,
plant(skills, payload) {
fs.mkdirSync(path.join(payload, 'qa'));
fs.writeFileSync(path.join(payload, 'qa', 'SKILL.md'), '---\nname: qa\n---\n');
fs.mkdirSync(path.join(skills, 'qa'));
fs.copyFileSync(path.join(payload, 'qa', 'SKILL.md'), path.join(skills, 'qa', 'SKILL.md'));
fs.mkdirSync(path.join(skills, 'ship'));
fs.writeFileSync(path.join(skills, 'ship', 'SKILL.md'), '---\nname: ship\n---\n');
fs.writeFileSync(path.join(skills, 'ship', '.gstack-owned'), '');
},
});
try {
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
expect(r.names).toEqual(['gstack', 'qa', 'ship']);
} finally {
fs.rmSync(r.tmp, { recursive: true, force: true });
}
});
});