Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a65628b
Require Codex 0.136.0 for native chats
brennanb2025 Oct 8, 2026
478feac
Add host-owned Codex install and update actions
brennanb2025 Oct 8, 2026
0dcaf11
Bind Codex checks and maintenance to the selected installation
brennanb2025 Oct 8, 2026
243d0d2
Resolve Windows updater cleanup through system helpers
brennanb2025 Oct 8, 2026
18a2986
Use one child environment for Codex checks and launch
brennanb2025 Oct 8, 2026
3e9dfd1
Expire Codex readiness and reconcile current host jobs
brennanb2025 Oct 8, 2026
b0a33c8
Resolve Codex checks against the child command search path
brennanb2025 Oct 8, 2026
1de680d
Restrict global npm repair to the selected Codex install layout
brennanb2025 Oct 8, 2026
68dba1b
Pin the host updater directory through Windows supervision
brennanb2025 Oct 8, 2026
4ae68b3
Synchronize Codex maintenance fixtures on readiness
brennanb2025 Oct 8, 2026
2eff4a7
Unify Codex notices and recover version-refused chats
brennanb2025 Oct 8, 2026
078f557
Route Codex recovery to the operation that remains
brennanb2025 Oct 8, 2026
679dfaa
Preserve Codex setup execution errors in the job log
brennanb2025 Oct 8, 2026
34fdf80
Preserve UTF-8 in Codex setup logs
brennanb2025 Oct 8, 2026
a5a5ec3
Remove unused Codex host installation detector
brennanb2025 Oct 8, 2026
8d1cbca
Merge origin/main into Codex minimum-version branch
brennanb2025 Oct 8, 2026
93066a4
Require host administration for Codex maintenance
brennanb2025 Oct 8, 2026
a7a661f
Show a missing Codex explanation once
brennanb2025 Oct 8, 2026
cda2bbe
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-mi…
brennanb2025 Oct 9, 2026
4436b0a
test(native-chat): give scripted Codex runtimes their installation fa…
brennanb2025 Oct 9, 2026
a80b83a
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-mi…
brennanb2025 Oct 9, 2026
e4fcbf1
refactor(native-chat): drop the Update Codex action; keep Install for…
brennanb2025 Oct 9, 2026
f80b429
refactor(native-chat): keep Install Codex in Settings only
brennanb2025 Oct 10, 2026
fe68bd3
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-mi…
brennanb2025 Oct 10, 2026
3583f3a
refactor(codex): check chat compatibility when acquiring the child
brennanb2025 Oct 12, 2026
76f4861
Restore workspace submit guard and simplify Codex maintenance
brennanb2025 Oct 12, 2026
afd3c58
Keep only the Codex chat minimum version
brennanb2025 Oct 12, 2026
aff57f9
Merge origin/main into brennanb2025/codex-minimum-version
brennanb2025 Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/main/agent-cli-version-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export async function probeAgentCliVersion(
input: Pick<ProcessSpec, 'program' | 'cwd' | 'env'>,
supports: (version: string) => boolean
): Promise<boolean> {
const result = await readAgentCliVersion(input)
if (result.version === null) {
return false
}
if (!supports(result.version)) {
warnRefused(input.program, `${result.version} is not a supported release`)
return false
}
console.info(`[agent-cli-version] ${input.program} --version: ${result.version} is supported`)
return true
}

export async function readAgentCliVersion(
input: Pick<ProcessSpec, 'program' | 'cwd' | 'env'>,
parseVersion: typeof parseCliVersion = parseCliVersion
): Promise<{ version: string | null }> {
let result
try {
result = await runProcess({
Expand All @@ -29,7 +45,7 @@ export async function probeAgentCliVersion(
})
} catch (error) {
warnRefused(input.program, `did not start (${String(error)})`)
return false
return { version: null }
}
const output = `${result.stdout}\n${result.stderr}`
if (result.timedOut || result.outputTruncated || result.code !== 0) {
Expand All @@ -39,19 +55,14 @@ export async function probeAgentCliVersion(
? 'printed too much'
: `exited ${result.code ?? result.signal}`
warnRefused(input.program, why, output)
return false
return { version: null }
}
const version = parseCliVersion(result.stdout)
const version = parseVersion(parseVersion === parseCliVersion ? result.stdout : output)
if (version === null) {
warnRefused(input.program, 'printed no version', output)
return false
return { version: null }
}
if (!supports(version)) {
warnRefused(input.program, `${version} is not a supported release`)
return false
}
console.info(`[agent-cli-version] ${input.program} --version: ${version} is supported`)
return true
return { version }
}

/** A stable release at or after `floor`, on any later major line too. */
Expand Down
75 changes: 75 additions & 0 deletions src/main/codex/codex-cli-installation-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { CodexInstallationProblem } from '../../shared/codex-cli-installation'
import { sayAgentSessionFailureEnglish } from '../../shared/agent-session-failure-copy'
import { readCodexCliInstallation } from '../preflight/codex-cli-installation'
import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentLaunchResolver } from '../runtime/structured-agent-launch-composition'
import {
resolveProviderChildEnv,
type ProviderProcessLaunch
} from '../provider-process/provider-process-launch'

export class CodexCliInstallationError extends AgentSessionPreSpawnError {
readonly codexInstallation: CodexInstallationProblem

constructor(problem: CodexInstallationProblem) {
super(
sayAgentSessionFailureEnglish(
problem.installedVersion === null ? 'cliMissing' : 'codexCliTooOld',
{
agent: 'Codex',
installedVersion: problem.installedVersion ?? '',
minimumVersion: problem.minimumVersion
}
)
)
this.codexInstallation = problem
}
}

export function codexInstallationProblemOf(
error: unknown,
depth = 0
): CodexInstallationProblem | undefined {
if (depth >= 6 || !(error instanceof Error)) {
return undefined
}
if (error instanceof CodexCliInstallationError) {
return error.codexInstallation
}
return codexInstallationProblemOf(error.cause, depth + 1)
}
Comment on lines +29 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Traverse AggregateError in codexInstallationProblemOf, as argumentProblemOf does.

argumentProblemOf in src/main/native-chat/structured-agent-arguments-error.ts:25-44 checks each entry of AggregateError.errors. codexInstallationProblemOf checks only error.cause. The acquisition path uses both helpers in failedAcquisitionDetails, failedAcquisitionRefusal, and providerStartupFailureFact. If any layer wraps a CodexCliInstallationError in an AggregateError, the codexInstallation fact is lost. The chat then shows the generic "couldn't start" text, and the renderer has no fact to offer Update Codex. Use the same traversal in both helpers.

🐛 Proposed fix
   if (error instanceof CodexCliInstallationError) {
     return error.codexInstallation
   }
+  if (error instanceof AggregateError) {
+    for (const inner of error.errors) {
+      const problem = codexInstallationProblemOf(inner, depth + 1)
+      if (problem) {
+        return problem
+      }
+    }
+  }
   return codexInstallationProblemOf(error.cause, depth + 1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function codexInstallationProblemOf(
error: unknown,
depth = 0
): CodexInstallationProblem | undefined {
if (depth >= 6 || !(error instanceof Error)) {
return undefined
}
if (error instanceof CodexCliInstallationError) {
return error.codexInstallation
}
return codexInstallationProblemOf(error.cause, depth + 1)
}
export function codexInstallationProblemOf(
error: unknown,
depth = 0
): CodexInstallationProblem | undefined {
if (depth >= 6 || !(error instanceof Error)) {
return undefined
}
if (error instanceof CodexCliInstallationError) {
return error.codexInstallation
}
if (error instanceof AggregateError) {
for (const inner of error.errors) {
const problem = codexInstallationProblemOf(inner, depth + 1)
if (problem) {
return problem
}
}
}
return codexInstallationProblemOf(error.cause, depth + 1)
}


export async function requireSupportedCodexCli(
input: Parameters<typeof readCodexCliInstallation>[0],
readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation
): Promise<void> {
const result = await readInstallation(input)
if (result.status === 'unsupported') {
throw new CodexCliInstallationError({
installedVersion: result.version,
minimumVersion: result.minimumVersion
})
}
if (result.status === 'unknown') {
console.warn(`[codex-cli-version] Could not verify ${input.program}; allowing structured chat`)
}
}

/** Check every acquired child against the same sealed launch the app-server will use. */
export function withSupportedCodexCli<L extends ProviderProcessLaunch>(
resolveLaunch: StructuredAgentLaunchResolver<L>,
readInstallation: typeof readCodexCliInstallation = readCodexCliInstallation
): StructuredAgentLaunchResolver<L> {
return async (request) => {
const launch = await resolveLaunch(request)
await requireSupportedCodexCli(
{
program: launch.command,
cwd: launch.cwd,
env: resolveProviderChildEnv(launch, process.env)
},
readInstallation
)
return launch
}
}
215 changes: 215 additions & 0 deletions src/main/codex/codex-minimum-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createFakePipedChild } from '../../shared/__fixtures__/fake-spawned-child'
import { codexCliInstallation } from '../../shared/codex-cli-installation'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
import { readWholeAgentSessionFailureFact } from '../../shared/agent-session-failure'
import { resolveAgentLaunchCommand } from '../../shared/tui-agent-launch-command'
import { readCodexCliInstallation } from '../preflight/codex-cli-installation'
import {
failedAcquisitionRefusal,
failedAcquisitionSettlement
} from '../native-chat/agent-session-wire/structured-agent-session-failed-create-refusal'
import { structuredAgentSessionStartFailure } from '../native-chat/agent-session-wire/structured-agent-session-failure-text'
import {
CodexCliInstallationError,
requireSupportedCodexCli,
withSupportedCodexCli
} from './codex-cli-installation-error'
import {
createCodexStructuredLaunchResolver,
TEST_SPAWN_TOKEN
} from '../runtime/structured-agent-launch-composition.test-support'
import { resolveProviderChildEnv } from '../provider-process/provider-process-launch'
import { identityFor } from './codex-structured-session-adapter-fixture'
import { openCodexAppServerConnection } from './codex-app-server-connection'

vi.mock('../preflight/codex-cli-installation', () => ({ readCodexCliInstallation: vi.fn() }))
beforeEach(() => {
vi.mocked(readCodexCliInstallation).mockReset()
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0'))
})
afterEach(() => vi.restoreAllMocks())

function record(): AgentSessionRecord {
return {
...agentSessionRecordFixture(),
provider: 'codex' as const,
providerHandleChain: [],
accountHome: { variable: 'CODEX_HOME' as const, path: '/account' }
}
}

describe('Codex structured launch version admission', () => {
it.each(['0.100.0', '0.135.0'])(
'refuses %s with concise repair copy and both version facts',
async (version) => {
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, version))
const caught = await requireSupportedCodexCli({ program: '/execution-host/codex' }).catch(
(error: unknown) => error
)
expect(caught).toBeInstanceOf(CodexCliInstallationError)
const wording = { record: record(), newSession: true }
const result = failedAcquisitionRefusal(caught, wording)
expect(result?.refusal.message).toBe(
`Codex ${version} is too old for chats. Update to 0.136.0 or newer.`
)
expect(result?.refusal.message).not.toContain('Settings → Agents')
expect(result?.refusal.message).not.toContain('codex update')
expect(failedAcquisitionSettlement(caught, wording).exitProof).toBe('processless')
const failure = structuredAgentSessionStartFailure({ error: caught })
expect(failure.reason).toBe(result?.refusal.message)
expect(readWholeAgentSessionFailureFact(failure.rejection)).toEqual(failure.rejection)
if (!result) {
throw new Error('Expected refusal')
}
expect(
structuredAgentSessionStartFailure({ refusal: result.refusal, newSession: true }).reason
).toBe(failure.reason)
}
)

it('leaves a missing Codex to the existing spawn refusal', async () => {
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(false, null))
await expect(requireSupportedCodexCli({ program: 'codex' })).resolves.toBeUndefined()
})

it('reports Windows ENOENT through the existing not-found failure with one explanation', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(false, null))
await requireSupportedCodexCli({ program: 'codex.exe' })
const child = createFakePipedChild()
Object.assign(child, { pid: undefined })
const opening = openCodexAppServerConnection(
{ command: 'codex.exe', args: ['app-server'] },
{},
() => child
).catch((error: unknown) => error)
child.emit(
'error',
Object.assign(new Error('spawn codex.exe ENOENT'), { code: 'ENOENT', path: 'codex.exe' })
)
child.emit('close', -2, null)
const failure = structuredAgentSessionStartFailure(
{ error: await opening },
{ agentName: 'Codex' }
)
expect(failure.rejection.kind).toBe('cliMissing')
expect(failure.reason).toBe(
"Codex wasn't found on the computer running this chat. Install it, or check its Command in Settings → Agents."
)
})

it('allows an unknown version with a log line', async () => {
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, null))
const log = vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(requireSupportedCodexCli({ program: '/host/codex' })).resolves.toBeUndefined()
expect(log).toHaveBeenCalledWith(expect.stringContaining('allowing structured chat'))
log.mockRestore()
})

it.each(['start', 'resume'] as const)(
'checks the exact host binary and launch environment before %s',
async (mode) => {
const value = record()
if (mode === 'resume') {
value.providerHandleChain = [
{
linkId: 'link',
handle: codexProviderHandle('thread'),
origin: 'created',
mintedAtFence: 1,
observedAt: 1
}
]
}
const resolve = withSupportedCodexCli(
createCodexStructuredLaunchResolver({
store: { getRecord: () => value, pinLaunchDirectory: vi.fn() },
resolveCommand: () => '/host/bin/codex',
resolveEnvironment: async () => ({ PATH: '/host/bin', HOME: '/host/home' }),
resolveLaunchArgs: () => [],
resolveWorkspacePath: async () => '/folder',
resolveRollout: async () => null
})
)
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.135.0'))
await expect(
resolve({ identity: identityFor(value.sessionId), spawnToken: TEST_SPAWN_TOKEN })
).rejects.toThrow('0.135.0')
const probe = vi.mocked(readCodexCliInstallation).mock.calls.at(-1)?.[0]
expect(probe?.program).toBe('/host/bin/codex')
expect(probe?.cwd).toBe('/folder')
expect(probe?.env?.PATH).toContain('/host/bin')
expect(probe?.env?.HOME).toBe('/host/home')
expect(probe?.env?.CODEX_HOME).toBe('/account')
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0'))
const launch = await resolve({
identity: identityFor(value.sessionId),
spawnToken: TEST_SPAWN_TOKEN
})
expect(launch).toMatchObject({ command: '/host/bin/codex', args: ['app-server'] })
expect(vi.mocked(readCodexCliInstallation).mock.calls.at(-1)?.[0]).toEqual({
program: launch.command,
cwd: launch.cwd,
env: resolveProviderChildEnv(launch, process.env)
})
}
)

it.each(['start', 'resume'] as const)(
'admits the workspace-selected version on %s',
async (mode) => {
const value = record()
if (mode === 'resume') {
value.providerHandleChain = [
{
linkId: 'link',
handle: codexProviderHandle('thread'),
origin: 'created',
mintedAtFence: 1,
observedAt: 1
}
]
}
vi.mocked(readCodexCliInstallation).mockImplementation(async (input) =>
codexCliInstallation(true, input.cwd === '/workspace' ? '0.136.0' : '0.135.0')
)
const resolve = withSupportedCodexCli(
createCodexStructuredLaunchResolver({
store: { getRecord: () => value, pinLaunchDirectory: vi.fn() },
resolveCommand: () => '/host/project-aware-codex',
resolveEnvironment: async () => ({ PATH: '/host/bin' }),
resolveLaunchArgs: () => [],
resolveWorkspacePath: async () => '/workspace',
resolveRollout: async () => null
})
)
expect(
(await resolve({ identity: identityFor(value.sessionId), spawnToken: TEST_SPAWN_TOKEN }))
.cwd
).toBe('/workspace')
}
)

it.each(['posix', 'powershell', 'cmd'] as const)(
'leaves terminal Codex launch unchanged in %s',
(shell) => {
vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.135.0'))
expect(
resolveAgentLaunchCommand({
agent: 'codex',
cmdOverrides: {},
platform: shell === 'posix' ? 'linux' : 'win32',
shell,
agentArgs: '--model chosen'
})
).toMatchObject({
ok: true,
command: shell === 'cmd' ? 'codex "--model" "chosen"' : "codex '--model' 'chosen'"
})
expect(readCodexCliInstallation).not.toHaveBeenCalled()
}
)
})
Loading
Loading