feat(cloud-oauth): support selectable google oauth clients

This commit is contained in:
Draculabo committed 2026-04-11 16:45:39 +08:00
1 parent 385036d577
commit bcdf2a6c24
8 files changed
+1370 -125

No files matched your search

+92 -6
View File
@@ -8,6 +8,8 @@ import {
useSetAutoSwitchEnabled, useSetAutoSwitchEnabled,
useForcePollCloudMonitor, useForcePollCloudMonitor,
useSyncLocalAccount, useSyncLocalAccount,
useOAuthClients,
useSetActiveOAuthClient,
startAuthFlow, startAuthFlow,
useExportCloudAccounts, useExportCloudAccounts,
useImportCloudAccounts, useImportCloudAccounts,
@@ -77,6 +79,7 @@ import {
getAccountSortValue, getAccountSortValue,
type QuotaStatus, type QuotaStatus,
} from '@/utils/quota-display'; } from '@/utils/quota-display';
import { shouldAutoSubmitGoogleAuthCode } from '@/utils/googleAuthSubmission';
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact'; export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
@@ -113,9 +116,12 @@ export function CloudAccountList() {
const { data: autoSwitchEnabled, isLoading: isSettingsLoading } = useAutoSwitchEnabled(); const { data: autoSwitchEnabled, isLoading: isSettingsLoading } = useAutoSwitchEnabled();
const setAutoSwitchMutation = useSetAutoSwitchEnabled(); const setAutoSwitchMutation = useSetAutoSwitchEnabled();
const forcePollMutation = useForcePollCloudMonitor(); const forcePollMutation = useForcePollCloudMonitor();
const { data: oauthClients = [], isLoading: isOAuthClientsLoading } = useOAuthClients();
const setActiveOAuthClientMutation = useSetActiveOAuthClient();
const { toast } = useToast(); const { toast } = useToast();
const lastLoadErrorToastAtRef = useRef<number>(0); const lastLoadErrorToastAtRef = useRef<number>(0);
const lastSubmittedAuthCodeRef = useRef<string | null>(null);
const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto'; const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto';
@@ -195,6 +201,7 @@ export function CloudAccountList() {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [authCode, setAuthCode] = useState(''); const [authCode, setAuthCode] = useState('');
const [selectedOAuthClientKey, setSelectedOAuthClientKey] = useState('');
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null); const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
@@ -219,12 +226,18 @@ export function CloudAccountList() {
if (!codeToUse) { if (!codeToUse) {
return; return;
} }
lastSubmittedAuthCodeRef.current = codeToUse;
addMutation.mutate( addMutation.mutate(
{ authCode: codeToUse }, {
authCode: codeToUse,
oauthClientKey:
selectedOAuthClientKey || oauthClients.find((client) => client.is_active)?.key,
},
{ {
onSuccess: () => { onSuccess: () => {
setIsAddDialogOpen(false); setIsAddDialogOpen(false);
setAuthCode(''); setAuthCode('');
lastSubmittedAuthCodeRef.current = null;
toast({ title: t('cloud.toast.addSuccess') }); toast({ title: t('cloud.toast.addSuccess') });
}, },
onError: (err) => { onError: (err) => {
@@ -237,14 +250,25 @@ export function CloudAccountList() {
}, },
); );
}, },
[addMutation, authCode, t, toast], [addMutation, authCode, oauthClients, selectedOAuthClientKey, t, toast],
); );
useEffect(() => {
if (selectedOAuthClientKey !== '') {
return;
}
const activeClientKey = oauthClients.find((client) => client.is_active)?.key;
if (activeClientKey) {
setSelectedOAuthClientKey(activeClientKey);
}
}, [oauthClients, selectedOAuthClientKey]);
// Listen for Google Auth Code // Listen for Google Auth Code
useEffect(() => { useEffect(() => {
if (window.electron?.onGoogleAuthCode) { if (window.electron?.onGoogleAuthCode) {
console.log('[OAuth] Registering Google auth code IPC listener'); console.log('[OAuth] Registering Google auth code IPC listener');
const cleanup = window.electron.onGoogleAuthCode((code) => { const cleanup = window.electron.onGoogleAuthCode((code) => {
console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...'); console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
lastSubmittedAuthCodeRef.current = null;
setAuthCode(code); setAuthCode(code);
}); });
return cleanup; return cleanup;
@@ -253,7 +277,14 @@ export function CloudAccountList() {
// Auto-submit when authCode is set and dialog is open // Auto-submit when authCode is set and dialog is open
useEffect(() => { useEffect(() => {
if (authCode && isAddDialogOpen && !addMutation.isPending) { if (
shouldAutoSubmitGoogleAuthCode({
authCode,
isAddDialogOpen,
isPending: addMutation.isPending,
lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current,
})
) {
console.log('[OAuth] Auto-submitting Google auth code'); console.log('[OAuth] Auto-submitting Google auth code');
submitAuthCode(authCode); submitAuthCode(authCode);
} }
@@ -399,7 +430,16 @@ export function CloudAccountList() {
const openGoogleAuthSignIn = async () => { const openGoogleAuthSignIn = async () => {
try { try {
await startAuthFlow(); lastSubmittedAuthCodeRef.current = null;
const effectiveClientKey =
selectedOAuthClientKey || oauthClients.find((client) => client.is_active)?.key;
await startAuthFlow(
effectiveClientKey
? {
oauthClientKey: effectiveClientKey,
}
: undefined,
);
} catch (e) { } catch (e) {
toast({ toast({
title: t('cloud.toast.startAuthFailed'), title: t('cloud.toast.startAuthFailed'),
@@ -833,7 +873,16 @@ export function CloudAccountList() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <Dialog
open={isAddDialogOpen}
onOpenChange={(open) => {
setIsAddDialogOpen(open);
if (!open) {
setAuthCode('');
lastSubmittedAuthCodeRef.current = null;
}
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="cursor-pointer"> <Button className="cursor-pointer">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
@@ -846,6 +895,41 @@ export function CloudAccountList() {
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription> <DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="oauth-client-select">{t('cloud.authDialog.oauthClient')}</Label>
<Select
value={selectedOAuthClientKey || undefined}
onValueChange={(value) => {
setSelectedOAuthClientKey(value);
setActiveOAuthClientMutation.mutate(
{
clientKey: value,
},
{
onError: (error) => {
toast({
title: t('cloud.toast.updateSettingsFailed'),
description: getLocalizedErrorMessage(error, t),
variant: 'destructive',
});
},
},
);
}}
disabled={isOAuthClientsLoading || setActiveOAuthClientMutation.isPending}
>
<SelectTrigger id="oauth-client-select">
<SelectValue placeholder={t('cloud.authDialog.oauthClientPlaceholder')} />
</SelectTrigger>
<SelectContent>
{oauthClients.map((client) => (
<SelectItem key={client.key} value={client.key}>
{client.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Button variant="outline" className="col-span-4" onClick={openGoogleAuthSignIn}> <Button variant="outline" className="col-span-4" onClick={openGoogleAuthSignIn}>
<Cloud className="mr-2 h-4 w-4" /> <Cloud className="mr-2 h-4 w-4" />
@@ -858,7 +942,9 @@ export function CloudAccountList() {
id="code" id="code"
placeholder={t('cloud.authDialog.placeholder')} placeholder={t('cloud.authDialog.placeholder')}
value={authCode} value={authCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAuthCode(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setAuthCode(e.target.value);
}}
/> />
<p className="text-muted-foreground text-xs">{t('cloud.authDialog.instruction')}</p> <p className="text-muted-foreground text-xs">{t('cloud.authDialog.instruction')}</p>
</div> </div>
+22
View File
@@ -5,11 +5,15 @@ import {
deleteCloudAccount, deleteCloudAccount,
refreshAccountQuota, refreshAccountQuota,
setAccountProxy, setAccountProxy,
listOAuthClients,
setActiveOAuthClient,
type OAuthClientDescriptor,
} from '@/actions/cloud'; } from '@/actions/cloud';
import { CloudAccount } from '@/types/cloudAccount'; import { CloudAccount } from '@/types/cloudAccount';
export const QUERY_KEYS = { export const QUERY_KEYS = {
cloudAccounts: ['cloudAccounts'], cloudAccounts: ['cloudAccounts'],
oauthClients: ['oauthClients'],
}; };
export function useCloudAccounts() { export function useCloudAccounts() {
@@ -31,6 +35,24 @@ export function useAddGoogleAccount() {
}); });
} }
export function useOAuthClients() {
return useQuery<OAuthClientDescriptor[]>({
queryKey: QUERY_KEYS.oauthClients,
queryFn: listOAuthClients,
staleTime: 1000 * 60 * 5,
});
}
export function useSetActiveOAuthClient() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: setActiveOAuthClient,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.oauthClients });
},
});
}
export function useDeleteCloudAccount() { export function useDeleteCloudAccount() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
+288 -22
View File
@@ -1,9 +1,8 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { CloudAccountRepo } from '../../ipc/database/cloudHandler'; import { CloudAccountRepo } from '../../ipc/database/cloudHandler';
import { GoogleAPIService } from '../../services/GoogleAPIService'; import { GoogleAPIService, type OAuthClientDescriptor } from '../../services/GoogleAPIService';
import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount'; import { CloudAccount, CloudAccountExportSchema } from '../../types/cloudAccount';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { AuthServer } from './authServer';
import { shell } from 'electron'; import { shell } from 'electron';
import fs from 'fs'; import fs from 'fs';
@@ -21,16 +20,7 @@ import { getAntigravityDbPaths } from '../../utils/paths';
import { runWithSwitchGuard } from '../../ipc/switchGuard'; import { runWithSwitchGuard } from '../../ipc/switchGuard';
import { executeSwitchFlow } from '../../ipc/switchFlow'; import { executeSwitchFlow } from '../../ipc/switchFlow';
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account'; import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
import { classifyAccountStatusFromError, extractErrorMessage } from '../../utils/account-status';
// Fallback constants if service constants are not available or for direct usage
const CLIENT_ID = '1071006060591-tmhssin21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const SCOPE = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs',
].join(' ');
// Helper to update tray // Helper to update tray
function notifyTrayUpdate(account: CloudAccount) { function notifyTrayUpdate(account: CloudAccount) {
@@ -44,10 +34,212 @@ function notifyTrayUpdate(account: CloudAccount) {
} }
} }
export async function addGoogleAccount(authCode: string): Promise<CloudAccount> { const ACTIVE_OAUTH_CLIENT_KEY_SETTING = 'active_oauth_client_key';
const OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING = 'oauth_client_key_backfill_v1_done';
const ENTERPRISE_OAUTH_CLIENT_KEY = 'antigravity_enterprise';
function isEnterpriseClient(clientKey?: string): boolean {
if (!clientKey) {
return false;
}
return clientKey.trim().toLowerCase() === ENTERPRISE_OAUTH_CLIENT_KEY;
}
function normalizeProjectId(projectId?: string): string | null {
if (typeof projectId !== 'string') {
return null;
}
const normalized = projectId.trim();
return normalized === '' ? null : normalized;
}
function recoverCachedQuotaOnRateLimit(account: CloudAccount, error: unknown): CloudAccount['quota'] | null {
const classified = classifyAccountStatusFromError(error);
if (!classified || classified.status !== 'rate_limited') {
return null;
}
if (!account.quota || !account.quota.models || Object.keys(account.quota.models).length === 0) {
return null;
}
return account.quota;
}
function formatSwitchRefreshError(reason: string): string {
const normalized = reason.toLowerCase();
if (
normalized.includes('unauthorized_client') ||
normalized.includes('invalid_client') ||
normalized.includes('invalid_grant')
) {
return `Token refresh failed: OAuth client is not authorized for this account. Please re-login and complete authorization. Raw error: ${reason}`;
}
if (
normalized.includes('verify your account') ||
normalized.includes('further action is required') ||
normalized.includes('validation required') ||
normalized.includes('validation_url') ||
normalized.includes('appeal_url')
) {
return `Token refresh failed: account requires additional verification. Please finish verification and retry. Raw error: ${reason}`;
}
if (
normalized.includes('resource_exhausted') ||
normalized.includes('resource has been exhausted')
) {
return `Token refresh failed: account is rate-limited or temporarily restricted (RESOURCE_EXHAUSTED). Please retry later. Raw error: ${reason}`;
}
return `Token refresh failed: ${reason}`;
}
async function ensureEnterpriseProjectReady(account: CloudAccount): Promise<void> {
if (!isEnterpriseClient(account.token.oauth_client_key)) {
return;
}
if (normalizeProjectId(account.token.project_id)) {
return;
}
logger.warn(
`[OAuth] Account ${account.email} is using enterprise OAuth client but missing project_id. Resolving before switch...`,
);
let resolvedProjectId: string | null = null;
try { try {
resolvedProjectId = await GoogleAPIService.fetchProjectId(
account.token.access_token,
account.proxy_url,
);
} catch (error) {
throw new Error(
`Account ${account.email} cannot be switched safely: missing enterprise project_id and auto-resolve failed (${extractErrorMessage(
error,
)}). Please re-login with a non-enterprise OAuth client or a project-enabled token.`,
);
}
const normalizedProjectId = normalizeProjectId(resolvedProjectId ?? undefined);
if (!normalizedProjectId) {
throw new Error(
`Account ${account.email} cannot be switched safely: enterprise OAuth requires a valid project_id.`,
);
}
account.token.project_id = normalizedProjectId;
await CloudAccountRepo.updateToken(account.id, account.token);
}
async function markAccountStatusFromError(account: CloudAccount, error: unknown): Promise<void> {
const classified = classifyAccountStatusFromError(error);
if (!classified) {
return;
}
account.status = classified.status;
account.status_reason = classified.reason;
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
}
async function clearAccountStatus(account: CloudAccount): Promise<void> {
account.status = 'active';
account.status_reason = undefined;
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
}
function hydrateActiveOAuthClientFromSettings(): void {
const preferredClientKey = CloudAccountRepo.getSetting<string>(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
if (preferredClientKey && preferredClientKey.trim() !== '') {
try {
GoogleAPIService.setActiveOAuthClientKey(preferredClientKey);
} catch (error) {
logger.warn(
`[OAuth] Stored active OAuth client '${preferredClientKey}' is invalid, falling back to default`,
error,
);
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, '');
}
}
}
async function backfillMissingOAuthClientKeyForLegacyAccounts(
accounts: CloudAccount[],
): Promise<boolean> {
const backfillDone = CloudAccountRepo.getSetting<boolean>(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, false);
if (backfillDone) {
return false;
}
hydrateActiveOAuthClientFromSettings();
const activeClientKey = GoogleAPIService.getActiveOAuthClientKey().trim().toLowerCase();
if (activeClientKey === '') {
return false;
}
let updatedCount = 0;
let skippedEnterpriseGuardCount = 0;
let hasFailure = false;
for (const account of accounts) {
if (account.provider !== 'google') {
continue;
}
const currentClientKey = account.token.oauth_client_key?.trim();
if (currentClientKey) {
continue;
}
const refreshToken = account.token.refresh_token?.trim();
if (!refreshToken) {
continue;
}
const projectMissing =
typeof account.token.project_id !== 'string' || account.token.project_id.trim() === '';
if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) {
skippedEnterpriseGuardCount += 1;
continue;
}
try {
await CloudAccountRepo.updateToken(account.id, {
...account.token,
oauth_client_key: activeClientKey,
});
updatedCount += 1;
} catch (error) {
hasFailure = true;
logger.warn(
`[OAuth] Failed to backfill oauth_client_key for account ${account.email} (${account.id})`,
error,
);
}
}
if (!hasFailure) {
CloudAccountRepo.setSetting(OAUTH_CLIENT_KEY_BACKFILL_DONE_SETTING, true);
}
logger.info(
`[OAuth] Backfill oauth_client_key completed: updated=${updatedCount}, skipped_enterprise_guard=${skippedEnterpriseGuardCount}, has_failure=${hasFailure}`,
);
return updatedCount > 0;
}
export async function addGoogleAccount(
authCode: string,
oauthClientKey?: string,
): Promise<CloudAccount> {
try {
if (oauthClientKey && oauthClientKey.trim() !== '') {
setActiveOAuthClient(oauthClientKey);
} else {
hydrateActiveOAuthClientFromSettings();
}
// 1. Exchange code for tokens // 1. Exchange code for tokens
const tokenResp = await GoogleAPIService.exchangeCode(authCode); const tokenResp = await GoogleAPIService.exchangeCode(authCode, undefined, oauthClientKey);
// 2. Get User Info // 2. Get User Info
const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token); const userInfo = await GoogleAPIService.getUserInfo(tokenResp.access_token);
@@ -73,6 +265,8 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
expiry_timestamp: now + tokenResp.expires_in, expiry_timestamp: now + tokenResp.expires_in,
token_type: tokenResp.token_type, token_type: tokenResp.token_type,
email: userInfo.email, email: userInfo.email,
oauth_client_key: tokenResp.oauth_client_key,
is_gcp_tos: true,
}, },
created_at: now, created_at: now,
last_used: now, last_used: now,
@@ -111,7 +305,12 @@ export async function addGoogleAccount(authCode: string): Promise<CloudAccount>
} }
export async function listCloudAccounts(): Promise<CloudAccount[]> { export async function listCloudAccounts(): Promise<CloudAccount[]> {
return CloudAccountRepo.getAccounts(); const accounts = await CloudAccountRepo.getAccounts();
const backfilled = await backfillMissingOAuthClientKeyForLegacyAccounts(accounts);
if (backfilled) {
return CloudAccountRepo.getAccounts();
}
return accounts;
} }
export async function deleteCloudAccount(accountId: string): Promise<void> { export async function deleteCloudAccount(accountId: string): Promise<void> {
@@ -131,15 +330,21 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
const newTokenData = await GoogleAPIService.refreshAccessToken( const newTokenData = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token, account.token.refresh_token,
account.proxy_url, account.proxy_url,
account.token.oauth_client_key,
); );
account.token.access_token = newTokenData.access_token; account.token.access_token = newTokenData.access_token;
account.token.expires_in = newTokenData.expires_in; account.token.expires_in = newTokenData.expires_in;
account.token.expiry_timestamp = now + newTokenData.expires_in; account.token.expiry_timestamp = now + newTokenData.expires_in;
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
account.token,
newTokenData.oauth_client_key,
);
await CloudAccountRepo.updateToken(account.id, account.token); await CloudAccountRepo.updateToken(account.id, account.token);
} catch (e) { } catch (e) {
logger.error(`Failed to refresh token during time-check for ${account.email}`, e); logger.error(`Failed to refresh token during time-check for ${account.email}`, e);
await markAccountStatusFromError(account, e);
throw new Error(`Token refresh failed for ${account.email}. Please try logging in again.`); throw new Error(`Token refresh failed for ${account.email}. Please try logging in again.`);
} }
} }
@@ -172,6 +377,7 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
await CloudAccountRepo.updateQuota(account.id, account.quota); await CloudAccountRepo.updateQuota(account.id, account.quota);
await CloudAccountRepo.updateLastUsed(account.id); await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); account.last_used = Math.floor(Date.now() / 1000);
await clearAccountStatus(account);
notifyTrayUpdate(account); notifyTrayUpdate(account);
return account; return account;
} catch (error: any) { } catch (error: any) {
@@ -181,12 +387,17 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
const newTokenData = await GoogleAPIService.refreshAccessToken( const newTokenData = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token, account.token.refresh_token,
account.proxy_url, account.proxy_url,
account.token.oauth_client_key,
); );
now = Math.floor(Date.now() / 1000); now = Math.floor(Date.now() / 1000);
account.token.access_token = newTokenData.access_token; account.token.access_token = newTokenData.access_token;
account.token.expires_in = newTokenData.expires_in; account.token.expires_in = newTokenData.expires_in;
account.token.expiry_timestamp = now + newTokenData.expires_in; account.token.expiry_timestamp = now + newTokenData.expires_in;
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
account.token,
newTokenData.oauth_client_key,
);
await CloudAccountRepo.updateToken(account.id, account.token); await CloudAccountRepo.updateToken(account.id, account.token);
@@ -220,21 +431,42 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
await CloudAccountRepo.updateQuota(account.id, account.quota); await CloudAccountRepo.updateQuota(account.id, account.quota);
await CloudAccountRepo.updateLastUsed(account.id); await CloudAccountRepo.updateLastUsed(account.id);
account.last_used = Math.floor(Date.now() / 1000); account.last_used = Math.floor(Date.now() / 1000);
await clearAccountStatus(account);
return account; return account;
} catch (refreshError) { } catch (refreshError) {
logger.error( logger.error(
`Failed to force refresh token or retry quota for ${account.email}`, `Failed to force refresh token or retry quota for ${account.email}`,
refreshError, refreshError,
); );
const cachedQuota = recoverCachedQuotaOnRateLimit(account, refreshError);
if (cachedQuota) {
logger.warn(
`[OAuth] Quota request is rate-limited for ${account.email}; reusing cached quota as fallback.`,
);
await markAccountStatusFromError(account, refreshError);
return account;
}
await markAccountStatusFromError(account, refreshError);
throw refreshError; throw refreshError;
} }
} else if (error.message === 'FORBIDDEN') { } else if (error.message === 'FORBIDDEN') {
logger.warn( logger.warn(
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`, `Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
); );
await markAccountStatusFromError(account, error);
return account; return account;
} }
const cachedQuota = recoverCachedQuotaOnRateLimit(account, error);
if (cachedQuota) {
logger.warn(
`[OAuth] Quota request is rate-limited for ${account.email}; reusing cached quota as fallback.`,
);
await markAccountStatusFromError(account, error);
return account;
}
await markAccountStatusFromError(account, error);
logger.error(`Failed to refresh quota for ${account.email}`, error); logger.error(`Failed to refresh quota for ${account.email}`, error);
throw error; throw error;
} }
@@ -267,6 +499,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
const newTokenData = await GoogleAPIService.refreshAccessToken( const newTokenData = await GoogleAPIService.refreshAccessToken(
account.token.refresh_token, account.token.refresh_token,
account.proxy_url, account.proxy_url,
account.token.oauth_client_key,
); );
const updatedToken = { const updatedToken = {
@@ -274,26 +507,33 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
access_token: newTokenData.access_token, access_token: newTokenData.access_token,
expires_in: newTokenData.expires_in, expires_in: newTokenData.expires_in,
expiry_timestamp: now + newTokenData.expires_in, expiry_timestamp: now + newTokenData.expires_in,
oauth_client_key: GoogleAPIService.normalizeRefreshedOAuthClientKey(
account.token,
newTokenData.oauth_client_key,
),
}; };
await CloudAccountRepo.updateToken(account.id, updatedToken); await CloudAccountRepo.updateToken(account.id, updatedToken);
account.token = updatedToken; account.token = updatedToken;
logger.info(`Token refreshed for ${account.email}`); logger.info(`Token refreshed for ${account.email}`);
} catch (e) { } catch (e) {
logger.warn('Failed to refresh token in parallel, will try to use existing', e); logger.warn('Failed to refresh token in parallel', e);
await markAccountStatusFromError(account, e);
const reason = extractErrorMessage(e);
throw new Error(formatSwitchRefreshError(reason));
} }
} }
})(); })();
await tokenRefreshPromise;
await ensureEnterpriseProjectReady(account);
await executeSwitchFlow({ await executeSwitchFlow({
scope: 'cloud', scope: 'cloud',
targetProfile: account.device_profile || null, targetProfile: account.device_profile || null,
applyFingerprint: isIdentityProfileApplyEnabled(), applyFingerprint: isIdentityProfileApplyEnabled(),
processExitTimeoutMs: 10000, processExitTimeoutMs: 10000,
performSwitch: async () => { performSwitch: async () => {
// Wait for token refresh to complete before injection if it was started
await tokenRefreshPromise;
// 3. Backup Database (Optimized to avoid race conditions) // 3. Backup Database (Optimized to avoid race conditions)
const dbPaths = getAntigravityDbPaths(); const dbPaths = getAntigravityDbPaths();
for (const dbPath of dbPaths) { for (const dbPath of dbPaths) {
@@ -315,6 +555,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
// 5. Update usage and active status // 5. Update usage and active status
CloudAccountRepo.updateLastUsed(account.id); CloudAccountRepo.updateLastUsed(account.id);
CloudAccountRepo.setActive(account.id); CloudAccountRepo.setActive(account.id);
await clearAccountStatus(account);
logger.info(`Successfully switched to cloud account: ${account.email}`); logger.info(`Successfully switched to cloud account: ${account.email}`);
notifyTrayUpdate(account); notifyTrayUpdate(account);
@@ -442,13 +683,33 @@ export async function forcePollCloudMonitor(): Promise<void> {
await CloudMonitorService.poll(); await CloudMonitorService.poll();
} }
export async function startAuthFlow(): Promise<void> { export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${CLIENT_ID}&redirect_uri=${AuthServer.getRedirectUri()}&response_type=code&scope=${SCOPE}&access_type=offline&prompt=consent&include_granted_scopes=true`; if (oauthClientKey && oauthClientKey.trim() !== '') {
setActiveOAuthClient(oauthClientKey);
} else {
hydrateActiveOAuthClientFromSettings();
}
const url = GoogleAPIService.getAuthUrl(oauthClientKey);
logger.info(`Starting auth flow, opening URL: ${url}`); logger.info(`Starting auth flow, opening URL: ${url}`);
await shell.openExternal(url); await shell.openExternal(url);
} }
export function listOAuthClients(): OAuthClientDescriptor[] {
hydrateActiveOAuthClientFromSettings();
return GoogleAPIService.listOAuthClients();
}
export function getActiveOAuthClient(): string {
hydrateActiveOAuthClientFromSettings();
return GoogleAPIService.getActiveOAuthClientKey();
}
export function setActiveOAuthClient(clientKey: string): void {
GoogleAPIService.setActiveOAuthClientKey(clientKey);
CloudAccountRepo.setSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, GoogleAPIService.getActiveOAuthClientKey());
}
export async function exportCloudAccounts(stripTokens = false): Promise<string> { export async function exportCloudAccounts(stripTokens = false): Promise<string> {
const accounts = await CloudAccountRepo.getAccounts(); const accounts = await CloudAccountRepo.getAccounts();
const exportData = { const exportData = {
@@ -464,6 +725,8 @@ export async function exportCloudAccounts(stripTokens = false): Promise<string>
device_profile: acc.device_profile, device_profile: acc.device_profile,
device_history: acc.device_history, device_history: acc.device_history,
proxy_url: acc.proxy_url ?? null, proxy_url: acc.proxy_url ?? null,
status: acc.status,
status_reason: acc.status_reason,
})), })),
}; };
@@ -526,6 +789,8 @@ export async function importCloudAccounts(
device_profile: acc.device_profile ?? existing.device_profile, device_profile: acc.device_profile ?? existing.device_profile,
device_history: acc.device_history ?? existing.device_history, device_history: acc.device_history ?? existing.device_history,
proxy_url: acc.proxy_url ?? existing.proxy_url, proxy_url: acc.proxy_url ?? existing.proxy_url,
status: acc.status ?? existing.status,
status_reason: acc.status_reason ?? existing.status_reason,
last_used: now, last_used: now,
}; };
@@ -545,7 +810,8 @@ export async function importCloudAccounts(
proxy_url: acc.proxy_url ?? undefined, proxy_url: acc.proxy_url ?? undefined,
created_at: now, created_at: now,
last_used: now, last_used: now,
status: 'active', status: acc.status ?? 'active',
status_reason: acc.status_reason,
is_active: false, is_active: false,
}; };
+73 -6
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { os } from '@orpc/server'; import { os, ORPCError } from '@orpc/server';
import { import {
addGoogleAccount, addGoogleAccount,
bindCloudIdentityProfile, bindCloudIdentityProfile,
@@ -18,6 +18,9 @@ import {
setAutoSwitchEnabled, setAutoSwitchEnabled,
forcePollCloudMonitor, forcePollCloudMonitor,
startAuthFlow, startAuthFlow,
listOAuthClients,
getActiveOAuthClient,
setActiveOAuthClient,
exportCloudAccounts, exportCloudAccounts,
importCloudAccounts, importCloudAccounts,
} from './handler'; } from './handler';
@@ -67,12 +70,44 @@ const switchStatusSnapshotSchema = z.object({
}), }),
}); });
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
export function toSyncLocalAccountORPCError(error: unknown): ORPCError<string, undefined> {
const message = extractErrorMessage(error);
const normalized = message.toLowerCase();
if (
normalized.includes('unauthenticated') ||
normalized.includes('unauthorized') ||
normalized.includes('token may be expired') ||
normalized.includes('re-login in antigravity ide')
) {
return new ORPCError('UNAUTHORIZED', { message });
}
if (
normalized.includes('no cloud account found in ide') ||
normalized.includes('no oauth token found in ide state') ||
normalized.includes('antigravity database not found')
) {
return new ORPCError('BAD_REQUEST', { message });
}
return new ORPCError('INTERNAL_SERVER_ERROR', { message });
}
export const cloudRouter = os.router({ export const cloudRouter = os.router({
addGoogleAccount: os addGoogleAccount: os
.input(z.object({ authCode: z.string() })) .input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
.output(CloudAccountSchema) .output(CloudAccountSchema)
.handler(async ({ input }) => { .handler(async ({ input }) => {
return addGoogleAccount(input.authCode); return addGoogleAccount(input.authCode, input.oauthClientKey);
}), }),
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => { listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
@@ -115,10 +150,42 @@ export const cloudRouter = os.router({
await forcePollCloudMonitor(); await forcePollCloudMonitor();
}), }),
startAuthFlow: os.output(z.void()).handler(async () => { startAuthFlow: os
await startAuthFlow(); .input(z.object({ oauthClientKey: z.string().optional() }).optional())
.output(z.void())
.handler(async ({ input }) => {
await startAuthFlow(input?.oauthClientKey);
}),
listOAuthClients: os
.output(
z.array(
z.object({
key: z.string(),
label: z.string(),
client_id: z.string(),
is_active: z.boolean(),
is_builtin: z.boolean(),
}),
),
)
.handler(async () => {
return listOAuthClients();
}),
getActiveOAuthClient: os.output(z.object({ client_key: z.string() })).handler(async () => {
return {
client_key: getActiveOAuthClient(),
};
}), }),
setActiveOAuthClient: os
.input(z.object({ clientKey: z.string() }))
.output(z.void())
.handler(async ({ input }) => {
setActiveOAuthClient(input.clientKey);
}),
setAccountProxy: os setAccountProxy: os
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() })) .input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
.output(z.void()) .output(z.void())
@@ -138,7 +205,7 @@ export const cloudRouter = os.router({
return result; return result;
} catch (error: any) { } catch (error: any) {
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack); logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
throw error; throw toSyncLocalAccountORPCError(error);
} }
}), }),
+15
View File
@@ -271,6 +271,8 @@ i18n
authDialog: { authDialog: {
title: 'Add Google Account', title: 'Add Google Account',
description: 'To add an account, you need to authorize the application.', description: 'To add an account, you need to authorize the application.',
oauthClient: 'OAuth Client',
oauthClientPlaceholder: 'Select OAuth client',
openLogin: 'Open Login Page', openLogin: 'Open Login Page',
authCode: 'Authorization Code', authCode: 'Authorization Code',
placeholder: 'Paste the code starting with 4/...', placeholder: 'Paste the code starting with 4/...',
@@ -282,6 +284,9 @@ i18n
active: 'Active', active: 'Active',
use: 'Use', use: 'Use',
rateLimited: 'Rate Limited', rateLimited: 'Rate Limited',
validationRiskControlled: 'Risk / Rate Limited',
validationOAuthReauthRequired: 'OAuth Re-auth Required',
validationRequired: 'Verification Required',
left: 'left', left: 'left',
used: 'Used', used: 'Used',
unknown: 'Unknown User', unknown: 'Unknown User',
@@ -697,6 +702,8 @@ i18n
authDialog: { authDialog: {
title: '添加 Google 账号', title: '添加 Google 账号',
description: '添加账号需要进行应用授权。', description: '添加账号需要进行应用授权。',
oauthClient: 'OAuth 客户端',
oauthClientPlaceholder: '请选择 OAuth 客户端',
openLogin: '打开登录页面', openLogin: '打开登录页面',
authCode: '授权码', authCode: '授权码',
placeholder: '粘贴以 4/ 开头的代码...', placeholder: '粘贴以 4/ 开头的代码...',
@@ -707,6 +714,9 @@ i18n
active: '活跃', active: '活跃',
use: '使用', use: '使用',
rateLimited: '受限', rateLimited: '受限',
validationRiskControlled: '风控 / 限流',
validationOAuthReauthRequired: '需要重新 OAuth 授权',
validationRequired: '需要校验',
left: '剩余', left: '剩余',
used: '已用', used: '已用',
unknown: '未知用户', unknown: '未知用户',
@@ -1126,6 +1136,8 @@ i18n
authDialog: { authDialog: {
title: 'Добавить Google Аккаунт', title: 'Добавить Google Аккаунт',
description: 'Для добавления аккаунта необходимо авторизовать приложение.', description: 'Для добавления аккаунта необходимо авторизовать приложение.',
oauthClient: 'OAuth клиент',
oauthClientPlaceholder: 'Выберите OAuth клиент',
openLogin: 'Открыть страницу входа', openLogin: 'Открыть страницу входа',
authCode: 'Код авторизации', authCode: 'Код авторизации',
placeholder: 'Вставьте код, начинающийся с 4/...', placeholder: 'Вставьте код, начинающийся с 4/...',
@@ -1137,6 +1149,9 @@ i18n
active: 'Активен', active: 'Активен',
use: 'Использовать', use: 'Использовать',
rateLimited: 'Лимит исчерпан', rateLimited: 'Лимит исчерпан',
validationRiskControlled: 'Риск / Ограничение',
validationOAuthReauthRequired: 'Требуется повторный OAuth',
validationRequired: 'Требуется проверка',
left: 'ост.', left: 'ост.',
used: 'Исп.', used: 'Исп.',
unknown: 'Неизвестный', unknown: 'Неизвестный',
+530 -91
View File
@@ -1,33 +1,207 @@
import { z } from 'zod'; import { z } from 'zod';
import { ConfigManager } from '../ipc/config/manager'; import { ConfigManager } from '../ipc/config/manager';
import { AuthServer } from '../ipc/cloud/authServer'; import { AuthServer } from '../ipc/cloud/authServer';
import { ProxyAgent } from 'undici'; import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { import {
buildUserAgent, buildUserAgent,
FALLBACK_VERSION, FALLBACK_VERSION,
resolveLocalInstalledVersion, resolveLocalInstalledVersion,
} from '@/server/modules/proxy/request-user-agent'; } from '@/server/modules/proxy/request-user-agent';
import { isNumber, isString } from 'lodash-es';
// --- Constants & Config --- // --- Constants & Config ---
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const CLIENT_SECRET = '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'; const CLIENT_SECRET = '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';
const OAUTH_CLIENTS_ENV = 'ANTIGRAVITY_OAUTH_CLIENTS';
const ACTIVE_OAUTH_CLIENT_ENV = 'ANTIGRAVITY_OAUTH_CLIENT_KEY';
const DEFAULT_OAUTH_CLIENT_KEY = 'antigravity_enterprise';
const URLS = { const URLS = {
TOKEN: 'https://oauth2.googleapis.com/token', TOKEN: 'https://oauth2.googleapis.com/token',
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo', USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
AUTH: 'https://accounts.google.com/o/oauth2/v2/auth', AUTH: 'https://accounts.google.com/o/oauth2/v2/auth',
QUOTA: 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
LOAD_PROJECT: 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist', LOAD_PROJECT: 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
DAILY_LOAD_PROJECT: 'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
FETCH_CREDITS: 'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
}; };
const QUOTA_API_ENDPOINTS = [
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
] as const;
// Internal API masquerading // Internal API masquerading
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64'; const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
// Request timeout in milliseconds (30 seconds) // Request timeout in milliseconds (30 seconds)
const REQUEST_TIMEOUT_MS = 30000; const REQUEST_TIMEOUT_MS = 30000;
interface OAuthClientConfig {
key: string;
label: string;
client_id: string;
client_secret: string;
is_builtin: boolean;
}
interface OAuthClientRegistry {
clients: OAuthClientConfig[];
activeKey: string;
}
let cachedOAuthClientRegistry: OAuthClientRegistry | null = null;
function normalizeClientKey(key: string): string {
return key.trim().toLowerCase();
}
function getClientByKey(
clients: OAuthClientConfig[],
clientKey: string | undefined,
): OAuthClientConfig | null {
if (!clientKey) {
return null;
}
const normalizedKey = normalizeClientKey(clientKey);
return clients.find((client) => client.key === normalizedKey) ?? null;
}
function buildOAuthClientRegistry(): OAuthClientRegistry {
const clients: OAuthClientConfig[] = [
{
key: normalizeClientKey(DEFAULT_OAUTH_CLIENT_KEY),
label: 'Antigravity Enterprise',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
is_builtin: true,
},
];
const rawExtraClients = process.env[OAUTH_CLIENTS_ENV];
if (isString(rawExtraClients) && rawExtraClients.trim() !== '') {
for (const entry of rawExtraClients.split(';')) {
const trimmed = entry.trim();
if (trimmed === '') {
continue;
}
const parts = trimmed.split('|').map((part) => part.trim());
if (parts.length < 3) {
logger.warn(
`[GoogleAPIService] Ignored invalid OAuth client entry in ${OAUTH_CLIENTS_ENV}: ${trimmed}`,
);
continue;
}
const key = normalizeClientKey(parts[0]);
const clientId = parts[1];
const clientSecret = parts[2];
if (key === '' || clientId === '' || clientSecret === '') {
logger.warn(
`[GoogleAPIService] Ignored incomplete OAuth client entry in ${OAUTH_CLIENTS_ENV}: ${trimmed}`,
);
continue;
}
const clientConfig: OAuthClientConfig = {
key,
label: parts[3] && parts[3] !== '' ? parts[3] : key,
client_id: clientId,
client_secret: clientSecret,
is_builtin: false,
};
const existingIndex = clients.findIndex((client) => client.key === key);
if (existingIndex >= 0) {
clients[existingIndex] = clientConfig;
} else {
clients.push(clientConfig);
}
}
}
let activeKey = normalizeClientKey(
process.env[ACTIVE_OAUTH_CLIENT_ENV] || DEFAULT_OAUTH_CLIENT_KEY,
);
if (!clients.some((client) => client.key === activeKey)) {
activeKey = clients[0]?.key ?? normalizeClientKey(DEFAULT_OAUTH_CLIENT_KEY);
}
return {
clients,
activeKey,
};
}
function getOAuthClientRegistry(): OAuthClientRegistry {
if (cachedOAuthClientRegistry) {
return cachedOAuthClientRegistry;
}
cachedOAuthClientRegistry = buildOAuthClientRegistry();
return cachedOAuthClientRegistry;
}
function getCandidateClients(preferredClientKey?: string): OAuthClientConfig[] {
const registry = getOAuthClientRegistry();
const candidates: OAuthClientConfig[] = [];
const seen = new Set<string>();
const pushCandidate = (candidate: OAuthClientConfig | null) => {
if (!candidate || seen.has(candidate.key)) {
return;
}
seen.add(candidate.key);
candidates.push(candidate);
};
const preferred = getClientByKey(registry.clients, preferredClientKey);
if (preferredClientKey && !preferred) {
logger.warn(
`[GoogleAPIService] Preferred OAuth client '${preferredClientKey}' not found; fallback to active client list`,
);
}
pushCandidate(preferred);
pushCandidate(getClientByKey(registry.clients, registry.activeKey));
for (const client of registry.clients) {
pushCandidate(client);
}
return candidates;
}
function selectAuthClient(clientKey?: string): OAuthClientConfig {
const registry = getOAuthClientRegistry();
if (registry.clients.length === 0) {
throw new Error('No OAuth clients configured');
}
if (clientKey && clientKey.trim() !== '') {
const selected = getClientByKey(registry.clients, clientKey);
if (!selected) {
throw new Error(`Unknown OAuth client key: ${clientKey}`);
}
return selected;
}
return getClientByKey(registry.clients, registry.activeKey) ?? registry.clients[0];
}
function isClientMismatchError(status: number, errorText: string): boolean {
const text = errorText.toLowerCase();
return (
status === 400 ||
status === 401 ||
status === 403 ||
text.includes('unauthorized_client') ||
text.includes('invalid_client')
);
}
/** /**
* Creates an AbortSignal that times out after the specified duration. * Creates an AbortSignal that times out after the specified duration.
*/ */
@@ -45,6 +219,15 @@ export interface TokenResponse {
token_type: string; token_type: string;
refresh_token?: string; refresh_token?: string;
scope?: string; scope?: string;
oauth_client_key?: string;
}
export interface OAuthClientDescriptor {
key: string;
label: string;
client_id: string;
is_active: boolean;
is_builtin: boolean;
} }
export interface UserInfo { export interface UserInfo {
@@ -52,19 +235,19 @@ export interface UserInfo {
email: string; email: string;
verified_email: boolean; verified_email: boolean;
name: string; name: string;
given_name: string; given_name?: string;
family_name: string; family_name?: string;
picture: string; picture?: string;
} }
export const UserInfoSchema = z.object({ export const UserInfoSchema = z.object({
id: z.string(), id: z.string(),
email: z.string(), email: z.string(),
verified_email: z.boolean(), verified_email: z.boolean().optional().default(false),
name: z.string(), name: z.string().optional(),
given_name: z.string(), given_name: z.string().optional(),
family_name: z.string(), family_name: z.string().optional(),
picture: z.string(), picture: z.string().optional(),
}); });
export interface QuotaData { export interface QuotaData {
@@ -118,6 +301,13 @@ interface TierRaw {
quotaTier?: string; quotaTier?: string;
name?: string; name?: string;
slug?: string; slug?: string;
availableCredits?: AvailableCreditRaw[];
}
interface AvailableCreditRaw {
creditType?: string;
creditAmount?: string | number;
minimumCreditAmountForUsage?: string | number;
} }
interface LoadProjectResponse { interface LoadProjectResponse {
@@ -228,11 +418,129 @@ function toModelForwardingRules(
return Object.keys(forwardingRules).length > 0 ? forwardingRules : undefined; return Object.keys(forwardingRules).length > 0 ? forwardingRules : undefined;
} }
function parseCreditAmount(value: string | number | undefined): number {
if (isNumber(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return 0;
}
function toAiCredits(
payload: Partial<{
credits: unknown;
remainingCredits: unknown;
expiryDate: unknown;
expirationDate: unknown;
}>,
): { credits: number; expiryDate: string } | null {
const creditsValue =
isNumber(payload.credits) || isString(payload.credits)
? payload.credits
: isNumber(payload.remainingCredits) || isString(payload.remainingCredits)
? payload.remainingCredits
: undefined;
if (typeof creditsValue === 'undefined') {
return null;
}
const expiryDate = isString(payload.expiryDate)
? payload.expiryDate
: isString(payload.expirationDate)
? payload.expirationDate
: '';
return {
credits: parseCreditAmount(creditsValue),
expiryDate,
};
}
function extractAiCreditsFromProjectContext(
payload: LoadProjectResponse,
): { credits: number; expiryDate: string } | null {
const availableCredit = payload.paidTier?.availableCredits?.[0];
if (!availableCredit) {
return null;
}
return {
credits: parseCreditAmount(availableCredit.creditAmount),
expiryDate: '',
};
}
// --- Service Implementation --- // --- Service Implementation ---
export class GoogleAPIService { export class GoogleAPIService {
static listOAuthClients(): OAuthClientDescriptor[] {
const registry = getOAuthClientRegistry();
return registry.clients.map((client) => {
return {
key: client.key,
label: client.label,
client_id: client.client_id,
is_active: client.key === registry.activeKey,
is_builtin: client.is_builtin,
};
});
}
static getActiveOAuthClientKey(): string {
const registry = getOAuthClientRegistry();
return registry.activeKey;
}
static setActiveOAuthClientKey(clientKey: string): void {
const registry = getOAuthClientRegistry();
const normalized = normalizeClientKey(clientKey);
const exists = registry.clients.some((client) => client.key === normalized);
if (!exists) {
const available = registry.clients.map((client) => client.key).join(', ');
throw new Error(`Unknown OAuth client key '${clientKey}'. Available: ${available}`);
}
registry.activeKey = normalized;
process.env[ACTIVE_OAUTH_CLIENT_ENV] = normalized;
}
static normalizeRefreshedOAuthClientKey(
currentToken: { oauth_client_key?: string; project_id?: string },
refreshedClientKey?: string,
): string | undefined {
const resolved = refreshedClientKey ?? currentToken.oauth_client_key;
const projectMissing =
typeof currentToken.project_id !== 'string' || currentToken.project_id.trim() === '';
if (
typeof currentToken.oauth_client_key !== 'string' &&
projectMissing &&
resolved &&
normalizeClientKey(resolved) === DEFAULT_OAUTH_CLIENT_KEY
) {
logger.warn(
'[GoogleAPIService] Refreshed token via enterprise client for a legacy account without project_id; keep oauth_client_key unset to avoid accidental enterprise lock',
);
return undefined;
}
return resolved ? normalizeClientKey(resolved) : undefined;
}
private static getFetchOptions(proxyUrl?: string) { private static getFetchOptions(proxyUrl?: string) {
const proxyTraceEnabled = process.env.DEBUG_PROXY_TRACE === '1';
if (proxyUrl && proxyUrl.length > 0) { if (proxyUrl && proxyUrl.length > 0) {
if (proxyTraceEnabled) {
logger.info('[GoogleAPIService] Proxy source: account proxy_url');
}
return { return {
dispatcher: new ProxyAgent(proxyUrl), dispatcher: new ProxyAgent(proxyUrl),
}; };
@@ -243,6 +551,9 @@ export class GoogleAPIService {
if (!config.proxy.upstream_proxy.url) { if (!config.proxy.upstream_proxy.url) {
throw new Error('Upstream proxy is enabled but URL is not configured'); throw new Error('Upstream proxy is enabled but URL is not configured');
} }
if (proxyTraceEnabled) {
logger.info('[GoogleAPIService] Proxy source: config.proxy.upstream_proxy.url');
}
return { return {
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url), dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
}; };
@@ -251,23 +562,62 @@ export class GoogleAPIService {
logger.error('[GoogleAPIService] Proxy configuration error', e); logger.error('[GoogleAPIService] Proxy configuration error', e);
throw e; throw e;
} }
const httpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
const httpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
const noProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
const electronProxyServer = process.env.ELECTRON_PROXY_SERVER?.trim();
if (httpProxy || httpsProxy) {
if (proxyTraceEnabled) {
logger.info(
`[GoogleAPIService] Proxy source: HTTP(S)_PROXY env (http: ${httpProxy ?? 'none'}, https: ${httpsProxy ?? 'none'})`,
);
}
return {
dispatcher: new EnvHttpProxyAgent({
httpProxy,
httpsProxy,
noProxy,
}),
};
}
if (electronProxyServer) {
if (proxyTraceEnabled) {
logger.info(
`[GoogleAPIService] Proxy source: ELECTRON_PROXY_SERVER env (${electronProxyServer})`,
);
}
return {
dispatcher: new ProxyAgent(electronProxyServer),
};
}
if (proxyTraceEnabled) {
logger.info('[GoogleAPIService] Proxy source: none');
}
return {}; return {};
} }
/** /**
* Generates the OAuth2 authorization URL. * Generates the OAuth2 authorization URL.
*/ */
static getAuthUrl(): string { static getAuthUrl(oauthClientKey?: string): string {
const scopes = [ const scopes = [
'https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/cclog',
'https://www.googleapis.com/auth/experimentsandconfigs',
].join(' '); ].join(' ');
const oauthClient = selectAuthClient(oauthClientKey);
const redirectUri = AuthServer.getRedirectUri(); const redirectUri = AuthServer.getRedirectUri();
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: CLIENT_ID, client_id: oauthClient.client_id,
redirect_uri: redirectUri, redirect_uri: redirectUri,
response_type: 'code', response_type: 'code',
scope: scopes, scope: scopes,
@@ -282,78 +632,121 @@ export class GoogleAPIService {
/** /**
* Exchanges an authorization code for tokens. * Exchanges an authorization code for tokens.
*/ */
static async exchangeCode(code: string, proxyUrl?: string): Promise<TokenResponse> { static async exchangeCode(
code: string,
proxyUrl?: string,
preferredClientKey?: string,
): Promise<TokenResponse> {
const redirectUri = AuthServer.getRedirectUri(); const redirectUri = AuthServer.getRedirectUri();
const candidates = getCandidateClients(preferredClientKey);
if (candidates.length === 0) {
throw new Error('No OAuth clients configured');
}
const params = new URLSearchParams({ const attemptErrors: string[] = [];
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: code,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
const response = await fetch(URLS.TOKEN, { for (const client of candidates) {
method: 'POST', const params = new URLSearchParams({
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, client_id: client.client_id,
body: params, client_secret: client.client_secret,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), code,
...this.getFetchOptions(proxyUrl), redirect_uri: redirectUri,
}).catch((err: unknown) => { grant_type: 'authorization_code',
if (err instanceof Error) { });
if (err.name === 'AbortError') {
const response = await fetch(URLS.TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
}).catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') {
throw new Error( throw new Error(
'Token exchange timed out. Please check your network connection and try again.', 'Token exchange timed out. Please check your network connection and try again.',
); );
} }
throw err;
});
if (response.ok) {
const tokenResponse = (await response.json()) as TokenResponse;
tokenResponse.oauth_client_key = client.key;
return tokenResponse;
} }
throw err;
});
if (!response.ok) {
const text = await response.text(); const text = await response.text();
throw new Error(`Token exchange failed: ${text}`); attemptErrors.push(`${client.key} => ${text}`);
if (isClientMismatchError(response.status, text)) {
logger.warn(
`[GoogleAPIService] Token exchange failed for OAuth client '${client.key}', trying next client`,
);
continue;
}
throw new Error(`Token exchange failed for client [${client.key}]: ${text}`);
} }
return response.json() as Promise<TokenResponse>; throw new Error(`Token exchange failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
} }
/** /**
* Refreshes an access token using a refresh token. * Refreshes an access token using a refresh token.
*/ */
static async refreshAccessToken(refreshToken: string, proxyUrl?: string): Promise<TokenResponse> { static async refreshAccessToken(
const params = new URLSearchParams({ refreshToken: string,
client_id: CLIENT_ID, proxyUrl?: string,
client_secret: CLIENT_SECRET, preferredClientKey?: string,
refresh_token: refreshToken, ): Promise<TokenResponse> {
grant_type: 'refresh_token', const candidates = getCandidateClients(preferredClientKey);
}); if (candidates.length === 0) {
throw new Error('No OAuth clients configured');
}
const response = await fetch(URLS.TOKEN, { const attemptErrors: string[] = [];
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, for (const client of candidates) {
body: params, const params = new URLSearchParams({
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), client_id: client.client_id,
...this.getFetchOptions(proxyUrl), client_secret: client.client_secret,
}).catch((err: unknown) => { refresh_token: refreshToken,
if (err instanceof Error) { grant_type: 'refresh_token',
if (err.name === 'AbortError') { });
const response = await fetch(URLS.TOKEN, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params,
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl),
}).catch((err: unknown) => {
if (err instanceof Error && err.name === 'AbortError') {
throw new Error( throw new Error(
'Token refresh timed out. Please check your network connection and try again.', 'Token refresh timed out. Please check your network connection and try again.',
); );
} }
throw err;
});
if (response.ok) {
const tokenResponse = (await response.json()) as TokenResponse;
tokenResponse.oauth_client_key = client.key;
return tokenResponse;
} }
throw err;
});
if (!response.ok) {
const text = await response.text(); const text = await response.text();
throw new Error(`Token refresh failed: ${text}`); attemptErrors.push(`${client.key} => ${text}`);
if (isClientMismatchError(response.status, text)) {
logger.warn(
`[GoogleAPIService] Token refresh failed for OAuth client '${client.key}', trying next client`,
);
continue;
}
throw new Error(`Token refresh failed for client [${client.key}]: ${text}`);
} }
const data = (await response.json()) as TokenResponse; throw new Error(`Token refresh failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
return data;
} }
/** /**
@@ -382,7 +775,13 @@ export class GoogleAPIService {
const data = await response.json(); const data = await response.json();
try { try {
return UserInfoSchema.parse(data); const parsed = UserInfoSchema.parse(data);
return {
...parsed,
// Google may omit profile claims such as family_name for accounts with limited profile data.
name: parsed.name ?? parsed.email,
};
} catch (err) { } catch (err) {
logger.error('[GoogleAPIService] Malformed user info response:', err); logger.error('[GoogleAPIService] Malformed user info response:', err);
throw new Error('Received malformed user info from Google APIs'); throw new Error('Received malformed user info from Google APIs');
@@ -451,20 +850,55 @@ export class GoogleAPIService {
proxyUrl?: string, proxyUrl?: string,
): Promise<{ credits: number; expiryDate: string } | null> { ): Promise<{ credits: number; expiryDate: string } | null> {
try { try {
const response = await fetch('https://cloudcode-pa.googleapis.com/v1internal:fetchCredits', { const fetchOptions = this.getFetchOptions(proxyUrl);
const response = await fetch(URLS.FETCH_CREDITS, {
method: 'POST', method: 'POST',
headers: buildInternalApiHeaders(accessToken), headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify({}), body: JSON.stringify({}),
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS), signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...this.getFetchOptions(proxyUrl), ...fetchOptions,
}); });
if (!response.ok) return null;
const data = await response.json(); if (response.ok) {
return { const data = (await response.json()) as Partial<{
credits: data?.credits ?? data?.remainingCredits ?? 0, credits: number | string;
expiryDate: data?.expiryDate ?? data?.expirationDate ?? '', remainingCredits: number | string;
}; expiryDate: string;
} catch { expirationDate: string;
}>;
return toAiCredits(data);
}
if (response.status !== 404) {
return null;
}
// `fetchCredits` has started returning 404 for some accounts. Reuse the
// internal `loadCodeAssist` payload, which also exposes paid tier credits.
logger.warn('[GoogleAPIService] fetchCredits returned 404, falling back to loadCodeAssist');
const discoveryVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
const fallbackResponse = await fetch(URLS.DAILY_LOAD_PROJECT, {
method: 'POST',
headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify({
metadata: {
ide_type: 'ANTIGRAVITY',
ide_version: discoveryVersion,
ide_name: 'antigravity',
},
}),
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
...fetchOptions,
});
if (!fallbackResponse.ok) {
return null;
}
const fallbackData = (await fallbackResponse.json()) as LoadProjectResponse;
return extractAiCreditsFromProjectContext(fallbackData);
} catch (e) {
return null; return null;
} }
} }
@@ -475,18 +909,16 @@ export class GoogleAPIService {
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> { static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl); const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = projectId ? { project: projectId } : {};
if (projectId) {
payload['project'] = projectId;
}
const maxRetries = 3;
let lastError: Error | null = null; let lastError: Error | null = null;
const fetchOptions = this.getFetchOptions(proxyUrl); const fetchOptions = this.getFetchOptions(proxyUrl);
for (let attempt = 1; attempt <= maxRetries; attempt++) { for (let endpointIndex = 0; endpointIndex < QUOTA_API_ENDPOINTS.length; endpointIndex++) {
const endpoint = QUOTA_API_ENDPOINTS[endpointIndex];
const hasNextEndpoint = endpointIndex + 1 < QUOTA_API_ENDPOINTS.length;
try { try {
const response = await fetch(URLS.QUOTA, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: buildInternalApiHeaders(accessToken), headers: buildInternalApiHeaders(accessToken),
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -506,17 +938,17 @@ export class GoogleAPIService {
} }
const errorMsg = `HTTP ${status} - ${text}`; const errorMsg = `HTTP ${status} - ${text}`;
logger.warn( if (hasNextEndpoint && (status === 429 || status >= 500)) {
`[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`, logger.warn(
); `[GoogleAPIService] Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
);
lastError = new Error(errorMsg);
if (attempt < maxRetries) { await sleep(1000);
const backoffMs = 1000 * Math.pow(2, attempt - 1);
await sleep(backoffMs);
continue; continue;
} else {
throw new Error(errorMsg);
} }
throw new Error(errorMsg);
} }
const data = (await response.json()) as FetchModelsResponse; const data = (await response.json()) as FetchModelsResponse;
@@ -538,26 +970,33 @@ export class GoogleAPIService {
result.model_forwarding_rules = modelForwardingRules; result.model_forwarding_rules = modelForwardingRules;
} }
if (endpointIndex > 0) {
logger.info(
`[GoogleAPIService] Quota API fallback succeeded at endpoint #${endpointIndex + 1}`,
);
}
return result; return result;
} catch (error: unknown) { } catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
if (error instanceof Error) {
logger.warn(
`[GoogleAPIService] Request failed: ${error.message} (Attempt ${attempt}/${maxRetries})`,
);
}
lastError = error instanceof Error ? error : new Error(String(error)); lastError = error instanceof Error ? error : new Error(String(error));
const isPermanentHttp4xx =
/^HTTP 4\d{2}\b/.test(errorMsg) && !errorMsg.startsWith('HTTP 429');
// Abort retries for auth errors // Abort retries for auth errors
if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') { if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') {
throw error; throw error;
} }
if (attempt < maxRetries) { if (hasNextEndpoint && !isPermanentHttp4xx) {
const backoffMs = 1000 * Math.pow(2, attempt - 1); logger.warn(
await sleep(backoffMs); `[GoogleAPIService] Quota API request failed at ${endpoint}: ${errorMsg}. Falling back to next endpoint`,
);
await sleep(1000);
continue; continue;
} }
throw lastError;
} }
} }
+337
View File
@@ -80,3 +80,340 @@ describe('GoogleAPIService Timeout', () => {
expect(() => handleAbortError(networkError)).toThrow('Network failure'); expect(() => handleAbortError(networkError)).toThrow('Network failure');
}); });
}); });
describe('GoogleAPIService OAuth clients', () => {
const originalOauthClientsEnv = process.env.ANTIGRAVITY_OAUTH_CLIENTS;
const originalActiveOauthClientEnv = process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
beforeEach(() => {
vi.resetModules();
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
});
afterEach(() => {
if (typeof originalOauthClientsEnv === 'string') {
process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv;
} else {
delete process.env.ANTIGRAVITY_OAUTH_CLIENTS;
}
if (typeof originalActiveOauthClientEnv === 'string') {
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv;
} else {
delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY;
}
});
it('loads builtin and custom oauth clients with active marker', async () => {
process.env.ANTIGRAVITY_OAUTH_CLIENTS =
'custom_a|id-a|secret-a|Custom A;custom_b|id-b|secret-b|Custom B';
process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = 'custom_b';
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
const clients = GoogleAPIService.listOAuthClients();
expect(clients.find((client) => client.key === 'antigravity_enterprise')).toBeDefined();
expect(clients.find((client) => client.key === 'custom_a')?.label).toBe('Custom A');
expect(clients.find((client) => client.key === 'custom_b')?.is_active).toBe(true);
});
it('switches active oauth client key', async () => {
process.env.ANTIGRAVITY_OAUTH_CLIENTS = 'custom_a|id-a|secret-a|Custom A';
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
GoogleAPIService.setActiveOAuthClientKey('custom_a');
expect(GoogleAPIService.getActiveOAuthClientKey()).toBe('custom_a');
});
it('throws when switching to unknown oauth client key', async () => {
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
expect(() => GoogleAPIService.setActiveOAuthClientKey('missing_client')).toThrow(
'Unknown OAuth client key',
);
});
});
describe('GoogleAPIService user info parsing', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it('accepts Google user info responses without family_name', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({
id: 'google-user-1',
email: '[email protected]',
verified_email: true,
name: 'Example User',
given_name: 'Example',
picture: 'https://example.com/avatar.png',
}),
});
vi.stubGlobal('fetch', fetchMock);
const { ConfigManager } = await import('../../ipc/config/manager');
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
proxy: {
upstream_proxy: {
enabled: false,
},
},
} as any);
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
await expect(GoogleAPIService.getUserInfo('access-token')).resolves.toEqual(
expect.objectContaining({
id: 'google-user-1',
email: '[email protected]',
name: 'Example User',
family_name: undefined,
}),
);
});
});
describe('CloudAccountList auth code auto-submit guard', () => {
it('returns true for a fresh auth code while the dialog is open', async () => {
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
expect(
shouldAutoSubmitGoogleAuthCode({
authCode: 'fresh-code',
isAddDialogOpen: true,
isPending: false,
lastSubmittedAuthCode: null,
}),
).toBe(true);
});
it('returns false after the same auth code was already auto-submitted', async () => {
const { shouldAutoSubmitGoogleAuthCode } = await import('../../utils/googleAuthSubmission');
expect(
shouldAutoSubmitGoogleAuthCode({
authCode: 'same-code',
isAddDialogOpen: true,
isPending: false,
lastSubmittedAuthCode: 'same-code',
}),
).toBe(false);
});
});
describe('GoogleAPIService fetchQuota fallback policy', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it('falls back to loadCodeAssist credits when fetchCredits returns 404', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 404,
text: vi.fn().mockResolvedValue('NOT_FOUND'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({
paidTier: {
availableCredits: [
{
creditType: 'GOOGLE_ONE_AI',
creditAmount: '1000',
minimumCreditAmountForUsage: '50',
},
],
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const { ConfigManager } = await import('../../ipc/config/manager');
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
proxy: {
upstream_proxy: {
enabled: false,
},
},
} as any);
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
const { FALLBACK_VERSION, resolveLocalInstalledVersion } = await import(
'../../server/modules/proxy/request-user-agent'
);
const expectedVersion = resolveLocalInstalledVersion() ?? FALLBACK_VERSION;
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toEqual({
credits: 1000,
expiryDate: '',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'https://cloudcode-pa.googleapis.com/v1internal:fetchCredits',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist',
);
expect(fetchMock.mock.calls[1]?.[1]?.method).toBe('POST');
expect(fetchMock.mock.calls[1]?.[1]?.body).toBe(
JSON.stringify({
metadata: {
ide_type: 'ANTIGRAVITY',
ide_version: expectedVersion,
ide_name: 'antigravity',
},
}),
);
});
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: false,
status: 400,
text: vi.fn().mockResolvedValue('INVALID_ARGUMENT'),
});
vi.stubGlobal('fetch', fetchMock);
const { ConfigManager } = await import('../../ipc/config/manager');
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
proxy: {
upstream_proxy: {
enabled: false,
},
},
} as any);
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
vi.spyOn(GoogleAPIService, 'fetchProjectContext').mockResolvedValue({
projectId: 'project-1',
subscriptionTier: 'free',
});
await expect(GoogleAPIService.fetchQuota('access-token')).rejects.toThrow(
'HTTP 400 - INVALID_ARGUMENT',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('uses an explicit dispatcher from env proxy settings when no account proxy is provided', async () => {
process.env.HTTP_PROXY = 'http://127.0.0.1:9090';
process.env.HTTPS_PROXY = 'http://127.0.0.1:9090';
process.env.NO_PROXY = 'localhost,127.0.0.1,::1';
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: false,
status: 502,
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
});
vi.stubGlobal('fetch', fetchMock);
const { ConfigManager } = await import('../../ipc/config/manager');
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
proxy: {
upstream_proxy: {
enabled: false,
},
},
} as any);
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
delete process.env.HTTP_PROXY;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
});
it('uses electron proxy env as an explicit dispatcher fallback', async () => {
process.env.ELECTRON_PROXY_SERVER = 'http://127.0.0.1:9090';
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: false,
status: 502,
text: vi.fn().mockResolvedValue('BAD_GATEWAY'),
});
vi.stubGlobal('fetch', fetchMock);
const { ConfigManager } = await import('../../ipc/config/manager');
vi.spyOn(ConfigManager, 'loadConfig').mockReturnValue({
proxy: {
upstream_proxy: {
enabled: false,
},
},
} as any);
const { GoogleAPIService } = await import('../../services/GoogleAPIService');
await expect(GoogleAPIService.fetchAICredits('access-token')).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBeDefined();
delete process.env.ELECTRON_PROXY_SERVER;
});
});
describe('QuotaService fallback policy', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});
it('does not fall through to the next endpoint on permanent 400 errors', async () => {
const postMock = vi
.fn()
.mockResolvedValueOnce({
status: 200,
data: {
cloudaicompanionProject: 'project-1',
currentTier: { id: 'free' },
},
})
.mockRejectedValueOnce({
isAxiosError: true,
response: {
status: 400,
data: { error: 'INVALID_ARGUMENT' },
},
message: 'Request failed with status code 400',
});
vi.doMock('axios', () => ({
default: {
create: vi.fn(() => ({
post: postMock,
})),
isAxiosError: (error: unknown) => Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
},
isAxiosError: (error: unknown) => Boolean((error as { isAxiosError?: boolean })?.isAxiosError),
}));
const { QuotaService } = await import('../../lib/antigravity/QuotaService');
await expect(QuotaService.fetchQuota('access-token', '[email protected]')).rejects.toThrow(
'HTTP 400 - {"error":"INVALID_ARGUMENT"}',
);
expect(postMock).toHaveBeenCalledTimes(2);
});
});
+13
View File
@@ -0,0 +1,13 @@
export function shouldAutoSubmitGoogleAuthCode(params: {
authCode: string;
isAddDialogOpen: boolean;
isPending: boolean;
lastSubmittedAuthCode: string | null;
}): boolean {
return (
params.authCode.trim() !== '' &&
params.isAddDialogOpen &&
!params.isPending &&
params.authCode !== params.lastSubmittedAuthCode
);
}