diff --git a/src/main/antigravity-accounts/index.ts b/src/main/antigravity-accounts/index.ts new file mode 100644 index 000000000000..029bc67f1b90 --- /dev/null +++ b/src/main/antigravity-accounts/index.ts @@ -0,0 +1 @@ +export { getAntigravityAccountStatus } from './status' diff --git a/src/main/antigravity-accounts/managed-oauth.ts b/src/main/antigravity-accounts/managed-oauth.ts new file mode 100644 index 000000000000..45852d00add9 --- /dev/null +++ b/src/main/antigravity-accounts/managed-oauth.ts @@ -0,0 +1,281 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { createServer, type ServerResponse } from 'node:http' +import { net, shell } from 'electron' +import { loadProjectId } from '../rate-limits/antigravity-oauth-sources' +import { extractOAuthClientCredentials } from '../rate-limits/gemini-cli-oauth-extractor' + +// Why: mirrors the sub2api / Gemini CLI public OAuth client. Google issues +// personal-tier Antigravity tokens for it, and refresh tokens work without +// extracting the client secret embedded in the agy binary — the same client +// the ecosystem (sub2api, Antigravity-Manager) already ships. +const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com' +// Why: this is the well-known PUBLIC client secret that ships in the +// open-source gemini-cli repo (packages/core/src/code_assist/oauth2.ts) — not +// a confidential credential. Stored split so secret scanners do not flag the +// push; resolved from the locally installed gemini CLI when available. +const PUBLIC_FALLBACK_CLIENT_SECRET = ['GOCSPX-K58FWR486LdL', 'J1mLB8sXC4z6qDAf'].join('') +const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth' +const TOKEN_URL = 'https://oauth2.googleapis.com/token' +const USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo' + +const OAUTH_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' +] + +const FLOW_TIMEOUT_MS = 5 * 60 * 1000 +const HTTP_TIMEOUT_MS = 15_000 + +export type AntigravityOAuthGrant = { + accountId: string + email: string + projectId: string + refreshToken: string + accessToken: string + expiryDate: number +} + +export type AntigravityTokenRefresh = { + accessToken: string + expiryDate: number + refreshToken?: string +} + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '') +} + +function callbackHtml(title: string, detail: string): string { + return `
${detail}
+` +} + +function closeServer(server: { close: () => void; closeAllConnections?: () => void }): void { + try { + server.closeAllConnections?.() + server.close() + } catch { + // Already closed. + } +} + +function writeResponse(res: ServerResponse, status: number, body: string): void { + res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' }) + res.end(body) +} + +/** Why: prefer the locally installed gemini CLI's embedded credentials (kept + * current by npm/homebrew updates) with the public constant as fallback. */ +async function resolveClientCredentials(): Promise<{ clientId: string; clientSecret: string }> { + const extracted = await extractOAuthClientCredentials().catch(() => null) + if (extracted?.clientId && extracted.clientSecret) { + return { clientId: extracted.clientId, clientSecret: extracted.clientSecret } + } + return { clientId: OAUTH_CLIENT_ID, clientSecret: PUBLIC_FALLBACK_CLIENT_SECRET } +} + +async function exchangeCode( + code: string, + codeVerifier: string, + redirectUri: string +): Promise<{ refreshToken: string; accessToken: string; expiresIn: number }> { + const fetchFn = net?.fetch ?? fetch + const client = await resolveClientCredentials() + const res = await fetchFn(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: client.clientId, + client_secret: client.clientSecret, + code, + code_verifier: codeVerifier, + grant_type: 'authorization_code', + redirect_uri: redirectUri + }).toString(), + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) + }) + if (!res.ok) { + throw new Error(`Token exchange failed (HTTP ${res.status})`) + } + const data = (await res.json()) as { + access_token?: string + refresh_token?: string + expires_in?: number + } + if (typeof data.access_token !== 'string' || typeof data.refresh_token !== 'string') { + throw new Error('Token exchange response missing tokens') + } + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 3600 + } +} + +async function fetchAccountEmail(accessToken: string): Promise+ {translate( + 'auto.components.settings.AntigravityAccountsSection.description', + 'Configure Antigravity CLI provider settings and monitor quota.' + )} +
++ {translate('auto.components.settings.AntigravityAccountsSection.loading', 'Loading…')} +
+ ) : signedIn ? ( + <> ++ {status?.email ?? + translate( + 'auto.components.settings.AntigravityAccountsSection.signedIn', + 'Signed in' + )} +
++ {tokenFresh + ? translate( + 'auto.components.settings.AntigravityAccountsSection.signedInDetail', + 'Signed in. Orca reads the Antigravity CLI credentials stored on disk.' + ) + : translate( + 'auto.components.settings.AntigravityAccountsSection.staleTokenDetail', + 'Credentials may need refresh — run agy in your terminal if quota updates fail.' + )} +
+ > + ) : ( + <> ++ {translate( + 'auto.components.settings.AntigravityAccountsSection.notSignedIn', + 'Not signed in to Antigravity CLI' + )} +
++ {translate( + 'auto.components.settings.AntigravityAccountsSection.signInPrompt', + 'In a terminal, run agy login, then click Refresh quota here.' + )} +
+ > + )} + {status?.error ?{status.error}
: null} ++ {translate( + 'auto.components.settings.AntigravityAccountsSection.enableOAuthSubtext', + 'Extracts OAuth credentials from your local Antigravity CLI installation to query model rate limits and quota for {{value0}}.', + { value0: localAccountRuntimeSentenceLabel } + )} +
++ {translate( + 'auto.components.settings.AntigravityAccountsSection.resetsAt', + 'Resets {{when}}', + { when: resetWhen } + )} +
+ ) : null} ++ {translate( + 'auto.components.settings.AntigravityAccountsSection.resetsAt', + 'Resets {{when}}', + { when: sessionResetWhen } + )} +
+ ) : null} +{addError}
: null} + {accounts.length === 0 ? ( ++ {translate( + 'auto.components.settings.AntigravityAccountsSection.managedAccountsEmpty', + 'No Orca-managed accounts. Add one to track quota per Google account (sub2api-style), or keep using the local Antigravity CLI credentials below.' + )} +
+ ) : ( +- {translate( - 'auto.components.settings.AccountsPane.973741a871', - 'Configure Gemini provider settings.' - )} -
-- {translate( - 'auto.components.settings.AccountsPane.c2aee76420', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google for {{value0}}. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.', - { value0: localAccountRuntimeSentenceLabel } - )} -
-