parent
6b8ab10414
commit
d35f03b396
7 changed files with 281 additions and 9 deletions
|
|
@ -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'], {
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue