diff --git a/test/codex-e2e-sol-scope.test.ts b/test/codex-e2e-sol-scope.test.ts index 395d96746..862f7b8de 100644 --- a/test/codex-e2e-sol-scope.test.ts +++ b/test/codex-e2e-sol-scope.test.ts @@ -25,8 +25,19 @@ const CODEX_AVAILABLE = spawnSync('which', ['codex'], { timeout: 30_000 }).statu // The run pins the model with --ignore-user-config; older codex CLIs reject // the flag with an argv error indistinguishable from a Sol regression, so // probe support and skip (not fail) on old CLIs. -const IGNORE_USER_CONFIG_SUPPORTED = CODEX_AVAILABLE - && (spawnSync('codex', ['exec', '--help'], { encoding: 'utf8', timeout: 120_000 }).stdout ?? '').includes('--ignore-user-config'); +// The probe runs in a private HOME and CODEX_HOME: every codex run writes +// $CODEX_HOME/tmp/arg0, and module load must never touch the real ~/.codex. +function codexSupportsIgnoreUserConfig(): boolean { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-sol-probe-')); + try { + fs.mkdirSync(path.join(home, '.codex')); + const help = spawnSync('codex', ['exec', '--help'], { + encoding: 'utf8', timeout: 120_000, env: { ...process.env, HOME: home, CODEX_HOME: path.join(home, '.codex') }, + }); + return (help.stdout ?? '').includes('--ignore-user-config'); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +} +const IGNORE_USER_CONFIG_SUPPORTED = CODEX_AVAILABLE && codexSupportsIgnoreUserConfig(); const evalsEnabled = !!process.env.EVALS; // External-service test — periodic tier only (CLAUDE.md tiering rule 3). The // positive guard shape below is what classifyPaidTestFile greps to exclude diff --git a/test/codex-hardening.test.ts b/test/codex-hardening.test.ts index 120a217ee..0a76657d7 100644 --- a/test/codex-hardening.test.ts +++ b/test/codex-hardening.test.ts @@ -956,3 +956,39 @@ describe('codex broken-install detection (#2742)', () => { expect(src).toContain('_CODEX_MP=$?'); }); }); + +// --- Live codex smokes keep away from the real Codex home ------------------- + +describe('live codex smokes run codex in a private HOME and CODEX_HOME', () => { + // Every codex run writes $CODEX_HOME/tmp/arg0. The two smokes below probe the + // CLI at module load or in free-tier tests, so with a real codex on PATH they + // used to write into the developer's ~/.codex. + for (const file of ['test/codex-resume-flag-semantics.test.ts', 'test/codex-e2e-sol-scope.test.ts']) { + test(`${file} never runs codex with the caller's HOME or Codex home`, () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-codex-smoke-home-')); + try { + const stubDir = path.join(home, 'stub-bin'); + const log = path.join(home, 'codex-calls.log'); + fs.mkdirSync(stubDir); + fs.writeFileSync(path.join(stubDir, 'codex'), `#!/usr/bin/env bash +printf '%s|%s\\n' "$HOME" "\${CODEX_HOME:-unset}" >> "${log}" +mkdir -p "\${CODEX_HOME:-$HOME/.codex}/tmp" && : > "\${CODEX_HOME:-$HOME/.codex}/tmp/arg0" +printf 'Usage: codex exec [OPTIONS]\\n -c, --config sandbox_mode\\n --ignore-user-config\\n' +`, { mode: 0o755 }); + const r = spawnSync(process.execPath, ['test', file], { + cwd: ROOT, encoding: 'utf8', timeout: 120_000, + env: { PATH: `${stubDir}:${process.env.PATH ?? ''}`, HOME: home, TMPDIR: os.tmpdir() }, + }); + expect(r.status).toBe(0); + const calls = fs.readFileSync(log, 'utf8').trim().split('\n'); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + const [callHome, codexHome] = call.split('|'); + expect({ call, privateHome: callHome !== home, privateCodexHome: codexHome !== 'unset' && !codexHome!.startsWith(home) }) + .toEqual({ call, privateHome: true, privateCodexHome: true }); + } + expect(fs.existsSync(path.join(home, '.codex'))).toBe(false); + } finally { fs.rmSync(home, { recursive: true, force: true }); } + }); + } +}); diff --git a/test/codex-resume-flag-semantics.test.ts b/test/codex-resume-flag-semantics.test.ts index 8f702ef2b..3db172290 100644 --- a/test/codex-resume-flag-semantics.test.ts +++ b/test/codex-resume-flag-semantics.test.ts @@ -12,11 +12,20 @@ * (the periodic-tier eval runners) will exercise it. */ -import { describe, test, expect } from 'bun:test'; +import { afterAll, describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; const codexPath = spawnSync('which', ['codex'], { encoding: 'utf-8', timeout: 30_000 }).stdout.trim(); const codexAvailable = codexPath.length > 0; +// Every codex run writes $CODEX_HOME/tmp/arg0, so the smoke gets a private +// HOME and CODEX_HOME and never touches the developer's real ~/.codex. +const smokeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resume-smoke-')); +fs.mkdirSync(path.join(smokeHome, '.codex')); +const smokeEnv = { ...process.env, HOME: smokeHome, CODEX_HOME: path.join(smokeHome, '.codex') }; +afterAll(() => fs.rmSync(smokeHome, { recursive: true, force: true })); describe.skipIf(!codexAvailable)( 'codex exec resume — flag semantics (live CLI smoke; closes #1270 regex-only gap)', @@ -26,6 +35,7 @@ describe.skipIf(!codexAvailable)( encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10_000, + env: smokeEnv, }); const helpText = (result.stdout || '') + '\n' + (result.stderr || ''); // The /codex skill builds resume invocations with `-c 'sandbox_mode="read-only"'`. @@ -39,6 +49,7 @@ describe.skipIf(!codexAvailable)( encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10_000, + env: smokeEnv, }); const helpText = (result.stdout || '') + '\n' + (result.stderr || ''); // The whole point of #1270 was that `codex exec resume` rejects `-C `.