feat(runtime): add codex-native phase 0 app integration

This commit is contained in:
777genius 2026-04-19 19:33:37 +03:00
parent fbf299f276
commit ba37c1caf5
23 changed files with 7908 additions and 103 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,226 @@
# Codex Native Runtime - Phase 0 Sign-off Evidence
Captured on 2026-04-19.
This file is the repo-visible evidence package referenced by:
- [codex-native-runtime-phase-0-implementation-spec.md](./codex-native-runtime-phase-0-implementation-spec.md)
## Verdict
Phase 0 sign-off evidence is now captured.
What this proves:
- the `codex-native` lane executes through the raw `codex exec --json` seam
- persisted transcript projection remains parseable by current `claude_team` readers
- `ephemeral` and `persistent` runs keep different history-completeness truth
- thread status, warning attribution, executable identity, and usage authority survive end-to-end
- old Codex lane fallback truth remains covered by targeted regression tests
What this does **not** mean:
- `codex-native` should be unlocked for general runtime selection
- `auto` should start resolving to `codex-native`
- broader plugin or interactive capability claims are now safe
## Command Package
### `agent_teams_orchestrator`
Executed:
```bash
bun test src/services/codexNative/signOffHarness.test.ts \
src/services/codexNative/statusAuthority.test.ts \
src/services/codexNative/transcriptProjector.test.ts \
src/services/codexNative/turnExecutor.test.ts \
src/services/codexNative/execRunner.test.ts \
src/services/codexNative/jsonlMapper.test.ts \
src/services/runtimeBackends/codexBackendResolver.test.ts \
src/services/runtimeBackends/registry.agentTeams.test.ts
```
Observed result:
- `27 pass`
- `0 fail`
### `claude_team`
Executed:
```bash
pnpm exec vitest run \
test/main/utils/jsonl.test.ts \
test/main/services/parsing/SessionParser.test.ts \
test/main/services/team/BoardTaskExactLogStrictParser.test.ts \
test/main/ipc/configValidation.test.ts \
test/main/services/runtime/ProviderConnectionService.test.ts \
test/main/services/runtime/providerAwareCliEnv.test.ts \
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \
test/renderer/components/runtime/providerConnectionUi.test.ts \
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \
test/renderer/components/cli/CliStatusVisibility.test.ts
```
Observed result:
- `134 pass`
- `0 fail`
### Diff cleanliness
Executed:
```bash
git diff --check
```
Observed result:
- clean in both worktrees
## Live Native Run Evidence
### Common live-run facts
Observed from both runs:
- native binary path: `/usr/local/bin/codex`
- native binary source: `system-path`
- native binary version: `codex-cli 0.117.0`
- credential input source for the sign-off harness: `OPENAI_API_KEY`
- credential source observed by the runner: `explicit-api-key`
- capability profile: `headless-limited`
- final assistant text: `OK`
### Ephemeral run
Executed:
```bash
bun run ./scripts/codex-native-phase0-signoff.ts \
--cwd /tmp \
--prompt 'Reply only with OK' \
--ephemeral
```
Observed result:
- thread id: `019da680-6f43-7e10-824c-4d985bcdca12`
- completion policy: `ephemeral`
- final history completeness: `live-only`
- final usage authority: `live-turn-completed`
- assistant usage:
- input tokens: `23616`
- cached input tokens: `0`
- output tokens: `42`
History authority proof:
- projected warning subtype: `codex_native_warning`
- projected warning source: `history`
- observed warning text contained:
- `thread/read failed while backfilling turn items for turn completion`
- `ephemeral threads do not support includeTurns`
This is the explicit proof that `ephemeral` live stream does **not** equal canonical hydrated history.
### Persistent run
Executed:
```bash
bun run ./scripts/codex-native-phase0-signoff.ts \
--cwd /tmp \
--prompt 'Reply only with OK' \
--persistent
```
Observed result:
- thread id: `019da680-6f42-77c0-94f1-4e450a69d1f1`
- completion policy: `persistent`
- final history completeness: `explicit-hydration-required`
- final usage authority: `live-turn-completed`
- assistant usage:
- input tokens: `23616`
- cached input tokens: `0`
- output tokens: `33`
This is the explicit proof that persistent native runs keep a different history-completeness contract from `ephemeral` runs.
## Warning Attribution Proof
The live runs produced both:
- process/runtime warnings
- history-completeness warnings
Observed process-attributed warnings included:
- plugin cache / featured plugins unauthorized warnings
- state DB migration mismatch warnings
- shell snapshot timeout warnings
- MCP process-group termination warnings
Observed history-attributed warning included:
- `thread/read failed while backfilling turn items for turn completion: ... ephemeral threads do not support includeTurns`
This proves the lane now keeps `process` and `history` warning truth distinct in projected transcript rows.
## Thread-status Proof
Observed projected system rows included:
- `codex_native_thread_status`
- `running`
- `completed`
This proves the lane now writes native thread-status authority into persisted transcript-compatible rows instead of forcing UI and replay consumers to infer health from provider-global process truth.
## Parser And Exact-log Proof
Covered by green targeted tests:
- `test/main/utils/jsonl.test.ts`
- `test/main/services/parsing/SessionParser.test.ts`
- `test/main/services/team/BoardTaskExactLogStrictParser.test.ts`
These tests prove:
- projected assistant usage remains parseable
- projected warning/source metadata remains parseable
- projected execution-summary/history metadata remains parseable
- exact-log readers do not drop the native authority rows
## Degraded Old-lane Fallback Proof
Covered by green targeted tests:
- `src/services/runtimeBackends/codexBackendResolver.test.ts`
- `src/services/runtimeBackends/registry.agentTeams.test.ts`
Those tests prove:
- `auto` still does not silently resolve to `codex-native`
- native lane remains unavailable without:
- feature flag
- binary
- `CODEX_API_KEY`
- old Codex lane remains the truthful fallback when native is absent or degraded
## Sign-off Conclusion
✅ The Phase 0 code path is implementation-complete and evidence-backed.
⚠️ The lane should still remain:
- feature-flagged
- non-default
- non-auto-resolved
- non-selectable for normal runtime switching
That remaining lock is now a rollout-policy choice, not a missing-code problem.

View file

@ -442,10 +442,16 @@ function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | V
}
if (providerId === 'codex') {
if (backendId !== 'auto' && backendId !== 'adapter') {
if (
backendId !== 'auto' &&
backendId !== 'adapter' &&
backendId !== 'api' &&
backendId !== 'codex-native'
) {
return {
valid: false,
error: 'runtime.providerBackends.codex must be one of: auto, adapter',
error:
'runtime.providerBackends.codex must be one of: auto, adapter, api, codex-native',
};
}
providerBackends.codex = backendId;

View file

@ -226,7 +226,7 @@ export interface GeneralConfig {
export interface RuntimeConfig {
providerBackends: {
gemini: 'auto' | 'api' | 'cli-sdk';
codex: 'auto' | 'adapter';
codex: 'auto' | 'adapter' | 'api' | 'codex-native';
};
}

View file

@ -43,6 +43,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
};
const CODEX_API_KEY_BETA_ENV_VAR = 'CLAUDE_CODE_CODEX_API_KEY_BETA';
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null;
@ -64,7 +66,7 @@ export class ProviderConnectionService {
if (providerId === 'codex') {
const codexConnection = this.configManager.getConfig().providerConnections.codex;
return codexConnection.apiKeyBetaEnabled ? codexConnection.authMode : null;
return this.shouldExposeCodexConnectionModes() ? codexConnection.authMode : null;
}
return null;
@ -107,31 +109,55 @@ export class ProviderConnectionService {
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
if (!codexConnection.apiKeyBetaEnabled) {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
if (!this.shouldExposeCodexConnectionModes()) {
delete env[CODEX_API_KEY_BETA_ENV_VAR];
delete env.OPENAI_API_KEY;
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
return env;
}
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
if (codexConnection.apiKeyBetaEnabled) {
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
} else {
delete env[CODEX_API_KEY_BETA_ENV_VAR];
}
if (codexConnection.authMode === 'oauth') {
env.CLAUDE_CODE_CODEX_BACKEND = 'adapter';
delete env.OPENAI_API_KEY;
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
return env;
}
env.CLAUDE_CODE_CODEX_BACKEND = 'api';
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
if (storedKey?.value.trim()) {
env.OPENAI_API_KEY = storedKey.value;
const existingOpenAiKey =
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
? env.OPENAI_API_KEY
: null;
const existingNativeKey =
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
: null;
const resolvedApiKey =
storedKey?.value.trim() ||
existingOpenAiKey ||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null);
if (resolvedApiKey) {
env.OPENAI_API_KEY = resolvedApiKey;
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID) {
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
} else {
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
}
return env;
}
if (typeof env.OPENAI_API_KEY !== 'string' || !env.OPENAI_API_KEY.trim()) {
delete env.OPENAI_API_KEY;
}
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
return env;
}
@ -165,20 +191,48 @@ export class ProviderConnectionService {
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
if (!codexConnection.apiKeyBetaEnabled) {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
if (!this.shouldExposeCodexConnectionModes()) {
return env;
}
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
env.CLAUDE_CODE_CODEX_BACKEND = codexConnection.authMode === 'oauth' ? 'adapter' : 'api';
if (codexConnection.apiKeyBetaEnabled) {
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
} else {
delete env[CODEX_API_KEY_BETA_ENV_VAR];
}
if (codexConnection.authMode !== 'api_key') {
return env;
}
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
const existingOpenAiKey =
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
? env.OPENAI_API_KEY
: null;
const existingNativeKey =
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
: null;
const resolvedApiKey =
storedKey?.value.trim() ||
existingOpenAiKey ||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null);
if (storedKey?.value.trim()) {
env.OPENAI_API_KEY = storedKey.value;
} else if (
!existingOpenAiKey &&
existingNativeKey &&
codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID
) {
env.OPENAI_API_KEY = existingNativeKey;
}
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID && resolvedApiKey) {
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
}
return env;
@ -216,7 +270,8 @@ export class ProviderConnectionService {
}
const codexConnection = this.configManager.getConfig().providerConnections.codex;
if (!codexConnection.apiKeyBetaEnabled || codexConnection.authMode !== 'api_key') {
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
if (!this.shouldExposeCodexConnectionModes() || codexConnection.authMode !== 'api_key') {
return null;
}
@ -224,10 +279,17 @@ export class ProviderConnectionService {
return null;
}
return (
'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. ' +
'Add a stored/environment API key or switch Codex auth mode back to OAuth.'
);
if (
codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID &&
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
) {
return null;
}
return codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID
? 'Codex API key mode is enabled for codex-native, but no OPENAI_API_KEY or CODEX_API_KEY is configured. Add a stored/environment API key or switch Codex auth mode back to OAuth.'
: 'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. Add a stored/environment API key or switch Codex auth mode back to OAuth.';
}
async getConfiguredConnectionIssues(
@ -260,17 +322,23 @@ export class ProviderConnectionService {
async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> {
const capabilities = PROVIDER_CAPABILITIES[providerId];
const storedApiKey = await this.getStoredApiKey(providerId);
const externalCredential = this.getExternalCredential(providerId);
const codexRuntimeBackend =
providerId === 'codex' ? this.getConfiguredCodexRuntimeBackend() : null;
const externalCredential = this.getExternalCredential(providerId, codexRuntimeBackend);
const codexBetaEnabled =
providerId === 'codex'
? this.configManager.getConfig().providerConnections.codex.apiKeyBetaEnabled
: undefined;
const configurableAuthModes =
providerId === 'codex' && codexBetaEnabled
providerId === 'codex' &&
(codexBetaEnabled || codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID)
? (['oauth', 'api_key'] as CliProviderAuthMode[])
: capabilities.configurableAuthModes;
const configuredAuthMode =
providerId === 'codex' && !codexBetaEnabled ? null : this.getConfiguredAuthMode(providerId);
providerId === 'codex' &&
!(codexBetaEnabled || codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID)
? null
: this.getConfiguredAuthMode(providerId);
return {
...capabilities,
@ -301,7 +369,22 @@ export class ProviderConnectionService {
return this.apiKeyService.lookupPreferred(envVarName);
}
private getExternalCredential(providerId: CliProviderId): ExternalCredential {
private getConfiguredCodexRuntimeBackend(): 'auto' | 'adapter' | 'api' | 'codex-native' {
return this.configManager.getConfig().runtime.providerBackends.codex;
}
private shouldExposeCodexConnectionModes(): boolean {
const config = this.configManager.getConfig();
return (
config.providerConnections.codex.apiKeyBetaEnabled ||
config.runtime.providerBackends.codex === CODEX_NATIVE_BACKEND_ID
);
}
private getExternalCredential(
providerId: CliProviderId,
codexRuntimeBackend: 'auto' | 'adapter' | 'api' | 'codex-native' | null = null
): ExternalCredential {
const shellEnv = getCachedShellEnv() ?? {};
const sources = [shellEnv, process.env];
@ -336,6 +419,16 @@ export class ProviderConnectionService {
}
if (providerId === 'codex') {
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID) {
const nativeApiKey = findEnvValue(CODEX_NATIVE_API_KEY_ENV_VAR);
if (nativeApiKey) {
return {
label: `Detected from ${CODEX_NATIVE_API_KEY_ENV_VAR}`,
value: nativeApiKey,
};
}
}
const apiKey = findEnvValue('OPENAI_API_KEY');
if (apiKey) {
return {

View file

@ -198,9 +198,22 @@ export interface AssistantEntry extends ConversationalEntry {
export interface SystemEntry extends ConversationalEntry {
type: 'system';
subtype: 'turn_duration' | 'init';
durationMs: number;
subtype?: 'turn_duration' | 'init' | 'informational' | 'permission_retry' | 'api_retry' | string;
durationMs?: number;
isMeta: boolean;
content?: string;
level?: 'info' | 'warning' | 'error' | 'suggestion' | string;
toolUseID?: string;
preventContinuation?: boolean;
codexNativeWarningSource?: string;
codexNativeThreadStatus?: string;
codexNativeThreadId?: string;
codexNativeCompletionPolicy?: 'ephemeral' | 'persistent' | string;
codexNativeHistoryCompleteness?: string;
codexNativeFinalUsageAuthority?: string;
codexNativeExecutablePath?: string;
codexNativeExecutableSource?: string;
codexNativeExecutableVersion?: string | null;
}
export interface SummaryEntry extends BaseEntry {

View file

@ -109,6 +109,19 @@ export interface ParsedMessage {
isCompactSummary?: boolean;
/** API request ID for deduplicating streaming entries */
requestId?: string;
/** System-message severity when available in the raw transcript */
level?: string;
/** Raw system subtype when available in the transcript */
subtype?: string;
codexNativeWarningSource?: string;
codexNativeThreadStatus?: string;
codexNativeThreadId?: string;
codexNativeCompletionPolicy?: string;
codexNativeHistoryCompleteness?: string;
codexNativeFinalUsageAuthority?: string;
codexNativeExecutablePath?: string;
codexNativeExecutableSource?: string;
codexNativeExecutableVersion?: string | null;
}
// =============================================================================

View file

@ -244,6 +244,17 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
let gitBranch: string | undefined;
let agentId: string | undefined;
let agentName: string | undefined;
let level: string | undefined;
let subtype: string | undefined;
let codexNativeWarningSource: string | undefined;
let codexNativeThreadStatus: string | undefined;
let codexNativeThreadId: string | undefined;
let codexNativeCompletionPolicy: string | undefined;
let codexNativeHistoryCompleteness: string | undefined;
let codexNativeFinalUsageAuthority: string | undefined;
let codexNativeExecutablePath: string | undefined;
let codexNativeExecutableSource: string | undefined;
let codexNativeExecutableVersion: string | null | undefined;
let isSidechain = false;
let isMeta = false;
let userType: string | undefined;
@ -283,7 +294,19 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
agentId = entry.agentId;
requestId = entry.requestId;
} else if (entry.type === 'system') {
content = entry.content ?? '';
isMeta = entry.isMeta ?? false;
level = entry.level;
subtype = entry.subtype;
codexNativeWarningSource = entry.codexNativeWarningSource;
codexNativeThreadStatus = entry.codexNativeThreadStatus;
codexNativeThreadId = entry.codexNativeThreadId;
codexNativeCompletionPolicy = entry.codexNativeCompletionPolicy;
codexNativeHistoryCompleteness = entry.codexNativeHistoryCompleteness;
codexNativeFinalUsageAuthority = entry.codexNativeFinalUsageAuthority;
codexNativeExecutablePath = entry.codexNativeExecutablePath;
codexNativeExecutableSource = entry.codexNativeExecutableSource;
codexNativeExecutableVersion = entry.codexNativeExecutableVersion;
}
}
@ -310,6 +333,17 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
isMeta,
userType,
isCompactSummary,
level,
subtype,
codexNativeWarningSource,
codexNativeThreadStatus,
codexNativeThreadId,
codexNativeCompletionPolicy,
codexNativeHistoryCompleteness,
codexNativeFinalUsageAuthority,
codexNativeExecutablePath,
codexNativeExecutableSource,
codexNativeExecutableVersion,
// Tool info
toolCalls,
toolResults: toolResultsList,

View file

@ -240,6 +240,15 @@ function getProviderTerminalCommand(provider: CliProviderStatus): {
};
}
if (provider.providerId === 'codex') {
return {
args: ['auth', 'login', '--provider', provider.providerId],
env: {
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
},
};
}
return {
args: ['auth', 'login', '--provider', provider.providerId],
};
@ -259,6 +268,15 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
};
}
if (provider.providerId === 'codex') {
return {
args: ['auth', 'logout', '--provider', provider.providerId],
env: {
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
},
};
}
return {
args: ['auth', 'logout', '--provider', provider.providerId],
};

View file

@ -100,7 +100,10 @@ export const ProviderRuntimeBackendSelector = ({
<SelectItem
key={option.id}
value={option.id}
disabled={!option.available && option.id !== selectedBackendId}
disabled={
(!option.available || option.selectable === false) &&
option.id !== selectedBackendId
}
className="py-2.5"
>
<div className="flex min-w-0 flex-col gap-1">
@ -127,6 +130,16 @@ export const ProviderRuntimeBackendSelector = ({
>
Unavailable
</span>
) : option.selectable === false ? (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
style={{
color: 'var(--color-text-secondary)',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
}}
>
Locked
</span>
) : null}
</div>
<span className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
@ -179,6 +192,27 @@ export const ProviderRuntimeBackendSelector = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : selectedOption.selectable === false ? (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="cursor-help rounded-full px-1.5 py-0.5 text-[10px]"
style={{
color: 'var(--color-text-secondary)',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
}}
>
Locked
</span>
</TooltipTrigger>
<TooltipContent>
{selectedOption.detailMessage ??
selectedOption.statusMessage ??
'This backend cannot be selected yet.'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
<div className="mt-2 space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>

View file

@ -103,6 +103,17 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
}
function hasExplicitRuntimeBackends(provider: CliProviderStatus): boolean {
return (provider.availableBackends?.length ?? 0) > 0;
}
function isCodexNativeLane(provider: CliProviderStatus): boolean {
return (
provider.providerId === 'codex' &&
(provider.selectedBackendId === 'codex-native' || provider.resolvedBackendId === 'codex-native')
);
}
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
return matches.find((entry) => entry.scope === 'user') ?? null;
@ -113,9 +124,11 @@ function getConnectionDescription(provider: CliProviderStatus): string {
case 'anthropic':
return 'Choose how app-launched Anthropic sessions authenticate.';
case 'codex':
return provider.connection?.apiKeyBetaEnabled
? 'Choose whether app-launched Codex sessions use your Codex subscription or an OpenAI API key. Runtime follows this automatically.'
: 'Codex uses your subscription session by default. Enable API key mode if you want to switch Codex to OPENAI_API_KEY billing.';
return hasExplicitRuntimeBackends(provider)
? 'Choose which credentials app-launched Codex sessions should use. Runtime backend is configured separately below.'
: provider.connection?.apiKeyBetaEnabled
? 'Choose whether app-launched Codex sessions use your Codex subscription or an OpenAI API key.'
: 'Codex uses your subscription session by default. Enable API key mode if you want to switch Codex credential routing to API-key billing.';
case 'gemini':
return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
}
@ -126,7 +139,9 @@ function getRuntimeDescription(provider: CliProviderStatus): string {
case 'anthropic':
return 'Anthropic currently has no separate runtime backend selector.';
case 'codex':
return 'Codex runtime selection follows the active connection method automatically.';
return hasExplicitRuntimeBackends(provider)
? 'Choose which Codex runtime backend multimodel should use. Connection method only controls credentials.'
: 'Codex runtime selection follows the active connection method automatically.';
case 'gemini':
return 'Choose which Gemini runtime backend multimodel should use.';
}
@ -146,8 +161,8 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider
if (providerId === 'codex') {
return authMode === 'api_key'
? 'Use OPENAI_API_KEY and the public OpenAI Responses API backend.'
: 'Use your Codex subscription session and the built-in Codex runtime.';
? 'Use API-key credentials for app-launched Codex sessions. The selected runtime backend decides how those credentials are consumed.'
: 'Use your Codex subscription session. API-key-only backends remain unavailable until you switch this credential mode.';
}
return '';
@ -185,25 +200,20 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
if (
provider.providerId === 'codex' &&
provider.connection?.apiKeyBetaEnabled &&
authMode === 'api_key' &&
!provider.connection?.apiKeyConfigured
) {
return 'API key mode is selected, but no OPENAI_API_KEY credential is available yet.';
return isCodexNativeLane(provider)
? 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.'
: 'API key mode is selected, but no OPENAI_API_KEY credential is available yet.';
}
if (
provider.providerId === 'codex' &&
provider.connection?.apiKeyBetaEnabled &&
authMode === 'oauth' &&
!hasCodexSubscriptionSession
) {
if (provider.providerId === 'codex' && authMode === 'oauth' && !hasCodexSubscriptionSession) {
return 'Codex subscription mode is selected. Sign in with Codex to use this provider.';
}
if (
provider.providerId === 'codex' &&
provider.connection?.apiKeyBetaEnabled &&
authMode === 'oauth' &&
provider.connection?.apiKeySource === 'stored'
) {
@ -266,7 +276,9 @@ function getConnectionMethodCardOptions(
function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null {
if (provider.providerId === 'codex') {
return 'Runtime follows your connection method automatically.';
return hasExplicitRuntimeBackends(provider)
? 'Connection method controls credentials only. Runtime backend selection is independent.'
: 'Runtime follows your connection method automatically.';
}
if (provider.providerId === 'anthropic') {
@ -899,7 +911,8 @@ export const ProviderRuntimeSettingsDialog = ({
{selectedProvider.providerId === 'codex' &&
selectedProvider.connection?.apiKeyBetaAvailable &&
!selectedProvider.connection.apiKeyBetaEnabled ? (
!selectedProvider.connection.apiKeyBetaEnabled &&
!showConnectionMethodCards ? (
<div
className="space-y-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}

View file

@ -69,11 +69,22 @@ export function formatProviderAuthMethodLabelForProvider(
return formatProviderAuthMethodLabel(authMethod);
}
function isCodexNativeLane(provider: CliProviderStatus): boolean {
return (
provider.providerId === 'codex' &&
(provider.resolvedBackendId === 'codex-native' || provider.selectedBackendId === 'codex-native')
);
}
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
return provider.providerId === 'codex';
return provider.providerId === 'codex' && (provider.availableBackends?.length ?? 0) === 0;
}
function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
if (isCodexNativeLane(provider) && provider.backend?.label) {
return provider.backend.label;
}
if (provider.authenticated) {
return provider.authMethod === 'api_key' ? CODEX_API_KEY_LABEL : CODEX_SUBSCRIPTION_LABEL;
}
@ -86,7 +97,7 @@ function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
}
export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null {
if (provider.providerId !== 'codex') {
if (provider.providerId !== 'codex' || !isConnectionManagedRuntimeProvider(provider)) {
return null;
}
@ -163,6 +174,12 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
}
if (provider.providerId === 'codex' && provider.connection?.apiKeyBetaEnabled !== true) {
if (isCodexNativeLane(provider)) {
return provider.connection.apiKeySource === 'stored'
? 'Saved API key available in Manage'
: (provider.connection.apiKeySourceLabel ?? 'API key is configured');
}
return provider.connection.apiKeySource === 'stored'
? 'OpenAI API key is saved in Manage. Enable API key mode to use it.'
: 'OpenAI API key detected. Enable API key mode in Manage to use it.';

View file

@ -105,6 +105,15 @@ function getProviderTerminalCommand(provider: CliProviderStatus): {
};
}
if (provider.providerId === 'codex') {
return {
args: ['auth', 'login', '--provider', provider.providerId],
env: {
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
},
};
}
return {
args: ['auth', 'login', '--provider', provider.providerId],
};
@ -124,6 +133,15 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
};
}
if (provider.providerId === 'codex') {
return {
args: ['auth', 'logout', '--provider', provider.providerId],
env: {
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
},
};
}
return {
args: ['auth', 'logout', '--provider', provider.providerId],
};

View file

@ -337,7 +337,7 @@ export interface AppConfig {
runtime: {
providerBackends: {
gemini: 'auto' | 'api' | 'cli-sdk';
codex: 'auto' | 'adapter';
codex: 'auto' | 'adapter' | 'api' | 'codex-native';
};
};
/** Display and UI settings */

View file

@ -239,4 +239,49 @@ describe('configValidation', () => {
expect(result.error).toContain('providerConnections.codex.authMode');
}
});
it('accepts Codex runtime backend updates for api and codex-native', () => {
const apiResult = validateConfigUpdatePayload('runtime', {
providerBackends: {
codex: 'api',
},
});
expect(apiResult.valid).toBe(true);
if (apiResult.valid) {
expect(apiResult.data).toEqual({
providerBackends: {
codex: 'api',
},
});
}
const nativeResult = validateConfigUpdatePayload('runtime', {
providerBackends: {
codex: 'codex-native',
},
});
expect(nativeResult.valid).toBe(true);
if (nativeResult.valid) {
expect(nativeResult.data).toEqual({
providerBackends: {
codex: 'codex-native',
},
});
}
});
it('rejects unknown Codex runtime backends', () => {
const result = validateConfigUpdatePayload('runtime', {
providerBackends: {
codex: 'native',
},
});
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain('auto, adapter, api, codex-native');
}
});
});

View file

@ -0,0 +1,218 @@
// @vitest-environment node
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider';
import { SessionParser } from '../../../../src/main/services/parsing/SessionParser';
import { BoardTaskExactLogStrictParser } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
const tempDirs: string[] = [];
const mockProjectScanner = {
scan: vi.fn(),
getSessionPath: vi.fn(),
listSessionsPaginated: vi.fn(),
listSessions: vi.fn(),
listSubagentFiles: vi.fn(),
getSession: vi.fn(),
listWorktreeSessions: vi.fn(),
scanWithWorktreeGrouping: vi.fn(),
getFileSystemProvider: vi.fn().mockReturnValue(new LocalFileSystemProvider()),
};
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map(async (dirPath) => {
await fs.promises.rm(dirPath, { recursive: true, force: true });
}),
);
});
describe('codex-native phase 0 smoke', () => {
let parser: SessionParser;
beforeEach(() => {
vi.clearAllMocks();
// @ts-expect-error test partial
parser = new SessionParser(mockProjectScanner);
});
it('keeps native projected runtime truth parseable through session and exact-log readers', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-native-phase0-smoke-'));
tempDirs.push(tempDir);
const filePath = path.join(tempDir, 'native-phase0-session.jsonl');
fs.writeFileSync(
filePath,
[
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-smoke',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-running',
timestamp: '2026-04-19T10:00:00.000Z',
subtype: 'codex_native_thread_status',
level: 'info',
isMeta: false,
content: 'Codex native thread started: thread-smoke',
codexNativeThreadStatus: 'running',
codexNativeThreadId: 'thread-smoke',
}),
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-smoke',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-warning',
timestamp: '2026-04-19T10:00:01.000Z',
subtype: 'codex_native_warning',
level: 'warning',
isMeta: false,
content: 'thread/read failed while backfilling turn items for turn completion',
codexNativeWarningSource: 'history',
codexNativeThreadId: 'thread-smoke',
}),
JSON.stringify({
parentUuid: 'user-smoke-1',
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-smoke',
version: '1.0.0',
gitBranch: 'main',
type: 'assistant',
uuid: 'assistant-smoke',
requestId: 'request-smoke',
timestamp: '2026-04-19T10:00:02.000Z',
message: {
role: 'assistant',
model: 'gpt-5.4-mini',
id: 'msg-native-smoke',
type: 'message',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 12,
cache_read_input_tokens: 4,
output_tokens: 2,
},
content: [{ type: 'text', text: 'OK' }],
},
}),
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-smoke',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-summary',
timestamp: '2026-04-19T10:00:03.000Z',
subtype: 'codex_native_execution_summary',
level: 'info',
isMeta: false,
content:
'Codex native execution summary: thread=thread-smoke, completion=ephemeral, history=live-only, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
codexNativeThreadId: 'thread-smoke',
codexNativeCompletionPolicy: 'ephemeral',
codexNativeHistoryCompleteness: 'live-only',
codexNativeFinalUsageAuthority: 'live-turn-completed',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
}),
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-smoke',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-completed',
timestamp: '2026-04-19T10:00:04.000Z',
subtype: 'codex_native_thread_status',
level: 'info',
isMeta: false,
content: 'Codex native thread completed: thread-smoke',
codexNativeThreadStatus: 'completed',
codexNativeThreadId: 'thread-smoke',
}),
].join('\n'),
'utf8',
);
const parsed = await parser.parseSessionFile(filePath);
const exact = await new BoardTaskExactLogStrictParser().parseFiles([filePath]);
const exactRows = exact.get(filePath) ?? [];
expect(parsed.byType.assistant).toMatchObject([
{
uuid: 'assistant-smoke',
requestId: 'request-smoke',
usage: {
input_tokens: 12,
cache_read_input_tokens: 4,
output_tokens: 2,
},
},
]);
expect(parsed.byType.system).toMatchObject([
{
uuid: 'native-running',
subtype: 'codex_native_thread_status',
codexNativeThreadStatus: 'running',
},
{
uuid: 'native-warning',
subtype: 'codex_native_warning',
codexNativeWarningSource: 'history',
},
{
uuid: 'native-summary',
subtype: 'codex_native_execution_summary',
codexNativeCompletionPolicy: 'ephemeral',
codexNativeHistoryCompleteness: 'live-only',
},
{
uuid: 'native-completed',
subtype: 'codex_native_thread_status',
codexNativeThreadStatus: 'completed',
},
]);
expect(exactRows.map((row) => row.uuid)).toEqual([
'native-running',
'native-warning',
'assistant-smoke',
'native-summary',
'native-completed',
]);
expect(exactRows).toEqual(
expect.arrayContaining([
expect.objectContaining({
uuid: 'native-warning',
subtype: 'codex_native_warning',
codexNativeWarningSource: 'history',
}),
expect.objectContaining({
uuid: 'native-summary',
subtype: 'codex_native_execution_summary',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
}),
]),
);
});
});

View file

@ -10,6 +10,9 @@
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
SessionParser,
@ -173,6 +176,145 @@ describe('SessionParser', () => {
});
});
describe('parseSessionFile', () => {
it('keeps codex-native projected assistant usage and modern system warnings parseable', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-parser-native-'));
const filePath = path.join(tempDir, 'native-session.jsonl');
try {
fs.writeFileSync(
filePath,
[
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-parse',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'system-native-warning-1',
timestamp: '2026-04-19T10:00:00.000Z',
subtype: 'codex_native_warning',
level: 'warning',
isMeta: false,
content: 'native stderr warning',
codexNativeWarningSource: 'process',
}),
JSON.stringify({
parentUuid: 'user-native-1',
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-parse',
version: '1.0.0',
gitBranch: 'main',
type: 'assistant',
uuid: 'assistant-native-1',
requestId: 'native-request-1',
timestamp: '2026-04-19T10:00:01.000Z',
message: {
role: 'assistant',
model: 'gpt-5.4-mini',
id: 'msg-native-1',
type: 'message',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 12,
cache_read_input_tokens: 4,
output_tokens: 2,
},
content: [{ type: 'text', text: 'OK' }],
},
}),
].join('\n'),
'utf8',
);
const parsed = await parser.parseSessionFile(filePath);
expect(parsed.byType.system).toMatchObject([
{
uuid: 'system-native-warning-1',
content: 'native stderr warning',
level: 'warning',
subtype: 'codex_native_warning',
codexNativeWarningSource: 'process',
},
]);
expect(parsed.byType.assistant).toMatchObject([
{
uuid: 'assistant-native-1',
requestId: 'native-request-1',
usage: {
input_tokens: 12,
cache_read_input_tokens: 4,
output_tokens: 2,
},
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('keeps codex-native execution summary metadata parseable for replay and history truth', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-parser-native-summary-'));
const filePath = path.join(tempDir, 'native-summary-session.jsonl');
try {
fs.writeFileSync(
filePath,
[
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-summary',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'system-native-summary-1',
timestamp: '2026-04-19T10:00:02.000Z',
subtype: 'codex_native_execution_summary',
level: 'info',
isMeta: false,
content:
'Codex native execution summary: thread=thread-persistent, completion=persistent, history=explicit-hydration-required, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
codexNativeThreadId: 'thread-persistent',
codexNativeCompletionPolicy: 'persistent',
codexNativeHistoryCompleteness: 'explicit-hydration-required',
codexNativeFinalUsageAuthority: 'live-turn-completed',
codexNativeExecutablePath: '/usr/local/bin/codex',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
}),
].join('\n'),
'utf8',
);
const parsed = await parser.parseSessionFile(filePath);
expect(parsed.byType.system).toMatchObject([
{
uuid: 'system-native-summary-1',
subtype: 'codex_native_execution_summary',
codexNativeThreadId: 'thread-persistent',
codexNativeCompletionPolicy: 'persistent',
codexNativeHistoryCompleteness: 'explicit-hydration-required',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe('getResponses', () => {
it('should get assistant responses after user message', () => {
const userMsgUuid = 'user-1';

View file

@ -2,6 +2,11 @@
import type { PathLike } from 'fs';
import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
getProviderConnectionModeSummary,
getProviderCurrentRuntimeSummary,
isConnectionManagedRuntimeProvider,
} from '@renderer/components/runtime/providerConnectionUi';
const execCliMock = vi.fn();
const buildProviderAwareCliEnvMock = vi.fn();
@ -293,4 +298,145 @@ describe('ClaudeMultimodelBridgeService', () => {
},
});
});
it('keeps codex-native lane truth honest from unified runtime status through renderer summaries', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
providers: {
anthropic: {
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
models: ['claude-sonnet-4-5'],
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: { status: 'supported', ownership: 'shared', reason: null },
mcp: { status: 'supported', ownership: 'shared', reason: null },
skills: { status: 'supported', ownership: 'shared', reason: null },
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
},
},
backend: { kind: 'anthropic', label: 'Anthropic' },
},
codex: {
supported: true,
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
canLoginFromUi: true,
statusMessage: 'Codex native lane is wired but remains locked for normal selection.',
detailMessage: 'Use the fallback adapter/API lane unless the experimental native lane is explicitly enabled.',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
availableBackends: [
{
id: 'auto',
label: 'Auto',
selectable: true,
recommended: true,
available: true,
},
{
id: 'codex-native',
label: 'Codex native',
selectable: false,
recommended: false,
available: true,
statusMessage: 'Experimental native lane',
detailMessage: 'Phase 0 keeps the lane locked behind rollout policy.',
},
],
externalRuntimeDiagnostics: [
{
id: 'codex-cli',
label: 'Codex CLI',
detected: true,
statusMessage: 'Detected',
detailMessage: 'System codex binary available.',
},
],
capabilities: {
teamLaunch: true,
oneShot: true,
extensions: {
plugins: {
status: 'unsupported',
ownership: 'shared',
reason: 'codex-native phase 0 keeps plugin execution disabled.',
},
mcp: {
status: 'unsupported',
ownership: 'shared',
reason: 'Headless-limited lane',
},
skills: {
status: 'unsupported',
ownership: 'shared',
reason: 'Headless-limited lane',
},
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
},
},
backend: {
kind: 'codex-native',
label: 'Codex native',
authMethodDetail: 'API key',
},
},
gemini: {
supported: false,
authenticated: false,
},
},
}),
stderr: '',
exitCode: 0,
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
const codex = providers.find((provider) => provider.providerId === 'codex');
expect(codex).toMatchObject({
providerId: 'codex',
authenticated: true,
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
backend: {
kind: 'codex-native',
label: 'Codex native',
},
availableBackends: [
expect.objectContaining({
id: 'auto',
selectable: true,
}),
expect.objectContaining({
id: 'codex-native',
selectable: false,
available: true,
statusMessage: 'Experimental native lane',
}),
],
externalRuntimeDiagnostics: [
expect.objectContaining({
id: 'codex-cli',
detected: true,
}),
],
});
expect(codex?.capabilities.extensions.plugins).toMatchObject({
status: 'unsupported',
});
expect(isConnectionManagedRuntimeProvider(codex!)).toBe(false);
expect(getProviderConnectionModeSummary(codex!)).toBeNull();
expect(getProviderCurrentRuntimeSummary(codex!)).toBeNull();
});
});

View file

@ -9,16 +9,30 @@ vi.mock('@main/utils/shellEnv', () => ({
describe('ProviderConnectionService', () => {
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
const originalCodexApiKey = process.env.CODEX_API_KEY;
function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') {
function createConfig(
authMode: 'auto' | 'oauth' | 'api_key' = 'auto',
overrides?: {
codexAuthMode?: 'oauth' | 'api_key';
codexApiKeyBetaEnabled?: boolean;
codexRuntimeBackend?: 'auto' | 'adapter' | 'api' | 'codex-native';
}
) {
return {
providerConnections: {
anthropic: {
authMode,
},
codex: {
apiKeyBetaEnabled: false,
authMode: 'oauth' as const,
apiKeyBetaEnabled: overrides?.codexApiKeyBetaEnabled ?? false,
authMode: overrides?.codexAuthMode ?? ('oauth' as const),
},
},
runtime: {
providerBackends: {
gemini: 'auto' as const,
codex: overrides?.codexRuntimeBackend ?? ('auto' as const),
},
},
};
@ -29,15 +43,22 @@ describe('ProviderConnectionService', () => {
vi.clearAllMocks();
getCachedShellEnvMock.mockReturnValue({});
delete process.env.OPENAI_API_KEY;
delete process.env.CODEX_API_KEY;
});
afterEach(() => {
if (originalOpenAiApiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
}
if (originalCodexApiKey === undefined) {
delete process.env.CODEX_API_KEY;
return;
}
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
process.env.CODEX_API_KEY = originalCodexApiKey;
});
it('removes Anthropic environment credentials when OAuth mode is selected', async () => {
@ -284,15 +305,11 @@ describe('ProviderConnectionService', () => {
} as never,
{
getConfig: () => ({
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
},
...createConfig('auto', {
codexApiKeyBetaEnabled: true,
codexAuthMode: 'api_key',
codexRuntimeBackend: 'api',
}),
}),
} as never
);
@ -307,11 +324,11 @@ describe('ProviderConnectionService', () => {
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('api');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('auto');
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
});
it('forces the Codex adapter and strips OPENAI_API_KEY in OAuth mode', async () => {
it('keeps the configured Codex backend and strips OPENAI_API_KEY in oauth mode', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
@ -320,16 +337,10 @@ describe('ProviderConnectionService', () => {
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => ({
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: true,
authMode: 'oauth',
},
},
getConfig: () => createConfig('auto', {
codexApiKeyBetaEnabled: true,
codexAuthMode: 'oauth',
codexRuntimeBackend: 'api',
}),
} as never
);
@ -343,7 +354,7 @@ describe('ProviderConnectionService', () => {
);
expect(result.OPENAI_API_KEY).toBeUndefined();
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('auto');
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
});
@ -356,17 +367,12 @@ describe('ProviderConnectionService', () => {
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => ({
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: true,
authMode: 'api_key',
},
},
}),
getConfig: () =>
createConfig('auto', {
codexApiKeyBetaEnabled: true,
codexAuthMode: 'api_key',
codexRuntimeBackend: 'api',
}),
} as never
);
@ -376,7 +382,7 @@ describe('ProviderConnectionService', () => {
expect(issue).toContain('OPENAI_API_KEY');
});
it('augments PTY env for Codex without deleting an existing OPENAI_API_KEY in oauth mode', async () => {
it('augments PTY env for Codex without rewriting the configured backend in oauth mode', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
@ -385,17 +391,12 @@ describe('ProviderConnectionService', () => {
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => ({
providerConnections: {
anthropic: {
authMode: 'auto',
},
codex: {
apiKeyBetaEnabled: true,
authMode: 'oauth',
},
},
}),
getConfig: () =>
createConfig('auto', {
codexApiKeyBetaEnabled: true,
codexAuthMode: 'oauth',
codexRuntimeBackend: 'api',
}),
} as never
);
@ -407,7 +408,106 @@ describe('ProviderConnectionService', () => {
);
expect(result.OPENAI_API_KEY).toBe('shell-key');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined();
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
});
it('exposes Codex connection modes when codex-native is selected even without the old API beta toggle', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () =>
createConfig('auto', {
codexApiKeyBetaEnabled: false,
codexAuthMode: 'oauth',
codexRuntimeBackend: 'codex-native',
}),
} as never
);
const info = await service.getConnectionInfo('codex');
expect(info).toMatchObject({
configurableAuthModes: ['oauth', 'api_key'],
configuredAuthMode: 'oauth',
apiKeyBetaEnabled: false,
});
});
it('mirrors a stored OpenAI key into CODEX_API_KEY for codex-native without changing the selected backend', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'openai-stored-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () =>
createConfig('auto', {
codexApiKeyBetaEnabled: false,
codexAuthMode: 'api_key',
codexRuntimeBackend: 'codex-native',
}),
} as never
);
const result = await service.applyConfiguredConnectionEnv(
{
CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
},
'codex'
);
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
expect(result.CODEX_API_KEY).toBe('openai-stored-key');
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native');
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBeUndefined();
});
it('accepts CODEX_API_KEY as the native external credential source for codex-native', async () => {
getCachedShellEnvMock.mockReturnValue({
CODEX_API_KEY: 'native-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () =>
createConfig('auto', {
codexApiKeyBetaEnabled: false,
codexAuthMode: 'api_key',
codexRuntimeBackend: 'codex-native',
}),
} as never
);
const info = await service.getConnectionInfo('codex');
const issue = await service.getConfiguredConnectionIssue(
{
CODEX_API_KEY: 'native-key',
},
'codex'
);
expect(info.apiKeyConfigured).toBe(true);
expect(info.apiKeySource).toBe('environment');
expect(info.apiKeySourceLabel).toBe('Detected from CODEX_API_KEY');
expect(issue).toBeNull();
});
});

View file

@ -44,4 +44,102 @@ describe('BoardTaskExactLogStrictParser', () => {
expect(parsed.get(filePath)?.map((message) => message.uuid)).toEqual(['good-ts']);
});
it('preserves codex-native replay and history authority rows for exact-log readers', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-native-'));
tempDirs.push(tempDir);
const filePath = path.join(tempDir, 'native-session.jsonl');
await fs.writeFile(
filePath,
[
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-ephemeral',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-warning-1',
timestamp: '2026-04-19T10:00:00.000Z',
subtype: 'codex_native_warning',
level: 'warning',
isMeta: false,
content: 'thread/read failed while backfilling turn items for turn completion',
codexNativeWarningSource: 'history',
}),
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-ephemeral',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-summary-ephemeral',
timestamp: '2026-04-19T10:00:01.000Z',
subtype: 'codex_native_execution_summary',
level: 'info',
isMeta: false,
content:
'Codex native execution summary: thread=thread-ephemeral, completion=ephemeral, history=live-only, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
codexNativeThreadId: 'thread-ephemeral',
codexNativeCompletionPolicy: 'ephemeral',
codexNativeHistoryCompleteness: 'live-only',
codexNativeFinalUsageAuthority: 'live-turn-completed',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
}),
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-persistent',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'native-summary-persistent',
timestamp: '2026-04-19T10:00:02.000Z',
subtype: 'codex_native_execution_summary',
level: 'info',
isMeta: false,
content:
'Codex native execution summary: thread=thread-persistent, completion=persistent, history=explicit-hydration-required, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
codexNativeThreadId: 'thread-persistent',
codexNativeCompletionPolicy: 'persistent',
codexNativeHistoryCompleteness: 'explicit-hydration-required',
codexNativeFinalUsageAuthority: 'live-turn-completed',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
}),
].join('\n'),
'utf8',
);
const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]);
expect(parsed.get(filePath)).toMatchObject([
{
uuid: 'native-warning-1',
subtype: 'codex_native_warning',
codexNativeWarningSource: 'history',
},
{
uuid: 'native-summary-ephemeral',
subtype: 'codex_native_execution_summary',
codexNativeCompletionPolicy: 'ephemeral',
codexNativeHistoryCompleteness: 'live-only',
},
{
uuid: 'native-summary-persistent',
subtype: 'codex_native_execution_summary',
codexNativeCompletionPolicy: 'persistent',
codexNativeHistoryCompleteness: 'explicit-hydration-required',
},
]);
});
});

View file

@ -276,5 +276,145 @@ describe('jsonl', () => {
expect(parsed?.sourceToolUseID).toBe('call-bash-real');
expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real');
});
it('parses codex-native projected assistant rows with usage intact', () => {
const parsed = parseJsonlLine(
JSON.stringify({
parentUuid: 'user-1',
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-1',
version: '1.0.0',
gitBranch: 'main',
type: 'assistant',
uuid: 'assistant-native-1',
requestId: 'native-request-1',
timestamp: '2026-04-19T10:00:00.000Z',
message: {
role: 'assistant',
model: 'gpt-5-codex',
id: 'msg-native-1',
type: 'message',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 12,
cache_read_input_tokens: 4,
output_tokens: 2,
},
content: [{ type: 'text', text: 'OK' }],
},
}),
);
expect(parsed).toMatchObject({
uuid: 'assistant-native-1',
type: 'assistant',
content: [{ type: 'text', text: 'OK' }],
requestId: 'native-request-1',
usage: {
input_tokens: 12,
cache_read_input_tokens: 4,
output_tokens: 2,
},
});
});
it('parses modern system warning rows without dropping content or severity', () => {
const parsed = parseJsonlLine(
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-1',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'system-native-warning-1',
timestamp: '2026-04-19T10:00:01.000Z',
subtype: 'informational',
level: 'warning',
isMeta: false,
content: 'native stderr warning',
}),
);
expect(parsed).toMatchObject({
uuid: 'system-native-warning-1',
type: 'system',
content: 'native stderr warning',
level: 'warning',
subtype: 'informational',
isMeta: false,
});
});
it('parses codex-native execution-summary and warning metadata from projected system rows', () => {
const warningParsed = parseJsonlLine(
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-warning',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'system-native-warning-2',
timestamp: '2026-04-19T10:00:02.000Z',
subtype: 'codex_native_warning',
level: 'warning',
isMeta: false,
content: 'thread/read failed while backfilling turn items for turn completion',
codexNativeWarningSource: 'history',
}),
);
expect(warningParsed).toMatchObject({
uuid: 'system-native-warning-2',
subtype: 'codex_native_warning',
codexNativeWarningSource: 'history',
});
const summaryParsed = parseJsonlLine(
JSON.stringify({
parentUuid: null,
isSidechain: false,
userType: 'external',
cwd: '/tmp/project',
sessionId: 'session-native-summary',
version: '1.0.0',
gitBranch: 'main',
type: 'system',
uuid: 'system-native-summary-1',
timestamp: '2026-04-19T10:00:03.000Z',
subtype: 'codex_native_execution_summary',
level: 'info',
isMeta: false,
content:
'Codex native execution summary: thread=thread-ephemeral, completion=ephemeral, history=live-only, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
codexNativeThreadId: 'thread-ephemeral',
codexNativeCompletionPolicy: 'ephemeral',
codexNativeHistoryCompleteness: 'live-only',
codexNativeFinalUsageAuthority: 'live-turn-completed',
codexNativeExecutablePath: '/usr/local/bin/codex',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
}),
);
expect(summaryParsed).toMatchObject({
uuid: 'system-native-summary-1',
subtype: 'codex_native_execution_summary',
codexNativeThreadId: 'thread-ephemeral',
codexNativeCompletionPolicy: 'ephemeral',
codexNativeHistoryCompleteness: 'live-only',
codexNativeFinalUsageAuthority: 'live-turn-completed',
codexNativeExecutableSource: 'system-path',
codexNativeExecutableVersion: 'codex-cli 0.117.0',
});
});
});
});

View file

@ -4,6 +4,7 @@ import {
getProviderConnectionModeSummary,
getProviderCredentialSummary,
getProviderCurrentRuntimeSummary,
isConnectionManagedRuntimeProvider,
} from '@renderer/components/runtime/providerConnectionUi';
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
@ -51,6 +52,10 @@ function createCodexProvider(
overrides?: Partial<CliProviderStatus['connection']> & {
authenticated?: boolean;
authMethod?: string | null;
selectedBackendId?: string | null;
resolvedBackendId?: string | null;
availableBackends?: CliProviderStatus['availableBackends'];
backend?: CliProviderStatus['backend'];
}
): CliProviderStatus {
return {
@ -68,14 +73,16 @@ function createCodexProvider(
oneShot: true,
extensions: createDefaultCliExtensionCapabilities(),
},
selectedBackendId: 'auto',
resolvedBackendId: 'adapter',
availableBackends: [],
selectedBackendId: overrides?.selectedBackendId ?? 'auto',
resolvedBackendId: overrides?.resolvedBackendId ?? 'adapter',
availableBackends: overrides?.availableBackends ?? [],
externalRuntimeDiagnostics: [],
backend: {
kind: 'adapter',
label: 'Codex subscription',
},
backend:
overrides?.backend ??
({
kind: 'adapter',
label: 'Codex subscription',
} satisfies NonNullable<CliProviderStatus['backend']>),
connection: {
supportsOAuth: true,
supportsApiKey: true,
@ -185,4 +192,64 @@ describe('providerConnectionUi', () => {
'OpenAI API key is saved in Manage. Enable API key mode to use it.'
);
});
it('treats Codex as lane-managed once explicit backend options exist', () => {
const provider = createCodexProvider({
availableBackends: [
{
id: 'auto',
label: 'Auto',
description: 'Automatically choose the best backend.',
selectable: true,
recommended: true,
available: true,
},
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: true,
recommended: false,
available: true,
},
],
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
backend: {
kind: 'codex-native',
label: 'Codex native',
},
});
expect(isConnectionManagedRuntimeProvider(provider)).toBe(false);
expect(getProviderCurrentRuntimeSummary(provider)).toBeNull();
});
it('does not tell the user to enable API key mode when codex-native is already selected', () => {
const provider = createCodexProvider({
apiKeyBetaEnabled: false,
configuredAuthMode: 'api_key',
apiKeyConfigured: true,
apiKeySource: 'stored',
apiKeySourceLabel: 'Stored in app',
selectedBackendId: 'codex-native',
resolvedBackendId: 'codex-native',
availableBackends: [
{
id: 'codex-native',
label: 'Codex native',
description: 'Use codex exec JSON mode.',
selectable: true,
recommended: false,
available: true,
},
],
backend: {
kind: 'codex-native',
label: 'Codex native',
},
});
expect(getProviderCredentialSummary(provider)).toBe('Saved API key available in Manage');
});
});