* 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:
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user