diff --git a/src/main/index.ts b/src/main/index.ts index 3a2144db..103ac0b9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -216,6 +216,7 @@ import { TeamTaskStallSnapshotSource, TeamTranscriptSourceLocator, UpdaterService, + resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath, } from './services'; import type { FileChangeEvent } from '@main/types'; @@ -351,6 +352,18 @@ async function createOpenCodeRuntimeAdapterRegistry( const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env }); bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId; bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath(); + try { + const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath(); + if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) { + bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary; + } + } catch (error) { + logger.warn( + `[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } try { reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...'); const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({ diff --git a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts index cf5656bd..2d0d190e 100644 --- a/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts +++ b/src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts @@ -8,6 +8,7 @@ import { getCachedShellEnv } from '@main/utils/shellEnv'; const CACHE_VERIFY_TTL_MS = 30_000; const VERSION_CACHE_TTL_MS = 30_000; +const BINARY_LAUNCH_VERIFY_TIMEOUT_MS = 3_000; let cachedBinaryPath: string | null | undefined; let cacheVerifiedAt = 0; @@ -23,6 +24,18 @@ async function fileExists(filePath: string): Promise { } } +async function binaryCanLaunch(candidate: string): Promise { + try { + await execCli(candidate, ['--version'], { + timeout: BINARY_LAUNCH_VERIFY_TIMEOUT_MS, + windowsHide: true, + }); + return true; + } catch { + return false; + } +} + function expandWindowsExtensions(candidate: string): string[] { if (process.platform !== 'win32') { return [candidate]; @@ -80,7 +93,7 @@ async function verifyBinary(candidate: string): Promise { if (isPathLikeCandidate(candidate)) { for (const expandedCandidate of expandedCandidates) { - if (await fileExists(expandedCandidate)) { + if ((await fileExists(expandedCandidate)) && (await binaryCanLaunch(expandedCandidate))) { return expandedCandidate; } } @@ -91,7 +104,7 @@ async function verifyBinary(candidate: string): Promise { for (const pathEntry of pathEntries) { for (const expandedCandidate of expandedCandidates) { const resolvedCandidate = resolvePathEntryCandidate(pathEntry, expandedCandidate); - if (await fileExists(resolvedCandidate)) { + if ((await fileExists(resolvedCandidate)) && (await binaryCanLaunch(resolvedCandidate))) { return resolvedCandidate; } } diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index a81b21a6..e730088f 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -8,6 +8,14 @@ import type { PathLike } from 'node:fs'; const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise>(); const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise>(); +const execCliMock = + vi.fn< + ( + binaryPath: string | null, + args: string[], + options?: { timeout?: number; windowsHide?: boolean } + ) => Promise<{ stdout: string; stderr: string }> + >(); vi.mock('node:fs/promises', () => ({ access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode), @@ -18,6 +26,14 @@ vi.mock('@features/codex-runtime-installer/main', () => ({ resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(), })); +vi.mock('@main/utils/childProcess', () => ({ + execCli: ( + binaryPath: string | null, + args: string[], + options?: { timeout?: number; windowsHide?: boolean } + ) => execCliMock(binaryPath, args, options), +})); + const originalPlatform = process.platform; const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; @@ -39,6 +55,7 @@ describe('CodexBinaryResolver', () => { process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM'; delete process.env.CODEX_CLI_PATH; resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null); + execCliMock.mockResolvedValue({ stdout: 'codex-cli 0.130.0', stderr: '' }); }); afterEach(() => { @@ -126,4 +143,31 @@ describe('CodexBinaryResolver', () => { await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary); }); + + it('skips Windows PATH candidates that exist but cannot be launched', async () => { + const blockedDir = + 'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources'; + const usableDir = 'C:\\Users\\User\\AppData\\Roaming\\npm'; + const blockedExe = path.win32.join(blockedDir, 'codex.exe'); + const cmdShim = path.win32.join(usableDir, 'codex.cmd'); + process.env.PATH = `${blockedDir};${usableDir}`; + + accessMock.mockImplementation((filePath) => { + if (filePath === blockedExe || filePath === cmdShim) { + return Promise.resolve(); + } + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + }); + execCliMock.mockImplementation((binaryPath) => { + if (binaryPath === blockedExe) { + return Promise.reject(Object.assign(new Error('spawn EACCES'), { code: 'EACCES' })); + } + return Promise.resolve({ stdout: 'codex-cli 0.130.0', stderr: '' }); + }); + + const { CodexBinaryResolver } = await import('../CodexBinaryResolver'); + CodexBinaryResolver.clearCache(); + + await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim); + }); }); diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index d484e20f..6f5cee57 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -22,8 +22,8 @@ import type { const logger = createLogger('ClaudeMultimodelBridgeService'); -const PROVIDER_STATUS_TIMEOUT_MS = 10_000; -const PROVIDER_MODELS_TIMEOUT_MS = 10_000; +const PROVIDER_STATUS_TIMEOUT_MS = 25_000; +const PROVIDER_MODELS_TIMEOUT_MS = 25_000; const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024; diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index e697d5a4..2b2bd20d 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -1,7 +1,8 @@ -import { execFile } from 'node:child_process'; +import crypto from 'node:crypto'; import path from 'node:path'; import { evaluateCodexLaunchReadiness } from '@features/codex-account'; +import { execCli } from '@main/utils/childProcess'; import { getCachedShellEnv } from '@main/utils/shellEnv'; import { isDynamicCodexModelCatalog, @@ -76,6 +77,8 @@ const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; const CODEX_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000; +const ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS = 10_000; +const ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS = 60_000; type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown'; @@ -89,13 +92,102 @@ type CodexCliLoginStatusChecker = (params: { env: NodeJS.ProcessEnv; }) => Promise; +type AnthropicApiKeyVerificationState = 'valid' | 'invalid' | 'unknown'; + +interface AnthropicApiKeyVerificationResult { + state: AnthropicApiKeyVerificationState; + status?: number | null; + errorType?: string | null; + errorMessage?: string | null; +} + +type AnthropicApiKeyVerifier = (apiKey: string) => Promise; + +function hashCredentialForCache(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function normalizeAnthropicApiKeyVerificationMessage( + result: AnthropicApiKeyVerificationResult +): string { + if (result.errorMessage?.trim()) { + return result.errorMessage.trim(); + } + + if (result.errorType?.trim()) { + return result.errorType.trim(); + } + + if (typeof result.status === 'number') { + return `HTTP ${result.status}`; + } + + return 'unknown verification error'; +} + +async function verifyAnthropicApiKeyWithApi( + apiKey: string +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ANTHROPIC_API_KEY_VERIFY_TIMEOUT_MS); + try { + const response = await fetch('https://api.anthropic.com/v1/models', { + method: 'GET', + signal: controller.signal, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + }); + const text = await response.text(); + let body: { error?: { type?: string; message?: string } } | null = null; + try { + body = text ? (JSON.parse(text) as { error?: { type?: string; message?: string } }) : null; + } catch { + body = null; + } + + if (response.ok) { + return { state: 'valid', status: response.status }; + } + + if (response.status === 401 || response.status === 403) { + return { + state: 'invalid', + status: response.status, + errorType: body?.error?.type ?? null, + errorMessage: body?.error?.message ?? null, + }; + } + + return { + state: 'unknown', + status: response.status, + errorType: body?.error?.type ?? null, + errorMessage: body?.error?.message ?? null, + }; + } catch (error) { + return { + state: 'unknown', + status: null, + errorMessage: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + function isCodexExecBinary(binaryPath?: string | null): boolean { const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase(); return ( binaryName === 'codex' || binaryName === 'codex.exe' || + binaryName === 'codex.cmd' || + binaryName === 'codex.bat' || binaryName === 'codex-cli' || - binaryName === 'codex-cli.exe' + binaryName === 'codex-cli.exe' || + binaryName === 'codex-cli.cmd' || + binaryName === 'codex-cli.bat' ); } @@ -158,34 +250,33 @@ async function checkCodexCliLoginStatus({ const executable = binaryPath?.trim() || 'codex'; const args = [...buildCodexForcedLoginLaunchArgs(executable, 'chatgpt'), 'login', 'status']; - return new Promise((resolve) => { - execFile( - executable, - args, - { - env, - timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS, - windowsHide: true, - maxBuffer: 128 * 1024, - }, - (error, stdout, stderr) => { - const detail = sanitizeCodexLoginStatusDetail(`${stdout ?? ''}\n${stderr ?? ''}`); - if (!error) { - resolve({ status: 'logged_in', detail: detail || null }); - return; - } + try { + const result = await execCli(executable, args, { + env, + timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS, + windowsHide: true, + maxBuffer: 128 * 1024, + }); + const detail = sanitizeCodexLoginStatusDetail(`${result.stdout}\n${result.stderr}`); + return { status: 'logged_in', detail: detail || null }; + } catch (error) { + const stdout = + error && typeof error === 'object' && 'stdout' in error + ? String((error as { stdout?: unknown }).stdout ?? '') + : ''; + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String((error as { stderr?: unknown }).stderr ?? '') + : ''; + const detail = sanitizeCodexLoginStatusDetail(`${stdout}\n${stderr}`); - if (/not logged in/i.test(detail)) { - resolve({ status: 'not_logged_in', detail: detail || null }); - return; - } + if (/not logged in/i.test(detail)) { + return { status: 'not_logged_in', detail: detail || null }; + } - const fallback = - error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null; - resolve({ status: 'unknown', detail: detail || fallback || null }); - } - ); - }); + const fallback = error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null; + return { status: 'unknown', detail: detail || fallback || null }; + } } export class ProviderConnectionService { @@ -193,11 +284,16 @@ export class ProviderConnectionService { private codexAccountFeature: Pick | null = null; private codexModelCatalogFeature: Pick | null = null; + private readonly anthropicApiKeyVerificationCache = new Map< + string, + { result: AnthropicApiKeyVerificationResult; at: number } + >(); constructor( private apiKeyService = new ApiKeyService(), private readonly configManager = ConfigManager.getInstance(), - private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus + private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus, + private readonly anthropicApiKeyVerifier: AnthropicApiKeyVerifier = verifyAnthropicApiKeyWithApi ) {} static getInstance(): ProviderConnectionService { @@ -647,21 +743,69 @@ export class ProviderConnectionService { } } - private enrichAnthropicProviderStatus(provider: CliProviderStatus): CliProviderStatus { + private async enrichAnthropicProviderStatus( + provider: CliProviderStatus + ): Promise { const connection = provider.connection; if (connection?.configuredAuthMode !== 'api_key') { return provider; } if (connection.apiKeyConfigured) { + const runtimeVerifiedApiKey = + provider.authenticated === true && + provider.authMethod === 'api_key' && + provider.verificationState === 'verified'; + + if (runtimeVerifiedApiKey) { + return { + ...provider, + authenticated: true, + authMethod: 'api_key', + subscriptionRateLimits: null, + verificationState: 'verified', + statusMessage: provider.statusMessage ?? 'Connected via API key', + }; + } + + const apiVerification = await this.verifyConfiguredAnthropicApiKeyForStatus(); + if (apiVerification?.state === 'valid') { + return { + ...provider, + authenticated: true, + authMethod: 'api_key', + subscriptionRateLimits: null, + verificationState: 'verified', + statusMessage: 'Connected via API key', + }; + } + + if (apiVerification?.state === 'invalid') { + return { + ...provider, + authenticated: false, + authMethod: null, + subscriptionRateLimits: null, + verificationState: 'error', + statusMessage: `Anthropic API key verification failed: ${normalizeAnthropicApiKeyVerificationMessage( + apiVerification + )}`, + }; + } + return { ...provider, - authenticated: true, - authMethod: 'api_key', + authenticated: false, + authMethod: null, subscriptionRateLimits: null, verificationState: - provider.verificationState === 'error' ? provider.verificationState : 'verified', - statusMessage: 'Connected via API key', + provider.verificationState === 'error' || provider.verificationState === 'offline' + ? provider.verificationState + : 'unknown', + statusMessage: + provider.verificationState === 'error' + ? (provider.statusMessage ?? 'Anthropic API key verification failed') + : 'Anthropic API key is configured, but has not been verified by the runtime yet.', }; } @@ -675,6 +819,32 @@ export class ProviderConnectionService { }; } + private async verifyConfiguredAnthropicApiKeyForStatus(): Promise { + const apiKey = await this.resolveAnthropicApiKeyForStatus(); + if (!apiKey) { + return null; + } + + const cacheKey = hashCredentialForCache(apiKey); + const cached = this.anthropicApiKeyVerificationCache.get(cacheKey); + if (cached && Date.now() - cached.at < ANTHROPIC_API_KEY_VERIFY_CACHE_TTL_MS) { + return cached.result; + } + + const result = await this.anthropicApiKeyVerifier(apiKey); + this.anthropicApiKeyVerificationCache.set(cacheKey, { result, at: Date.now() }); + return result; + } + + private async resolveAnthropicApiKeyForStatus(): Promise { + const storedKey = await this.lookupStoredApiKeyValue('ANTHROPIC_API_KEY'); + if (storedKey?.value.trim()) { + return storedKey.value.trim(); + } + + return this.getExternalCredential('anthropic')?.value.trim() || null; + } + async enrichProviderStatuses(providers: CliProviderStatus[]): Promise { return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider))); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 4fb9dc77..054cd3cc 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -580,6 +580,13 @@ function appendPreflightDebugLog(event: string, data: Record): // Best-effort debug logging only. } } + +function truncatePreflightDebugText(value: string, maxLength = 1200): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength)}...`; +} const { AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES, AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES, @@ -2022,6 +2029,32 @@ type ProvisioningAuthSource = | 'gemini_runtime' | 'none'; +function isAnthropicApiKeyBackedAuthSource(authSource: unknown): boolean { + return ( + authSource === 'anthropic_api_key' || + authSource === 'anthropic_auth_token' || + authSource === 'anthropic_api_key_helper' + ); +} + +function buildAnthropicCrossProviderDirectAuthEnvPatch(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const envPatch: NodeJS.ProcessEnv = {}; + const apiKey = env.ANTHROPIC_API_KEY?.trim(); + if (apiKey) { + envPatch.ANTHROPIC_API_KEY = apiKey; + } + const baseUrl = env.ANTHROPIC_BASE_URL?.trim(); + if (baseUrl) { + envPatch.ANTHROPIC_BASE_URL = baseUrl; + } + for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) { + if (key !== 'ANTHROPIC_API_KEY') { + envPatch[key] = ''; + } + } + return envPatch; +} + interface TeamRuntimeAuthContext { teamName?: string; authMaterialId?: string; @@ -17179,7 +17212,7 @@ export class TeamProvisioningService { const providerLabel = getTeamProviderLabel(providerId); const { authSource } = probeResult; - if (authSource === 'anthropic_api_key') { + if (authSource === 'anthropic_api_key' || authSource === 'anthropic_api_key_helper') { logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`); } else if (authSource === 'anthropic_auth_token') { logger.info( @@ -17205,29 +17238,68 @@ export class TeamProvisioningService { }; const appendOneShotDiagnostic = async (): Promise => { - if (opts?.modelVerificationMode !== 'deep') { + let envResolution: ProvisioningEnvResolution | null = null; + const ensureEnvResolution = async (): Promise => { + if (!envResolution) { + envResolution = await this.buildProvisioningEnv(providerId); + } + return envResolution; + }; + + let shouldRequireRuntimePingForAnthropicApiKey = + isAnthropicApiKeyBackedAuthSource(authSource); + if ( + resolveTeamProviderId(providerId) === 'anthropic' && + !shouldRequireRuntimePingForAnthropicApiKey + ) { + const resolvedEnv = await ensureEnvResolution(); + shouldRequireRuntimePingForAnthropicApiKey = isAnthropicApiKeyBackedAuthSource( + resolvedEnv.authSource + ); + if (resolvedEnv.authSource === 'configured_api_key_missing' && resolvedEnv.warning) { + blockingMessages.push( + providerIds.length > 1 + ? `${providerLabel}: ${resolvedEnv.warning}` + : resolvedEnv.warning + ); + return; + } + } + + if (opts?.modelVerificationMode !== 'deep' && !shouldRequireRuntimePingForAnthropicApiKey) { return; } - const envResolution = await this.buildProvisioningEnv(providerId); - if (envResolution.warning) { - warnings.push( + const resolvedEnv = await ensureEnvResolution(); + if (resolvedEnv.warning) { + const prefixedWarning = providerIds.length > 1 - ? `${providerLabel}: ${envResolution.warning}` - : envResolution.warning - ); + ? `${providerLabel}: ${resolvedEnv.warning}` + : resolvedEnv.warning; + if (resolvedEnv.authSource === 'configured_api_key_missing') { + blockingMessages.push(prefixedWarning); + return; + } + warnings.push(prefixedWarning); return; } const diagnostic = await this.runProviderOneShotDiagnostic( probeResult.claudePath, targetCwd, - envResolution.env, + resolvedEnv.env, providerId, - envResolution.providerArgs + resolvedEnv.providerArgs ); if (diagnostic.warning) { - warnings.push( - providerIds.length > 1 ? `${providerLabel}: ${diagnostic.warning}` : diagnostic.warning - ); + const prefixedWarning = + providerIds.length > 1 ? `${providerLabel}: ${diagnostic.warning}` : diagnostic.warning; + if ( + shouldRequireRuntimePingForAnthropicApiKey && + this.isAuthFailureWarning(diagnostic.warning, 'probe') + ) { + blockingMessages.push(prefixedWarning); + return; + } + warnings.push(prefixedWarning); } }; @@ -17246,6 +17318,7 @@ export class TeamProvisioningService { const isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe'); const isBlockingPreflightWarning = authSource === 'configured_api_key_missing' || + (isAnthropicApiKeyBackedAuthSource(authSource) && isAuthFailure) || ((authSource === 'none' || authSource === 'codex_runtime' || authSource === 'gemini_runtime') && @@ -17260,6 +17333,8 @@ export class TeamProvisioningService { isAuthFailure ) { blockingMessages.push(prefixedWarning); + } else if (isAnthropicApiKeyBackedAuthSource(authSource) && isAuthFailure) { + blockingMessages.push(prefixedWarning); } else if (isBinaryProbeWarning(probeResult.warning)) { blockingMessages.push(prefixedWarning); } else { @@ -32568,6 +32643,8 @@ export class TeamProvisioningService { if (env.anthropicApiKeyHelper) { usesAnthropicApiKeyHelper = true; Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch); + } else if (providerId === 'anthropic' && isAnthropicApiKeyBackedAuthSource(env.authSource)) { + Object.assign(envPatch, buildAnthropicCrossProviderDirectAuthEnvPatch(env.env)); } const flattenedArgs = providerId === 'anthropic' && env.anthropicApiKeyHelper @@ -34066,25 +34143,33 @@ export class TeamProvisioningService { return {}; } + const args = buildProviderCliCommandArgs(providerArgs, getPreflightPingArgs(providerId)); + const timeoutMs = getPreflightTimeoutMs(providerId); + appendPreflightDebugLog('provider_one_shot_diagnostic_start', { + providerId: resolvedProviderId, + cwd, + timeoutMs, + args, + }); + for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) { let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null; try { - pingProbe = await this.spawnProbe( - claudePath, - buildProviderCliCommandArgs(providerArgs, getPreflightPingArgs(providerId)), - cwd, - env, - getPreflightTimeoutMs(providerId), - { - resolveOnOutputMatch: ({ stdout, stderr }) => { - const combined = `${stdout}\n${stderr}`.trim(); - return /\bPONG\b/i.test(combined); - }, - } - ); + pingProbe = await this.spawnProbe(claudePath, args, cwd, env, timeoutMs, { + resolveOnOutputMatch: ({ stdout, stderr }) => { + const combined = `${stdout}\n${stderr}`.trim(); + return /\bPONG\b/i.test(combined); + }, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { + appendPreflightDebugLog('provider_one_shot_diagnostic_retry', { + providerId: resolvedProviderId, + cwd, + attempt, + reason: truncatePreflightDebugText(message), + }); logger.warn( `One-shot diagnostic failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` + `retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}` @@ -34093,6 +34178,14 @@ export class TeamProvisioningService { continue; } const normalizedMessage = normalizeProviderModelProbeFailureReason(message); + appendPreflightDebugLog('provider_one_shot_diagnostic_complete', { + providerId: resolvedProviderId, + cwd, + attempt, + ok: false, + reason: isProbeTimeoutMessage(message) ? 'timeout' : 'error', + message: truncatePreflightDebugText(normalizedMessage), + }); return { warning: (isProbeTimeoutMessage(message) @@ -34106,6 +34199,14 @@ export class TeamProvisioningService { const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe'); if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) { + appendPreflightDebugLog('provider_one_shot_diagnostic_retry', { + providerId: resolvedProviderId, + cwd, + attempt, + exitCode: pingProbe.exitCode, + reason: 'auth_failure', + output: truncatePreflightDebugText(combinedOutput), + }); logger.warn( `One-shot diagnostic auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` + `retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms - likely stale locks from interrupted process` @@ -34131,6 +34232,15 @@ export class TeamProvisioningService { : normalizedOutput ? `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}). Details: ${normalizedOutput}` : `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`; + appendPreflightDebugLog('provider_one_shot_diagnostic_complete', { + providerId: resolvedProviderId, + cwd, + attempt, + ok: false, + exitCode: pingProbe.exitCode, + authFailure: isAuthFailure, + output: truncatePreflightDebugText(normalizedOutput || combinedOutput), + }); return { warning: 'One-shot diagnostic failed after runtime readiness passed. ' + @@ -34143,6 +34253,15 @@ export class TeamProvisioningService { pongCandidate ); if (!isPong) { + appendPreflightDebugLog('provider_one_shot_diagnostic_complete', { + providerId: resolvedProviderId, + cwd, + attempt, + ok: false, + exitCode: pingProbe.exitCode, + reason: 'unexpected_output', + output: truncatePreflightDebugText(combinedOutput), + }); return { warning: 'One-shot diagnostic completed but did not return the expected PONG. ' + @@ -34156,6 +34275,13 @@ export class TeamProvisioningService { `One-shot diagnostic succeeded on attempt ${attempt} (previous attempt had auth failure)` ); } + appendPreflightDebugLog('provider_one_shot_diagnostic_complete', { + providerId: resolvedProviderId, + cwd, + attempt, + ok: true, + exitCode: pingProbe.exitCode, + }); return {}; } diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts index cf011708..b033598e 100644 --- a/src/renderer/components/runtime/providerConnectionUi.ts +++ b/src/renderer/components/runtime/providerConnectionUi.ts @@ -123,7 +123,10 @@ function isAnthropicApiKeyModeReady(provider: CliProviderStatus): boolean { return ( provider.providerId === 'anthropic' && provider.connection?.configuredAuthMode === 'api_key' && - provider.connection.apiKeyConfigured === true + provider.connection.apiKeyConfigured === true && + provider.authenticated === true && + provider.authMethod === 'api_key' && + provider.verificationState === 'verified' ); } @@ -261,6 +264,18 @@ export function formatProviderStatusText(provider: CliProviderStatus): string { return 'Connected via API key'; } + if ( + provider.providerId === 'anthropic' && + provider.connection?.configuredAuthMode === 'api_key' && + provider.connection.apiKeyConfigured === true + ) { + const statusMessage = provider.statusMessage?.trim(); + if (statusMessage && !/^connected\b/i.test(statusMessage)) { + return statusMessage; + } + return 'API key configured, but not verified yet'; + } + if (isAnthropicApiKeyModeMissingCredential(provider)) { return 'API key mode selected, but no API key is configured'; } diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx index ed59a0d5..b6fab3bc 100644 --- a/src/renderer/components/ui/dialog.tsx +++ b/src/renderer/components/ui/dialog.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */ import * as React from 'react'; +import { createPortal } from 'react-dom'; import * as DialogPrimitive from '@radix-ui/react-dialog'; import { cn } from '@renderer/lib/utils'; @@ -7,9 +8,22 @@ import { X } from 'lucide-react'; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; +type DialogPortalProps = React.ComponentPropsWithoutRef; + +const DialogPortal = ({ children, container }: DialogPortalProps): React.ReactPortal | null => { + const [mounted, setMounted] = React.useState(false); + + React.useLayoutEffect(() => { + setMounted(true); + }, []); + + const portalContainer = container ?? (mounted ? globalThis.document?.body : null); + return portalContainer ? createPortal(<>{children}, portalContainer) : null; +}; +DialogPortal.displayName = DialogPrimitive.Portal.displayName; + const DialogOverlay = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef diff --git a/src/renderer/index.html b/src/renderer/index.html index 352e755c..8b1d07d1 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -1441,18 +1441,14 @@ })(); diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 98599d7f..52f89f1a 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -2,11 +2,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(); +const execCliMock = vi.fn< + ( + binaryPath: string | null, + args: string[], + options?: { + env?: NodeJS.ProcessEnv; + timeout?: number; + windowsHide?: boolean; + maxBuffer?: number; + } + ) => Promise<{ stdout: string; stderr: string }> +>(); vi.mock('@main/utils/shellEnv', () => ({ getCachedShellEnv: () => getCachedShellEnvMock(), })); +vi.mock('@main/utils/childProcess', () => ({ + execCli: ( + binaryPath: string | null, + args: string[], + options?: { + env?: NodeJS.ProcessEnv; + timeout?: number; + windowsHide?: boolean; + maxBuffer?: number; + } + ) => execCliMock(binaryPath, args, options), +})); + describe('ProviderConnectionService', () => { const originalOpenAiApiKey = process.env.OPENAI_API_KEY; const originalCodexApiKey = process.env.CODEX_API_KEY; @@ -34,6 +59,7 @@ describe('ProviderConnectionService', () => { vi.resetModules(); vi.clearAllMocks(); getCachedShellEnvMock.mockReturnValue({}); + execCliMock.mockResolvedValue({ stdout: 'Logged in using ChatGPT', stderr: '' }); delete process.env.OPENAI_API_KEY; delete process.env.CODEX_API_KEY; }); @@ -259,7 +285,177 @@ describe('ProviderConnectionService', () => { }); }); - it('surfaces stored Anthropic API key mode as the effective provider auth status', async () => { + it('does not report stored Anthropic API key mode as connected until runtime verifies the API key', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const verifyAnthropicApiKey = vi.fn().mockResolvedValue({ state: 'unknown' }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never, + undefined, + verifyAnthropicApiKey + ); + + const status = await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(status).toMatchObject({ + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: + 'Anthropic API key is configured, but has not been verified by the runtime yet.', + connection: { + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }, + }); + expect(verifyAnthropicApiKey).toHaveBeenCalledWith('stored-key'); + }); + + it('reports Anthropic API key mode as connected after direct API verification succeeds', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const verifyAnthropicApiKey = vi.fn().mockResolvedValue({ state: 'valid', status: 200 }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never, + undefined, + verifyAnthropicApiKey + ); + + const status = await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(status).toMatchObject({ + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + statusMessage: 'Connected via API key', + connection: { + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }, + }); + expect(verifyAnthropicApiKey).toHaveBeenCalledTimes(1); + }); + + it('reports an invalid Anthropic API key after direct API verification fails', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const verifyAnthropicApiKey = vi.fn().mockResolvedValue({ + state: 'invalid', + status: 401, + errorType: 'authentication_error', + errorMessage: 'invalid x-api-key', + }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue({ + envVarName: 'ANTHROPIC_API_KEY', + value: 'stored-key', + }), + } as never, + { + getConfig: () => createConfig('api_key'), + } as never, + undefined, + verifyAnthropicApiKey + ); + + const status = await service.enrichProviderStatus({ + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'claude.ai', + verificationState: 'verified', + statusMessage: 'Connected via Anthropic subscription', + models: ['claude-sonnet-4-6'], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { mcp: 'unsupported', skills: 'unsupported', plugins: 'unsupported' }, + }, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + backend: null, + connection: null, + } as never); + + expect(status).toMatchObject({ + authenticated: false, + authMethod: null, + verificationState: 'error', + statusMessage: 'Anthropic API key verification failed: invalid x-api-key', + }); + }); + + it('reports Anthropic API key mode as connected after runtime verifies the API key', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); @@ -280,9 +476,9 @@ describe('ProviderConnectionService', () => { displayName: 'Anthropic', supported: true, authenticated: true, - authMethod: 'claude.ai', + authMethod: 'api_key', verificationState: 'verified', - statusMessage: 'Connected via Anthropic subscription', + statusMessage: null, models: ['claude-sonnet-4-6'], canLoginFromUi: true, capabilities: { @@ -823,6 +1019,73 @@ describe('ProviderConnectionService', () => { expect(loginStatusChecker).not.toHaveBeenCalled(); }); + it('verifies degraded Codex cmd shim login status through the shared CLI launcher', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'warning_degraded_but_launchable', + appServerState: 'degraded', + appServerStatusMessage: 'Using cached ChatGPT account after transient app-server failure.', + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex.cmd', + codexHome: '/Users/tester/.codex-custom', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + await expect(service.getConfiguredConnectionIssue({}, 'codex')).resolves.toBeNull(); + + expect(execCliMock).toHaveBeenCalledWith( + '/opt/codex/bin/codex.cmd', + ['-c', 'forced_login_method="chatgpt"', 'login', 'status'], + expect.objectContaining({ + timeout: 5_000, + windowsHide: true, + maxBuffer: 128 * 1024, + env: expect.objectContaining({ + CODEX_CLI_PATH: '/opt/codex/bin/codex.cmd', + CODEX_HOME: '/Users/tester/.codex-custom', + CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD: 'chatgpt', + }), + }) + ); + }); + it('blocks launch when managed ChatGPT is selected but degraded exact runtime login is logged out', async () => { const { ProviderConnectionService } = await import('@main/services/runtime/ProviderConnectionService'); diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 43f38d76..dad1923a 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -449,6 +449,34 @@ describe('TeamProvisioningService prepare/auth behavior', () => { ]); }); + it('passes direct Anthropic API-key env to non-Anthropic leads for cross-provider teammates', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + ANTHROPIC_API_KEY: 'sk-ant-cross-provider', + ANTHROPIC_BASE_URL: 'https://api.anthropic.com', + ANTHROPIC_AUTH_TOKEN: 'stale-token', + CLAUDE_CODE_OAUTH_TOKEN: 'stale-oauth-token', + }, + authSource: 'anthropic_api_key', + geminiRuntimeAuth: null, + providerArgs: ['--anthropic-safe-passthrough'], + }); + + const result = await (svc as any).buildCrossProviderMemberArgs( + 'codex', + [{ name: 'bob', providerId: 'anthropic', model: 'haiku' }], + { teamRuntimeAuth: { teamName: 'mixed-team', authMaterialId: 'run-1' } } + ); + + expect(result.usesAnthropicApiKeyHelper).toBe(false); + expect(result.envPatch.ANTHROPIC_API_KEY).toBe('sk-ant-cross-provider'); + expect(result.envPatch.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com'); + expect(result.envPatch.ANTHROPIC_AUTH_TOKEN).toBe(''); + expect(result.envPatch.CLAUDE_CODE_OAUTH_TOKEN).toBe(''); + expect(result.args).toContain('--anthropic-safe-passthrough'); + }); + afterEach(async () => { await removeTempRoot(tempRoot); }); @@ -1293,6 +1321,79 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(runProviderOneShotDiagnostic).not.toHaveBeenCalled(); }); + it('runs Anthropic one-shot when launch env uses API key despite cached runtime auth', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'none', + }); + vi.spyOn(svc as any, 'verifySelectedProviderModels').mockResolvedValue({ + details: ['Selected model haiku verified for launch.'], + warnings: [], + blockingMessages: [], + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + ANTHROPIC_API_KEY: 'test-key', + }, + authSource: 'anthropic_api_key', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"anthropic":{"auth":"api_key"}}'], + }); + const runProviderOneShotDiagnostic = vi + .spyOn(svc as any, 'runProviderOneShotDiagnostic') + .mockResolvedValue({}); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + modelIds: ['haiku'], + modelVerificationMode: 'compatibility', + }); + + expect(result.ready).toBe(true); + expect(runProviderOneShotDiagnostic).toHaveBeenCalledWith( + '/fake/claude', + tempRoot, + expect.objectContaining({ ANTHROPIC_API_KEY: 'test-key' }), + 'anthropic', + ['--settings', '{"anthropic":{"auth":"api_key"}}'] + ); + }); + + it('blocks Anthropic API-key prepare when one-shot reports invalid credentials', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'getCachedOrProbeResult').mockResolvedValue({ + claudePath: '/fake/claude', + authSource: 'none', + }); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + ANTHROPIC_API_KEY: 'test-key', + }, + authSource: 'anthropic_api_key', + geminiRuntimeAuth: null, + providerArgs: [], + }); + vi.spyOn(svc as any, 'runProviderOneShotDiagnostic').mockResolvedValue({ + warning: + 'One-shot diagnostic failed after runtime readiness passed. Details: API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid authentication credentials"}}', + }); + + const result = await svc.prepareForProvisioning(tempRoot, { + forceFresh: true, + providerId: 'anthropic', + modelVerificationMode: 'compatibility', + }); + + expect(result.ready).toBe(false); + expect(result.message).toContain('Invalid authentication credentials'); + }); + it('falls back from an unavailable Anthropic 1M launch id to the base model during prepare', async () => { execCliMock.mockImplementationOnce(async (_binaryPath: string | null, args: string[]) => { if (args[0] === 'model') { diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index a89682c1..bc740d5d 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -184,10 +184,10 @@ describe('providerConnectionUi', () => { expect(getProviderConnectionModeSummary(provider)).toBeNull(); }); - it('shows Anthropic API key as the effective connection when API key mode is pinned', () => { + it('shows Anthropic API key as the effective connection after runtime verification', () => { const provider = createAnthropicProvider({ authenticated: true, - authMethod: 'claude.ai', + authMethod: 'api_key', configuredAuthMode: 'api_key', apiKeyConfigured: true, apiKeySource: 'stored', @@ -198,6 +198,20 @@ describe('providerConnectionUi', () => { expect(getProviderCredentialSummary(provider)).toBe('Stored in app'); }); + it('does not show API key mode as connected when only a stored key is known', () => { + const provider = createAnthropicProvider({ + authenticated: false, + authMethod: null, + configuredAuthMode: 'api_key', + apiKeyConfigured: true, + apiKeySource: 'stored', + apiKeySourceLabel: 'Stored in app', + }); + + expect(formatProviderStatusText(provider)).toBe('API key configured, but not verified yet'); + expect(getProviderCredentialSummary(provider)).toBe('API key also configured in Manage'); + }); + it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => { const provider = createAnthropicProvider({ authenticated: true,