fix(codex): propagate native runtime context

This commit is contained in:
777genius 2026-04-28 15:00:44 +03:00
parent bef34983f5
commit e87ef2dd85
5 changed files with 192 additions and 0 deletions

View file

@ -64,6 +64,11 @@ export interface CodexLoginStateDto {
startedAt: string | null;
}
export interface CodexRuntimeContextDto {
binaryPath: string | null;
codexHome: string | null;
}
export interface CodexAccountSnapshotDto {
preferredAuthMode: CodexAccountAuthMode;
effectiveAuthMode: CodexAccountEffectiveAuthMode;
@ -77,6 +82,7 @@ export interface CodexAccountSnapshotDto {
requiresOpenaiAuth: boolean | null;
localAccountArtifactsPresent?: boolean;
localActiveChatgptAccountPresent?: boolean;
runtimeContext?: CodexRuntimeContextDto;
login: CodexLoginStateDto;
rateLimits: CodexRateLimitSnapshotDto | null;
updatedAt: string;

View file

@ -48,6 +48,16 @@ interface CodexLastKnownRateLimits {
observedAt: number;
}
interface CodexRuntimeContext {
binaryPath: string | null;
codexHome: string | null;
}
interface CodexLastKnownRuntimeContext {
payload: CodexRuntimeContext;
observedAt: number;
}
interface CodexSnapshotRefreshOptions {
includeRateLimits: boolean;
forceRefreshToken: boolean;
@ -130,6 +140,16 @@ function asRateLimits(
};
}
function createRuntimeContext(
binaryPath: string | null | undefined,
codexHome: string | null | undefined
): CodexRuntimeContext {
return {
binaryPath: binaryPath?.trim() || null,
codexHome: codexHome?.trim() || null,
};
}
function getPreferredAuthMode(configManager: {
getConfig: () => {
providerConnections: {
@ -236,6 +256,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null;
private lastKnownAccount: CodexLastKnownAccount | null = null;
private lastKnownRateLimits: CodexLastKnownRateLimits | null = null;
private lastKnownRuntimeContext: CodexLastKnownRuntimeContext | null = null;
private mutationQueue: Promise<void> = Promise.resolve();
private mutationQueueRelease: (() => void) | null = null;
private activeMutationCount = 0;
@ -372,6 +393,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
this.pendingRefreshOptions = null;
this.lastKnownAccount = null;
this.lastKnownRateLimits = null;
this.lastKnownRuntimeContext = null;
this.activeMutationCount = 0;
if (this.mutationQueueRelease) {
this.mutationQueueRelease();
@ -441,6 +463,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
let appServerStatusMessage: string | null = null;
let accountPayload = this.lastKnownAccount?.payload ?? null;
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
let runtimeContext = createRuntimeContext(binaryPath, null);
try {
const accountResult = await this.appServerClient.readAccount({
@ -448,6 +471,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
env,
refreshToken: options?.forceRefreshToken ?? false,
});
runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome);
if (runtimeContext.codexHome) {
this.lastKnownRuntimeContext = {
payload: runtimeContext,
observedAt: now,
};
}
const canReuseLastKnownManagedAccount =
options?.forceRefreshToken !== true &&
localActiveChatgptAccountPresent &&
@ -483,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
accountPayload = this.lastKnownAccount.payload;
requiresOpenaiAuth = this.lastKnownAccount.payload.requiresOpenaiAuth;
}
if (
this.lastKnownRuntimeContext &&
now - this.lastKnownRuntimeContext.observedAt <= LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS &&
this.lastKnownRuntimeContext.payload.binaryPath === binaryPath
) {
runtimeContext = this.lastKnownRuntimeContext.payload;
}
}
let rateLimits: CodexRateLimitSnapshotDto | null = null;
@ -542,6 +580,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
requiresOpenaiAuth,
localAccountArtifactsPresent,
localActiveChatgptAccountPresent,
runtimeContext,
login,
rateLimits,
updatedAt: new Date(now).toISOString(),

View file

@ -62,6 +62,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
};
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH';
const CODEX_HOME_ENV_VAR = 'CODEX_HOME';
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
function isCodexExecBinary(binaryPath?: string | null): boolean {
@ -85,6 +87,21 @@ function buildCodexForcedLoginLaunchArgs(
return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })];
}
function applyCodexRuntimeContextEnv(
env: NodeJS.ProcessEnv,
snapshot: CodexAccountSnapshotDto
): void {
const binaryPath = snapshot.runtimeContext?.binaryPath?.trim();
if (binaryPath) {
env[CODEX_CLI_PATH_ENV_VAR] = binaryPath;
}
const codexHome = snapshot.runtimeContext?.codexHome?.trim();
if (codexHome) {
env[CODEX_HOME_ENV_VAR] = codexHome;
}
}
export class ProviderConnectionService {
private static instance: ProviderConnectionService | null = null;
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
@ -179,6 +196,7 @@ export class ProviderConnectionService {
}
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
applyCodexRuntimeContextEnv(env, snapshot);
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount,
@ -239,6 +257,7 @@ export class ProviderConnectionService {
}
const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env);
applyCodexRuntimeContextEnv(env, snapshot);
const readiness = evaluateCodexLaunchReadiness({
preferredAuthMode: snapshot.preferredAuthMode,
managedAccount: snapshot.managedAccount,
@ -566,6 +585,10 @@ export class ProviderConnectionService {
requiresOpenaiAuth: null,
localAccountArtifactsPresent: false,
localActiveChatgptAccountPresent: false,
runtimeContext: {
binaryPath: null,
codexHome: null,
},
login: {
status: 'idle',
error: null,

View file

@ -269,6 +269,10 @@ describe('createCodexAccountFeature', () => {
source: 'environment',
sourceLabel: 'Detected from OPENAI_API_KEY',
},
runtimeContext: {
binaryPath: '/usr/local/bin/codex',
codexHome: '/Users/test/.codex',
},
launchAllowed: true,
launchReadinessState: 'ready_both',
});
@ -315,6 +319,10 @@ describe('createCodexAccountFeature', () => {
type: 'chatgpt',
email: 'user@example.com',
});
expect(degradedSnapshot.runtimeContext).toEqual({
binaryPath: '/usr/local/bin/codex',
codexHome: '/Users/test/.codex',
});
expect(degradedSnapshot.launchAllowed).toBe(true);
expect(logger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('false logout'),

View file

@ -285,6 +285,122 @@ describe('ProviderConnectionService', () => {
expect(result.CODEX_API_KEY).toBe('shell-openai-key');
});
it('passes Codex runtime context while clearing API keys for ChatGPT launches', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred: vi.fn().mockResolvedValue(null),
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'chatgpt',
effectiveAuthMode: 'chatgpt',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_chatgpt',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: {
type: 'chatgpt',
email: 'user@example.com',
planType: 'pro',
},
apiKey: {
available: false,
source: null,
sourceLabel: null,
},
requiresOpenaiAuth: false,
runtimeContext: {
binaryPath: '/opt/codex/bin/codex',
codexHome: '/Users/tester/.codex-custom',
},
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const result = await service.applyConfiguredConnectionEnv(
{
OPENAI_API_KEY: 'ambient-openai-key',
CODEX_API_KEY: 'ambient-codex-key',
},
'codex'
);
expect(result.OPENAI_API_KEY).toBeUndefined();
expect(result.CODEX_API_KEY).toBeUndefined();
expect(result.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex');
expect(result.CODEX_HOME).toBe('/Users/tester/.codex-custom');
});
it('keeps Codex runtime context when API-key mode mirrors credentials', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'stored-openai-key',
});
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
lookupPreferred,
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
service.setCodexAccountFeature({
getSnapshot: vi.fn().mockResolvedValue({
preferredAuthMode: 'api_key',
effectiveAuthMode: 'api_key',
launchAllowed: true,
launchIssueMessage: null,
launchReadinessState: 'ready_api_key',
appServerState: 'healthy',
appServerStatusMessage: null,
managedAccount: null,
apiKey: {
available: true,
source: 'stored',
sourceLabel: 'Stored in app',
},
requiresOpenaiAuth: false,
runtimeContext: {
binaryPath: '/opt/codex/bin/codex.cmd',
codexHome: 'C:\\Users\\tester\\.codex',
},
login: {
status: 'idle',
error: null,
startedAt: null,
},
rateLimits: null,
updatedAt: '2026-04-20T00:00:00.000Z',
}),
} as never);
const result = await service.applyConfiguredConnectionEnv({}, 'codex');
expect(result.OPENAI_API_KEY).toBe('stored-openai-key');
expect(result.CODEX_API_KEY).toBe('stored-openai-key');
expect(result.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex.cmd');
expect(result.CODEX_HOME).toBe('C:\\Users\\tester\\.codex');
});
it('accepts CODEX_API_KEY as the native external credential source for Codex', async () => {
getCachedShellEnvMock.mockReturnValue({
CODEX_API_KEY: 'native-key',