Skip to content

Commit d78cf4b

Browse files
xuansheepxhd902
andauthored
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>
1 parent ec1ab23 commit d78cf4b

6 files changed

Lines changed: 323 additions & 14 deletions

File tree

cli/src/codex/codexLocal.test.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import { win32 } from 'node:path';
12
import { beforeEach, describe, expect, it, vi } from 'vitest';
23

3-
const { spawnWithTerminalGuardMock } = vi.hoisted(() => ({
4+
const { resolveCodexCommandMock, spawnWithTerminalGuardMock } = vi.hoisted(() => ({
5+
resolveCodexCommandMock: vi.fn(() => ({ command: 'codex', args: [] as string[] })),
46
spawnWithTerminalGuardMock: vi.fn(async (_options: unknown) => {})
57
}));
68

9+
vi.mock('./utils/codexExecutable', () => ({
10+
resolveCodexCommand: resolveCodexCommandMock
11+
}));
12+
713
vi.mock('@/utils/spawnWithTerminalGuard', () => ({
814
spawnWithTerminalGuard: spawnWithTerminalGuardMock
915
}));
@@ -16,6 +22,10 @@ vi.mock('@/ui/logger', () => ({
1622

1723
import { codexLocal, filterResumeSubcommand } from './codexLocal';
1824

25+
const codexScriptPath = win32.join('toolchains', 'nodejs', 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
26+
const hapiCommandPath = win32.join('hapi-bin', 'hapi.exe');
27+
const workspacePath = win32.join('workspace', 'project');
28+
1929
describe('filterResumeSubcommand', () => {
2030
it('returns empty array unchanged', () => {
2131
expect(filterResumeSubcommand([])).toEqual([]);
@@ -50,20 +60,26 @@ describe('filterResumeSubcommand', () => {
5060

5161
describe('codexLocal', () => {
5262
beforeEach(() => {
63+
resolveCodexCommandMock.mockReset();
64+
resolveCodexCommandMock.mockReturnValue({ command: 'codex', args: [] as string[] });
5365
spawnWithTerminalGuardMock.mockClear();
5466
});
5567

56-
it('launches codex without shell so Windows keeps -c config values as argv elements', async () => {
68+
it('launches the resolved Codex command without shell so Windows keeps -c config values as argv elements', async () => {
5769
const controller = new AbortController();
70+
resolveCodexCommandMock.mockReturnValue({
71+
command: 'node',
72+
args: [codexScriptPath]
73+
});
5874

5975
await codexLocal({
6076
abort: controller.signal,
6177
sessionId: null,
62-
path: 'C:\\workspace\\project',
78+
path: workspacePath,
6379
onSessionFound: vi.fn(),
6480
mcpServers: {
6581
hapi: {
66-
command: 'C:\\Users\\test\\AppData\\Local\\hapi.exe',
82+
command: hapiCommandPath,
6783
args: ['mcp', '--url', 'http://127.0.0.1:63995/']
6884
}
6985
},
@@ -81,12 +97,13 @@ describe('codexLocal', () => {
8197
shell?: unknown;
8298
};
8399
expect(spawnOptions).toEqual(expect.objectContaining({
84-
command: 'codex',
85-
cwd: 'C:\\workspace\\project'
100+
command: 'node',
101+
cwd: workspacePath
86102
}));
87103
expect(spawnOptions).not.toHaveProperty('shell');
88104

89105
const args = spawnOptions.args;
106+
expect(args[0]).toBe(codexScriptPath);
90107
const hookArg = args.find((arg) => arg.startsWith('hooks.SessionStart='));
91108
expect(hookArg).toBeDefined();
92109
expect(hookArg).toContain('{ hooks = [{ type = "command", command = "');
@@ -99,7 +116,7 @@ describe('codexLocal', () => {
99116
await codexLocal({
100117
abort: controller.signal,
101118
sessionId: 'codex-session-1',
102-
path: '/workspace/project',
119+
path: workspacePath,
103120
modelReasoningEffort: 'high',
104121
onSessionFound: vi.fn()
105122
});

cli/src/codex/codexLocal.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from './utils/codexMcpConfig';
99
import { codexSystemPrompt } from './utils/systemPrompt';
1010
import type { ReasoningEffort } from './appServerTypes';
11+
import { resolveCodexCommand } from './utils/codexExecutable';
1112

1213
/**
1314
* Filter out 'resume' subcommand which is managed internally by hapi.
@@ -86,9 +87,11 @@ export async function codexLocal(opts: {
8687
return;
8788
}
8889

90+
const codexCommand = resolveCodexCommand();
91+
8992
await spawnWithTerminalGuard({
90-
command: 'codex',
91-
args,
93+
command: codexCommand.command,
94+
args: [...codexCommand.args, ...args],
9295
cwd: opts.path,
9396
env: process.env,
9497
signal: opts.abort,
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { win32 } from 'node:path';
2+
import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { execFileSyncMock, existsSyncMock, homedirMock } = vi.hoisted(() => ({
5+
execFileSyncMock: vi.fn(),
6+
existsSyncMock: vi.fn(),
7+
homedirMock: vi.fn(() => 'home\junes')
8+
}));
9+
10+
vi.mock('node:child_process', async () => {
11+
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process');
12+
return {
13+
...actual,
14+
execFileSync: execFileSyncMock
15+
};
16+
});
17+
18+
vi.mock('node:fs', async () => {
19+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
20+
return {
21+
...actual,
22+
existsSync: existsSyncMock
23+
};
24+
});
25+
26+
vi.mock('node:os', async () => {
27+
const actual = await vi.importActual<typeof import('node:os')>('node:os');
28+
return {
29+
...actual,
30+
homedir: homedirMock
31+
};
32+
});
33+
34+
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
35+
const homeDir = win32.join('home', 'junes');
36+
const nodeRoot = win32.join('toolchains', 'nodejs');
37+
38+
function codexShimPath(): string {
39+
return win32.join(nodeRoot, 'codex.cmd');
40+
}
41+
42+
function nativeCodexPath(): string {
43+
return win32.join(
44+
nodeRoot,
45+
'node_modules',
46+
'@openai',
47+
'codex',
48+
'node_modules',
49+
'@openai',
50+
'codex-win32-x64',
51+
'vendor',
52+
'x86_64-pc-windows-msvc',
53+
'bin',
54+
'codex.exe'
55+
);
56+
}
57+
58+
function codexScriptPath(): string {
59+
return win32.join(nodeRoot, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
60+
}
61+
62+
function userCodexExePath(): string {
63+
return win32.join(homeDir, '.local', 'bin', 'codex.exe');
64+
}
65+
66+
function setPlatform(value: string) {
67+
Object.defineProperty(process, 'platform', {
68+
value,
69+
configurable: true
70+
});
71+
}
72+
73+
describe('resolveCodexCommand', () => {
74+
beforeAll(() => {
75+
if (!originalPlatformDescriptor?.configurable) {
76+
throw new Error('process.platform is not configurable in this runtime');
77+
}
78+
});
79+
80+
beforeEach(() => {
81+
vi.clearAllMocks();
82+
vi.resetModules();
83+
homedirMock.mockReturnValue(homeDir);
84+
execFileSyncMock.mockImplementation(() => {
85+
throw new Error('not found');
86+
});
87+
existsSyncMock.mockReturnValue(false);
88+
});
89+
90+
afterAll(() => {
91+
if (originalPlatformDescriptor) {
92+
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
93+
}
94+
});
95+
96+
it('resolves a Windows npm codex.cmd shim through the Codex launcher', async () => {
97+
setPlatform('win32');
98+
const shim = codexShimPath();
99+
const laterExe = userCodexExePath();
100+
const executable = nativeCodexPath();
101+
const script = codexScriptPath();
102+
execFileSyncMock.mockImplementation((command: string, args: string[]) => {
103+
if (command === 'where.exe' && args[0] === 'codex') {
104+
return `${shim}\r\n${laterExe}\r\n`;
105+
}
106+
throw new Error('not found');
107+
});
108+
existsSyncMock.mockImplementation((candidate: string) =>
109+
candidate === shim || candidate === laterExe || candidate === executable || candidate === script
110+
);
111+
const { resolveCodexCommand } = await import('./codexExecutable');
112+
113+
expect(resolveCodexCommand()).toEqual({
114+
command: 'node',
115+
args: [script]
116+
});
117+
});
118+
119+
it('continues to the next Windows PATH candidate when a shim has no launcher script', async () => {
120+
setPlatform('win32');
121+
const shim = codexShimPath();
122+
const executable = userCodexExePath();
123+
execFileSyncMock.mockImplementation((command: string, args: string[]) => {
124+
if (command === 'where.exe' && args[0] === 'codex') {
125+
return `${shim}\r\n${executable}\r\n`;
126+
}
127+
throw new Error('not found');
128+
});
129+
existsSyncMock.mockImplementation((candidate: string) => candidate === shim || candidate === executable);
130+
const { resolveCodexCommand } = await import('./codexExecutable');
131+
132+
expect(resolveCodexCommand()).toEqual({
133+
command: executable,
134+
args: []
135+
});
136+
});
137+
138+
it('keeps a Windows codex.exe found first on PATH', async () => {
139+
setPlatform('win32');
140+
const executable = userCodexExePath();
141+
execFileSyncMock.mockImplementation((command: string, args: string[]) => {
142+
if (command === 'where.exe' && args[0] === 'codex') {
143+
return `${executable}\r\n`;
144+
}
145+
throw new Error('not found');
146+
});
147+
existsSyncMock.mockImplementation((candidate: string) => candidate === executable);
148+
const { resolveCodexCommand } = await import('./codexExecutable');
149+
150+
expect(resolveCodexCommand()).toEqual({
151+
command: executable,
152+
args: []
153+
});
154+
});
155+
156+
it('falls back to node plus codex.js when a Windows shim has no native exe', async () => {
157+
setPlatform('win32');
158+
const shim = codexShimPath();
159+
const script = codexScriptPath();
160+
execFileSyncMock.mockImplementation((command: string, args: string[]) => {
161+
if (command === 'where.exe' && args[0] === 'codex') {
162+
return `${shim}\r\n`;
163+
}
164+
throw new Error('not found');
165+
});
166+
existsSyncMock.mockImplementation((candidate: string) => candidate === shim || candidate === script);
167+
const { resolveCodexCommand } = await import('./codexExecutable');
168+
169+
expect(resolveCodexCommand()).toEqual({
170+
command: 'node',
171+
args: [script]
172+
});
173+
});
174+
175+
it('uses the plain codex command outside Windows', async () => {
176+
setPlatform('linux');
177+
const { resolveCodexCommand } = await import('./codexExecutable');
178+
179+
expect(resolveCodexCommand()).toEqual({
180+
command: 'codex',
181+
args: []
182+
});
183+
});
184+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { existsSync } from 'node:fs';
3+
import { homedir } from 'node:os';
4+
import path from 'node:path';
5+
6+
const windowsPath = path.win32;
7+
8+
export interface CodexCommand {
9+
command: string;
10+
args: string[];
11+
}
12+
13+
function findWhereResults(command: string): string[] {
14+
try {
15+
const result = execFileSync('where.exe', [command], {
16+
encoding: 'utf8',
17+
stdio: ['pipe', 'pipe', 'pipe'],
18+
cwd: homedir()
19+
});
20+
21+
return result
22+
.split(/\r?\n/)
23+
.map((line) => line.trim())
24+
.filter(Boolean);
25+
} catch {
26+
return [];
27+
}
28+
}
29+
30+
function resolveShimScript(shimPath: string): string | null {
31+
const shimDirectory = windowsPath.dirname(shimPath);
32+
const script = windowsPath.join(shimDirectory, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');
33+
34+
if (existsSync(script)) {
35+
return script;
36+
}
37+
38+
return null;
39+
}
40+
41+
function resolveWindowsCandidate(candidate: string): CodexCommand | null {
42+
if (!existsSync(candidate)) {
43+
return null;
44+
}
45+
46+
if (windowsPath.extname(candidate).toLowerCase() === '.exe') {
47+
return { command: candidate, args: [] };
48+
}
49+
50+
const script = resolveShimScript(candidate);
51+
if (script) {
52+
return { command: 'node', args: [script] };
53+
}
54+
55+
return null;
56+
}
57+
58+
function resolveWindowsCodexCommand(): CodexCommand {
59+
for (const candidate of findWhereResults('codex')) {
60+
const resolved = resolveWindowsCandidate(candidate);
61+
if (resolved) {
62+
return resolved;
63+
}
64+
}
65+
66+
return { command: 'codex', args: [] };
67+
}
68+
69+
export function resolveCodexCommand(): CodexCommand {
70+
if (process.platform !== 'win32') {
71+
return { command: 'codex', args: [] };
72+
}
73+
74+
return resolveWindowsCodexCommand();
75+
}

0 commit comments

Comments
 (0)