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.