mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
guard local codex startup by version
This commit is contained in:
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user