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
+24 -7
View File
@@ -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()
});
+5 -2
View File
@@ -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,
+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()
})