* 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:
@@ -135,6 +135,57 @@ describe('init commands: agents_installed field (#1371)', () => {
|
||||
assert.deepStrictEqual(output.missing_agents, []);
|
||||
});
|
||||
|
||||
test('init reports codex when the session signal is present', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { CODEX_SANDBOX: 'seatbelt' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'codex');
|
||||
});
|
||||
|
||||
test('init honors explicit config runtime over detection', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'claude' }),
|
||||
);
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { CODEX_SANDBOX: 'seatbelt' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'claude');
|
||||
});
|
||||
|
||||
test('init is unaffected in a non-codex session', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'claude');
|
||||
});
|
||||
|
||||
test('init honors GSD_RUNTIME over detection', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, {
|
||||
GSD_RUNTIME: 'opencode',
|
||||
CODEX_SANDBOX: 'seatbelt',
|
||||
});
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'opencode');
|
||||
});
|
||||
|
||||
test('init execute-phase includes missing_agents list when agents are missing', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
@@ -76,6 +76,16 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
|
||||
'GSD_RUNTIME',
|
||||
'GSD_PROJECT',
|
||||
'GSD_WORKSTREAM',
|
||||
// #3245: host-session signals GSD now reads (host-runtime-detection.cts's
|
||||
// detectHostRuntime / resolveReportedRuntime). Scrubbed for the same reason
|
||||
// GSD_RUNTIME is — an ambiently-set CODEX_SANDBOX / (this repo's test suite
|
||||
// running from inside a Codex session, or any host that happens to export
|
||||
// these) would non-deterministically flip the detected runtime for every
|
||||
// test that does not explicitly pass them. Tests that WANT them set still
|
||||
// can, via the per-call env override, which is applied after this base and
|
||||
// so continues to win.
|
||||
'CODEX_SANDBOX',
|
||||
'CODEX_SANDBOX_NETWORK_DISABLED',
|
||||
];
|
||||
|
||||
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
|
||||
|
||||
582
tests/host-runtime-detection.test.cjs
Normal file
582
tests/host-runtime-detection.test.cjs
Normal file
@@ -0,0 +1,582 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Unit tests for host-runtime-detection.cjs (#3245, epic #2313 Phase 5).
|
||||
*
|
||||
* Requires the COMPILED artifact:
|
||||
* ../gsd-core/bin/lib/host-runtime-detection.cjs
|
||||
*
|
||||
* detectHostRuntime is a pure, injected-deps function; resolveReportedRuntime
|
||||
* layers the explicit GSD_RUNTIME/config.json precedence (via runtime-slash's
|
||||
* resolveExplicitRuntime) above the host-detection rung. Neither writes to
|
||||
* disk (#2297).
|
||||
*/
|
||||
|
||||
const { test, describe, mock } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const LIB_DIR = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
|
||||
const {
|
||||
CODEX_SESSION_ENV_SIGNALS,
|
||||
CODEX_CONFIG_HOME_ENV,
|
||||
CODEX_CONFIG_MARKER,
|
||||
detectHostRuntime,
|
||||
resolveReportedRuntime,
|
||||
} = require(path.join(LIB_DIR, 'host-runtime-detection.cjs'));
|
||||
|
||||
const { resolveRuntime, resolveExplicitRuntime } = require(path.join(LIB_DIR, 'runtime-slash.cjs'));
|
||||
|
||||
const updateContext = require(path.join(LIB_DIR, 'update-context.cjs'));
|
||||
|
||||
const NONE_RESULT = { runtime: null, source: 'none', signal: null };
|
||||
|
||||
/**
|
||||
* Returns a fileExists spy pre-seeded with a set of "existing" paths. Every
|
||||
* probed path is recorded (normalized, backslash -> forward-slash) on
|
||||
* `.calls`, so a test can assert both the return value AND exactly which
|
||||
* paths were probed (needed for the negative-probe assertion at #13).
|
||||
*/
|
||||
function spyFileExists(existingPaths = []) {
|
||||
const normalized = new Set(existingPaths.map((p) => String(p).replace(/\\/g, '/')));
|
||||
const spy = (p) => {
|
||||
const key = String(p).replace(/\\/g, '/');
|
||||
spy.calls.push(key);
|
||||
return normalized.has(key);
|
||||
};
|
||||
spy.calls = [];
|
||||
return spy;
|
||||
}
|
||||
|
||||
/** Write `<dir>/.planning/config.json` with a raw (possibly non-JSON) string. */
|
||||
function writeConfig(dir, rawString) {
|
||||
const planningDir = path.join(dir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), rawString);
|
||||
}
|
||||
|
||||
// ─── detectHostRuntime ───────────────────────────────────────────────────
|
||||
|
||||
describe('detectHostRuntime', () => {
|
||||
test('CODEX_SANDBOX detects a codex session', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_SANDBOX: 'seatbelt' }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'session-env', signal: 'CODEX_SANDBOX' });
|
||||
});
|
||||
|
||||
test('network-disabled sandbox var detects a codex session', () => {
|
||||
const result = detectHostRuntime({
|
||||
env: { CODEX_SANDBOX_NETWORK_DISABLED: '1' },
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.deepStrictEqual(result, {
|
||||
runtime: 'codex',
|
||||
source: 'session-env',
|
||||
signal: 'CODEX_SANDBOX_NETWORK_DISABLED',
|
||||
});
|
||||
});
|
||||
|
||||
test('CODEX_HOME with config.toml detects codex', () => {
|
||||
const home = path.join(os.tmpdir(), 'fake-codex-home');
|
||||
const spy = spyFileExists([path.join(home, 'config.toml')]);
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: home }, fileExists: spy });
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'config-home', signal: 'CODEX_HOME' });
|
||||
});
|
||||
|
||||
test('session signal outranks the config-home rung', () => {
|
||||
const home = path.join(os.tmpdir(), 'fake-codex-home');
|
||||
const spy = spyFileExists([path.join(home, 'config.toml')]);
|
||||
const result = detectHostRuntime({
|
||||
env: { CODEX_SANDBOX: 'seatbelt', CODEX_HOME: home },
|
||||
fileExists: spy,
|
||||
});
|
||||
assert.strictEqual(result.source, 'session-env');
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'session-env', signal: 'CODEX_SANDBOX' });
|
||||
});
|
||||
|
||||
test('no signal resolves to no detection', () => {
|
||||
const result = detectHostRuntime({ env: {}, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('empty env value is not a signal', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_SANDBOX: '' }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('whitespace-only env value is not a signal', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_SANDBOX: ' ' }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('CODEX_HOME without config.toml is not a codex root', () => {
|
||||
const home = path.join(os.tmpdir(), 'fake-codex-home-empty');
|
||||
const spy = spyFileExists([]);
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: home }, fileExists: spy });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
assert.deepStrictEqual(spy.calls, [path.join(home, 'config.toml').replace(/\\/g, '/')]);
|
||||
});
|
||||
|
||||
test('nonexistent CODEX_HOME degrades to no detection', () => {
|
||||
const home = path.join(os.tmpdir(), 'gsd-nonexistent-codex-home-' + Date.now());
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: home }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('CODEX_HOME pointing at a file degrades to no detection', (t) => {
|
||||
const tmpFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-home-file-')), 'not-a-dir');
|
||||
fs.writeFileSync(tmpFile, 'not a codex home\n');
|
||||
t.after(() => cleanup(path.dirname(tmpFile)));
|
||||
// No `fileExists` override: exercises the real fs.existsSync default.
|
||||
assert.doesNotThrow(() => {
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: tmpFile } });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
test('empty CODEX_HOME is unset', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: '' }, fileExists: () => true });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('a throwing fileExists degrades rather than propagating', () => {
|
||||
const throwingFileExists = () => {
|
||||
const err = new Error('permission denied');
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
};
|
||||
assert.doesNotThrow(() => {
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: '/some/codex/home' }, fileExists: throwingFileExists });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
test('a throwing env degrades to the none-result rather than propagating', () => {
|
||||
// The whole detection body — not just the fileExists probe — must be
|
||||
// throw-safe: a proxied env whose GET trap throws must degrade like any
|
||||
// other unreadable signal (#3245 fix 4).
|
||||
const throwingEnv = new Proxy({}, { get() { throw new Error('boom'); } });
|
||||
assert.doesNotThrow(() => {
|
||||
const result = detectHostRuntime({ env: throwingEnv, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
test('the default codex home is never probed', () => {
|
||||
// Every machine that has ever run Codex has ~/.codex/config.toml, so
|
||||
// probing the DEFAULT home unconditionally would misreport a plain
|
||||
// Claude session as codex just because Codex was installed at some
|
||||
// point. Detection must require an explicit CODEX_HOME.
|
||||
const defaultMarker = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const spy = spyFileExists([defaultMarker]);
|
||||
const result = detectHostRuntime({ env: {}, fileExists: spy });
|
||||
assert.strictEqual(result.runtime, null);
|
||||
assert.ok(
|
||||
spy.calls.every((p) => !p.includes('/.codex/')),
|
||||
`expected no probe of the default ~/.codex root, got: ${JSON.stringify(spy.calls)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('detection never writes shared defaults or any file (#2297)', (t) => {
|
||||
const writeFileMock = mock.method(fs, 'writeFileSync', () => {});
|
||||
const appendFileMock = mock.method(fs, 'appendFileSync', () => {});
|
||||
const mkdirMock = mock.method(fs, 'mkdirSync', () => {});
|
||||
t.after(() => {
|
||||
writeFileMock.mock.restore();
|
||||
appendFileMock.mock.restore();
|
||||
mkdirMock.mock.restore();
|
||||
});
|
||||
|
||||
detectHostRuntime({ env: {}, fileExists: () => false });
|
||||
detectHostRuntime({ env: { CODEX_SANDBOX: 'seatbelt' }, fileExists: () => false });
|
||||
detectHostRuntime({ env: { CODEX_HOME: '/some/codex/home' }, fileExists: () => true });
|
||||
|
||||
assert.strictEqual(writeFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(appendFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(mkdirMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('signal table is frozen and locked', () => {
|
||||
assert.strictEqual(Object.isFrozen(CODEX_SESSION_ENV_SIGNALS), true);
|
||||
assert.deepStrictEqual(
|
||||
[...CODEX_SESSION_ENV_SIGNALS].sort(),
|
||||
['CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveReportedRuntime precedence ──────────────────────────────────
|
||||
|
||||
describe('resolveReportedRuntime: precedence', () => {
|
||||
function withProject(t) {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => {
|
||||
cleanup(tmpDir);
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
test('env runtime wins with no other signal', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: 'opencode' } });
|
||||
assert.strictEqual(result, 'opencode');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME outranks config and detection', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
const result = resolveReportedRuntime(tmpDir, {
|
||||
env: { GSD_RUNTIME: 'opencode', CODEX_SANDBOX: 'seatbelt', CODEX_SANDBOX_NETWORK_DISABLED: '1' },
|
||||
});
|
||||
assert.strictEqual(result, 'opencode');
|
||||
});
|
||||
|
||||
test('env alias normalizes before detection can fire', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: 'codex-cli' } });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('invalid GSD_RUNTIME falls through to config', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'kimi' }));
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: ' ' } });
|
||||
assert.strictEqual(result, 'kimi');
|
||||
});
|
||||
|
||||
test('explicit config runtime outranks detection (#2517 preserved)', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
|
||||
test('explicit codex config unchanged', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'codex' }));
|
||||
const result = resolveReportedRuntime(tmpDir, { env: {} });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('no signal resolves to the claude default', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: {} });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
|
||||
test('missing config file still detects', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('malformed config json falls through to detection', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, '{ this is not json');
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('non-object config json is not a runtime source', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const rawBodies = ['0', '"str"', '[]', 'null', 'true', ''];
|
||||
for (const raw of rawBodies) {
|
||||
writeConfig(tmpDir, raw);
|
||||
assert.strictEqual(
|
||||
resolveReportedRuntime(tmpDir, { env: {} }),
|
||||
'claude',
|
||||
`raw config body ${JSON.stringify(raw)} unexpectedly acted as a runtime source (no signal)`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } }),
|
||||
'codex',
|
||||
`raw config body ${JSON.stringify(raw)} unexpectedly blocked detection`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crlf config json is unaffected', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const crlfConfig = ['{', ' "runtime": "kimi"', '}'].join('\r\n');
|
||||
writeConfig(tmpDir, crlfConfig);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'kimi');
|
||||
});
|
||||
|
||||
// Deliberately NOT a "windows-shaped CODEX_HOME" test computed with
|
||||
// path.join(...) on the same POSIX host as the production code: that
|
||||
// construction passes by definition (both sides run the identical join)
|
||||
// and cannot catch a separator bug. This test instead proves path.join is
|
||||
// actually being used — a hand-rolled `home + '/' + marker` concatenation
|
||||
// would double the separator when `home` already ends in one; path.join
|
||||
// collapses it.
|
||||
test('probe path is joined, not concatenated', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const homeWithTrailingSep = path.join(os.tmpdir(), 'gsd-3245-trailing') + path.sep;
|
||||
const spy = spyFileExists([]);
|
||||
const result = resolveReportedRuntime(tmpDir, {
|
||||
env: { CODEX_HOME: homeWithTrailingSep },
|
||||
fileExists: spy,
|
||||
});
|
||||
assert.strictEqual(result, 'claude');
|
||||
assert.strictEqual(spy.calls.length, 1);
|
||||
const probedPath = spy.calls[0];
|
||||
assert.ok(
|
||||
!probedPath.includes('//'),
|
||||
`expected a normalized probe path with no doubled separator, got: ${probedPath}`,
|
||||
);
|
||||
assert.strictEqual(probedPath.split('/').pop(), CODEX_CONFIG_MARKER);
|
||||
});
|
||||
|
||||
test('a throwing env degrades to the claude default rather than propagating', (t) => {
|
||||
// resolveReportedRuntime's degraded answer is the 'claude' default, not
|
||||
// the detection none-result — matching detectHostRuntime's contract one
|
||||
// rung up the ladder (#3245 fix 4).
|
||||
const tmpDir = withProject(t);
|
||||
const throwingEnv = new Proxy({}, { get() { throw new Error('boom'); } });
|
||||
assert.doesNotThrow(() => {
|
||||
const result = resolveReportedRuntime(tmpDir, { env: throwingEnv, fileExists: () => false });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
});
|
||||
|
||||
test('detection never mutates the project config', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const before = fs.readFileSync(configPath);
|
||||
resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
const after = fs.readFileSync(configPath);
|
||||
assert.deepStrictEqual(before, after);
|
||||
});
|
||||
|
||||
test('default deps match injected deps', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const savedCodexSandbox = process.env.CODEX_SANDBOX;
|
||||
process.env.CODEX_SANDBOX = 'seatbelt';
|
||||
t.after(() => {
|
||||
if (savedCodexSandbox === undefined) delete process.env.CODEX_SANDBOX;
|
||||
else process.env.CODEX_SANDBOX = savedCodexSandbox;
|
||||
});
|
||||
|
||||
const viaDefaultDeps = resolveReportedRuntime(tmpDir);
|
||||
const viaInjectedDeps = resolveReportedRuntime(tmpDir, { env: process.env });
|
||||
assert.strictEqual(viaDefaultDeps, 'codex');
|
||||
assert.strictEqual(viaDefaultDeps, viaInjectedDeps);
|
||||
});
|
||||
|
||||
test('resolveReportedRuntime never writes shared defaults or any file (#2297)', (t) => {
|
||||
// The #2297 negative proof above only wraps detectHostRuntime, but the
|
||||
// function that actually ships is resolveReportedRuntime (called by
|
||||
// withProjectRoot). This sibling wraps IT, across all three outcomes on
|
||||
// its ladder: explicit-config hit, detection hit, and the plain 'claude'
|
||||
// default.
|
||||
//
|
||||
// Fixture setup (createTempProject/writeConfig, which themselves call
|
||||
// mkdirSync/writeFileSync) runs BEFORE the mocks are installed, so the
|
||||
// fixtures land on real disk and only resolveReportedRuntime's own
|
||||
// behavior is under observation.
|
||||
const explicitDir = createTempProject();
|
||||
writeConfig(explicitDir, JSON.stringify({ runtime: 'opencode' }));
|
||||
const detectionDir = createTempProject();
|
||||
const defaultDir = createTempProject();
|
||||
|
||||
// Each mock is restored via t.after() immediately after creation — not a
|
||||
// try/finally in the test body (CONTRIBUTING.md bans that) — so restore
|
||||
// still runs even if an earlier assertion below throws.
|
||||
const writeFileMock = mock.method(fs, 'writeFileSync', () => {});
|
||||
t.after(() => writeFileMock.mock.restore());
|
||||
const appendFileMock = mock.method(fs, 'appendFileSync', () => {});
|
||||
t.after(() => appendFileMock.mock.restore());
|
||||
const mkdirMock = mock.method(fs, 'mkdirSync', () => {});
|
||||
t.after(() => mkdirMock.mock.restore());
|
||||
t.after(() => {
|
||||
cleanup(explicitDir);
|
||||
cleanup(detectionDir);
|
||||
cleanup(defaultDir);
|
||||
});
|
||||
|
||||
// Outcome 1: explicit config.json runtime wins.
|
||||
assert.strictEqual(resolveReportedRuntime(explicitDir, { env: {} }), 'opencode');
|
||||
// Outcome 2: no explicit config; host-detection rung fires.
|
||||
assert.strictEqual(
|
||||
resolveReportedRuntime(detectionDir, { env: { CODEX_SANDBOX: 'seatbelt' } }),
|
||||
'codex',
|
||||
);
|
||||
// Outcome 3: no explicit config, no detection signal; plain default.
|
||||
assert.strictEqual(resolveReportedRuntime(defaultDir, { env: {} }), 'claude');
|
||||
|
||||
assert.strictEqual(writeFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(appendFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(mkdirMock.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── runtime-slash frozen contract ──────────────────────────────────────
|
||||
|
||||
describe('runtime-slash: frozen contract (#3245 extraction must not move it)', () => {
|
||||
test('resolveRuntime contract is unchanged by the extraction', (t) => {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
t.after(() => {
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
|
||||
assert.strictEqual(resolveRuntime(undefined), 'claude');
|
||||
assert.strictEqual(resolveRuntime(null), 'claude');
|
||||
assert.strictEqual(
|
||||
resolveRuntime(path.join(os.tmpdir(), 'gsd-nonexistent-project-' + Date.now())),
|
||||
'claude',
|
||||
);
|
||||
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'claude');
|
||||
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'opencode' }));
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'opencode');
|
||||
|
||||
process.env.GSD_RUNTIME = 'kimi';
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'kimi');
|
||||
});
|
||||
|
||||
test('resolveRuntime ignores a codex session signal', (t) => {
|
||||
// Detection is deliberately NOT wired into resolveRuntime — only
|
||||
// resolveReportedRuntime's separate ladder consults it — so command-style
|
||||
// emission (formatGsdSlash et al.) does not move (ADR-2313 scope boundary).
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
const savedCodexSandbox = process.env.CODEX_SANDBOX;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CODEX_SANDBOX = 'seatbelt';
|
||||
t.after(() => {
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
if (savedCodexSandbox === undefined) delete process.env.CODEX_SANDBOX;
|
||||
else process.env.CODEX_SANDBOX = savedCodexSandbox;
|
||||
});
|
||||
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'claude');
|
||||
});
|
||||
|
||||
test('explicit claude is distinguishable from the default', (t) => {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
t.after(() => {
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
assert.strictEqual(resolveExplicitRuntime(tmpDir), null);
|
||||
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
assert.strictEqual(resolveExplicitRuntime(tmpDir), 'claude');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Generative-fix-divergence parity with inferPreferredRuntime ────────
|
||||
|
||||
describe('parity with update-context.cjs:inferPreferredRuntime (generative-fix-divergence guard)', () => {
|
||||
// Two surfaces independently encode "what indicates Codex" —
|
||||
// inferPreferredRuntime (update.md's version-detection ladder) and
|
||||
// detectHostRuntime (init's agent_runtime rung). This pins them to agree
|
||||
// on the CODEX_HOME signal so a future edit to one cannot silently diverge
|
||||
// from the other.
|
||||
test('codex marker filename is single-sourced', () => {
|
||||
// The ONLY thing actually shared between the two functions: the marker
|
||||
// FILENAME. update-context.cjs is the owner; host-runtime-detection.cjs
|
||||
// imports and re-exports it. This asserts the re-export is the same
|
||||
// binding (strict equality), not merely an independently-matching literal.
|
||||
assert.strictEqual(CODEX_CONFIG_MARKER, 'config.toml');
|
||||
assert.strictEqual(CODEX_CONFIG_MARKER, updateContext.CODEX_CONFIG_MARKER);
|
||||
});
|
||||
|
||||
test('detection requires the marker where inferPreferredRuntime does not', () => {
|
||||
// THE DIVERGENCE POINT (deliberate, not a bug — see the header comment on
|
||||
// CODEX_CONFIG_HOME_ENV in src/host-runtime-detection.cts and the "Host
|
||||
// Runtime Detection Module" entry in CONTEXT.md). With CODEX_HOME set and
|
||||
// NO config.toml present:
|
||||
// - inferPreferredRuntime resolves an UPDATE CONTEXT, where a bare
|
||||
// CODEX_HOME is a sufficient hint -> 'codex'.
|
||||
// - detectHostRuntime asserts SESSION IDENTITY and requires the
|
||||
// stronger marker-file signal -> no detection (null).
|
||||
// This test exists so that difference is a recorded decision rather than
|
||||
// an accident: it fails loudly if either side's rule ever changes.
|
||||
const dir = path.join(os.tmpdir(), 'not-a-real-codex-home');
|
||||
const env = { CODEX_HOME: dir };
|
||||
|
||||
const inferred = updateContext.inferPreferredRuntime({
|
||||
fs: { exists: () => false, readFile: () => null },
|
||||
env,
|
||||
preferredConfigDir: '',
|
||||
});
|
||||
assert.strictEqual(inferred, 'codex');
|
||||
|
||||
const detected = detectHostRuntime({ env, fileExists: () => false });
|
||||
assert.strictEqual(detected.runtime, null);
|
||||
});
|
||||
|
||||
test('config.toml is the shared codex marker filename', () => {
|
||||
const dir = path.join(os.tmpdir(), 'preferred-config-dir');
|
||||
const marker = path.join(dir, CODEX_CONFIG_MARKER);
|
||||
|
||||
const inferred = updateContext.inferPreferredRuntime({
|
||||
fs: { exists: (p) => p === marker, readFile: () => null },
|
||||
env: {},
|
||||
preferredConfigDir: dir,
|
||||
});
|
||||
assert.strictEqual(inferred, 'codex');
|
||||
assert.strictEqual(CODEX_CONFIG_MARKER, 'config.toml');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectHostRuntime: properties', () => {
|
||||
const FC_OPTS = { numRuns: 200, seed: 32452843, verbose: true };
|
||||
|
||||
test('property — no signal key implies no detection', () => {
|
||||
const excludedKeys = new Set([...CODEX_SESSION_ENV_SIGNALS, CODEX_CONFIG_HOME_ENV]);
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.dictionary(
|
||||
fc.string({ minLength: 1, maxLength: 12 }).filter((k) => !excludedKeys.has(k)),
|
||||
fc.string(),
|
||||
),
|
||||
(env) => {
|
||||
const result = detectHostRuntime({ env, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
},
|
||||
),
|
||||
FC_OPTS,
|
||||
);
|
||||
});
|
||||
|
||||
test('property — any non-empty signal value detects codex', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...CODEX_SESSION_ENV_SIGNALS),
|
||||
fc.string().filter((s) => s.trim() !== ''),
|
||||
(key, value) => {
|
||||
const result = detectHostRuntime({ env: { [key]: value }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'session-env', signal: key });
|
||||
},
|
||||
),
|
||||
FC_OPTS,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user