diff --git a/cli/src/codex/utils/codexVersion.test.ts b/cli/src/codex/utils/codexVersion.test.ts new file mode 100644 index 00000000..83354149 --- /dev/null +++ b/cli/src/codex/utils/codexVersion.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileSyncMock } = vi.hoisted(() => ({ + execFileSyncMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFileSync: execFileSyncMock +})) + +import { + assertCodexLocalSupported, + isCodexVersionAtLeast, + MIN_CODEX_HOOKS_VERSION, + parseCodexVersion +} from './codexVersion' + +describe('codexVersion', () => { + beforeEach(() => { + execFileSyncMock.mockReset() + }) + + describe('parseCodexVersion', () => { + it('extracts the version from codex --version output', () => { + expect(parseCodexVersion('codex-cli 0.124.0')).toBe('0.124.0') + }) + + it('returns null when the output does not contain a semver', () => { + expect(parseCodexVersion('codex-cli version unknown')).toBeNull() + }) + }) + + describe('isCodexVersionAtLeast', () => { + it('accepts the minimum supported version', () => { + expect(isCodexVersionAtLeast('0.124.0', MIN_CODEX_HOOKS_VERSION)).toBe(true) + }) + + it('accepts newer patch and minor versions', () => { + expect(isCodexVersionAtLeast('0.124.1', MIN_CODEX_HOOKS_VERSION)).toBe(true) + expect(isCodexVersionAtLeast('0.125.0', MIN_CODEX_HOOKS_VERSION)).toBe(true) + }) + + it('rejects older versions', () => { + expect(isCodexVersionAtLeast('0.123.9', MIN_CODEX_HOOKS_VERSION)).toBe(false) + }) + }) + + describe('assertCodexLocalSupported', () => { + it('passes when codex is new enough', () => { + execFileSyncMock.mockReturnValueOnce('codex-cli 0.124.0\n') + + expect(() => assertCodexLocalSupported()).not.toThrow() + expect(execFileSyncMock).toHaveBeenCalledWith('codex', ['--version'], expect.objectContaining({ + encoding: 'utf8' + })) + }) + + it('fails when codex is too old', () => { + execFileSyncMock.mockReturnValueOnce('codex-cli 0.123.9\n') + + expect(() => assertCodexLocalSupported()).toThrow( + 'Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Detected: 0.123.9. Please upgrade Codex and retry.' + ) + }) + + it('fails when the version output cannot be parsed', () => { + execFileSyncMock.mockReturnValueOnce('codex-cli version unknown\n') + + expect(() => assertCodexLocalSupported()).toThrow( + 'Could not determine Codex CLI version. Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Please upgrade Codex and retry.' + ) + }) + + it('fails when codex is not available on PATH', () => { + const error = new Error('spawnSync codex ENOENT') as NodeJS.ErrnoException + error.code = 'ENOENT' + execFileSyncMock.mockImplementationOnce(() => { + throw error + }) + + expect(() => assertCodexLocalSupported()).toThrow( + 'Codex CLI 0.124.0+ is required for hapi codex local mode because HAPI depends on stable hooks. Codex was not found on PATH. Please install or upgrade Codex and retry.' + ) + }) + }) +}) diff --git a/cli/src/codex/utils/codexVersion.ts b/cli/src/codex/utils/codexVersion.ts new file mode 100644 index 00000000..f91adbcc --- /dev/null +++ b/cli/src/codex/utils/codexVersion.ts @@ -0,0 +1,93 @@ +import { execFileSync } from 'node:child_process' +import { withBunRuntimeEnv } from '@/utils/bunRuntime' + +export const MIN_CODEX_HOOKS_VERSION = '0.124.0' + +const SEMVER_PATTERN = /\b(\d+)\.(\d+)\.(\d+)\b/ + +function parseVersionTuple(value: string): [number, number, number] | null { + const match = value.match(SEMVER_PATTERN) + if (!match) { + return null + } + + return [ + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + Number.parseInt(match[3], 10) + ] +} + +function getLocalModeRequirementMessage(): string { + return `Codex CLI ${MIN_CODEX_HOOKS_VERSION}+ is required for hapi codex local mode because HAPI depends on stable hooks.` +} + +export function parseCodexVersion(output: string): string | null { + const tuple = parseVersionTuple(output) + if (!tuple) { + return null + } + + return tuple.join('.') +} + +export function isCodexVersionAtLeast(version: string, minimum: string): boolean { + const versionTuple = parseVersionTuple(version) + const minimumTuple = parseVersionTuple(minimum) + + if (!versionTuple || !minimumTuple) { + throw new Error('Invalid semver value') + } + + for (let i = 0; i < versionTuple.length; i++) { + if (versionTuple[i] > minimumTuple[i]) { + return true + } + if (versionTuple[i] < minimumTuple[i]) { + return false + } + } + + return true +} + +export function assertCodexLocalSupported(): void { + let output: string + + try { + output = execFileSync('codex', ['--version'], { + encoding: 'utf8', + env: withBunRuntimeEnv(), + shell: process.platform === 'win32' + }).trim() + } catch (error) { + const maybeError = error as NodeJS.ErrnoException + const message = maybeError?.message ? ` ${maybeError.message}` : '' + + if (maybeError?.code === 'ENOENT') { + throw new Error( + `${getLocalModeRequirementMessage()} Codex was not found on PATH. Please install or upgrade Codex and retry.`, + { cause: error } + ) + } + + throw new Error( + `Could not determine Codex CLI version.${message} ` + + `${getLocalModeRequirementMessage()} Please upgrade Codex and retry.`, + { cause: error } + ) + } + + const version = parseCodexVersion(output) + if (!version) { + throw new Error( + `Could not determine Codex CLI version. ${getLocalModeRequirementMessage()} Please upgrade Codex and retry.` + ) + } + + if (!isCodexVersionAtLeast(version, MIN_CODEX_HOOKS_VERSION)) { + throw new Error( + `${getLocalModeRequirementMessage()} Detected: ${version}. Please upgrade Codex and retry.` + ) + } +} diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts new file mode 100644 index 00000000..a1182287 --- /dev/null +++ b/cli/src/commands/codex.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + initializeTokenMock, + maybeAutoStartServerMock, + authAndSetupMachineIfNeededMock, + assertCodexLocalSupportedMock, + runCodexMock +} = vi.hoisted(() => ({ + initializeTokenMock: vi.fn(async () => {}), + maybeAutoStartServerMock: vi.fn(async () => {}), + authAndSetupMachineIfNeededMock: vi.fn(async () => {}), + assertCodexLocalSupportedMock: vi.fn(), + runCodexMock: vi.fn(async () => {}) +})) + +vi.mock('@/ui/tokenInit', () => ({ + initializeToken: initializeTokenMock +})) + +vi.mock('@/utils/autoStartServer', () => ({ + maybeAutoStartServer: maybeAutoStartServerMock +})) + +vi.mock('@/ui/auth', () => ({ + authAndSetupMachineIfNeeded: authAndSetupMachineIfNeededMock +})) + +vi.mock('@/codex/utils/codexVersion', () => ({ + assertCodexLocalSupported: assertCodexLocalSupportedMock +})) + +vi.mock('@/codex/runCodex', () => ({ + runCodex: runCodexMock +})) + +import { codexCommand } from './codex' + +function createCommandContext(commandArgs: string[]) { + return { + args: ['codex', ...commandArgs], + commandArgs + } +} + +describe('codexCommand', () => { + beforeEach(() => { + initializeTokenMock.mockClear() + maybeAutoStartServerMock.mockClear() + authAndSetupMachineIfNeededMock.mockClear() + assertCodexLocalSupportedMock.mockClear() + runCodexMock.mockClear() + }) + + it('checks Codex version before starting a local session', async () => { + await codexCommand.run(createCommandContext([])) + + expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce() + expect(initializeTokenMock).toHaveBeenCalledOnce() + expect(maybeAutoStartServerMock).toHaveBeenCalledOnce() + expect(authAndSetupMachineIfNeededMock).toHaveBeenCalledOnce() + expect(runCodexMock).toHaveBeenCalledWith({}) + }) + + it('checks Codex version before resuming a local session', async () => { + await codexCommand.run(createCommandContext(['resume', 'session-123'])) + + expect(assertCodexLocalSupportedMock).toHaveBeenCalledOnce() + expect(runCodexMock).toHaveBeenCalledWith({ + resumeSessionId: 'session-123' + }) + }) + + it('skips the local version check for runner-started sessions', async () => { + await codexCommand.run(createCommandContext(['--started-by', 'runner'])) + + expect(assertCodexLocalSupportedMock).not.toHaveBeenCalled() + expect(runCodexMock).toHaveBeenCalledWith({ + startedBy: 'runner' + }) + }) + + it('prints the upgrade error and exits when the local version check fails', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + + assertCodexLocalSupportedMock.mockImplementationOnce(() => { + throw new Error('Codex CLI 0.124.0+ is required') + }) + + try { + await expect(codexCommand.run(createCommandContext([]))).rejects.toThrow('process.exit:1') + + expect(runCodexMock).not.toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Codex CLI 0.124.0+ is required') + } finally { + consoleErrorSpy.mockRestore() + exitSpy.mockRestore() + } + }) +}) diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index b97baa90..8db32de4 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -6,6 +6,7 @@ import type { CommandDefinition } from './types' import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort } from '@/codex/appServerTypes' +import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' function parseReasoningEffort(value: string): ReasoningEffort { switch (value) { @@ -83,6 +84,10 @@ export const codexCommand: CommandDefinition = { options.codexArgs = unknownArgs } + if (options.startedBy !== 'runner') { + assertCodexLocalSupported() + } + await initializeToken() await maybeAutoStartServer() await authAndSetupMachineIfNeeded()