From a65628bcdab31f02d00dc1b039eda95b11a4bd3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:05:56 -0700 Subject: [PATCH 01/23] Require Codex 0.136.0 for native chats --- src/main/agent-cli-version-probe.ts | 28 ++-- .../codex/codex-cli-installation-error.ts | 46 ++++++ src/main/codex/codex-minimum-version.test.ts | 129 +++++++++++++++++ ...codex-structured-launch-resolution.test.ts | 1 + .../codex-structured-launch-resolution.ts | 6 + .../codex-structured-thread-open.test.ts | 73 +++------- .../codex/codex-structured-thread-open.ts | 41 +----- ...red-agent-session-failed-create-refusal.ts | 8 +- .../structured-agent-session-failure-text.ts | 10 ++ src/main/preflight/agent-command-detection.ts | 1 + src/main/preflight/agent-detection.ts | 1 + .../preflight/codex-cli-installation-cache.ts | 38 +++++ .../preflight/codex-cli-installation.test.ts | 132 ++++++++++++++++++ src/main/preflight/codex-cli-installation.ts | 66 +++++++++ .../preflight/codex-host-installation.test.ts | 114 +++++++++++++++ src/main/preflight/codex-host-installation.ts | 97 +++++++++++++ src/relay/preflight-codex-version.test.ts | 72 ++++++++++ src/relay/preflight-handler.ts | 15 +- ...turedSession.start-failure-notice.test.tsx | 29 +++- .../agent-session-failure-words-text.ts | 8 ++ .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-failure-copy.ts | 6 + src/shared/agent-session-failure-words.ts | 5 + src/shared/agent-session-refusal-details.ts | 16 ++- src/shared/codex-cli-installation.test.ts | 44 ++++++ src/shared/codex-cli-installation.ts | 48 +++++++ .../codex-installation-failure-words.ts | 18 +++ 33 files changed, 956 insertions(+), 110 deletions(-) create mode 100644 src/main/codex/codex-cli-installation-error.ts create mode 100644 src/main/codex/codex-minimum-version.test.ts create mode 100644 src/main/preflight/codex-cli-installation-cache.ts create mode 100644 src/main/preflight/codex-cli-installation.test.ts create mode 100644 src/main/preflight/codex-cli-installation.ts create mode 100644 src/main/preflight/codex-host-installation.test.ts create mode 100644 src/main/preflight/codex-host-installation.ts create mode 100644 src/relay/preflight-codex-version.test.ts create mode 100644 src/shared/codex-cli-installation.test.ts create mode 100644 src/shared/codex-cli-installation.ts create mode 100644 src/shared/codex-installation-failure-words.ts diff --git a/src/main/agent-cli-version-probe.ts b/src/main/agent-cli-version-probe.ts index 4cf45ce0e4d2..8f3e664ea028 100644 --- a/src/main/agent-cli-version-probe.ts +++ b/src/main/agent-cli-version-probe.ts @@ -17,6 +17,21 @@ export async function probeAgentCliVersion( input: Pick, supports: (version: string) => boolean ): Promise { + 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 +): Promise<{ version: string | null }> { let result try { result = await runProcess({ @@ -28,7 +43,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) { @@ -38,19 +53,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) 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. */ diff --git a/src/main/codex/codex-cli-installation-error.ts b/src/main/codex/codex-cli-installation-error.ts new file mode 100644 index 000000000000..e3264b9e183b --- /dev/null +++ b/src/main/codex/codex-cli-installation-error.ts @@ -0,0 +1,46 @@ +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' + +export class CodexCliInstallationError extends AgentSessionPreSpawnError { + readonly codexInstallation: CodexInstallationProblem + + constructor(problem: CodexInstallationProblem) { + super( + sayAgentSessionFailureEnglish( + problem.installedVersion === null ? 'codexCliMissing' : 'codexCliTooOld', + { 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[0] +): Promise { + const result = await readCodexCliInstallation(input) + if (result.status === 'missing' || 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`) + } +} diff --git a/src/main/codex/codex-minimum-version.test.ts b/src/main/codex/codex-minimum-version.test.ts new file mode 100644 index 000000000000..ddaccabc7321 --- /dev/null +++ b/src/main/codex/codex-minimum-version.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +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 } from './codex-cli-installation-error' +import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' +import { identityFor } from './codex-structured-session-adapter-fixture' + +vi.mock('../preflight/codex-cli-installation', () => ({ readCodexCliInstallation: vi.fn() })) +beforeEach(() => { + vi.mocked(readCodexCliInstallation).mockReset() + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0')) +}) + +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([null, '0.135.0'])( + 'refuses %s with install/update commands and both version facts', + async (version) => { + vi.mocked(readCodexCliInstallation).mockResolvedValue( + codexCliInstallation(version !== null, 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).toContain('0.136.0') + expect(result?.refusal.message).toContain('npm install -g @openai/codex') + expect(result?.refusal.message).toContain(version ?? 'not installed') + if (version) { + expect(result?.refusal.message).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('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 = 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) })).rejects.toThrow('0.135.0') + expect(readCodexCliInstallation).toHaveBeenCalledWith({ + program: '/host/bin/codex', + env: { PATH: '/host/bin', HOME: '/host/home' } + }) + vi.mocked(readCodexCliInstallation).mockResolvedValue(codexCliInstallation(true, '0.136.0')) + await expect(resolve({ identity: identityFor(value.sessionId) })).resolves.toMatchObject({ + command: '/host/bin/codex', + args: ['app-server'] + }) + } + ) + + 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() + } + ) +}) diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index b47599113281..12156aff10d9 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -18,6 +18,7 @@ vi.mock('../windows/windows-process-table', async (importOriginal) => ({ ...(await importOriginal()), isWindowsProcessStartTimeAvailable })) +vi.mock('./codex-cli-installation-error', () => ({ requireSupportedCodexCli: vi.fn() })) const SESSION_ID = 'session-1' const IDENTITY = { sessionId: SESSION_ID } as Parameters< diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 8016c938ae83..9e61fb1ffadc 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -18,6 +18,7 @@ import type { CodexStructuredPermissionPolicy } from './codex-structured-permiss import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' import { codexStructuredLaunchArgs } from './codex-structured-launch-args' import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' +import { requireSupportedCodexCli } from './codex-cli-installation-error' import type { PrepareNativeChatVisuals } from '../native-chat/native-chat-visuals-delivery' export type CodexStructuredLaunchResolverDeps = { @@ -36,6 +37,7 @@ export type CodexStructuredLaunchResolverDeps = { resolvePermissionPolicy?: () => CodexStructuredPermissionPolicy /** This chat's visuals folder and skill; absent or null ⇒ the chat gets neither. */ prepareVisuals?: PrepareNativeChatVisuals + requireSupportedCli?: typeof requireSupportedCodexCli } export type CodexStructuredInvocation = { @@ -90,6 +92,10 @@ export function createCodexStructuredLaunchResolver( } const { command, environment } = await resolveCodexStructuredInvocation(deps) const args = codexStructuredLaunchArgs(await deps.resolveLaunchArgs()) + await (deps.requireSupportedCli ?? requireSupportedCodexCli)({ + program: command, + env: environment + }) const permissionPolicy = deps.resolvePermissionPolicy?.() const head = agentSessionProviderHandleChainHead(record.providerHandleChain) // A Codex record's chain holds only Codex handles; the attach admission refuses anything else. diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index efd32b666cd5..21f71cdaa11e 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -125,37 +125,23 @@ describe('openCodexThread', () => { ) }) - it('caches a narrowly proven excludeTurns refusal and uses one bounded fallback', async () => { - const request = vi.fn(async (_method: string, params?: Record) => { - if (params?.excludeTurns) { - throw new CodexAppServerRequestError( - 'thread/resume', - -32602, - 'codex app-server thread/resume failed: unknown field `excludeTurns`' - ) - } - return { thread: { id: 'thread-1', turns: [{ id: 'turn-1', items: [] }] } } - }) - const connection = connectionFor(request) - - const first = await openCodexThread( - connection, - { cwd: '/workspace', resumeThreadId: 'thread-1' }, - 2_000 - ) - const second = await openCodexThread( - connection, - { cwd: '/workspace', resumeThreadId: 'thread-1' }, - 2_000 + it('does not retry without excludeTurns after an unsupported-field refusal', async () => { + const refusal = new CodexAppServerRequestError( + 'thread/resume', + -32602, + 'unknown field excludeTurns' ) - - expect(first.thread?.turns).toHaveLength(1) - expect(second.thread?.turns).toHaveLength(1) - expect(request.mock.calls.map(([, params]) => params)).toEqual([ - expect.objectContaining({ excludeTurns: true }), - { threadId: 'thread-1', cwd: '/workspace' }, - { threadId: 'thread-1', cwd: '/workspace' } - ]) + const request = vi.fn(async () => { + throw refusal + }) + await expect( + openCodexThread( + connectionFor(request), + { cwd: '/workspace', resumeThreadId: 'thread-1' }, + 2_000 + ) + ).rejects.toBe(refusal) + expect(request).toHaveBeenCalledOnce() }) it('does not retry ambiguous invalid params or oversized history responses', async () => { @@ -190,33 +176,6 @@ describe('openCodexThread', () => { expect(oversizedRequest).toHaveBeenCalledOnce() }) - it('accepts a fallback result beyond the daemon wire limit', async () => { - const request = vi.fn(async (_method: string, params?: Record) => { - if (params?.excludeTurns) { - throw new CodexAppServerRequestError( - 'thread/resume', - -32602, - 'codex app-server thread/resume failed: unsupported excludeTurns parameter' - ) - } - return { - thread: { - id: 'thread-1', - turns: [{ id: 'turn-1', items: [{ output: 'x'.repeat(16 * 1024 * 1024 + 1) }] }] - } - } - }) - - await expect( - openCodexThread( - connectionFor(request), - { cwd: '/workspace', resumeThreadId: 'thread-1' }, - 2_000 - ) - ).resolves.toMatchObject({ threadId: 'thread-1' }) - expect(request).toHaveBeenCalledTimes(2) - }) - describe('a thread Codex never saved', () => { const noRollout = (threadId: string, code = -32600) => new CodexAppServerRequestError( diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 6fd58ba17ecd..c6e686869b7a 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -28,41 +28,6 @@ function nonEmptyString(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value : null } -const resumeMetadataUnsupported = new WeakSet() - -function isExcludeTurnsUnsupported(error: unknown): boolean { - return ( - isCodexAppServerRequestError(error) && - error.code === -32602 && - /(?:unknown|unexpected|unsupported|unrecognized).{0,80}excludeTurns|excludeTurns.{0,80}(?:unknown|unexpected|unsupported|unrecognized)/i.test( - error.message - ) - ) -} - -async function resumeCodexThread( - connection: Pick, - params: Record, - timeoutMs: number | undefined -): Promise { - if (resumeMetadataUnsupported.has(connection)) { - return connection.request('thread/resume', params, { timeoutMs }) - } - try { - return await connection.request( - 'thread/resume', - { ...params, excludeTurns: true }, - { timeoutMs } - ) - } catch (error) { - if (!isExcludeTurnsUnsupported(error)) { - throw error - } - resumeMetadataUnsupported.add(connection) - return connection.request('thread/resume', params, { timeoutMs }) - } -} - /** * Codex's own answer that it holds no rollout for this exact thread: the thread was started but * never given input, so there is no conversation to lose. Codex matches the same exact text @@ -120,7 +85,11 @@ export async function openCodexThread( ...(launch.resumePath ? { path: launch.resumePath } : {}) } try { - opened = await resumeCodexThread(connection, resumeParams, timeoutMs) + opened = await connection.request( + 'thread/resume', + { ...resumeParams, excludeTurns: true }, + { timeoutMs } + ) } catch (error) { if (!launch.supersedeIfUnsaved || !isCodexNoRolloutError(error, resumeThreadId)) { throw error diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts index 3244ef1e07fb..91e7214c0b86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-refusal.ts @@ -29,6 +29,7 @@ import { } from './structured-agent-session-failure-text' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import { argumentProblemOf } from '../structured-agent-arguments-error' +import { codexInstallationProblemOf } from '../../codex/codex-cli-installation-error' /** Who a failed acquisition's sentence names, and whether it was the session's first start. */ export type FailedAcquisitionWording = { @@ -110,6 +111,10 @@ function isExitProvenAcquisitionFailure(error: unknown): error is Error { function failedAcquisitionDetails( error: unknown ): AgentSessionRefusalDetailsByCode['agent_session_operation_invalid'] | undefined { + const codexInstallation = codexInstallationProblemOf(error) + if (codexInstallation) { + return { reason: 'attachFailed', codexInstallation } + } const argumentProblem = argumentProblemOf(error) if (argumentProblem) { return { reason: 'attachFailed', argumentProblem } @@ -136,7 +141,8 @@ export function failedAcquisitionRefusal( if ( error instanceof AgentSessionAcquisitionRefusal || isExitProvenAcquisitionFailure(error) || - (isAgentSessionPreSpawnError(error) && argumentProblemOf(error)) + (isAgentSessionPreSpawnError(error) && + (argumentProblemOf(error) || codexInstallationProblemOf(error))) ) { return { ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index 79bf84fb6d43..d560ad958214 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -21,6 +21,7 @@ import { } from '../../../shared/agent-session-wire-refusals' import { AgentSessionAcquisitionRefusal } from './structured-agent-session-adapter' import { argumentProblemOf } from '../structured-agent-arguments-error' +import { codexInstallationProblemOf } from '../../codex/codex-cli-installation-error' /** Start refusals whose situation is itself what the person reads, with its own next step. */ const TYPED_START_REFUSALS = [ @@ -70,6 +71,15 @@ export function providerExitObserved(error: unknown, depth = 0): boolean { * adapter observed says the provider stopped; anything else blames no one — it may be Orca's, or * a spawn that failed. Either keeps the provider's diagnostic when the error carried one. */ export function providerStartupFailureFact(cause?: unknown): SubmissionRejectionFact { + const codexInstallation = codexInstallationProblemOf(cause) + if (codexInstallation) { + return agentSessionFailureFact('startFailed', { + refusal: { + code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed', codexInstallation } + } + }) + } const typed = typedStartRefusal( cause instanceof AgentSessionAcquisitionRefusal ? cause.reason : undefined ) diff --git a/src/main/preflight/agent-command-detection.ts b/src/main/preflight/agent-command-detection.ts index 3712036337c1..269d32863e18 100644 --- a/src/main/preflight/agent-command-detection.ts +++ b/src/main/preflight/agent-command-detection.ts @@ -7,6 +7,7 @@ import { type PreflightRuntimeContext } from '../ipc/preflight-runtime-target' import { isCommandOnPath } from '../ipc/preflight-command-exec' +export { detectCodexInstallationOnHost } from './codex-host-installation' export async function detectAgentCommandsOnHost( commands: readonly string[], diff --git a/src/main/preflight/agent-detection.ts b/src/main/preflight/agent-detection.ts index 92484812c3bf..77a8ae80aa5c 100644 --- a/src/main/preflight/agent-detection.ts +++ b/src/main/preflight/agent-detection.ts @@ -44,6 +44,7 @@ import { invalidateWslGuestEnvironment } from '../wsl/wsl-guest-environment' import { prunePreflightWslCache } from '../preflight-wsl-cache' import { detectAgentCommandsOnHost } from './agent-command-detection' export { detectAgentCommandsOnHost } from './agent-command-detection' +export { detectCodexInstallationOnHost } from './codex-host-installation' export type PreflightStatus = { git: { installed: boolean } diff --git a/src/main/preflight/codex-cli-installation-cache.ts b/src/main/preflight/codex-cli-installation-cache.ts new file mode 100644 index 000000000000..6c0bce944c90 --- /dev/null +++ b/src/main/preflight/codex-cli-installation-cache.ts @@ -0,0 +1,38 @@ +import type { CodexCliInstallation } from '../../shared/codex-cli-installation' + +type CacheEntry = { fingerprint: string; expiresAt: number; result: Promise } + +export class CodexCliInstallationCache { + private readonly entries = new Map() + + async read( + host: string, + fingerprint: string, + probe: () => Promise + ): Promise { + const existing = this.entries.get(host) + if (existing?.fingerprint === fingerprint && existing.expiresAt > Date.now()) { + return existing.result + } + const entry: CacheEntry = { fingerprint, expiresAt: Infinity, result: probe() } + this.entries.set(host, entry) + try { + const result = await entry.result + // Unknown probes must heal even when the binary did not change. + entry.expiresAt = result.status === 'unknown' ? Date.now() + 30_000 : Infinity + return result + } catch (error) { + if (this.entries.get(host) === entry) { + this.entries.delete(host) + } + throw error + } finally { + if (this.entries.size > 128) { + const oldest = this.entries.keys().next().value + if (oldest !== undefined && oldest !== host) { + this.entries.delete(oldest) + } + } + } + } +} diff --git a/src/main/preflight/codex-cli-installation.test.ts b/src/main/preflight/codex-cli-installation.test.ts new file mode 100644 index 000000000000..742ee6ffe1c6 --- /dev/null +++ b/src/main/preflight/codex-cli-installation.test.ts @@ -0,0 +1,132 @@ +import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readCodexCliInstallation } from './codex-cli-installation' +import { CodexCliInstallationCache } from './codex-cli-installation-cache' +import { codexCliInstallation } from '../../shared/codex-cli-installation' + +const { runProcess } = vi.hoisted(() => ({ runProcess: vi.fn() })) +vi.mock('../../shared/child-process/run-process', async (original) => ({ + ...(await original()), + runProcess +})) + +const roots: string[] = [] +async function binary(): Promise { + const root = await mkdtemp(join(tmpdir(), 'codex-installation-test-')) + roots.push(root) + const file = join(root, 'codex') + await writeFile(file, 'fake binary; never executed') + return file +} +function prints(stdout: string, timedOut = false): void { + runProcess.mockResolvedValue({ code: 0, stdout, stderr: '', timedOut }) +} +afterEach(async () => { + vi.restoreAllMocks() + vi.useRealTimers() + runProcess.mockReset() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('Codex binary version cache', () => { + it('shares concurrent probes and caches the version for ordinary starts', async () => { + const program = await binary() + prints('codex-cli 0.136.0') + const input = { program, env: { PATH: '/launch/path' } } + const results = await Promise.all([ + readCodexCliInstallation(input), + readCodexCliInstallation(input) + ]) + expect(results.every((result) => result.status === 'ready')).toBe(true) + await readCodexCliInstallation(input) + expect(runProcess).toHaveBeenCalledTimes(1) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program, + env: input.env, + args: ['--version'], + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + ) + }) + + it('rechecks an updated binary and a different resolved path', async () => { + const program = await binary() + prints('codex-cli 0.135.0') + expect((await readCodexCliInstallation({ program })).status).toBe('unsupported') + await writeFile(program, 'updated fake binary of a different size') + prints('codex-cli 0.136.0') + expect((await readCodexCliInstallation({ program })).status).toBe('ready') + await utimes(program, new Date(), new Date(Date.now() + 10_000)) + await readCodexCliInstallation({ program }) + await readCodexCliInstallation({ program: await binary() }) + expect(runProcess).toHaveBeenCalledTimes(4) + }) + + it('allows a timeout, then retries the unchanged binary after the bounded cache expires', async () => { + const program = await binary() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + prints('codex-cli 0.135.0', true) + expect((await readCodexCliInstallation({ program })).status).toBe('unknown') + prints('codex-cli 0.136.0') + expect((await readCodexCliInstallation({ program })).status).toBe('unknown') + vi.setSystemTime(30_001) + expect((await readCodexCliInstallation({ program })).status).toBe('ready') + expect(runProcess).toHaveBeenCalledTimes(2) + }) + + it('notices an npm package update even when the launcher is unchanged', async () => { + const root = await mkdtemp(join(tmpdir(), 'codex-npm-test-')) + roots.push(root) + await mkdir(join(root, 'bin')) + const program = join(root, 'bin', 'codex.js') + await writeFile(program, 'unchanged npm launcher; never executed') + await writeFile(join(root, 'package.json'), '{"version":"0.135.0"}') + prints('codex-cli 0.135.0') + expect((await readCodexCliInstallation({ program })).status).toBe('unsupported') + await writeFile(join(root, 'package.json'), '{"version":"0.136.0","updated":true}') + prints('codex-cli 0.136.0') + expect((await readCodexCliInstallation({ program })).status).toBe('ready') + expect(runProcess).toHaveBeenCalledTimes(2) + }) + + it('proves a missing binary from the filesystem and allows an installed binary with a broken interpreter', async () => { + const program = await binary() + expect((await readCodexCliInstallation({ program: join(program, 'missing') })).status).toBe( + 'unknown' + ) + expect( + (await readCodexCliInstallation({ program: join(program, '..', 'missing') })).status + ).toBe('missing') + expect(runProcess).not.toHaveBeenCalled() + runProcess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + expect((await readCodexCliInstallation({ program })).status).toBe('unknown') + runProcess.mockRejectedValue(Object.assign(new Error('unavailable'), { code: 'EACCES' })) + expect((await readCodexCliInstallation({ program: await binary() })).status).toBe('unknown') + }) + + it('isolates hosts and does not let a slow old fingerprint replace a new verdict', async () => { + const cache = new CodexCliInstallationCache() + let finish: ((result: ReturnType) => void) | undefined + const stale = cache.read( + 'wsl:A', + 'old', + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const fresh = vi.fn(async () => codexCliInstallation(true, '0.136.0')) + await cache.read('wsl:A', 'new', fresh) + finish?.(codexCliInstallation(true, '0.135.0')) + await stale + expect((await cache.read('wsl:A', 'new', fresh)).status).toBe('ready') + await cache.read('wsl:B', 'new', fresh) + await cache.read('ssh:A', 'new', fresh) + expect(fresh).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/preflight/codex-cli-installation.ts b/src/main/preflight/codex-cli-installation.ts new file mode 100644 index 000000000000..a4990c32acfe --- /dev/null +++ b/src/main/preflight/codex-cli-installation.ts @@ -0,0 +1,66 @@ +import { stat, realpath } from 'node:fs/promises' +import { dirname, isAbsolute, join } from 'node:path' +import { + codexCliInstallation, + type CodexCliInstallation +} from '../../shared/codex-cli-installation' +import { resolveSpawn, type ProcessSpec } from '../../shared/child-process/run-process' +import { readAgentCliVersion } from '../agent-cli-version-probe' +import { listLocalCommandPaths } from '../ipc/command-path-resolver' +import { CodexCliInstallationCache } from './codex-cli-installation-cache' + +const cache = new CodexCliInstallationCache() + +async function stamp(file: string): Promise { + try { + const value = await stat(file) + return `${file}:${value.ino}:${value.mtimeMs}:${value.ctimeMs}:${value.size}` + } catch { + return `${file}:unverifiable` + } +} + +async function binaryFingerprint(input: Pick): Promise { + const resolved = resolveSpawn(input, process.platform) + const target = await realpath(input.program).catch(() => input.program) + const launchers = [target, ...resolved.args.filter(isAbsolute)] + const files = new Set([ + input.program, + target, + resolved.file, + ...launchers, + // npm can keep its launcher unchanged while replacing the package underneath it. + ...launchers.map((file) => join(dirname(dirname(file)), 'package.json')) + ]) + return JSON.stringify(await Promise.all([...files].map(stamp))) +} + +export async function readCodexCliInstallation( + input: Pick +): Promise { + const program = isAbsolute(input.program) + ? input.program + : ( + await listLocalCommandPaths(input.program, { + env: input.env, + cwd: input.cwd, + maxResults: 1 + }) + )[0] + if (!program) { + return codexCliInstallation(false, null) + } + try { + await stat(program) + } catch (error) { + const missing = + typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' + return codexCliInstallation(!missing, null) + } + const launch = { ...input, program } + const fingerprint = await binaryFingerprint(launch) + return cache.read(`native:${program}`, fingerprint, async () => { + const result = await readAgentCliVersion(launch) + return codexCliInstallation(true, result.version) + }) +} diff --git a/src/main/preflight/codex-host-installation.test.ts b/src/main/preflight/codex-host-installation.test.ts new file mode 100644 index 000000000000..22635cfe4a96 --- /dev/null +++ b/src/main/preflight/codex-host-installation.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { codexCliInstallation } from '../../shared/codex-cli-installation' +import { detectCodexInstallationOnHost } from './codex-host-installation' + +const { execWsl, local, mux, request } = vi.hoisted(() => ({ + execWsl: vi.fn(), + local: vi.fn(), + mux: vi.fn(), + request: vi.fn() +})) +vi.mock('../ipc/preflight-command-exec', () => ({ + execCommandInWslOrThrow: execWsl, + shellQuote: (value: string) => `'${value.replace(/'/g, "'\\''")}'` +})) +vi.mock('../ssh/ssh-target-registry', () => ({ getActiveMultiplexer: mux })) +vi.mock('./codex-cli-installation', () => ({ readCodexCliInstallation: local })) +vi.mock('../../shared/node-cli-command-resolution', () => ({ + resolveCodexCommand: () => '/native/codex' +})) + +const platform = process.platform +beforeEach(() => { + execWsl.mockReset() + local.mockReset() + mux.mockReset() + request.mockReset() + mux.mockReturnValue({ isDisposed: () => false, request }) +}) +afterEach(() => Object.defineProperty(process, 'platform', { value: platform })) + +function inWindows(): void { + Object.defineProperty(process, 'platform', { value: 'win32' }) +} + +describe('Codex installation execution host ownership', () => { + it('reads the same native binary the structured resolver selects', async () => { + local.mockResolvedValue(codexCliInstallation(true, '0.136.0')) + expect((await detectCodexInstallationOnHost()).status).toBe('ready') + expect(local).toHaveBeenCalledWith({ program: '/native/codex' }) + expect(execWsl).not.toHaveBeenCalled() + expect(request).not.toHaveBeenCalled() + }) + + it('probes and caches the guest binary separately for each WSL distro and update', async () => { + inWindows() + let stamp = 'first' + execWsl.mockImplementation(async (target: { distro: string }, command: string) => ({ + stdout: command.endsWith('--version') + ? target.distro === 'version-A' + ? '0.135.0' + : '0.136.0' + : `__ORCA_CODEX_PATH__/guest/codex\n__ORCA_CODEX_STAMP__${stamp}\n`, + stderr: '' + })) + expect( + (await detectCodexInstallationOnHost({ context: { wslDistro: 'version-A' } })).status + ).toBe('unsupported') + expect( + (await detectCodexInstallationOnHost({ context: { wslDistro: 'version-B' } })).status + ).toBe('ready') + await detectCodexInstallationOnHost({ context: { wslDistro: 'version-A' } }) + expect(execWsl.mock.calls.filter(([, script]) => script.endsWith('--version'))).toHaveLength(2) + stamp = 'updated' + await detectCodexInstallationOnHost({ context: { wslDistro: 'version-A' } }) + expect(execWsl.mock.calls.filter(([, script]) => script.endsWith('--version'))).toHaveLength(3) + expect(execWsl.mock.calls[0]?.[1]).toContain('/proc/mounts') + expect(local).not.toHaveBeenCalled() + }) + + it('keeps WSL missing, malformed output and contact loss distinct', async () => { + inWindows() + execWsl.mockResolvedValueOnce({ stdout: '__ORCA_CODEX_MISSING__\n' }) + expect( + (await detectCodexInstallationOnHost({ context: { wslDistro: 'missing' } })).status + ).toBe('missing') + execWsl.mockResolvedValueOnce({ stdout: 'distro banner\n' }) + expect( + (await detectCodexInstallationOnHost({ context: { wslDistro: 'malformed' } })).status + ).toBe('unknown') + execWsl.mockRejectedValueOnce(new Error('connection lost')) + expect( + (await detectCodexInstallationOnHost({ context: { wslDistro: 'unreachable' } })).status + ).toBe('unknown') + }) + + it('asks the relay for the version on that SSH host and tolerates older peers', async () => { + request.mockResolvedValueOnce({ agents: ['codex'], versions: { codex: '0.135.0' } }) + expect((await detectCodexInstallationOnHost({ connectionId: 'ssh-old' })).status).toBe( + 'unsupported' + ) + request.mockResolvedValueOnce({ agents: ['codex'], versions: { codex: '0.136.0' } }) + expect((await detectCodexInstallationOnHost({ connectionId: 'ssh-new' })).status).toBe('ready') + request.mockResolvedValueOnce({ agents: ['codex'] }) + expect((await detectCodexInstallationOnHost({ connectionId: 'ssh-older-relay' })).status).toBe( + 'unknown' + ) + expect(request).toHaveBeenCalledWith('preflight.detectAgents', { + commands: [{ id: 'codex', cmd: 'codex', reportVersion: true }] + }) + expect(local).not.toHaveBeenCalled() + expect(execWsl).not.toHaveBeenCalled() + }) + + it('does not interpret SSH contact loss as a missing or old install', async () => { + request.mockRejectedValueOnce(new Error('disconnected')) + expect((await detectCodexInstallationOnHost({ connectionId: 'ssh-disconnected' })).status).toBe( + 'unknown' + ) + mux.mockReturnValueOnce(null) + expect((await detectCodexInstallationOnHost({ connectionId: 'ssh-absent' })).status).toBe( + 'unknown' + ) + }) +}) diff --git a/src/main/preflight/codex-host-installation.ts b/src/main/preflight/codex-host-installation.ts new file mode 100644 index 000000000000..cae34e88dd48 --- /dev/null +++ b/src/main/preflight/codex-host-installation.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' +import { + codexCliInstallation, + type CodexCliInstallation +} from '../../shared/codex-cli-installation' +import { resolveCodexCommand } from '../../shared/node-cli-command-resolution' +import { buildPosixCommandPathLookupScript } from '../../shared/posix-command-path-lookup' +import { execCommandInWslOrThrow, shellQuote } from '../ipc/preflight-command-exec' +import { + getPreflightWslTarget, + type PreflightRuntimeContext +} from '../ipc/preflight-runtime-target' +import { getActiveMultiplexer } from '../ssh/ssh-target-registry' +import { readCodexCliInstallation } from './codex-cli-installation' +import { CodexCliInstallationCache } from './codex-cli-installation-cache' + +const wslCache = new CodexCliInstallationCache() +const PATH_PREFIX = '__ORCA_CODEX_PATH__' +const STAMP_PREFIX = '__ORCA_CODEX_STAMP__' +const MISSING = '__ORCA_CODEX_MISSING__' + +async function readWslCodexInstallation( + target: NonNullable> +): Promise { + const script = [ + buildPosixCommandPathLookupScript( + { kind: 'literal', value: 'codex' }, + { skipWindowsMountDirs: true } + ), + 'if [ -n "$resolved" ]; then', + `printf '${PATH_PREFIX}%s\\n' "$resolved"`, + `printf '${STAMP_PREFIX}'`, + 'stat -L -c "%i:%y:%z:%s" -- "$resolved"', + 'else', + `printf '${MISSING}\\n'`, + 'fi' + ].join('\n') + try { + const { stdout } = await execCommandInWslOrThrow(target, script) + const lines = stdout.split(/\r?\n/) + if (lines.includes(MISSING)) { + return codexCliInstallation(false, null) + } + const binary = lines.find((line) => line.startsWith(PATH_PREFIX))?.slice(PATH_PREFIX.length) + const stamp = lines.find((line) => line.startsWith(STAMP_PREFIX)) + if (!binary || !stamp) { + return codexCliInstallation(true, null) + } + return await wslCache.read( + `wsl:${target.distro ?? ''}:${binary}`, + `${binary}:${stamp}`, + async () => { + try { + const version = await execCommandInWslOrThrow(target, `${shellQuote(binary)} --version`) + return codexCliInstallation(true, version.stdout) + } catch { + return codexCliInstallation(true, null) + } + } + ) + } catch { + console.warn('[codex-cli-version] Could not verify WSL host installation') + return codexCliInstallation(true, null) + } +} + +export async function detectCodexInstallationOnHost( + options: { connectionId?: string | null; context?: PreflightRuntimeContext } = {} +): Promise { + if (options.connectionId) { + const mux = getActiveMultiplexer(options.connectionId) + if (!mux || mux.isDisposed()) { + return codexCliInstallation(true, null) + } + try { + const result = z + .object({ + agents: z.array(z.string()), + versions: z.record(z.string(), z.string()).optional() + }) + .parse( + await mux.request('preflight.detectAgents', { + commands: [{ id: 'codex', cmd: 'codex', reportVersion: true }] + }) + ) + return codexCliInstallation(result.agents.includes('codex'), result.versions?.codex ?? null) + } catch { + console.warn('[codex-cli-version] Could not verify SSH host installation') + return codexCliInstallation(true, null) + } + } + const target = getPreflightWslTarget(options.context) + if (target) { + return readWslCodexInstallation(target) + } + return readCodexCliInstallation({ program: resolveCodexCommand() }) +} diff --git a/src/relay/preflight-codex-version.test.ts b/src/relay/preflight-codex-version.test.ts new file mode 100644 index 000000000000..4664151ac67a --- /dev/null +++ b/src/relay/preflight-codex-version.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { codexCliInstallation } from '../shared/codex-cli-installation' +import { RelayDispatcher } from './dispatcher' +import { PreflightHandler } from './preflight-handler' + +const { lookup, readInstallation } = vi.hoisted(() => ({ + lookup: vi.fn(), + readInstallation: vi.fn() +})) +vi.mock('node:child_process', () => ({ + execFile: Object.assign(vi.fn(), { [Symbol.for('nodejs.util.promisify.custom')]: lookup }) +})) +vi.mock('../main/preflight/codex-cli-installation', () => ({ + readCodexCliInstallation: readInstallation +})) +vi.mock('../main/pwsh', () => ({ isPwshAvailableAsync: vi.fn() })) +vi.mock('../main/wsl', () => ({ isWslAvailableAsync: vi.fn(), listWslDistrosAsync: vi.fn() })) +vi.mock('../main/git-bash', () => ({ isGitBashAvailable: vi.fn() })) + +const dispatchers: RelayDispatcher[] = [] +function handler() { + const dispatcher = new RelayDispatcher(() => {}) + dispatchers.push(dispatcher) + const registered = vi.spyOn(dispatcher, 'onRequest') + new PreflightHandler(dispatcher) + const found = registered.mock.calls.find(([method]) => method === 'preflight.detectAgents')?.[1] + if (!found) { + throw new Error('Agent detector was not registered') + } + return (commands: { id: string; cmd: string; reportVersion?: true }[]) => + found({ commands }, { clientId: 1, isStale: () => false }) +} +beforeEach(() => { + lookup.mockReset() + readInstallation.mockReset() + lookup.mockResolvedValue({ stdout: '__ORCA_AGENT_PATH__/execution-host/bin/codex\n' }) +}) +afterEach(() => { + for (const dispatcher of dispatchers.splice(0)) { + dispatcher.dispose() + } +}) + +describe('relay Codex version reporting', () => { + it('reads the version of the binary resolved on the relay host', async () => { + readInstallation.mockResolvedValue(codexCliInstallation(true, '0.135.0')) + await expect(handler()([{ id: 'codex', cmd: 'codex', reportVersion: true }])).resolves.toEqual({ + agents: ['codex'], + versions: { codex: '0.135.0' } + }) + expect(readInstallation).toHaveBeenCalledWith( + expect.objectContaining({ program: '/execution-host/bin/codex' }) + ) + }) + + it('keeps ordinary detection unchanged and does not probe a missing binary', async () => { + const detect = handler() + await expect(detect([{ id: 'codex', cmd: 'codex' }])).resolves.toEqual({ agents: ['codex'] }) + lookup.mockResolvedValue({ stdout: '' }) + await expect(detect([{ id: 'codex', cmd: 'codex', reportVersion: true }])).resolves.toEqual({ + agents: [] + }) + expect(readInstallation).not.toHaveBeenCalled() + }) + + it('omits an unknown version so mixed-version clients can still read the detector response', async () => { + readInstallation.mockResolvedValue(codexCliInstallation(true, null)) + await expect(handler()([{ id: 'codex', cmd: 'codex', reportVersion: true }])).resolves.toEqual({ + agents: ['codex'] + }) + }) +}) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a98a32af3a83..f84b355eb52a 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -9,6 +9,7 @@ import { isWslAvailableAsync, listWslDistrosAsync } from '../main/wsl' import { isGitBashAvailable } from '../main/git-bash' import { buildPosixCommandPathLookupScript } from '../shared/posix-command-path-lookup' import { runProcess } from '../shared/child-process/run-process' +import { readCodexCliInstallation } from '../main/preflight/codex-cli-installation' const execFileAsync = promisify(execFile) @@ -87,9 +88,9 @@ export class PreflightHandler { const versions: Record = {} for (const command of detectedCommands) { if ( - command.id !== 'claude' || + (command.id !== 'claude' && command.id !== 'codex') || command.reportVersion !== true || - versions.claude !== undefined + versions[command.id] !== undefined ) { continue } @@ -97,7 +98,15 @@ export class PreflightHandler { if (!executablePath) { continue } - const version = await probeCommandVersion(executablePath) + const version = + command.id === 'codex' + ? ( + await readCodexCliInstallation({ + program: executablePath, + env: buildRelayCommandEnv(process.env, process.platform) + }) + ).version + : await probeCommandVersion(executablePath) if (version) { versions[command.id] = version } diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx index 73334d71ca3b..f4947902306f 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.start-failure-notice.test.tsx @@ -83,7 +83,10 @@ function rejected(clientMessageId: string, reason: string, rejection: AgentSessi } // The notices are the host's rows'; a copy an earlier session left in the outbox gives way to them. -function renderPane(messages: ReturnType[]): void { +function renderPane( + messages: ReturnType[], + agent: 'claude' | 'codex' = 'claude' +): void { mocks.submissions = messages.map((message) => message.submission) localStorage.setItem( `orca:desktopStructuredAgentSessionOutbox:v1:${encodeURIComponent(SESSION_ID)}`, @@ -96,7 +99,7 @@ function renderPane(messages: ReturnType[]): void { tabId="start-failure-tab" sessionId={SESSION_ID} target={{ kind: 'local' }} - agent="claude" + agent={agent} /> ) } @@ -160,3 +163,25 @@ it("keeps the start failure's own words when its row is not loaded", async () => expect(within(await notice('first')).getByText(START_FAILED_REASON)).toBeTruthy() }) + +it.each([null, '0.135.0'])( + 'shows the Codex install/update instructions for installed version %s in the existing failure notice', + async (installedVersion) => { + const failure: AgentSessionFailureFact = { + kind: 'startFailed', + refusal: { + code: 'agent_session_operation_invalid', + details: { + reason: 'attachFailed', + codexInstallation: { installedVersion, minimumVersion: '0.136.0' } + } + } + } + const { text } = agentSessionFailureWords(failure, { agentName: 'Codex', surface: 'row' }) + renderPane([rejected('codex-message', text, failure)], 'codex') + const row = await notice('codex-message') + expect(within(row).getByText(text)).toBeTruthy() + expect(text).toContain('0.136.0') + expect(text).toContain('npm install -g @openai/codex') + } +) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 51f3f4f4c94c..d334bbc4801b 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -35,6 +35,14 @@ const PIECES: Record translate('components.native-chat.failureWords.couldNotStart', COPY.couldNotStart, values), + codexCliMissing: (values) => + translate( + 'components.native-chat.failureWords.codexCliMissing', + COPY.codexCliMissing, + values + ), + codexCliTooOld: (values) => + translate('components.native-chat.failureWords.codexCliTooOld', COPY.codexCliTooOld, values), couldNotRestart: (values) => translate( 'components.native-chat.failureWords.couldNotRestart', diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index fb8572dee168..ef89406794d6 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2845,6 +2845,8 @@ "chooseClaudeAccountThenSend": "Choose or add one in Claude Accounts settings, then send your message again.", "claudeAccountFolderMissing": "The selected Claude account's folder is missing. Sign in to it again or choose another account.", "claudeAccountSetupFailed": "The selected Claude account could not be set up. Try again or choose another account.", + "codexCliMissing": "Codex is not installed on this host. Chats require Codex {{minimumVersion}} or newer. Install it with npm install -g @openai/codex, then try again.", + "codexCliTooOld": "Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer on this host with codex update (or npm install -g @openai/codex for npm installs), then try again.", "commandRefused": "This command didn't run.", "commandRefusedTryAgain": "This command didn't run. Try it again.", "compactionFailed": "Compaction failed.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 97abc760cd61..02c523eb4a5a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17978,6 +17978,8 @@ "sendAgainToTryOnceMore": "Send your message again to try once more.", "couldNotStart": "{{agent}} couldn't start.", "couldNotRestart": "{{agent}} couldn't restart.", + "codexCliMissing": "Codex is not installed on this host. Chats require Codex {{minimumVersion}} or newer. Install it with npm install -g @openai/codex, then try again.", + "codexCliTooOld": "Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer on this host with codex update (or npm install -g @openai/codex for npm installs), then try again.", "notSignedIn": "{{agent}} is not signed in for the selected account.", "signInFirst": "Sign in first.", "signInThenRunCommand": "Sign in, then run /{{command}} again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ca78deda08b2..8e18de58d47b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17750,6 +17750,8 @@ "couldNotStart": "{{agent}} no pudo iniciarse.", "editSavedArguments": "Edítalos en Ajustes > Agentes > Argumentos.", "couldNotRestart": "{{agent}} no pudo reiniciarse.", + "codexCliMissing": "Codex no está instalado en este equipo. Los chats requieren Codex {{minimumVersion}} o posterior. Instálalo con npm install -g @openai/codex y vuelve a intentarlo.", + "codexCliTooOld": "Codex {{installedVersion}} es demasiado antiguo para los chats. Actualiza a {{minimumVersion}} o posterior en este equipo con codex update (o npm install -g @openai/codex si lo instalaste con npm) y vuelve a intentarlo.", "notSignedIn": "{{agent}} no tiene la sesión iniciada con la cuenta seleccionada.", "signInFirst": "Inicie sesión primero.", "signInThenRunCommand": "Inicie sesión y vuelva a ejecutar /{{command}}.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a18e5d599a4b..158ce7281258 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17851,6 +17851,8 @@ "couldNotStart": "{{agent}} n'a pas pu démarrer.", "editSavedArguments": "Modifiez-les dans Paramètres > Agents > Arguments.", "couldNotRestart": "{{agent}} n'a pas pu redémarrer.", + "codexCliMissing": "Codex n’est pas installé sur cet hôte. Les chats nécessitent Codex {{minimumVersion}} ou une version ultérieure. Installez-le avec npm install -g @openai/codex, puis réessayez.", + "codexCliTooOld": "Codex {{installedVersion}} est trop ancien pour les chats. Passez à {{minimumVersion}} ou une version ultérieure sur cet hôte avec codex update (ou npm install -g @openai/codex pour une installation npm), puis réessayez.", "notSignedIn": "{{agent}} n'est pas connecté avec le compte sélectionné.", "signInFirst": "Connectez-vous d'abord.", "signInThenRunCommand": "Connectez-vous, puis relancez /{{command}}.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 72efe22af4f2..4c62cc675bdb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17785,6 +17785,8 @@ "couldNotStart": "{{agent}} を起動できませんでした。", "editSavedArguments": "設定 > Agent > 引数で編集してください。", "couldNotRestart": "{{agent}} を再起動できませんでした。", + "codexCliMissing": "このホストに Codex がインストールされていません。チャットには Codex {{minimumVersion}} 以降が必要です。npm install -g @openai/codex でインストールしてから、再試行してください。", + "codexCliTooOld": "Codex {{installedVersion}} はチャットに対応していません。このホストで codex update(npm でインストールした場合は npm install -g @openai/codex)を実行して {{minimumVersion}} 以降に更新し、再試行してください。", "notSignedIn": "{{agent}} は選択したアカウントでサインインしていません。", "signInFirst": "先にサインインしてください。", "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1a66b828be6a..a9015c91649b 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17785,6 +17785,8 @@ "couldNotStart": "{{agent}}을(를) 시작하지 못했습니다.", "editSavedArguments": "설정 > 에이전트 > 인수에서 수정하세요.", "couldNotRestart": "{{agent}}을(를) 다시 시작하지 못했습니다.", + "codexCliMissing": "이 호스트에 Codex가 설치되어 있지 않습니다. 채팅에는 Codex {{minimumVersion}} 이상이 필요합니다. npm install -g @openai/codex로 설치한 후 다시 시도하세요.", + "codexCliTooOld": "Codex {{installedVersion}}은 채팅에 사용하기에 너무 오래된 버전입니다. 이 호스트에서 codex update(또는 npm으로 설치했다면 npm install -g @openai/codex)로 {{minimumVersion}} 이상으로 업데이트한 후 다시 시도하세요.", "notSignedIn": "{{agent}}이(가) 선택한 계정으로 로그인되어 있지 않습니다.", "signInFirst": "먼저 로그인하세요.", "signInThenRunCommand": "로그인한 다음 /{{command}}을(를) 다시 실행하세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cccf45c2ed35..9f91f6ae67a7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17750,6 +17750,8 @@ "couldNotStart": "{{agent}} 无法启动。", "editSavedArguments": "请在设置 > 智能体 > 参数中编辑。", "couldNotRestart": "{{agent}} 无法重新启动。", + "codexCliMissing": "此主机尚未安装 Codex。聊天需要 Codex {{minimumVersion}} 或更高版本。请运行 npm install -g @openai/codex 安装后重试。", + "codexCliTooOld": "Codex {{installedVersion}} 版本过旧,无法用于聊天。请在此主机上运行 codex update(如果通过 npm 安装,则运行 npm install -g @openai/codex)更新至 {{minimumVersion}} 或更高版本后重试。", "notSignedIn": "{{agent}} 未使用所选账户登录。", "signInFirst": "请先登录。", "signInThenRunCommand": "请登录,然后重新运行 /{{command}}。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 10da640f9541..1f8b3dbf328f 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -22,6 +22,10 @@ export const AGENT_SESSION_FAILURE_COPY = { sendAgainToTryOnceMore: 'Send your message again to try once more.', couldNotStart: "{{agent}} couldn't start.", couldNotRestart: "{{agent}} couldn't restart.", + codexCliMissing: + 'Codex is not installed on this host. Chats require Codex {{minimumVersion}} or newer. Install it with npm install -g @openai/codex, then try again.', + codexCliTooOld: + 'Codex {{installedVersion}} is too old for chats. Update to {{minimumVersion}} or newer on this host with codex update (or npm install -g @openai/codex for npm installs), then try again.', argumentsUnsupportedOption: 'Saved Arguments contain an unsupported option ({{option}}).', argumentsMissingValue: 'Saved Arguments need a value for {{option}}.', argumentsMultipleValues: 'Saved Arguments give {{option}} more than one value.', @@ -112,6 +116,8 @@ export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY /** What a piece's `{{name}}` placeholders stand for. */ export type AgentSessionFailureCopyValues = { + installedVersion?: string + minimumVersion?: string agent?: string command?: string detail?: string diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 946e775639c9..f42156187998 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -27,6 +27,7 @@ import { import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words' import { joinSentences } from './sentence-joining' +import { codexInstallationFailureWords } from './codex-installation-failure-words' import { DISPATCH_REJECTED_CANCELLED, DISPATCH_REJECTED_CODEX_QUEUE_FULL, @@ -137,6 +138,10 @@ function startRetry( function couldNot(verb: 'couldNotStart' | 'couldNotRestart'): Sentence { return (context, fact, _surface, say) => { + const installationFailure = codexInstallationFailureWords(fact.refusal, say) + if (installationFailure) { + return installationFailure + } const failed = say(verb, agent(say, context)) if (fact.argumentProblem) { const problemCopy = { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index f27b9c5737e8..7e7bdc2b6246 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -7,6 +7,10 @@ // older host, and the reader falls back to what it does for the code. import type { AgentJournalResolution } from './agent-session-journal-types' +import { + readCodexInstallationProblem, + type CodexInstallationProblem +} from './codex-cli-installation' import { readAgentSessionArgumentProblem, type AgentSessionArgumentProblem @@ -150,7 +154,10 @@ type NoFacts = Record /** The facts a code carries beside its reason. */ type AgentSessionRefusalFactsByCode = { - agent_session_operation_invalid: RewindFacts & { argumentProblem?: AgentSessionArgumentProblem } + agent_session_operation_invalid: RewindFacts & { + argumentProblem?: AgentSessionArgumentProblem + codexInstallation?: CodexInstallationProblem + } agent_session_operation_unknown: RewindFacts agent_session_checkpoint_stale: { /** So the client can retry without another round trip. */ @@ -271,10 +278,15 @@ export function readAgentSessionRefusalDetails 0 ? (read as AgentSessionRefusalDetails) : undefined diff --git a/src/shared/codex-cli-installation.test.ts b/src/shared/codex-cli-installation.test.ts new file mode 100644 index 000000000000..e7d5ca955983 --- /dev/null +++ b/src/shared/codex-cli-installation.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_MINIMUM_SUPPORTED_VERSION, + codexCliInstallation, + readCodexInstallationProblem +} from './codex-cli-installation' + +describe('Codex structured chat version floor', () => { + it.each([ + ['codex-cli 0.135.0', 'unsupported'], + ['codex-cli 0.136.0', 'ready'], + ['codex-cli v0.136.0', 'ready'], + ['0.136.0-alpha.2', 'unsupported'], + ['0.136.0+build.2', 'ready'], + ['0.137.0-alpha.2', 'ready'], + ['codex-cli 0.153.4\n', 'ready'], + ['codex-cli 1.0.0', 'ready'], + ['codex-cli unknown', 'unknown'], + ['', 'unknown'] + ])('%s is %s', (output, status) => { + expect(codexCliInstallation(true, output)).toMatchObject({ + status, + minimumVersion: CODEX_MINIMUM_SUPPORTED_VERSION + }) + }) + + it('distinguishes an absent binary from an unverifiable version', () => { + expect(codexCliInstallation(false, null)).toMatchObject({ status: 'missing', version: null }) + expect(codexCliInstallation(true, null)).toMatchObject({ status: 'unknown', version: null }) + }) + + it('admits only version facts that can safely be put in the refusal sentence', () => { + const problem = { installedVersion: '0.135.0', minimumVersion: '0.136.0' } + expect(readCodexInstallationProblem(problem)).toEqual(problem) + expect(readCodexInstallationProblem({ ...problem, installedVersion: null })).toEqual({ + ...problem, + installedVersion: null + }) + expect( + readCodexInstallationProblem({ ...problem, installedVersion: '