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