/** * Windows re-run refresh (#2444). * * On Windows, _link_or_copy installs REAL directory copies (no Developer * Mode symlinks). The skill-linking guards `[ -L "$target" ] || [ ! -e * "$target" ]` in link_codex_skill_dirs / link_factory_skill_dirs / * link_opencode_skill_dirs / create_agents_sidecar therefore skipped every * re-run: `./setup --host codex` reported "gstack ready" but never refreshed * an already-installed SKILL.md after `git pull`. The fix bypasses the guard * when IS_WINDOWS=1 — _link_or_copy rm -rf's the destination first, so the * copy refreshes in place. * * The behavior fixture drives the REAL link_codex_skill_dirs / * create_agents_sidecar functions (extracted from setup) against a fake * install tree; the static block pins the bypass at all five guard sites so * factory/opencode can't silently regress. */ import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; const ROOT = path.resolve(import.meta.dir, '..'); const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); function extractFn(name: string): string { const start = SETUP_SRC.indexOf(`${name}() {`); const end = SETUP_SRC.indexOf('\n}\n', start); if (start < 0 || end < 0) throw new Error(`Could not locate ${name}() in setup`); return SETUP_SRC.slice(start, end + 2); } const WINDOWS_BYPASS = '[ "$IS_WINDOWS" -eq 1 ] || [ -L '; describe('setup: Windows re-run refresh — static guard sites (#2444)', () => { test('no install guard is missing the IS_WINDOWS bypass', () => { // Every `[ -L ...] || [ ! -e ...]` refresh guard in setup must carry the // bypass — a bare guard is a Windows re-run no-op waiting to happen. const bareGuards = SETUP_SRC .split('\n') .filter((l) => /\[ -L "\$[A-Za-z_/${}.]+" \] \|\| \[ ! -e /.test(l) && !l.includes('IS_WINDOWS')); expect(bareGuards).toEqual([]); expect(SETUP_SRC.split(WINDOWS_BYPASS).length - 1).toBeGreaterThanOrEqual(5); }); test.each([ 'link_codex_skill_dirs', 'link_factory_skill_dirs', 'link_opencode_skill_dirs', 'link_cursor_skill_dirs', 'create_agents_sidecar', 'create_cursor_sidecar', ])('%s bypasses the symlink-or-missing guard on Windows', (fn) => { expect(extractFn(fn)).toContain(WINDOWS_BYPASS); }); // #2142 ownership census: the Windows bypass rm -rf's real dirs, so every // skill-dir installer must gate the replacement on provable gstack // ownership, and every sidecar/runtime-root installer must refuse a // user-owned root. A bypass without its gate deletes user data. test.each([ 'link_codex_skill_dirs', 'link_factory_skill_dirs', 'link_opencode_skill_dirs', 'link_cursor_skill_dirs', ])('%s gates the Windows real-dir replacement on _owned_for_windows_refresh', (fn) => { expect(extractFn(fn)).toContain('_owned_for_windows_refresh "$target"'); }); test.each([ 'create_agents_sidecar', 'create_cursor_sidecar', 'create_cursor_runtime_root', ])('%s refuses a user-owned root via _sidecar_root_user_owned', (fn) => { expect(extractFn(fn)).toContain('_sidecar_root_user_owned'); }); }); describe('setup: Windows refresh ownership gate — behavior fixture (#2142)', () => { test("IS_WINDOWS=1: a user's own real dir on a gstack* name survives; a bannered install refreshes", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-owned-')); try { const fake = path.join(tmp, 'gstack'); const skills = path.join(tmp, 'skills'); const banner = '\n'; // Generated tree ships two skills. for (const name of ['gstack-demo', 'gstack-notes']) { const d = path.join(fake, '.agents', 'skills', name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'SKILL.md'), `${banner}upstream-v2\n`); } fs.mkdirSync(skills, { recursive: true }); // gstack-demo: a prior gstack install (bannered) — must refresh. fs.mkdirSync(path.join(skills, 'gstack-demo'), { recursive: true }); fs.writeFileSync(path.join(skills, 'gstack-demo', 'SKILL.md'), `${banner}installed-v1\n`); // gstack-notes: the USER'S own hand-written skill — must survive. fs.mkdirSync(path.join(skills, 'gstack-notes'), { recursive: true }); fs.writeFileSync(path.join(skills, 'gstack-notes', 'SKILL.md'), '# my own notes\n'); const r = runInstaller( '1', ['_owned_for_windows_refresh', 'link_codex_skill_dirs'], `link_codex_skill_dirs "${tmp}/gstack" "${skills}"`, ); expect(r.status).toBe(0); expect(fs.readFileSync(path.join(skills, 'gstack-demo', 'SKILL.md'), 'utf-8')).toContain('upstream-v2'); expect(fs.readFileSync(path.join(skills, 'gstack-notes', 'SKILL.md'), 'utf-8')).toBe('# my own notes\n'); expect(r.stderr).toContain('left in place'); expect(r.stderr).toContain('gstack-notes'); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); test('IS_WINDOWS=1: create_agents_sidecar refuses a user-owned root and writes nothing into it', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-owned-sidecar-')); try { const fake = path.join(tmp, 'gstack'); fs.mkdirSync(path.join(fake, 'bin'), { recursive: true }); fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v1\n'); // The user's own skill squats on .agents/skills/gstack. const root = path.join(fake, '.agents', 'skills', 'gstack'); fs.mkdirSync(root, { recursive: true }); fs.writeFileSync(path.join(root, 'SKILL.md'), '# hand-written\n'); const vars = `SOURCE_GSTACK_DIR="${fake}"`; const r = runInstaller( '1', ['_sidecar_root_user_owned', 'create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars, ); expect(r.status).toBe(0); expect(r.stderr).toContain('left in place'); expect(fs.existsSync(path.join(root, 'bin'))).toBe(false); expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf-8')).toBe('# hand-written\n'); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); }); interface RunResult { status: number | null; stdout: string; stderr: string; } /** Run the extracted installer functions against a fake tree. */ function runInstaller( isWindows: '0' | '1', fns: string[], invocation: string, extraVars = '', ): RunResult { const script = [ 'set -e', `IS_WINDOWS=${isWindows}`, extraVars, extractFn('_link_or_copy'), // Ownership gates (#2142) — dependencies of every installer under test. extractFn('_owned_for_windows_refresh'), extractFn('_sidecar_root_user_owned'), ...fns.map(extractFn), invocation, ].join('\n'); const r = spawnSync('bash', ['-c', script], { encoding: 'utf-8', timeout: 15_000 }); return { status: r.status, stdout: r.stdout, stderr: r.stderr }; } describe('setup: Windows re-run refresh — behavior fixture (#2444)', () => { test('IS_WINDOWS=1: link_codex_skill_dirs refreshes an already-installed skill', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-')); try { const fake = path.join(tmp, 'gstack'); const skills = path.join(tmp, 'skills'); const demo = path.join(fake, '.agents', 'skills', 'gstack-demo'); fs.mkdirSync(demo, { recursive: true }); fs.mkdirSync(skills, { recursive: true }); // Generated SKILL.md files always carry the banner — the #2142 // ownership gate keys the Windows refresh on it. const banner = '\n'; fs.writeFileSync(path.join(demo, 'SKILL.md'), `${banner}v1-original\n`); // First run: installs the copy. let r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); expect(r.status).toBe(0); const installed = path.join(skills, 'gstack-demo', 'SKILL.md'); expect(fs.readFileSync(installed, 'utf-8')).toBe(`${banner}v1-original\n`); expect(fs.lstatSync(path.join(skills, 'gstack-demo')).isSymbolicLink()).toBe(false); // Upstream ships a change (the git pull). fs.writeFileSync(path.join(demo, 'SKILL.md'), `${banner}v2-UPDATED\n`); // Second run: pre-#2444 this was a silent no-op on Windows. r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); expect(r.status).toBe(0); expect(fs.readFileSync(installed, 'utf-8')).toBe(`${banner}v2-UPDATED\n`); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); test('IS_WINDOWS=1: create_agents_sidecar refreshes copied runtime assets', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-sidecar-')); try { const fake = path.join(tmp, 'gstack'); fs.mkdirSync(path.join(fake, 'bin'), { recursive: true }); fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v1\n'); fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v1\n'); const vars = `SOURCE_GSTACK_DIR="${fake}"`; let r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars); expect(r.status).toBe(0); const sidecarBin = path.join(fake, '.agents', 'skills', 'gstack', 'bin', 'tool.sh'); const sidecarEthos = path.join(fake, '.agents', 'skills', 'gstack', 'ETHOS.md'); expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v1\n'); expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v1\n'); fs.writeFileSync(path.join(fake, 'bin', 'tool.sh'), 'v2\n'); fs.writeFileSync(path.join(fake, 'ETHOS.md'), 'ethos-v2\n'); r = runInstaller('1', ['create_agents_sidecar'], `create_agents_sidecar "${fake}"`, vars); expect(r.status).toBe(0); expect(fs.readFileSync(sidecarBin, 'utf-8')).toBe('v2\n'); expect(fs.readFileSync(sidecarEthos, 'utf-8')).toBe('ethos-v2\n'); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); test('IS_WINDOWS=1: nested gitignored build output does NOT survive the runtime-asset copy (P5)', () => { // The exclusion list in _link_skill_runtime_assets filters direct // children only; cp -R swept NESTED node_modules/.build/dist too // (concrete: ios-qa/scripts/gen-accessors-tool/.build, 252MB). The // Windows branch prunes them post-copy; real asset files at every level // survive. const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-prune-')); try { const src = path.join(tmp, 'skill-src'); const dst = path.join(tmp, 'skill-dst'); fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', '.build'), { recursive: true }); fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', 'node_modules', 'dep'), { recursive: true }); fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', 'dist'), { recursive: true }); fs.mkdirSync(dst, { recursive: true }); fs.writeFileSync(path.join(src, 'scripts', 'runner.sh'), 'echo run\n'); fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', 'main.swift'), 'source\n'); fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'), '#'.repeat(4096)); fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', 'node_modules', 'dep', 'index.js'), 'x\n'); fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', 'dist', 'compiled'), 'bin\n'); const r = runInstaller( '1', ['_link_skill_runtime_assets'], `_link_skill_runtime_assets "${src}" "${dst}"`, ); expect(r.status).toBe(0); // Real assets at both levels survive… expect(fs.readFileSync(path.join(dst, 'scripts', 'runner.sh'), 'utf-8')).toBe('echo run\n'); expect(fs.readFileSync(path.join(dst, 'scripts', 'gen-tool', 'main.swift'), 'utf-8')).toBe('source\n'); // …nested build output does not. expect(fs.existsSync(path.join(dst, 'scripts', 'gen-tool', '.build'))).toBe(false); expect(fs.existsSync(path.join(dst, 'scripts', 'gen-tool', 'node_modules'))).toBe(false); expect(fs.existsSync(path.join(dst, 'scripts', 'gen-tool', 'dist'))).toBe(false); // The source tree is untouched — the prune runs on the COPY only. expect(fs.existsSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'))).toBe(true); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); test('IS_WINDOWS=0: the Unix symlink path never prunes through into the source', () => { // On Unix the asset is a SYMLINK into the working tree; pruning through // it would delete real build output from the repo. The prune is gated on // the Windows real-copy shape ([ -d ] && [ ! -L ]). // Not runnable ON Windows: this sub-case models the UNIX shape, but Git // Bash's `ln -snf` produces a real copy there (no Developer Mode on CI), // so the symlink assertion is false by platform, not by regression. The // Unix lanes (macOS dev boxes + Linux CI) own this case. if (process.platform === 'win32') return; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-prune-unix-')); try { const src = path.join(tmp, 'skill-src'); const dst = path.join(tmp, 'skill-dst'); fs.mkdirSync(path.join(src, 'scripts', 'gen-tool', '.build'), { recursive: true }); fs.mkdirSync(dst, { recursive: true }); fs.writeFileSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'), 'keep'); const r = runInstaller( '0', ['_link_skill_runtime_assets'], `_link_skill_runtime_assets "${src}" "${dst}"`, ); expect(r.status).toBe(0); expect(fs.lstatSync(path.join(dst, 'scripts')).isSymbolicLink()).toBe(true); expect(fs.existsSync(path.join(src, 'scripts', 'gen-tool', '.build', 'blob.bin'))).toBe(true); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); test('IS_WINDOWS=1: the gstack sidecar dir is still skipped by the skill loop', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-skip-')); try { const fake = path.join(tmp, 'gstack'); const skills = path.join(tmp, 'skills'); const sidecar = path.join(fake, '.agents', 'skills', 'gstack'); fs.mkdirSync(sidecar, { recursive: true }); fs.mkdirSync(skills, { recursive: true }); fs.writeFileSync(path.join(sidecar, 'SKILL.md'), 'sidecar\n'); const r = runInstaller('1', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); expect(r.status).toBe(0); expect(fs.existsSync(path.join(skills, 'gstack'))).toBe(false); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); }); // On real Windows, `ln -snf` under Git Bash silently produces copies, so the // Unix-mode symlink assertions are meaningless there — the same skip the // _link_or_copy behavior matrix uses (test/setup-windows-fallback.test.ts). describe.skipIf(process.platform === 'win32')( 'setup: Unix path unchanged by the #2444 bypass', () => { test('IS_WINDOWS=0: installs a symlink and re-runs still refresh through it', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-rerun-unix-')); try { const fake = path.join(tmp, 'gstack'); const skills = path.join(tmp, 'skills'); const demo = path.join(fake, '.agents', 'skills', 'gstack-demo'); fs.mkdirSync(demo, { recursive: true }); fs.mkdirSync(skills, { recursive: true }); fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v1-original\n'); let r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); expect(r.status).toBe(0); const target = path.join(skills, 'gstack-demo'); expect(fs.lstatSync(target).isSymbolicLink()).toBe(true); // A symlink serves updates without any re-run at all… fs.writeFileSync(path.join(demo, 'SKILL.md'), 'v2-UPDATED\n'); expect(fs.readFileSync(path.join(target, 'SKILL.md'), 'utf-8')).toBe('v2-UPDATED\n'); // …and the re-run keeps it a symlink (guard still passes via -L). r = runInstaller('0', ['link_codex_skill_dirs'], `link_codex_skill_dirs "${fake}" "${skills}"`); expect(r.status).toBe(0); expect(fs.lstatSync(target).isSymbolicLink()).toBe(true); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); }, );