mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: []
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user