fix: harden provider-aware cli env handling
This commit is contained in:
parent
c30e8d414a
commit
02d516cb4e
14 changed files with 1034 additions and 148 deletions
|
|
@ -91,7 +91,7 @@ async function handleSpawn(
|
|||
options?: PtySpawnOptions
|
||||
): Promise<IpcResult<string>> {
|
||||
try {
|
||||
const id = service.spawn(options);
|
||||
const id = await service.spawn(options);
|
||||
return { success: true, data: id };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@
|
|||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||
|
||||
import type { PtySpawnOptions } from '@shared/types/terminal';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
|
|
@ -46,7 +47,7 @@ export class PtyTerminalService {
|
|||
* @returns Unique PTY ID for subsequent write/resize/kill calls.
|
||||
* @throws If node-pty native module is not available.
|
||||
*/
|
||||
spawn(options?: PtySpawnOptions): string {
|
||||
async spawn(options?: PtySpawnOptions): Promise<string> {
|
||||
if (!nodePty) {
|
||||
throw new Error(
|
||||
'Terminal not available: node-pty native module not found. Run: pnpm install'
|
||||
|
|
@ -54,11 +55,15 @@ export class PtyTerminalService {
|
|||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const { env } = await buildProviderAwareCliEnv({
|
||||
env: options?.env,
|
||||
connectionMode: 'augment',
|
||||
});
|
||||
const shell =
|
||||
options?.command ??
|
||||
(process.platform === 'win32'
|
||||
? (process.env.COMSPEC ?? 'powershell.exe')
|
||||
: (process.env.SHELL ?? '/bin/bash'));
|
||||
? (env.COMSPEC ?? process.env.COMSPEC ?? 'powershell.exe')
|
||||
: (env.SHELL ?? process.env.SHELL ?? '/bin/bash'));
|
||||
|
||||
const home = getHomeDir();
|
||||
const pty = nodePty.spawn(shell, options?.args ?? [], {
|
||||
|
|
@ -66,10 +71,7 @@ export class PtyTerminalService {
|
|||
cols: options?.cols ?? 80,
|
||||
rows: options?.rows ?? 24,
|
||||
cwd: options?.cwd ?? home,
|
||||
env: {
|
||||
...buildEnrichedEnv(),
|
||||
...options?.env,
|
||||
} as Record<string, string>,
|
||||
env: env as Record<string, string>,
|
||||
});
|
||||
|
||||
pty.onData((data) => this.send(TERMINAL_DATA, id, data));
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import {
|
||||
getCachedShellEnv,
|
||||
getShellPreferredHome,
|
||||
resolveInteractiveShellEnv,
|
||||
} from '@main/utils/shellEnv';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
import { applyConfiguredRuntimeBackendsEnv, applyProviderRuntimeEnv } from './providerRuntimeEnv';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
|
||||
|
|
@ -151,38 +144,29 @@ function extractModelIds(
|
|||
return [];
|
||||
}
|
||||
|
||||
return models.flatMap((model) => {
|
||||
return models.flatMap<string>((model) => {
|
||||
if (typeof model === 'string') {
|
||||
return model;
|
||||
return [model];
|
||||
}
|
||||
if (typeof model?.id === 'string' && model.id.trim().length > 0) {
|
||||
return model.id.trim();
|
||||
return [model.id.trim()];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export class ClaudeMultimodelBridgeService {
|
||||
private async buildCliEnv(binaryPath: string): Promise<NodeJS.ProcessEnv> {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const home =
|
||||
getShellPreferredHome() || shellEnv.HOME || process.env.HOME || process.env.USERPROFILE;
|
||||
const env = {
|
||||
...buildEnrichedEnv(binaryPath),
|
||||
...shellEnv,
|
||||
};
|
||||
if (home) {
|
||||
env.HOME = home;
|
||||
}
|
||||
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||
return providerConnectionService.applyAllConfiguredConnectionEnv(env);
|
||||
private async buildCliEnv(
|
||||
binaryPath: string
|
||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
return buildProviderAwareCliEnv({ binaryPath });
|
||||
}
|
||||
|
||||
private async buildProviderCliEnv(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId);
|
||||
): Promise<Awaited<ReturnType<typeof buildProviderAwareCliEnv>>> {
|
||||
return buildProviderAwareCliEnv({ binaryPath, providerId });
|
||||
}
|
||||
|
||||
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
|
||||
|
|
@ -252,12 +236,38 @@ export class ClaudeMultimodelBridgeService {
|
|||
};
|
||||
}
|
||||
|
||||
private applyConnectionIssue(
|
||||
provider: CliProviderStatus,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||
): CliProviderStatus {
|
||||
const issue = connectionIssues[provider.providerId];
|
||||
if (!issue) {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage: issue,
|
||||
backend: null,
|
||||
};
|
||||
}
|
||||
|
||||
private applyConnectionIssues(
|
||||
providers: CliProviderStatus[],
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||
): CliProviderStatus[] {
|
||||
return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues));
|
||||
}
|
||||
|
||||
async getProviderStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = await this.buildCliEnv(binaryPath);
|
||||
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
|
|
@ -270,7 +280,10 @@ export class ClaudeMultimodelBridgeService {
|
|||
);
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||
this.applyConnectionIssue(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||
|
|
@ -291,7 +304,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
|
||||
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
|
||||
const provider = createDefaultProviderStatus('gemini');
|
||||
const env = await this.buildProviderCliEnv(binaryPath, 'gemini');
|
||||
const { env } = await this.buildProviderCliEnv(binaryPath, 'gemini');
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(
|
||||
|
|
@ -350,7 +363,7 @@ export class ClaudeMultimodelBridgeService {
|
|||
onUpdate?: (providers: CliProviderStatus[]) => void
|
||||
): Promise<CliProviderStatus[]> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = await this.buildCliEnv(binaryPath);
|
||||
const { env, connectionIssues } = await this.buildCliEnv(binaryPath);
|
||||
|
||||
try {
|
||||
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
|
||||
|
|
@ -359,8 +372,11 @@ export class ClaudeMultimodelBridgeService {
|
|||
});
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
const providers = await providerConnectionService.enrichProviderStatuses(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) =>
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||
this.applyConnectionIssues(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) =>
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||
),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
onUpdate?.(providers);
|
||||
|
|
@ -470,7 +486,10 @@ export class ClaudeMultimodelBridgeService {
|
|||
onUpdate?.(ORDERED_PROVIDER_IDS.map((id) => providers.get(id)!));
|
||||
|
||||
const enrichedProviders = await providerConnectionService.enrichProviderStatuses(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!)
|
||||
this.applyConnectionIssues(
|
||||
ORDERED_PROVIDER_IDS.map((providerId) => providers.get(providerId)!),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
onUpdate?.(enrichedProviders);
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,108 @@ export class ProviderConnectionService {
|
|||
return nextEnv;
|
||||
}
|
||||
|
||||
async augmentConfiguredConnectionEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
return env;
|
||||
}
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('ANTHROPIC_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
env.ANTHROPIC_API_KEY = storedKey.value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
if (providerId !== 'codex') {
|
||||
return env;
|
||||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled) {
|
||||
return env;
|
||||
}
|
||||
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = codexConnection.authMode === 'oauth' ? 'adapter' : 'api';
|
||||
|
||||
if (codexConnection.authMode !== 'api_key') {
|
||||
return env;
|
||||
}
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
env.OPENAI_API_KEY = storedKey.value;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async augmentAllConfiguredConnectionEnv(env: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv> {
|
||||
let nextEnv = env;
|
||||
for (const providerId of ['anthropic', 'codex', 'gemini'] as const) {
|
||||
nextEnv = await this.augmentConfiguredConnectionEnv(nextEnv, providerId);
|
||||
}
|
||||
return nextEnv;
|
||||
}
|
||||
|
||||
async getConfiguredConnectionIssue(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: CliProviderId
|
||||
): Promise<string | null> {
|
||||
if (providerId === 'anthropic') {
|
||||
if (this.getConfiguredAuthMode(providerId) !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured. ' +
|
||||
'Add a stored/environment API key or switch Anthropic auth mode back to Auto or OAuth.'
|
||||
);
|
||||
}
|
||||
|
||||
if (providerId !== 'codex') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled || codexConnection.authMode !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()) {
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
async getConfiguredConnectionIssues(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerIds: readonly CliProviderId[] = ['anthropic', 'codex', 'gemini']
|
||||
): Promise<Partial<Record<CliProviderId, string>>> {
|
||||
const issues: Partial<Record<CliProviderId, string>> = {};
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
const issue = await this.getConfiguredConnectionIssue(env, providerId);
|
||||
if (issue) {
|
||||
issues[providerId] = issue;
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async enrichProviderStatus(provider: CliProviderStatus): Promise<CliProviderStatus> {
|
||||
return {
|
||||
...provider,
|
||||
|
|
|
|||
105
src/main/services/runtime/providerAwareCliEnv.ts
Normal file
105
src/main/services/runtime/providerAwareCliEnv.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
|
||||
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import { providerConnectionService } from './ProviderConnectionService';
|
||||
import {
|
||||
applyConfiguredRuntimeBackendsEnv,
|
||||
applyProviderRuntimeEnv,
|
||||
resolveTeamProviderId,
|
||||
} from './providerRuntimeEnv';
|
||||
|
||||
import type { CliProviderId, TeamProviderId } from '@shared/types';
|
||||
|
||||
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
|
||||
|
||||
export interface ProviderAwareCliEnvOptions {
|
||||
binaryPath?: string | null;
|
||||
providerId?: ProviderEnvTargetId;
|
||||
shellEnv?: NodeJS.ProcessEnv | null;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
connectionMode?: 'strict' | 'augment';
|
||||
}
|
||||
|
||||
export interface ProviderAwareCliEnvResult {
|
||||
env: NodeJS.ProcessEnv;
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>;
|
||||
}
|
||||
|
||||
function getFirstNonEmptyEnvValue(...values: (string | null | undefined)[]): string | undefined {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function buildProviderAwareCliEnv(
|
||||
options: ProviderAwareCliEnvOptions = {}
|
||||
): Promise<ProviderAwareCliEnvResult> {
|
||||
const connectionMode = options.connectionMode ?? 'strict';
|
||||
const shellEnv = options.shellEnv ?? getCachedShellEnv() ?? {};
|
||||
const env = {
|
||||
...buildEnrichedEnv(options.binaryPath),
|
||||
...shellEnv,
|
||||
};
|
||||
|
||||
applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||
|
||||
Object.assign(env, options.env ?? {});
|
||||
|
||||
const explicitHome = getFirstNonEmptyEnvValue(options.env?.HOME, options.env?.USERPROFILE);
|
||||
const fallbackHome = getFirstNonEmptyEnvValue(
|
||||
env.HOME,
|
||||
env.USERPROFILE,
|
||||
getShellPreferredHome(),
|
||||
shellEnv.HOME,
|
||||
process.env.HOME,
|
||||
process.env.USERPROFILE
|
||||
);
|
||||
|
||||
if (explicitHome) {
|
||||
env.HOME = getFirstNonEmptyEnvValue(options.env?.HOME, explicitHome);
|
||||
env.USERPROFILE = getFirstNonEmptyEnvValue(options.env?.USERPROFILE, explicitHome);
|
||||
} else if (fallbackHome) {
|
||||
env.HOME = getFirstNonEmptyEnvValue(env.HOME, fallbackHome);
|
||||
env.USERPROFILE = getFirstNonEmptyEnvValue(env.USERPROFILE, fallbackHome);
|
||||
}
|
||||
|
||||
if (options.providerId) {
|
||||
const resolvedProviderId = resolveTeamProviderId(options.providerId);
|
||||
applyProviderRuntimeEnv(env, options.providerId);
|
||||
if (connectionMode === 'augment') {
|
||||
await providerConnectionService.augmentConfiguredConnectionEnv(env, resolvedProviderId);
|
||||
return {
|
||||
env,
|
||||
connectionIssues: {},
|
||||
};
|
||||
}
|
||||
|
||||
await providerConnectionService.applyConfiguredConnectionEnv(env, resolvedProviderId);
|
||||
|
||||
return {
|
||||
env,
|
||||
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env, [
|
||||
resolvedProviderId,
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
if (connectionMode === 'augment') {
|
||||
await providerConnectionService.augmentAllConfiguredConnectionEnv(env);
|
||||
return {
|
||||
env,
|
||||
connectionIssues: {},
|
||||
};
|
||||
}
|
||||
|
||||
await providerConnectionService.applyAllConfiguredConnectionEnv(env);
|
||||
return {
|
||||
env,
|
||||
connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env),
|
||||
};
|
||||
}
|
||||
|
|
@ -9,15 +9,10 @@
|
|||
*/
|
||||
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { providerConnectionService } from '../runtime/ProviderConnectionService';
|
||||
import {
|
||||
applyConfiguredRuntimeBackendsEnv,
|
||||
applyProviderRuntimeEnv,
|
||||
} from '../runtime/providerRuntimeEnv';
|
||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
|
||||
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
||||
|
|
@ -106,19 +101,23 @@ export class ScheduledTaskExecutor {
|
|||
|
||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||
|
||||
const env = await providerConnectionService.applyConfiguredConnectionEnv(
|
||||
applyProviderRuntimeEnv(
|
||||
applyConfiguredRuntimeBackendsEnv({
|
||||
...buildEnrichedEnv(binaryPath),
|
||||
...shellEnv,
|
||||
CLAUDECODE: undefined,
|
||||
}),
|
||||
request.config.providerId
|
||||
),
|
||||
const providerId =
|
||||
request.config.providerId === 'codex' || request.config.providerId === 'gemini'
|
||||
? request.config.providerId
|
||||
: 'anthropic'
|
||||
);
|
||||
: 'anthropic';
|
||||
const { env, connectionIssues } = await buildProviderAwareCliEnv({
|
||||
binaryPath,
|
||||
providerId,
|
||||
shellEnv,
|
||||
env: {
|
||||
...shellEnv,
|
||||
CLAUDECODE: undefined,
|
||||
},
|
||||
});
|
||||
const connectionIssue = connectionIssues[providerId];
|
||||
if (connectionIssue) {
|
||||
throw new Error(connectionIssue);
|
||||
}
|
||||
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
cwd: request.config.cwd,
|
||||
|
|
|
|||
|
|
@ -65,12 +65,8 @@ import {
|
|||
type GeminiRuntimeAuthState,
|
||||
resolveGeminiRuntimeAuth,
|
||||
} from '../runtime/geminiRuntimeAuth';
|
||||
import { providerConnectionService } from '../runtime/ProviderConnectionService';
|
||||
import {
|
||||
applyConfiguredRuntimeBackendsEnv,
|
||||
applyProviderRuntimeEnv,
|
||||
resolveTeamProviderId,
|
||||
} from '../runtime/providerRuntimeEnv';
|
||||
import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv';
|
||||
import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv';
|
||||
|
||||
import { buildActionModeProtocol } from './actionModeInstructions';
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
|
@ -704,6 +700,7 @@ type LeadActivityState = 'active' | 'idle' | 'offline';
|
|||
type ProvisioningAuthSource =
|
||||
| 'anthropic_api_key'
|
||||
| 'anthropic_auth_token'
|
||||
| 'configured_api_key_missing'
|
||||
| 'codex_runtime'
|
||||
| 'gemini_runtime'
|
||||
| 'none';
|
||||
|
|
@ -712,6 +709,7 @@ interface ProvisioningEnvResolution {
|
|||
env: NodeJS.ProcessEnv;
|
||||
authSource: ProvisioningAuthSource;
|
||||
geminiRuntimeAuth: GeminiRuntimeAuthState | null;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
interface PromptSizeSummary {
|
||||
|
|
@ -3562,7 +3560,9 @@ export class TeamProvisioningService {
|
|||
const prefixedWarning =
|
||||
providerIds.length > 1 ? `${providerLabel}: ${probeResult.warning}` : probeResult.warning;
|
||||
const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||
if (
|
||||
if (authSource === 'configured_api_key_missing') {
|
||||
blockingMessages.push(prefixedWarning);
|
||||
} else if (
|
||||
(authSource === 'none' ||
|
||||
authSource === 'codex_runtime' ||
|
||||
authSource === 'gemini_runtime') &&
|
||||
|
|
@ -3664,7 +3664,15 @@ export class TeamProvisioningService {
|
|||
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||
if (!claudePath) return null;
|
||||
|
||||
const { env, authSource } = await this.buildProvisioningEnv(providerId);
|
||||
const { env, authSource, warning } = await this.buildProvisioningEnv(providerId);
|
||||
if (warning) {
|
||||
return {
|
||||
claudePath,
|
||||
authSource,
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
const probe = await this.probeClaudeRuntime(claudePath, cwd, env, providerId);
|
||||
const result = {
|
||||
claudePath,
|
||||
|
|
@ -4556,9 +4564,14 @@ export class TeamProvisioningService {
|
|||
const initialUserPrompt = request.prompt?.trim() ?? '';
|
||||
const promptSize = getPromptSizeSummary(initialUserPrompt);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv(
|
||||
request.providerId
|
||||
);
|
||||
const {
|
||||
env: shellEnv,
|
||||
geminiRuntimeAuth,
|
||||
warning: envWarning,
|
||||
} = await this.buildProvisioningEnv(request.providerId);
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||
if (teammateModeDecision.forceProcessTeammates) {
|
||||
|
|
@ -5087,9 +5100,14 @@ export class TeamProvisioningService {
|
|||
);
|
||||
const promptSize = getPromptSizeSummary(prompt);
|
||||
let child: ReturnType<typeof spawn>;
|
||||
const { env: shellEnv, geminiRuntimeAuth } = await this.buildProvisioningEnv(
|
||||
request.providerId
|
||||
);
|
||||
const {
|
||||
env: shellEnv,
|
||||
geminiRuntimeAuth,
|
||||
warning: envWarning,
|
||||
} = await this.buildProvisioningEnv(request.providerId);
|
||||
if (envWarning) {
|
||||
throw new Error(envWarning);
|
||||
}
|
||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||
if (teammateModeDecision.forceProcessTeammates) {
|
||||
|
|
@ -10305,21 +10323,23 @@ export class TeamProvisioningService {
|
|||
: {}),
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
};
|
||||
applyConfiguredRuntimeBackendsEnv(env);
|
||||
applyProviderRuntimeEnv(env, providerId);
|
||||
await providerConnectionService.applyConfiguredConnectionEnv(
|
||||
const resolvedProviderId = resolveTeamProviderId(providerId);
|
||||
const providerEnvResult = await buildProviderAwareCliEnv({
|
||||
providerId,
|
||||
shellEnv,
|
||||
env,
|
||||
resolveTeamProviderId(providerId)
|
||||
);
|
||||
});
|
||||
const providerConnectionIssue = providerEnvResult.connectionIssues[resolvedProviderId];
|
||||
const providerEnv = providerEnvResult.env;
|
||||
|
||||
const controlApiBaseUrl = await this.resolveControlApiBaseUrl();
|
||||
if (controlApiBaseUrl) {
|
||||
env.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl;
|
||||
providerEnv.CLAUDE_TEAM_CONTROL_URL = controlApiBaseUrl;
|
||||
}
|
||||
|
||||
// SHELL is a Unix concept — only set it on non-Windows platforms.
|
||||
if (!isWindows) {
|
||||
env.SHELL = shell;
|
||||
providerEnv.SHELL = shell;
|
||||
}
|
||||
|
||||
// XDG directories are a freedesktop.org (Linux/macOS) convention.
|
||||
|
|
@ -10333,35 +10353,47 @@ export class TeamProvisioningService {
|
|||
shellEnv.XDG_STATE_HOME?.trim() ||
|
||||
process.env.XDG_STATE_HOME?.trim() ||
|
||||
`${home}/.local/state`;
|
||||
env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
env.XDG_STATE_HOME = xdgStateHome;
|
||||
providerEnv.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
providerEnv.XDG_STATE_HOME = xdgStateHome;
|
||||
}
|
||||
|
||||
if (resolveTeamProviderId(providerId) === 'codex') {
|
||||
return { env, authSource: 'codex_runtime', geminiRuntimeAuth: null };
|
||||
}
|
||||
|
||||
if (resolveTeamProviderId(providerId) === 'gemini') {
|
||||
if (providerConnectionIssue) {
|
||||
return {
|
||||
env,
|
||||
env: providerEnv,
|
||||
authSource: 'configured_api_key_missing',
|
||||
geminiRuntimeAuth: null,
|
||||
warning: providerConnectionIssue,
|
||||
};
|
||||
}
|
||||
|
||||
if (resolvedProviderId === 'codex') {
|
||||
return { env: providerEnv, authSource: 'codex_runtime', geminiRuntimeAuth: null };
|
||||
}
|
||||
|
||||
if (resolvedProviderId === 'gemini') {
|
||||
return {
|
||||
env: providerEnv,
|
||||
authSource: 'gemini_runtime',
|
||||
geminiRuntimeAuth: await resolveGeminiRuntimeAuth(env),
|
||||
geminiRuntimeAuth: await resolveGeminiRuntimeAuth(providerEnv),
|
||||
};
|
||||
}
|
||||
|
||||
// 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
|
||||
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
|
||||
return { env, authSource: 'anthropic_api_key', geminiRuntimeAuth: null };
|
||||
if (
|
||||
typeof providerEnv.ANTHROPIC_API_KEY === 'string' &&
|
||||
providerEnv.ANTHROPIC_API_KEY.trim().length > 0
|
||||
) {
|
||||
return { env: providerEnv, authSource: 'anthropic_api_key', geminiRuntimeAuth: null };
|
||||
}
|
||||
|
||||
// 2. Proxy token (ANTHROPIC_AUTH_TOKEN) — `-p` mode does NOT read this var,
|
||||
// so we must copy it into ANTHROPIC_API_KEY for it to work.
|
||||
if (
|
||||
typeof env.ANTHROPIC_AUTH_TOKEN === 'string' &&
|
||||
env.ANTHROPIC_AUTH_TOKEN.trim().length > 0
|
||||
typeof providerEnv.ANTHROPIC_AUTH_TOKEN === 'string' &&
|
||||
providerEnv.ANTHROPIC_AUTH_TOKEN.trim().length > 0
|
||||
) {
|
||||
env.ANTHROPIC_API_KEY = env.ANTHROPIC_AUTH_TOKEN;
|
||||
return { env, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null };
|
||||
providerEnv.ANTHROPIC_API_KEY = providerEnv.ANTHROPIC_AUTH_TOKEN;
|
||||
return { env: providerEnv, authSource: 'anthropic_auth_token', geminiRuntimeAuth: null };
|
||||
}
|
||||
|
||||
// 3. No explicit API key — let the CLI handle its own OAuth auth.
|
||||
|
|
@ -10369,7 +10401,7 @@ export class TeamProvisioningService {
|
|||
// tokens in-memory. Injecting CLAUDE_CODE_OAUTH_TOKEN from the
|
||||
// credentials file causes 401 errors because the stored token is
|
||||
// often stale (CLI refreshes in-memory but rarely writes back).
|
||||
return { env, authSource: 'none', geminiRuntimeAuth: null };
|
||||
return { env: providerEnv, authSource: 'none', geminiRuntimeAuth: null };
|
||||
}
|
||||
|
||||
private async resolveControlApiBaseUrl(): Promise<string | null> {
|
||||
|
|
|
|||
|
|
@ -331,6 +331,14 @@ function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boo
|
|||
);
|
||||
}
|
||||
|
||||
function getApiKeyActionRequiredProviders(
|
||||
providers: readonly CliProviderStatus[]
|
||||
): CliProviderStatus[] {
|
||||
return providers.filter(
|
||||
(provider) => !provider.authenticated && provider.connection?.configuredAuthMode === 'api_key'
|
||||
);
|
||||
}
|
||||
|
||||
function formatRuntimeLabel(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
|
||||
): string | null {
|
||||
|
|
@ -1232,6 +1240,29 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
!cliStatus.authStatusChecking &&
|
||||
!cliStatus.authLoggedIn
|
||||
) {
|
||||
const apiKeyActionRequiredProviders = getApiKeyActionRequiredProviders(cliStatus.providers);
|
||||
const hasApiKeyModeIssue = apiKeyActionRequiredProviders.length > 0;
|
||||
const primaryApiKeyProvider = apiKeyActionRequiredProviders[0] ?? null;
|
||||
const apiKeyMissingProviders = apiKeyActionRequiredProviders.filter(
|
||||
(provider) => provider.connection?.apiKeyConfigured !== true
|
||||
);
|
||||
const allApiKeyIssuesAreMissingKeys =
|
||||
hasApiKeyModeIssue && apiKeyMissingProviders.length === apiKeyActionRequiredProviders.length;
|
||||
const warningTitle = hasApiKeyModeIssue
|
||||
? allApiKeyIssuesAreMissingKeys
|
||||
? 'API key required'
|
||||
: 'Provider action required'
|
||||
: 'Not logged in';
|
||||
const warningMessage = hasApiKeyModeIssue
|
||||
? allApiKeyIssuesAreMissingKeys
|
||||
? apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
|
||||
? `${primaryApiKeyProvider.displayName} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.`
|
||||
: 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.'
|
||||
: apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
|
||||
? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.`
|
||||
: 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.'
|
||||
: `${cliStatus.displayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstalledBanner
|
||||
|
|
@ -1263,43 +1294,57 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<AlertTriangle className="mt-0.5 size-5 shrink-0" style={{ color: '#f59e0b' }} />
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: '#fbbf24' }}>
|
||||
Not logged in
|
||||
{warningTitle}
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{cliStatus.displayName} is installed but you are not authenticated. Login is
|
||||
required for team provisioning and AI features.
|
||||
{warningMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowTroubleshoot((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-2 text-xs transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
Already logged in?
|
||||
{showTroubleshoot ? (
|
||||
<ChevronUp className="size-3" />
|
||||
) : (
|
||||
<ChevronDown className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLoginTerminal(true)}
|
||||
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ backgroundColor: '#f59e0b' }}
|
||||
>
|
||||
<LogIn className="size-4" />
|
||||
Login
|
||||
</button>
|
||||
{hasApiKeyModeIssue ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
handleProviderManage(primaryApiKeyProvider?.providerId ?? 'anthropic')
|
||||
}
|
||||
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ backgroundColor: '#f59e0b' }}
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Manage Providers
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowTroubleshoot((v) => !v)}
|
||||
className="flex items-center gap-1.5 rounded-md border px-3 py-2 text-xs transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
Already logged in?
|
||||
{showTroubleshoot ? (
|
||||
<ChevronUp className="size-3" />
|
||||
) : (
|
||||
<ChevronDown className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLoginTerminal(true)}
|
||||
className="flex items-center gap-1.5 rounded-md px-4 py-2 text-sm font-medium text-white transition-colors"
|
||||
style={{ backgroundColor: '#f59e0b' }}
|
||||
>
|
||||
<LogIn className="size-4" />
|
||||
Login
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTroubleshoot && (
|
||||
{!hasApiKeyModeIssue && showTroubleshoot && (
|
||||
<div
|
||||
className="mt-3 rounded-md border p-3"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import * as path from 'path';
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const execCliMock = vi.fn();
|
||||
const buildEnrichedEnvMock = vi.fn<(binaryPath: string) => NodeJS.ProcessEnv>();
|
||||
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
|
||||
const getShellPreferredHomeMock = vi.fn<() => string>();
|
||||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
const resolveInteractiveShellEnvMock = vi.fn<() => Promise<NodeJS.ProcessEnv>>();
|
||||
const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise<string>>();
|
||||
const enrichProviderStatusMock = vi.fn((provider) => Promise.resolve(provider));
|
||||
|
|
@ -16,13 +14,7 @@ vi.mock('@main/utils/childProcess', () => ({
|
|||
execCli: (...args: Parameters<typeof execCliMock>) => execCliMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/cliEnv', () => ({
|
||||
buildEnrichedEnv: (binaryPath: string) => buildEnrichedEnvMock(binaryPath),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||
getShellPreferredHome: () => getShellPreferredHomeMock(),
|
||||
resolveInteractiveShellEnv: () => resolveInteractiveShellEnvMock(),
|
||||
}));
|
||||
|
||||
|
|
@ -49,18 +41,29 @@ vi.mock('@main/services/runtime/ProviderConnectionService', () => ({
|
|||
enrichProviderStatusMock(...args),
|
||||
enrichProviderStatuses: (...args: Parameters<typeof enrichProviderStatusesMock>) =>
|
||||
enrichProviderStatusesMock(...args),
|
||||
applyAllConfiguredConnectionEnv: vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env)),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
||||
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
|
||||
buildProviderAwareCliEnvMock(...args),
|
||||
}));
|
||||
|
||||
describe('ClaudeMultimodelBridgeService', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
buildEnrichedEnvMock.mockReturnValue({});
|
||||
getCachedShellEnvMock.mockReturnValue({});
|
||||
getShellPreferredHomeMock.mockReturnValue('/Users/tester');
|
||||
resolveInteractiveShellEnvMock.mockResolvedValue({});
|
||||
buildProviderAwareCliEnvMock.mockImplementation(
|
||||
({ providerId }: { providerId?: string } = {}) =>
|
||||
Promise.resolve({
|
||||
env: {
|
||||
HOME: '/Users/tester',
|
||||
...(providerId ? { CLAUDE_CODE_ENTRY_PROVIDER: providerId } : {}),
|
||||
},
|
||||
connectionIssues: {},
|
||||
})
|
||||
);
|
||||
readFileMock.mockImplementation((filePath) => {
|
||||
if (String(filePath) === path.join('/Users/tester', '.claude.json')) {
|
||||
return Promise.resolve(
|
||||
|
|
@ -180,4 +183,44 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('overrides provider auth status when provider-aware env reports a missing API key', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { HOME: '/Users/tester' },
|
||||
connectionIssues: {
|
||||
anthropic:
|
||||
'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.',
|
||||
},
|
||||
});
|
||||
execCliMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
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', 'anthropic');
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
});
|
||||
expect(provider.statusMessage).toContain('ANTHROPIC_API_KEY');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -119,6 +119,75 @@ describe('ProviderConnectionService', () => {
|
|||
expect(result.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a missing Anthropic API key when api_key mode is selected', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => createConfig('api_key'),
|
||||
} as never
|
||||
);
|
||||
|
||||
const issue = await service.getConfiguredConnectionIssue({}, 'anthropic');
|
||||
|
||||
expect(issue).toContain('Anthropic API key mode is enabled');
|
||||
expect(issue).toContain('ANTHROPIC_API_KEY');
|
||||
});
|
||||
|
||||
it('does not report a missing Anthropic API key once env is populated', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => createConfig('api_key'),
|
||||
} as never
|
||||
);
|
||||
|
||||
const issue = await service.getConfiguredConnectionIssue(
|
||||
{
|
||||
ANTHROPIC_API_KEY: 'env-key',
|
||||
},
|
||||
'anthropic'
|
||||
);
|
||||
|
||||
expect(issue).toBeNull();
|
||||
});
|
||||
|
||||
it('augments PTY env with stored Anthropic API key without stripping auth token', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue({
|
||||
envVarName: 'ANTHROPIC_API_KEY',
|
||||
value: 'stored-key',
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => createConfig('api_key'),
|
||||
} as never
|
||||
);
|
||||
|
||||
const result = await service.augmentConfiguredConnectionEnv(
|
||||
{
|
||||
ANTHROPIC_AUTH_TOKEN: 'oauth-token',
|
||||
},
|
||||
'anthropic'
|
||||
);
|
||||
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('stored-key');
|
||||
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('oauth-token');
|
||||
});
|
||||
|
||||
it('prefers stored API key status over environment detection', async () => {
|
||||
getCachedShellEnvMock.mockReturnValue({
|
||||
ANTHROPIC_API_KEY: 'shell-key',
|
||||
|
|
@ -277,4 +346,68 @@ describe('ProviderConnectionService', () => {
|
|||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
|
||||
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
|
||||
});
|
||||
|
||||
it('reports a missing Codex API key when beta api_key mode is enabled', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: true,
|
||||
authMode: 'api_key',
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const issue = await service.getConfiguredConnectionIssue({}, 'codex');
|
||||
|
||||
expect(issue).toContain('Codex API key mode is enabled');
|
||||
expect(issue).toContain('OPENAI_API_KEY');
|
||||
});
|
||||
|
||||
it('augments PTY env for Codex without deleting an existing OPENAI_API_KEY in oauth mode', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: true,
|
||||
authMode: 'oauth',
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const result = await service.augmentConfiguredConnectionEnv(
|
||||
{
|
||||
OPENAI_API_KEY: 'shell-key',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(result.OPENAI_API_KEY).toBe('shell-key');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
|
||||
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
192
test/main/services/runtime/providerAwareCliEnv.test.ts
Normal file
192
test/main/services/runtime/providerAwareCliEnv.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const buildEnrichedEnvMock = vi.fn();
|
||||
const getCachedShellEnvMock = vi.fn();
|
||||
const getShellPreferredHomeMock = vi.fn();
|
||||
const augmentAllConfiguredConnectionEnvMock = vi.fn();
|
||||
const augmentConfiguredConnectionEnvMock = vi.fn();
|
||||
const applyConfiguredConnectionEnvMock = vi.fn();
|
||||
const applyAllConfiguredConnectionEnvMock = vi.fn();
|
||||
const getConfiguredConnectionIssuesMock = vi.fn();
|
||||
|
||||
vi.mock('@main/utils/cliEnv', () => ({
|
||||
buildEnrichedEnv: (...args: Parameters<typeof buildEnrichedEnvMock>) => buildEnrichedEnvMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||
getShellPreferredHome: () => getShellPreferredHomeMock(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({
|
||||
configManager: {
|
||||
getConfig: () => ({
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'cli',
|
||||
codex: 'adapter',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/main/services/runtime/ProviderConnectionService', () => ({
|
||||
providerConnectionService: {
|
||||
augmentConfiguredConnectionEnv: (...args: Parameters<typeof augmentConfiguredConnectionEnvMock>) =>
|
||||
augmentConfiguredConnectionEnvMock(...args),
|
||||
augmentAllConfiguredConnectionEnv: (...args: Parameters<typeof augmentAllConfiguredConnectionEnvMock>) =>
|
||||
augmentAllConfiguredConnectionEnvMock(...args),
|
||||
applyConfiguredConnectionEnv: (...args: Parameters<typeof applyConfiguredConnectionEnvMock>) =>
|
||||
applyConfiguredConnectionEnvMock(...args),
|
||||
applyAllConfiguredConnectionEnv: (...args: Parameters<typeof applyAllConfiguredConnectionEnvMock>) =>
|
||||
applyAllConfiguredConnectionEnvMock(...args),
|
||||
getConfiguredConnectionIssues: (...args: Parameters<typeof getConfiguredConnectionIssuesMock>) =>
|
||||
getConfiguredConnectionIssuesMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('buildProviderAwareCliEnv', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
buildEnrichedEnvMock.mockReturnValue({
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
getCachedShellEnvMock.mockReturnValue({
|
||||
SHELL: '/bin/zsh',
|
||||
});
|
||||
getShellPreferredHomeMock.mockReturnValue('/Users/tester');
|
||||
augmentConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) =>
|
||||
Promise.resolve(env)
|
||||
);
|
||||
augmentAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) =>
|
||||
Promise.resolve(env)
|
||||
);
|
||||
applyConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) =>
|
||||
Promise.resolve(env)
|
||||
);
|
||||
applyAllConfiguredConnectionEnvMock.mockImplementation((env: NodeJS.ProcessEnv) =>
|
||||
Promise.resolve(env)
|
||||
);
|
||||
getConfiguredConnectionIssuesMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('builds provider-pinned CLI env and returns provider-specific issues', async () => {
|
||||
getConfiguredConnectionIssuesMock.mockResolvedValue({
|
||||
anthropic: 'missing key',
|
||||
});
|
||||
|
||||
const { buildProviderAwareCliEnv } = await import(
|
||||
'../../../../src/main/services/runtime/providerAwareCliEnv'
|
||||
);
|
||||
const result = await buildProviderAwareCliEnv({
|
||||
binaryPath: '/mock/claude',
|
||||
providerId: 'anthropic',
|
||||
shellEnv: {
|
||||
EXTRA_FLAG: '1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildEnrichedEnvMock).toHaveBeenCalledWith('/mock/claude');
|
||||
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HOME: '/Users/tester',
|
||||
USERPROFILE: '/Users/tester',
|
||||
EXTRA_FLAG: '1',
|
||||
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
|
||||
CLAUDE_CODE_ENTRY_PROVIDER: 'anthropic',
|
||||
}),
|
||||
'anthropic'
|
||||
);
|
||||
expect(result.connectionIssues).toEqual({
|
||||
anthropic: 'missing key',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds shared env for generic CLI launches when no provider is specified', async () => {
|
||||
const { buildProviderAwareCliEnv } = await import(
|
||||
'../../../../src/main/services/runtime/providerAwareCliEnv'
|
||||
);
|
||||
const result = await buildProviderAwareCliEnv();
|
||||
|
||||
expect(applyAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HOME: '/Users/tester',
|
||||
USERPROFILE: '/Users/tester',
|
||||
SHELL: '/bin/zsh',
|
||||
})
|
||||
);
|
||||
expect(getConfiguredConnectionIssuesMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HOME: '/Users/tester',
|
||||
})
|
||||
);
|
||||
expect(result.connectionIssues).toEqual({});
|
||||
});
|
||||
|
||||
it('uses non-destructive credential augmentation for PTY-style envs', async () => {
|
||||
const { buildProviderAwareCliEnv } = await import(
|
||||
'../../../../src/main/services/runtime/providerAwareCliEnv'
|
||||
);
|
||||
const result = await buildProviderAwareCliEnv({
|
||||
connectionMode: 'augment',
|
||||
env: {
|
||||
OPENAI_API_KEY: 'shell-key',
|
||||
},
|
||||
});
|
||||
|
||||
expect(applyAllConfiguredConnectionEnvMock).not.toHaveBeenCalled();
|
||||
expect(augmentAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
OPENAI_API_KEY: 'shell-key',
|
||||
})
|
||||
);
|
||||
expect(result.connectionIssues).toEqual({});
|
||||
});
|
||||
|
||||
it('preserves caller-provided HOME and USERPROFILE overrides', async () => {
|
||||
const { buildProviderAwareCliEnv } = await import(
|
||||
'../../../../src/main/services/runtime/providerAwareCliEnv'
|
||||
);
|
||||
const result = await buildProviderAwareCliEnv({
|
||||
providerId: 'anthropic',
|
||||
env: {
|
||||
HOME: '/Users/electron-home',
|
||||
USERPROFILE: '/Users/electron-home',
|
||||
},
|
||||
});
|
||||
|
||||
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
HOME: '/Users/electron-home',
|
||||
USERPROFILE: '/Users/electron-home',
|
||||
}),
|
||||
'anthropic'
|
||||
);
|
||||
expect(result.env.HOME).toBe('/Users/electron-home');
|
||||
expect(result.env.USERPROFILE).toBe('/Users/electron-home');
|
||||
});
|
||||
|
||||
it('preserves explicit backend overrides passed by the caller', async () => {
|
||||
const { buildProviderAwareCliEnv } = await import(
|
||||
'../../../../src/main/services/runtime/providerAwareCliEnv'
|
||||
);
|
||||
const result = await buildProviderAwareCliEnv({
|
||||
connectionMode: 'augment',
|
||||
env: {
|
||||
CLAUDE_CODE_GEMINI_BACKEND: 'api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(augmentAllConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
CLAUDE_CODE_GEMINI_BACKEND: 'api',
|
||||
CLAUDE_CODE_CODEX_BACKEND: 'adapter',
|
||||
})
|
||||
);
|
||||
expect(result.env.CLAUDE_CODE_GEMINI_BACKEND).toBe('api');
|
||||
expect(result.env.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ const mockSpawnCli = vi.fn();
|
|||
const mockKillProcessTree = vi.fn();
|
||||
const mockResolve = vi.fn();
|
||||
const mockResolveShellEnv = vi.fn();
|
||||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
|
||||
vi.mock('@main/utils/childProcess', () => ({
|
||||
spawnCli: (...args: unknown[]) => mockSpawnCli(...args),
|
||||
|
|
@ -26,8 +27,9 @@ vi.mock('@main/utils/shellEnv', () => ({
|
|||
resolveInteractiveShellEnv: () => mockResolveShellEnv(),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/cliEnv', () => ({
|
||||
buildEnrichedEnv: () => ({ ...process.env }),
|
||||
vi.mock('../../../../src/main/services/runtime/providerAwareCliEnv', () => ({
|
||||
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
|
||||
buildProviderAwareCliEnvMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/main/services/team/ClaudeBinaryResolver', () => ({
|
||||
|
|
@ -84,6 +86,10 @@ describe('ScheduledTaskExecutor', () => {
|
|||
vi.clearAllMocks();
|
||||
mockResolve.mockResolvedValue('/usr/local/bin/claude');
|
||||
mockResolveShellEnv.mockResolvedValue({ SHELL: '/bin/zsh' });
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { ...process.env, SHELL: '/bin/zsh' },
|
||||
connectionIssues: {},
|
||||
});
|
||||
|
||||
const mod = await import('../../../../src/main/services/schedule/ScheduledTaskExecutor');
|
||||
ScheduledTaskExecutor = mod.ScheduledTaskExecutor;
|
||||
|
|
@ -445,7 +451,7 @@ describe('ScheduledTaskExecutor', () => {
|
|||
|
||||
const opts = mockSpawnCli.mock.calls[0][2];
|
||||
expect(opts.cwd).toBe('/home/user/project');
|
||||
expect(opts.env.MY_VAR).toBe('test');
|
||||
expect(opts.env.SHELL).toBe('/bin/zsh');
|
||||
expect(opts.stdio).toEqual(['ignore', 'pipe', 'pipe']);
|
||||
|
||||
proc.emit('close', 0);
|
||||
|
|
@ -477,4 +483,19 @@ describe('ScheduledTaskExecutor', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('fails fast when provider-aware env reports a missing API key', async () => {
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: { SHELL: '/bin/zsh' },
|
||||
connectionIssues: {
|
||||
anthropic:
|
||||
'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.',
|
||||
},
|
||||
});
|
||||
|
||||
const executor = new ScheduledTaskExecutor();
|
||||
|
||||
await expect(executor.execute(makeRequest())).rejects.toThrow('ANTHROPIC_API_KEY');
|
||||
expect(mockSpawnCli).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ vi.mock('@main/utils/shellEnv', () => ({
|
|||
resolveInteractiveShellEnv: vi.fn(),
|
||||
}));
|
||||
|
||||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
|
||||
buildProviderAwareCliEnv: (...args: Parameters<typeof buildProviderAwareCliEnvMock>) =>
|
||||
buildProviderAwareCliEnvMock(...args),
|
||||
}));
|
||||
|
||||
const addTeamNotificationMock = vi.fn().mockResolvedValue(null);
|
||||
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||
NotificationManager: {
|
||||
|
|
@ -37,6 +43,12 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
});
|
||||
buildProviderAwareCliEnvMock.mockImplementation(({ env }: { env: NodeJS.ProcessEnv }) =>
|
||||
Promise.resolve({
|
||||
env,
|
||||
connectionIssues: {},
|
||||
})
|
||||
);
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
});
|
||||
|
|
@ -82,18 +94,18 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
it('checks each unique provider during multi-provider prepare and blocks on provider auth failure', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const getCachedOrProbeResult = vi.spyOn(svc as any, 'getCachedOrProbeResult');
|
||||
getCachedOrProbeResult.mockImplementation(async (_cwd: unknown, providerId: unknown) => {
|
||||
getCachedOrProbeResult.mockImplementation((_cwd: unknown, providerId: unknown) => {
|
||||
if (providerId === 'codex') {
|
||||
return {
|
||||
return Promise.resolve({
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'none',
|
||||
warning: 'Not logged in to Codex runtime',
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
return Promise.resolve({
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'oauth_token',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const result = await svc.prepareForProvisioning(tempRoot, {
|
||||
|
|
@ -140,6 +152,51 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(result.env.ANTHROPIC_API_KEY).toBe('real-key');
|
||||
});
|
||||
|
||||
it('allows help-env resolution to continue even when provisioning env warns', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
authSource: 'configured_api_key_missing',
|
||||
geminiRuntimeAuth: null,
|
||||
warning: 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.',
|
||||
});
|
||||
vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({
|
||||
claudePath: '/fake/claude',
|
||||
authSource: 'none',
|
||||
});
|
||||
vi.spyOn(svc as any, 'spawnProbe').mockResolvedValue({
|
||||
stdout: 'usage: claude [options]',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const output = await svc.getCliHelpOutput(tempRoot);
|
||||
|
||||
expect(output).toContain('usage: claude');
|
||||
});
|
||||
|
||||
it('surfaces a missing configured Anthropic API key before probing', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
buildProviderAwareCliEnvMock.mockResolvedValue({
|
||||
env: {
|
||||
PATH: '/usr/bin',
|
||||
SHELL: '/bin/zsh',
|
||||
},
|
||||
connectionIssues: {
|
||||
anthropic:
|
||||
'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await (svc as any).buildProvisioningEnv();
|
||||
|
||||
expect(result.authSource).toBe('configured_api_key_missing');
|
||||
expect(result.warning).toContain('ANTHROPIC_API_KEY');
|
||||
});
|
||||
|
||||
it('does not treat assistant-text 401 noise as an auth failure', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
|
|
@ -213,7 +270,7 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
const svc = new TeamProvisioningService();
|
||||
const handleAuthFailureInOutput = vi
|
||||
.spyOn(svc as any, 'handleAuthFailureInOutput')
|
||||
.mockImplementation(() => {});
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const run = {
|
||||
runId: 'run-2',
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ interface StoreState {
|
|||
const storeState = {} as StoreState;
|
||||
let providerRuntimeSettingsDialogProps: {
|
||||
onSelectBackend?: (providerId: string, backendId: string) => Promise<void> | void;
|
||||
open?: boolean;
|
||||
initialProviderId?: string;
|
||||
} | null = null;
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
|
|
@ -58,9 +60,19 @@ vi.mock('@renderer/components/common/ConfirmDialog', () => ({
|
|||
vi.mock('@renderer/components/runtime/ProviderRuntimeSettingsDialog', () => ({
|
||||
ProviderRuntimeSettingsDialog: (props: {
|
||||
onSelectBackend?: (providerId: string, backendId: string) => Promise<void> | void;
|
||||
open?: boolean;
|
||||
initialProviderId?: string;
|
||||
}) => {
|
||||
providerRuntimeSettingsDialogProps = props;
|
||||
return null;
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'provider-runtime-settings-dialog',
|
||||
'data-open': String(Boolean(props.open)),
|
||||
'data-provider': props.initialProviderId ?? '',
|
||||
},
|
||||
null
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -135,6 +147,60 @@ function createInstalledCliStatus(
|
|||
};
|
||||
}
|
||||
|
||||
function createApiKeyMisconfiguredProvider(
|
||||
providerId: 'anthropic' | 'codex'
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
providerId,
|
||||
displayName: providerId === 'anthropic' ? 'Anthropic' : 'Codex',
|
||||
supported: true,
|
||||
authenticated: false,
|
||||
authMethod: null,
|
||||
verificationState: 'error',
|
||||
statusMessage:
|
||||
providerId === 'anthropic'
|
||||
? 'Anthropic API key mode is enabled, but no ANTHROPIC_API_KEY is configured.'
|
||||
: 'Codex API key mode is enabled, but no OPENAI_API_KEY is configured.',
|
||||
models: [],
|
||||
canLoginFromUi: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
},
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'],
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyBetaAvailable: providerId === 'codex' ? true : undefined,
|
||||
apiKeyBetaEnabled: providerId === 'codex' ? true : undefined,
|
||||
apiKeyConfigured: false,
|
||||
apiKeySource: null,
|
||||
apiKeySourceLabel: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createApiKeyModeProviderIssue(
|
||||
providerId: 'anthropic' | 'codex'
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...createApiKeyMisconfiguredProvider(providerId),
|
||||
statusMessage:
|
||||
providerId === 'anthropic'
|
||||
? 'Anthropic API key was rejected by the runtime.'
|
||||
: 'OpenAI API key was rejected by the runtime.',
|
||||
connection: {
|
||||
...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record<string, unknown> })
|
||||
.connection),
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'stored',
|
||||
apiKeySourceLabel:
|
||||
providerId === 'anthropic' ? 'Stored Anthropic API key' : 'Stored OpenAI API key',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('CLI status visibility during completed install state', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
|
|
@ -401,4 +467,74 @@ describe('CLI status visibility during completed install state', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('routes API-key misconfiguration to provider settings instead of login', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
authLoggedIn: false,
|
||||
providers: [createApiKeyMisconfiguredProvider('anthropic')],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('API key required');
|
||||
expect(host.textContent).toContain('Manage Providers');
|
||||
expect(host.textContent).not.toContain('Already logged in?');
|
||||
expect(host.textContent).not.toContain('Login');
|
||||
|
||||
const manageButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.includes('Manage Providers')
|
||||
);
|
||||
expect(manageButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
manageButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const dialog = host.querySelector('[data-testid="provider-runtime-settings-dialog"]');
|
||||
expect(dialog?.getAttribute('data-open')).toBe('true');
|
||||
expect(dialog?.getAttribute('data-provider')).toBe('anthropic');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps API-key mode issues on provider settings even when a saved key exists', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
authLoggedIn: false,
|
||||
providers: [createApiKeyModeProviderIssue('anthropic')],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Provider action required');
|
||||
expect(host.textContent).toContain('Manage Providers');
|
||||
expect(host.textContent).not.toContain('Already logged in?');
|
||||
expect(host.textContent).not.toContain('Login');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue