diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts index 3c8654ef..0bd649bd 100644 --- a/cli/src/codex/codexLocal.test.ts +++ b/cli/src/codex/codexLocal.test.ts @@ -1,9 +1,15 @@ +import { win32 } from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { spawnWithTerminalGuardMock } = vi.hoisted(() => ({ +const { resolveCodexCommandMock, spawnWithTerminalGuardMock } = vi.hoisted(() => ({ + resolveCodexCommandMock: vi.fn(() => ({ command: 'codex', args: [] as string[] })), spawnWithTerminalGuardMock: vi.fn(async (_options: unknown) => {}) })); +vi.mock('./utils/codexExecutable', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})); + vi.mock('@/utils/spawnWithTerminalGuard', () => ({ spawnWithTerminalGuard: spawnWithTerminalGuardMock })); @@ -16,6 +22,10 @@ vi.mock('@/ui/logger', () => ({ import { codexLocal, filterResumeSubcommand } from './codexLocal'; +const codexScriptPath = win32.join('toolchains', 'nodejs', 'node_modules', '@openai', 'codex', 'bin', 'codex.js'); +const hapiCommandPath = win32.join('hapi-bin', 'hapi.exe'); +const workspacePath = win32.join('workspace', 'project'); + describe('filterResumeSubcommand', () => { it('returns empty array unchanged', () => { expect(filterResumeSubcommand([])).toEqual([]); @@ -50,20 +60,26 @@ describe('filterResumeSubcommand', () => { describe('codexLocal', () => { beforeEach(() => { + resolveCodexCommandMock.mockReset(); + resolveCodexCommandMock.mockReturnValue({ command: 'codex', args: [] as string[] }); spawnWithTerminalGuardMock.mockClear(); }); - it('launches codex without shell so Windows keeps -c config values as argv elements', async () => { + it('launches the resolved Codex command without shell so Windows keeps -c config values as argv elements', async () => { const controller = new AbortController(); + resolveCodexCommandMock.mockReturnValue({ + command: 'node', + args: [codexScriptPath] + }); await codexLocal({ abort: controller.signal, sessionId: null, - path: 'C:\\workspace\\project', + path: workspacePath, onSessionFound: vi.fn(), mcpServers: { hapi: { - command: 'C:\\Users\\test\\AppData\\Local\\hapi.exe', + command: hapiCommandPath, args: ['mcp', '--url', 'http://127.0.0.1:63995/'] } }, @@ -81,12 +97,13 @@ describe('codexLocal', () => { shell?: unknown; }; expect(spawnOptions).toEqual(expect.objectContaining({ - command: 'codex', - cwd: 'C:\\workspace\\project' + command: 'node', + cwd: workspacePath })); expect(spawnOptions).not.toHaveProperty('shell'); const args = spawnOptions.args; + expect(args[0]).toBe(codexScriptPath); const hookArg = args.find((arg) => arg.startsWith('hooks.SessionStart=')); expect(hookArg).toBeDefined(); expect(hookArg).toContain('{ hooks = [{ type = "command", command = "'); @@ -99,7 +116,7 @@ describe('codexLocal', () => { await codexLocal({ abort: controller.signal, sessionId: 'codex-session-1', - path: '/workspace/project', + path: workspacePath, modelReasoningEffort: 'high', onSessionFound: vi.fn() }); diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index 8b458c62..a72ee38a 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -8,6 +8,7 @@ import { } from './utils/codexMcpConfig'; import { codexSystemPrompt } from './utils/systemPrompt'; import type { ReasoningEffort } from './appServerTypes'; +import { resolveCodexCommand } from './utils/codexExecutable'; /** * Filter out 'resume' subcommand which is managed internally by hapi. @@ -86,9 +87,11 @@ export async function codexLocal(opts: { return; } + const codexCommand = resolveCodexCommand(); + await spawnWithTerminalGuard({ - command: 'codex', - args, + command: codexCommand.command, + args: [...codexCommand.args, ...args], cwd: opts.path, env: process.env, signal: opts.abort, diff --git a/cli/src/codex/utils/codexExecutable.test.ts b/cli/src/codex/utils/codexExecutable.test.ts new file mode 100644 index 00000000..8a5a182c --- /dev/null +++ b/cli/src/codex/utils/codexExecutable.test.ts @@ -0,0 +1,184 @@ +import { win32 } from 'node:path'; +import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execFileSyncMock, existsSyncMock, homedirMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn(), + existsSyncMock: vi.fn(), + homedirMock: vi.fn(() => 'home\junes') +})); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execFileSync: execFileSyncMock + }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: existsSyncMock + }; +}); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: homedirMock + }; +}); + +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); +const homeDir = win32.join('home', 'junes'); +const nodeRoot = win32.join('toolchains', 'nodejs'); + +function codexShimPath(): string { + return win32.join(nodeRoot, 'codex.cmd'); +} + +function nativeCodexPath(): string { + return win32.join( + nodeRoot, + 'node_modules', + '@openai', + 'codex', + 'node_modules', + '@openai', + 'codex-win32-x64', + 'vendor', + 'x86_64-pc-windows-msvc', + 'bin', + 'codex.exe' + ); +} + +function codexScriptPath(): string { + return win32.join(nodeRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'); +} + +function userCodexExePath(): string { + return win32.join(homeDir, '.local', 'bin', 'codex.exe'); +} + +function setPlatform(value: string) { + Object.defineProperty(process, 'platform', { + value, + configurable: true + }); +} + +describe('resolveCodexCommand', () => { + beforeAll(() => { + if (!originalPlatformDescriptor?.configurable) { + throw new Error('process.platform is not configurable in this runtime'); + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + homedirMock.mockReturnValue(homeDir); + execFileSyncMock.mockImplementation(() => { + throw new Error('not found'); + }); + existsSyncMock.mockReturnValue(false); + }); + + afterAll(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor); + } + }); + + it('resolves a Windows npm codex.cmd shim through the Codex launcher', async () => { + setPlatform('win32'); + const shim = codexShimPath(); + const laterExe = userCodexExePath(); + const executable = nativeCodexPath(); + const script = codexScriptPath(); + execFileSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === 'where.exe' && args[0] === 'codex') { + return `${shim}\r\n${laterExe}\r\n`; + } + throw new Error('not found'); + }); + existsSyncMock.mockImplementation((candidate: string) => + candidate === shim || candidate === laterExe || candidate === executable || candidate === script + ); + const { resolveCodexCommand } = await import('./codexExecutable'); + + expect(resolveCodexCommand()).toEqual({ + command: 'node', + args: [script] + }); + }); + + it('continues to the next Windows PATH candidate when a shim has no launcher script', async () => { + setPlatform('win32'); + const shim = codexShimPath(); + const executable = userCodexExePath(); + execFileSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === 'where.exe' && args[0] === 'codex') { + return `${shim}\r\n${executable}\r\n`; + } + throw new Error('not found'); + }); + existsSyncMock.mockImplementation((candidate: string) => candidate === shim || candidate === executable); + const { resolveCodexCommand } = await import('./codexExecutable'); + + expect(resolveCodexCommand()).toEqual({ + command: executable, + args: [] + }); + }); + + it('keeps a Windows codex.exe found first on PATH', async () => { + setPlatform('win32'); + const executable = userCodexExePath(); + execFileSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === 'where.exe' && args[0] === 'codex') { + return `${executable}\r\n`; + } + throw new Error('not found'); + }); + existsSyncMock.mockImplementation((candidate: string) => candidate === executable); + const { resolveCodexCommand } = await import('./codexExecutable'); + + expect(resolveCodexCommand()).toEqual({ + command: executable, + args: [] + }); + }); + + it('falls back to node plus codex.js when a Windows shim has no native exe', async () => { + setPlatform('win32'); + const shim = codexShimPath(); + const script = codexScriptPath(); + execFileSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === 'where.exe' && args[0] === 'codex') { + return `${shim}\r\n`; + } + throw new Error('not found'); + }); + existsSyncMock.mockImplementation((candidate: string) => candidate === shim || candidate === script); + const { resolveCodexCommand } = await import('./codexExecutable'); + + expect(resolveCodexCommand()).toEqual({ + command: 'node', + args: [script] + }); + }); + + it('uses the plain codex command outside Windows', async () => { + setPlatform('linux'); + const { resolveCodexCommand } = await import('./codexExecutable'); + + expect(resolveCodexCommand()).toEqual({ + command: 'codex', + args: [] + }); + }); +}); diff --git a/cli/src/codex/utils/codexExecutable.ts b/cli/src/codex/utils/codexExecutable.ts new file mode 100644 index 00000000..1cba91c9 --- /dev/null +++ b/cli/src/codex/utils/codexExecutable.ts @@ -0,0 +1,75 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +const windowsPath = path.win32; + +export interface CodexCommand { + command: string; + args: string[]; +} + +function findWhereResults(command: string): string[] { + try { + const result = execFileSync('where.exe', [command], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: homedir() + }); + + return result + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +function resolveShimScript(shimPath: string): string | null { + const shimDirectory = windowsPath.dirname(shimPath); + const script = windowsPath.join(shimDirectory, 'node_modules', '@openai', 'codex', 'bin', 'codex.js'); + + if (existsSync(script)) { + return script; + } + + return null; +} + +function resolveWindowsCandidate(candidate: string): CodexCommand | null { + if (!existsSync(candidate)) { + return null; + } + + if (windowsPath.extname(candidate).toLowerCase() === '.exe') { + return { command: candidate, args: [] }; + } + + const script = resolveShimScript(candidate); + if (script) { + return { command: 'node', args: [script] }; + } + + return null; +} + +function resolveWindowsCodexCommand(): CodexCommand { + for (const candidate of findWhereResults('codex')) { + const resolved = resolveWindowsCandidate(candidate); + if (resolved) { + return resolved; + } + } + + return { command: 'codex', args: [] }; +} + +export function resolveCodexCommand(): CodexCommand { + if (process.platform !== 'win32') { + return { command: 'codex', args: [] }; + } + + return resolveWindowsCodexCommand(); +} diff --git a/cli/src/codex/utils/codexVersion.test.ts b/cli/src/codex/utils/codexVersion.test.ts index 4452c6a4..e2b57571 100644 --- a/cli/src/codex/utils/codexVersion.test.ts +++ b/cli/src/codex/utils/codexVersion.test.ts @@ -1,9 +1,15 @@ +import { win32 } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { spawnSyncMock } = vi.hoisted(() => ({ +const { resolveCodexCommandMock, spawnSyncMock } = vi.hoisted(() => ({ + resolveCodexCommandMock: vi.fn(() => ({ command: 'codex', args: [] as string[] })), spawnSyncMock: vi.fn() })) +vi.mock('./codexExecutable', () => ({ + resolveCodexCommand: resolveCodexCommandMock +})) + vi.mock('cross-spawn', () => ({ default: { sync: spawnSyncMock @@ -17,8 +23,12 @@ import { parseCodexVersion } from './codexVersion' +const codexScriptPath = win32.join('toolchains', 'nodejs', 'node_modules', '@openai', 'codex', 'bin', 'codex.js') + describe('codexVersion', () => { beforeEach(() => { + resolveCodexCommandMock.mockReset() + resolveCodexCommandMock.mockReturnValue({ command: 'codex', args: [] as string[] }) spawnSyncMock.mockReset() }) @@ -48,6 +58,27 @@ describe('codexVersion', () => { }) describe('assertCodexLocalSupported', () => { + it('checks the resolved Codex command', () => { + resolveCodexCommandMock.mockReturnValue({ + command: 'node', + args: [codexScriptPath] + }) + spawnSyncMock.mockReturnValueOnce({ + status: 0, + stdout: 'codex-cli 0.124.0\n', + stderr: '' + }) + + expect(() => assertCodexLocalSupported()).not.toThrow() + expect(spawnSyncMock).toHaveBeenCalledWith( + 'node', + [codexScriptPath, '--version'], + expect.objectContaining({ + encoding: 'utf8' + }) + ) + }) + it('passes when codex is new enough', () => { spawnSyncMock.mockReturnValueOnce({ status: 0, @@ -56,9 +87,6 @@ describe('codexVersion', () => { }) expect(() => assertCodexLocalSupported()).not.toThrow() - expect(spawnSyncMock).toHaveBeenCalledWith('codex', ['--version'], expect.objectContaining({ - encoding: 'utf8' - })) }) it('fails when codex is too old', () => { diff --git a/cli/src/codex/utils/codexVersion.ts b/cli/src/codex/utils/codexVersion.ts index 02fc1e0c..8e885408 100644 --- a/cli/src/codex/utils/codexVersion.ts +++ b/cli/src/codex/utils/codexVersion.ts @@ -1,5 +1,6 @@ import spawn from 'cross-spawn' import { withBunRuntimeEnv } from '@/utils/bunRuntime' +import { resolveCodexCommand } from './codexExecutable' export const MIN_CODEX_HOOKS_VERSION = '0.124.0' @@ -53,8 +54,9 @@ export function isCodexVersionAtLeast(version: string, minimum: string): boolean export function assertCodexLocalSupported(): void { let output: string + const codexCommand = resolveCodexCommand() - const result = spawn.sync('codex', ['--version'], { + const result = spawn.sync(codexCommand.command, [...codexCommand.args, '--version'], { encoding: 'utf8', env: withBunRuntimeEnv() })