Merge branch 'dev' of https://github.com/777genius/agent-teams-ai into dev
# Conflicts: # src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts
This commit is contained in:
commit
6726c4c55d
12 changed files with 849 additions and 80 deletions
|
|
@ -216,6 +216,7 @@ import {
|
||||||
TeamTaskStallSnapshotSource,
|
TeamTaskStallSnapshotSource,
|
||||||
TeamTranscriptSourceLocator,
|
TeamTranscriptSourceLocator,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
import type { FileChangeEvent } from '@main/types';
|
import type { FileChangeEvent } from '@main/types';
|
||||||
|
|
@ -351,6 +352,18 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||||
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
bridgeEnv.CLAUDE_TEAM_APP_INSTANCE_ID = openCodeManagedHostInstanceId;
|
||||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
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 {
|
try {
|
||||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
|
|
||||||
const CACHE_VERIFY_TTL_MS = 30_000;
|
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||||
const VERSION_CACHE_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 cachedBinaryPath: string | null | undefined;
|
||||||
let cacheVerifiedAt = 0;
|
let cacheVerifiedAt = 0;
|
||||||
|
|
@ -23,6 +24,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[] {
|
function expandWindowsExtensions(candidate: string): string[] {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
return [candidate];
|
return [candidate];
|
||||||
|
|
@ -80,7 +93,7 @@ async function verifyBinary(candidate: string): Promise<string | null> {
|
||||||
|
|
||||||
if (isPathLikeCandidate(candidate)) {
|
if (isPathLikeCandidate(candidate)) {
|
||||||
for (const expandedCandidate of expandedCandidates) {
|
for (const expandedCandidate of expandedCandidates) {
|
||||||
if (await fileExists(expandedCandidate)) {
|
if ((await fileExists(expandedCandidate)) && (await binaryCanLaunch(expandedCandidate))) {
|
||||||
return expandedCandidate;
|
return expandedCandidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +104,7 @@ async function verifyBinary(candidate: string): Promise<string | null> {
|
||||||
for (const pathEntry of pathEntries) {
|
for (const pathEntry of pathEntries) {
|
||||||
for (const expandedCandidate of expandedCandidates) {
|
for (const expandedCandidate of expandedCandidates) {
|
||||||
const resolvedCandidate = resolvePathEntryCandidate(pathEntry, expandedCandidate);
|
const resolvedCandidate = resolvePathEntryCandidate(pathEntry, expandedCandidate);
|
||||||
if (await fileExists(resolvedCandidate)) {
|
if ((await fileExists(resolvedCandidate)) && (await binaryCanLaunch(resolvedCandidate))) {
|
||||||
return resolvedCandidate;
|
return resolvedCandidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,14 @@ import type { PathLike } from 'node:fs';
|
||||||
|
|
||||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||||
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>();
|
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>();
|
||||||
|
const execCliMock =
|
||||||
|
vi.fn<
|
||||||
|
(
|
||||||
|
binaryPath: string | null,
|
||||||
|
args: string[],
|
||||||
|
options?: { timeout?: number; windowsHide?: boolean }
|
||||||
|
) => Promise<{ stdout: string; stderr: string }>
|
||||||
|
>();
|
||||||
|
|
||||||
vi.mock('node:fs/promises', () => ({
|
vi.mock('node:fs/promises', () => ({
|
||||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||||
|
|
@ -18,6 +26,14 @@ vi.mock('@features/codex-runtime-installer/main', () => ({
|
||||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock(),
|
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 originalPlatform = process.platform;
|
||||||
const originalPath = process.env.PATH;
|
const originalPath = process.env.PATH;
|
||||||
const originalPathExt = process.env.PATHEXT;
|
const originalPathExt = process.env.PATHEXT;
|
||||||
|
|
@ -39,6 +55,7 @@ describe('CodexBinaryResolver', () => {
|
||||||
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
||||||
delete process.env.CODEX_CLI_PATH;
|
delete process.env.CODEX_CLI_PATH;
|
||||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||||
|
execCliMock.mockResolvedValue({ stdout: 'codex-cli 0.130.0', stderr: '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -126,4 +143,31 @@ describe('CodexBinaryResolver', () => {
|
||||||
|
|
||||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(appManagedBinary);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ import type {
|
||||||
|
|
||||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
|
|
||||||
const PROVIDER_STATUS_TIMEOUT_MS = 10_000;
|
const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
|
||||||
const PROVIDER_MODELS_TIMEOUT_MS = 10_000;
|
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
|
||||||
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
|
||||||
const PROVIDER_MODELS_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 path from 'node:path';
|
||||||
|
|
||||||
import { evaluateCodexLaunchReadiness } from '@features/codex-account';
|
import { evaluateCodexLaunchReadiness } from '@features/codex-account';
|
||||||
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||||
import {
|
import {
|
||||||
isDynamicCodexModelCatalog,
|
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_FORCED_LOGIN_METHOD_ENV_VAR = 'CLAUDE_CODE_CODEX_FORCED_LOGIN_METHOD';
|
||||||
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
||||||
const CODEX_LOGIN_STATUS_TIMEOUT_MS = 5_000;
|
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';
|
type CodexCliLoginStatus = 'logged_in' | 'not_logged_in' | 'unknown';
|
||||||
|
|
||||||
|
|
@ -89,13 +92,102 @@ type CodexCliLoginStatusChecker = (params: {
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}) => Promise<CodexCliLoginStatusCheckResult>;
|
}) => 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 {
|
function isCodexExecBinary(binaryPath?: string | null): boolean {
|
||||||
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
|
const binaryName = path.basename(binaryPath?.trim() ?? '').toLowerCase();
|
||||||
return (
|
return (
|
||||||
binaryName === 'codex' ||
|
binaryName === 'codex' ||
|
||||||
binaryName === 'codex.exe' ||
|
binaryName === 'codex.exe' ||
|
||||||
|
binaryName === 'codex.cmd' ||
|
||||||
|
binaryName === 'codex.bat' ||
|
||||||
binaryName === 'codex-cli' ||
|
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 executable = binaryPath?.trim() || 'codex';
|
||||||
const args = [...buildCodexForcedLoginLaunchArgs(executable, 'chatgpt'), 'login', 'status'];
|
const args = [...buildCodexForcedLoginLaunchArgs(executable, 'chatgpt'), 'login', 'status'];
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
try {
|
||||||
execFile(
|
const result = await execCli(executable, args, {
|
||||||
executable,
|
env,
|
||||||
args,
|
timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS,
|
||||||
{
|
windowsHide: true,
|
||||||
env,
|
maxBuffer: 128 * 1024,
|
||||||
timeout: CODEX_LOGIN_STATUS_TIMEOUT_MS,
|
});
|
||||||
windowsHide: true,
|
const detail = sanitizeCodexLoginStatusDetail(`${result.stdout}\n${result.stderr}`);
|
||||||
maxBuffer: 128 * 1024,
|
return { status: 'logged_in', detail: detail || null };
|
||||||
},
|
} catch (error) {
|
||||||
(error, stdout, stderr) => {
|
const stdout =
|
||||||
const detail = sanitizeCodexLoginStatusDetail(`${stdout ?? ''}\n${stderr ?? ''}`);
|
error && typeof error === 'object' && 'stdout' in error
|
||||||
if (!error) {
|
? String((error as { stdout?: unknown }).stdout ?? '')
|
||||||
resolve({ status: 'logged_in', detail: detail || null });
|
: '';
|
||||||
return;
|
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)) {
|
if (/not logged in/i.test(detail)) {
|
||||||
resolve({ status: 'not_logged_in', detail: detail || null });
|
return { status: 'not_logged_in', detail: detail || null };
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const fallback =
|
const fallback = error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null;
|
||||||
error instanceof Error ? sanitizeCodexLoginStatusDetail(error.message) : null;
|
return { status: 'unknown', detail: detail || fallback || null };
|
||||||
resolve({ status: 'unknown', detail: detail || fallback || null });
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProviderConnectionService {
|
export class ProviderConnectionService {
|
||||||
|
|
@ -193,11 +284,16 @@ export class ProviderConnectionService {
|
||||||
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
private codexAccountFeature: Pick<CodexAccountFeatureFacade, 'getSnapshot'> | null = null;
|
||||||
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
|
private codexModelCatalogFeature: Pick<CodexModelCatalogFeatureFacade, 'getCatalog'> | null =
|
||||||
null;
|
null;
|
||||||
|
private readonly anthropicApiKeyVerificationCache = new Map<
|
||||||
|
string,
|
||||||
|
{ result: AnthropicApiKeyVerificationResult; at: number }
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private apiKeyService = new ApiKeyService(),
|
private apiKeyService = new ApiKeyService(),
|
||||||
private readonly configManager = ConfigManager.getInstance(),
|
private readonly configManager = ConfigManager.getInstance(),
|
||||||
private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus
|
private readonly codexCliLoginStatusChecker: CodexCliLoginStatusChecker = checkCodexCliLoginStatus,
|
||||||
|
private readonly anthropicApiKeyVerifier: AnthropicApiKeyVerifier = verifyAnthropicApiKeyWithApi
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
static getInstance(): ProviderConnectionService {
|
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;
|
const connection = provider.connection;
|
||||||
if (connection?.configuredAuthMode !== 'api_key') {
|
if (connection?.configuredAuthMode !== 'api_key') {
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection.apiKeyConfigured) {
|
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 {
|
return {
|
||||||
...provider,
|
...provider,
|
||||||
authenticated: true,
|
authenticated: false,
|
||||||
authMethod: 'api_key',
|
authMethod: null,
|
||||||
subscriptionRateLimits: null,
|
subscriptionRateLimits: null,
|
||||||
verificationState:
|
verificationState:
|
||||||
provider.verificationState === 'error' ? provider.verificationState : 'verified',
|
provider.verificationState === 'error' || provider.verificationState === 'offline'
|
||||||
statusMessage: 'Connected via API key',
|
? 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[]> {
|
async enrichProviderStatuses(providers: CliProviderStatus[]): Promise<CliProviderStatus[]> {
|
||||||
return Promise.all(providers.map((provider) => this.enrichProviderStatus(provider)));
|
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.
|
// Best-effort debug logging only.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncatePreflightDebugText(value: string, maxLength = 1200): string {
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return `${value.slice(0, maxLength)}...`;
|
||||||
|
}
|
||||||
const {
|
const {
|
||||||
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
|
||||||
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES,
|
||||||
|
|
@ -2022,6 +2029,32 @@ type ProvisioningAuthSource =
|
||||||
| 'gemini_runtime'
|
| 'gemini_runtime'
|
||||||
| 'none';
|
| '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 {
|
interface TeamRuntimeAuthContext {
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
authMaterialId?: string;
|
authMaterialId?: string;
|
||||||
|
|
@ -17179,7 +17212,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const providerLabel = getTeamProviderLabel(providerId);
|
const providerLabel = getTeamProviderLabel(providerId);
|
||||||
const { authSource } = probeResult;
|
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}`);
|
logger.info(`Auth: using explicit ANTHROPIC_API_KEY for ${providerLabel}`);
|
||||||
} else if (authSource === 'anthropic_auth_token') {
|
} else if (authSource === 'anthropic_auth_token') {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -17205,29 +17238,68 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendOneShotDiagnostic = async (): Promise<void> => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const envResolution = await this.buildProvisioningEnv(providerId);
|
const resolvedEnv = await ensureEnvResolution();
|
||||||
if (envResolution.warning) {
|
if (resolvedEnv.warning) {
|
||||||
warnings.push(
|
const prefixedWarning =
|
||||||
providerIds.length > 1
|
providerIds.length > 1
|
||||||
? `${providerLabel}: ${envResolution.warning}`
|
? `${providerLabel}: ${resolvedEnv.warning}`
|
||||||
: envResolution.warning
|
: resolvedEnv.warning;
|
||||||
);
|
if (resolvedEnv.authSource === 'configured_api_key_missing') {
|
||||||
|
blockingMessages.push(prefixedWarning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warnings.push(prefixedWarning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const diagnostic = await this.runProviderOneShotDiagnostic(
|
const diagnostic = await this.runProviderOneShotDiagnostic(
|
||||||
probeResult.claudePath,
|
probeResult.claudePath,
|
||||||
targetCwd,
|
targetCwd,
|
||||||
envResolution.env,
|
resolvedEnv.env,
|
||||||
providerId,
|
providerId,
|
||||||
envResolution.providerArgs
|
resolvedEnv.providerArgs
|
||||||
);
|
);
|
||||||
if (diagnostic.warning) {
|
if (diagnostic.warning) {
|
||||||
warnings.push(
|
const prefixedWarning =
|
||||||
providerIds.length > 1 ? `${providerLabel}: ${diagnostic.warning}` : diagnostic.warning
|
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 isAuthFailure = this.isAuthFailureWarning(probeResult.warning, 'probe');
|
||||||
const isBlockingPreflightWarning =
|
const isBlockingPreflightWarning =
|
||||||
authSource === 'configured_api_key_missing' ||
|
authSource === 'configured_api_key_missing' ||
|
||||||
|
(isAnthropicApiKeyBackedAuthSource(authSource) && isAuthFailure) ||
|
||||||
((authSource === 'none' ||
|
((authSource === 'none' ||
|
||||||
authSource === 'codex_runtime' ||
|
authSource === 'codex_runtime' ||
|
||||||
authSource === 'gemini_runtime') &&
|
authSource === 'gemini_runtime') &&
|
||||||
|
|
@ -17260,6 +17333,8 @@ export class TeamProvisioningService {
|
||||||
isAuthFailure
|
isAuthFailure
|
||||||
) {
|
) {
|
||||||
blockingMessages.push(prefixedWarning);
|
blockingMessages.push(prefixedWarning);
|
||||||
|
} else if (isAnthropicApiKeyBackedAuthSource(authSource) && isAuthFailure) {
|
||||||
|
blockingMessages.push(prefixedWarning);
|
||||||
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
} else if (isBinaryProbeWarning(probeResult.warning)) {
|
||||||
blockingMessages.push(prefixedWarning);
|
blockingMessages.push(prefixedWarning);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -32568,6 +32643,8 @@ export class TeamProvisioningService {
|
||||||
if (env.anthropicApiKeyHelper) {
|
if (env.anthropicApiKeyHelper) {
|
||||||
usesAnthropicApiKeyHelper = true;
|
usesAnthropicApiKeyHelper = true;
|
||||||
Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch);
|
Object.assign(envPatch, env.anthropicApiKeyHelper.envPatch);
|
||||||
|
} else if (providerId === 'anthropic' && isAnthropicApiKeyBackedAuthSource(env.authSource)) {
|
||||||
|
Object.assign(envPatch, buildAnthropicCrossProviderDirectAuthEnvPatch(env.env));
|
||||||
}
|
}
|
||||||
const flattenedArgs =
|
const flattenedArgs =
|
||||||
providerId === 'anthropic' && env.anthropicApiKeyHelper
|
providerId === 'anthropic' && env.anthropicApiKeyHelper
|
||||||
|
|
@ -34066,25 +34143,33 @@ export class TeamProvisioningService {
|
||||||
return {};
|
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++) {
|
for (let attempt = 1; attempt <= PREFLIGHT_AUTH_MAX_RETRIES; attempt++) {
|
||||||
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
|
let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null;
|
||||||
try {
|
try {
|
||||||
pingProbe = await this.spawnProbe(
|
pingProbe = await this.spawnProbe(claudePath, args, cwd, env, timeoutMs, {
|
||||||
claudePath,
|
resolveOnOutputMatch: ({ stdout, stderr }) => {
|
||||||
buildProviderCliCommandArgs(providerArgs, getPreflightPingArgs(providerId)),
|
const combined = `${stdout}\n${stderr}`.trim();
|
||||||
cwd,
|
return /\bPONG\b/i.test(combined);
|
||||||
env,
|
},
|
||||||
getPreflightTimeoutMs(providerId),
|
});
|
||||||
{
|
|
||||||
resolveOnOutputMatch: ({ stdout, stderr }) => {
|
|
||||||
const combined = `${stdout}\n${stderr}`.trim();
|
|
||||||
return /\bPONG\b/i.test(combined);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
if (!isProbeTimeoutMessage(message) && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
||||||
|
appendPreflightDebugLog('provider_one_shot_diagnostic_retry', {
|
||||||
|
providerId: resolvedProviderId,
|
||||||
|
cwd,
|
||||||
|
attempt,
|
||||||
|
reason: truncatePreflightDebugText(message),
|
||||||
|
});
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`One-shot diagnostic failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
`One-shot diagnostic failed (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
||||||
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms: ${message}`
|
||||||
|
|
@ -34093,6 +34178,14 @@ export class TeamProvisioningService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const normalizedMessage = normalizeProviderModelProbeFailureReason(message);
|
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 {
|
return {
|
||||||
warning:
|
warning:
|
||||||
(isProbeTimeoutMessage(message)
|
(isProbeTimeoutMessage(message)
|
||||||
|
|
@ -34106,6 +34199,14 @@ export class TeamProvisioningService {
|
||||||
const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe');
|
const isAuthFailure = this.isAuthFailureWarning(combinedOutput, 'probe');
|
||||||
|
|
||||||
if (isAuthFailure && attempt < PREFLIGHT_AUTH_MAX_RETRIES) {
|
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(
|
logger.warn(
|
||||||
`One-shot diagnostic auth failure detected (attempt ${attempt}/${PREFLIGHT_AUTH_MAX_RETRIES}), ` +
|
`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`
|
`retrying in ${PREFLIGHT_AUTH_RETRY_DELAY_MS}ms - likely stale locks from interrupted process`
|
||||||
|
|
@ -34131,6 +34232,15 @@ export class TeamProvisioningService {
|
||||||
: normalizedOutput
|
: normalizedOutput
|
||||||
? `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}). Details: ${normalizedOutput}`
|
? `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}). Details: ${normalizedOutput}`
|
||||||
: `${cliCommandLabel} preflight check failed (exit code ${pingProbe.exitCode ?? 'unknown'}).`;
|
: `${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 {
|
return {
|
||||||
warning:
|
warning:
|
||||||
'One-shot diagnostic failed after runtime readiness passed. ' +
|
'One-shot diagnostic failed after runtime readiness passed. ' +
|
||||||
|
|
@ -34143,6 +34253,15 @@ export class TeamProvisioningService {
|
||||||
pongCandidate
|
pongCandidate
|
||||||
);
|
);
|
||||||
if (!isPong) {
|
if (!isPong) {
|
||||||
|
appendPreflightDebugLog('provider_one_shot_diagnostic_complete', {
|
||||||
|
providerId: resolvedProviderId,
|
||||||
|
cwd,
|
||||||
|
attempt,
|
||||||
|
ok: false,
|
||||||
|
exitCode: pingProbe.exitCode,
|
||||||
|
reason: 'unexpected_output',
|
||||||
|
output: truncatePreflightDebugText(combinedOutput),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
warning:
|
warning:
|
||||||
'One-shot diagnostic completed but did not return the expected PONG. ' +
|
'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)`
|
`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 {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,10 @@ function isAnthropicApiKeyModeReady(provider: CliProviderStatus): boolean {
|
||||||
return (
|
return (
|
||||||
provider.providerId === 'anthropic' &&
|
provider.providerId === 'anthropic' &&
|
||||||
provider.connection?.configuredAuthMode === 'api_key' &&
|
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';
|
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)) {
|
if (isAnthropicApiKeyModeMissingCredential(provider)) {
|
||||||
return 'API key mode selected, but no API key is configured';
|
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 */
|
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -7,9 +8,22 @@ import { X } from 'lucide-react';
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root;
|
const Dialog = DialogPrimitive.Root;
|
||||||
const DialogTrigger = DialogPrimitive.Trigger;
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
const DialogPortal = DialogPrimitive.Portal;
|
|
||||||
const DialogClose = DialogPrimitive.Close;
|
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<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
|
|
||||||
|
|
@ -1441,18 +1441,14 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (window.location.protocol !== 'file:') {
|
const { startSplashScene } = await import('./components/splash/splashScene.ts');
|
||||||
const { startSplashScene } = await import('./components/splash/splashScene.ts');
|
const splash = document.getElementById('splash');
|
||||||
const splash = document.getElementById('splash');
|
if (splash && !window.__claudeTeamsSplashScene) {
|
||||||
if (splash && !window.__claudeTeamsSplashScene) {
|
window.__claudeTeamsSplashScene = startSplashScene(splash);
|
||||||
window.__claudeTeamsSplashScene = startSplashScene(splash);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
if (window.location.protocol !== 'file:') {
|
import('./main.tsx');
|
||||||
import('./main.tsx');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,36 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>();
|
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', () => ({
|
vi.mock('@main/utils/shellEnv', () => ({
|
||||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
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', () => {
|
describe('ProviderConnectionService', () => {
|
||||||
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
|
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
|
||||||
const originalCodexApiKey = process.env.CODEX_API_KEY;
|
const originalCodexApiKey = process.env.CODEX_API_KEY;
|
||||||
|
|
@ -34,6 +59,7 @@ describe('ProviderConnectionService', () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
getCachedShellEnvMock.mockReturnValue({});
|
getCachedShellEnvMock.mockReturnValue({});
|
||||||
|
execCliMock.mockResolvedValue({ stdout: 'Logged in using ChatGPT', stderr: '' });
|
||||||
delete process.env.OPENAI_API_KEY;
|
delete process.env.OPENAI_API_KEY;
|
||||||
delete process.env.CODEX_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 } =
|
const { ProviderConnectionService } =
|
||||||
await import('@main/services/runtime/ProviderConnectionService');
|
await import('@main/services/runtime/ProviderConnectionService');
|
||||||
|
|
||||||
|
|
@ -280,9 +476,9 @@ describe('ProviderConnectionService', () => {
|
||||||
displayName: 'Anthropic',
|
displayName: 'Anthropic',
|
||||||
supported: true,
|
supported: true,
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
authMethod: 'claude.ai',
|
authMethod: 'api_key',
|
||||||
verificationState: 'verified',
|
verificationState: 'verified',
|
||||||
statusMessage: 'Connected via Anthropic subscription',
|
statusMessage: null,
|
||||||
models: ['claude-sonnet-4-6'],
|
models: ['claude-sonnet-4-6'],
|
||||||
canLoginFromUi: true,
|
canLoginFromUi: true,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
@ -823,6 +1019,73 @@ describe('ProviderConnectionService', () => {
|
||||||
expect(loginStatusChecker).not.toHaveBeenCalled();
|
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 () => {
|
it('blocks launch when managed ChatGPT is selected but degraded exact runtime login is logged out', async () => {
|
||||||
const { ProviderConnectionService } =
|
const { ProviderConnectionService } =
|
||||||
await import('@main/services/runtime/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 () => {
|
afterEach(async () => {
|
||||||
await removeTempRoot(tempRoot);
|
await removeTempRoot(tempRoot);
|
||||||
});
|
});
|
||||||
|
|
@ -1293,6 +1321,79 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
||||||
expect(runProviderOneShotDiagnostic).not.toHaveBeenCalled();
|
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 () => {
|
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[]) => {
|
execCliMock.mockImplementationOnce(async (_binaryPath: string | null, args: string[]) => {
|
||||||
if (args[0] === 'model') {
|
if (args[0] === 'model') {
|
||||||
|
|
|
||||||
|
|
@ -184,10 +184,10 @@ describe('providerConnectionUi', () => {
|
||||||
expect(getProviderConnectionModeSummary(provider)).toBeNull();
|
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({
|
const provider = createAnthropicProvider({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
authMethod: 'claude.ai',
|
authMethod: 'api_key',
|
||||||
configuredAuthMode: 'api_key',
|
configuredAuthMode: 'api_key',
|
||||||
apiKeyConfigured: true,
|
apiKeyConfigured: true,
|
||||||
apiKeySource: 'stored',
|
apiKeySource: 'stored',
|
||||||
|
|
@ -198,6 +198,20 @@ describe('providerConnectionUi', () => {
|
||||||
expect(getProviderCredentialSummary(provider)).toBe('Stored in app');
|
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', () => {
|
it('does not describe Anthropic API key mode as subscription connected when the key is missing', () => {
|
||||||
const provider = createAnthropicProvider({
|
const provider = createAnthropicProvider({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue