feat(cloud-oauth): support selectable google oauth clients
This commit is contained in:
1 parent
385036d577
commit
bcdf2a6c24
8 files changed
+1370
-125
No files matched your search
@@ -8,6 +8,8 @@ import {
|
||||
useSetAutoSwitchEnabled,
|
||||
useForcePollCloudMonitor,
|
||||
useSyncLocalAccount,
|
||||
useOAuthClients,
|
||||
useSetActiveOAuthClient,
|
||||
startAuthFlow,
|
||||
useExportCloudAccounts,
|
||||
useImportCloudAccounts,
|
||||
@@ -77,6 +79,7 @@ import {
|
||||
getAccountSortValue,
|
||||
type QuotaStatus,
|
||||
} from '@/utils/quota-display';
|
||||
import { shouldAutoSubmitGoogleAuthCode } from '@/utils/googleAuthSubmission';
|
||||
|
||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
||||
|
||||
@@ -113,9 +116,12 @@ export function CloudAccountList() {
|
||||
const { data: autoSwitchEnabled, isLoading: isSettingsLoading } = useAutoSwitchEnabled();
|
||||
const setAutoSwitchMutation = useSetAutoSwitchEnabled();
|
||||
const forcePollMutation = useForcePollCloudMonitor();
|
||||
const { data: oauthClients = [], isLoading: isOAuthClientsLoading } = useOAuthClients();
|
||||
const setActiveOAuthClientMutation = useSetActiveOAuthClient();
|
||||
|
||||
const { toast } = useToast();
|
||||
const lastLoadErrorToastAtRef = useRef<number>(0);
|
||||
const lastSubmittedAuthCodeRef = useRef<string | null>(null);
|
||||
|
||||
const gridLayout: GridLayout = (config?.grid_layout as GridLayout) || 'auto';
|
||||
|
||||
@@ -195,6 +201,7 @@ export function CloudAccountList() {
|
||||
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [authCode, setAuthCode] = useState('');
|
||||
const [selectedOAuthClientKey, setSelectedOAuthClientKey] = useState('');
|
||||
const [identityAccount, setIdentityAccount] = useState<CloudAccount | null>(null);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
@@ -219,12 +226,18 @@ export function CloudAccountList() {
|
||||
if (!codeToUse) {
|
||||
return;
|
||||
}
|
||||
lastSubmittedAuthCodeRef.current = codeToUse;
|
||||
addMutation.mutate(
|
||||
{ authCode: codeToUse },
|
||||
{
|
||||
authCode: codeToUse,
|
||||
oauthClientKey:
|
||||
selectedOAuthClientKey || oauthClients.find((client) => client.is_active)?.key,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsAddDialogOpen(false);
|
||||
setAuthCode('');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
toast({ title: t('cloud.toast.addSuccess') });
|
||||
},
|
||||
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
|
||||
useEffect(() => {
|
||||
if (window.electron?.onGoogleAuthCode) {
|
||||
console.log('[OAuth] Registering Google auth code IPC listener');
|
||||
const cleanup = window.electron.onGoogleAuthCode((code) => {
|
||||
console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
setAuthCode(code);
|
||||
});
|
||||
return cleanup;
|
||||
@@ -253,7 +277,14 @@ export function CloudAccountList() {
|
||||
|
||||
// Auto-submit when authCode is set and dialog is open
|
||||
useEffect(() => {
|
||||
if (authCode && isAddDialogOpen && !addMutation.isPending) {
|
||||
if (
|
||||
shouldAutoSubmitGoogleAuthCode({
|
||||
authCode,
|
||||
isAddDialogOpen,
|
||||
isPending: addMutation.isPending,
|
||||
lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current,
|
||||
})
|
||||
) {
|
||||
console.log('[OAuth] Auto-submitting Google auth code');
|
||||
submitAuthCode(authCode);
|
||||
}
|
||||
@@ -399,7 +430,16 @@ export function CloudAccountList() {
|
||||
|
||||
const openGoogleAuthSignIn = async () => {
|
||||
try {
|
||||
await startAuthFlow();
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
const effectiveClientKey =
|
||||
selectedOAuthClientKey || oauthClients.find((client) => client.is_active)?.key;
|
||||
await startAuthFlow(
|
||||
effectiveClientKey
|
||||
? {
|
||||
oauthClientKey: effectiveClientKey,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t('cloud.toast.startAuthFailed'),
|
||||
@@ -833,7 +873,16 @@ export function CloudAccountList() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<Dialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddDialogOpen(open);
|
||||
if (!open) {
|
||||
setAuthCode('');
|
||||
lastSubmittedAuthCodeRef.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
@@ -846,6 +895,41 @@ export function CloudAccountList() {
|
||||
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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">
|
||||
<Button variant="outline" className="col-span-4" onClick={openGoogleAuthSignIn}>
|
||||
<Cloud className="mr-2 h-4 w-4" />
|
||||
@@ -858,7 +942,9 @@ export function CloudAccountList() {
|
||||
id="code"
|
||||
placeholder={t('cloud.authDialog.placeholder')}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,15 @@ import {
|
||||
deleteCloudAccount,
|
||||
refreshAccountQuota,
|
||||
setAccountProxy,
|
||||
listOAuthClients,
|
||||
setActiveOAuthClient,
|
||||
type OAuthClientDescriptor,
|
||||
} from '@/actions/cloud';
|
||||
import { CloudAccount } from '@/types/cloudAccount';
|
||||
|
||||
export const QUERY_KEYS = {
|
||||
cloudAccounts: ['cloudAccounts'],
|
||||
oauthClients: ['oauthClients'],
|
||||
};
|
||||
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
+288
-22
@@ -1,9 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
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 { logger } from '../../utils/logger';
|
||||
import { AuthServer } from './authServer';
|
||||
|
||||
import { shell } from 'electron';
|
||||
import fs from 'fs';
|
||||
@@ -21,16 +20,7 @@ import { getAntigravityDbPaths } from '../../utils/paths';
|
||||
import { runWithSwitchGuard } from '../../ipc/switchGuard';
|
||||
import { executeSwitchFlow } from '../../ipc/switchFlow';
|
||||
import type { DeviceProfile, DeviceProfilesSnapshot } from '../../types/account';
|
||||
|
||||
// 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(' ');
|
||||
import { classifyAccountStatusFromError, extractErrorMessage } from '../../utils/account-status';
|
||||
|
||||
// Helper to update tray
|
||||
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 {
|
||||
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
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode);
|
||||
const tokenResp = await GoogleAPIService.exchangeCode(authCode, undefined, oauthClientKey);
|
||||
|
||||
// 2. Get User Info
|
||||
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,
|
||||
token_type: tokenResp.token_type,
|
||||
email: userInfo.email,
|
||||
oauth_client_key: tokenResp.oauth_client_key,
|
||||
is_gcp_tos: true,
|
||||
},
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
@@ -111,7 +305,12 @@ export async function addGoogleAccount(authCode: string): 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> {
|
||||
@@ -131,15 +330,21 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = 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);
|
||||
} catch (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.`);
|
||||
}
|
||||
}
|
||||
@@ -172,6 +377,7 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
await clearAccountStatus(account);
|
||||
notifyTrayUpdate(account);
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
@@ -181,12 +387,17 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
now = Math.floor(Date.now() / 1000);
|
||||
|
||||
account.token.access_token = newTokenData.access_token;
|
||||
account.token.expires_in = 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);
|
||||
|
||||
@@ -220,21 +431,42 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
|
||||
await CloudAccountRepo.updateQuota(account.id, account.quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
account.last_used = Math.floor(Date.now() / 1000);
|
||||
await clearAccountStatus(account);
|
||||
return account;
|
||||
} catch (refreshError) {
|
||||
logger.error(
|
||||
`Failed to force refresh token or retry quota for ${account.email}`,
|
||||
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;
|
||||
}
|
||||
} else if (error.message === 'FORBIDDEN') {
|
||||
logger.warn(
|
||||
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
|
||||
);
|
||||
await markAccountStatusFromError(account, error);
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
@@ -267,6 +499,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
const newTokenData = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
|
||||
const updatedToken = {
|
||||
@@ -274,26 +507,33 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
access_token: newTokenData.access_token,
|
||||
expires_in: 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);
|
||||
|
||||
account.token = updatedToken;
|
||||
logger.info(`Token refreshed for ${account.email}`);
|
||||
} 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({
|
||||
scope: 'cloud',
|
||||
targetProfile: account.device_profile || null,
|
||||
applyFingerprint: isIdentityProfileApplyEnabled(),
|
||||
processExitTimeoutMs: 10000,
|
||||
performSwitch: async () => {
|
||||
// Wait for token refresh to complete before injection if it was started
|
||||
await tokenRefreshPromise;
|
||||
|
||||
// 3. Backup Database (Optimized to avoid race conditions)
|
||||
const dbPaths = getAntigravityDbPaths();
|
||||
for (const dbPath of dbPaths) {
|
||||
@@ -315,6 +555,7 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
|
||||
// 5. Update usage and active status
|
||||
CloudAccountRepo.updateLastUsed(account.id);
|
||||
CloudAccountRepo.setActive(account.id);
|
||||
await clearAccountStatus(account);
|
||||
|
||||
logger.info(`Successfully switched to cloud account: ${account.email}`);
|
||||
notifyTrayUpdate(account);
|
||||
@@ -442,13 +683,33 @@ export async function forcePollCloudMonitor(): Promise<void> {
|
||||
await CloudMonitorService.poll();
|
||||
}
|
||||
|
||||
export async function startAuthFlow(): 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`;
|
||||
export async function startAuthFlow(oauthClientKey?: string): Promise<void> {
|
||||
if (oauthClientKey && oauthClientKey.trim() !== '') {
|
||||
setActiveOAuthClient(oauthClientKey);
|
||||
} else {
|
||||
hydrateActiveOAuthClientFromSettings();
|
||||
}
|
||||
const url = GoogleAPIService.getAuthUrl(oauthClientKey);
|
||||
|
||||
logger.info(`Starting auth flow, opening URL: ${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> {
|
||||
const accounts = await CloudAccountRepo.getAccounts();
|
||||
const exportData = {
|
||||
@@ -464,6 +725,8 @@ export async function exportCloudAccounts(stripTokens = false): Promise<string>
|
||||
device_profile: acc.device_profile,
|
||||
device_history: acc.device_history,
|
||||
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_history: acc.device_history ?? existing.device_history,
|
||||
proxy_url: acc.proxy_url ?? existing.proxy_url,
|
||||
status: acc.status ?? existing.status,
|
||||
status_reason: acc.status_reason ?? existing.status_reason,
|
||||
last_used: now,
|
||||
};
|
||||
|
||||
@@ -545,7 +810,8 @@ export async function importCloudAccounts(
|
||||
proxy_url: acc.proxy_url ?? undefined,
|
||||
created_at: now,
|
||||
last_used: now,
|
||||
status: 'active',
|
||||
status: acc.status ?? 'active',
|
||||
status_reason: acc.status_reason,
|
||||
is_active: false,
|
||||
};
|
||||
|
||||
|
||||
+73
-6
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { os } from '@orpc/server';
|
||||
import { os, ORPCError } from '@orpc/server';
|
||||
import {
|
||||
addGoogleAccount,
|
||||
bindCloudIdentityProfile,
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
setAutoSwitchEnabled,
|
||||
forcePollCloudMonitor,
|
||||
startAuthFlow,
|
||||
listOAuthClients,
|
||||
getActiveOAuthClient,
|
||||
setActiveOAuthClient,
|
||||
exportCloudAccounts,
|
||||
importCloudAccounts,
|
||||
} 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({
|
||||
addGoogleAccount: os
|
||||
.input(z.object({ authCode: z.string() }))
|
||||
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
|
||||
.output(CloudAccountSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return addGoogleAccount(input.authCode);
|
||||
return addGoogleAccount(input.authCode, input.oauthClientKey);
|
||||
}),
|
||||
|
||||
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
|
||||
@@ -115,10 +150,42 @@ export const cloudRouter = os.router({
|
||||
await forcePollCloudMonitor();
|
||||
}),
|
||||
|
||||
startAuthFlow: os.output(z.void()).handler(async () => {
|
||||
await startAuthFlow();
|
||||
startAuthFlow: os
|
||||
.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
|
||||
.input(z.object({ accountId: z.string(), proxyUrl: z.string().nullable() }))
|
||||
.output(z.void())
|
||||
@@ -138,7 +205,7 @@ export const cloudRouter = os.router({
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack);
|
||||
throw error;
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -271,6 +271,8 @@ i18n
|
||||
authDialog: {
|
||||
title: 'Add Google Account',
|
||||
description: 'To add an account, you need to authorize the application.',
|
||||
oauthClient: 'OAuth Client',
|
||||
oauthClientPlaceholder: 'Select OAuth client',
|
||||
openLogin: 'Open Login Page',
|
||||
authCode: 'Authorization Code',
|
||||
placeholder: 'Paste the code starting with 4/...',
|
||||
@@ -282,6 +284,9 @@ i18n
|
||||
active: 'Active',
|
||||
use: 'Use',
|
||||
rateLimited: 'Rate Limited',
|
||||
validationRiskControlled: 'Risk / Rate Limited',
|
||||
validationOAuthReauthRequired: 'OAuth Re-auth Required',
|
||||
validationRequired: 'Verification Required',
|
||||
left: 'left',
|
||||
used: 'Used',
|
||||
unknown: 'Unknown User',
|
||||
@@ -697,6 +702,8 @@ i18n
|
||||
authDialog: {
|
||||
title: '添加 Google 账号',
|
||||
description: '添加账号需要进行应用授权。',
|
||||
oauthClient: 'OAuth 客户端',
|
||||
oauthClientPlaceholder: '请选择 OAuth 客户端',
|
||||
openLogin: '打开登录页面',
|
||||
authCode: '授权码',
|
||||
placeholder: '粘贴以 4/ 开头的代码...',
|
||||
@@ -707,6 +714,9 @@ i18n
|
||||
active: '活跃',
|
||||
use: '使用',
|
||||
rateLimited: '受限',
|
||||
validationRiskControlled: '风控 / 限流',
|
||||
validationOAuthReauthRequired: '需要重新 OAuth 授权',
|
||||
validationRequired: '需要校验',
|
||||
left: '剩余',
|
||||
used: '已用',
|
||||
unknown: '未知用户',
|
||||
@@ -1126,6 +1136,8 @@ i18n
|
||||
authDialog: {
|
||||
title: 'Добавить Google Аккаунт',
|
||||
description: 'Для добавления аккаунта необходимо авторизовать приложение.',
|
||||
oauthClient: 'OAuth клиент',
|
||||
oauthClientPlaceholder: 'Выберите OAuth клиент',
|
||||
openLogin: 'Открыть страницу входа',
|
||||
authCode: 'Код авторизации',
|
||||
placeholder: 'Вставьте код, начинающийся с 4/...',
|
||||
@@ -1137,6 +1149,9 @@ i18n
|
||||
active: 'Активен',
|
||||
use: 'Использовать',
|
||||
rateLimited: 'Лимит исчерпан',
|
||||
validationRiskControlled: 'Риск / Ограничение',
|
||||
validationOAuthReauthRequired: 'Требуется повторный OAuth',
|
||||
validationRequired: 'Требуется проверка',
|
||||
left: 'ост.',
|
||||
used: 'Исп.',
|
||||
unknown: 'Неизвестный',
|
||||
|
||||
@@ -1,33 +1,207 @@
|
||||
import { z } from 'zod';
|
||||
import { ConfigManager } from '../ipc/config/manager';
|
||||
import { AuthServer } from '../ipc/cloud/authServer';
|
||||
import { ProxyAgent } from 'undici';
|
||||
import { EnvHttpProxyAgent, ProxyAgent } from 'undici';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
buildUserAgent,
|
||||
FALLBACK_VERSION,
|
||||
resolveLocalInstalledVersion,
|
||||
} from '@/server/modules/proxy/request-user-agent';
|
||||
import { isNumber, isString } from 'lodash-es';
|
||||
|
||||
// --- Constants & Config ---
|
||||
|
||||
const CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
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 = {
|
||||
TOKEN: 'https://oauth2.googleapis.com/token',
|
||||
USER_INFO: 'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
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',
|
||||
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
|
||||
const USER_AGENT = 'antigravity/1.11.3 Darwin/arm64';
|
||||
|
||||
// Request timeout in milliseconds (30 seconds)
|
||||
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.
|
||||
*/
|
||||
@@ -45,6 +219,15 @@ export interface TokenResponse {
|
||||
token_type: string;
|
||||
refresh_token?: 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 {
|
||||
@@ -52,19 +235,19 @@ export interface UserInfo {
|
||||
email: string;
|
||||
verified_email: boolean;
|
||||
name: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
picture: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
export const UserInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
verified_email: z.boolean(),
|
||||
name: z.string(),
|
||||
given_name: z.string(),
|
||||
family_name: z.string(),
|
||||
picture: z.string(),
|
||||
verified_email: z.boolean().optional().default(false),
|
||||
name: z.string().optional(),
|
||||
given_name: z.string().optional(),
|
||||
family_name: z.string().optional(),
|
||||
picture: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface QuotaData {
|
||||
@@ -118,6 +301,13 @@ interface TierRaw {
|
||||
quotaTier?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
availableCredits?: AvailableCreditRaw[];
|
||||
}
|
||||
|
||||
interface AvailableCreditRaw {
|
||||
creditType?: string;
|
||||
creditAmount?: string | number;
|
||||
minimumCreditAmountForUsage?: string | number;
|
||||
}
|
||||
|
||||
interface LoadProjectResponse {
|
||||
@@ -228,11 +418,129 @@ function toModelForwardingRules(
|
||||
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 ---
|
||||
|
||||
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) {
|
||||
const proxyTraceEnabled = process.env.DEBUG_PROXY_TRACE === '1';
|
||||
|
||||
if (proxyUrl && proxyUrl.length > 0) {
|
||||
if (proxyTraceEnabled) {
|
||||
logger.info('[GoogleAPIService] Proxy source: account proxy_url');
|
||||
}
|
||||
return {
|
||||
dispatcher: new ProxyAgent(proxyUrl),
|
||||
};
|
||||
@@ -243,6 +551,9 @@ export class GoogleAPIService {
|
||||
if (!config.proxy.upstream_proxy.url) {
|
||||
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 {
|
||||
dispatcher: new ProxyAgent(config.proxy.upstream_proxy.url),
|
||||
};
|
||||
@@ -251,23 +562,62 @@ export class GoogleAPIService {
|
||||
logger.error('[GoogleAPIService] Proxy configuration error', 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 {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the OAuth2 authorization URL.
|
||||
*/
|
||||
static getAuthUrl(): string {
|
||||
static getAuthUrl(oauthClientKey?: string): string {
|
||||
const scopes = [
|
||||
'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(' ');
|
||||
|
||||
const oauthClient = selectAuthClient(oauthClientKey);
|
||||
const redirectUri = AuthServer.getRedirectUri();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_id: oauthClient.client_id,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: scopes,
|
||||
@@ -282,78 +632,121 @@ export class GoogleAPIService {
|
||||
/**
|
||||
* 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 candidates = getCandidateClients(preferredClientKey);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('No OAuth clients configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
const attemptErrors: string[] = [];
|
||||
|
||||
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) {
|
||||
if (err.name === 'AbortError') {
|
||||
for (const client of candidates) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
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(
|
||||
'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();
|
||||
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.
|
||||
*/
|
||||
static async refreshAccessToken(refreshToken: string, proxyUrl?: string): Promise<TokenResponse> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
static async refreshAccessToken(
|
||||
refreshToken: string,
|
||||
proxyUrl?: string,
|
||||
preferredClientKey?: string,
|
||||
): Promise<TokenResponse> {
|
||||
const candidates = getCandidateClients(preferredClientKey);
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('No OAuth clients configured');
|
||||
}
|
||||
|
||||
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) {
|
||||
if (err.name === 'AbortError') {
|
||||
const attemptErrors: string[] = [];
|
||||
|
||||
for (const client of candidates) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
|
||||
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(
|
||||
'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();
|
||||
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;
|
||||
|
||||
return data;
|
||||
throw new Error(`Token refresh failed for all OAuth clients: ${attemptErrors.join(' | ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,7 +775,13 @@ export class GoogleAPIService {
|
||||
|
||||
const data = await response.json();
|
||||
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) {
|
||||
logger.error('[GoogleAPIService] Malformed user info response:', err);
|
||||
throw new Error('Received malformed user info from Google APIs');
|
||||
@@ -451,20 +850,55 @@ export class GoogleAPIService {
|
||||
proxyUrl?: string,
|
||||
): Promise<{ credits: number; expiryDate: string } | null> {
|
||||
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',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify({}),
|
||||
signal: createTimeoutSignal(REQUEST_TIMEOUT_MS),
|
||||
...this.getFetchOptions(proxyUrl),
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return {
|
||||
credits: data?.credits ?? data?.remainingCredits ?? 0,
|
||||
expiryDate: data?.expiryDate ?? data?.expirationDate ?? '',
|
||||
};
|
||||
} catch {
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as Partial<{
|
||||
credits: number | string;
|
||||
remainingCredits: number | string;
|
||||
expiryDate: string;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -475,18 +909,16 @@ export class GoogleAPIService {
|
||||
static async fetchQuota(accessToken: string, proxyUrl?: string): Promise<QuotaData> {
|
||||
const { projectId, subscriptionTier } = await this.fetchProjectContext(accessToken, proxyUrl);
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (projectId) {
|
||||
payload['project'] = projectId;
|
||||
}
|
||||
|
||||
const maxRetries = 3;
|
||||
const payload: Record<string, unknown> = projectId ? { project: projectId } : {};
|
||||
let lastError: Error | null = null;
|
||||
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 {
|
||||
const response = await fetch(URLS.QUOTA, {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: buildInternalApiHeaders(accessToken),
|
||||
body: JSON.stringify(payload),
|
||||
@@ -506,17 +938,17 @@ export class GoogleAPIService {
|
||||
}
|
||||
|
||||
const errorMsg = `HTTP ${status} - ${text}`;
|
||||
logger.warn(
|
||||
`[GoogleAPIService] API Error: ${errorMsg} (Attempt ${attempt}/${maxRetries})`,
|
||||
);
|
||||
if (hasNextEndpoint && (status === 429 || status >= 500)) {
|
||||
logger.warn(
|
||||
`[GoogleAPIService] Quota API ${endpoint} returned ${status}, falling back to next endpoint`,
|
||||
);
|
||||
lastError = new Error(errorMsg);
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||
await sleep(backoffMs);
|
||||
await sleep(1000);
|
||||
continue;
|
||||
} else {
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FetchModelsResponse;
|
||||
@@ -538,26 +970,33 @@ export class GoogleAPIService {
|
||||
result.model_forwarding_rules = modelForwardingRules;
|
||||
}
|
||||
|
||||
if (endpointIndex > 0) {
|
||||
logger.info(
|
||||
`[GoogleAPIService] Quota API fallback succeeded at endpoint #${endpointIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
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));
|
||||
const isPermanentHttp4xx =
|
||||
/^HTTP 4\d{2}\b/.test(errorMsg) && !errorMsg.startsWith('HTTP 429');
|
||||
|
||||
// Abort retries for auth errors
|
||||
if (errorMsg === 'FORBIDDEN' || errorMsg === 'UNAUTHORIZED') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
const backoffMs = 1000 * Math.pow(2, attempt - 1);
|
||||
await sleep(backoffMs);
|
||||
if (hasNextEndpoint && !isPermanentHttp4xx) {
|
||||
logger.warn(
|
||||
`[GoogleAPIService] Quota API request failed at ${endpoint}: ${errorMsg}. Falling back to next endpoint`,
|
||||
);
|
||||
await sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,3 +80,340 @@ describe('GoogleAPIService Timeout', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user