test: live codex smokes run codex in a private HOME and CODEX_HOME

codex-resume-flag-semantics and codex-e2e-sol-scope probe the codex CLI (the
latter at module load, in every free run), and every codex run writes
$CODEX_HOME/tmp/arg0, so with a real codex on PATH they wrote into the
developer's ~/.codex. Both now give codex a private HOME and CODEX_HOME and
remove it afterwards. A free test runs each file against a stub codex that
records HOME and CODEX_HOME and asserts neither is the caller's.
This commit is contained in:
garrytan committed 2026-10-06 20:43:44 +00:00
1 parent f6d2f3376d
commit 75a56be46f
3 files changed
+61 -3

No files matched your search

+13 -2
View File
@@ -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
+36
View File
@@ -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 <key=value> 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 }); }
});
}
});
+12 -1
View File
@@ -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 <dir>`.