From 02d516cb4e03c0140bc889373ebcc4401b5625e2 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 13:18:49 +0300 Subject: [PATCH] fix: harden provider-aware cli env handling --- src/main/ipc/terminal.ts | 2 +- .../infrastructure/PtyTerminalService.ts | 18 +- .../runtime/ClaudeMultimodelBridgeService.ts | 87 ++++---- .../runtime/ProviderConnectionService.ts | 102 ++++++++++ .../services/runtime/providerAwareCliEnv.ts | 105 ++++++++++ .../schedule/ScheduledTaskExecutor.ts | 33 ++- .../services/team/TeamProvisioningService.ts | 106 ++++++---- .../components/dashboard/CliStatusBanner.tsx | 101 ++++++--- .../ClaudeMultimodelBridgeService.test.ts | 69 +++++-- .../runtime/ProviderConnectionService.test.ts | 133 ++++++++++++ .../runtime/providerAwareCliEnv.test.ts | 192 ++++++++++++++++++ .../schedule/ScheduledTaskExecutor.test.ts | 27 ++- .../TeamProvisioningServicePrepare.test.ts | 69 ++++++- .../cli/CliStatusVisibility.test.ts | 138 ++++++++++++- 14 files changed, 1034 insertions(+), 148 deletions(-) create mode 100644 src/main/services/runtime/providerAwareCliEnv.ts create mode 100644 test/main/services/runtime/providerAwareCliEnv.test.ts diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts index 784959c7..e1e6eab3 100644 --- a/src/main/ipc/terminal.ts +++ b/src/main/ipc/terminal.ts @@ -91,7 +91,7 @@ async function handleSpawn( options?: PtySpawnOptions ): Promise> { try { - const id = service.spawn(options); + const id = await service.spawn(options); return { success: true, data: id }; } catch (error) { const msg = getErrorMessage(error); diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index ba23ebb5..64b43bdc 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -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 { 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, + env: env as Record, }); pty.onData((data) => this.send(TERMINAL_DATA, id, data)); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index ef768fe1..243de82d 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -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((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 { - 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>> { + return buildProviderAwareCliEnv({ binaryPath }); } private async buildProviderCliEnv( binaryPath: string, providerId: CliProviderId - ): Promise { - return applyProviderRuntimeEnv({ ...(await this.buildCliEnv(binaryPath)) }, providerId); + ): Promise>> { + return buildProviderAwareCliEnv({ binaryPath, providerId }); } private isUnifiedRuntimeUnsupported(error: unknown): boolean { @@ -252,12 +236,38 @@ export class ClaudeMultimodelBridgeService { }; } + private applyConnectionIssue( + provider: CliProviderStatus, + connectionIssues: Partial> + ): 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> + ): CliProviderStatus[] { + return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); + } + async getProviderStatus( binaryPath: string, providerId: CliProviderId ): Promise { 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(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 { 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 { 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(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); diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 8eff71de..f49e732d 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -144,6 +144,108 @@ export class ProviderConnectionService { return nextEnv; } + async augmentConfiguredConnectionEnv( + env: NodeJS.ProcessEnv, + providerId: CliProviderId + ): Promise { + 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 { + 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 { + 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>> { + const issues: Partial> = {}; + + for (const providerId of providerIds) { + const issue = await this.getConfiguredConnectionIssue(env, providerId); + if (issue) { + issues[providerId] = issue; + } + } + + return issues; + } + async enrichProviderStatus(provider: CliProviderStatus): Promise { return { ...provider, diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts new file mode 100644 index 00000000..2793d710 --- /dev/null +++ b/src/main/services/runtime/providerAwareCliEnv.ts @@ -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>; +} + +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 { + 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), + }; +} diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index aa1b99e5..a98bde32 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index afddfdd9..1dc1a7ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; - 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; - 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 { diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index d707e24a..471c10f9 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -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['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 ( <> {

- Not logged in + {warningTitle}

- {cliStatus.displayName} is installed but you are not authenticated. Login is - required for team provisioning and AI features. + {warningMessage}

- - + {hasApiKeyModeIssue ? ( + + ) : ( + <> + + + + )}
- {showTroubleshoot && ( + {!hasApiKeyModeIssue && showTroubleshoot && (
NodeJS.ProcessEnv>(); -const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(); -const getShellPreferredHomeMock = vi.fn<() => string>(); +const buildProviderAwareCliEnvMock = vi.fn(); const resolveInteractiveShellEnvMock = vi.fn<() => Promise>(); const readFileMock = vi.fn<(path: PathLike, encoding: BufferEncoding) => Promise>(); const enrichProviderStatusMock = vi.fn((provider) => Promise.resolve(provider)); @@ -16,13 +14,7 @@ vi.mock('@main/utils/childProcess', () => ({ execCli: (...args: Parameters) => 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) => enrichProviderStatusesMock(...args), - applyAllConfiguredConnectionEnv: vi.fn((env: NodeJS.ProcessEnv) => Promise.resolve(env)), }, })); +vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({ + buildProviderAwareCliEnv: (...args: Parameters) => + 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'); + }); }); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 37ad01e3..1fdac064 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -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'); + }); }); diff --git a/test/main/services/runtime/providerAwareCliEnv.test.ts b/test/main/services/runtime/providerAwareCliEnv.test.ts new file mode 100644 index 00000000..ff76dce9 --- /dev/null +++ b/test/main/services/runtime/providerAwareCliEnv.test.ts @@ -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) => 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) => + augmentConfiguredConnectionEnvMock(...args), + augmentAllConfiguredConnectionEnv: (...args: Parameters) => + augmentAllConfiguredConnectionEnvMock(...args), + applyConfiguredConnectionEnv: (...args: Parameters) => + applyConfiguredConnectionEnvMock(...args), + applyAllConfiguredConnectionEnv: (...args: Parameters) => + applyAllConfiguredConnectionEnvMock(...args), + getConfiguredConnectionIssues: (...args: Parameters) => + 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'); + }); +}); diff --git a/test/main/services/schedule/ScheduledTaskExecutor.test.ts b/test/main/services/schedule/ScheduledTaskExecutor.test.ts index 2b4fe5f7..223d6261 100644 --- a/test/main/services/schedule/ScheduledTaskExecutor.test.ts +++ b/test/main/services/schedule/ScheduledTaskExecutor.test.ts @@ -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) => + 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(); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index e5d7874e..208a2385 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -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) => + 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', diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 696dafd8..98883aa2 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -42,6 +42,8 @@ interface StoreState { const storeState = {} as StoreState; let providerRuntimeSettingsDialogProps: { onSelectBackend?: (providerId: string, backendId: string) => Promise | 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; + 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 { + 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 { + 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 }) + .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(); + }); + }); });