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

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