fix(setup): Windows refresh bypass no longer deletes a user's own skill dirs

The #2444 IS_WINDOWS refresh bypass (link_codex/factory/opencode/cursor
_skill_dirs) rm -rf's the destination before re-copying — and the host
skills dirs are SHARED namespaces, so the gstack* glob can land on a
user's OWN real directory (e.g. ~/.cursor/skills/gstack-notes). Every
./setup re-run silently deleted it — the ownership guard the comments
still claimed (#2142). The sidecar installers had the same shape against
a hand-written skill squatting on the canonical .../skills/gstack root,
and create_cursor_runtime_root wiped that root unconditionally on every
platform.

Same provenance model as bin/gstack-uninstall (#2563):

- _owned_for_windows_refresh: a real dir is only replaced when its
  SKILL.md carries the AUTO-GENERATED banner; symlinks and missing
  targets always pass. Non-matching dirs are kept and listed to stderr.
  Wired into all four *_skill_dirs loops.
- _sidecar_root_user_owned: a root whose SKILL.md exists WITHOUT the
  banner is the user's — create_agents_sidecar, create_cursor_sidecar,
  and create_cursor_runtime_root skip it entirely instead of writing
  into (or wiping) someone else's skill. A root with no SKILL.md stays
  presumed ours (the documented install location; old/partial installs
  look like that).

Pinned by a static census (every bypass site must carry its gate) plus
behavior fixtures: a bannerless user dir survives the Windows re-run
while a bannered install still refreshes, and a squatted sidecar root is
left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-16 14:13:16 -07:00
co-authored by Claude Fable 5
parent 8c1192d0dd
commit 77374d1f0e
2 changed files with 180 additions and 14 deletions
+85 -10
View File
@@ -104,6 +104,38 @@ _link_or_copy() {
fi
}
# ─── Ownership gates for the Windows refresh bypass (#2444 → #2142) ─────────
# On Windows a refresh means rm -rf + re-copy (_link_or_copy). The host
# skills dirs are SHARED namespaces (~/.codex/skills, ~/.factory/skills,
# ~/.cursor/skills, ...), so a gstack* glob name can collide with a user's
# OWN real directory (e.g. ~/.cursor/skills/gstack-notes) — deleting it on
# every ./setup re-run is silent data loss. Mirror of bin/gstack-uninstall's
# provenance gate (#2563): an existing REAL skill dir may only be replaced
# when its SKILL.md carries the generated banner. Missing targets and
# symlinks always pass (replacing a link never destroys content); non-dir
# targets pass (file targets live inside gstack-owned roots).
_owned_for_windows_refresh() {
local dst="$1"
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then return 0; fi
if [ -L "$dst" ]; then return 0; fi
if [ ! -d "$dst" ]; then return 0; fi
grep -q '<!-- AUTO-GENERATED from' "$dst/SKILL.md" 2>/dev/null
}
# A sidecar/runtime ROOT (…/skills/gstack) is provably USER-owned when it is
# a real dir whose SKILL.md exists but lacks the generated banner — a
# hand-written skill squatting on the canonical name. The sidecar installers
# skip it entirely rather than write into (or wipe) someone else's skill.
# A root with NO SKILL.md stays presumed ours: it is the documented gstack
# install location and old/partial installs legitimately look like that.
_sidecar_root_user_owned() {
local root="$1"
[ -d "$root" ] || return 1
[ -L "$root" ] && return 1
[ -f "$root/SKILL.md" ] || return 1
! grep -q '<!-- AUTO-GENERATED from' "$root/SKILL.md" 2>/dev/null
}
_WINDOWS_COPY_NOTE_PRINTED=0
_print_windows_copy_note_once() {
if [ "$IS_WINDOWS" -eq 1 ] && [ "$_WINDOWS_COPY_NOTE_PRINTED" -eq 0 ]; then
@@ -1012,9 +1044,15 @@ link_codex_skill_dirs() {
# the symlink-or-missing guard skipped every re-run and SKILL.md never
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
# _link_or_copy rm -rf's the destination first, refreshing the copy.
# #2142: a real dir may only be replaced when it is provably ours
# (_owned_for_windows_refresh), never a user's own colliding dir.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
@@ -1030,6 +1068,13 @@ link_codex_skill_dirs() {
create_agents_sidecar() {
local repo_root="$1"
local agents_gstack="$repo_root/.agents/skills/gstack"
# #2142: a hand-written skill squatting on the canonical name is the
# user's — never write into it (the Windows branch would rm -rf its
# subdirs on every re-run).
if _sidecar_root_user_owned "$agents_gstack"; then
echo " left in place (existing dir not gstack-managed — no generated banner): $agents_gstack" >&2
return 0
fi
mkdir -p "$agents_gstack"
# Sidecar directories that skills reference at runtime. bin scripts import
@@ -1274,9 +1319,15 @@ link_factory_skill_dirs() {
# the symlink-or-missing guard skipped every re-run and SKILL.md never
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
# _link_or_copy rm -rf's the destination first, refreshing the copy.
# #2142: a real dir may only be replaced when it is provably ours
# (_owned_for_windows_refresh), never a user's own colliding dir.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
@@ -1310,9 +1361,15 @@ link_opencode_skill_dirs() {
# the symlink-or-missing guard skipped every re-run and SKILL.md never
# refreshed after `git pull`. IS_WINDOWS bypasses the guard —
# _link_or_copy rm -rf's the destination first, refreshing the copy.
# #2142: a real dir may only be replaced when it is provably ours
# (_owned_for_windows_refresh), never a user's own colliding dir.
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
@@ -1333,6 +1390,11 @@ create_cursor_runtime_root() {
if [ -L "$cursor_gstack" ]; then
rm -f "$cursor_gstack"
elif _sidecar_root_user_owned "$cursor_gstack"; then
# #2142: a hand-written skill squatting on the canonical name is the
# user's — never wipe it to make room for the runtime root.
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
return 0
elif [ -d "$cursor_gstack" ] && [ "$cursor_gstack" != "$gstack_dir" ] && [ "$cursor_gstack" != "$generated_root" ]; then
rm -rf "$cursor_gstack"
fi
@@ -1382,6 +1444,14 @@ create_cursor_sidecar() {
local cursor_gstack="$repo_root/.cursor/skills/gstack"
local cursor_dir="$repo_root/.cursor/skills"
# #2142: same user-ownership gate as create_agents_sidecar — but the
# generated tree's own root (cursor_dir/gstack carries the banner) always
# passes, so normal installs refresh as before.
if _sidecar_root_user_owned "$cursor_gstack"; then
echo " left in place (existing dir not gstack-managed — no generated banner): $cursor_gstack" >&2
return 0
fi
mkdir -p "$cursor_gstack" "$cursor_gstack/browse" "$cursor_gstack/gstack-upgrade" "$cursor_gstack/review"
if [ -d "$repo_root/bin" ]; then
@@ -1445,11 +1515,16 @@ link_cursor_skill_dirs() {
[ "$skill_name" = "gstack" ] && continue
target="$skills_dir/$skill_name"
# #2444: IS_WINDOWS bypass — real-dir copies never match -L, so re-runs
# skipped the refresh. Only replace a symlink or a missing path
# otherwise; never an unowned real Cursor skill dir (#2142).
# skipped the refresh. Only replace a symlink, a missing path, or a
# PROVABLY gstack-managed real dir; never a user's own Cursor skill
# dir that merely starts with gstack (#2142).
if [ "$IS_WINDOWS" -eq 1 ] || [ -L "$target" ] || [ ! -e "$target" ]; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
if _owned_for_windows_refresh "$target"; then
_link_or_copy "$skill_dir" "$target"
linked+=("$skill_name")
else
echo " left in place (existing dir not gstack-managed — no generated banner): $target" >&2
fi
fi
fi
done
+95 -4
View File
@@ -54,6 +54,91 @@ describe('setup: Windows re-run refresh — static guard sites (#2444)', () => {
])('%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 = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\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 {
@@ -74,6 +159,9 @@ function runInstaller(
`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');
@@ -90,22 +178,25 @@ describe('setup: Windows re-run refresh — behavior fixture (#2444)', () => {
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');
// Generated SKILL.md files always carry the banner — the #2142
// ownership gate keys the Windows refresh on it.
const banner = '<!-- AUTO-GENERATED from SKILL.md.tmpl - DO NOT EDIT DIRECTLY -->\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('v1-original\n');
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'), 'v2-UPDATED\n');
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('v2-UPDATED\n');
expect(fs.readFileSync(installed, 'utf-8')).toBe(`${banner}v2-UPDATED\n`);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}