fix(codex): propagate native runtime context
This commit is contained in:
parent
bef34983f5
commit
e87ef2dd85
5 changed files with 192 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue