* test(#4717): add failing-first coverage for the two runtime-identity marker seams * fix(#4717): consult the per-install runtime marker at both identity seams resolveReportedRuntime (agent_runtime) and loadConfigResolved (config.runtime) both ignored the per-install .gsd-runtime marker that resolveRuntime and the model-resolver gate already read. On a multi-runtime machine (e.g. a globally exported CODEX_HOME), host sniffing misreported every Claude Code session as codex, and a shared defaults.json stamped by the first non-Claude install leaked its runtime to every other one. Seam 1: the reported-runtime ladder becomes explicit > install marker > host detection > claude. Seam 2: loadConfigResolved fills an empty config.runtime from GSD_RUNTIME then the marker, copy-on-write (the builtin-defaults branch returns a shared object). Explicit runtimes and marker-less trees are unchanged. * fix(#4717): a marker-detected runtime opts into its tier map (decision a) * fix(#4717): stamped-defaults leg, marker fail-safe, docs, review fold-ins * chore(#4717): backfill changeset PR number (4861) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1704,3 +1704,147 @@ describe('#3894 research_before_questions global-defaults forwarding', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── #4717 — an empty config.runtime is filled from GSD_RUNTIME / the marker ──
|
||||
describe('loadConfigResolved — runtime identity fill (#4717)', () => {
|
||||
const slash = require('../gsd-core/bin/lib/runtime-slash.cjs');
|
||||
const fsx = require('node:fs');
|
||||
const pathx = require('node:path');
|
||||
const { createTempDir: mkTmp4717, cleanup: cleanup4717 } = require('./helpers.cjs');
|
||||
let tmpCodexHome;
|
||||
let originalCodexHome;
|
||||
let originalGsdRuntime;
|
||||
|
||||
let originalGsdHome;
|
||||
let originalHome;
|
||||
let originalUserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCodexHome = process.env.CODEX_HOME;
|
||||
originalGsdRuntime = process.env.GSD_RUNTIME;
|
||||
originalGsdHome = process.env.GSD_HOME;
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
tmpCodexHome = mkTmp4717('gsd-4717-');
|
||||
process.env.CODEX_HOME = tmpCodexHome;
|
||||
// Isolate the SHARED defaults file too: loadConfigResolved's global-defaults
|
||||
// branch reads ~/.gsd/defaults.json from GSD_HOME || homedir, and a real
|
||||
// machine's stamped defaults would bleed into these rows (#4717 review).
|
||||
process.env.GSD_HOME = tmpCodexHome;
|
||||
process.env.HOME = tmpCodexHome;
|
||||
process.env.USERPROFILE = tmpCodexHome;
|
||||
slash._setInstallRuntimeMarkerForTests('codex');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
slash._resetInstallRuntimeMarkerCacheForTests();
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = originalCodexHome;
|
||||
if (originalGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = originalGsdRuntime;
|
||||
if (originalGsdHome === undefined) delete process.env.GSD_HOME;
|
||||
else process.env.GSD_HOME = originalGsdHome;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
cleanup4717(tmpCodexHome);
|
||||
});
|
||||
|
||||
test('an empty config.runtime is filled from the install marker (#4717)', (t) => {
|
||||
const projDir = mkTmp4717('gsd-4717-proj-');
|
||||
t.after(() => cleanup4717(projDir));
|
||||
const resolved = loadConfigResolved(projDir, { persist: false });
|
||||
assert.equal(resolved.config.runtime, 'codex',
|
||||
'the marker-owned runtime fills an empty config.runtime (copy-on-write)');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME outranks the marker in the identity fill (#4717)', (t) => {
|
||||
const projDir = mkTmp4717('gsd-4717-proj-');
|
||||
t.after(() => cleanup4717(projDir));
|
||||
process.env.GSD_RUNTIME = 'kimi';
|
||||
const resolved = loadConfigResolved(projDir, { persist: false });
|
||||
assert.equal(resolved.config.runtime, 'kimi');
|
||||
});
|
||||
|
||||
// Branch D (the shared-defaults path) fires only when the project dir has NO
|
||||
// .planning/ at all — these rows use bare dirs for exactly that (#4717).
|
||||
function bareProjDir(t, prefix) {
|
||||
const dir = mkTmp4717(prefix);
|
||||
t.after(() => cleanup4717(dir));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('#4717 stamped-defaults leg: a shared defaults runtime does not leak past THIS install\'s marker', (t) => {
|
||||
// The issue's second failure shape: the first non-Claude install stamped
|
||||
// `runtime` into the SHARED ~/.gsd/defaults.json; a Claude install on the
|
||||
// same machine must not inherit that identity. Branch D forwards the
|
||||
// stamped value; the fill corrects it to the marker's own.
|
||||
fsx.mkdirSync(pathx.join(tmpCodexHome, '.gsd'), { recursive: true });
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpCodexHome, '.gsd', 'defaults.json'),
|
||||
JSON.stringify({ runtime: 'codex' }),
|
||||
);
|
||||
slash._setInstallRuntimeMarkerForTests('claude');
|
||||
|
||||
const projDir = bareProjDir(t, 'gsd-4717-proj-stamped-');
|
||||
|
||||
const resolved = loadConfigResolved(projDir);
|
||||
assert.equal(resolved.source, 'global-defaults', 'fixture: the shared-defaults branch must fire');
|
||||
assert.equal(
|
||||
resolved.config.runtime,
|
||||
'claude',
|
||||
'the marker-owned identity must correct the machine-wide stamp',
|
||||
);
|
||||
});
|
||||
|
||||
test('#4717 stamped-defaults leg: with no marker of its own, the stamped value still stands (status quo preserved)', (t) => {
|
||||
fsx.mkdirSync(pathx.join(tmpCodexHome, '.gsd'), { recursive: true });
|
||||
fsx.writeFileSync(
|
||||
pathx.join(tmpCodexHome, '.gsd', 'defaults.json'),
|
||||
JSON.stringify({ runtime: 'codex' }),
|
||||
);
|
||||
slash._setInstallRuntimeMarkerForTests(null);
|
||||
|
||||
const projDir = bareProjDir(t, 'gsd-4717-proj-stamped-nomarker-');
|
||||
|
||||
const resolved = loadConfigResolved(projDir);
|
||||
assert.equal(resolved.config.runtime, 'codex', 'no own identity — the stamped value is all we know');
|
||||
});
|
||||
|
||||
test('#4717 fail-safe: a garbage marker does not fill the runtime (#4717 review)', (_t) => {
|
||||
slash._setInstallRuntimeMarkerForTests(' not-a-runtime ');
|
||||
|
||||
const projDir = bareProjDir(_t, 'gsd-4717-proj-garbage-');
|
||||
|
||||
const resolved = loadConfigResolved(projDir);
|
||||
assert.equal(
|
||||
resolved.config.runtime || null,
|
||||
null,
|
||||
'an unrecognizable marker value must fail safe to no identity',
|
||||
);
|
||||
});
|
||||
|
||||
test('an explicit config.runtime is preserved — the fill never overrides it (#4717)', (t) => {
|
||||
const projDir = mkTmp4717('gsd-4717-proj-');
|
||||
t.after(() => cleanup4717(projDir));
|
||||
fsx.mkdirSync(pathx.join(projDir, '.planning'), { recursive: true });
|
||||
fsx.writeFileSync(
|
||||
pathx.join(projDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'claude' }),
|
||||
);
|
||||
const resolved = loadConfigResolved(projDir, { persist: false });
|
||||
assert.equal(resolved.config.runtime, 'claude',
|
||||
'an explicit project runtime is never overwritten by the marker fill');
|
||||
});
|
||||
|
||||
test('copy-on-write: the shared builtin-defaults object is never mutated (#4717)', (t) => {
|
||||
const projDir = mkTmp4717('gsd-4717-proj-');
|
||||
t.after(() => cleanup4717(projDir));
|
||||
const resolved = loadConfigResolved(projDir, { persist: false });
|
||||
assert.equal(resolved.config.runtime, 'codex');
|
||||
const again = loadConfigResolved(projDir, { persist: false });
|
||||
assert.equal(again.config.runtime, 'codex');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,3 +580,81 @@ describe('detectHostRuntime: properties', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #4717 — the per-install .gsd-runtime marker outranks host detection ──────
|
||||
// On a multi-runtime machine (CODEX_HOME exported globally), host sniffing
|
||||
// misreports every Claude Code session as codex. The per-install marker names
|
||||
// the runtime that owns THIS tree — a stronger signal than host detection.
|
||||
// Ladder: explicit (GSD_RUNTIME / config.runtime) > marker > host detection >
|
||||
// 'claude'. Marker-less trees (dev/source, pre-#2297 installs) are unchanged.
|
||||
|
||||
describe('resolveReportedRuntime: install-marker rung (#4717)', () => {
|
||||
const slash = require('../gsd-core/bin/lib/runtime-slash.cjs');
|
||||
|
||||
// Self-contained withProject: clears GSD_RUNTIME (the marker rung sits below
|
||||
// it) and scaffolds a throwaway project — scoped to this describe so the
|
||||
// sibling precedence describe's own helper is untouched.
|
||||
function withProject4717(t) {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => {
|
||||
cleanup(tmpDir);
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function writeConfig4717(tmpDir, runtime) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime }),
|
||||
);
|
||||
}
|
||||
|
||||
test('install marker outranks host detection (claude session on a codex-sniffing machine)', (t) => {
|
||||
const tmpDir = withProject4717(t);
|
||||
slash._setInstallRuntimeMarkerForTests('claude');
|
||||
t.after(() => slash._resetInstallRuntimeMarkerCacheForTests());
|
||||
// CODEX_SANDBOX makes host detection report codex — the issue's repro shape.
|
||||
const result = resolveReportedRuntime(tmpDir, {
|
||||
env: { CODEX_SANDBOX: 'seatbelt', CODEX_SANDBOX_NETWORK_DISABLED: '1' },
|
||||
});
|
||||
assert.strictEqual(result, 'claude',
|
||||
'the marker (claude) must outrank host detection (codex) on the owning tree');
|
||||
});
|
||||
|
||||
test('install marker supplies the runtime when host detection finds nothing (codex install)', (t) => {
|
||||
const tmpDir = withProject4717(t);
|
||||
slash._setInstallRuntimeMarkerForTests('codex');
|
||||
t.after(() => slash._resetInstallRuntimeMarkerCacheForTests());
|
||||
const result = resolveReportedRuntime(tmpDir, { env: {} });
|
||||
assert.strictEqual(result, 'codex',
|
||||
'a codex-owned tree reports codex even where host sniffing would say claude');
|
||||
});
|
||||
|
||||
test('explicit GSD_RUNTIME still outranks the marker (#4717 ladder rung 1)', (t) => {
|
||||
const tmpDir = withProject4717(t);
|
||||
slash._setInstallRuntimeMarkerForTests('codex');
|
||||
t.after(() => slash._resetInstallRuntimeMarkerCacheForTests());
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: 'claude' } });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
|
||||
test('explicit config runtime still outranks the marker (#4717 ladder rung 1)', (t) => {
|
||||
const tmpDir = withProject4717(t);
|
||||
writeConfig4717(tmpDir, 'kimi');
|
||||
slash._setInstallRuntimeMarkerForTests('codex');
|
||||
t.after(() => slash._resetInstallRuntimeMarkerCacheForTests());
|
||||
const result = resolveReportedRuntime(tmpDir, { env: {} });
|
||||
assert.strictEqual(result, 'kimi');
|
||||
});
|
||||
|
||||
test('no marker: host detection unchanged (dev/source trees, pre-#2297 installs)', (t) => {
|
||||
const tmpDir = withProject4717(t);
|
||||
slash._resetInstallRuntimeMarkerCacheForTests();
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'codex', 'host detection remains the fallback without a marker');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4635,6 +4635,15 @@ describe('#2229 PROPERTY: resolveTierFromConfig never throws and always returns
|
||||
* Active-runtime precedence: `process.env.GSD_RUNTIME` -> `config.runtime` ->
|
||||
* per-install `.gsd-runtime` marker -> `'claude'` (all canonicalized).
|
||||
*
|
||||
* #4717 (user-sanctioned decision a) ADDENDUM: with the runtime-identity fill
|
||||
* in config-loader, an empty `config.runtime` is materialized from
|
||||
* GSD_RUNTIME/the marker — so a genuinely installed non-Claude runtime now
|
||||
* counts as the opt-in and resolves its own tier map past a GLOBAL or
|
||||
* project omit. The #2297 Claude-protection cases are unchanged: a claude
|
||||
* runtime still ignores the shared omit, garbage runtime values still fail
|
||||
* safe to "", and an explicit project omit still applies when no runtime
|
||||
* identity is known.
|
||||
*
|
||||
* NOTE: the global-defaults merge path in config-loader.cjs (branch D: "no
|
||||
* .planning/ at all") only fires when the project dir has NO `.planning/`
|
||||
* whatsoever — the moment `.planning/` exists, `~/.gsd/defaults.json` is not
|
||||
@@ -4757,12 +4766,17 @@ describe('#2297: global-defaults resolve_model_ids:"omit" is scoped to the activ
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'sonnet');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME="codex": a non-alias runtime still honors the global omit (acceptance #4)', () => {
|
||||
test('GSD_RUNTIME="codex": the env-detected runtime opts into its tier map past the global omit (#4717 decision a)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
process.env.GSD_RUNTIME = 'codex';
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), '');
|
||||
// #4717 (user-sanctioned semantics change): the fill materializes the env
|
||||
// runtime into config.runtime, and a recognised non-Claude runtime there
|
||||
// counts as an opt-in — the shared "omit" stays a Claude protection, not a
|
||||
// codex directive. Supersedes the #2297 acceptance-#4 reading for
|
||||
// detected runtimes.
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'gpt-5.6-terra');
|
||||
});
|
||||
|
||||
// #2297 correctness-review BLOCKER: resolveActiveRuntime() must canonicalize
|
||||
@@ -4980,12 +4994,13 @@ describe('#2297: install-marker precedence rung (GSD_RUNTIME and config.runtime
|
||||
_resetInstallRuntimeMarkerCacheForTests();
|
||||
});
|
||||
|
||||
test('marker="codex" (non-alias runtime): honors the poisoned global omit -> ""', () => {
|
||||
test('marker="codex" (non-alias runtime): the marker-detected runtime opts into its tier map (#4717 decision a)', () => {
|
||||
writeGlobalDefaults({ resolve_model_ids: 'omit' });
|
||||
projDir = mkProjNoPlanning();
|
||||
_setInstallRuntimeMarkerForTests('codex');
|
||||
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), '');
|
||||
// #4717 (decision a): the marker-filled runtime counts as opt-in.
|
||||
assert.strictEqual(resolveModelInternal(projDir, 'gsd-executor'), 'gpt-5.6-terra');
|
||||
});
|
||||
|
||||
test('marker="claude": ignores the poisoned global omit -> "sonnet"', () => {
|
||||
@@ -6830,17 +6845,20 @@ describe('#4192 model_overrides: fully-qualified claude IDs resolve as configure
|
||||
|
||||
const DR = { enabled: true, tier_models: { light: 'haiku', standard: 'DR-STANDARD', heavy: 'opus' } };
|
||||
|
||||
// REGRESSION for the blocker: with a non-Claude runtime and an omit, the gate
|
||||
// returned "" before this PR and must keep doing so. The earlier cut handed
|
||||
// out a model id here.
|
||||
test('resolve_model_ids:"omit" still wins over the tier table', () => {
|
||||
// #4717 (user-sanctioned semantics change, decision a): with the runtime
|
||||
// identity fill, a detected codex runtime materializes config.runtime and
|
||||
// counts as an opt-in — the runtime tier map (step 3) now resolves before
|
||||
// the omit gate would, so the omit no longer yields "" for a genuinely
|
||||
// installed non-Claude runtime. The gate's canonicalization guard rows
|
||||
// below still fail safe to "" for garbage runtime values.
|
||||
test('a detected codex runtime resolves its tier map; the global omit no longer wins (#4717 decision a)', () => {
|
||||
dir = project({ model_profile: 'quality', resolve_model_ids: 'omit', dynamic_routing: DR });
|
||||
assert.strictEqual(resolveModel(dir, 'gsd-executor', 'codex'), '');
|
||||
assert.strictEqual(resolveModel(dir, 'gsd-executor', 'codex'), 'gpt-5.6-sol');
|
||||
});
|
||||
|
||||
test('the omit gate wins in the init payload a real spawn reads, too', () => {
|
||||
test('the init payload a real spawn reads resolves the tier map too (#4717 decision a)', () => {
|
||||
dir = project({ model_profile: 'quality', resolve_model_ids: 'omit', dynamic_routing: DR });
|
||||
assert.strictEqual(initQuick(dir, 'codex').executor_model, '');
|
||||
assert.strictEqual(initQuick(dir, 'codex').executor_model, 'gpt-5.6-sol');
|
||||
});
|
||||
|
||||
test('the runtime tier map (step 3) outranks the tier table', () => {
|
||||
@@ -6966,16 +6984,19 @@ describe('#4192 model_overrides: fully-qualified claude IDs resolve as configure
|
||||
// that is the "poisoned global" #2297 acceptance #4 is actually about. An
|
||||
// earlier cut of this row wrote it into the project config, which is the
|
||||
// #2517 project-explicit path and exercises a different branch entirely.
|
||||
test('#2297 acceptance #4: a merely DETECTED runtime still honors a GLOBAL omit', () => {
|
||||
test('#4717 (decision a): a DETECTED runtime opts into its tier map past a GLOBAL omit', () => {
|
||||
// No project config at all — see the `project` docblock: that is the only
|
||||
// shape in which the shared defaults layer is consulted.
|
||||
// shape in which the shared defaults layer is consulted. #4717's
|
||||
// user-sanctioned semantics change supersedes the #2297 acceptance-#4
|
||||
// reading: the fill materializes the detected runtime into config.runtime
|
||||
// and a recognised non-Claude runtime there opts into its own tier map.
|
||||
dir = project(null, { resolve_model_ids: 'omit' });
|
||||
assert.strictEqual(resolveModel(dir, 'gsd-executor', 'codex'), '');
|
||||
assert.strictEqual(resolveModel(dir, 'gsd-executor', 'codex'), 'gpt-5.6-terra');
|
||||
});
|
||||
|
||||
test('a project-explicit omit is honored for a detected runtime too', () => {
|
||||
test('#4717 (decision a): a project-explicit omit also yields to a detected runtime\'s tier map', () => {
|
||||
dir = project({ resolve_model_ids: 'omit' });
|
||||
assert.strictEqual(resolveModel(dir, 'gsd-executor', 'codex'), '');
|
||||
assert.strictEqual(resolveModel(dir, 'gsd-executor', 'codex'), 'gpt-5.6-terra');
|
||||
});
|
||||
|
||||
// The opt-in signal is CANONICALIZED. An earlier cut of this fix compared the
|
||||
|
||||
Reference in New Issue
Block a user