* 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user