diff --git a/src/main/ipc/rate-limits.ts b/src/main/ipc/rate-limits.ts index b906ddce51f1..d1484e0bbe03 100644 --- a/src/main/ipc/rate-limits.ts +++ b/src/main/ipc/rate-limits.ts @@ -25,4 +25,16 @@ export function registerRateLimitHandlers(rateLimits: RateLimitService): void { ) ipcMain.handle('rateLimits:refreshMiniMax', () => rateLimits.refresh()) ipcMain.handle('rateLimits:refreshGrok', () => rateLimits.refreshGrok()) + ipcMain.handle('rateLimits:selectAntigravityAccount', (_event, id: string) => + rateLimits.selectAntigravityAccount(id) + ) + ipcMain.handle('rateLimits:addCurrentAntigravityAccount', () => + rateLimits.addCurrentAntigravityAccount() + ) + ipcMain.handle('rateLimits:removeAntigravityAccount', (_event, id: string) => + rateLimits.removeAntigravityAccount(id) + ) + ipcMain.handle('rateLimits:refreshAntigravityAccounts', () => + rateLimits.refreshAntigravityAccountsOnOpen() + ) } diff --git a/src/main/rate-limits/antigravity-account-store.test.ts b/src/main/rate-limits/antigravity-account-store.test.ts new file mode 100644 index 000000000000..9d340c5bd464 --- /dev/null +++ b/src/main/rate-limits/antigravity-account-store.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const netFetchMock = vi.hoisted(() => vi.fn()) +const state = vi.hoisted<{ userData: string }>(() => ({ userData: '' })) + +vi.mock('electron', () => ({ + app: { getPath: (name: string) => (name === 'userData' ? state.userData : '') }, + net: { fetch: netFetchMock }, + safeStorage: { + isEncryptionAvailable: () => true, + // Reversible stand-in for the OS-backed cipher. + encryptString: (s: string) => Buffer.from(`enc:${s}`, 'utf-8'), + decryptString: (b: Buffer) => b.toString('utf-8').replace(/^enc:/, '') + } +})) + +import { + fetchAccountEmail, + getAccountCredentials, + getActiveAccountCredentials, + getActiveAccountId, + listAccounts, + removeAccount, + setActiveAccount, + upsertAccount +} from './antigravity-account-store' + +const creds = (accessToken: string) => ({ + access_token: accessToken, + refresh_token: `refresh-${accessToken}`, + expiry_date: Date.now() + 3_600_000 +}) + +describe('antigravity-account-store', () => { + beforeEach(() => { + state.userData = mkdtempSync(join(tmpdir(), 'orca-agy-store-')) + netFetchMock.mockReset() + }) + + afterEach(() => { + rmSync(state.userData, { recursive: true, force: true }) + vi.clearAllMocks() + }) + + it('adds the first account, makes it active, and round-trips encrypted credentials', () => { + const id = upsertAccount('a@gmail.com', creds('tok-a')) + expect(getActiveAccountId()).toBe(id) + expect(listAccounts()).toEqual([{ id, email: 'a@gmail.com', isActive: true }]) + expect(getActiveAccountCredentials()?.access_token).toBe('tok-a') + expect(getAccountCredentials(id)?.refresh_token).toBe('refresh-tok-a') + }) + + it('keeps the first account active when a second is added without makeActive', () => { + const first = upsertAccount('a@gmail.com', creds('tok-a')) + upsertAccount('b@gmail.com', creds('tok-b')) + expect(getActiveAccountId()).toBe(first) + expect(listAccounts().map((a) => a.email)).toEqual(['a@gmail.com', 'b@gmail.com']) + }) + + it('switches the active account and updates credentials returned', () => { + upsertAccount('a@gmail.com', creds('tok-a')) + const second = upsertAccount('b@gmail.com', creds('tok-b')) + expect(setActiveAccount(second)).toBe(true) + expect(getActiveAccountId()).toBe(second) + expect(getActiveAccountCredentials()?.access_token).toBe('tok-b') + expect(listAccounts().find((a) => a.id === second)?.isActive).toBe(true) + }) + + it('dedupes by email and refreshes the stored credential', () => { + const id = upsertAccount('a@gmail.com', creds('tok-a')) + const again = upsertAccount('a@gmail.com', creds('tok-a2')) + expect(again).toBe(id) + expect(listAccounts()).toHaveLength(1) + expect(getAccountCredentials(id)?.access_token).toBe('tok-a2') + }) + + it('removes an account and re-points the active pointer', () => { + const first = upsertAccount('a@gmail.com', creds('tok-a')) + const second = upsertAccount('b@gmail.com', creds('tok-b')) + setActiveAccount(second) + removeAccount(second) + expect(getActiveAccountId()).toBe(first) + expect(listAccounts()).toHaveLength(1) + }) + + it('setActiveAccount returns false for an unknown id', () => { + upsertAccount('a@gmail.com', creds('tok-a')) + expect(setActiveAccount('nope')).toBe(false) + }) + + it('fetchAccountEmail returns the email from the userinfo endpoint', async () => { + netFetchMock.mockResolvedValue({ ok: true, json: async () => ({ email: 'x@gmail.com' }) }) + expect(await fetchAccountEmail('tok')).toBe('x@gmail.com') + }) + + it('fetchAccountEmail returns null on a failed request', async () => { + netFetchMock.mockResolvedValue({ ok: false, json: async () => ({}) }) + expect(await fetchAccountEmail('tok')).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-account-store.ts b/src/main/rate-limits/antigravity-account-store.ts new file mode 100644 index 000000000000..44637355cbdd --- /dev/null +++ b/src/main/rate-limits/antigravity-account-store.ts @@ -0,0 +1,195 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { app, net, safeStorage } from 'electron' +import type { AntigravityAccountSummary } from '../../shared/rate-limit-types' +import type { GeminiCredentials } from './gemini-oauth-sources' + +// Why: `agy` is single-account — it keeps one Google OAuth token in the OS +// keyring, replaced on each re-login. To offer multiple Antigravity accounts in +// the status bar, Orca maintains its own account store: it snapshots the OAuth +// credentials for each Google account the user has signed into and lets the +// user switch between them. Credentials are encrypted at rest with Electron's +// safeStorage (OS-backed); only account metadata (id, email) is kept in clear. +const STORE_DIR_NAME = 'antigravity-accounts' +const STORE_FILE_NAME = 'accounts.json' +const USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' +const API_TIMEOUT_MS = 10_000 + +type StoredAccount = { + id: string + email: string + /** base64 safeStorage ciphertext of the JSON GeminiCredentials, or plaintext JSON fallback. */ + credential: string + encrypted: boolean +} + +type AccountStoreFile = { + activeAccountId: string | null + accounts: StoredAccount[] +} + +/** Directory holding the account store, under Electron's userData path. */ +function storeDir(): string { + return join(app.getPath('userData'), STORE_DIR_NAME) +} + +/** Full path to the account store JSON file. */ +function storePath(): string { + return join(storeDir(), STORE_FILE_NAME) +} + +/** + * Load the on-disk store, returning an empty store when absent or unreadable. + * Fully guarded (incl. resolving the userData path) so a store failure can never + * propagate into the rate-limit fetch cycle that reads it synchronously. + */ +function loadStore(): AccountStoreFile { + try { + const path = storePath() + if (!existsSync(path)) { + return { activeAccountId: null, accounts: [] } + } + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial + return { + activeAccountId: typeof parsed.activeAccountId === 'string' ? parsed.activeAccountId : null, + accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] + } + } catch { + return { activeAccountId: null, accounts: [] } + } +} + +/** Persist the store atomically (0600), creating the directory as needed. */ +function saveStore(store: AccountStoreFile): void { + mkdirSync(storeDir(), { recursive: true }) + const tmp = `${storePath()}.${process.pid}.tmp` + writeFileSync(tmp, JSON.stringify(store, null, 2), { encoding: 'utf-8', mode: 0o600 }) + // rename is atomic on the same volume; fall back to a direct write on failure. + try { + renameSync(tmp, storePath()) + } catch { + writeFileSync(storePath(), JSON.stringify(store, null, 2), { encoding: 'utf-8', mode: 0o600 }) + } +} + +/** Encrypt credentials with safeStorage (base64), or fall back to plain JSON when unavailable. */ +function encryptCredential(creds: GeminiCredentials): string { + const json = JSON.stringify(creds) + if (safeStorage.isEncryptionAvailable()) { + return safeStorage.encryptString(json).toString('base64') + } + return json +} + +/** Decrypt a stored account's credentials; returns null when the blob is invalid. */ +function decryptCredential(account: StoredAccount): GeminiCredentials | null { + try { + const raw = account.encrypted + ? safeStorage.decryptString(Buffer.from(account.credential, 'base64')) + : account.credential + const parsed = JSON.parse(raw) as GeminiCredentials + if (typeof parsed.access_token === 'string' && typeof parsed.expiry_date === 'number') { + return parsed + } + return null + } catch { + return null + } +} + +/** Fetch the Google account email for an access token via the OpenID userinfo endpoint. */ +export async function fetchAccountEmail(accessToken: string): Promise { + try { + const res = await net.fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }) + if (!res.ok) { + return null + } + const data = (await res.json()) as { email?: unknown } + return typeof data.email === 'string' ? data.email : null + } catch { + return null + } +} + +/** List all stored accounts with their active flag, for the status-bar switcher. */ +export function listAccounts(): AntigravityAccountSummary[] { + const store = loadStore() + return store.accounts.map((a) => ({ + id: a.id, + email: a.email, + isActive: a.id === store.activeAccountId + })) +} + +/** The id of the active account, or null when none is stored. */ +export function getActiveAccountId(): string | null { + return loadStore().activeAccountId +} + +/** Decrypt and return an account's credentials, or null. */ +export function getAccountCredentials(id: string): GeminiCredentials | null { + const account = loadStore().accounts.find((a) => a.id === id) + return account ? decryptCredential(account) : null +} + +/** Credentials for the active account, or null when none is stored. */ +export function getActiveAccountCredentials(): GeminiCredentials | null { + const store = loadStore() + if (!store.activeAccountId) { + return null + } + return getAccountCredentials(store.activeAccountId) +} + +/** + * Add (or refresh) an account keyed by email, encrypting its credentials. When + * `makeActive` is true (or it is the first account), it becomes the active one. + * Returns the account id. + */ +export function upsertAccount(email: string, creds: GeminiCredentials, makeActive = false): string { + const store = loadStore() + const existing = store.accounts.find((a) => a.email === email) + const credential = encryptCredential(creds) + const encrypted = safeStorage.isEncryptionAvailable() + if (existing) { + existing.credential = credential + existing.encrypted = encrypted + if (makeActive) { + store.activeAccountId = existing.id + } + saveStore(store) + return existing.id + } + const id = randomUUID() + store.accounts.push({ id, email, credential, encrypted }) + if (makeActive || store.accounts.length === 1) { + store.activeAccountId = id + } + saveStore(store) + return id +} + +/** Set the active account. No-op when the id is unknown. */ +export function setActiveAccount(id: string): boolean { + const store = loadStore() + if (!store.accounts.some((a) => a.id === id)) { + return false + } + store.activeAccountId = id + saveStore(store) + return true +} + +/** Remove an account; re-points the active account to the first remaining one. */ +export function removeAccount(id: string): void { + const store = loadStore() + store.accounts = store.accounts.filter((a) => a.id !== id) + if (store.activeAccountId === id) { + store.activeAccountId = store.accounts[0]?.id ?? null + } + saveStore(store) +} diff --git a/src/main/rate-limits/antigravity-fetcher.test.ts b/src/main/rate-limits/antigravity-fetcher.test.ts new file mode 100644 index 000000000000..b65b37cc8d54 --- /dev/null +++ b/src/main/rate-limits/antigravity-fetcher.test.ts @@ -0,0 +1,213 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GeminiCredentials } from './gemini-oauth-sources' + +const netFetchMock = vi.hoisted(() => vi.fn()) +const oauthState = vi.hoisted<{ + credentials: GeminiCredentials | null + readError: Error | null + refreshAccessToken: string | null +}>(() => ({ + credentials: null, + readError: null, + refreshAccessToken: 'refreshed-token' +})) + +vi.mock('electron', () => ({ + net: { fetch: netFetchMock } +})) + +const keyringState = vi.hoisted<{ credentials: GeminiCredentials | null }>(() => ({ + credentials: null +})) + +vi.mock('./gemini-oauth-sources', () => ({ + readGeminiCredentials: async () => { + if (oauthState.readError) { + throw oauthState.readError + } + return oauthState.credentials + }, + tryRefreshTokenFromBundle: async () => + oauthState.refreshAccessToken + ? { accessToken: oauthState.refreshAccessToken, newRefreshToken: null } + : null +})) + +vi.mock('./antigravity-keyring', () => ({ + readAntigravityKeyringCredentials: () => keyringState.credentials +})) + +import { fetchAntigravityRateLimits } from './antigravity-fetcher' + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body + } as Response +} + +const PROJECT_RESPONSE = { cloudaicompanionProject: 'projects/test-123' } + +// Shape captured from POST cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels. +const MODELS_RESPONSE = { + models: { + 'gemini-3.1-pro': { + quotaInfo: { remainingFraction: 0.25, resetTime: '2026-07-10T08:00:00Z' } + }, + 'gemini-3.1-flash': { + quotaInfo: { remainingFraction: 0.9, resetTime: '2026-07-10T08:00:00Z' } + } + } +} + +const QUOTA_RESPONSE = { + buckets: [ + { modelId: 'gemini-3.1-pro', remainingFraction: 0.4, resetTime: '2026-07-10T09:00:00Z' } + ] +} + +function freshCredentials(): GeminiCredentials { + return { + access_token: 'tok-abc', + refresh_token: 'refresh-abc', + expiry_date: Date.now() + 60 * 60 * 1000 + } +} + +function routeByUrl(handlers: { models?: Response; quota?: Response; project?: Response }): void { + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(handlers.project ?? jsonResponse(PROJECT_RESPONSE)) + } + if (url.includes('fetchAvailableModels')) { + return Promise.resolve(handlers.models ?? jsonResponse(MODELS_RESPONSE)) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(handlers.quota ?? jsonResponse(QUOTA_RESPONSE)) + } + return Promise.resolve(jsonResponse({}, 404)) + }) +} + +describe('fetchAntigravityRateLimits', () => { + beforeEach(() => { + netFetchMock.mockReset() + oauthState.credentials = null + oauthState.readError = null + oauthState.refreshAccessToken = 'refreshed-token' + keyringState.credentials = null + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('reports unavailable when no ~/.gemini credentials exist', async () => { + oauthState.credentials = null + const result = await fetchAntigravityRateLimits() + expect(result.provider).toBe('antigravity') + expect(result.status).toBe('unavailable') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('falls back to the OS keyring when no ~/.gemini file exists', async () => { + oauthState.credentials = null + keyringState.credentials = freshCredentials() + routeByUrl({}) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(75) + }) + + it('maps fetchAvailableModels quota into buckets with a most-constrained session', async () => { + oauthState.credentials = freshCredentials() + routeByUrl({}) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('ok') + expect(result.buckets && result.buckets.length).toBeGreaterThan(0) + // 0.25 remaining => 75% used is the most constrained model. + expect(result.session?.usedPercent).toBe(75) + expect(result.session?.resetsAt).toBe(new Date('2026-07-10T08:00:00Z').getTime()) + }) + + it('groups models into Gemini and Claude/GPT families and drops internal buckets', async () => { + oauthState.credentials = freshCredentials() + routeByUrl({ + models: jsonResponse({ + models: { + 'gemini-2.5-pro': { + quotaInfo: { remainingFraction: 0.2, resetTime: '2026-07-10T08:00:00Z' } + }, + 'gemini-2.5-flash': { + quotaInfo: { remainingFraction: 0.9, resetTime: '2026-07-10T08:00:00Z' } + }, + 'claude-sonnet-4-6': { + quotaInfo: { remainingFraction: 0.5, resetTime: '2026-07-10T09:00:00Z' } + }, + 'gpt-oss-120b': { + quotaInfo: { remainingFraction: 0.7, resetTime: '2026-07-10T09:00:00Z' } + }, + tab_flash_lite_preview: { + quotaInfo: { remainingFraction: 1, resetTime: '2026-07-10T08:00:00Z' } + }, + chat_20706: { quotaInfo: { remainingFraction: 1, resetTime: '2026-07-10T08:00:00Z' } } + } + }) + }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('ok') + // Two families; internal buckets dropped. + expect(result.buckets?.map((b) => b.name)).toEqual(['Gemini Models', 'Claude and GPT models']) + // Each family shows its most-constrained model: Gemini pro 80%, Claude/GPT 50%. + expect(result.buckets?.map((b) => b.usedPercent)).toEqual([80, 50]) + }) + + it('falls back to retrieveUserQuota when fetchAvailableModels is empty', async () => { + oauthState.credentials = freshCredentials() + routeByUrl({ models: jsonResponse({ models: {} }) }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('ok') + // 0.4 remaining => 60% used from the quota bucket. + expect(result.session?.usedPercent).toBe(60) + }) + + it('refreshes the token in memory and retries once on 401', async () => { + oauthState.credentials = freshCredentials() + let modelsCalls = 0 + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(jsonResponse(PROJECT_RESPONSE)) + } + if (url.includes('fetchAvailableModels')) { + modelsCalls += 1 + return Promise.resolve( + modelsCalls === 1 ? jsonResponse({}, 401) : jsonResponse(MODELS_RESPONSE) + ) + } + return Promise.resolve(jsonResponse({}, 404)) + }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('ok') + expect(modelsCalls).toBe(2) + }) + + it('errors when the token is expired and cannot be refreshed', async () => { + oauthState.credentials = { + access_token: 'stale', + refresh_token: 'refresh-abc', + expiry_date: Date.now() - 1000 + } + oauthState.refreshAccessToken = null + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('error') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('errors when the quota response has no usable buckets', async () => { + oauthState.credentials = freshCredentials() + routeByUrl({ models: jsonResponse({ models: {} }), quota: jsonResponse({ buckets: [] }) }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('error') + }) +}) diff --git a/src/main/rate-limits/antigravity-fetcher.ts b/src/main/rate-limits/antigravity-fetcher.ts new file mode 100644 index 000000000000..a329db34692f --- /dev/null +++ b/src/main/rate-limits/antigravity-fetcher.ts @@ -0,0 +1,352 @@ +import { net } from 'electron' +import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' +import { + readGeminiCredentials, + tryRefreshTokenFromBundle, + type GeminiCredentials +} from './gemini-oauth-sources' +import { readAntigravityKeyringCredentials } from './antigravity-keyring' +import { buildRateLimitBucket, deriveSessionSummary } from './gemini-bucket-formatting' + +// Why: Antigravity (Google's agentic coding tool, CLI `agy`) authenticates the +// same Google account the Gemini CLI uses and shares the `~/.gemini` config +// directory. Its subscription usage is served by Google's Code Assist backend +// on the same host as Gemini's, but under the ANTIGRAVITY ideType, which +// returns Antigravity's own per-model quota rather than the Gemini CLI quota. +// Phase 1 is single-account and READ-ONLY. It sources the Google OAuth token +// from, in order: (1) `~/.gemini/oauth_creds.json` (written by a Gemini CLI +// login), then (2) the OS credential store where the `agy` CLI keeps its token +// (Windows Credential Manager entry `gemini:antigravity`, macOS/Linux keyring). +// It never rewrites either source, so it can't race the Gemini provider or the +// `agy` CLI that own them. +const API_TIMEOUT_MS = 10_000 +// Nominal session window (5h) shown on the status-bar segment label, matching +// the other subscription providers; the exact per-model reset is in the popover. +const SESSION_WINDOW_MINUTES = 300 +// Internal autocomplete/chat quota buckets that are not user-facing models. +const INTERNAL_MODEL_RE = /^(tab_|chat_)/ +const BASE_URL = 'https://cloudcode-pa.googleapis.com' +const LOAD_CODE_ASSIST_URL = `${BASE_URL}/v1internal:loadCodeAssist` +const FETCH_AVAILABLE_MODELS_URL = `${BASE_URL}/v1internal:fetchAvailableModels` +const RETRIEVE_QUOTA_URL = `${BASE_URL}/v1internal:retrieveUserQuota` + +// Why: mirrors CodexBar's AntigravityRemoteUsageFetcher metadata so the backend +// returns Antigravity quota buckets, not the Gemini CLI's. +const ANTIGRAVITY_METADATA = { + ideType: 'ANTIGRAVITY', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' +} as const + +type ModelQuota = { remainingFraction: number; resetTime: string; modelId: string } + +// Why: the Antigravity app groups usage into two model families; we mirror those +// exact labels so the meter reads the same as the app's usage panel. +type AntigravityFamily = 'gemini' | 'claude-gpt' +const FAMILY_LABEL: Record = { + gemini: 'Gemini Models', + 'claude-gpt': 'Claude and GPT models' +} + +/** Classify a model id into its Antigravity display family (Gemini vs Claude/GPT). */ +function modelFamily(modelId: string): AntigravityFamily { + return modelId.startsWith('gemini') ? 'gemini' : 'claude-gpt' +} + +/** Build an `unavailable` result — the provider is not configured (no credentials). */ +function unavailable(error: string): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'unavailable' + } +} + +/** Build an `error` result — a configured provider that failed transiently. */ +function failed(error: string): ProviderRateLimits { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error, + status: 'error' + } +} + +/** POST a JSON body to a Code Assist endpoint with the Antigravity Bearer auth headers. */ +async function postJson(url: string, accessToken: string, body: unknown): Promise { + return net.fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + // Why: the Antigravity backend keys quota off the calling client's + // User-Agent; CodexBar sends "antigravity" and so do we. + 'User-Agent': 'antigravity' + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }) +} + +/** + * Extract the Code Assist project id from `cloudaicompanionProject`, which + * comes back either as a bare string or a `{ value: string }` reference + * depending on the account's onboarding state. + */ +function extractProjectId(value: unknown): string { + if (typeof value === 'string') { + return value.trim() + } + if (value && typeof value === 'object' && 'value' in value) { + const inner = (value as { value?: unknown }).value + return typeof inner === 'string' ? inner.trim() : '' + } + return '' +} + +/** Resolve the Code Assist project id via `loadCodeAssist` under the ANTIGRAVITY ideType. */ +async function loadProjectId(accessToken: string): Promise { + const res = await postJson(LOAD_CODE_ASSIST_URL, accessToken, { metadata: ANTIGRAVITY_METADATA }) + if (!res.ok) { + throw new Error(`loadCodeAssist failed (HTTP ${res.status})`) + } + const data = (await res.json()) as { cloudaicompanionProject?: unknown } + return extractProjectId(data.cloudaicompanionProject) +} + +/** Type guard for a finite number (rejects NaN/Infinity and non-numbers). */ +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +/** + * Parse the primary usage source: `fetchAvailableModels` returns a + * `{ models: { : { quotaInfo: { remainingFraction, resetTime } } } }` map, + * Antigravity's live per-model quota view. Skips entries without usable quota. + */ +function parseModelQuotas(data: unknown): ModelQuota[] { + if (!data || typeof data !== 'object' || !('models' in data)) { + return [] + } + const models = (data as { models?: Record }).models + if (!models || typeof models !== 'object') { + return [] + } + const quotas: ModelQuota[] = [] + for (const [modelId, model] of Object.entries(models)) { + if (!model || typeof model !== 'object' || !('quotaInfo' in model)) { + continue + } + const quotaInfo = (model as { quotaInfo?: unknown }).quotaInfo + if (!quotaInfo || typeof quotaInfo !== 'object') { + continue + } + const { remainingFraction, resetTime } = quotaInfo as { + remainingFraction?: unknown + resetTime?: unknown + } + if (isFiniteNumber(remainingFraction) && typeof resetTime === 'string') { + quotas.push({ remainingFraction, resetTime, modelId }) + } + } + return quotas +} + +/** Parse the fallback usage source: `retrieveUserQuota` returns a `{ buckets: [...] }` array. */ +function parseQuotaBuckets(data: unknown): ModelQuota[] { + let rawBuckets: unknown[] = [] + if (data && typeof data === 'object' && 'buckets' in data && Array.isArray(data.buckets)) { + rawBuckets = data.buckets + } + const quotas: ModelQuota[] = [] + for (const bucket of rawBuckets) { + if (!bucket || typeof bucket !== 'object') { + continue + } + const { remainingFraction, resetTime, modelId } = bucket as { + remainingFraction?: unknown + resetTime?: unknown + modelId?: unknown + } + if (isFiniteNumber(remainingFraction) && typeof resetTime === 'string') { + quotas.push({ + remainingFraction, + resetTime, + modelId: typeof modelId === 'string' ? modelId : 'unknown' + }) + } + } + return quotas +} + +/** + * Fetch per-model quotas, preferring `fetchAvailableModels` and falling back to + * `retrieveUserQuota` when the former is empty or errors. Throws + * `UnauthorizedError` on 401 so the caller can refresh and retry once. + */ +async function fetchModelQuotas(accessToken: string, projectId: string): Promise { + const body = projectId ? { project: projectId } : {} + const modelsRes = await postJson(FETCH_AVAILABLE_MODELS_URL, accessToken, body) + if (modelsRes.status === 401) { + throw new UnauthorizedError() + } + if (modelsRes.ok) { + const quotas = parseModelQuotas(await modelsRes.json()) + if (quotas.length > 0) { + return quotas + } + } + // Fall back to retrieveUserQuota when fetchAvailableModels is empty or errors. + const quotaRes = await postJson(RETRIEVE_QUOTA_URL, accessToken, body) + if (quotaRes.status === 401) { + throw new UnauthorizedError() + } + if (!quotaRes.ok) { + throw new Error(`Quota fetch failed (HTTP ${quotaRes.status})`) + } + return parseQuotaBuckets(await quotaRes.json()) +} + +/** Internal sentinel thrown on an HTTP 401 to trigger a single refresh + retry. */ +class UnauthorizedError extends Error { + constructor() { + super('Antigravity request unauthorized (HTTP 401)') + this.name = 'UnauthorizedError' + } +} + +/** + * Map parsed model quotas into a `ProviderRateLimits`, deduplicating buckets and + * summarizing the most-constrained one as the session window. Empty → `error`. + */ +function toRateLimits(quotas: ModelQuota[]): ProviderRateLimits { + // Why: mirror how the Antigravity app itself presents usage — two model + // families ("Gemini Models" and "Claude and GPT models"), each collapsed to a + // single limit — instead of ~20 near-identical per-model rows. Each family + // shows its most-constrained model (the binding limit). Internal + // autocomplete/chat buckets (`tab_*`, `chat_*`) are dropped. + const byFamily = new Map() + for (const quota of quotas) { + if (INTERNAL_MODEL_RE.test(quota.modelId)) { + continue + } + const family = modelFamily(quota.modelId) + const bucket = buildRateLimitBucket(quota) + const current = byFamily.get(family) + // Keep the most-constrained (highest used) model as the family's limit. + if (!current || bucket.usedPercent > current.usedPercent) { + byFamily.set(family, { ...bucket, name: FAMILY_LABEL[family] }) + } + } + const buckets: RateLimitBucket[] = [...byFamily.values()].sort( + (a, b) => b.usedPercent - a.usedPercent + ) + if (buckets.length === 0) { + return failed('Antigravity quota response did not include any model buckets') + } + const session = deriveSessionSummary(buckets) + // Why: the per-model buckets use Gemini's nominal 1h window; the status-bar + // segment renders the session with a "5h" label for parity with the other + // subscription providers (the exact reset stays visible in the popover). + if (session) { + session.windowMinutes = SESSION_WINDOW_MINUTES + } + return { + provider: 'antigravity', + session, + weekly: null, + buckets, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +/** + * Read the current Antigravity credentials from disk (Gemini CLI login) then the + * OS keyring (agy). Used by the account store to seed / capture accounts. + */ +export async function readAntigravityCredentials(): Promise { + try { + return (await readGeminiCredentials()) ?? readAntigravityKeyringCredentials() + } catch { + return null + } +} + +/** + * Return a usable access token: the stored one if unexpired, otherwise a fresh + * one minted in memory via the Gemini CLI OAuth client. Never persisted — see + * the inline note on why we don't rewrite the credential store. + */ +async function resolveAccessToken(creds: GeminiCredentials): Promise { + if (creds.expiry_date >= Date.now() && creds.access_token) { + return creds.access_token + } + // Why: read-only refresh — reuse the Gemini CLI's OAuth client (same Google + // account, same `~/.gemini` creds) to mint a fresh access token in memory. + // We deliberately do NOT persist it: the Gemini provider owns that file, and + // Google refresh tokens are reusable, so an in-memory token is sufficient. + const refreshed = await tryRefreshTokenFromBundle(creds.refresh_token, true) + return refreshed?.accessToken ?? null +} + +/** + * Read-only, single-account Antigravity subscription usage. + * + * Reads the Google OAuth token at `~/.gemini/oauth_creds.json` (written by a + * Gemini/Google login on this host — the `agy` CLI keeps its own token in the + * OS keyring, with no readable file), resolves the Code Assist project under + * the ANTIGRAVITY ideType, and reports Antigravity's per-model quota. Never + * writes the credentials file. + * + * @param credsOverride when provided (multi-account mode), fetch usage for that + * specific account's stored credentials instead of the on-disk / keyring token. + */ +export async function fetchAntigravityRateLimits( + credsOverride?: GeminiCredentials | null +): Promise { + let creds: GeminiCredentials | null + try { + // Prefer an explicit account's credentials; otherwise the file first + // (Gemini CLI login), then the OS keyring where `agy` stores its token — + // most Antigravity users only have the latter. + creds = credsOverride ?? (await readGeminiCredentials()) ?? readAntigravityKeyringCredentials() + } catch (err) { + return failed(err instanceof Error ? err.message : 'Unable to read Antigravity credentials') + } + if (!creds) { + return unavailable( + 'Not signed in to Antigravity (no ~/.gemini credentials or agy keyring token)' + ) + } + + try { + const accessToken = await resolveAccessToken(creds) + if (!accessToken) { + return failed('Antigravity token expired — sign in again to refresh') + } + const projectId = await loadProjectId(accessToken).catch(() => '') + try { + return toRateLimits(await fetchModelQuotas(accessToken, projectId)) + } catch (err) { + if (!(err instanceof UnauthorizedError)) { + throw err + } + // One refresh + retry on 401, mirroring the Gemini fetcher. + const refreshed = await tryRefreshTokenFromBundle(creds.refresh_token, true) + if (!refreshed?.accessToken) { + return failed('Antigravity request unauthorized and token refresh failed') + } + const retryProjectId = await loadProjectId(refreshed.accessToken).catch(() => projectId) + return toRateLimits(await fetchModelQuotas(refreshed.accessToken, retryProjectId)) + } + } catch (err) { + return failed(err instanceof Error ? err.message : 'Antigravity usage request failed') + } +} diff --git a/src/main/rate-limits/antigravity-keyring.test.ts b/src/main/rate-limits/antigravity-keyring.test.ts new file mode 100644 index 000000000000..c4058890b1da --- /dev/null +++ b/src/main/rate-limits/antigravity-keyring.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileSyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ + execFileSync: execFileSyncMock +})) + +import { + readAntigravityKeyringCredentials, + writeAntigravityKeyringCredentials +} from './antigravity-keyring' + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) +} + +// The real agy blob shape from Windows Credential Manager `gemini:antigravity`. +const AGY_BLOB = JSON.stringify({ + token: { + access_token: 'ya29.test-access', + token_type: 'Bearer', + refresh_token: '1//test-refresh', + expiry: '2026-07-09T16:35:43.000Z' + }, + auth_method: 'consumer' +}) + +describe('readAntigravityKeyringCredentials', () => { + beforeEach(() => { + execFileSyncMock.mockReset() + }) + + afterEach(() => { + setPlatform(originalPlatform) + vi.clearAllMocks() + }) + + it('reads and normalizes the Windows Credential Manager blob', () => { + setPlatform('win32') + execFileSyncMock.mockReturnValue(AGY_BLOB) + const creds = readAntigravityKeyringCredentials() + expect(creds).not.toBeNull() + expect(creds?.access_token).toBe('ya29.test-access') + expect(creds?.refresh_token).toBe('1//test-refresh') + expect(creds?.expiry_date).toBe(new Date('2026-07-09T16:35:43.000Z').getTime()) + // Passed the CredRead script via -EncodedCommand. + const [cmd, args] = execFileSyncMock.mock.calls[0] as [string, string[]] + expect(cmd).toBe('powershell') + expect(args).toContain('-EncodedCommand') + }) + + it('tolerates a flat token shape without the token wrapper', () => { + setPlatform('win32') + execFileSyncMock.mockReturnValue( + JSON.stringify({ + access_token: 'flat-access', + refresh_token: 'flat-refresh', + expiry_date: 1_783_614_943_568 + }) + ) + const creds = readAntigravityKeyringCredentials() + expect(creds?.access_token).toBe('flat-access') + expect(creds?.expiry_date).toBe(1_783_614_943_568) + }) + + it('returns null when the credential is absent (CredRead miss throws)', () => { + setPlatform('win32') + execFileSyncMock.mockImplementation(() => { + throw new Error('Command failed: exit 1') + }) + expect(readAntigravityKeyringCredentials()).toBeNull() + }) + + it('returns null on malformed JSON', () => { + setPlatform('win32') + execFileSyncMock.mockReturnValue('not json') + expect(readAntigravityKeyringCredentials()).toBeNull() + }) + + it('uses the macOS security tool on darwin', () => { + setPlatform('darwin') + execFileSyncMock.mockReturnValue(AGY_BLOB) + const creds = readAntigravityKeyringCredentials() + expect(creds?.access_token).toBe('ya29.test-access') + const [cmd] = execFileSyncMock.mock.calls[0] as [string, string[]] + expect(cmd).toBe('security') + }) + + it('returns null on unsupported platforms without invoking a shell', () => { + setPlatform('freebsd' as NodeJS.Platform) + expect(readAntigravityKeyringCredentials()).toBeNull() + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('write returns false (never throws) on a bad expiry_date', () => { + setPlatform('win32') + const creds = { access_token: 'a', refresh_token: 'r', expiry_date: Number.NaN } + // A NaN expiry makes toISOString() throw; it must resolve to false, and the + // failure must be caught before any shell-out. + expect(writeAntigravityKeyringCredentials(creds)).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) + + it('write returns false on non-Windows platforms without a shell-out', () => { + setPlatform('darwin') + const creds = { access_token: 'a', refresh_token: 'r', expiry_date: Date.now() } + expect(writeAntigravityKeyringCredentials(creds)).toBe(false) + expect(execFileSyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/antigravity-keyring.ts b/src/main/rate-limits/antigravity-keyring.ts new file mode 100644 index 000000000000..64df8802fcd3 --- /dev/null +++ b/src/main/rate-limits/antigravity-keyring.ts @@ -0,0 +1,242 @@ +import { execFileSync } from 'node:child_process' +import type { GeminiCredentials } from './gemini-oauth-sources' + +// Why: the `agy` (Antigravity) CLI stores its Google OAuth token in the OS +// credential store, not in a file like the Gemini CLI's oauth_creds.json. On +// Windows the entry is a Generic credential named `gemini:antigravity`; macOS +// and Linux use the platform keyring with the same identifiers. This module +// reads that entry read-only and normalizes it to the same shape the file path +// returns, so the Antigravity usage fetcher works for real `agy` users who +// never run the Gemini CLI. Background usage polling only ever READS this entry; +// the one exception is an explicit user-initiated account switch, which writes +// the selected account's token back (Windows only) so `agy` itself uses the +// switched account on its next launch. +const KEYRING_SERVICE = 'gemini' +const KEYRING_ACCOUNT = 'antigravity' +const WINDOWS_TARGET = `${KEYRING_SERVICE}:${KEYRING_ACCOUNT}` +const CMD_TIMEOUT_MS = 10_000 + +type KeyringToken = { + access_token?: unknown + refresh_token?: unknown + expiry?: unknown + expiry_date?: unknown +} + +type KeyringBlob = KeyringToken & { token?: KeyringToken } + +/** + * Normalize an expiry value to a Unix-ms timestamp. Accepts an ISO-8601 string + * (agy's format), epoch seconds, or epoch milliseconds; returns null when the + * value is missing or unparseable. + */ +function toMillis(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + // Heuristic: seconds vs milliseconds (agy stores ISO strings, but be safe). + return value > 10_000_000_000 ? value : value * 1000 + } + if (typeof value === 'string' && value.trim()) { + const parsed = new Date(value).getTime() + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +/** + * Parse a raw keyring blob into the `GeminiCredentials` shape. Tolerates both + * agy's nested `{ token: { … } }` layout and a flat token object; returns null + * when the blob is not JSON or lacks a usable access token / expiry. + */ +function normalize(raw: string): GeminiCredentials | null { + let parsed: KeyringBlob + try { + parsed = JSON.parse(raw) as KeyringBlob + } catch { + return null + } + // agy nests the OAuth token under `token`; tolerate a flat shape too. + const token: KeyringToken = parsed.token ?? parsed + const accessToken = typeof token.access_token === 'string' ? token.access_token : null + const refreshToken = typeof token.refresh_token === 'string' ? token.refresh_token : '' + const expiryDate = toMillis(token.expiry ?? token.expiry_date) + if (!accessToken || expiryDate === null) { + return null + } + return { access_token: accessToken, refresh_token: refreshToken, expiry_date: expiryDate } +} + +/** + * Read the `gemini:antigravity` Generic credential from the Windows Credential + * Manager and return its raw JSON blob (or null when absent). The CredRead + * P/Invoke script is passed via -EncodedCommand (UTF-16LE base64) so the + * embedded C# and quotes survive shell parsing intact — mirroring the + * established `execFileSync('powershell', …)` pattern in browser-cookie-import. + */ +function readWindowsCredential(): string | null { + const script = ` +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public class OrcaAntigravityCred { + [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern bool CredRead(string target, int type, int flags, out IntPtr credential); + [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr cred); + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; public int Type; public string TargetName; public string Comment; + public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob; + public int Persist; public int AttributeCount; public IntPtr Attributes; + public string TargetAlias; public string UserName; + } + public static byte[] Read(string target) { + IntPtr p; + if (!CredRead(target, 1, 0, out p)) return null; + var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL)); + var b = new byte[c.CredentialBlobSize]; + Marshal.Copy(c.CredentialBlob, b, 0, c.CredentialBlobSize); + CredFree(p); + return b; + } +} +'@ +$bytes = [OrcaAntigravityCred]::Read('${WINDOWS_TARGET}') +if ($null -eq $bytes) { exit 1 } +$text = [System.Text.Encoding]::UTF8.GetString($bytes) +if ($text -notmatch '[{]') { $text = [System.Text.Encoding]::Unicode.GetString($bytes) } +Write-Output $text +` + const encoded = Buffer.from(script, 'utf16le').toString('base64') + const out = execFileSync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { encoding: 'utf-8', timeout: CMD_TIMEOUT_MS } + ) + return out.trim() || null +} + +/** + * Read the agy token from the macOS Keychain (best-effort). Assumes a generic + * password stored under the same `gemini` / `antigravity` identifiers; returns + * its raw blob or null. + */ +function readMacCredential(): string | null { + const out = execFileSync( + 'security', + ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'], + { encoding: 'utf-8', timeout: CMD_TIMEOUT_MS } + ) + return out.trim() || null +} + +/** + * Read the agy token from the Linux libsecret keyring (best-effort) via + * `secret-tool`, matching the same service/account attributes; returns its raw + * blob or null. + */ +function readLinuxCredential(): string | null { + const out = execFileSync( + 'secret-tool', + ['lookup', 'service', KEYRING_SERVICE, 'account', KEYRING_ACCOUNT], + { encoding: 'utf-8', timeout: CMD_TIMEOUT_MS } + ) + return out.trim() || null +} + +/** + * Read the Antigravity (`agy`) OAuth token from the OS credential store. + * Returns null when the entry is absent, unreadable, or malformed — callers + * fall back to the `~/.gemini/oauth_creds.json` file path. Read-only. + */ +export function readAntigravityKeyringCredentials(): GeminiCredentials | null { + try { + let raw: string | null + if (process.platform === 'win32') { + raw = readWindowsCredential() + } else if (process.platform === 'darwin') { + raw = readMacCredential() + } else if (process.platform === 'linux') { + raw = readLinuxCredential() + } else { + return null + } + return raw ? normalize(raw) : null + } catch { + // Missing entry (CredRead miss / non-zero exit) or no keyring tool present. + return null + } +} + +/** + * Write the given credentials back into the Windows Credential Manager + * `gemini:antigravity` entry in `agy`'s native blob shape, so switching the + * active account in Orca also switches which account `agy` uses on next launch. + * Windows-only and explicit (never called from background polling); returns + * false on non-Windows or any failure, in which case Orca still switches the + * account it *displays* via its own account store. + */ +export function writeAntigravityKeyringCredentials(creds: GeminiCredentials): boolean { + if (process.platform !== 'win32') { + return false + } + // Why: pass the blob as base64 on stdin so no secret ever lands on the command + // line, and let PowerShell CredWrite the Generic credential (LocalMachine + // persistence, UserName 'antigravity') that agy reads back. + const script = ` +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public class OrcaAntigravityCredWrite { + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; public int Type; public string TargetName; public string Comment; + public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob; + public int Persist; public int AttributeCount; public IntPtr Attributes; + public string TargetAlias; public string UserName; + } + [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + public static extern bool CredWrite(ref CREDENTIAL cred, int flags); + public static bool Write(string target, string user, byte[] blob) { + var c = new CREDENTIAL(); + c.Type = 1; c.TargetName = target; c.UserName = user; c.Persist = 2; + c.CredentialBlobSize = blob.Length; + c.CredentialBlob = Marshal.AllocHGlobal(blob.Length); + Marshal.Copy(blob, 0, c.CredentialBlob, blob.Length); + try { return CredWrite(ref c, 0); } + finally { Marshal.FreeHGlobal(c.CredentialBlob); } + } +} +'@ +$blob = [System.Text.Encoding]::UTF8.GetBytes([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadLine()))) +$ok = [OrcaAntigravityCredWrite]::Write('${WINDOWS_TARGET}', '${KEYRING_ACCOUNT}', $blob) +if (-not $ok) { exit 1 } +Write-Output 'ok' +` + try { + // Inside the try: a bad (e.g. NaN) expiry_date makes toISOString() throw, + // which must resolve to `false`, not escape this function. + const blob = JSON.stringify({ + token: { + access_token: creds.access_token, + token_type: 'Bearer', + refresh_token: creds.refresh_token, + expiry: new Date(creds.expiry_date).toISOString() + }, + auth_method: 'consumer' + }) + const encoded = Buffer.from(script, 'utf16le').toString('base64') + const out = execFileSync( + 'powershell', + ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], + { + encoding: 'utf-8', + timeout: CMD_TIMEOUT_MS, + input: Buffer.from(blob, 'utf-8').toString('base64') + } + ) + return out.trim() === 'ok' + } catch { + return false + } +} diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index cd301cfe516d..ba232a5beea9 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -7,7 +7,8 @@ import type { CodexRateLimitResetResult, RateLimitState, ProviderRateLimits, - InactiveAccountUsage + InactiveAccountUsage, + AntigravityAccountSummary } from '../../shared/rate-limit-types' import { fetchClaudeRateLimits, fetchManagedAccountUsage } from './claude-fetcher' import type { InactiveClaudeAccountInfo } from './claude-fetcher' @@ -31,6 +32,18 @@ import { type CodexAccountSelectionTarget, type NormalizedCodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' +import { fetchAntigravityRateLimits, readAntigravityCredentials } from './antigravity-fetcher' +import { + fetchAccountEmail, + getAccountCredentials, + getActiveAccountCredentials, + getActiveAccountId, + listAccounts, + removeAccount, + setActiveAccount, + upsertAccount +} from './antigravity-account-store' +import { writeAntigravityKeyringCredentials } from './antigravity-keyring' export type InactiveCodexAccountInfo = { id: string @@ -137,6 +150,10 @@ export class RateLimitService { grok: null } private grokAuthConfigured = readGrokAuthSession().status === 'ok' + // Why: multi-account Antigravity keeps a summary + per-inactive-account usage + // so the status-bar switcher can render without re-reading the store each paint. + private antigravityAccountsSummary: AntigravityAccountSummary[] = [] + private antigravityInactiveAccounts: InactiveAccountUsage[] = [] private pollInterval: number = DEFAULT_POLL_MS private timer: ReturnType | null = null private deferredStartupRefreshTimer: ReturnType | null = null @@ -316,7 +333,9 @@ export class RateLimitService { inactiveCodexAccounts: this.buildInactiveArray( this.inactiveCodexCache, this.inactiveCodexFetching - ) + ), + antigravityAccounts: this.antigravityAccountsSummary, + inactiveAntigravityAccounts: this.antigravityInactiveAccounts } } @@ -1344,32 +1363,42 @@ export class RateLimitService { (reason) => ({ status: 'rejected', reason }) as const ) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), + const [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult, + antigravityResult + ] = await Promise.allSettled([ + fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), signal }), - missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), + fetchKimiRateLimits(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels }), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits(cookie, workspaceIdOverride || undefined), - fetchKimiRateLimits(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels - }) - ]) + // Why: real Antigravity Code Assist quota (not a Gemini mirror). Prefer + // the multi-account store active token; fall back to file / agy keyring. + fetchAntigravityRateLimits(getActiveAccountCredentials()) + ]) if (signal.aborted) { return @@ -1414,13 +1443,20 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity shares Google/Gemini usage credentials today; mirror the - // Gemini snapshot under provider 'antigravity' so status-bar UI that checks - // antigravity state receives a real fetch lifecycle instead of staying null. - const antigravity: ProviderRateLimits = { - ...gemini, - provider: 'antigravity' - } + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' @@ -1527,6 +1563,99 @@ export class RateLimitService { ...this.state, grok: this.applyStalePolicy(grok, previousState.grok) }) + + // Why: keep the multi-account switcher in sync after every full poll so a + // new agy login surfaces without waiting for the user to open the popover. + await this.refreshAntigravityAccounts() + } + + /** + * Refresh the Antigravity account switcher: seed the store from the current + * agy / file token, then fetch per-account usage for every non-active account. + */ + private async refreshAntigravityAccounts(): Promise { + try { + // Why: auto-follow agy so a new Google login surfaces without a manual step. + const creds = await readAntigravityCredentials() + if (creds && creds.expiry_date >= Date.now()) { + const email = await fetchAccountEmail(creds.access_token) + if (email) { + upsertAccount(email, creds, true) + } + } + } catch { + // Best-effort capture; still rebuild the switcher below. + } + await this.rebuildAntigravityAccountsState() + } + + /** + * Rebuild the account summary + per-account usage from the store and push it, + * WITHOUT capturing the current agy account (used after explicit removal). + */ + private async rebuildAntigravityAccountsState(): Promise { + try { + const accounts = listAccounts() + const activeId = getActiveAccountId() + const inactive = accounts.filter((a) => a.id !== activeId) + const inactiveUsage: InactiveAccountUsage[] = await Promise.all( + inactive.map(async (account) => { + const creds = getAccountCredentials(account.id) + const rateLimits = creds ? await fetchAntigravityRateLimits(creds) : null + return { accountId: account.id, rateLimits, updatedAt: Date.now(), isFetching: false } + }) + ) + this.antigravityAccountsSummary = accounts + this.antigravityInactiveAccounts = inactiveUsage + this.pushToRenderer() + } catch { + // Best-effort: keep the last known account summary / inactive usage. + } + } + + /** + * Switch the active Antigravity account. Updates Orca's display pointer and, + * on Windows, writes the selected token back to the gemini:antigravity keyring + * so agy itself uses the account on next launch. + */ + async selectAntigravityAccount(id: string): Promise { + if (!setActiveAccount(id)) { + return + } + const creds = getAccountCredentials(id) + if (creds) { + writeAntigravityKeyringCredentials(creds) + } + await this.refresh() + } + + /** Capture the account agy is currently signed into as a stored account. */ + async addCurrentAntigravityAccount(): Promise<{ ok: boolean; email: string | null }> { + const creds = await readAntigravityCredentials() + if (!creds || creds.expiry_date < Date.now()) { + return { ok: false, email: null } + } + const email = await fetchAccountEmail(creds.access_token) + if (!email) { + return { ok: false, email: null } + } + upsertAccount(email, creds, true) + await this.refresh() + return { ok: true, email } + } + + /** + * Remove a stored Antigravity account without immediately re-capturing the + * current agy login (so remove stays sticky until the next open/poll capture). + */ + async removeAntigravityAccount(id: string): Promise { + removeAccount(id) + await this.rebuildAntigravityAccountsState() + } + + /** Capture the current agy account when the usage popover opens. */ + async refreshAntigravityAccountsOnOpen(): Promise { + await this.refreshAntigravityAccounts() } private async runFetchCodexOnlyCycle(signal: AbortSignal): Promise { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 4babcd587bbf..a03647764890 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2988,6 +2988,10 @@ export type PreloadApi = { fetchInactiveCodexAccounts: () => Promise refreshMiniMax: () => Promise refreshGrok: () => Promise + selectAntigravityAccount: (id: string) => Promise + addCurrentAntigravityAccount: () => Promise<{ ok: boolean; email: string | null }> + removeAntigravityAccount: (id: string) => Promise + refreshAntigravityAccounts: () => Promise onUpdate: (callback: (state: RateLimitState) => void) => () => void } minimaxCredentials: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c7af55f0af2..39ea599bfcac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4012,6 +4012,14 @@ const api = { ipcRenderer.invoke('rateLimits:fetchInactiveCodexAccounts'), refreshMiniMax: (): Promise => ipcRenderer.invoke('rateLimits:refreshMiniMax'), refreshGrok: (): Promise => ipcRenderer.invoke('rateLimits:refreshGrok'), + selectAntigravityAccount: (id: string): Promise => + ipcRenderer.invoke('rateLimits:selectAntigravityAccount', id), + addCurrentAntigravityAccount: (): Promise<{ ok: boolean; email: string | null }> => + ipcRenderer.invoke('rateLimits:addCurrentAntigravityAccount'), + removeAntigravityAccount: (id: string): Promise => + ipcRenderer.invoke('rateLimits:removeAntigravityAccount', id), + refreshAntigravityAccounts: (): Promise => + ipcRenderer.invoke('rateLimits:refreshAntigravityAccounts'), onUpdate: (callback: (state: RateLimitState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, state: RateLimitState) => callback(state) ipcRenderer.on('rateLimits:update', listener) diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx index d1ef3700f834..8397febd008c 100644 --- a/src/renderer/src/components/stats/GrokUsagePane.test.tsx +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -42,7 +42,9 @@ const mockStoreState = { claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] + inactiveCodexAccounts: [], + antigravityAccounts: [], + inactiveAntigravityAccounts: [] }, refreshGrokRateLimits: storeMocks.refreshGrokRateLimits, openSettingsPage: storeMocks.openSettingsPage, diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index aa826573e73d..8c2ef6e6d928 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -57,6 +57,7 @@ import { } from './tooltip' import { ClaudeIcon, GeminiIcon, MiniMaxIcon, OpenAIIcon, OpenCodeGoIcon } from './icons' import { AgentIcon } from '@/lib/agent-catalog' +import { AntigravityAccountSwitcher } from './antigravity-account-switcher' import { formatWindowLabel } from '@/lib/window-label-formatter' import { markLiveCodexSessionsForRestart } from '@/lib/codex-session-restart' import { UpdateStatusSegment } from './UpdateStatusSegment' @@ -1155,7 +1156,10 @@ function ProviderSegment({ // Has data (ok, fetching with stale data, or error with stale data) const isStale = p.status === 'error' - if (p.buckets && p.buckets.length > 0) { + // Why: Antigravity keeps per-model buckets for the popover detail, but its + // status-bar segment mirrors session-window providers (MiniBar + %) rather + // than Gemini-style bucket names. + if (p.buckets && p.buckets.length > 0 && provider !== 'antigravity') { const visibleBuckets = p.buckets.filter((b) => STATUS_BAR_BUCKET_NAMES.has(b.name)) return ( @@ -2089,7 +2093,9 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele 'auto.components.status.bar.StatusBar.antigravityUsageDetails', 'Open Antigravity usage details' )} - /> + > + + )} {showOpencodeGo && ( +
+
+ ) + } + if (!limits?.session) { + return null + } + const used = clampUsedPercent(limits.session.usedPercent) + const usedSuffix = translate('auto.components.status.bar.tooltip.cedb7b99e3', '% used') + return ( +
+
+
+
+ + {used} + {usedSuffix} {translate('auto.components.status.bar.StatusBar.d79c3362c4', '5h')} + +
+ ) +} + +/** + * Account switcher rendered inside the Antigravity usage popover. Lists the + * stored Google accounts with per-account usage, lets the user switch the + * active account (which also re-points `agy` on Windows), and capture the + * account `agy` is currently signed into. + */ +export function AntigravityAccountSwitcher(): React.JSX.Element { + const accounts = useAppStore((s) => s.rateLimits.antigravityAccounts) + const inactive = useAppStore((s) => s.rateLimits.inactiveAntigravityAccounts) + const activeUsage = useAppStore((s) => s.rateLimits.antigravity) + const [busy, setBusy] = useState(false) + const [manageMode, setManageMode] = useState(false) + + /** Index inactive-account usage snapshots by account id for quick row lookup. */ + const inactiveById = useMemo(() => { + const map = new Map() + for (const entry of inactive) { + map.set(entry.accountId, entry) + } + return map + }, [inactive]) + + /** Run an account action behind a busy guard, logging (never throwing) on failure. */ + const run = useCallback(async (action: () => Promise) => { + setBusy(true) + try { + await action() + } catch (err) { + // Why: called via `void run(...)`, so an unhandled rejection here (e.g. a + // failed keyring write on switch) would be lost — log it and reset busy. + console.error('Antigravity account action failed', err) + } finally { + setBusy(false) + } + }, []) + + // Why: this component mounts when the popover opens, so capture the account + // agy is currently signed into right then — signing into a new account in agy + // and opening this menu surfaces it immediately, without waiting for the poll. + useEffect(() => { + void window.api.rateLimits.refreshAntigravityAccounts().catch(() => {}) + }, []) + + /** Handle a click on an account row: remove it in manage mode, else switch to it. */ + const handleAccountAction = useCallback( + (account: AntigravityAccountSummary) => { + if (manageMode) { + void run(() => window.api.rateLimits.removeAntigravityAccount(account.id)) + } else if (!account.isActive) { + void run(() => window.api.rateLimits.selectAntigravityAccount(account.id)) + } + }, + [manageMode, run] + ) + + /** Toggle the switcher between switch mode and remove/manage mode. */ + const toggleManageMode = useCallback((event: Event) => { + event.preventDefault() + setManageMode((value) => !value) + }, []) + + return ( +
+ {accounts.length > 0 ? ( +
+ {translate('auto.components.status.bar.StatusBar.a051e38c9a', 'Accounts')} +
+ ) : null} + {accounts.map((account) => { + const inactiveUsage = inactiveById.get(account.id) + // The active account's usage is the main meter state; inactive accounts + // carry their own per-account snapshot. + const usageLimits = account.isActive ? activeUsage : (inactiveUsage?.rateLimits ?? null) + const isFetching = account.isActive + ? activeUsage?.status === 'fetching' + : Boolean(inactiveUsage?.isFetching) + return ( + { + event.preventDefault() + handleAccountAction(account) + }} + > +
+
+ + {account.email} + {manageMode ? ( + + ) : account.isActive ? ( + + {translate('auto.components.status.bar.StatusBar.ff0fbe9311', 'Active')} + + ) : null} +
+ {!manageMode ? ( + + ) : null} +
+
+ ) + })} + {accounts.length > 0 ? ( + <> + + + + {manageMode + ? translate('auto.components.status.bar.StatusBar.137d65696b', 'Done') + : translate('auto.components.status.bar.StatusBar.87a19bd15f', 'Manage accounts')} + + + ) : null} + {manageMode ? ( +
+ {translate( + 'auto.components.status.bar.StatusBar.73759b7e83', + 'Removing an account only removes it from Orca; sign out in agy to fully disconnect it.' + )} +
+ ) : ( +
+ {translate( + 'auto.components.status.bar.StatusBar.1298e2427b', + 'Sign in to another Google account in agy to add it here.' + )} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 7389a372f800..f883d8e3df67 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -159,15 +159,14 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + it('does not treat Antigravity PATH/checked settings as durable without a snapshot', () => { + // Why: real usage is snapshot-driven from agy keyring / account store. expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) - ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. + ).toBe(false) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -316,28 +315,13 @@ describe('getVisibleUsageProvider', () => { ).toBe(null) }) - it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { - const visible = getVisibleUsageProvider( - 'antigravity', - null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) - ) - expect(visible).toMatchObject({ - provider: 'antigravity', - status: 'fetching', - session: null, - weekly: null - }) - }) - - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. + it('hides Antigravity until a real snapshot is configured', () => { + // Why: no durable settings signal — only isProviderConfigured data shows. expect( getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true }) + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) ).toBe(null) expect( @@ -345,7 +329,7 @@ describe('getVisibleUsageProvider', () => { 'antigravity', provider('unavailable', { provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' + error: 'Not signed in to Antigravity' }), usageSettings({ antigravityUsageConfigured: true }) ) @@ -455,27 +439,8 @@ describe('isUsageEmptyState', () => { ).toBe(true) }) - it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { - expect( - isUsageEmptyState( - { - claude: provider('unavailable', { provider: 'claude' }), - codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), - opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }), - antigravity: null, - grok: provider('unavailable', { provider: 'grok' }), - minimax: provider('unavailable', { provider: 'minimax' }) - }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) - ) - ).toBe(false) - }) - - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('still shows the setup CTA when Antigravity is checked but has no snapshot', () => { + // Why: checked PATH item alone is not configured usage; snapshot data is. expect( isUsageEmptyState( { diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 1da3dc0f7571..d6f7c833409b 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,11 +8,9 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { - // Why: Antigravity has no separate persisted usage credential in Orca. The - // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. + // Why: retained for StatusBar call sites and tests. Real Antigravity usage is + // snapshot-driven (agy keyring / account store) like Kimi — not a settings + // durable signal — so a checked item alone must not pin a dead bar. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -72,8 +70,7 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + // Antigravity is snapshot-driven (see hasUsageProviderSettingsForProvider). settings?.minimaxCookieConfigured === true || settings?.grokAuthConfigured === true ) @@ -99,10 +96,11 @@ export function hasUsageProviderSettingsForProvider( return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + // Why: credentials live in the agy keyring / multi-account store. Until a + // real snapshot is configured (ok/error with data), do not treat PATH or a + // checked item as durable — that would pin a permanent "--" bar for users + // without an agy login (same posture as Kimi). + return false } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true @@ -119,7 +117,7 @@ function createPendingProviderSnapshot(providerId: UsageProviderId): ProviderRat session: null, weekly: null, ...(providerId === 'opencode-go' ? { monthly: null } : {}), - ...(providerId === 'gemini' ? { buckets: [] } : {}), + ...(providerId === 'gemini' || providerId === 'antigravity' ? { buckets: [] } : {}), updatedAt: 0, error: null, status: 'fetching' diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 38822767e1a4..0c5772903530 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -499,7 +499,9 @@ describe('useIpcEvents rate-limit hydration', () => { claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] + inactiveCodexAccounts: [], + antigravityAccounts: [], + inactiveAntigravityAccounts: [] } const freshState = { ...staleState, diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 3df495cf32d6..5d6490d91951 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -30,7 +30,9 @@ export const createRateLimitSlice: StateCreator { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index cd77c51acefb..726f05aff7e6 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2608,7 +2608,9 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] + inactiveCodexAccounts: [], + antigravityAccounts: [], + inactiveAntigravityAccounts: [] } return { get: () => Promise.resolve(empty), @@ -2623,6 +2625,10 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { fetchInactiveCodexAccounts: () => Promise.resolve(), refreshMiniMax: () => Promise.resolve(empty), refreshGrok: () => Promise.resolve(empty), + selectAntigravityAccount: () => Promise.resolve(), + addCurrentAntigravityAccount: () => Promise.resolve({ ok: false, email: null }), + removeAntigravityAccount: () => Promise.resolve(), + refreshAntigravityAccounts: () => Promise.resolve(), onUpdate: () => noopUnsubscribe } } diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 6d35c2d22fc8..f09a385cfd4f 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -21,7 +21,9 @@ describe('RateLimitState', () => { claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], - inactiveCodexAccounts: [] + inactiveCodexAccounts: [], + antigravityAccounts: [], + inactiveAntigravityAccounts: [] } expect(state.antigravity).toBeNull() diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 65c09aaffaec..e14cab89e6ad 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -103,6 +103,13 @@ export type InactiveAccountUsage = { isFetching: boolean } +/** A stored Antigravity Google account, surfaced to the status-bar switcher. */ +export type AntigravityAccountSummary = { + id: string + email: string + isActive: boolean +} + export type GrokAccountStatus = { signedIn: boolean email: string | null @@ -133,4 +140,8 @@ export type RateLimitState = { codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[] inactiveCodexAccounts: InactiveAccountUsage[] + /** All stored Antigravity accounts (active + inactive) for the switcher. */ + antigravityAccounts: AntigravityAccountSummary[] + /** Per-account usage for the non-active Antigravity accounts. */ + inactiveAntigravityAccounts: InactiveAccountUsage[] }