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