* 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:
145
src/host-runtime-detection.cts
Normal file
145
src/host-runtime-detection.cts
Normal 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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user