enhance(#3245): report the detected host runtime in init (#3307)

* test(#3245): failing-first coverage for host runtime detection in init

Locks the behavior epic #2313 Phase 5 must produce before any of it exists: init reports the detected host, explicit GSD_RUNTIME and config runtime still outrank detection, non-Codex sessions are untouched, and nothing is ever written to shared defaults (#2297).

* enhance(#3245): report the detected host runtime in init

init reported agent_runtime: claude inside a Codex session, and resolved agents_dir to the Claude agents root with agents_installed: true — a spuriously healthy triple. Runtime identity was only ever read from GSD_RUNTIME or an explicit runtime in .planning/config.json.

Adds a detection rung beneath both explicit sources, in a new pure module. Codex is identified from its own documented session environment (CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED), else an explicitly exported CODEX_HOME whose config.toml exists. The default ~/.codex is never probed: that file exists on every machine that has run Codex, so probing it would misreport other runtimes' sessions.

resolveRuntime keeps its exact contract and all 71 dependents, including formatGsdSlash command-style emission; only withProjectRoot consumes the new rung. Nothing is written on any path (#2297). Explicit config still wins (#2517).

* fix(#3245): make the parity guard real and single-source the marker

Four independent review passes found the generative-fix-divergence guard was vacuous: it asserted agreement at the one input where inferPreferredRuntime and detectHostRuntime do not differ, so it could not fail. It now pins the actual divergence point (CODEX_HOME set, config.toml absent) and records that the asymmetry is deliberate.

The config.toml marker is now single-sourced from update-context.cts and imported, rather than carried independently by two surfaces. tests/helpers.cjs now scrubs CODEX_SANDBOX and CODEX_SANDBOX_NETWORK_DISABLED: GSD reads them, so an ambient Codex session would otherwise make the non-codex control test fail non-deterministically.

Also: detection is throw-safe end to end rather than only around the fs probe; the Windows-join test is replaced with one that can actually fail (trailing-separator, catches hand-rolled concatenation); the #2297 no-write proof now wraps resolveReportedRuntime, the function that ships, across all three ladder outcomes.

* chore(#3245): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-10 09:48:26 -04:00
committed by GitHub
parent 57437071e7
commit 96a82bbffb
16 changed files with 926 additions and 12 deletions

View File

@@ -0,0 +1,145 @@
/**
* Host runtime detection — ADR-2313 Phase 5 (#3245, folded from #2320).
*
* `init`'s reported `agent_runtime` was hardcoding `claude` even when run
* inside a Codex session, because resolveRuntime's ladder only checks the
* explicit `GSD_RUNTIME` env var and `.planning/config.json`'s `runtime`
* field — there was no fallback that looked at the actual host process.
*
* This module adds a host-detection rung strictly BELOW those two explicit
* sources: it only runs when neither `GSD_RUNTIME` nor config `runtime` is
* set. It never writes anything (#2297 — no shared-defaults poisoning: this
* module never touches .planning/config.json or any other file). It never
* shells out, so there is no subprocess to time-bound — detection is pure
* env-var and existence-check inspection.
*/
import fs from 'node:fs';
import path from 'node:path';
import { resolveExplicitRuntime } from './runtime-slash.cjs';
import { CODEX_CONFIG_MARKER } from './update-context.cjs';
export { CODEX_CONFIG_MARKER };
export type DetectionSource = 'session-env' | 'config-home' | 'none';
export interface HostRuntimeDetection {
runtime: string | null;
source: DetectionSource;
signal: string | null;
}
export interface DetectionDeps {
env?: Record<string, string | undefined>;
fileExists?: (p: string) => boolean;
}
// Codex sandbox env vars set by the shell tool / Seatbelt child-process spawn.
// Evidence: openai/codex AGENTS.md — "The sandbox environment automatically
// sets CODEX_SANDBOX_NETWORK_DISABLED=1 when using the shell tool, and
// CODEX_SANDBOX=seatbelt for child processes spawned via Seatbelt." (injected
// by spawn_child_async in codex-rs/core/src/spawn.rs). These are absent under
// sandbox_mode = "danger-full-access", so this signal is best-effort and
// degrades to the default when unset.
//
// Note: CODEX_THREAD_ID (which appears in src/active-workstream-store.cts's
// WORKSTREAM_SESSION_ENV_KEYS) is deliberately NOT used here — it is
// undocumented in Codex's published env-var reference and source, so it
// fails this repo's citation bar.
export const CODEX_SESSION_ENV_SIGNALS: readonly string[] = Object.freeze([
'CODEX_SANDBOX',
'CODEX_SANDBOX_NETWORK_DISABLED',
]);
// Evidence: learn.chatgpt.com/docs/config-file/environment-variables —
// "Sets the root for Codex state, including config…". The marker FILENAME
// (`config.toml`) is single-sourced from `update-context.cts`'s
// `inferPreferredRuntime` (imported above and re-exported for existing
// importers) — that is the only thing shared between the two functions. The
// TRUTHINESS RULE deliberately differs: `inferPreferredRuntime` treats a
// bare, unchecked `CODEX_HOME` as sufficient to resolve an update context,
// while THIS module additionally requires the marker file to exist, because
// it is asserting session identity rather than resolving an update context
// and needs the stronger signal. That difference is intentional and pinned
// by a test in `tests/host-runtime-detection.test.cjs` rather than left
// implicit.
//
// The DEFAULT `~/.codex/config.toml` is deliberately NEVER probed here:
// every machine that has ever run Codex has that file, so probing it
// unconditionally would misreport Claude Code sessions (or any other
// runtime) as codex just because Codex was installed at some point. An
// explicitly-exported CODEX_HOME is the user designating a Codex root for
// the CURRENT session, which is a much stronger signal.
export const CODEX_CONFIG_HOME_ENV = 'CODEX_HOME';
// The degraded no-detection result. Also the fallback returned when ANY step
// of detection throws (see the module's stated no-throw premise below).
const NO_DETECTION: HostRuntimeDetection = { runtime: null, source: 'none', signal: null };
/**
* Detect the host runtime from process environment signals, without ever
* consulting the explicit GSD_RUNTIME/config.json sources (those are a
* higher-priority rung handled by resolveExplicitRuntime).
*
* Never throws: the whole body — including the raw `env[key]` reads, which a
* caller could supply as a throwing Proxy — is wrapped in a single guarded
* region that degrades to `NO_DETECTION` on any unexpected error, rather than
* only guarding the `fileExists` probe.
*/
export function detectHostRuntime(deps?: DetectionDeps): HostRuntimeDetection {
try {
const env = deps?.env ?? process.env;
const fileExists = deps?.fileExists ?? ((p: string) => fs.existsSync(p));
for (const key of CODEX_SESSION_ENV_SIGNALS) {
const value = env[key];
if (typeof value === 'string' && value.trim() !== '') {
return { runtime: 'codex', source: 'session-env', signal: key };
}
}
const codexHome = env[CODEX_CONFIG_HOME_ENV];
if (typeof codexHome === 'string' && codexHome.trim() !== '') {
try {
if (fileExists(path.join(codexHome, CODEX_CONFIG_MARKER))) {
return { runtime: 'codex', source: 'config-home', signal: CODEX_CONFIG_HOME_ENV };
}
} catch {
// Swallow probe failures (EACCES etc.) and fall through to no-detection.
}
}
return NO_DETECTION;
} catch {
// A malformed `deps.env` (e.g. a throwing Proxy) must degrade like any
// other unreadable signal, not propagate — this function's contract is
// that it never throws.
return NO_DETECTION;
}
}
/**
* Resolve the runtime to report from init: explicit sources first, then the
* host-detection rung, then the 'claude' default. This is intentionally
* separate from resolveRuntime — only init's agent_runtime reporting call
* site uses this ladder; every other resolveRuntime caller is unaffected.
*
* Never throws: degrades to the 'claude' default on any unexpected error
* (e.g. a throwing `deps.env`), matching detectHostRuntime's no-throw
* contract.
*/
export function resolveReportedRuntime(projectDir: string | null | undefined, deps?: DetectionDeps): string {
try {
return resolveReportedRuntimeUnsafe(projectDir, deps);
} catch {
return 'claude';
}
}
function resolveReportedRuntimeUnsafe(projectDir: string | null | undefined, deps?: DetectionDeps): string {
const explicit = resolveExplicitRuntime(projectDir, deps?.env ?? process.env);
if (explicit) return explicit;
const detected = detectHostRuntime(deps);
if (detected.runtime) return detected.runtime;
return 'claude';
}

View File

@@ -35,6 +35,7 @@ import { maskIfSecret } from './secrets.cjs';
import scanPhasePlans = require('./plan-scan.cjs');
import { stateExtractField } from './state-document.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { resolveReportedRuntime } from './host-runtime-detection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
import commandsMod = require('./commands.cjs');
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
@@ -304,7 +305,8 @@ function getLatestCompletedMilestone(cwd: string): { version: string; name: stri
function withProjectRoot(cwd: string, result: Record<string, unknown>): Record<string, unknown> {
result['project_root'] = cwd;
const activeRuntime = resolveRuntime(cwd);
// #3245: the reported agent_runtime gets a host-detection rung below the two explicit sources; every other resolveRuntime caller keeps the old ladder (ADR-2313 scope boundary).
const activeRuntime = resolveReportedRuntime(cwd);
const agentStatus = checkAgentsInstalled(activeRuntime, cwd);
result['agents_installed'] = agentStatus.agents_installed;
result['missing_agents'] = agentStatus.missing_agents;

View File

@@ -74,19 +74,24 @@ export function formatGsdSlash(commandName: unknown, runtime: unknown): unknown
}
/**
* Resolve the effective runtime for a project directory.
* Resolve the explicit runtime for a project directory, from the two
* explicit sources only — no default is applied.
*
* process.env.GSD_RUNTIME > config.runtime > 'claude'
* env.GSD_RUNTIME > config.runtime > null
*
* Mirrors the precedence already used by profile-output.cjs and the rest of
* the runtime resolution chain. Returns a lowercased string so downstream
* comparisons can be case-blind.
* Returns null when neither explicit source is set, so a caller can
* distinguish "config said claude" from "nothing was set" (needed by
* #3245's host-detection rung, which must only run when this returns null).
*
* @param projectDir - path to the project directory, or null/undefined
* @returns the resolved runtime name
* @param env - environment variables to read GSD_RUNTIME from; defaults to process.env
* @returns the resolved runtime name, or null if neither source is set
*/
export function resolveRuntime(projectDir: string | null | undefined): string {
const envRuntime = resolveRuntimeNameFromCandidates(process.env['GSD_RUNTIME']);
export function resolveExplicitRuntime(
projectDir: string | null | undefined,
env: Record<string, string | undefined> = process.env,
): string | null {
const envRuntime = resolveRuntimeNameFromCandidates(env['GSD_RUNTIME']);
if (envRuntime) return envRuntime;
if (projectDir) {
try {
@@ -109,7 +114,23 @@ export function resolveRuntime(projectDir: string | null | undefined): string {
// runtime output formatting.
}
}
return 'claude';
return null;
}
/**
* Resolve the effective runtime for a project directory.
*
* process.env.GSD_RUNTIME > config.runtime > 'claude'
*
* Mirrors the precedence already used by profile-output.cjs and the rest of
* the runtime resolution chain. Returns a lowercased string so downstream
* comparisons can be case-blind.
*
* @param projectDir - path to the project directory, or null/undefined
* @returns the resolved runtime name
*/
export function resolveRuntime(projectDir: string | null | undefined): string {
return resolveExplicitRuntime(projectDir) ?? 'claude';
}
/**

View File

@@ -35,6 +35,13 @@ export const RUNTIME_DIRS: RuntimeDirEntry[] = [
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
// Shared Codex config-root marker filename. Single-sourced here (this module
// is the pre-existing, dependency-free owner) and re-exported by
// host-runtime-detection.cts, whose detection rung reuses this exact probe
// filename (though NOT the truthiness rule below — see that module's header
// comment for the deliberate divergence).
export const CODEX_CONFIG_MARKER = 'config.toml';
function expandHome(p: string | undefined | null, home: string): string {
if (!p) return '';
return p.startsWith('~/') ? path.join(home, p.slice(2)) : p;
@@ -81,7 +88,7 @@ export function inferPreferredRuntime({ fs, env, preferredConfigDir }: InferPref
fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo';
if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) ||
fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode';
if (fs.exists(path.join(preferredConfigDir, 'config.toml'))) return 'codex';
if (fs.exists(path.join(preferredConfigDir, CODEX_CONFIG_MARKER))) return 'codex';
}
if (env['CODEX_HOME']) return 'codex';
if (env['ANTIGRAVITY_CONFIG_DIR']) return 'antigravity';