diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index d4491288..0199dfc1 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -83,6 +83,32 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st ); } +function ensureExplicitCodexHomeDir(env: NodeJS.ProcessEnv): string | undefined { + const codexHome = env.CODEX_HOME?.trim(); + if (!codexHome) { + return undefined; + } + + try { + fs.mkdirSync(codexHome, { recursive: true }); + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code !== 'EEXIST') { + return `[X] Unable to initialize CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + } + } + + try { + if (!fs.statSync(codexHome).isDirectory()) { + return `[X] CODEX_HOME path is not a directory: ${codexHome}`; + } + return undefined; + } catch (err) { + const error = err as NodeJS.ErrnoException; + return `[X] Unable to access CODEX_HOME (${error.code || 'unknown'}): ${codexHome}`; + } +} + export class CodexAdapter implements TargetAdapter { readonly type: TargetType = 'codex'; readonly displayName = 'Codex CLI'; @@ -207,6 +233,12 @@ export class CodexAdapter implements TargetAdapter { return exitWithCleanup(1); } + const codexHomeInitError = ensureExplicitCodexHomeDir(env); + if (codexHomeInitError) { + console.error(codexHomeInitError); + return exitWithCleanup(1); + } + const isWindows = process.platform === 'win32'; const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 92539e48..83723f67 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -270,6 +270,58 @@ process.exit(0); ]); }); + it('creates an explicit CODEX_HOME directory before launching native Codex', () => { + if (process.platform === 'win32') return; + + const freshCodexHome = path.join(tmpHome, 'fresh-codex-home'); + const result = runCcsxpAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CODEX_HOME: freshCodexHome, + }); + + expect(result.status).toBe(0); + expect(fs.existsSync(freshCodexHome)).toBe(true); + expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['--version']]); + expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ + { + CODEX_HOME: freshCodexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + }, + ]); + }); + + it('fails with a clean error when CODEX_HOME points to a file', () => { + if (process.platform === 'win32') return; + + const invalidCodexHome = path.join(tmpHome, 'codex-home-file'); + fs.writeFileSync(invalidCodexHome, 'not-a-directory'); + + const result = runCcsxpAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CODEX_HOME: invalidCodexHome, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(`[X] CODEX_HOME path is not a directory: ${invalidCodexHome}`); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([]); + }); + it('keeps ccsxp pinned to native Codex even when a user passes another --target override', () => { if (process.platform === 'win32') return;