* 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>
146 lines
6.3 KiB
TypeScript
146 lines
6.3 KiB
TypeScript
/**
|
|
* 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';
|
|
}
|