'use strict'; process.env.MSD_TEST_MODE = '1'; /** * Unit tests for host-runtime-detection.cjs (#3245, epic #2313 Phase 5). * * Requires the COMPILED artifact: * ../msd-core/bin/lib/host-runtime-detection.cjs * * detectHostRuntime is a pure, injected-deps function; resolveReportedRuntime * layers the explicit MSD_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, '..', 'msd-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 `/.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(), 'msd-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(), 'msd-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 savedMsdRuntime = process.env.MSD_RUNTIME; delete process.env.MSD_RUNTIME; const tmpDir = createTempProject(); t.after(() => { cleanup(tmpDir); if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME; else process.env.MSD_RUNTIME = savedMsdRuntime; }); return tmpDir; } test('env runtime wins with no other signal', (t) => { const tmpDir = withProject(t); const result = resolveReportedRuntime(tmpDir, { env: { MSD_RUNTIME: 'opencode' } }); assert.strictEqual(result, 'opencode'); }); test('MSD_RUNTIME outranks config and detection', (t) => { const tmpDir = withProject(t); writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' })); const result = resolveReportedRuntime(tmpDir, { env: { MSD_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: { MSD_RUNTIME: 'codex-cli' } }); assert.strictEqual(result, 'codex'); }); test('invalid MSD_RUNTIME falls through to config', (t) => { const tmpDir = withProject(t); writeConfig(tmpDir, JSON.stringify({ runtime: 'cursor' })); const result = resolveReportedRuntime(tmpDir, { env: { MSD_RUNTIME: ' ' } }); assert.strictEqual(result, 'cursor'); }); 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": "cursor"', '}'].join('\r\n'); writeConfig(tmpDir, crlfConfig); const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } }); assert.strictEqual(result, 'cursor'); }); // 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(), 'msd-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 savedMsdRuntime = process.env.MSD_RUNTIME; delete process.env.MSD_RUNTIME; t.after(() => { if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME; else process.env.MSD_RUNTIME = savedMsdRuntime; }); assert.strictEqual(resolveRuntime(undefined), 'claude'); assert.strictEqual(resolveRuntime(null), 'claude'); assert.strictEqual( resolveRuntime(path.join(os.tmpdir(), 'msd-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.MSD_RUNTIME = 'cursor'; assert.strictEqual(resolveRuntime(tmpDir), 'cursor'); }); 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 (formatMsdSlash et al.) does not move (ADR-2313 scope boundary). const savedMsdRuntime = process.env.MSD_RUNTIME; const savedCodexSandbox = process.env.CODEX_SANDBOX; delete process.env.MSD_RUNTIME; process.env.CODEX_SANDBOX = 'seatbelt'; t.after(() => { if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME; else process.env.MSD_RUNTIME = savedMsdRuntime; 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 savedMsdRuntime = process.env.MSD_RUNTIME; delete process.env.MSD_RUNTIME; t.after(() => { if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME; else process.env.MSD_RUNTIME = savedMsdRuntime; }); 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, ); }); }); // ── #4717 — the per-install .msd-runtime marker outranks host detection ────── // On a multi-runtime machine (CODEX_HOME exported globally), host sniffing // misreports every Claude Code session as codex. The per-install marker names // the runtime that owns THIS tree — a stronger signal than host detection. // Ladder: explicit (MSD_RUNTIME / config.runtime) > marker > host detection > // 'claude'. Marker-less trees (dev/source, pre-#2297 installs) are unchanged. describe('resolveReportedRuntime: install-marker rung (#4717)', () => { const slash = require('../msd-core/bin/lib/runtime-slash.cjs'); // Self-contained withProject: clears MSD_RUNTIME (the marker rung sits below // it) and scaffolds a throwaway project — scoped to this describe so the // sibling precedence describe's own helper is untouched. function withProject4717(t) { const savedMsdRuntime = process.env.MSD_RUNTIME; delete process.env.MSD_RUNTIME; const tmpDir = createTempProject(); t.after(() => { cleanup(tmpDir); if (savedMsdRuntime === undefined) delete process.env.MSD_RUNTIME; else process.env.MSD_RUNTIME = savedMsdRuntime; }); return tmpDir; } function writeConfig4717(tmpDir, runtime) { fs.writeFileSync( path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime }), ); } test('install marker outranks host detection (claude session on a codex-sniffing machine)', (t) => { const tmpDir = withProject4717(t); slash._setInstallRuntimeMarkerForTests('claude'); t.after(() => slash._resetInstallRuntimeMarkerCacheForTests()); // CODEX_SANDBOX makes host detection report codex — the issue's repro shape. const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt', CODEX_SANDBOX_NETWORK_DISABLED: '1' }, }); assert.strictEqual(result, 'claude', 'the marker (claude) must outrank host detection (codex) on the owning tree'); }); test('install marker supplies the runtime when host detection finds nothing (codex install)', (t) => { const tmpDir = withProject4717(t); slash._setInstallRuntimeMarkerForTests('codex'); t.after(() => slash._resetInstallRuntimeMarkerCacheForTests()); const result = resolveReportedRuntime(tmpDir, { env: {} }); assert.strictEqual(result, 'codex', 'a codex-owned tree reports codex even where host sniffing would say claude'); }); test('explicit MSD_RUNTIME still outranks the marker (#4717 ladder rung 1)', (t) => { const tmpDir = withProject4717(t); slash._setInstallRuntimeMarkerForTests('codex'); t.after(() => slash._resetInstallRuntimeMarkerCacheForTests()); const result = resolveReportedRuntime(tmpDir, { env: { MSD_RUNTIME: 'claude' } }); assert.strictEqual(result, 'claude'); }); test('explicit config runtime still outranks the marker (#4717 ladder rung 1)', (t) => { const tmpDir = withProject4717(t); writeConfig4717(tmpDir, 'cursor'); slash._setInstallRuntimeMarkerForTests('codex'); t.after(() => slash._resetInstallRuntimeMarkerCacheForTests()); const result = resolveReportedRuntime(tmpDir, { env: {} }); assert.strictEqual(result, 'cursor'); }); test('no marker: host detection unchanged (dev/source trees, pre-#2297 installs)', (t) => { const tmpDir = withProject4717(t); slash._resetInstallRuntimeMarkerCacheForTests(); const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } }); assert.strictEqual(result, 'codex', 'host detection remains the fallback without a marker'); }); });