fix: harden provider-aware cli env handling

This commit is contained in:
777genius 2026-04-12 13:18:49 +03:00
parent c30e8d414a
commit 02d516cb4e
14 changed files with 1034 additions and 148 deletions

View file

@ -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);

View file

@ -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));

View file

@ -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);

View file

@ -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,

View 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),
};
}

View file

@ -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,

View file

@ -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> {

View file

@ -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={{

View file

@ -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');
});
});

View file

@ -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');
});
});

View 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');
});
});

View file

@ -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();
});
});

View file

@ -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',

View file

@ -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();
});
});
});