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