Repository navigation
feat(native-chat): require Codex 0.136.0 for chats #26684
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 478feac
Add host-owned Codex install and update actions
brennanb2025 0dcaf11
Bind Codex checks and maintenance to the selected installation
brennanb2025 243d0d2
Resolve Windows updater cleanup through system helpers
brennanb2025 18a2986
Use one child environment for Codex checks and launch
brennanb2025 3e9dfd1
Expire Codex readiness and reconcile current host jobs
brennanb2025 b0a33c8
Resolve Codex checks against the child command search path
brennanb2025 1de680d
Restrict global npm repair to the selected Codex install layout
brennanb2025 68dba1b
Pin the host updater directory through Windows supervision
brennanb2025 4ae68b3
Synchronize Codex maintenance fixtures on readiness
brennanb2025 2eff4a7
Unify Codex notices and recover version-refused chats
brennanb2025 078f557
Route Codex recovery to the operation that remains
brennanb2025 679dfaa
Preserve Codex setup execution errors in the job log
brennanb2025 34fdf80
Preserve UTF-8 in Codex setup logs
brennanb2025 a5a5ec3
Remove unused Codex host installation detector
brennanb2025 8d1cbca
Merge origin/main into Codex minimum-version branch
brennanb2025 93066a4
Require host administration for Codex maintenance
brennanb2025 a7a661f
Show a missing Codex explanation once
brennanb2025 cda2bbe
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-mi…
brennanb2025 4436b0a
test(native-chat): give scripted Codex runtimes their installation fa…
brennanb2025 a80b83a
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-mi…
brennanb2025 e4fcbf1
refactor(native-chat): drop the Update Codex action; keep Install for…
brennanb2025 f80b429
refactor(native-chat): keep Install Codex in Settings only
brennanb2025 fe68bd3
Merge remote-tracking branch 'origin/main' into brennanb2025/codex-mi…
brennanb2025 3583f3a
refactor(codex): check chat compatibility when acquiring the child
brennanb2025 76f4861
Restore workspace submit guard and simplify Codex maintenance
brennanb2025 afd3c58
Keep only the Codex chat minimum version
brennanb2025 aff57f9
Merge origin/main into brennanb2025/codex-minimum-version
brennanb2025 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
| } | ||
| ) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
AggregateErrorincodexInstallationProblemOf, asargumentProblemOfdoes.argumentProblemOfinsrc/main/native-chat/structured-agent-arguments-error.ts:25-44checks each entry ofAggregateError.errors.codexInstallationProblemOfchecks onlyerror.cause. The acquisition path uses both helpers infailedAcquisitionDetails,failedAcquisitionRefusal, andproviderStartupFailureFact. If any layer wraps aCodexCliInstallationErrorin anAggregateError, thecodexInstallationfact 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