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

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3589
---
**Agent isolation guard enforces on multi-runtime machines** — the isolation guard (and Cursor's subagent-start fallback) resolved the project runtime from the host-wide ~/.gsd/defaults.json, which names whichever runtime installed last; on machines with two runtimes this confidently picked the wrong runtime and silently disabled executor worktree policing. Both now read the per-install .gsd-runtime marker above that file. (#3566)

View File

@@ -43,8 +43,9 @@
// authoritative — `none`/`orchestrator-worktree` ALLOW immediately
// (sequential/orchestrator-managed dispatch is legitimate, not a bug); an
// absent/stale sentinel falls back to a conservative registry+config check
// (GSD_RUNTIME env > .planning/config.json `runtime` — no confident signal
// degrades to inert rather than guessing 'claude', see resolveRegistryIsolation)
// (GSD_RUNTIME env > .planning/config.json `runtime` > the per-install
// `.gsd-runtime` marker, #3566 — no confident signal degrades to inert rather
// than guessing 'claude', see resolveRegistryIsolation)
// gated additionally by `workflow.use_worktrees` — read directly, in-process,
// no subprocess spawn.
//
@@ -85,6 +86,42 @@ function parseHarnessFlag(flag) {
return { param: m[1], value: m[2] };
}
// ─── #3566: per-install runtime marker ────────────────────────────────────────
// bin/install.js writes `<install>/gsd-core/.gsd-runtime` for EVERY runtime
// install (#2297), co-located with VERSION. Unlike `~/.gsd/defaults.json` —
// which is host-wide and names whichever runtime's install ran LAST, the exact
// leakage #2840's config.cjs change exists to prevent — the marker describes
// THIS install, which is the property runtime identity needs on a machine
// with 2+ runtimes. Mirrors readInstallRuntimeMarker in src/model-resolver.cts
// (same cache + test-seam shape); this hook cannot import that module without
// dragging the whole model-resolution stack into a PreToolUse hot path, so the
// 5-line read lives here against the same sibling-layout assumption the hook's
// own require('../gsd-core/bin/lib/…') already makes. Epic #3473 B3 owns
// consolidating every marker reader into one shared seam.
let _installMarkerCache; // undefined = unread; null = known absent; string = value
function readInstallRuntimeMarker() {
if (_installMarkerCache !== undefined) return _installMarkerCache;
try {
const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime');
const raw = fs.readFileSync(markerPath, 'utf-8').trim();
_installMarkerCache = raw || null;
} catch {
// No marker: dev/source tree, or an install predating #2297 — "no signal
// from this rung", never a resolution failure. Falls through to the
// defaults rung below.
_installMarkerCache = null;
}
return _installMarkerCache;
}
// Test seam for the marker rung (the dev/source tree has no marker file, so
// the read always bottoms out at null there — same seam contract as
// model-resolver.cts's _setInstallRuntimeMarkerForTests, #2297).
function _setInstallRuntimeMarkerForTests(value) {
_installMarkerCache = value;
}
/**
* Resolve this project's declared `runtime` identity WITHOUT defaulting to
* 'claude' when no explicit signal exists (#3045 MAJOR 2).
@@ -100,9 +137,10 @@ function parseHarnessFlag(flag) {
*
* Returns `{ runtimeId, confident }`. `confident` is true only when an
* explicit signal exists (GSD_RUNTIME env override, a `runtime` key literally
* present in config.json, or a `runtime` persisted to `~/.gsd/defaults.json`
* by the installer — see below); false means "cannot determine" and callers
* must NOT silently substitute 'claude' — see resolveRegistryIsolation.
* present in config.json, the per-install `.gsd-runtime` marker, or a
* `runtime` persisted to `~/.gsd/defaults.json` by the installer — see
* below); false means "cannot determine" and callers must NOT silently
* substitute 'claude' — see resolveRegistryIsolation.
*
* #3045 BLOCKER 2 fix: precedence is GSD_RUNTIME env > config.json `runtime`
* key > `~/.gsd/defaults.json` `runtime`. The first two are unchanged; the
@@ -117,6 +155,17 @@ function parseHarnessFlag(flag) {
* (`nativeModelAliases` short-circuits it) and therefore correctly still rely
* on config.json/env. Reading the installer's own persisted signal makes
* "confident" the common case instead.
*
* #3566: the per-install `.gsd-runtime` marker now sits BETWEEN config.json
* and defaults.json. defaults.json is host-wide and names whichever runtime
* installed LAST — on a 2-runtime machine that confidently resolves the WRONG
* runtime (a Codex install's `runtime:"codex"` leaking into Claude projects),
* and when the wrong runtime declares no harnessIsolationFlag the guard goes
* silently inert. The marker describes THIS install (written for every
* runtime since #2297), which is the source #2840's config.cjs change names
* as correct. defaults.json stays as the final rung so single-runtime default
* installs and pre-#2297 installs (no marker on disk) keep the #3045
* BLOCKER 2 behavior.
*/
function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidates) {
const envRuntime = resolveRuntimeNameFromCandidates(process.env.GSD_RUNTIME);
@@ -132,6 +181,13 @@ function resolveRuntimeIdentity(cwd, configPath, resolveRuntimeNameFromCandidate
if (configRuntime) return { runtimeId: configRuntime, confident: true };
}
// #3566: the per-install marker, above the host-wide defaults — see the
// block comment on readInstallRuntimeMarker. An empty/whitespace-only file
// or an unknown value degrades exactly like the other rungs (no signal /
// future-runtime tolerance via resolveRuntimeNameFromCandidates).
const markerRuntime = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker());
if (markerRuntime) return { runtimeId: markerRuntime, confident: true };
// #3045 BLOCKER 2: fall back to the installer-persisted default. Read
// defensively — an absent/corrupt/non-object defaults.json is "no signal",
// never a resolution failure (this function only ever throws for the
@@ -425,4 +481,5 @@ module.exports = {
resolveHarnessFlag,
resolveRegistryIsolation,
parseHarnessFlag,
_setInstallRuntimeMarkerForTests,
};

View File

@@ -333,6 +333,37 @@ function resolveIsolationDecision(data, { clock = Date, realpath = fs.realpathSy
return { action: 'allow' };
}
// ─── #3566: per-install runtime marker ────────────────────────────────────────
// Same contract as hooks/gsd-agent-isolation-guard.js's readInstallRuntimeMarker
// (mirroring src/model-resolver.cts #2297): bin/install.js writes
// `<install>/gsd-core/.gsd-runtime` beside VERSION for every runtime install;
// this hook ships at `<install>/hooks/`, so the marker is the `gsd-core` sibling
// of this file's own directory — the same sibling-layout assumption the
// require('../gsd-core/bin/lib/…') calls below already make. Epic #3473 B3 owns
// consolidating every marker reader into one shared seam.
let _installMarkerCache; // undefined = unread; null = known absent; string = value
function readInstallRuntimeMarker() {
if (_installMarkerCache !== undefined) return _installMarkerCache;
try {
const markerPath = path.join(__dirname, '..', 'gsd-core', '.gsd-runtime');
const raw = fs.readFileSync(markerPath, 'utf-8').trim();
_installMarkerCache = raw || null;
} catch {
// No marker: dev/source tree, or an install predating #2297 — "no signal
// from this rung", never a resolution failure.
_installMarkerCache = null;
}
return _installMarkerCache;
}
// Test seam — same contract as model-resolver.cts's #2297 seam; the dev/source
// tree has no marker file, so spawned-hook tests (fresh process, no marker)
// are unaffected.
function _setInstallRuntimeMarkerForTests(value) {
_installMarkerCache = value;
}
/**
* Conservative fallback resolution used when the #3045 sentinel is absent or
* stale for `root`: re-derive isolation from the registry CAPABILITY, gated
@@ -355,7 +386,8 @@ function resolveIsolationDecision(data, { clock = Date, realpath = fs.realpathSy
* otherwise legitimate dispatches, unlike `hooks/gsd-agent-isolation-guard.js`,
* which degrades an undeterminable runtime to inert (#3045 MAJOR 2). Aligned
* here: an explicit signal is now required — `GSD_RUNTIME` > config.json
* `runtime` key > `~/.gsd/defaults.json` `runtime` (mirrors the Claude hook's
* `runtime` key > the per-install `.gsd-runtime` marker (#3566) >
* `~/.gsd/defaults.json` `runtime` (mirrors the Claude hook's
* `resolveRuntimeIdentity`; `bin/install.js`'s `writeNonClaudeDefaults`
* persists the installed runtime there for every non-Claude install,
* including Cursor, so a REAL Cursor+GSD install still resolves confidently
@@ -372,6 +404,13 @@ function resolveFallbackIsolation(root, configPath) {
if (!runtimeId && parsedConfig && typeof parsedConfig === 'object' && 'runtime' in parsedConfig) {
runtimeId = resolveRuntimeNameFromCandidates(parsedConfig.runtime) || null;
}
if (!runtimeId) {
// #3566: the per-install marker, above the host-wide defaults — same fix as
// hooks/gsd-agent-isolation-guard.js's resolveRuntimeIdentity. defaults.json
// is host-wide and names whichever runtime installed LAST (#2840's poison);
// the marker describes THIS install (written for every runtime since #2297).
runtimeId = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker()) || null;
}
if (!runtimeId) {
try {
const defaultsPath = path.join(os.homedir(), '.gsd', 'defaults.json');
@@ -557,4 +596,5 @@ module.exports = {
resolveFallbackIsolation,
resolveIsolationEvidence,
getWorkspaceRoots,
_setInstallRuntimeMarkerForTests,
};

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',
);
});
});

View File

@@ -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`