diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts deleted file mode 100644 index 543dc657..00000000 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Gemini Token Refresh - * - * Handles proactive token validation and refresh for Gemini OAuth tokens. - * Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use. - * - * Token sources (priority order): - * 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens - * 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { getProviderAuthDir } from '../config-generator'; -import { getDefaultAccount, getProviderAccounts } from '../account-manager'; -import { isTokenFileForProvider } from './token-manager'; - -/** Google OAuth token endpoint */ -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; - -/** Refresh tokens 5 minutes before expiry */ -const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; - -const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID']; -const GEMINI_CLIENT_SECRET_ENV_KEYS = [ - 'CCS_GEMINI_OAUTH_CLIENT_SECRET', - 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', -]; - -/** Gemini oauth_creds.json structure */ -interface GeminiOAuthCreds { - access_token: string; - refresh_token?: string; - expiry_date?: number; // Unix timestamp in milliseconds - scope?: string; - token_type?: string; - id_token?: string; - client_id?: string; - client_secret?: string; - token_uri?: string; -} - -/** Gemini credentials with source path for write-back */ -interface GeminiCredsWithSource { - creds: GeminiOAuthCreds; - sourcePath: string; -} - -/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */ -interface CliproxyGeminiToken { - token: { - access_token: string; - refresh_token?: string; - expiry?: number; // Unix timestamp in milliseconds - client_id?: string; - client_secret?: string; - token_uri?: string; - }; - project_id: string; - email: string; - type: 'gemini'; -} - -/** Token refresh response from Google */ -interface TokenRefreshResponse { - access_token?: string; - expires_in?: number; - token_type?: string; - error?: string; - error_description?: string; -} - -interface GoogleOAuthClientCredentials { - clientId: string; - clientSecret: string; - tokenUrl: string; -} - -/** - * Get path to Gemini OAuth credentials file - */ -export function getGeminiOAuthPath(): string { - return path.join(os.homedir(), '.gemini', 'oauth_creds.json'); -} - -/** - * Map CLIProxyAPI token format to internal GeminiOAuthCreds format - */ -function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds { - return { - access_token: cliproxy.token.access_token, - refresh_token: cliproxy.token.refresh_token, - expiry_date: cliproxy.token.expiry, - token_type: 'Bearer', - client_id: cliproxy.token.client_id, - client_secret: cliproxy.token.client_secret, - token_uri: cliproxy.token.token_uri, - }; -} - -/** - * Validate CLIProxyAPI token structure has required fields - */ -function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken { - if (typeof data !== 'object' || data === null) return false; - const obj = data as Record; - if (obj.type !== 'gemini') return false; - if (typeof obj.token !== 'object' || obj.token === null) return false; - const token = obj.token as Record; - return typeof token.access_token === 'string'; -} - -function getFirstEnvValue(keys: readonly string[]): string | undefined { - for (const key of keys) { - const value = process.env[key]?.trim(); - if (value) { - return value; - } - } - return undefined; -} - -function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): { - credentials?: GoogleOAuthClientCredentials; - error?: string; -} { - const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS); - const clientSecret = - creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS); - - if (!clientId || !clientSecret) { - return { - error: - 'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' + - 'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.', - }; - } - - return { - credentials: { - clientId, - clientSecret, - tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL, - }, - }; -} - -/** - * Read Gemini token from CLIProxy auth directory - * Returns credentials with source path, or null if no valid token found - */ -function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null { - const authDir = getProviderAuthDir('gemini'); - if (!fs.existsSync(authDir)) return null; - - let tokenPath: string | null = null; - const normalizedAccountId = accountId?.trim(); - const accounts = getProviderAccounts('gemini'); - - // Account-specific refresh path (used by background worker) - if (normalizedAccountId) { - const targetAccount = accounts.find((account) => account.id === normalizedAccountId); - if (!targetAccount) { - return null; - } - - tokenPath = path.join(authDir, targetAccount.tokenFile); - } - - if (!normalizedAccountId) { - // Try to find default account's token file - const defaultAccount = getDefaultAccount('gemini'); - if (defaultAccount) { - tokenPath = path.join(authDir, defaultAccount.tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; - } - - // Fallback: find any gemini account token file - if (!tokenPath && accounts.length > 0) { - tokenPath = path.join(authDir, accounts[0].tokenFile); - if (!fs.existsSync(tokenPath)) tokenPath = null; - } - - // Last fallback: scan directory for gemini token files - if (!tokenPath) { - try { - const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json')); - for (const file of files) { - const filePath = path.join(authDir, file); - if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) { - tokenPath = filePath; - break; - } - } - } catch { - // Directory read failed - continue to return null - return null; - } - } - } - - if (!tokenPath) return null; - - try { - const content = fs.readFileSync(tokenPath, 'utf8'); - const data: unknown = JSON.parse(content); - - // Validate CLIProxyAPI format with proper type checking - if (isValidCliproxyToken(data)) { - return { - creds: mapCliproxyToGeminiCreds(data), - sourcePath: tokenPath, - }; - } - - return null; - } catch { - return null; - } -} - -/** - * Read Gemini OAuth credentials - * Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json - * Returns credentials with source path for correct write-back - */ -function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null { - // 1. Try CLIProxy auth directory first (CCS-managed tokens) - const cliproxyResult = readCliproxyGeminiCreds(accountId); - if (cliproxyResult) { - return cliproxyResult; - } - - // Account-scoped refresh is only supported for CLIProxy account files. - // Do not fall back to ~/.gemini for a specific accountId. - if (accountId?.trim()) { - return null; - } - - // 2. Fall back to standard Gemini CLI location - const oauthPath = getGeminiOAuthPath(); - if (!fs.existsSync(oauthPath)) { - return null; - } - try { - const content = fs.readFileSync(oauthPath, 'utf8'); - return { - creds: JSON.parse(content) as GeminiOAuthCreds, - sourcePath: oauthPath, - }; - } catch { - return null; - } -} - -/** - * Write updated credentials to CLIProxy token file - * Preserves existing fields (email, project_id), only updates token subfields - */ -function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined { - try { - const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8')); - const updated = { - ...existing, - token: { - ...existing.token, - access_token: creds.access_token, - refresh_token: creds.refresh_token, - expiry: creds.expiry_date, - }, - }; - fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 }); - return undefined; - } catch (err) { - return err instanceof Error ? err.message : 'Failed to write credentials'; - } -} - -/** - * Write Gemini OAuth credentials - * Writes back to the specified source location (CLIProxy or ~/.gemini) - * @param creds - The credentials to write - * @param sourcePath - The path where credentials were originally read from - * @returns error message if write failed, undefined on success - */ -function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined { - const geminiOAuthPath = getGeminiOAuthPath(); - - // If source is not the standard Gemini path, write to CLIProxy format - if (sourcePath !== geminiOAuthPath) { - return writeCliproxyGeminiCreds(sourcePath, creds); - } - - // Otherwise write to standard Gemini CLI location - const dir = path.dirname(geminiOAuthPath); - try { - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); - } - fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); - return undefined; - } catch (err) { - return err instanceof Error ? err.message : 'Failed to write credentials'; - } -} - -/** - * Check if Gemini token is expired or expiring soon - */ -export function isGeminiTokenExpiringSoon(accountId?: string): boolean { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.access_token) { - return true; // No token = needs auth - } - if (!result.creds.expiry_date) { - return false; // No expiry info = assume valid - } - const expiresIn = result.creds.expiry_date - Date.now(); - return expiresIn < REFRESH_LEAD_TIME_MS; -} - -/** - * Refresh Gemini access token using refresh_token - * @param accountId Optional account ID for account-scoped refresh - * @returns Result with success status, optional error, and expiry time - */ -export async function refreshGeminiToken(accountId?: string): Promise<{ - success: boolean; - error?: string; - expiresAt?: number; -}> { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.refresh_token) { - return { success: false, error: 'No refresh token available' }; - } - - const { creds, sourcePath } = result; - const resolvedCredentials = resolveGeminiRefreshCredentials(creds); - if (!resolvedCredentials.credentials) { - return { success: false, error: resolvedCredentials.error }; - } - - const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - - try { - const response = await fetch(tokenUrl, { - method: 'POST', - signal: controller.signal, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: creds.refresh_token as string, // Already validated above - client_id: clientId, - client_secret: clientSecret, - }).toString(), - }); - - clearTimeout(timeoutId); - - const data = (await response.json()) as TokenRefreshResponse; - - if (!response.ok || data.error) { - return { - success: false, - error: data.error_description || data.error || `OAuth error: ${response.status}`, - }; - } - - if (!data.access_token) { - return { success: false, error: 'No access_token in response' }; - } - - // Update credentials file with new token - const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; - const updatedCreds: GeminiOAuthCreds = { - ...creds, - access_token: data.access_token, - expiry_date: expiresAt, - }; - const writeError = writeGeminiCreds(updatedCreds, sourcePath); - if (writeError) { - return { success: false, error: `Token refreshed but failed to save: ${writeError}` }; - } - - return { success: true, expiresAt }; - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof Error && err.name === 'AbortError') { - return { success: false, error: 'Token refresh timeout' }; - } - return { success: false, error: err instanceof Error ? err.message : 'Unknown error' }; - } -} - -/** - * Ensure Gemini token is valid, refreshing if needed - * @param verbose Log progress if true - * @param accountId Optional account ID for account-scoped refresh - * @returns true if token is valid (or was refreshed), false if refresh failed - */ -export async function ensureGeminiTokenValid( - verbose = false, - accountId?: string -): Promise<{ - valid: boolean; - refreshed: boolean; - error?: string; -}> { - const result = readGeminiCreds(accountId); - if (!result || !result.creds.access_token) { - return { valid: false, refreshed: false, error: 'No Gemini credentials found' }; - } - - if (!isGeminiTokenExpiringSoon(accountId)) { - return { valid: true, refreshed: false }; - } - - // Token is expired or expiring soon - try to refresh - if (verbose) { - console.log('[i] Gemini token expired or expiring soon, refreshing...'); - } - - const refreshResult = await refreshGeminiToken(accountId); - if (refreshResult.success) { - if (verbose) { - console.log('[OK] Gemini token refreshed successfully'); - } - return { valid: true, refreshed: true }; - } - - return { valid: false, refreshed: false, error: refreshResult.error }; -} diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index 207ac0fa..d5cfea3b 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -4,8 +4,7 @@ * Exports refresh functions for each OAuth provider. * * Refresh responsibility: - * - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth) - * - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi + * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi * (CLIProxyAPIPlus handles refresh automatically in background) * - Not implemented: claude */ @@ -16,7 +15,6 @@ import { getTokenRefreshOwnership, isRefreshDelegatedToCLIProxy, } from '../../provider-capabilities'; -import { refreshGeminiToken } from '../gemini-token-refresh'; /** Token refresh result */ export interface ProviderRefreshResult { @@ -66,19 +64,18 @@ export async function refreshToken( }; } - if (provider === 'gemini') { - return await refreshGeminiTokenWrapper(normalizedAccountId); - } - const ownership = getTokenRefreshOwnership(provider); switch (ownership) { case 'cliproxy': // CLIProxyAPIPlus handles refresh for these providers automatically. // No action needed from CCS — report success with delegated flag. return { success: true, delegated: true }; - case 'unsupported': case 'ccs': - // Non-gemini CCS-owned refresh paths are not implemented yet. + return { + success: false, + error: `Token refresh not yet implemented for ${provider}`, + }; + case 'unsupported': return { success: false, error: `Token refresh not yet implemented for ${provider}`, @@ -87,23 +84,3 @@ export async function refreshToken( return assertNever(ownership); } } - -/** - * Wrapper for Gemini token refresh - * Converts gemini-token-refresh.ts format to provider-refreshers format - */ -async function refreshGeminiTokenWrapper(accountId: string): Promise { - const result = await refreshGeminiToken(accountId); - - if (!result.success) { - return { - success: false, - error: result.error, - }; - } - - return { - success: true, - expiresAt: result.expiresAt, - }; -} diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 721012c1..a41ae62b 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -14,6 +14,7 @@ import { getProviderAuthDir } from '../config-generator'; import { getProviderAccounts, getDefaultAccount } from '../account-manager'; import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops'; import { buildEmailBackedAccountId } from '../accounts/email-account-identity'; +import { getTokenRefreshOwnership } from '../provider-capabilities'; import { AuthStatus, PROVIDER_AUTH_PREFIXES, @@ -507,14 +508,16 @@ export function displayAuthStatus(): void { */ export async function ensureTokenValid( provider: CLIProxyProvider, - verbose = false + _verbose = false ): Promise<{ valid: boolean; refreshed: boolean; error?: string }> { - if (provider === 'gemini') { - const { ensureGeminiTokenValid } = await import('./gemini-token-refresh'); - return ensureGeminiTokenValid(verbose); + if (getTokenRefreshOwnership(provider) === 'ccs') { + return { + valid: false, + refreshed: false, + error: `CCS-managed token validation is not available for ${provider}`, + }; } - // For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus. - // CCS only verifies the token file exists (authentication state). + // Runtime-managed providers refresh upstream. CCS only verifies auth material exists locally. return { valid: isAuthenticated(provider), refreshed: false }; } diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index ebbe2773..bafd436a 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -33,7 +33,7 @@ export const PROVIDER_CAPABILITIES: Record; +} + +interface ManagedGeminiAuthLookupResult { + authIndex: string | number | null; + unavailable: boolean; +} + +interface ManagedGeminiRequestResult { + response: ManagedResponse | null; + unavailable: boolean; +} + +function getRemainingTimeoutMs(deadlineMs: number): number { + return Math.max(1, deadlineMs - Date.now()); +} + /** * Extract project ID from account field * Input: "user@example.com (cloudaicompanion-abc-123)" @@ -167,6 +208,244 @@ function isGeminiAuthFile(filename: string): boolean { return false; } +function safeParseJson(bodyText: string): unknown { + try { + return JSON.parse(bodyText); + } catch { + return null; + } +} + +async function readManagedResponse( + response: Response, + viaManagement: boolean +): Promise { + const bodyText = await response.text(); + return { + status: response.status, + bodyText, + json: safeParseJson(bodyText), + viaManagement, + }; +} + +function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean { + const rawProvider = normalizeStringValue(file.provider ?? file.type); + if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') { + return false; + } + + const email = normalizeStringValue(file.email); + const normalizedAccountId = accountId.trim().toLowerCase(); + if (email?.toLowerCase() === normalizedAccountId) { + return true; + } + + const normalizedName = normalizeStringValue(file.name); + if (!normalizedName) { + return false; + } + + const normalizedFileName = normalizedName.toLowerCase(); + const sanitizedAccount = sanitizeEmail(accountId).toLowerCase(); + return ( + normalizedFileName === `gemini-${sanitizedAccount}.json` || + normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) || + normalizedFileName.includes(sanitizedAccount) + ); +} + +async function findManagedGeminiAuthIndex( + accountId: string, + timeoutMs: number +): Promise { + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { + signal: controller.signal, + headers: buildManagementHeaders(target), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return { authIndex: null, unavailable: true }; + } + + const data = (await response.json()) as { files?: ManagementAuthFile[] }; + const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId)); + return { authIndex: match?.auth_index ?? null, unavailable: false }; + } catch { + clearTimeout(timeoutId); + return { authIndex: null, unavailable: true }; + } +} + +async function getManagedGeminiAuthIndex( + accountId: string, + timeoutMs: number, + context?: ManagedGeminiAuthContext +): Promise { + if (!context) { + return await findManagedGeminiAuthIndex(accountId, timeoutMs); + } + + context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs); + return await context.authIndexLookupPromise; +} + +class GeminiManagedAuthUnavailableError extends Error { + constructor() { + super('CLIProxy managed Gemini auth is temporarily unavailable'); + this.name = 'GeminiManagedAuthUnavailableError'; + } +} + +async function performManagedGeminiRequest( + accountId: string, + url: string, + body: string, + timeoutMs: number, + authContext?: ManagedGeminiAuthContext +): Promise { + const deadlineMs = Date.now() + timeoutMs; + const lookupResult = await getManagedGeminiAuthIndex( + accountId, + getRemainingTimeoutMs(deadlineMs), + authContext + ); + if (lookupResult.unavailable) { + return { response: null, unavailable: true }; + } + + const authIndex = lookupResult.authIndex; + if (authIndex === null || authIndex === undefined) { + return { response: null, unavailable: false }; + } + + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs)); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { + method: 'POST', + signal: controller.signal, + headers: buildManagementHeaders(target, { + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + auth_index: authIndex, + method: 'POST', + url, + header: { + Authorization: 'Bearer $TOKEN$', + 'Content-Type': 'application/json', + }, + data: body, + }), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return { response: null, unavailable: true }; + } + + const apiResponse = (await response.json()) as ManagementApiCallResponse; + const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : ''; + return { + response: { + status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, + bodyText, + json: safeParseJson(bodyText), + viaManagement: true, + }, + unavailable: false, + }; + } catch { + clearTimeout(timeoutId); + return { response: null, unavailable: true }; + } +} + +async function performGeminiCliRequest( + accountId: string, + accessToken: string, + url: string, + body: string, + preferManagement = false, + authContext?: ManagedGeminiAuthContext +): Promise { + let managementAttempted = false; + let managementUnavailable = false; + + if (preferManagement) { + managementAttempted = true; + const managedResult = await performManagedGeminiRequest( + accountId, + url, + body, + MANAGEMENT_API_TIMEOUT_MS, + authContext + ); + managementUnavailable = managedResult.unavailable; + if (managedResult.response) { + return managedResult.response; + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS + ); + + try { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body, + }); + clearTimeout(timeoutId); + + const directResult = await readManagedResponse(response, false); + if (directResult.status !== 401) { + return directResult; + } + + if (managementAttempted) { + if (managementUnavailable) { + throw new GeminiManagedAuthUnavailableError(); + } + return directResult; + } + + const managedResult = await performManagedGeminiRequest( + accountId, + url, + body, + SECONDARY_REQUEST_TIMEOUT_MS, + authContext + ); + if (managedResult.response) { + return managedResult.response; + } + if (managedResult.unavailable) { + throw new GeminiManagedAuthUnavailableError(); + } + return directResult; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + /** * Read auth data from Gemini CLI auth file * Supports multiple file naming conventions and JSON structures @@ -308,42 +587,43 @@ function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | nu } async function fetchGeminiCliSupplementary( + accountId: string, accessToken: string, projectId: string, - verbose: boolean + verbose: boolean, + authContext?: ManagedGeminiAuthContext ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const requestBody = JSON.stringify({ + cloudaicompanionProject: projectId, + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: projectId, + }, + }); try { - const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, { - method: 'POST', - signal: controller.signal, - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - cloudaicompanionProject: projectId, - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - duetProject: projectId, - }, - }), - }); + const response = await performGeminiCliRequest( + accountId, + accessToken, + GEMINI_CLI_CODE_ASSIST_URL, + requestBody, + false, + authContext + ); - clearTimeout(timeoutId); - - if (!response.ok) { + if (response.status !== 200) { if (verbose) { - console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`); + const source = response.viaManagement ? 'managed' : 'direct'; + console.error( + `[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}` + ); } return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } - const payload = (await response.json()) as GeminiCliCodeAssistResponse; + const payload = response.json as GeminiCliCodeAssistResponse | null; return { tierLabel: resolveGeminiCliTierLabel(payload), tierId: resolveGeminiCliTierId(payload), @@ -351,7 +631,6 @@ async function fetchGeminiCliSupplementary( normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)), }; } catch (error) { - clearTimeout(timeoutId); if (verbose) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`); @@ -680,41 +959,42 @@ async function fetchWithAuthData( }); } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); + const authContext: ManagedGeminiAuthContext = {}; const supplementaryPromise = fetchGeminiCliSupplementary( + accountId, authData.accessToken, authData.projectId, - verbose + verbose, + authContext ); + const requestBody = JSON.stringify({ project: authData.projectId }); try { - const response = await fetch(GEMINI_CLI_QUOTA_URL, { - method: 'POST', - signal: controller.signal, - headers: { - Authorization: `Bearer ${authData.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ project: authData.projectId }), - }); + const response = await performGeminiCliRequest( + accountId, + authData.accessToken, + GEMINI_CLI_QUOTA_URL, + requestBody, + authData.isExpired, + authContext + ); - clearTimeout(timeoutId); + if (verbose) { + const source = response.viaManagement ? 'managed' : 'direct'; + console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`); + } - if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`); - - if (!response.ok) { - const bodyText = await response.text(); + if (response.status !== 200) { return buildGeminiCliHttpFailureResult( accountId, authData.projectId, response.status, - bodyText + response.bodyText ); } - const data = (await response.json()) as GeminiCliQuotaResponse; - const rawBuckets = data.buckets || []; + const data = response.json as GeminiCliQuotaResponse | null; + const rawBuckets = data?.buckets || []; const buckets = buildGeminiCliBuckets(rawBuckets); const supplementary = await supplementaryPromise; @@ -744,7 +1024,16 @@ async function fetchWithAuthData( accountId, }; } catch (err) { - clearTimeout(timeoutId); + if (err instanceof GeminiManagedAuthUnavailableError) { + return buildGeminiCliFailureResult(accountId, authData.projectId, { + error: 'Gemini delegated auth refresh is temporarily unavailable', + errorCode: 'managed_auth_unavailable', + errorDetail: err.message, + actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.', + retryable: true, + }); + } + const errorMsg = err instanceof Error && err.name === 'AbortError' ? 'Request timeout' @@ -778,7 +1067,7 @@ export async function fetchGeminiCliQuota( ): Promise { if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`); - let authData = readGeminiCliAuthData(accountId); + const authData = readGeminiCliAuthData(accountId); if (!authData) { const error = 'Auth file not found for Gemini account'; if (verbose) console.error(`[!] Error: ${error}`); @@ -790,62 +1079,15 @@ export async function fetchGeminiCliQuota( }); } - // Proactive refresh: refresh if expired OR expiring within 5 minutes - const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; - const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); - const shouldRefresh = - authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; - let refreshedBeforeQuotaFetch = false; - - if (shouldRefresh) { - if (verbose) - console.error( - authData.isExpired - ? '[i] Token expired, refreshing...' - : '[i] Token expiring soon, proactive refresh...' - ); - const refreshResult = await refreshGeminiToken(accountId); - - if (refreshResult.success) { - refreshedBeforeQuotaFetch = true; - if (verbose) console.error('[i] Token refreshed successfully'); - // Re-read auth data after successful refresh - const refreshedAuthData = readGeminiCliAuthData(accountId); - if (refreshedAuthData) { - authData = refreshedAuthData; - } - } else if (authData.isExpired) { - // Only fail if token is actually expired (not just expiring soon) - const error = refreshResult.error || 'Token refresh failed'; - if (verbose) console.error(`[!] Refresh failed: ${error}`); - return buildGeminiCliFailureResult(accountId, authData.projectId, { - error, - errorCode: 'reauth_required', - errorDetail: error, - actionHint: 'Run ccs gemini --auth to reconnect this account.', - needsReauth: true, - retryable: false, - }); - } - // If proactive refresh fails but token isn't expired yet, continue with existing token + if (authData.isExpired && verbose) { + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); + const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown'; + console.error( + `[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.` + ); } - // First attempt with current token - const result = await fetchWithAuthData(authData, accountId, verbose); - - // Retry once with an account-scoped refresh when the quota endpoint rejects auth. - if (result.needsReauth && !refreshedBeforeQuotaFetch) { - if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(accountId); - if (refreshResult.success) { - const refreshedAuthData = readGeminiCliAuthData(accountId); - if (refreshedAuthData) { - return await fetchWithAuthData(refreshedAuthData, accountId, verbose); - } - } - } - - return result; + return await fetchWithAuthData(authData, accountId, verbose); } /** diff --git a/tests/unit/cliproxy/gemini-refresh-delegation.test.ts b/tests/unit/cliproxy/gemini-refresh-delegation.test.ts new file mode 100644 index 00000000..7a89a416 --- /dev/null +++ b/tests/unit/cliproxy/gemini-refresh-delegation.test.ts @@ -0,0 +1,72 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +describe('Gemini refresh delegation', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + let moduleVersion = 0; + + beforeEach(() => { + moduleVersion += 1; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-delegation-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + fs.rmSync(tempHome, { recursive: true, force: true }); + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalCcsDir === undefined) { + delete process.env.CCS_DIR; + } else { + process.env.CCS_DIR = originalCcsDir; + } + }); + + it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => { + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}` + ); + const { ensureTokenValid } = await import( + `../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}` + ); + + const authDir = getProviderAuthDir('gemini'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'gemini-delegated.json'), + JSON.stringify( + { + type: 'gemini', + email: 'delegated@example.com', + project_id: 'delegated-project', + token: { + access_token: 'expired-access-token', + refresh_token: 'still-present-refresh-token', + expiry: Date.now() - 60_000, + }, + }, + null, + 2 + ) + ); + + const result = await ensureTokenValid('gemini'); + + expect(result).toEqual({ + valid: true, + refreshed: false, + }); + }); +}); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index 1f574e26..7c462b6d 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -118,6 +118,7 @@ describe('provider-capabilities', () => { expect(getOAuthCallbackPort('cursor')).toBeNull(); expect(getOAuthCallbackPort('gitlab')).toBe(17171); expect(getOAuthCallbackPort('gemini')).toBe(8085); + expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy'); expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 86271f88..63c4672b 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -13,18 +13,16 @@ import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist'; - const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + const MANAGEMENT_AUTH_FILES_URL = 'http://127.0.0.1:8317/v0/management/auth-files'; + const MANAGEMENT_API_CALL_URL = 'http://127.0.0.1:8317/v0/management/api-call'; let tempHome: string; let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; - let originalGeminiClientId: string | undefined; - let originalGeminiClientSecret: string | undefined; let moduleVersion = 0; let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota; let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports; - let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { @@ -47,9 +45,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'access-token', refresh_token: 'refresh-token', expiry: Date.now() + 60 * 60 * 1000, - client_id: 'test-client-id', - client_secret: 'test-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, ...overrides, }); @@ -60,12 +55,7 @@ describe('Gemini CLI Quota Fetcher', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); originalCcsHome = process.env.CCS_HOME; originalCcsDir = process.env.CCS_DIR; - originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; process.env.CCS_HOME = tempHome; - - delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; delete process.env.CCS_DIR; const configGenerator = await import( @@ -79,9 +69,6 @@ describe('Gemini CLI Quota Fetcher', () => { } = await import( `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` )); - ({ refreshGeminiToken } = await import( - `../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}` - )); ({ getProviderAuthDir } = configGenerator); }); @@ -100,18 +87,6 @@ describe('Gemini CLI Quota Fetcher', () => { } else { process.env.CCS_DIR = originalCcsDir; } - - if (originalGeminiClientId === undefined) { - delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; - } else { - process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId; - } - - if (originalGeminiClientSecret === undefined) { - delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; - } else { - process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret; - } }); describe('resolveGeminiCliProjectId', () => { @@ -509,6 +484,12 @@ describe('Gemini CLI Quota Fetcher', () => { writeActiveGeminiAccount('reauth@example.com'); mockFetch([ + { + url: MANAGEMENT_AUTH_FILES_URL, + response: { + files: [], + }, + }, { url: GEMINI_QUOTA_URL, method: 'POST', @@ -520,14 +501,6 @@ describe('Gemini CLI Quota Fetcher', () => { }, }, }, - { - url: GOOGLE_TOKEN_URL, - method: 'POST', - status: 400, - response: { - error: 'invalid_grant', - }, - }, ]); const result = await fetchGeminiCliQuota('reauth@example.com'); @@ -667,7 +640,7 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.errorDetail).toBe('[HTML error response omitted]'); }); - it('refreshes the requested Gemini account instead of the default account', async () => { + it('uses the requested Gemini account when delegating auth recovery to CLIProxy management', async () => { writeGeminiToken( { type: 'gemini', @@ -677,9 +650,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'default-access-token', refresh_token: 'default-refresh-token', expiry: Date.now() + 60 * 60 * 1000, - client_id: 'default-client-id', - client_secret: 'default-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-default.json' @@ -694,9 +664,6 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'target-stale-token', refresh_token: 'target-refresh-token', expiry: Date.now() - 1000, - client_id: 'target-client-id', - client_secret: 'target-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-target.json' @@ -704,16 +671,37 @@ describe('Gemini CLI Quota Fetcher', () => { mockFetch([ { - url: GOOGLE_TOKEN_URL, + url: MANAGEMENT_AUTH_FILES_URL, + response: { + files: [ + { + auth_index: 'target-auth-index', + provider: 'gemini-cli', + email: 'target@example.com', + name: 'target@example.com-gen-lang-client-target-project.json', + }, + ], + }, + }, + { + url: MANAGEMENT_API_CALL_URL, method: 'POST', - response: { access_token: 'target-fresh-token', expires_in: 1800 }, + response: { + status_code: 200, + body: JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }), + }, }, { url: GEMINI_QUOTA_URL, method: 'POST', - status: 200, + status: 401, response: { - buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, }, }, { @@ -728,14 +716,15 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.success).toBe(true); - const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); - expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); - expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); - expect(refreshRequest.body).not.toContain('default-refresh-token'); - expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + const [, managedLookupRequest, managedQuotaRequest] = getCapturedFetchRequests(); + expect(managedLookupRequest.url).toBe(MANAGEMENT_AUTH_FILES_URL); + expect(managedQuotaRequest.url).toBe(MANAGEMENT_API_CALL_URL); + expect(managedQuotaRequest.body).toContain('"auth_index":"target-auth-index"'); + expect(managedQuotaRequest.body).toContain('"Authorization":"Bearer $TOKEN$"'); + expect(managedQuotaRequest.body).not.toContain('default-refresh-token'); }); - it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + it('retries a 401 quota failure through the management API instead of refreshing locally', async () => { writeGeminiToken( { type: 'gemini', @@ -745,20 +734,12 @@ describe('Gemini CLI Quota Fetcher', () => { access_token: 'retry-stale-token', refresh_token: 'retry-refresh-token', expiry: Date.now() + 60 * 1000, - client_id: 'retry-client-id', - client_secret: 'retry-client-secret', - token_uri: GOOGLE_TOKEN_URL, }, }, 'gemini-retry.json' ); mockFetch([ - { - url: GOOGLE_TOKEN_URL, - method: 'POST', - response: { access_token: 'unused-default', expires_in: 1800 }, - }, { url: GEMINI_QUOTA_URL, method: 'POST', @@ -776,49 +757,63 @@ describe('Gemini CLI Quota Fetcher', () => { ]); const originalFetch = globalThis.fetch; - let refreshAttempt = 0; let quotaAttempt = 0; + let managedLookupAttempt = 0; + let managedRequestAttempt = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; - if (url === GOOGLE_TOKEN_URL) { - refreshAttempt += 1; - return refreshAttempt === 1 - ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { - status: 503, - headers: { 'Content-Type': 'application/json' }, - }) - : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - } - if (url === GEMINI_QUOTA_URL) { quotaAttempt += 1; - return quotaAttempt === 1 - ? new Response( - JSON.stringify({ - error: { - message: 'Session expired', - status: 'UNAUTHENTICATED', - }, - }), + return new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url === MANAGEMENT_AUTH_FILES_URL) { + managedLookupAttempt += 1; + return new Response( + JSON.stringify({ + files: [ { - status: 401, - headers: { 'Content-Type': 'application/json' }, - } - ) - : new Response( - JSON.stringify({ - buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ); + auth_index: 'retry-auth-index', + provider: 'gemini-cli', + email: 'retry@example.com', + name: 'retry@example.com-gen-lang-client-retry-project.json', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url === MANAGEMENT_API_CALL_URL) { + managedRequestAttempt += 1; + return new Response( + JSON.stringify({ + status_code: 200, + body: JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); } return originalFetch(input, init); @@ -828,13 +823,85 @@ describe('Gemini CLI Quota Fetcher', () => { const result = await fetchGeminiCliQuota('retry@example.com'); expect(result.success).toBe(true); - expect(refreshAttempt).toBe(2); - expect(quotaAttempt).toBe(2); + expect(quotaAttempt).toBe(1); + expect(managedLookupAttempt).toBe(1); + expect(managedRequestAttempt).toBe(1); + } finally { + globalThis.fetch = originalFetch; + } + }); - const storedToken = JSON.parse( - fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') - ) as { token?: { access_token?: string } }; - expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + it('reports a retryable management failure instead of reauth when delegated auth is unavailable', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'managed-failure@example.com', + project_id: 'managed-failure-project', + token: { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expiry: Date.now() - 1000, + }, + }, + 'gemini-managed-failure.json' + ); + + mockFetch([ + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let directQuotaAttempt = 0; + let managedLookupAttempt = 0; + let managedRequestAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === MANAGEMENT_AUTH_FILES_URL) { + managedLookupAttempt += 1; + return new Response('lookup unavailable', { status: 503 }); + } + + if (url === MANAGEMENT_API_CALL_URL) { + managedRequestAttempt += 1; + return new Response('should not be called', { status: 500 }); + } + + if (url === GEMINI_QUOTA_URL) { + directQuotaAttempt += 1; + return new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('managed-failure@example.com'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBeUndefined(); + expect(result.retryable).toBe(true); + expect(result.errorCode).toBe('managed_auth_unavailable'); + expect(directQuotaAttempt).toBe(1); + expect(managedLookupAttempt).toBe(1); + expect(managedRequestAttempt).toBe(0); } finally { globalThis.fetch = originalFetch; } @@ -934,84 +1001,4 @@ describe('Gemini CLI Quota Fetcher', () => { }); }); - describe('refreshGeminiToken', () => { - it('uses OAuth client metadata stored in the token file', async () => { - writeGeminiToken({ - type: 'gemini', - email: 'file@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - client_id: 'file-client-id', - client_secret: 'file-client-secret', - token_uri: 'https://oauth2.googleapis.com/token', - }, - }); - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { access_token: 'fresh-token', expires_in: 1800 }, - }, - ]); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(true); - const [request] = getCapturedFetchRequests(); - expect(request.body).toContain('client_id=file-client-id'); - expect(request.body).toContain('client_secret=file-client-secret'); - expect(request.body).toContain('refresh_token=refresh-from-file'); - }); - - it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => { - process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id'; - process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret'; - - writeGeminiToken({ - type: 'gemini', - email: 'env@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - }, - }); - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { access_token: 'fresh-token', expires_in: 1800 }, - }, - ]); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(true); - const [request] = getCapturedFetchRequests(); - expect(request.body).toContain('client_id=env-client-id'); - expect(request.body).toContain('client_secret=env-client-secret'); - }); - - it('returns a clear error when no refresh client credentials are available', async () => { - writeGeminiToken({ - type: 'gemini', - email: 'missing@example.com', - token: { - access_token: 'old-token', - refresh_token: 'refresh-from-file', - expiry: Date.now() - 1000, - }, - }); - - const result = await refreshGeminiToken(); - - expect(result.success).toBe(false); - expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID'); - expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET'); - }); - }); }); diff --git a/tests/unit/cliproxy/routing-strategy.test.ts b/tests/unit/cliproxy/routing-strategy.test.ts index 9302cdac..fca87c39 100644 --- a/tests/unit/cliproxy/routing-strategy.test.ts +++ b/tests/unit/cliproxy/routing-strategy.test.ts @@ -5,8 +5,8 @@ import * as path from 'path'; describe('cliproxy routing strategy service', () => { let tempHome = ''; - let originalCcsHome: string | undefined; - let setGlobalConfigDir: (dir: string | undefined) => void; + let scopedConfigDir = ''; + let runWithScopedConfigDir: (ccsDir: string, fn: () => Promise | T) => Promise; let routingTarget = { host: '127.0.0.1', port: 8317, @@ -16,29 +16,24 @@ describe('cliproxy routing strategy service', () => { let responseFactory: (() => Promise) | null = null; beforeEach(async () => { - originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-routing-strategy-')); - process.env.CCS_HOME = tempHome; + scopedConfigDir = path.join(tempHome, '.ccs'); - ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); - setGlobalConfigDir(path.join(tempHome, '.ccs')); + ({ runWithScopedConfigDir } = await import('../../../src/utils/config-manager')); }); afterEach(() => { mock.restore(); - setGlobalConfigDir(undefined); - - if (originalCcsHome !== undefined) { - process.env.CCS_HOME = originalCcsHome; - } else { - delete process.env.CCS_HOME; - } if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + async function withScopedConfig(fn: () => Promise | T): Promise { + return await runWithScopedConfigDir(scopedConfigDir, fn); + } + async function loadRoutingModule() { mock.module('../../../src/cliproxy/routing-strategy-http', () => ({ getCliproxyRoutingTarget: () => routingTarget, @@ -58,70 +53,78 @@ describe('cliproxy routing strategy service', () => { } it('normalizes canonical and shorthand strategy values', async () => { - const mod = await loadRoutingModule(); + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); - expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin'); - expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin'); - expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first'); - expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first'); - expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull(); + expect(mod.normalizeCliproxyRoutingStrategy('round-robin')).toBe('round-robin'); + expect(mod.normalizeCliproxyRoutingStrategy('RR')).toBe('round-robin'); + expect(mod.normalizeCliproxyRoutingStrategy('fillfirst')).toBe('fill-first'); + expect(mod.normalizeCliproxyRoutingStrategy('ff')).toBe('fill-first'); + expect(mod.normalizeCliproxyRoutingStrategy('nope')).toBeNull(); + }); }); it('falls back to the saved local default when live CLIProxy is unavailable', async () => { - const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); - mutateUnifiedConfig((config) => { - if (config.cliproxy) { - config.cliproxy.routing = { strategy: 'fill-first' }; - } + await withScopedConfig(async () => { + const { mutateUnifiedConfig } = await import('../../../src/config/unified-config-loader'); + mutateUnifiedConfig((config) => { + if (config.cliproxy) { + config.cliproxy.routing = { strategy: 'fill-first' }; + } + }); + + const mod = await loadRoutingModule(); + const state = await mod.readCliproxyRoutingState(); + + expect(state.strategy).toBe('fill-first'); + expect(state.source).toBe('config'); + expect(state.target).toBe('local'); + expect(state.reachable).toBe(false); }); - - const mod = await loadRoutingModule(); - const state = await mod.readCliproxyRoutingState(); - - expect(state.strategy).toBe('fill-first'); - expect(state.source).toBe('config'); - expect(state.target).toBe('local'); - expect(state.reachable).toBe(false); }); it('persists the local startup default even when the live proxy is down', async () => { - const mod = await loadRoutingModule(); - const result = await mod.applyCliproxyRoutingStrategy('fill-first'); + await withScopedConfig(async () => { + const mod = await loadRoutingModule(); + const result = await mod.applyCliproxyRoutingStrategy('fill-first'); - expect(result.applied).toBe('config-only'); - expect(result.strategy).toBe('fill-first'); + expect(result.applied).toBe('config-only'); + expect(result.strategy).toBe('fill-first'); - const configPath = path.join(tempHome, '.ccs', 'cliproxy', 'config.yaml'); - const configContent = fs.readFileSync(configPath, 'utf8'); - expect(configContent).toContain('routing:'); - expect(configContent).toContain('strategy: fill-first'); + const configPath = path.join(scopedConfigDir, 'cliproxy', 'config.yaml'); + const configContent = fs.readFileSync(configPath, 'utf8'); + expect(configContent).toContain('routing:'); + expect(configContent).toContain('strategy: fill-first'); + }); }); it('reads and writes remote strategy without mutating the local default', async () => { - routingTarget = { - host: 'remote.example.com', - port: 8080, - protocol: 'http', - isRemote: true, - }; + await withScopedConfig(async () => { + routingTarget = { + host: 'remote.example.com', + port: 8080, + protocol: 'http', + isRemote: true, + }; - let methodCount = 0; - responseFactory = async () => { - methodCount += 1; - return new Response(JSON.stringify({ strategy: 'fill-first' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }; + let methodCount = 0; + responseFactory = async () => { + methodCount += 1; + return new Response(JSON.stringify({ strategy: 'fill-first' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; - const mod = await loadRoutingModule(); - const readState = await mod.readCliproxyRoutingState(); - const writeState = await mod.applyCliproxyRoutingStrategy('fill-first'); + const mod = await loadRoutingModule(); + const readState = await mod.readCliproxyRoutingState(); + const writeState = await mod.applyCliproxyRoutingStrategy('fill-first'); - expect(readState.strategy).toBe('fill-first'); - expect(readState.target).toBe('remote'); - expect(writeState.applied).toBe('live'); - expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin'); - expect(methodCount).toBe(2); + expect(readState.strategy).toBe('fill-first'); + expect(readState.target).toBe('remote'); + expect(writeState.applied).toBe('live'); + expect(mod.getConfiguredCliproxyRoutingStrategy()).toBe('round-robin'); + expect(methodCount).toBe(2); + }); }); });