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,
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>
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}),
+15
View File
@@ -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: 'Неизвестный',
+530 -91
View File
@@ -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;
}
}
+337
View File
@@ -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);
});
});
+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
);
}