fix(#3566): read the per-install .gsd-runtime marker above host-wide defaults in the isolation guards (#3589)

* test(#3566): pin per-install .gsd-runtime marker precedence in the isolation guard

Failing-first regression for #3566: resolveRuntimeIdentity must consult the
per-install marker (<install>/gsd-core/.gsd-runtime, written by every install
since #2297) above the host-wide ~/.gsd/defaults.json whose leakage #2840
exists to prevent. In-process block drives the marker through the same
_setInstallRuntimeMarkerForTests seam model-resolver.cts established.

* fix(#3566): read the per-install .gsd-runtime marker above host-wide defaults in the isolation guard

resolveRuntimeIdentity consulted ~/.gsd/defaults.json — the exact host-wide
file whose runtime leakage #2840 exists to prevent — and never the
per-install marker the installer has written for every runtime since #2297.
On a 2-runtime machine the guard confidently resolved the WRONG runtime and
silently went inert when that runtime declares no harnessIsolationFlag.
Precedence is now GSD_RUNTIME > config.json runtime > .gsd-runtime marker >
defaults.json, restoring #2840's design; the defaults rung stays last so
single-runtime and pre-#2297 installs keep #3045 BLOCKER 2 behavior.

* fix(#3566): apply the marker rung to the cursor subagent-start fallback; review fixes

Review finding (spec pass): hooks/gsd-cursor-subagent-start.js's
resolveFallbackIsolation mirrored the Claude hook's exact three-rung chain
and shared the bug — same rung inserted between config.json and the
host-wide defaults, same #2297-pattern seam, in-process regression +
negative controls.

Review finding (standards): dropped the one new raw-text assert.match on
the block reason (CONTRIBUTING test-output rule); the reason-naming
property stays pinned by the pre-existing #3045 row.

* chore(#3566): add changeset fragment

* chore(#3566): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-17 10:48:32 -04:00
committed by GitHub
parent 8a56595700
commit 58e5a5b581
5 changed files with 390 additions and 6 deletions

View File

@@ -914,3 +914,86 @@ describe('gsd-cursor-subagent-start.js: #3045 MAJOR — clock seam boundary cove
}
});
});
describe('gsd-cursor-subagent-start.js: #3566 — per-install .gsd-runtime marker rung (in-process)', () => {
// Same seam contract as the agent guard's #3566 block in
// tests/gsd-agent-isolation-guard.test.cjs: the marker is __dirname-relative
// in production, so a spawned hook in this dev tree (no marker) can never
// exercise the rung — require the module and drive the seam directly.
const cursorHookModule = require('../hooks/gsd-cursor-subagent-start.js');
let savedHome;
let savedUserProfile;
let savedGsdRuntime;
let project; // scaffold-shaped config ({}), per #2840's no-runtime-key template
before(() => {
savedHome = process.env.HOME;
savedUserProfile = process.env.USERPROFILE;
savedGsdRuntime = process.env.GSD_RUNTIME;
project = createTempDir('gsd-cs-3566-');
fs.mkdirSync(path.join(project, '.planning'), { recursive: true });
fs.writeFileSync(path.join(project, '.planning', 'config.json'), JSON.stringify({}));
});
after(() => {
cleanup(project);
if (savedHome === undefined) delete process.env.HOME;
else process.env.HOME = savedHome;
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = savedUserProfile;
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = savedGsdRuntime;
cursorHookModule._setInstallRuntimeMarkerForTests(null);
});
// Redirects HOME (mirrored onto USERPROFILE for Windows) at a fake home with
// an optional defaults.json naming `defaultsRuntime`.
function pinHome(t, defaultsRuntime) {
const home = createTempDir('gsd-cs-3566-home-');
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
fs.writeFileSync(path.join(home, '.gsd', 'defaults.json'), JSON.stringify({ runtime: defaultsRuntime }));
process.env.HOME = home;
process.env.USERPROFILE = home;
t.after(() => cleanup(home));
}
function fallback() {
return cursorHookModule.resolveFallbackIsolation(project, path.join(project, '.planning', 'config.json'));
}
test('#3566: per-install marker outranks host-wide defaults — two-runtime machine resolves the marker runtime', (t) => {
// defaults.json says codex (a Codex install ran last); the install's own
// marker says claude. Pre-#3566 the fallback resolved codex confidently
// (here: orchestrator-worktree — not harness-worktree); post-fix it must
// resolve claude → harness-worktree.
pinHome(t, 'codex');
delete process.env.GSD_RUNTIME;
cursorHookModule._setInstallRuntimeMarkerForTests('claude');
t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null));
assert.equal(fallback(), 'harness-worktree');
});
test('#3566 (negative control): absent marker still falls through to the defaults rung', (t) => {
pinHome(t, 'codex');
delete process.env.GSD_RUNTIME;
cursorHookModule._setInstallRuntimeMarkerForTests(null);
assert.equal(fallback(), 'orchestrator-worktree', 'defaults.json remains the final rung (#3045 behavior intact)');
});
test('#3566 (negative control): explicit config.json runtime still outranks the marker', (t) => {
const cfgProject = createTempDir('gsd-cs-3566-cfg-');
fs.mkdirSync(path.join(cfgProject, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cfgProject, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' }));
t.after(() => cleanup(cfgProject));
pinHome(t, 'codex');
delete process.env.GSD_RUNTIME;
cursorHookModule._setInstallRuntimeMarkerForTests('claude');
t.after(() => cursorHookModule._setInstallRuntimeMarkerForTests(null));
assert.equal(
cursorHookModule.resolveFallbackIsolation(cfgProject, path.join(cfgProject, '.planning', 'config.json')),
'orchestrator-worktree',
'the explicit config override wins over both marker and defaults',
);
});
});