fix(#2624): write the .gsd-source marker before staging reads it (#2811)

* test(#2624): failing-first regression for stale .gsd-source marker read-before-write

Adds failing-first regression proving a Claude-global upgrade currently lets
staging read a stale prior-version .gsd-source marker before install() rewrites
it. Pre-seeds a stale marker pointing at a still-existing fake source, spies on
findInstallSourceRoot to capture which source staging resolves, and asserts
staging never resolves the stale path. Also covers fresh-install and ghost-marker
negative space.

* fix(#2624): write the .gsd-source marker before staging reads it

A Claude-global upgrade silently installed skill content from the PREVIOUS
version. findInstallSourceRoot prefers <configDir>/.gsd-source, and install()
used to rewrite that marker AFTER staging had already read it — so on an
upgrade the marker still pointed at the prior version's source (an npx cache
dir that still exists on disk) and every converted skill was generated from
the OLD commands/gsd, with generateManifest then recording the stale content's
hash as correct.

Extract the marker write into _writeGsdSourceMarker(runtime, targetDir, src,
isGlobal) and call it BEFORE the staging pass (before the _isSkillsRuntime
branch), preserving the original sourceMarkerFile && isGlobal guard, the
half-published-package existsSync guard, and the non-fatal write-failure warn.
This closes the read-before-write hole for every findInstallSourceRoot
consumer (skills, commands, /gsd-surface, capability-state) in one move.

Long-standing (marker write added in ee6f3b70c / #1477, already in v1.7.0);
triggers on any Claude-global upgrade where skill content changed and the
prior source path still exists (the common npx-persistent-cache case).

* test(#2624): use t.mock.method for process.exit/console per CONTRIBUTING rules

Address code-review finding: replace manual process.exit/console monkeypatch
and try/finally in the #2624 test helpers with t.mock.method (auto-restored),
matching CONTRIBUTING.md test conventions and the t.mock.method idiom used
elsewhere in the suite.

* docs(changeset): #2624 install-source-root-stale-marker

* docs(changeset): backfill #2624 PR number to 2811
This commit is contained in:
Tom Boucher
2026-07-28 23:09:10 -04:00
committed by GitHub
parent 7f13ee5373
commit 46bae2f9ff
3 changed files with 189 additions and 29 deletions

View File

@@ -10406,6 +10406,45 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
return Array.isArray(scopeLayout) && scopeLayout.length > 0;
})();
// #2624: write the .gsd-source marker. Extracted from its former late position so it can be
// called BEFORE staging reads the marker (see the call site below). Scoped to the Claude-global
// layout (issue #1477) — the only install path that ships the skills layout without a
// commands/gsd source tree, so findInstallSourceRoot's walk-up has nothing to find and
// /gsd-surface (list/status) throws without it. Points at the package's own commands/gsd
// source. Guarded on source presence so a half-published package never writes a dangling
// marker. Write failure is non-fatal (install proceeds; warn so /gsd-surface breakage is
// diagnosable) — the same contract the late write had.
function _writeGsdSourceMarker(runtime, targetDir, src, isGlobal) {
if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
const gsdSourceCommands = path.join(src, 'commands', 'gsd');
if (fs.existsSync(gsdSourceCommands)) {
try {
// ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
// must resolve under targetDir (parity with the other descriptor-driven writes).
const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
} catch (err) {
// Non-fatal: install proceeds. But on the Claude-global layout walk-up
// also fails (no commands/gsd source tree), so a silent write failure
// still leaves /gsd-surface broken at runtime — warn so it's diagnosable.
console.warn(` ${yellow}!${reset} Could not write .gsd-source marker (${err.message}); /gsd-surface list/status may fail`);
}
}
}
}
// #2624: write the .gsd-source marker BEFORE any staging reads it. The marker write
// formerly lived AFTER staging; on an upgrade the marker still held the PREVIOUS
// install's source path (e.g. an npx per-version cache dir that still exists on disk),
// so findInstallSourceRoot(configDir) — called inside installRuntimeArtifacts below —
// returned the stale path and every converted skill was generated from the OLD version's
// commands/gsd, silently installing prior-version content with a self-consistent manifest
// hash. Writing first closes the read-before-write hole for every findInstallSourceRoot
// consumer (skills, commands, /gsd-surface, capability-state). Placed here (before the
// _isSkillsRuntime branch) so it runs for every Claude-global install, matching the
// original write's sourceMarkerFile && isGlobal guard exactly.
_writeGsdSourceMarker(runtime, targetDir, src, isGlobal);
if (_isSkillsRuntime) {
// Layout-driven install for skills-based runtimes (full and minimal modes)
const scope = isGlobal ? 'global' : 'local';
@@ -10694,35 +10733,11 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
failures.push('gsd-core');
}
// Write the .gsd-source marker so runtime source resolution succeeds at
// runtime (#1477). The Claude-global skills layout ships gsd-core/{bin,
// contexts,references,templates,workflows} but NOT the commands/gsd source
// tree, and _runLegacyUninstallCleanup actively removes any commands/gsd/
// for that scope — so findInstallSourceRoot's walk-up has nothing to find
// and /gsd-surface (list/status) throws. This is the writer half of the
// marker that runtime-artifact-layout.cjs's finders already read (the reader
// landed in #1476). It points at the package's own commands/gsd source.
// Scoped to the Claude-global layout (issue #1477) — the only install path
// that ships the skills layout without a commands/gsd source tree; every
// other runtime/scope deploys commands/gsd, so its walk-up already resolves
// and needs no marker. Guarded on source presence so a half-published
// package never writes a dangling marker.
if (_hostBehaviors(runtime).sourceMarkerFile && isGlobal) {
const gsdSourceCommands = path.join(src, 'commands', 'gsd');
if (fs.existsSync(gsdSourceCommands)) {
try {
// ADR-1239 Phase B write-confinement: the descriptor-sourced marker filename
// must resolve under targetDir (parity with the other descriptor-driven writes).
const _markerPath = assertDestWithinConfigHome(targetDir, _hostBehaviors(runtime).sourceMarkerFile);
fs.writeFileSync(_markerPath, gsdSourceCommands + '\n', 'utf8');
} catch (err) {
// Non-fatal: install proceeds. But on the Claude-global layout walk-up
// also fails (no commands/gsd source tree), so a silent write failure
// still leaves /gsd-surface broken at runtime — warn so it's diagnosable.
console.warn(` ${yellow}!${reset} Could not write .gsd-source marker (${err.message}); /gsd-surface list/status may fail`);
}
}
}
// #2624: the .gsd-source marker is now written by _writeGsdSourceMarker()
// BEFORE staging reads it (see the early call above the _isSkillsRuntime
// block). The former write lived here — AFTER staging — which on an upgrade
// let staging read a stale prior-version marker and silently install
// old-version skill content. Moved up; this site intentionally left empty.
// #1629 critical fix: Windsurf workflow wrappers (convertClaudeCommandToWindsurfWorkflow)
// delegate to command bodies at <targetDir>/gsd-core/commands/gsd/${stem}.md via a