fix(cli): Fix Codex CLI execution issue in PowerShell with Hapi Codex (#763)

* fix(cli): fixed an issue where the codex cli failed to run successfully when using hapi codex in powershell

* fix(cli): Fixes the issue of Windows Codex npm shim bypassing the launcher

---------

Co-authored-by: xhd902 <xuhang@infypower.cn>
This commit is contained in:
Ein
2026-06-01 12:23:34 +08:00
committed by GitHub
co-authored by xhd902
parent ec1ab23e6c
commit d78cf4b171
6 changed files with 323 additions and 14 deletions
+184
View File
@@ -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<typeof import('node:child_process')>('node:child_process');
return {
...actual,
execFileSync: execFileSyncMock
};
});
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
existsSync: existsSyncMock
};
});
vi.mock('node:os', async () => {
const actual = await vi.importActual<typeof import('node:os')>('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: []
});
});
});
+75
View File
@@ -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();
}
+32 -4
View File
@@ -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', () => {
+3 -1
View File
@@ -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()
})