fix(runtime): stabilize provider readiness checks
This commit is contained in:
parent
6e67e9b3a4
commit
6dc103b731
12 changed files with 849 additions and 80 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue