fix(#4717): consult the per-install runtime marker at both identity seams (#4861)

* 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:
Tom Boucher
2026-09-18 13:50:01 -04:00
committed by GitHub
parent 58c7bbb16a
commit 9a41a95212
10 changed files with 343 additions and 33 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4861
---
**A genuinely installed non-Claude runtime now resolves its own models instead of an empty string** — the per-install runtime identity is materialized into the config, so a marker-detected Codex install resolves its tier map past a shared `resolve_model_ids:"omit"` that was written for Claude protection; Claude resolutions and garbage runtime values behave exactly as before. (#4717)

View File

@@ -2020,7 +2020,7 @@ when the two differ, and `effort_clamp_reason` explains why (`null` when unclamp
>
> [Codex CLI 0.130.0](https://github.com/openai/codex/releases/tag/rust-v0.130.0) (released 2026-05-08) removed extra-skills-roots discovery via [openai/codex#21485](https://github.com/openai/codex/pull/21485). From this version forward, Codex CLI only scans `~/.codex/skills/<name>/SKILL.md`, `<project>/.codex/skills/`, and registered plugin roots for invocable skills. GSD installs the `$gsd-*` surface as `~/.codex/skills/gsd-<name>/SKILL.md` so commands resolve after a Codex restart. Earlier Codex CLI versions can show a duplicate listing (the legacy extra-roots scan plus the user-root copies) — restart Codex and either upgrade to ≥ 0.130.0 or accept the duplicates until you do.
When GSD is installed for a non-Claude runtime, the installer automatically sets `resolve_model_ids: "omit"` in `~/.gsd/defaults.json`. This causes GSD to return an empty model parameter for all agents, so each agent uses whatever model the runtime is configured with. No additional setup is needed for the default case.
When GSD is installed for a non-Claude runtime, the installer automatically sets `resolve_model_ids: "omit"` in the shared `~/.gsd/defaults.json`, and records the runtime identity in the install's `.gsd-runtime` marker. The omit acts as a Claude protection — it keeps a Claude session on the same machine from resolving Claude-tier model IDs against a defaults file written for another runtime. A non-Claude session whose install marker names that runtime resolves its own runtime tier map instead (for example `gpt-5.6-*` on Codex), so agents use models the runtime actually has. No additional setup is needed for the default case.
If you want different agents to use different models, use `model_overrides` with fully-qualified model IDs that your runtime recognizes:
@@ -2042,8 +2042,8 @@ The intent is the same as the Claude profile tiers -- use a stronger model for p
| Scenario | Setting | Effect |
|----------|---------|--------|
| Non-Claude runtime, single model | `resolve_model_ids: "omit"` (installer default) | All agents use the runtime's default model |
| Non-Claude runtime, tiered models | `resolve_model_ids: "omit"` + `model_overrides` | Named agents use specific models, others use runtime default |
| Non-Claude runtime, single model | `resolve_model_ids: "omit"` (installer default) | Agents resolve the runtime's own tier map (runtime default where the runtime has no map) |
| Non-Claude runtime, tiered models | `resolve_model_ids: "omit"` + `model_overrides` | Named agents use specific models, others resolve from the runtime tier map |
| Claude Code with OpenRouter/local provider | `model_profile: "inherit"` | All agents follow the session model |
| Claude Code with OpenRouter, tiered | `model_profile: "inherit"` + `model_overrides` | Named agents use specific models, others inherit |
@@ -2053,7 +2053,7 @@ The intent is the same as the Claude profile tiers -- use a stronger model for p
|-------|----------|----------|
| `false` (default) | Returns Claude aliases (`opus`, `sonnet`, `haiku`) | Claude Code with native Anthropic API |
| `true` | Maps aliases to full Claude model IDs (`claude-opus-4-8`) | Claude Code with API that requires full IDs |
| `"omit"` | Returns empty string (runtime picks its default) | Non-Claude runtimes (Codex, OpenCode, Antigravity CLI, Kilo) |
| `"omit"` | Claude protection: yields an empty string when no runtime identity is known (or the value fails recognition); a runtime identified by its install marker resolves its own tier map instead | Non-Claude runtimes (Codex, OpenCode, Antigravity CLI, Kilo) |
### The `tier` Field
@@ -2133,7 +2133,7 @@ On the Claude runtime, tier resolution stays on Claude Code's adaptive tier alia
1. `model_overrides[<agent>]` — explicit per-agent ID always wins.
2. **Runtime-aware tier resolution** (this section) — when `runtime` is set and profile is not `inherit`. On non-Claude runtimes this is the built-in tier map merged with your `model_profile_overrides`; on the Claude runtime it applies only the `model_profile_overrides.claude.<tier>` entry you set (#4192) — never the built-in defaults, so unpinned installs keep resolving aliases.
3. `resolve_model_ids: "omit"` — returns empty string when no `runtime` is set (an explicit project-level `"omit"` wins over a `claude` tier override too).
3. `resolve_model_ids: "omit"` — an explicit project-level `"omit"` yields an empty string unless the runtime identity (config, environment, or install marker) names a recognized non-Claude runtime, whose own tier map then applies.
4. Claude-native default — `model_profile` tier as alias (current default).
5. `inherit` — propagates literal `inherit` for `Task(model="inherit")` semantics.

View File

@@ -844,7 +844,7 @@ For the full audit, harness reference, and the composition note with `model_prof
> **Codex CLI minimum supported version: `0.130.0`** (issue [#3562](https://github.com/open-gsd/gsd-core/issues/3562)).
If you installed GSD for a non-Claude runtime, the installer already configured model resolution. No manual setup is needed — `resolve_model_ids: "omit"` is set automatically, which tells GSD to skip Anthropic model ID resolution and let the runtime choose its own default model.
If you installed GSD for a non-Claude runtime, the installer already configured model resolution. No manual setup is needed — `resolve_model_ids: "omit"` is set automatically as a Claude protection, and your install's recorded runtime identity tells GSD to skip Anthropic model ID resolution and resolve from the runtime's own model tiers instead.
To assign different models on a non-Claude runtime:

View File

@@ -187,7 +187,7 @@ quota / rate-limit failures; other failures keep the tier ladder. Leaving
## Using GSD on non-Anthropic runtimes
If you installed GSD for Codex, OpenCode, Antigravity CLI, or Kilo, the installer already set `resolve_model_ids: "omit"` in your config. This prevents unresolved Anthropic model IDs from leaking into those runtimes. When `runtime` is set, runtime-native profile resolution still supplies any model and effort that the runtime adapter can transport. No manual setup is needed for the basic case.
If you installed GSD for Codex, OpenCode, Antigravity CLI, or Kilo, the installer already set `resolve_model_ids: "omit"` in the shared config. This prevents unresolved Anthropic model IDs from leaking into those runtimes. Your install's recorded runtime identity (the `.gsd-runtime` marker) tells GSD which runtime tier map to resolve instead, so runtime-native profile resolution still supplies any model and effort that the runtime adapter can transport. No manual setup is needed for the basic case.
### Codex routes tiers at spawn time when supported

View File

@@ -25,6 +25,9 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
// #4717: runtime-identity fill — env rung + per-install marker rung.
import { readInstallRuntimeMarker } from './runtime-slash.cjs';
import { canonicalizeRuntimeName, resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, planningRoot } = planningWorkspace;
@@ -669,7 +672,7 @@ function _warnUnusableConfig(fault: ConfigFault): void {
* cannot dirty the working tree; the ~30 callers that omit it keep persisting, so
* a legacy config is still migrated exactly once by ordinary use.
*/
function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}): ConfigResolution {
function loadConfigResolvedInternal(cwd: string, options: Record<string, unknown> = {}): ConfigResolution {
// Opt-OUT, not opt-in: omitting the option must preserve the historical
// write-back for every existing caller.
const persist = options['persist'] !== false;
@@ -1033,7 +1036,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
// `workstream: null` still wins the `hasOwnProperty` check at the top of this
// function, so spreading cannot let `workstreamContext` reintroduce a workstream.
if (wsRequested && rootParsed) {
const fb = loadConfigResolved(cwd, { ...options, workstream: null });
const fb = loadConfigResolvedInternal(cwd, { ...options, workstream: null });
return fallback({ config: fb.config, source: 'root', degraded: true });
}
@@ -1042,7 +1045,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
if (rootParsed) {
// Branch B: workstream requested but ws config.json absent; root config present.
// (Only reached when wsRequested is false — e.g. ws='' with .planning/workstreams//config.json)
const fb = loadConfigResolved(cwd, { ...options, workstream: null });
const fb = loadConfigResolvedInternal(cwd, { ...options, workstream: null });
return fallback({ config: fb.config, source: 'root', degraded: true });
}
// Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
@@ -1124,6 +1127,51 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
}
}
/**
* #4717 — fill an empty `runtime` from the environment, then the per-install
* marker. writeNonClaudeDefaults stamps `runtime` into the SHARED
* ~/.gsd/defaults.json with whichever non-Claude runtime installed first, so
* direct readers of config.runtime saw another runtime's identity (or
* nothing) on a multi-runtime machine. Copy-on-write: the builtin-defaults
* branch returns a shared object, so never assign into it. An explicit
* config.runtime is never overridden.
*/
function fillRuntimeIdentity(resolved: ConfigResolution): ConfigResolution {
const cfg = resolved?.config;
if (!cfg) return resolved;
const runtime = resolveRuntimeNameFromCandidates(
process.env['GSD_RUNTIME'],
readInstallRuntimeMarker(),
);
// Only a runtime the name policy can canonicalize is an identity. Unknown
// tokens pass THROUGH resolveRuntimeNameFromCandidates (future-runtime
// tolerance) and must not be materialized into config.runtime, where ~30
// consumers would read them — fail safe to no identity (#4717 review).
const canonicalRuntime = runtime ? canonicalizeRuntimeName(runtime) : null;
if (!canonicalRuntime) return resolved;
// Rung 1 — empty runtime: materialize THIS install's identity (env, then
// the per-install marker). An explicit runtime is never overridden.
if (!cfg['runtime']) {
return { ...resolved, config: { ...cfg, runtime: canonicalRuntime } };
}
// Rung 2 (#4717 stamped-defaults leg): the global-defaults branch forwards
// the SHARED ~/.gsd/defaults.json's runtime verbatim — whichever non-Claude
// runtime installed FIRST stamped that machine-wide file, and every other
// runtime's resolution inherited its identity (the issue's second failure
// shape). When THIS install carries its own identity (GSD_RUNTIME or the
// marker), the stamp — not the operator — is speaking: correct it. Project
// and workstream configs are explicit operator intent and are never touched;
// with no install identity of its own the stamped value stays (status quo).
if (resolved.source === 'global-defaults') {
return { ...resolved, config: { ...cfg, runtime: canonicalRuntime } };
}
return resolved;
}
function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}): ConfigResolution {
return fillRuntimeIdentity(loadConfigResolvedInternal(cwd, options));
}
/**
* loadConfig — backwards-compatible config loading, now a thin wrapper over loadConfigResolved.
* Returns the config object only; for provenance metadata use loadConfigResolved.

View File

@@ -16,7 +16,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveExplicitRuntime } from './runtime-slash.cjs';
import { resolveExplicitRuntime, readInstallRuntimeMarker } from './runtime-slash.cjs';
import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs';
import { CODEX_CONFIG_MARKER } from './update-context.cjs';
export { CODEX_CONFIG_MARKER };
@@ -139,6 +140,13 @@ export function resolveReportedRuntime(projectDir: string | null | undefined, de
function resolveReportedRuntimeUnsafe(projectDir: string | null | undefined, deps?: DetectionDeps): string {
const explicit = resolveExplicitRuntime(projectDir, deps?.env ?? process.env);
if (explicit) return explicit;
// #4717: the per-install marker names the runtime that owns THIS tree — a
// stronger signal than host sniffing, which misreports every session on a
// multi-runtime machine (e.g. a globally exported CODEX_HOME makes a Claude
// Code session read as codex). Marker-less trees (dev/source, pre-#2297
// installs) fall through to host detection unchanged.
const marker = resolveRuntimeNameFromCandidates(readInstallRuntimeMarker());
if (marker) return marker;
const detected = detectHostRuntime(deps);
if (detected.runtime) return detected.runtime;
return 'claude';

View File

@@ -603,13 +603,19 @@ function resolveModelInternal(cwd: string, agentType: string): string {
// #2517 `runtime:"codex"` + resolve_model_ids:"omit" -> the codex tier
// model. "Explicit non-Claude opt-in wins" — the operator naming a
// runtime in the project config outranks an omit.
// #2297 GSD_RUNTIME/marker = codex + a GLOBAL (defaults-poisoned) omit
// -> "" (acceptance #4). A merely DETECTED runtime does not
// constitute that opt-in, so the omit still wins.
// #4717 (user-sanctioned decision a — supersedes #2297 acceptance #4):
// a DETECTED runtime now constitutes that opt-in too. The identity
// fill in config-loader materializes GSD_RUNTIME / the per-install
// marker into `config.runtime` when it is empty, so a genuinely
// installed non-Claude runtime resolves its own tier map instead of
// the omit's "". The shared "omit" remains a Claude protection:
// marker/env = claude still ignores it (native aliases), and
// garbage runtime values still fail safe to "" (guard below).
//
// So the opt-in signal is specifically the `runtime` KEY, not the resolved
// runtime: step 3 reads the active runtime's tier map, but only outranks the
// omit gate when the operator wrote that key. Both contracts hold unchanged.
// So the opt-in signal is the `runtime` KEY — written by the operator or
// materialized by the #4717 fill: step 3 reads the active runtime's tier
// map, but only outranks the omit gate when that key canonicalizes to a
// recognised non-Claude runtime.
const omitApplies = config['resolve_model_ids'] === 'omit'
&& (projectExplicitlySetsOmit(cwd) || !RUNTIMES_WITH_NATIVE_ALIASES.has(activeRuntime));
// CANONICALIZED, not the raw field. Comparing the raw value against the literal

View File

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

View File

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

View File

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