fix(runtime): pass stored Codex key to status probes

Refs #189
This commit is contained in:
777genius 2026-05-28 18:36:06 +03:00
parent 6b8ab10414
commit d35f03b396
7 changed files with 281 additions and 9 deletions

View file

@ -10,7 +10,11 @@ import { tmpdir } from 'os';
import path from 'path';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import {
buildProviderAwareCliEnv,
getAggregateProviderStatusStoredCredentialAllowlist,
getProviderStatusStoredCredentialAllowlist,
} from './providerAwareCliEnv';
import { providerConnectionService } from './ProviderConnectionService';
import type {
@ -838,12 +842,15 @@ export class ClaudeMultimodelBridgeService {
}
private async buildCliEnv(
binaryPath: string
binaryPath: string,
options: { allowedStoredApiKeyEnvVarNames?: readonly string[] } = {}
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
return buildProviderAwareCliEnv({
binaryPath,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN'],
allowedStoredApiKeyEnvVarNames: options.allowedStoredApiKeyEnvVarNames ?? [
'ANTHROPIC_AUTH_TOKEN',
],
});
}
@ -855,8 +862,7 @@ export class ClaudeMultimodelBridgeService {
binaryPath,
providerId,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames:
providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist(providerId),
});
}
@ -1744,7 +1750,9 @@ export class ClaudeMultimodelBridgeService {
);
}
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
const { env, connectionIssues } = await this.buildCliEnv(binaryPath, {
allowedStoredApiKeyEnvVarNames: getAggregateProviderStatusStoredCredentialAllowlist(),
});
try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {

View file

@ -3,7 +3,10 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
import {
buildProviderAwareCliEnv,
getProviderStatusStoredCredentialAllowlist,
} from './providerAwareCliEnv';
import {
buildProviderModelProbeArgs,
classifyProviderModelProbeFailure,
@ -194,8 +197,9 @@ export class CliProviderModelAvailabilityService {
binaryPath: context.binaryPath,
providerId: context.provider.providerId,
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames:
context.provider.providerId === 'anthropic' ? ['ANTHROPIC_AUTH_TOKEN'] : undefined,
allowedStoredApiKeyEnvVarNames: getProviderStatusStoredCredentialAllowlist(
context.provider.providerId
),
}).then((result) => ({
env: result.env,
providerArgs: result.providerArgs ?? [],

View file

@ -12,6 +12,14 @@ import type { CliProviderId, TeamProviderId } from '@shared/types';
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
const PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = {
anthropic: ['ANTHROPIC_AUTH_TOKEN'],
codex: ['OPENAI_API_KEY'],
} as const;
const AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST = [
'ANTHROPIC_AUTH_TOKEN',
'OPENAI_API_KEY',
] as const;
export interface ProviderAwareCliEnvOptions {
binaryPath?: string | null;
@ -30,6 +38,20 @@ export interface ProviderAwareCliEnvResult {
providerArgs: string[];
}
export function getProviderStatusStoredCredentialAllowlist(
providerId: ProviderEnvTargetId
): readonly string[] | undefined {
if (providerId === 'anthropic' || providerId === 'codex') {
return PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST[providerId];
}
return undefined;
}
export function getAggregateProviderStatusStoredCredentialAllowlist(): readonly string[] {
return AGGREGATE_PROVIDER_STATUS_STORED_CREDENTIAL_ALLOWLIST;
}
function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void {
delete env[ELECTRON_RUN_AS_NODE_ENV];
}

View file

@ -57,6 +57,16 @@ vi.mock('@main/services/runtime/ProviderConnectionService', () => ({
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
buildProviderAwareCliEnvMock(...args),
getAggregateProviderStatusStoredCredentialAllowlist: () => [
'ANTHROPIC_AUTH_TOKEN',
'OPENAI_API_KEY',
],
getProviderStatusStoredCredentialAllowlist: (providerId?: string) =>
providerId === 'anthropic'
? ['ANTHROPIC_AUTH_TOKEN']
: providerId === 'codex'
? ['OPENAI_API_KEY']
: undefined,
}));
describe('ClaudeMultimodelBridgeService', () => {
@ -249,6 +259,14 @@ describe('ClaudeMultimodelBridgeService', () => {
projectId: 'demo-project',
},
});
const aggregateEnvBuild = buildProviderAwareCliEnvMock.mock.calls.find(
([options]) => options.providerId === undefined
);
expect(aggregateEnvBuild?.[0]).toMatchObject({
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'],
});
});
it('falls back to provider-scoped full runtime status without probing Gemini', async () => {
@ -950,6 +968,18 @@ describe('ClaudeMultimodelBridgeService', () => {
modelCatalogRefreshState: 'ready',
});
expect(hydratedCodex?.modelCatalog?.models.map((model) => model.id)).toEqual(['gpt-5.4']);
const codexEnvBuilds = buildProviderAwareCliEnvMock.mock.calls.filter(
([options]) => options.providerId === 'codex'
);
expect(codexEnvBuilds.length).toBeGreaterThanOrEqual(2);
for (const [options] of codexEnvBuilds) {
expect(options).toMatchObject({
providerId: 'codex',
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'],
});
}
});
it('promotes OpenCode auth when full catalog hydration proves built-in free access', async () => {
@ -1839,6 +1869,44 @@ describe('ClaudeMultimodelBridgeService', () => {
);
});
it('allows the stored Codex API key for Codex status checks', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({
providers: {
codex: {
supported: true,
authenticated: true,
authMethod: 'api_key',
verificationState: 'verified',
canLoginFromUi: true,
capabilities: { teamLaunch: true, oneShot: true },
},
},
}),
stderr: '',
exitCode: 0,
});
const { ClaudeMultimodelBridgeService } =
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
const service = new ClaudeMultimodelBridgeService();
const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex');
expect(provider).toMatchObject({
providerId: 'codex',
authenticated: true,
authMethod: 'api_key',
});
expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'codex',
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'],
})
);
});
it('falls back conservatively when the runtime omits extension capability metadata', async () => {
execCliMock.mockResolvedValue({
stdout: JSON.stringify({

View file

@ -11,6 +11,12 @@ vi.mock('@main/utils/childProcess', () => ({
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
buildProviderAwareCliEnvMock(...args),
getProviderStatusStoredCredentialAllowlist: (providerId?: string) =>
providerId === 'anthropic'
? ['ANTHROPIC_AUTH_TOKEN']
: providerId === 'codex'
? ['OPENAI_API_KEY']
: undefined,
}));
import {
@ -186,4 +192,25 @@ describe('CliProviderModelAvailabilityService', () => {
);
});
});
it('allows stored Codex API-key access for model probes', async () => {
buildProviderAwareCliEnvMock.mockResolvedValue({
env: { HOME: '/Users/tester' },
connectionIssues: {},
});
execCliMock.mockResolvedValue({ stdout: 'PONG', stderr: '' });
const service = new CliProviderModelAvailabilityService();
service.getSnapshot(createContext(['gpt-5.4']));
await vi.waitFor(() => {
expect(buildProviderAwareCliEnvMock).toHaveBeenCalledWith(
expect.objectContaining({
providerId: 'codex',
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'],
})
);
});
});
});

View file

@ -1200,6 +1200,115 @@ describe('ProviderConnectionService', () => {
expect(result.CODEX_API_KEY).toBe('openai-stored-key');
});
it('mirrors a stored Codex OpenAI key when metadata-only status checks allow it', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'openai-stored-key',
});
const hasPreferred = vi.fn().mockResolvedValue(true);
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
hasPreferred,
lookupPreferred,
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'codex', undefined, {
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['OPENAI_API_KEY'],
});
expect(hasPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
expect(result.CODEX_API_KEY).toBe('openai-stored-key');
});
it('does not mirror a stored Codex OpenAI key during metadata-only checks without allowlist', async () => {
const lookupPreferred = vi.fn().mockResolvedValue({
envVarName: 'OPENAI_API_KEY',
value: 'openai-stored-key',
});
const hasPreferred = vi.fn().mockResolvedValue(true);
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
hasPreferred,
lookupPreferred,
} as never,
{
getConfig: () => createConfig('auto'),
} as never
);
const result = await service.applyConfiguredConnectionEnv({}, 'codex', undefined, {
allowStoredApiKeyDecryption: false,
});
expect(hasPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(lookupPreferred).not.toHaveBeenCalled();
expect(result.OPENAI_API_KEY).toBeUndefined();
expect(result.CODEX_API_KEY).toBeUndefined();
});
it('applies aggregate metadata-only allowlist without decrypting unrelated provider keys', async () => {
const lookupPreferred = vi.fn(async (envVarName: string) => {
if (envVarName === 'ANTHROPIC_AUTH_TOKEN') {
return { envVarName, value: 'anthropic-compatible-token' };
}
if (envVarName === 'OPENAI_API_KEY') {
return { envVarName, value: 'openai-stored-key' };
}
if (envVarName === 'GEMINI_API_KEY') {
return { envVarName, value: 'gemini-stored-key' };
}
return null;
});
const hasPreferred = vi.fn(async (envVarName: string) => envVarName === 'OPENAI_API_KEY');
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');
const service = new ProviderConnectionService(
{
hasPreferred,
lookupPreferred,
} as never,
{
getConfig: () =>
createConfig('auto', {
enabled: true,
baseUrl: 'http://localhost:1234',
}),
} as never
);
const result = await service.applyAllConfiguredConnectionEnv(
{},
{
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'],
}
);
expect(result.ANTHROPIC_BASE_URL).toBe('http://localhost:1234');
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('anthropic-compatible-token');
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
expect(result.CODEX_API_KEY).toBe('openai-stored-key');
expect(result.GEMINI_API_KEY).toBeUndefined();
expect(lookupPreferred).toHaveBeenCalledWith('ANTHROPIC_AUTH_TOKEN');
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
expect(lookupPreferred).not.toHaveBeenCalledWith('GEMINI_API_KEY');
expect(lookupPreferred).not.toHaveBeenCalledWith('ANTHROPIC_API_KEY');
});
it('keeps ambient OpenAI credentials for native Codex launches', async () => {
const { ProviderConnectionService } =
await import('@main/services/runtime/ProviderConnectionService');

View file

@ -107,6 +107,25 @@ describe('buildProviderAwareCliEnv', () => {
});
});
it('returns narrow provider status stored credential allowlists', async () => {
const {
getAggregateProviderStatusStoredCredentialAllowlist,
getProviderStatusStoredCredentialAllowlist,
} = await import('../../../../src/main/services/runtime/providerAwareCliEnv');
expect(getProviderStatusStoredCredentialAllowlist('anthropic')).toEqual([
'ANTHROPIC_AUTH_TOKEN',
]);
expect(getProviderStatusStoredCredentialAllowlist('codex')).toEqual(['OPENAI_API_KEY']);
expect(getProviderStatusStoredCredentialAllowlist('gemini')).toBeUndefined();
expect(getProviderStatusStoredCredentialAllowlist('opencode')).toBeUndefined();
expect(getProviderStatusStoredCredentialAllowlist(undefined)).toBeUndefined();
expect(getAggregateProviderStatusStoredCredentialAllowlist()).toEqual([
'ANTHROPIC_AUTH_TOKEN',
'OPENAI_API_KEY',
]);
});
it('builds provider-pinned CLI env and returns provider-specific issues', async () => {
getConfiguredConnectionIssuesMock.mockResolvedValue({
anthropic: 'missing key',
@ -234,6 +253,21 @@ describe('buildProviderAwareCliEnv', () => {
expect(applyAllConfiguredConnectionEnvMock).not.toHaveBeenCalled();
});
it('passes a stored API key decrypt allowlist through shared env building', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
await buildProviderAwareCliEnv({
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'],
});
expect(applyAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(expect.any(Object), {
allowStoredApiKeyDecryption: false,
allowedStoredApiKeyEnvVarNames: ['ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY'],
});
expect(applyConfiguredConnectionEnvMock).not.toHaveBeenCalled();
});
it('builds shared env for generic CLI launches when no provider is specified', async () => {
const { buildProviderAwareCliEnv } =
await import('../../../../src/main/services/runtime/providerAwareCliEnv');