fix(runtime): stabilize provider readiness checks

This commit is contained in:
infiniti 2026-05-14 00:22:57 +03:00 committed by GitHub
parent 6e67e9b3a4
commit 6dc103b731
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 849 additions and 80 deletions

View file

@ -214,6 +214,7 @@ import {
TeamTaskStallSnapshotSource,
TeamTranscriptSourceLocator,
UpdaterService,
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
} from './services';
import type { FileChangeEvent } from '@main/types';
@ -346,6 +347,18 @@ async function createOpenCodeRuntimeAdapterRegistry(
reportProgress('runtime-environment', 'Preparing runtime environment...');
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
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({

View file

@ -6,6 +6,7 @@ import { execCli } from '@main/utils/childProcess';
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;
@ -21,6 +22,18 @@ async function fileExists(filePath: string): Promise<boolean> {
}
}
async function binaryCanLaunch(candidate: string): Promise<boolean> {
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];
@ -67,7 +80,7 @@ async function verifyBinary(candidate: string): Promise<string | null> {
if (isPathLikeCandidate(candidate)) {
for (const expandedCandidate of expandedCandidates) {
if (await fileExists(expandedCandidate)) {
if ((await fileExists(expandedCandidate)) && (await binaryCanLaunch(expandedCandidate))) {
return expandedCandidate;
}
}
@ -78,7 +91,7 @@ async function verifyBinary(candidate: string): Promise<string | null> {
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;
}
}

View file

@ -7,11 +7,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PathLike } from 'node:fs';
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
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),
}));
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;
@ -32,6 +48,7 @@ describe('CodexBinaryResolver', () => {
setPlatform('win32');
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
delete process.env.CODEX_CLI_PATH;
execCliMock.mockResolvedValue({ stdout: 'codex-cli 0.130.0', stderr: '' });
});
afterEach(() => {
@ -78,4 +95,31 @@ describe('CodexBinaryResolver', () => {
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
});
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);
});
});

View file

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

View file

@ -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<CodexCliLoginStatusCheckResult>;
type AnthropicApiKeyVerificationState = 'valid' | 'invalid' | 'unknown';
interface AnthropicApiKeyVerificationResult {
state: AnthropicApiKeyVerificationState;
status?: number | null;
errorType?: string | null;
errorMessage?: string | null;
}
type AnthropicApiKeyVerifier = (apiKey: string) => Promise<AnthropicApiKeyVerificationResult>;
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<AnthropicApiKeyVerificationResult> {
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<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | 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<CliProviderStatus> {
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<AnthropicApiKeyVerificationResult | null> {
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<string | null> {
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<CliProviderStatus[]> {
return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider)));
}

View file

@ -580,6 +580,13 @@ function appendPreflightDebugLog(event: string, data: Record<string, unknown>):
// 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<void> => {
if (opts?.modelVerificationMode !== 'deep') {
let envResolution: ProvisioningEnvResolution | null = null;
const ensureEnvResolution = async (): Promise<ProvisioningEnvResolution> => {
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 {};
}

View file

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

View file

@ -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<typeof DialogPrimitive.Portal>;
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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>

View file

@ -1441,18 +1441,14 @@
})();
</script>
<script type="module">
if (window.location.protocol !== 'file:') {
const { startSplashScene } = await import('./components/splash/splashScene.ts');
const splash = document.getElementById('splash');
if (splash && !window.__claudeTeamsSplashScene) {
window.__claudeTeamsSplashScene = startSplashScene(splash);
}
const { startSplashScene } = await import('./components/splash/splashScene.ts');
const splash = document.getElementById('splash');
if (splash && !window.__claudeTeamsSplashScene) {
window.__claudeTeamsSplashScene = startSplashScene(splash);
}
</script>
<script type="module">
if (window.location.protocol !== 'file:') {
import('./main.tsx');
}
import('./main.tsx');
</script>
</body>
</html>

View file

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

View file

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

View file

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