guard local codex startup by version

This commit is contained in:
weishu
2026-04-24 11:17:39 +08:00
parent 8e54bbeb53
commit d87b6c6996
4 changed files with 287 additions and 0 deletions
+86
View File
@@ -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.'
)
})
})
})
+93
View File
@@ -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.`
)
}
}
+103
View File
@@ -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()
}
})
})
+5
View File
@@ -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()