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:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -621,6 +621,205 @@ describe('gsd-agent-isolation-guard.js: #3045 MAJOR — clock seam boundary cove
|
||||
});
|
||||
});
|
||||
|
||||
describe('gsd-agent-isolation-guard.js: #3566 — per-install .gsd-runtime marker rung (in-process)', () => {
|
||||
// Precedence under the fix: GSD_RUNTIME > config.json `runtime` > the per-install
|
||||
// marker at <install>/gsd-core/.gsd-runtime > ~/.gsd/defaults.json `runtime`.
|
||||
//
|
||||
// The marker is __dirname-relative in production (hooks/ sits beside gsd-core/ in
|
||||
// every install tree — the same sibling assumption the hook's own
|
||||
// require('../gsd-core/bin/lib/…') already makes), so a spawned hook in this dev
|
||||
// tree (which has no marker) can never exercise the rung. These tests require the
|
||||
// module in-process and drive the marker through the same
|
||||
// _setInstallRuntimeMarkerForTests seam src/model-resolver.cts established for
|
||||
// #2297 — null simulates a dev tree / pre-#2297 install with no marker file.
|
||||
const guardModule = require('../hooks/gsd-agent-isolation-guard.js');
|
||||
const { resolveRuntimeNameFromCandidates } = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
|
||||
|
||||
// Distinct canonical runtimes so the precedence oracle is unambiguous.
|
||||
const IDENTITY_POOL = ['claude', 'codex', 'windsurf', 'opencode'];
|
||||
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedGsdRuntime;
|
||||
let markerProject; // scaffold-shaped config ({}), per #2840's no-runtime-key template
|
||||
|
||||
// Per-test world: install marker (seam), HOME containing an optional defaults.json,
|
||||
// GSD_RUNTIME. resolveRuntimeIdentity resolves the defaults rung through
|
||||
// os.homedir() at call time, so redirecting HOME — mirrored onto USERPROFILE for
|
||||
// Windows, exactly as runHook documents above — pins it hermetically.
|
||||
function setWorld(t, { marker = null, defaultsRuntime = null, envRuntime = undefined }) {
|
||||
guardModule._setInstallRuntimeMarkerForTests(marker);
|
||||
const home = mkProject('gsd-aig-3566-home-');
|
||||
if (defaultsRuntime !== null) {
|
||||
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;
|
||||
if (envRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = envRuntime;
|
||||
t.after(() => {
|
||||
cleanup(home);
|
||||
guardModule._setInstallRuntimeMarkerForTests(null);
|
||||
});
|
||||
}
|
||||
|
||||
function identity(proj = markerProject) {
|
||||
const configPath = path.join(proj, '.planning', 'config.json');
|
||||
return guardModule.resolveRuntimeIdentity(proj, configPath, resolveRuntimeNameFromCandidates);
|
||||
}
|
||||
|
||||
before(() => {
|
||||
savedHome = process.env.HOME;
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
markerProject = mkProject('gsd-aig-3566-');
|
||||
// Mirrors gsd-core/templates/config.json exactly: no `runtime` key — the COMMON
|
||||
// scaffold shape since #2840 stopped copying runtime into project configs.
|
||||
writeConfig(markerProject, JSON.stringify({}));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(markerProject);
|
||||
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;
|
||||
guardModule._setInstallRuntimeMarkerForTests(null);
|
||||
});
|
||||
|
||||
test('#3566: per-install marker outranks host-wide defaults — two-runtime machine enforces instead of going inert', (t) => {
|
||||
// The exact issue scenario: a Codex install ran last (defaults.json says codex),
|
||||
// the Claude install's own marker says claude, the project scaffolded without a
|
||||
// runtime key, GSD_RUNTIME unset. Pre-fix the guard resolved codex confidently
|
||||
// and silently went inert; post-fix it resolves claude and DEMANDS the flag.
|
||||
setWorld(t, { marker: 'claude', defaultsRuntime: 'codex' });
|
||||
const decision = guardModule.evaluateDispatch(
|
||||
{ tool_name: 'Agent', tool_input: { subagent_type: 'gsd-executor' }, cwd: markerProject },
|
||||
);
|
||||
assert.equal(decision.action, 'block', 'must resolve claude → harness-worktree and demand the isolation param');
|
||||
// (The block REASON's "names the exact parameter to add" property is already
|
||||
// pinned by the pre-existing #3045 row 'reason names the exact parameter to
|
||||
// add' — no new raw-text matching here, per CONTRIBUTING's test-output rule.)
|
||||
});
|
||||
|
||||
test('#3566: marker rung returns confident claude above codex defaults (identity contract)', (t) => {
|
||||
setWorld(t, { marker: 'claude', defaultsRuntime: 'codex' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
|
||||
});
|
||||
|
||||
test('#3566: explicit config.json runtime still outranks the install marker', (t) => {
|
||||
const proj = mkProject('gsd-aig-3566-cfg-');
|
||||
writeConfig(proj, JSON.stringify({ runtime: 'codex' }));
|
||||
t.after(() => cleanup(proj));
|
||||
setWorld(t, { marker: 'claude' });
|
||||
assert.deepEqual(identity(proj), { runtimeId: 'codex', confident: true });
|
||||
const decision = guardModule.evaluateDispatch(
|
||||
{ tool_name: 'Agent', tool_input: { subagent_type: 'gsd-executor' }, cwd: proj },
|
||||
);
|
||||
assert.equal(decision.action, 'allow', 'codex dispatch isolation is not harness-worktree — the explicit config override is respected');
|
||||
});
|
||||
|
||||
test('#3566: GSD_RUNTIME env still outranks the install marker', (t) => {
|
||||
setWorld(t, { marker: 'claude', envRuntime: 'windsurf' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'windsurf', confident: true });
|
||||
});
|
||||
|
||||
test('#3566: empty marker is no signal — falls through to the defaults rung', (t) => {
|
||||
setWorld(t, { marker: '', defaultsRuntime: 'claude' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
|
||||
});
|
||||
|
||||
test('#3566: whitespace-only marker is no signal', (t) => {
|
||||
setWorld(t, { marker: ' ', defaultsRuntime: 'claude' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
|
||||
});
|
||||
|
||||
test('#3566: marker value canonicalized through runtime-name-policy', (t) => {
|
||||
setWorld(t, { marker: 'claude-code' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
|
||||
});
|
||||
|
||||
test('#3566: unknown marker value degrades to inert via registry miss, mirroring every other rung', (t) => {
|
||||
setWorld(t, { marker: 'not-a-runtime' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'not-a-runtime', confident: true }, 'future-runtime tolerance passthrough');
|
||||
const configPath = path.join(markerProject, '.planning', 'config.json');
|
||||
assert.deepEqual(
|
||||
guardModule.resolveRegistryIsolation(markerProject, configPath),
|
||||
{ isolation: 'none', harnessFlag: null },
|
||||
'the SPECIFIC degraded verdict — not merely survival',
|
||||
);
|
||||
const decision = guardModule.evaluateDispatch(
|
||||
{ tool_name: 'Agent', tool_input: { subagent_type: 'gsd-executor' }, cwd: markerProject },
|
||||
);
|
||||
assert.equal(decision.action, 'allow');
|
||||
});
|
||||
|
||||
test('#3566: absent marker preserves the #3045 defaults.json confidence rung', (t) => {
|
||||
setWorld(t, { marker: null, defaultsRuntime: 'claude' });
|
||||
assert.deepEqual(identity(), { runtimeId: 'claude', confident: true });
|
||||
const decision = guardModule.evaluateDispatch(
|
||||
{ tool_name: 'Agent', tool_input: { subagent_type: 'gsd-executor' }, cwd: markerProject },
|
||||
);
|
||||
assert.equal(decision.action, 'block', 'single-runtime default install still enforces (#3045 BLOCKER 2 part B)');
|
||||
});
|
||||
|
||||
test('#3566: no-signal case still degrades to inert, never a guessed runtime', (t) => {
|
||||
setWorld(t, { marker: null });
|
||||
assert.deepEqual(identity(), { runtimeId: null, confident: false });
|
||||
const decision = guardModule.evaluateDispatch(
|
||||
{ tool_name: 'Agent', tool_input: { subagent_type: 'gsd-executor' }, cwd: markerProject },
|
||||
);
|
||||
assert.equal(decision.action, 'allow');
|
||||
});
|
||||
|
||||
test('#3566 property: precedence chain is a total order over arbitrary signal subsets', (t) => {
|
||||
const proj = mkProject('gsd-aig-3566-prop-');
|
||||
const home = mkProject('gsd-aig-3566-prophome-');
|
||||
fs.mkdirSync(path.join(home, '.gsd'), { recursive: true });
|
||||
const defaultsPath = path.join(home, '.gsd', 'defaults.json');
|
||||
const configPath = path.join(proj, '.planning', 'config.json');
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
t.after(() => {
|
||||
cleanup(proj);
|
||||
cleanup(home);
|
||||
guardModule._setInstallRuntimeMarkerForTests(null);
|
||||
});
|
||||
|
||||
fc.assert(fc.property(
|
||||
fc.uniqueArray(fc.constantFrom(...IDENTITY_POOL), { minLength: 4, maxLength: 4 }),
|
||||
fc.tuple(fc.boolean(), fc.boolean(), fc.boolean(), fc.boolean()),
|
||||
(perm, actives) => {
|
||||
// perm (a uniqueArray over the exact 4-runtime pool) assigns DISTINCT
|
||||
// canonical runtimes to the four rungs; actives[i] selects whether rung i
|
||||
// carries a value at all. Distinctness makes the oracle unambiguous: the
|
||||
// winner is the first ACTIVE rung in precedence order env > config >
|
||||
// marker > defaults.
|
||||
const [envV, cfgV, mkV, defV] = perm;
|
||||
const [envOn, cfgOn, mkOn, defOn] = actives;
|
||||
if (envOn) process.env.GSD_RUNTIME = envV;
|
||||
else delete process.env.GSD_RUNTIME;
|
||||
writeConfig(proj, cfgOn ? JSON.stringify({ runtime: cfgV }) : JSON.stringify({}));
|
||||
guardModule._setInstallRuntimeMarkerForTests(mkOn ? mkV : null);
|
||||
if (defOn) fs.writeFileSync(defaultsPath, JSON.stringify({ runtime: defV }));
|
||||
else fs.writeFileSync(defaultsPath, JSON.stringify({})); // no `runtime` key = no signal from the rung
|
||||
|
||||
const expected = envOn ? envV : cfgOn ? cfgV : mkOn ? mkV : defOn ? defV : null;
|
||||
const id = guardModule.resolveRuntimeIdentity(proj, configPath, resolveRuntimeNameFromCandidates);
|
||||
if (expected === null) {
|
||||
assert.equal(id.runtimeId, null);
|
||||
assert.equal(id.confident, false);
|
||||
} else {
|
||||
assert.deepEqual(id, { runtimeId: expected, confident: true });
|
||||
}
|
||||
},
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
// Folded from tests/fix-3045-dispatch-isolation-resolver.test.cjs (#3333 wave
|
||||
// 1, test-only consolidation — no behavior change). These describe blocks
|
||||
// cover the sentinel WRITE side: `gsd-tools.cjs query dispatch-isolation`
|
||||
|
||||
Reference in New Issue
Block a user