From 0ff0748cf2e5cef02d066d521155eae7ac4e247c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Le=C3=A3o?= Date: Tue, 14 Jul 2026 12:05:35 +0100 Subject: [PATCH 1/4] fix(rate-limits): read Antigravity native credentials --- .../antigravity-oauth-keyring.test.ts | 66 +++++++ .../rate-limits/antigravity-oauth-keyring.ts | 162 ++++++++++++++++++ .../gemini-usage-fetcher.fallback.test.ts | 4 + .../rate-limits/gemini-usage-fetcher.test.ts | 78 ++++++++- src/main/rate-limits/gemini-usage-fetcher.ts | 20 +++ .../src/components/settings/AccountsPane.tsx | 9 +- .../components/settings/accounts-search.ts | 5 +- src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- src/shared/types.ts | 4 +- 13 files changed, 365 insertions(+), 43 deletions(-) create mode 100644 src/main/rate-limits/antigravity-oauth-keyring.test.ts create mode 100644 src/main/rate-limits/antigravity-oauth-keyring.ts diff --git a/src/main/rate-limits/antigravity-oauth-keyring.test.ts b/src/main/rate-limits/antigravity-oauth-keyring.test.ts new file mode 100644 index 00000000000..79fd6a45c8a --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-keyring.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + getAntigravityKeyringCommand, + parseAntigravityKeyringCredentials +} from './antigravity-oauth-keyring' + +function encodedKeyringValue(value: unknown): string { + return `go-keyring-base64:${Buffer.from(JSON.stringify(value)).toString('base64')}` +} + +describe('parseAntigravityKeyringCredentials', () => { + it('parses the AGY native-keyring envelope', () => { + const result = parseAntigravityKeyringCredentials( + encodedKeyringValue({ + auth_method: 'consumer', + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + token_type: 'Bearer', + expiry: '2026-07-14T12:32:24.665308+01:00' + } + }) + ) + + expect(result).toEqual({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry_date: Date.parse('2026-07-14T12:32:24.665308+01:00') + }) + }) + + it.each([ + '', + 'plain-text', + 'go-keyring-base64:not-base64-json', + encodedKeyringValue({ token: { access_token: 'access', expiry: '2026-07-14' } }), + encodedKeyringValue({ + token: { access_token: 'access', refresh_token: 'refresh', expiry: 'not-a-date' } + }) + ])('rejects malformed keyring data', (value) => { + expect(parseAntigravityKeyringCredentials(value)).toBeNull() + }) +}) + +describe('getAntigravityKeyringCommand', () => { + it('uses native keyring readers on each supported desktop platform', () => { + expect(getAntigravityKeyringCommand('darwin')).toMatchObject({ + command: 'security', + args: expect.arrayContaining(['gemini', 'antigravity']), + output: 'text' + }) + expect(getAntigravityKeyringCommand('linux')).toMatchObject({ + command: 'secret-tool', + args: ['lookup', 'service', 'gemini', 'username', 'antigravity'], + output: 'text' + }) + expect(getAntigravityKeyringCommand('win32')).toMatchObject({ + command: 'powershell.exe', + output: 'base64' + }) + }) + + it('returns null on unsupported platforms', () => { + expect(getAntigravityKeyringCommand('aix')).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-oauth-keyring.ts b/src/main/rate-limits/antigravity-oauth-keyring.ts new file mode 100644 index 00000000000..75e7d0e131b --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-keyring.ts @@ -0,0 +1,162 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { GeminiCredentials } from './gemini-oauth-sources' + +const execFileAsync = promisify(execFile) +const KEYRING_SERVICE = 'gemini' +const KEYRING_ACCOUNT = 'antigravity' +const KEYRING_VALUE_PREFIX = 'go-keyring-base64:' +const KEYRING_TIMEOUT_MS = 3_000 + +type KeyringCommand = { + command: string + args: string[] + output: 'text' | 'base64' +} + +const WINDOWS_CREDENTIAL_SCRIPT = String.raw` +$signature = @' +using System; +using System.Runtime.InteropServices; +public static class OrcaCredentialReader { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct Credential { + public uint Flags; + public uint Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); + + [DllImport("advapi32.dll")] + public static extern void CredFree(IntPtr credential); +} +'@ +Add-Type -TypeDefinition $signature +$pointer = [IntPtr]::Zero +if ([OrcaCredentialReader]::CredRead('gemini:antigravity', 1, 0, [ref]$pointer)) { + try { + $credential = [Runtime.InteropServices.Marshal]::PtrToStructure( + $pointer, + [type][OrcaCredentialReader+Credential] + ) + $bytes = New-Object byte[] $credential.CredentialBlobSize + [Runtime.InteropServices.Marshal]::Copy( + $credential.CredentialBlob, + $bytes, + 0, + $credential.CredentialBlobSize + ) + [Convert]::ToBase64String($bytes) + } finally { + [OrcaCredentialReader]::CredFree($pointer) + } +} +`.trim() + +export function getAntigravityKeyringCommand(platform: NodeJS.Platform): KeyringCommand | null { + switch (platform) { + case 'darwin': + return { + command: 'security', + args: ['find-generic-password', '-s', KEYRING_SERVICE, '-a', KEYRING_ACCOUNT, '-w'], + output: 'text' + } + case 'linux': + case 'freebsd': + case 'openbsd': + return { + command: 'secret-tool', + args: ['lookup', 'service', KEYRING_SERVICE, 'username', KEYRING_ACCOUNT], + output: 'text' + } + case 'win32': + return { + command: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_CREDENTIAL_SCRIPT], + output: 'base64' + } + case 'aix': + case 'android': + case 'cygwin': + case 'haiku': + case 'netbsd': + case 'sunos': + return null + } +} + +export function parseAntigravityKeyringCredentials(rawValue: string): GeminiCredentials | null { + const trimmed = rawValue.trim() + if (!trimmed.startsWith(KEYRING_VALUE_PREFIX)) { + return null + } + + try { + const decoded = Buffer.from(trimmed.slice(KEYRING_VALUE_PREFIX.length), 'base64').toString( + 'utf8' + ) + const parsed = JSON.parse(decoded) as { + token?: { + access_token?: unknown + refresh_token?: unknown + expiry?: unknown + } + } + const accessToken = parsed.token?.access_token + const refreshToken = parsed.token?.refresh_token + const expiry = parsed.token?.expiry + const expiryDate = typeof expiry === 'string' ? Date.parse(expiry) : Number.NaN + if ( + typeof accessToken !== 'string' || + accessToken.length === 0 || + typeof refreshToken !== 'string' || + refreshToken.length === 0 || + !Number.isFinite(expiryDate) + ) { + return null + } + + return { + access_token: accessToken, + refresh_token: refreshToken, + expiry_date: expiryDate + } + } catch { + return null + } +} + +export async function readAntigravityCredentials(): Promise { + const command = getAntigravityKeyringCommand(process.platform) + if (!command) { + return null + } + + try { + // Why: AGY moved OAuth state into the OS keyring, so the legacy Gemini file + // can stay expired even while Antigravity has a current session. + const { stdout } = await execFileAsync(command.command, command.args, { + encoding: 'utf8', + maxBuffer: 16 * 1024, + timeout: KEYRING_TIMEOUT_MS, + windowsHide: true + }) + const keyringValue = + command.output === 'base64' ? Buffer.from(stdout.trim(), 'base64').toString('utf8') : stdout + return parseAntigravityKeyringCredentials(keyringValue) + } catch { + // A locked or unavailable keyring should fall through to legacy Gemini sources. + return null + } +} diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index fddd0a77d59..0f21555a811 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -22,6 +22,10 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ extractOAuthClientCredentials: extractCredsMock })) +vi.mock('./antigravity-oauth-keyring', () => ({ + readAntigravityCredentials: vi.fn().mockResolvedValue(null) +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock, // Why: saveGeminiCredentials is exercised on the refresh path. The atomic diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index e8415c2e07e..cadc77e6ab4 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -6,11 +6,14 @@ import { quotaResponse } from './gemini-usage-fetcher.test-fixtures' -const { readFileMock, extractCredsMock, netFetchMock } = vi.hoisted(() => ({ - readFileMock: vi.fn(), - extractCredsMock: vi.fn(), - netFetchMock: vi.fn() -})) +const { readFileMock, extractCredsMock, netFetchMock, readAntigravityCredentialsMock } = vi.hoisted( + () => ({ + readFileMock: vi.fn(), + extractCredsMock: vi.fn(), + netFetchMock: vi.fn(), + readAntigravityCredentialsMock: vi.fn() + }) +) // Why: mock the extractor at the module boundary rather than re-routing every // child_process/fs call. The extractor is a self-contained dependency with a @@ -21,6 +24,10 @@ vi.mock('./gemini-cli-oauth-extractor', () => ({ extractOAuthClientCredentials: extractCredsMock })) +vi.mock('./antigravity-oauth-keyring', () => ({ + readAntigravityCredentials: readAntigravityCredentialsMock +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock, writeFile: vi.fn().mockResolvedValue(undefined), @@ -37,6 +44,8 @@ describe('fetchGeminiRateLimits', () => { readFileMock.mockReset() extractCredsMock.mockReset() netFetchMock.mockReset() + readAntigravityCredentialsMock.mockReset() + readAntigravityCredentialsMock.mockResolvedValue(null) netFetchMock.mockImplementation((url: string) => { if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) @@ -73,6 +82,65 @@ describe('fetchGeminiRateLimits', () => { expect(result.status).toBe('unavailable') }) + it('uses a current Antigravity keyring token before legacy Gemini files', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() + 60_000 + }) + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'agy-project' })) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(readFileMock).not.toHaveBeenCalled() + expect(extractCredsMock).not.toHaveBeenCalled() + const quotaRequest = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect(quotaRequest).toBeDefined() + expect((quotaRequest![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer agy-access-token' + }) + }) + + it('falls back to legacy Gemini credentials when the Antigravity token is expired', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'expired-agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() - 60_000 + }) + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string) => { + if (url.includes('loadCodeAssist')) { + return Promise.resolve(makeResponse({ cloudaicompanionProject: 'legacy-project' })) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + expect(readFileMock).toHaveBeenCalled() + const quotaRequest = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect((quotaRequest![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer auth-json-access-token' + }) + }) + it('returns quota via auth.json', async () => { setupAuthJsonValid() netFetchMock.mockImplementation((url: string) => { diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts index 1f214b4b5eb..cef0521bbe4 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -9,6 +9,7 @@ import { type GeminiCredentials, type GoogleAuthEntry } from './gemini-oauth-sources' +import { readAntigravityCredentials } from './antigravity-oauth-keyring' import { buildRateLimitBucket, deduplicateBuckets, @@ -217,6 +218,25 @@ export async function fetchGeminiRateLimits( } try { + const antigravityCreds = await readAntigravityCredentials() + if (antigravityCreds && antigravityCreds.expiry_date > Date.now()) { + // Why: AGY owns refreshing and persisting its native-keyring token. Using + // only a current token avoids writing Antigravity auth into Gemini's file. + const projectId = await loadProjectId(antigravityCreds.access_token).catch(() => { + return '' + }) + return projectId + ? await fetchQuota(antigravityCreds.access_token, projectId) + : { + provider: 'gemini', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Gemini project ID not found', + status: 'error' + } + } + const authJson = await readAuthJson() const result = authJson?.google?.type === 'oauth' diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 5268a890e36..292e802df54 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1381,13 +1381,14 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.96f3649526', - 'Use Gemini CLI credentials (experimental)' + 'Use Antigravity or Gemini CLI credentials (experimental)' )}

{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.', + "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google for {{value0}}. These credentials were issued to Google's apps, not Orca. May break if Google changes their storage. Use at your own risk.", { value0: localAccountRuntimeSentenceLabel } )}

diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 6ff07366bc9..db6fd5570de 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -100,13 +100,14 @@ export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ { title: translate( 'auto.components.settings.accounts.search.d819755b02', - 'Use Gemini CLI credentials' + 'Use Antigravity or Gemini CLI credentials' ), description: translate( 'auto.components.settings.accounts.search.bada4a3218', - 'Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.' + "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google." ), keywords: [ + ...translateSearchKeyword('auto.lib.agent.catalog.691dd11789', 'antigravity'), ...translateSearchKeyword('auto.components.settings.accounts.search.e8e1ff3887', 'gemini'), ...translateSearchKeyword('auto.components.settings.accounts.search.8630464352', 'cli'), ...translateSearchKeyword('auto.components.settings.accounts.search.933deaf732', 'oauth'), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4fc41a01115..e48c9a30fa6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4770,10 +4770,10 @@ "36223200ac": "OpenCode Go Session Cookie", "ea631977b5": "Configure OpenCode Go provider settings.", "4ac10b4d08": "OpenCode Go", - "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.", - "96f3649526": "Use Gemini CLI credentials (experimental)", - "d676c41fc6": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", - "0c7f915b01": "Use Gemini CLI credentials", + "c2aee76420": "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google for {{value0}}. These credentials were issued to Google's apps, not Orca. May break if Google changes their storage. Use at your own risk.", + "96f3649526": "Use Antigravity or Gemini CLI credentials (experimental)", + "d676c41fc6": "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google. These credentials were issued to Google's apps, not Orca. May break if Google changes their storage. Use at your own risk.", + "0c7f915b01": "Use Antigravity or Gemini CLI credentials", "973741a871": "Configure Gemini provider settings.", "0c64dc2a64": "Gemini", "db209ee572": "Remove", @@ -7013,8 +7013,8 @@ "933deaf732": "oauth", "8630464352": "cli", "e8e1ff3887": "gemini", - "bada4a3218": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google.", - "d819755b02": "Use Gemini CLI credentials", + "bada4a3218": "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google.", + "d819755b02": "Use Antigravity or Gemini CLI credentials", "35b461d817": "sign in", "f2d666a886": "optional", "8b06729e0f": "active", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 788d29209e5..e30a2d3ef75 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4770,10 +4770,10 @@ "36223200ac": "Cookie de sesión de OpenCode Go", "ea631977b5": "Configura los ajustes del proveedor OpenCode Go.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google para {{value0}}. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", - "96f3649526": "Usar credenciales de Gemini CLI (experimental)", - "d676c41fc6": "Extrae credenciales OAuth de tu instalación local de Gemini CLI para autenticarse con Google. Usa credenciales emitidas para la app Gemini CLI, no para Orca. Puede romperse si Google actualiza la CLI. Úsalo bajo tu propio riesgo.", - "0c7f915b01": "Usar credenciales de Gemini CLI", + "c2aee76420": "Lee las credenciales OAuth del llavero nativo del sistema operativo de Antigravity o de tu instalación local de Gemini CLI para autenticarse con Google en {{value0}}. Estas credenciales se emitieron para las aplicaciones de Google, no para Orca. Puede dejar de funcionar si Google cambia su almacenamiento. Úsalo bajo tu propia responsabilidad.", + "96f3649526": "Usar credenciales de Antigravity o Gemini CLI (experimental)", + "d676c41fc6": "Lee las credenciales OAuth del llavero nativo del sistema operativo de Antigravity o de tu instalación local de Gemini CLI para autenticarse con Google. Estas credenciales se emitieron para las aplicaciones de Google, no para Orca. Puede dejar de funcionar si Google cambia su almacenamiento. Úsalo bajo tu propia responsabilidad.", + "0c7f915b01": "Usar credenciales de Antigravity o Gemini CLI", "973741a871": "Configura los ajustes del proveedor Gemini.", "0c64dc2a64": "Gemini", "db209ee572": "Eliminar", @@ -6976,8 +6976,8 @@ "933deaf732": "OAuth", "8630464352": "CLI", "e8e1ff3887": "Gemini", - "bada4a3218": "Extrae las credenciales de OAuth de tu instalación local de la CLI de Gemini para autenticarte con Google.", - "d819755b02": "Usar credenciales de la CLI de Gemini", + "bada4a3218": "Lee las credenciales OAuth del llavero nativo del sistema operativo de Antigravity o de tu instalación local de Gemini CLI para autenticarte con Google.", + "d819755b02": "Usar credenciales de Antigravity o Gemini CLI", "35b461d817": "iniciar sesión", "f2d666a886": "opcional", "8b06729e0f": "activo", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b92cf73d46e..bc058403c8f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4755,10 +4755,10 @@ "36223200ac": "OpenCode Go セッション Cookie", "ea631977b5": "OpenCode Go プロバイダー設定を構成します。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出し、{{value0}} の Google 認証に使用します。これは、Orca ではなく、Gemini CLI アプリに発行された認証情報を使用します。Google が CLI を更新すると壊れる可能性があります。ご自身の責任でご使用ください。", - "96f3649526": "Gemini CLI 認証情報を使用する (実験的機能)", - "d676c41fc6": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出して、Google で認証します。これは、Orca ではなく、Gemini CLI アプリに発行された認証情報を使用します。 Google が CLI を更新すると壊れる可能性があります。ご自身の責任でご使用ください。", - "0c7f915b01": "Gemini CLI 認証情報を使用する", + "c2aee76420": "Antigravity の OS ネイティブキーチェーンまたはローカルの Gemini CLI インストールから OAuth 認証情報を読み取り、{{value0}} で Google に認証します。これらの認証情報は Orca ではなく Google のアプリに発行されたものです。Google が保存方式を変更すると動作しなくなる可能性があります。自己責任で使用してください。", + "96f3649526": "Antigravity または Gemini CLI の認証情報を使用する(実験的)", + "d676c41fc6": "Antigravity の OS ネイティブキーチェーンまたはローカルの Gemini CLI インストールから OAuth 認証情報を読み取り、Google に認証します。これらの認証情報は Orca ではなく Google のアプリに発行されたものです。Google が保存方式を変更すると動作しなくなる可能性があります。自己責任で使用してください。", + "0c7f915b01": "Antigravity または Gemini CLI の認証情報を使用する", "973741a871": "Gemini プロバイダー設定を構成します。", "0c64dc2a64": "Gemini", "db209ee572": "削除", @@ -6998,8 +6998,8 @@ "933deaf732": "認証", "8630464352": "CLI", "e8e1ff3887": "gemini", - "bada4a3218": "ローカルの Gemini CLI インストールから OAuth 認証情報を抽出して、Google で認証します。", - "d819755b02": "Gemini CLI 認証情報を使用する", + "bada4a3218": "Antigravity の OS ネイティブキーチェーンまたはローカルの Gemini CLI インストールから OAuth 認証情報を読み取り、Google に認証します。", + "d819755b02": "Antigravity または Gemini CLI の認証情報を使用する", "35b461d817": "サインイン", "f2d666a886": "オプション", "8b06729e0f": "アクティブ", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 05eacf25060..1057672fbbb 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4755,10 +4755,10 @@ "36223200ac": "OpenCode Go 세션 쿠키", "ea631977b5": "OpenCode Go 공급자 설정을 구성합니다.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "{{value0}}에서 Google에 인증하기 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다. 이는 Orca가 아닌 Gemini CLI 앱에 발급된 자격 증명을 사용합니다. Google이 CLI를 업데이트하면 중단될 수 있습니다. 자신의 책임하에 사용하십시오.", - "96f3649526": "Gemini CLI 자격 증명 사용(실험적)", - "d676c41fc6": "Google 인증을 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다. 이는 Orca가 아닌 Gemini CLI 앱에 발급된 자격 증명을 사용합니다. Google이 CLI를 업데이트하면 중단될 수 있습니다. 자신의 책임하에 사용하십시오.", - "0c7f915b01": "Gemini CLI 자격 증명 사용", + "c2aee76420": "Antigravity의 OS 기본 키링 또는 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 읽어 {{value0}}에서 Google 인증에 사용합니다. 이 자격 증명은 Orca가 아니라 Google 앱에 발급된 것입니다. Google이 저장 방식을 변경하면 작동하지 않을 수 있습니다. 사용에 따른 책임은 사용자에게 있습니다.", + "96f3649526": "Antigravity 또는 Gemini CLI 자격 증명 사용(실험적)", + "d676c41fc6": "Antigravity의 OS 기본 키링 또는 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 읽어 Google 인증에 사용합니다. 이 자격 증명은 Orca가 아니라 Google 앱에 발급된 것입니다. Google이 저장 방식을 변경하면 작동하지 않을 수 있습니다. 사용에 따른 책임은 사용자에게 있습니다.", + "0c7f915b01": "Antigravity 또는 Gemini CLI 자격 증명 사용", "973741a871": "Gemini 공급자 설정을 구성합니다.", "0c64dc2a64": "Gemini", "db209ee572": "제거", @@ -6961,8 +6961,8 @@ "933deaf732": "OAuth", "8630464352": "CLI", "e8e1ff3887": "gemini", - "bada4a3218": "Google 인증을 위해 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 추출합니다.", - "d819755b02": "Gemini CLI 자격 증명 사용", + "bada4a3218": "Antigravity의 OS 기본 키링 또는 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 읽어 Google 인증에 사용합니다.", + "d819755b02": "Antigravity 또는 Gemini CLI 자격 증명 사용", "35b461d817": "로그인", "f2d666a886": "선택 사항", "8b06729e0f": "활성", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2519b1856e5..d389373a824 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4755,10 +4755,10 @@ "36223200ac": "OpenCode Go 会话 Cookie", "ea631977b5": "配置 OpenCode Go 提供商设置。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "从本地 Gemini CLI 安装中提取 OAuth 凭据,用于在 {{value0}} 通过 Google 进行身份验证。这将使用颁发给 Gemini CLI 应用的凭据,而非 Orca。若 Google 更新 CLI,可能会失效。请自行承担风险。", - "96f3649526": "使用 Gemini CLI 凭据(实验)", - "d676c41fc6": "从本地 Gemini CLI 安装中提取 OAuth 凭据以通过 Google 认证。这将使用颁发给 Gemini CLI 应用的凭据,而非 Orca。若 Google 更新 CLI,可能会失效。请自行承担风险。", - "0c7f915b01": "使用 Gemini CLI 凭据", + "c2aee76420": "从 Antigravity 的操作系统原生密钥环或本地 Gemini CLI 安装中读取 OAuth 凭据,用于在 {{value0}} 通过 Google 进行身份验证。这些凭据由 Google 应用获取,而非 Orca。若 Google 更改存储方式,可能会失效。请自行承担风险。", + "96f3649526": "使用 Antigravity 或 Gemini CLI 凭据(实验)", + "d676c41fc6": "从 Antigravity 的操作系统原生密钥环或本地 Gemini CLI 安装中读取 OAuth 凭据以通过 Google 认证。这些凭据由 Google 应用获取,而非 Orca。若 Google 更改存储方式,可能会失效。请自行承担风险。", + "0c7f915b01": "使用 Antigravity 或 Gemini CLI 凭据", "973741a871": "配置 Gemini 提供商设置。", "0c64dc2a64": "Gemini", "db209ee572": "移除", @@ -6961,8 +6961,8 @@ "933deaf732": "OAuth", "8630464352": "CLI", "e8e1ff3887": "gemini", - "bada4a3218": "从本地 Gemini CLI 安装中提取 OAuth 凭据以通过 Google 进行身份验证。", - "d819755b02": "使用 Gemini CLI 凭据", + "bada4a3218": "从 Antigravity 的操作系统原生密钥环或本地 Gemini CLI 安装中读取 OAuth 凭据以通过 Google 进行身份验证。", + "d819755b02": "使用 Antigravity 或 Gemini CLI 凭据", "35b461d817": "登入", "f2d666a886": "可选", "8b06729e0f": "活跃", diff --git a/src/shared/types.ts b/src/shared/types.ts index 6c1ebaf8cc8..43d587073a8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2838,8 +2838,8 @@ export type GlobalSettings = { minimaxGroupId: string /** Comma-separated MiniMax model names to show in the status bar usage window. */ minimaxUsageModels: string - /** Whether to extract OAuth credentials from the local Gemini CLI installation - * for rate-limit fetching. Disabled by default for explicit opt-in. */ + /** Whether to read OAuth credentials from Antigravity's native keyring or a + * local Gemini CLI installation. Disabled by default for explicit opt-in. */ geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> From 2094210d69c82386727698b879faeb91efe4322b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Le=C3=A3o?= Date: Tue, 14 Jul 2026 12:45:28 +0100 Subject: [PATCH 2/4] feat(rate-limits): show Antigravity quota groups --- .../antigravity-local-quota.test.ts | 59 +++++ .../rate-limits/antigravity-local-quota.ts | 236 ++++++++++++++++++ .../antigravity-quota-summary.test.ts | 84 +++++++ .../rate-limits/antigravity-quota-summary.ts | 149 +++++++++++ .../gemini-usage-fetcher.fallback.test.ts | 4 + .../rate-limits/gemini-usage-fetcher.test.ts | 47 +++- src/main/rate-limits/gemini-usage-fetcher.ts | 6 + .../src/components/status-bar/tooltip.test.ts | 57 +++++ .../src/components/status-bar/tooltip.tsx | 70 +++++- src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- src/shared/rate-limit-types.ts | 13 + 15 files changed, 733 insertions(+), 17 deletions(-) create mode 100644 src/main/rate-limits/antigravity-local-quota.test.ts create mode 100644 src/main/rate-limits/antigravity-local-quota.ts create mode 100644 src/main/rate-limits/antigravity-quota-summary.test.ts create mode 100644 src/main/rate-limits/antigravity-quota-summary.ts diff --git a/src/main/rate-limits/antigravity-local-quota.test.ts b/src/main/rate-limits/antigravity-local-quota.test.ts new file mode 100644 index 00000000000..e1bc30d68fc --- /dev/null +++ b/src/main/rate-limits/antigravity-local-quota.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getPath: vi.fn() } })) + +import { + getAntigravityCliLogDirectory, + getAntigravityLanguageServerLogPath, + parseAntigravityAppConfig, + parseAntigravityCliServerPorts, + parseAntigravityLanguageServerPort +} from './antigravity-local-quota' + +describe('Antigravity language-server discovery', () => { + it('uses AGY CLI logs beneath the cross-platform home directory', () => { + expect(getAntigravityCliLogDirectory('/home/lee')).toBe('/home/lee/.gemini/antigravity-cli/log') + }) + + it('uses Antigravity application log locations on desktop platforms', () => { + expect(getAntigravityLanguageServerLogPath('darwin', '/Users/lee', '/app-data')).toBe( + '/Users/lee/Library/Logs/Antigravity/language_server.log' + ) + expect(getAntigravityLanguageServerLogPath('linux', '/home/lee', '/home/lee/.config')).toBe( + '/home/lee/.config/Antigravity/logs/language_server.log' + ) + }) + + it('uses the most recent HTTPS port from a restarted language server', () => { + const log = [ + 'Language server listening on random port at 40100 for HTTPS (gRPC)', + 'Language server listening on random port at 40200 for HTTPS (gRPC)' + ].join('\n') + + expect(parseAntigravityLanguageServerPort(log)).toBe(40200) + expect(parseAntigravityLanguageServerPort('no listener')).toBeNull() + }) + + it('discovers both loopback ports exposed by the AGY CLI', () => { + const log = [ + 'Language server listening on random port at 58601 for HTTPS (gRPC)', + 'Language server listening on random port at 58602 for HTTP' + ].join('\n') + + expect(parseAntigravityCliServerPorts(log)).toEqual({ http: 58602, https: 58601 }) + expect(parseAntigravityCliServerPorts('no listener')).toEqual({ http: null, https: null }) + }) + + it('accepts only Antigravity app configuration with a CSRF token', () => { + expect( + parseAntigravityAppConfig( + '' + ) + ).toEqual({ productName: 'antigravity', csrfToken: 'token' }) + expect( + parseAntigravityAppConfig( + '' + ) + ).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-local-quota.ts b/src/main/rate-limits/antigravity-local-quota.ts new file mode 100644 index 00000000000..17c990d880b --- /dev/null +++ b/src/main/rate-limits/antigravity-local-quota.ts @@ -0,0 +1,236 @@ +import { readFile, readdir } from 'node:fs/promises' +import { request as httpRequest } from 'node:http' +import { request as httpsRequest } from 'node:https' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { app } from 'electron' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { parseAntigravityQuotaSummary } from './antigravity-quota-summary' + +const LANGUAGE_SERVER_LOG_NAME = 'language_server.log' +const CLI_LOG_LIMIT = 12 +const REQUEST_TIMEOUT_MS = 3_000 +const MAX_RESPONSE_BYTES = 1024 * 1024 +const QUOTA_SUMMARY_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' + +type LocalProtocol = 'http:' | 'https:' + +type AntigravityAppConfig = { + csrfToken: string + productName: string +} + +export type AntigravityServerPorts = { + http: number | null + https: number | null +} + +function validPort(value: string | undefined): number | null { + const port = Number(value) + return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null +} + +export function getAntigravityCliLogDirectory(homePath: string): string { + return join(homePath, '.gemini', 'antigravity-cli', 'log') +} + +export function parseAntigravityCliServerPorts(log: string): AntigravityServerPorts { + const httpsMatches = [ + ...log.matchAll(/language server listening on (?:random port at )?(\d+) for HTTPS/gi) + ] + const httpMatches = [ + ...log.matchAll(/language server listening on (?:random port at )?(\d+) for HTTP(?!S)/gi) + ] + return { + http: validPort(httpMatches.at(-1)?.[1]), + https: validPort(httpsMatches.at(-1)?.[1]) + } +} + +export function getAntigravityLanguageServerLogPath( + platform: NodeJS.Platform, + homePath: string, + appDataPath: string +): string { + return platform === 'darwin' + ? join(homePath, 'Library', 'Logs', 'Antigravity', LANGUAGE_SERVER_LOG_NAME) + : join(appDataPath, 'Antigravity', 'logs', LANGUAGE_SERVER_LOG_NAME) +} + +export function parseAntigravityLanguageServerPort(log: string): number | null { + return parseAntigravityCliServerPorts(log).https +} + +export function parseAntigravityAppConfig(html: string): AntigravityAppConfig | null { + const configJson = html.match(/window\.__APP_CONFIG__\s*=\s*(\{.*?\});<\/script>/s)?.[1] + if (!configJson) { + return null + } + try { + const config = JSON.parse(configJson) as Record + return config.productName === 'antigravity' && + typeof config.csrfToken === 'string' && + config.csrfToken.length > 0 + ? { productName: config.productName, csrfToken: config.csrfToken } + : null + } catch { + return null + } +} + +function requestLocalAntigravity( + protocol: LocalProtocol, + port: number, + path: string, + options?: { body?: string; csrfToken?: string } +): Promise { + return new Promise((resolve, reject) => { + const body = options?.body + const headers: Record = { Connection: 'close' } + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + headers['Content-Length'] = Buffer.byteLength(body) + headers['Connect-Protocol-Version'] = '1' + } + if (options?.csrfToken) { + headers['x-codeium-csrf-token'] = options.csrfToken + } + + // Why: older Antigravity servers expose only self-signed HTTPS. Restricting + // the exception to a fixed loopback host prevents it from reaching a network. + const request = protocol === 'https:' ? httpsRequest : httpRequest + const req = request( + { + protocol, + hostname: '127.0.0.1', + port, + path, + method: body === undefined ? 'GET' : 'POST', + headers, + rejectUnauthorized: protocol === 'https:' ? false : undefined, + timeout: REQUEST_TIMEOUT_MS + }, + (res) => { + const chunks: Buffer[] = [] + let byteLength = 0 + res.on('data', (chunk: Buffer) => { + byteLength += chunk.length + if (byteLength > MAX_RESPONSE_BYTES) { + req.destroy(new Error('Antigravity quota response exceeded size limit')) + return + } + chunks.push(chunk) + }) + res.on('end', () => { + const responseBody = Buffer.concat(chunks).toString('utf8') + if (res.statusCode !== 200) { + reject(new Error(`Antigravity quota request failed (${res.statusCode ?? 'unknown'})`)) + return + } + resolve(responseBody) + }) + } + ) + req.on('timeout', () => { + req.destroy(new Error('Antigravity quota request timed out')) + }) + req.on('error', reject) + req.end(body) + }) +} + +function parseQuotaResponse(response: string): ProviderRateLimits | null { + return parseAntigravityQuotaSummary(JSON.parse(response) as unknown) +} + +async function fetchFromCliLogs(homePath: string): Promise { + const logDirectory = getAntigravityCliLogDirectory(homePath) + const entries = await readdir(logDirectory, { withFileTypes: true }) + const logNames = entries + .filter((entry) => entry.isFile() && /^cli-.*\.log$/i.test(entry.name)) + .map((entry) => entry.name) + .sort((a, b) => b.localeCompare(a)) + .slice(0, CLI_LOG_LIMIT) + const attemptedEndpoints = new Set() + + for (const logName of logNames) { + let log: string + try { + log = await readFile(join(logDirectory, logName), 'utf8') + } catch { + continue + } + const ports = parseAntigravityCliServerPorts(log) + const endpoints: { protocol: LocalProtocol; port: number | null }[] = [ + { protocol: 'http:', port: ports.http }, + { protocol: 'https:', port: ports.https } + ] + for (const endpoint of endpoints) { + if (!endpoint.port) { + continue + } + const endpointKey = `${endpoint.protocol}//127.0.0.1:${endpoint.port}` + if (attemptedEndpoints.has(endpointKey)) { + continue + } + attemptedEndpoints.add(endpointKey) + try { + const response = await requestLocalAntigravity( + endpoint.protocol, + endpoint.port, + QUOTA_SUMMARY_PATH, + { body: JSON.stringify({ forceRefresh: true }) } + ) + const parsed = parseQuotaResponse(response) + if (parsed) { + return { + ...parsed, + usageMetadata: { source: 'cli', attemptedSources: ['cli'] } + } + } + } catch { + // A newer one-shot AGY command can leave a stale log above a live session. + } + } + } + return null +} + +async function fetchFromDesktopApp( + homePath: string, + appDataPath: string +): Promise { + const logPath = getAntigravityLanguageServerLogPath(process.platform, homePath, appDataPath) + const port = parseAntigravityLanguageServerPort(await readFile(logPath, 'utf8')) + if (!port) { + return null + } + const appConfig = parseAntigravityAppConfig(await requestLocalAntigravity('https:', port, '/')) + if (!appConfig) { + return null + } + const response = await requestLocalAntigravity('https:', port, QUOTA_SUMMARY_PATH, { + csrfToken: appConfig.csrfToken, + body: JSON.stringify({ forceRefresh: true }) + }) + return parseQuotaResponse(response) +} + +export async function fetchAntigravityLocalRateLimits(): Promise { + const homePath = homedir() + try { + const cliRateLimits = await fetchFromCliLogs(homePath) + if (cliRateLimits) { + return cliRateLimits + } + } catch { + // AGY is optional; the desktop language server may still be available. + } + + try { + return await fetchFromDesktopApp(homePath, app.getPath('appData')) + } catch { + // Native keyring and Gemini OAuth sources remain the final fallback. + return null + } +} diff --git a/src/main/rate-limits/antigravity-quota-summary.test.ts b/src/main/rate-limits/antigravity-quota-summary.test.ts new file mode 100644 index 00000000000..ddd1ece8d75 --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-summary.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravityQuotaSummary } from './antigravity-quota-summary' + +const quotaSummary = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit', + window: 'weekly', + remainingFraction: 0.916, + resetTime: '2026-07-16T21:59:04Z' + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit', + window: '5h', + remainingFraction: 1, + resetTime: '2026-07-14T16:30:11Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { + bucketId: '3p-weekly', + displayName: 'Weekly Limit', + window: 'weekly', + remainingFraction: 0.988, + resetTime: '2026-07-21T11:28:50Z' + }, + { + bucketId: '3p-5h', + displayName: 'Five Hour Limit', + window: '5h', + remainingFraction: 0.964, + resetTime: '2026-07-14T16:28:50Z' + } + ] + } + ] + } +} + +describe('parseAntigravityQuotaSummary', () => { + it('preserves model families with their five-hour and weekly limits', () => { + const result = parseAntigravityQuotaSummary(quotaSummary, 1234) + + expect(result).toMatchObject({ + provider: 'gemini', + session: { usedPercent: 4, windowMinutes: 300 }, + weekly: { usedPercent: 8, windowMinutes: 10_080 }, + updatedAt: 1234, + status: 'ok' + }) + expect(result?.groups?.map((group) => group.id)).toEqual(['gemini-models', 'claude-gpt-models']) + expect(result?.groups?.[0]?.windows.map((window) => window.id)).toEqual(['session', 'weekly']) + expect(result?.groups?.[1]?.windows.map((window) => window.window.usedPercent)).toEqual([4, 1]) + }) + + it('rejects responses without usable groups', () => { + expect(parseAntigravityQuotaSummary({ response: { groups: [] } })).toBeNull() + expect(parseAntigravityQuotaSummary({ response: { groups: [{ displayName: 'Empty' }] } })).toBe( + null + ) + expect(parseAntigravityQuotaSummary(null)).toBeNull() + }) + + it('keeps usable windows when AGY omits reset metadata', () => { + const withoutReset = structuredClone(quotaSummary) + delete (withoutReset.response.groups[0].buckets[0] as { resetTime?: string }).resetTime + + expect( + parseAntigravityQuotaSummary(withoutReset)?.groups?.[0]?.windows[1]?.window + ).toMatchObject({ + usedPercent: 8, + resetsAt: null + }) + }) +}) diff --git a/src/main/rate-limits/antigravity-quota-summary.ts b/src/main/rate-limits/antigravity-quota-summary.ts new file mode 100644 index 00000000000..bcc0aed2f17 --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-summary.ts @@ -0,0 +1,149 @@ +import type { + ProviderRateLimits, + RateLimitGroup, + RateLimitWindow +} from '../../shared/rate-limit-types' + +type QuotaSummaryBucket = { + bucketId: string + displayName: string + window: string + remainingFraction: number + resetTime: string | null +} + +type QuotaSummaryGroup = { + displayName: string + buckets: QuotaSummaryBucket[] +} + +function parseBucket(value: unknown): QuotaSummaryBucket | null { + if (!value || typeof value !== 'object') { + return null + } + const bucket = value as Record + if ( + typeof bucket.bucketId !== 'string' || + typeof bucket.displayName !== 'string' || + typeof bucket.window !== 'string' || + typeof bucket.remainingFraction !== 'number' || + !Number.isFinite(bucket.remainingFraction) || + (bucket.resetTime !== undefined && + bucket.resetTime !== null && + typeof bucket.resetTime !== 'string') + ) { + return null + } + return { + bucketId: bucket.bucketId, + displayName: bucket.displayName, + window: bucket.window, + remainingFraction: bucket.remainingFraction, + resetTime: typeof bucket.resetTime === 'string' ? bucket.resetTime : null + } +} + +function getGroupId(group: QuotaSummaryGroup): string { + if (group.buckets.some((bucket) => bucket.bucketId.startsWith('gemini-'))) { + return 'gemini-models' + } + if (group.buckets.some((bucket) => bucket.bucketId.startsWith('3p-'))) { + return 'claude-gpt-models' + } + return group.displayName + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, '-') + .replaceAll(/^-|-$/g, '') +} + +function getWindowId(bucket: QuotaSummaryBucket): 'session' | 'weekly' | null { + if (bucket.window === '5h' || bucket.bucketId.endsWith('-5h')) { + return 'session' + } + if (bucket.window === 'weekly' || bucket.bucketId.endsWith('-weekly')) { + return 'weekly' + } + return null +} + +function toRateLimitWindow(bucket: QuotaSummaryBucket): RateLimitWindow { + const remainingFraction = Math.min(1, Math.max(0, bucket.remainingFraction)) + const resetsAt = bucket.resetTime ? new Date(bucket.resetTime).getTime() : Number.NaN + return { + usedPercent: Math.round((1 - remainingFraction) * 100), + windowMinutes: getWindowId(bucket) === 'weekly' ? 10_080 : 300, + resetsAt: Number.isNaN(resetsAt) ? null : resetsAt, + resetDescription: null + } +} + +function parseGroup(value: unknown): RateLimitGroup | null { + if (!value || typeof value !== 'object') { + return null + } + const rawGroup = value as Record + if (typeof rawGroup.displayName !== 'string' || !Array.isArray(rawGroup.buckets)) { + return null + } + const parsedGroup: QuotaSummaryGroup = { + displayName: rawGroup.displayName, + buckets: rawGroup.buckets.map(parseBucket).filter((bucket) => bucket !== null) + } + const windows = parsedGroup.buckets + .map((bucket) => { + const id = getWindowId(bucket) + return id ? { id, name: bucket.displayName, window: toRateLimitWindow(bucket) } : null + }) + .filter((window) => window !== null) + .sort((a, b) => { + const rank = { session: 0, weekly: 1 } + return rank[a.id] - rank[b.id] + }) + if (windows.length === 0) { + return null + } + return { + id: getGroupId(parsedGroup), + name: parsedGroup.displayName, + windows + } +} + +function mostConstrainedWindow(groups: RateLimitGroup[], id: string): RateLimitWindow | null { + const windows = groups.flatMap((group) => + group.windows.filter((entry) => entry.id === id).map((entry) => entry.window) + ) + return windows.reduce((worst, window) => { + return !worst || window.usedPercent > worst.usedPercent ? window : worst + }, null) +} + +export function parseAntigravityQuotaSummary( + value: unknown, + updatedAt = Date.now() +): ProviderRateLimits | null { + if (!value || typeof value !== 'object') { + return null + } + const response = (value as { response?: unknown }).response + if (!response || typeof response !== 'object') { + return null + } + const rawGroups = (response as { groups?: unknown }).groups + if (!Array.isArray(rawGroups)) { + return null + } + const groups = rawGroups.map(parseGroup).filter((group) => group !== null) + if (groups.length === 0) { + return null + } + return { + provider: 'gemini', + session: mostConstrainedWindow(groups, 'session'), + weekly: mostConstrainedWindow(groups, 'weekly'), + groups, + updatedAt, + error: null, + status: 'ok' + } +} diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index 0f21555a811..7ad1b4b17c6 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -26,6 +26,10 @@ vi.mock('./antigravity-oauth-keyring', () => ({ readAntigravityCredentials: vi.fn().mockResolvedValue(null) })) +vi.mock('./antigravity-local-quota', () => ({ + fetchAntigravityLocalRateLimits: vi.fn().mockResolvedValue(null) +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock, // Why: saveGeminiCredentials is exercised on the refresh path. The atomic diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index cadc77e6ab4..e98dba5c65b 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -6,14 +6,19 @@ import { quotaResponse } from './gemini-usage-fetcher.test-fixtures' -const { readFileMock, extractCredsMock, netFetchMock, readAntigravityCredentialsMock } = vi.hoisted( - () => ({ - readFileMock: vi.fn(), - extractCredsMock: vi.fn(), - netFetchMock: vi.fn(), - readAntigravityCredentialsMock: vi.fn() - }) -) +const { + readFileMock, + extractCredsMock, + netFetchMock, + readAntigravityCredentialsMock, + fetchAntigravityLocalRateLimitsMock +} = vi.hoisted(() => ({ + readFileMock: vi.fn(), + extractCredsMock: vi.fn(), + netFetchMock: vi.fn(), + readAntigravityCredentialsMock: vi.fn(), + fetchAntigravityLocalRateLimitsMock: vi.fn() +})) // Why: mock the extractor at the module boundary rather than re-routing every // child_process/fs call. The extractor is a self-contained dependency with a @@ -28,6 +33,10 @@ vi.mock('./antigravity-oauth-keyring', () => ({ readAntigravityCredentials: readAntigravityCredentialsMock })) +vi.mock('./antigravity-local-quota', () => ({ + fetchAntigravityLocalRateLimits: fetchAntigravityLocalRateLimitsMock +})) + vi.mock('node:fs/promises', () => ({ readFile: readFileMock, writeFile: vi.fn().mockResolvedValue(undefined), @@ -46,6 +55,8 @@ describe('fetchGeminiRateLimits', () => { netFetchMock.mockReset() readAntigravityCredentialsMock.mockReset() readAntigravityCredentialsMock.mockResolvedValue(null) + fetchAntigravityLocalRateLimitsMock.mockReset() + fetchAntigravityLocalRateLimitsMock.mockResolvedValue(null) netFetchMock.mockImplementation((url: string) => { if (url.includes('loadCodeAssist')) { return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' })) @@ -82,6 +93,26 @@ describe('fetchGeminiRateLimits', () => { expect(result.status).toBe('unavailable') }) + it('uses Antigravity grouped quota before credential-based sources', async () => { + const groupedQuota = { + provider: 'gemini' as const, + session: { usedPercent: 4, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 8, windowMinutes: 10_080, resetsAt: null, resetDescription: null }, + groups: [], + updatedAt: Date.now(), + error: null, + status: 'ok' as const + } + fetchAntigravityLocalRateLimitsMock.mockResolvedValue(groupedQuota) + + const result = await fetchGeminiRateLimits(true) + + expect(result).toBe(groupedQuota) + expect(readAntigravityCredentialsMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + expect(netFetchMock).not.toHaveBeenCalled() + }) + it('uses a current Antigravity keyring token before legacy Gemini files', async () => { readAntigravityCredentialsMock.mockResolvedValue({ access_token: 'agy-access-token', diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts index cef0521bbe4..a3ac14f236f 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -10,6 +10,7 @@ import { type GoogleAuthEntry } from './gemini-oauth-sources' import { readAntigravityCredentials } from './antigravity-oauth-keyring' +import { fetchAntigravityLocalRateLimits } from './antigravity-local-quota' import { buildRateLimitBucket, deduplicateBuckets, @@ -218,6 +219,11 @@ export async function fetchGeminiRateLimits( } try { + const antigravityRateLimits = await fetchAntigravityLocalRateLimits() + if (antigravityRateLimits) { + return antigravityRateLimits + } + const antigravityCreds = await readAntigravityCredentials() if (antigravityCreds && antigravityCreds.expiry_date > Date.now()) { // Why: AGY owns refreshing and persisting its native-keyring token. Using diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index d9387633fa0..9c95f1a92db 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -28,6 +28,7 @@ import { formatResetCountdown, getProviderUsageErrorMessage, getProviderUsageStatusLabel, + getWindowGroups, getWindowSections, ProviderIcon, ProviderPanel @@ -415,6 +416,62 @@ describe('getWindowSections', () => { }) }) +describe('getWindowGroups', () => { + it('localizes Antigravity model families and their quota windows', () => { + const fiveHour = { + usedPercent: 0, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + } + const weekly = { + usedPercent: 8, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + } + const p = provider({ + provider: 'antigravity', + status: 'ok', + session: fiveHour, + weekly, + groups: [ + { + id: 'gemini-models', + name: 'Gemini Models', + windows: [ + { id: 'session', name: 'Five Hour Limit', window: fiveHour }, + { id: 'weekly', name: 'Weekly Limit', window: weekly } + ] + }, + { + id: 'claude-gpt-models', + name: 'Claude and GPT models', + windows: [ + { id: 'session', name: 'Five Hour Limit', window: fiveHour }, + { id: 'weekly', name: 'Weekly Limit', window: weekly } + ] + } + ] + }) + + expect(getWindowGroups(p).map((group) => group.label)).toEqual([ + 'Gemini models', + 'Claude and GPT models' + ]) + expect(getWindowGroups(p)[0]?.windows.map((window) => window.label)).toEqual([ + 'Five-hour', + 'Weekly' + ]) + + const markup = renderToStaticMarkup(ProviderPanel({ p, usagePercentageDisplay: 'remaining' })) + expect(markup).toContain('Gemini models') + expect(markup).toContain('Claude and GPT models') + expect(markup.match(/Five-hour/g)).toHaveLength(2) + expect(markup.match(/92% left/g)).toHaveLength(2) + }) +}) + describe('ProviderPanel reset rendering', () => { it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 849dc7beda3..83caf1cd9e9 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -1,4 +1,8 @@ -import type { ProviderRateLimits, RateLimitWindow } from '../../../../shared/rate-limit-types' +import type { + ProviderRateLimits, + RateLimitGroup, + RateLimitWindow +} from '../../../../shared/rate-limit-types' import { formatResetCountdown, formatResetDuration @@ -175,6 +179,50 @@ export function getWindowSections( return sections } +type GroupedWindowSection = { + id: string + label: string + window: RateLimitWindow +} + +type WindowGroupSection = { + id: string + label: string + windows: GroupedWindowSection[] +} + +function getWindowGroupLabel(group: RateLimitGroup): string { + if (group.id === 'gemini-models') { + return translate('auto.components.status.bar.tooltip.3c639d6830', 'Gemini models') + } + if (group.id === 'claude-gpt-models') { + return translate('auto.components.status.bar.tooltip.8786272ed1', 'Claude and GPT models') + } + return group.name +} + +function getGroupedWindowLabel(window: RateLimitGroup['windows'][number]): string { + if (window.id === 'session') { + return translate('auto.components.status.bar.tooltip.6ff013c8af', 'Five-hour') + } + if (window.id === 'weekly') { + return translate('auto.components.status.bar.tooltip.252c096536', 'Weekly') + } + return window.name +} + +export function getWindowGroups(p: ProviderRateLimits): WindowGroupSection[] { + return (p.groups ?? []).map((group) => ({ + id: group.id, + label: getWindowGroupLabel(group), + windows: group.windows.map((window) => ({ + id: window.id, + label: getGroupedWindowLabel(window), + window: window.window + })) + })) +} + // --------------------------------------------------------------------------- // Tooltip — progress bar section for a single window // --------------------------------------------------------------------------- @@ -267,6 +315,7 @@ export function ProviderPanel({ resetCreditCount != null ? formatResetCreditExpiry(p.rateLimitResetCredits?.nextExpiresAt, resetCreditCount) : null + const windowGroups = getWindowGroups(p) const PanelWindowSection = ({ w, @@ -327,9 +376,22 @@ export function ProviderPanel({
- {getWindowSections(p).map((s) => ( - - ))} + {windowGroups.length > 0 + ? windowGroups.map((group) => ( +
+
+ {group.label} +
+ {group.windows.map((window) => ( + + ))} +
+ )) + : getWindowSections(p).map((s) => ( + + ))} {p.error ? ( Date: Tue, 14 Jul 2026 12:58:44 +0100 Subject: [PATCH 3/4] test(rate-limits): harden Antigravity usage fallbacks --- .../antigravity-local-quota.test.ts | 79 +++++++++++++++++++ .../rate-limits/antigravity-local-quota.ts | 19 ++++- .../rate-limits/antigravity-oauth-keyring.ts | 3 + .../rate-limits/antigravity-quota-summary.ts | 1 + .../components/settings/AccountsPane.test.tsx | 3 +- .../src/components/settings/AccountsPane.tsx | 8 +- .../components/settings/accounts-search.ts | 4 +- .../src/components/status-bar/tooltip.tsx | 1 + src/renderer/src/i18n/locales/en.json | 12 +-- src/renderer/src/i18n/locales/es.json | 12 +-- src/renderer/src/i18n/locales/ja.json | 12 +-- src/renderer/src/i18n/locales/ko.json | 12 +-- src/renderer/src/i18n/locales/zh.json | 12 +-- src/shared/types.ts | 4 +- 14 files changed, 140 insertions(+), 42 deletions(-) diff --git a/src/main/rate-limits/antigravity-local-quota.test.ts b/src/main/rate-limits/antigravity-local-quota.test.ts index e1bc30d68fc..c232c5de9b2 100644 --- a/src/main/rate-limits/antigravity-local-quota.test.ts +++ b/src/main/rate-limits/antigravity-local-quota.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' vi.mock('electron', () => ({ app: { getPath: vi.fn() } })) import { + fetchAntigravityLocalRateLimits, getAntigravityCliLogDirectory, getAntigravityLanguageServerLogPath, parseAntigravityAppConfig, @@ -11,6 +16,80 @@ import { } from './antigravity-local-quota' describe('Antigravity language-server discovery', () => { + it('falls past a newer stale AGY log to a live CLI quota service', async () => { + const homePath = await mkdtemp(join(tmpdir(), 'orca-antigravity-cli-')) + const logDirectory = getAntigravityCliLogDirectory(homePath) + let requestBody = '' + const server = createServer((request, response) => { + request.on('data', (chunk: Buffer) => { + requestBody += chunk.toString('utf8') + }) + request.on('end', () => { + response.writeHead(200, { 'content-type': 'application/json' }) + response.end( + JSON.stringify({ + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit', + window: 'weekly', + remainingFraction: 0.92 + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit', + window: '5h', + remainingFraction: 1 + } + ] + } + ] + } + }) + ) + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener') + } + + try { + await mkdir(logDirectory, { recursive: true }) + await writeFile( + join(logDirectory, 'cli-20260714_123131.log'), + 'Language server listening on random port at 1 for HTTP' + ) + await writeFile( + join(logDirectory, 'cli-20260714_103225.log'), + `Language server listening on random port at ${address.port} for HTTP` + ) + + const result = await fetchAntigravityLocalRateLimits({ + homePath, + appDataPath: join(homePath, 'app-data') + }) + + expect(requestBody).toBe('{"forceRefresh":true}') + expect(result).toMatchObject({ + provider: 'gemini', + session: { usedPercent: 0 }, + weekly: { usedPercent: 8 }, + usageMetadata: { source: 'cli' } + }) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + await rm(homePath, { recursive: true, force: true }) + } + }) + it('uses AGY CLI logs beneath the cross-platform home directory', () => { expect(getAntigravityCliLogDirectory('/home/lee')).toBe('/home/lee/.gemini/antigravity-cli/log') }) diff --git a/src/main/rate-limits/antigravity-local-quota.ts b/src/main/rate-limits/antigravity-local-quota.ts index 17c990d880b..527cd8a0bf4 100644 --- a/src/main/rate-limits/antigravity-local-quota.ts +++ b/src/main/rate-limits/antigravity-local-quota.ts @@ -25,15 +25,22 @@ export type AntigravityServerPorts = { https: number | null } +export type AntigravityLocalRateLimitOptions = { + homePath?: string + appDataPath?: string +} + function validPort(value: string | undefined): number | null { const port = Number(value) return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null } +/** Resolves AGY's per-user CLI log directory without platform-specific separators. */ export function getAntigravityCliLogDirectory(homePath: string): string { return join(homePath, '.gemini', 'antigravity-cli', 'log') } +/** Uses the newest listener announcement when a CLI log contains server restarts. */ export function parseAntigravityCliServerPorts(log: string): AntigravityServerPorts { const httpsMatches = [ ...log.matchAll(/language server listening on (?:random port at )?(\d+) for HTTPS/gi) @@ -47,6 +54,7 @@ export function parseAntigravityCliServerPorts(log: string): AntigravityServerPo } } +/** Resolves the Antigravity desktop language-server log on supported platforms. */ export function getAntigravityLanguageServerLogPath( platform: NodeJS.Platform, homePath: string, @@ -57,10 +65,12 @@ export function getAntigravityLanguageServerLogPath( : join(appDataPath, 'Antigravity', 'logs', LANGUAGE_SERVER_LOG_NAME) } +/** Returns the newest desktop HTTPS listener recorded in a language-server log. */ export function parseAntigravityLanguageServerPort(log: string): number | null { return parseAntigravityCliServerPorts(log).https } +/** Accepts CSRF configuration only from a page identifying itself as Antigravity. */ export function parseAntigravityAppConfig(html: string): AntigravityAppConfig | null { const configJson = html.match(/window\.__APP_CONFIG__\s*=\s*(\{.*?\});<\/script>/s)?.[1] if (!configJson) { @@ -216,8 +226,11 @@ async function fetchFromDesktopApp( return parseQuotaResponse(response) } -export async function fetchAntigravityLocalRateLimits(): Promise { - const homePath = homedir() +/** Prefers a live AGY CLI, then degrades through desktop and credential sources. */ +export async function fetchAntigravityLocalRateLimits( + options?: AntigravityLocalRateLimitOptions +): Promise { + const homePath = options?.homePath ?? homedir() try { const cliRateLimits = await fetchFromCliLogs(homePath) if (cliRateLimits) { @@ -228,7 +241,7 @@ export async function fetchAntigravityLocalRateLimits(): Promise { const command = getAntigravityKeyringCommand(process.platform) if (!command) { diff --git a/src/main/rate-limits/antigravity-quota-summary.ts b/src/main/rate-limits/antigravity-quota-summary.ts index bcc0aed2f17..ac8c1857589 100644 --- a/src/main/rate-limits/antigravity-quota-summary.ts +++ b/src/main/rate-limits/antigravity-quota-summary.ts @@ -118,6 +118,7 @@ function mostConstrainedWindow(groups: RateLimitGroup[], id: string): RateLimitW }, null) } +/** Converts AGY's grouped remaining quotas into Orca's consumption-based model. */ export function parseAntigravityQuotaSummary( value: unknown, updatedAt = Date.now() diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index d694b39ffc0..8d93761c874 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -55,7 +55,8 @@ describe('AccountsPane', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).toContain('Showing accounts for this device. New accounts are added there.') - expect(markup).toContain('authenticate with Google for this device. This uses credentials') + expect(markup).toContain('Reads quota from a running Antigravity CLI or app for this device') + expect(markup).toContain('Credentials stay on this device') expect(markup).not.toContain('ShowingThis device') expect(markup).not.toContain('forThis device') }) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 292e802df54..83fe95611cc 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1381,11 +1381,11 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.96f3649526', - 'Use Antigravity or Gemini CLI credentials (experimental)' + 'Use Antigravity or Gemini CLI for usage (experimental)' )}

{translate( 'auto.components.settings.AccountsPane.c2aee76420', - "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google for {{value0}}. These credentials were issued to Google's apps, not Orca. May break if Google changes their storage. Use at your own risk.", + 'Reads quota from a running Antigravity CLI or app for {{value0}}, with native keyring and local Gemini CLI credentials as fallbacks. Credentials stay on {{value0}} and are sent only to Google APIs. May break if Google changes these interfaces. Use at your own risk.', { value0: localAccountRuntimeSentenceLabel } )}

diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index db6fd5570de..72f7aac6a80 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -100,11 +100,11 @@ export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ { title: translate( 'auto.components.settings.accounts.search.d819755b02', - 'Use Antigravity or Gemini CLI credentials' + 'Use Antigravity or Gemini CLI for usage' ), description: translate( 'auto.components.settings.accounts.search.bada4a3218', - "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google." + 'Reads Antigravity quota locally, with native keyring and Gemini CLI credentials as fallbacks.' ), keywords: [ ...translateSearchKeyword('auto.lib.agent.catalog.691dd11789', 'antigravity'), diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index 83caf1cd9e9..d2705445d52 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -211,6 +211,7 @@ function getGroupedWindowLabel(window: RateLimitGroup['windows'][number]): strin return window.name } +/** Keeps provider group identifiers stable while localizing their visible labels. */ export function getWindowGroups(p: ProviderRateLimits): WindowGroupSection[] { return (p.groups ?? []).map((group) => ({ id: group.id, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index bff5f1ee2b4..476da8ba2ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4773,10 +4773,10 @@ "36223200ac": "OpenCode Go Session Cookie", "ea631977b5": "Configure OpenCode Go provider settings.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google for {{value0}}. These credentials were issued to Google's apps, not Orca. May break if Google changes their storage. Use at your own risk.", - "96f3649526": "Use Antigravity or Gemini CLI credentials (experimental)", - "d676c41fc6": "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google. These credentials were issued to Google's apps, not Orca. May break if Google changes their storage. Use at your own risk.", - "0c7f915b01": "Use Antigravity or Gemini CLI credentials", + "c2aee76420": "Reads quota from a running Antigravity CLI or app for {{value0}}, with native keyring and local Gemini CLI credentials as fallbacks. Credentials stay on {{value0}} and are sent only to Google APIs. May break if Google changes these interfaces. Use at your own risk.", + "96f3649526": "Use Antigravity or Gemini CLI for usage (experimental)", + "d676c41fc6": "Reads quota from a running Antigravity CLI or app, with native keyring and local Gemini CLI credentials as fallbacks. Credentials stay on this device and are sent only to Google APIs. May break if Google changes these interfaces. Use at your own risk.", + "0c7f915b01": "Use Antigravity or Gemini CLI for usage", "973741a871": "Configure Gemini provider settings.", "0c64dc2a64": "Gemini", "db209ee572": "Remove", @@ -7016,8 +7016,8 @@ "933deaf732": "oauth", "8630464352": "cli", "e8e1ff3887": "gemini", - "bada4a3218": "Reads OAuth credentials from Antigravity's native OS keyring or your local Gemini CLI installation to authenticate with Google.", - "d819755b02": "Use Antigravity or Gemini CLI credentials", + "bada4a3218": "Reads Antigravity quota locally, with native keyring and Gemini CLI credentials as fallbacks.", + "d819755b02": "Use Antigravity or Gemini CLI for usage", "35b461d817": "sign in", "f2d666a886": "optional", "8b06729e0f": "active", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 486eba444f2..6726fe16768 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4773,10 +4773,10 @@ "36223200ac": "Cookie de sesión de OpenCode Go", "ea631977b5": "Configura los ajustes del proveedor OpenCode Go.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Lee las credenciales OAuth del llavero nativo del sistema operativo de Antigravity o de tu instalación local de Gemini CLI para autenticarse con Google en {{value0}}. Estas credenciales se emitieron para las aplicaciones de Google, no para Orca. Puede dejar de funcionar si Google cambia su almacenamiento. Úsalo bajo tu propia responsabilidad.", - "96f3649526": "Usar credenciales de Antigravity o Gemini CLI (experimental)", - "d676c41fc6": "Lee las credenciales OAuth del llavero nativo del sistema operativo de Antigravity o de tu instalación local de Gemini CLI para autenticarse con Google. Estas credenciales se emitieron para las aplicaciones de Google, no para Orca. Puede dejar de funcionar si Google cambia su almacenamiento. Úsalo bajo tu propia responsabilidad.", - "0c7f915b01": "Usar credenciales de Antigravity o Gemini CLI", + "c2aee76420": "Lee la cuota desde una CLI o aplicación Antigravity en ejecución para {{value0}}, con el llavero nativo y las credenciales locales de Gemini CLI como alternativas. Las credenciales permanecen en {{value0}} y solo se envían a las API de Google. Puede dejar de funcionar si Google cambia estas interfaces. Úsalo bajo tu propia responsabilidad.", + "96f3649526": "Usar Antigravity o Gemini CLI para el uso (experimental)", + "d676c41fc6": "Lee la cuota desde una CLI o aplicación Antigravity en ejecución, con el llavero nativo y las credenciales locales de Gemini CLI como alternativas. Las credenciales permanecen en este dispositivo y solo se envían a las API de Google. Puede dejar de funcionar si Google cambia estas interfaces. Úsalo bajo tu propia responsabilidad.", + "0c7f915b01": "Usar Antigravity o Gemini CLI para el uso", "973741a871": "Configura los ajustes del proveedor Gemini.", "0c64dc2a64": "Gemini", "db209ee572": "Eliminar", @@ -6979,8 +6979,8 @@ "933deaf732": "OAuth", "8630464352": "CLI", "e8e1ff3887": "Gemini", - "bada4a3218": "Lee las credenciales OAuth del llavero nativo del sistema operativo de Antigravity o de tu instalación local de Gemini CLI para autenticarte con Google.", - "d819755b02": "Usar credenciales de Antigravity o Gemini CLI", + "bada4a3218": "Lee la cuota de Antigravity localmente, con el llavero nativo y las credenciales de Gemini CLI como alternativas.", + "d819755b02": "Usar Antigravity o Gemini CLI para el uso", "35b461d817": "iniciar sesión", "f2d666a886": "opcional", "8b06729e0f": "activo", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b94667dc776..47624b288b0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4758,10 +4758,10 @@ "36223200ac": "OpenCode Go セッション Cookie", "ea631977b5": "OpenCode Go プロバイダー設定を構成します。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Antigravity の OS ネイティブキーチェーンまたはローカルの Gemini CLI インストールから OAuth 認証情報を読み取り、{{value0}} で Google に認証します。これらの認証情報は Orca ではなく Google のアプリに発行されたものです。Google が保存方式を変更すると動作しなくなる可能性があります。自己責任で使用してください。", - "96f3649526": "Antigravity または Gemini CLI の認証情報を使用する(実験的)", - "d676c41fc6": "Antigravity の OS ネイティブキーチェーンまたはローカルの Gemini CLI インストールから OAuth 認証情報を読み取り、Google に認証します。これらの認証情報は Orca ではなく Google のアプリに発行されたものです。Google が保存方式を変更すると動作しなくなる可能性があります。自己責任で使用してください。", - "0c7f915b01": "Antigravity または Gemini CLI の認証情報を使用する", + "c2aee76420": "{{value0}} で実行中の Antigravity CLI またはアプリからクォータを読み取り、ネイティブキーチェーンとローカル Gemini CLI の認証情報をフォールバックとして使用します。認証情報は {{value0}} 内に保持され、Google API にのみ送信されます。Google がこれらのインターフェースを変更すると動作しなくなる可能性があります。自己責任で使用してください。", + "96f3649526": "使用量に Antigravity または Gemini CLI を使用する(実験的)", + "d676c41fc6": "実行中の Antigravity CLI またはアプリからクォータを読み取り、ネイティブキーチェーンとローカル Gemini CLI の認証情報をフォールバックとして使用します。認証情報はこのデバイス内に保持され、Google API にのみ送信されます。Google がこれらのインターフェースを変更すると動作しなくなる可能性があります。自己責任で使用してください。", + "0c7f915b01": "使用量に Antigravity または Gemini CLI を使用する", "973741a871": "Gemini プロバイダー設定を構成します。", "0c64dc2a64": "Gemini", "db209ee572": "削除", @@ -7001,8 +7001,8 @@ "933deaf732": "認証", "8630464352": "CLI", "e8e1ff3887": "gemini", - "bada4a3218": "Antigravity の OS ネイティブキーチェーンまたはローカルの Gemini CLI インストールから OAuth 認証情報を読み取り、Google に認証します。", - "d819755b02": "Antigravity または Gemini CLI の認証情報を使用する", + "bada4a3218": "Antigravity のクォータをローカルで読み取り、ネイティブキーチェーンと Gemini CLI の認証情報をフォールバックとして使用します。", + "d819755b02": "使用量に Antigravity または Gemini CLI を使用する", "35b461d817": "サインイン", "f2d666a886": "オプション", "8b06729e0f": "アクティブ", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5e9082abd5d..c575b45d86f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4758,10 +4758,10 @@ "36223200ac": "OpenCode Go 세션 쿠키", "ea631977b5": "OpenCode Go 공급자 설정을 구성합니다.", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "Antigravity의 OS 기본 키링 또는 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 읽어 {{value0}}에서 Google 인증에 사용합니다. 이 자격 증명은 Orca가 아니라 Google 앱에 발급된 것입니다. Google이 저장 방식을 변경하면 작동하지 않을 수 있습니다. 사용에 따른 책임은 사용자에게 있습니다.", - "96f3649526": "Antigravity 또는 Gemini CLI 자격 증명 사용(실험적)", - "d676c41fc6": "Antigravity의 OS 기본 키링 또는 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 읽어 Google 인증에 사용합니다. 이 자격 증명은 Orca가 아니라 Google 앱에 발급된 것입니다. Google이 저장 방식을 변경하면 작동하지 않을 수 있습니다. 사용에 따른 책임은 사용자에게 있습니다.", - "0c7f915b01": "Antigravity 또는 Gemini CLI 자격 증명 사용", + "c2aee76420": "{{value0}}에서 실행 중인 Antigravity CLI 또는 앱의 할당량을 읽고, 기본 키링과 로컬 Gemini CLI 자격 증명을 대체 수단으로 사용합니다. 자격 증명은 {{value0}}에 유지되며 Google API에만 전송됩니다. Google이 이러한 인터페이스를 변경하면 작동하지 않을 수 있습니다. 사용에 따른 책임은 사용자에게 있습니다.", + "96f3649526": "사용량에 Antigravity 또는 Gemini CLI 사용(실험적)", + "d676c41fc6": "실행 중인 Antigravity CLI 또는 앱의 할당량을 읽고, 기본 키링과 로컬 Gemini CLI 자격 증명을 대체 수단으로 사용합니다. 자격 증명은 이 기기에 유지되며 Google API에만 전송됩니다. Google이 이러한 인터페이스를 변경하면 작동하지 않을 수 있습니다. 사용에 따른 책임은 사용자에게 있습니다.", + "0c7f915b01": "사용량에 Antigravity 또는 Gemini CLI 사용", "973741a871": "Gemini 공급자 설정을 구성합니다.", "0c64dc2a64": "Gemini", "db209ee572": "제거", @@ -6964,8 +6964,8 @@ "933deaf732": "OAuth", "8630464352": "CLI", "e8e1ff3887": "gemini", - "bada4a3218": "Antigravity의 OS 기본 키링 또는 로컬 Gemini CLI 설치에서 OAuth 자격 증명을 읽어 Google 인증에 사용합니다.", - "d819755b02": "Antigravity 또는 Gemini CLI 자격 증명 사용", + "bada4a3218": "Antigravity 할당량을 로컬에서 읽고 기본 키링과 Gemini CLI 자격 증명을 대체 수단으로 사용합니다.", + "d819755b02": "사용량에 Antigravity 또는 Gemini CLI 사용", "35b461d817": "로그인", "f2d666a886": "선택 사항", "8b06729e0f": "활성", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index da3ac1b175f..c39cf3b1885 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4758,10 +4758,10 @@ "36223200ac": "OpenCode Go 会话 Cookie", "ea631977b5": "配置 OpenCode Go 提供商设置。", "4ac10b4d08": "OpenCode Go", - "c2aee76420": "从 Antigravity 的操作系统原生密钥环或本地 Gemini CLI 安装中读取 OAuth 凭据,用于在 {{value0}} 通过 Google 进行身份验证。这些凭据由 Google 应用获取,而非 Orca。若 Google 更改存储方式,可能会失效。请自行承担风险。", - "96f3649526": "使用 Antigravity 或 Gemini CLI 凭据(实验)", - "d676c41fc6": "从 Antigravity 的操作系统原生密钥环或本地 Gemini CLI 安装中读取 OAuth 凭据以通过 Google 认证。这些凭据由 Google 应用获取,而非 Orca。若 Google 更改存储方式,可能会失效。请自行承担风险。", - "0c7f915b01": "使用 Antigravity 或 Gemini CLI 凭据", + "c2aee76420": "从 {{value0}} 上运行的 Antigravity CLI 或应用读取配额,并以原生密钥环和本地 Gemini CLI 凭据作为后备。凭据保留在 {{value0}},且仅发送到 Google API。若 Google 更改这些接口,可能会失效。请自行承担风险。", + "96f3649526": "使用 Antigravity 或 Gemini CLI 获取用量(实验)", + "d676c41fc6": "从运行中的 Antigravity CLI 或应用读取配额,并以原生密钥环和本地 Gemini CLI 凭据作为后备。凭据保留在此设备上,且仅发送到 Google API。若 Google 更改这些接口,可能会失效。请自行承担风险。", + "0c7f915b01": "使用 Antigravity 或 Gemini CLI 获取用量", "973741a871": "配置 Gemini 提供商设置。", "0c64dc2a64": "Gemini", "db209ee572": "移除", @@ -6964,8 +6964,8 @@ "933deaf732": "OAuth", "8630464352": "CLI", "e8e1ff3887": "gemini", - "bada4a3218": "从 Antigravity 的操作系统原生密钥环或本地 Gemini CLI 安装中读取 OAuth 凭据以通过 Google 进行身份验证。", - "d819755b02": "使用 Antigravity 或 Gemini CLI 凭据", + "bada4a3218": "在本地读取 Antigravity 配额,并以原生密钥环和 Gemini CLI 凭据作为后备。", + "d819755b02": "使用 Antigravity 或 Gemini CLI 获取用量", "35b461d817": "登入", "f2d666a886": "可选", "8b06729e0f": "活跃", diff --git a/src/shared/types.ts b/src/shared/types.ts index 43d587073a8..0348fc18ac0 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2838,8 +2838,8 @@ export type GlobalSettings = { minimaxGroupId: string /** Comma-separated MiniMax model names to show in the status bar usage window. */ minimaxUsageModels: string - /** Whether to read OAuth credentials from Antigravity's native keyring or a - * local Gemini CLI installation. Disabled by default for explicit opt-in. */ + /** Whether to read Antigravity quota locally, with native-keyring and Gemini + * CLI credentials as fallbacks. Disabled by default for explicit opt-in. */ geminiCliOAuthEnabled: boolean /** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */ agentCmdOverrides: Partial> From f93b7427e40fb9522ec35f1146cc039444091976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Le=C3=A3o?= Date: Tue, 14 Jul 2026 14:11:41 +0100 Subject: [PATCH 4/4] fix(rate-limits): harden Antigravity review paths --- .../rate-limits/antigravity-local-quota.ts | 5 + .../antigravity-oauth-keyring-read.test.ts | 102 ++++++++++++++++++ .../rate-limits/antigravity-oauth-keyring.ts | 27 ++++- .../rate-limits/antigravity-quota-summary.ts | 6 ++ .../rate-limits/gemini-usage-fetcher.test.ts | 70 ++++++++++++ src/main/rate-limits/gemini-usage-fetcher.ts | 51 ++++++--- .../src/components/status-bar/tooltip.tsx | 2 + 7 files changed, 243 insertions(+), 20 deletions(-) create mode 100644 src/main/rate-limits/antigravity-oauth-keyring-read.test.ts diff --git a/src/main/rate-limits/antigravity-local-quota.ts b/src/main/rate-limits/antigravity-local-quota.ts index 527cd8a0bf4..eca1e3e2b74 100644 --- a/src/main/rate-limits/antigravity-local-quota.ts +++ b/src/main/rate-limits/antigravity-local-quota.ts @@ -30,6 +30,7 @@ export type AntigravityLocalRateLimitOptions = { appDataPath?: string } +/** Converts a listener value into a valid TCP port. */ function validPort(value: string | undefined): number | null { const port = Number(value) return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null @@ -88,6 +89,7 @@ export function parseAntigravityAppConfig(html: string): AntigravityAppConfig | } } +/** Sends a bounded request to an Antigravity service on the loopback interface. */ function requestLocalAntigravity( protocol: LocalProtocol, port: number, @@ -149,10 +151,12 @@ function requestLocalAntigravity( }) } +/** Parses a local quota response into Orca's provider rate-limit shape. */ function parseQuotaResponse(response: string): ProviderRateLimits | null { return parseAntigravityQuotaSummary(JSON.parse(response) as unknown) } +/** Searches recent CLI logs for the newest reachable Antigravity server. */ async function fetchFromCliLogs(homePath: string): Promise { const logDirectory = getAntigravityCliLogDirectory(homePath) const entries = await readdir(logDirectory, { withFileTypes: true }) @@ -206,6 +210,7 @@ async function fetchFromCliLogs(homePath: string): Promise ({ + execFileAsyncMock: vi.fn(), + execFileMock: vi.fn(), + promisifyCustom: Symbol.for('nodejs.util.promisify.custom') +})) + +vi.mock('node:child_process', () => ({ + execFile: Object.assign(execFileMock, { + [promisifyCustom]: execFileAsyncMock + }) +})) + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform + }) +} + +function encodedKeyringValue(): string { + return `go-keyring-base64:${Buffer.from( + JSON.stringify({ + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry: '2026-07-14T14:00:00.000Z' + } + }) + ).toString('base64')}` +} + +describe('readAntigravityCredentials', () => { + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-14T12:00:00.000Z')) + execFileAsyncMock.mockReset() + }) + + afterEach(() => { + vi.useRealTimers() + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + }) + + it('reads and parses a native keyring value with bounded process options', async () => { + setPlatform('darwin') + execFileAsyncMock.mockResolvedValue({ stdout: encodedKeyringValue(), stderr: '' }) + const { readAntigravityCredentials } = await import('./antigravity-oauth-keyring') + + await expect(readAntigravityCredentials()).resolves.toEqual({ + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry_date: Date.parse('2026-07-14T14:00:00.000Z') + }) + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'security', + ['find-generic-password', '-s', 'gemini', '-a', 'antigravity', '-w'], + { + encoding: 'utf8', + maxBuffer: 16 * 1024, + timeout: 3_000, + windowsHide: true + } + ) + }) + + it('derives the Windows target and decodes credential-manager output', async () => { + setPlatform('win32') + execFileAsyncMock.mockResolvedValue({ + stdout: Buffer.from(encodedKeyringValue()).toString('base64'), + stderr: '' + }) + const { readAntigravityCredentials } = await import('./antigravity-oauth-keyring') + + await expect(readAntigravityCredentials()).resolves.toMatchObject({ + access_token: 'access-token' + }) + const command = execFileAsyncMock.mock.calls[0]?.[1]?.[3] as string + expect(command).toContain("CredRead('gemini:antigravity'") + expect(command).not.toContain('__ORCA_CREDENTIAL_TARGET__') + }) + + it('caches native keyring failures briefly before retrying', async () => { + setPlatform('darwin') + execFileAsyncMock.mockRejectedValue(new Error('keyring unavailable')) + const { readAntigravityCredentials } = await import('./antigravity-oauth-keyring') + + await expect(readAntigravityCredentials()).resolves.toBeNull() + await expect(readAntigravityCredentials()).resolves.toBeNull() + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(60_001) + await expect(readAntigravityCredentials()).resolves.toBeNull() + expect(execFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/rate-limits/antigravity-oauth-keyring.ts b/src/main/rate-limits/antigravity-oauth-keyring.ts index 5f86361484f..bf34d45fffb 100644 --- a/src/main/rate-limits/antigravity-oauth-keyring.ts +++ b/src/main/rate-limits/antigravity-oauth-keyring.ts @@ -7,6 +7,10 @@ const KEYRING_SERVICE = 'gemini' const KEYRING_ACCOUNT = 'antigravity' const KEYRING_VALUE_PREFIX = 'go-keyring-base64:' const KEYRING_TIMEOUT_MS = 3_000 +const KEYRING_MISS_CACHE_MS = 60_000 +const WINDOWS_TARGET_PLACEHOLDER = '__ORCA_CREDENTIAL_TARGET__' + +let keyringMissExpiresAt = 0 type KeyringCommand = { command: string @@ -44,7 +48,7 @@ public static class OrcaCredentialReader { '@ Add-Type -TypeDefinition $signature $pointer = [IntPtr]::Zero -if ([OrcaCredentialReader]::CredRead('gemini:antigravity', 1, 0, [ref]$pointer)) { +if ([OrcaCredentialReader]::CredRead('${WINDOWS_TARGET_PLACEHOLDER}', 1, 0, [ref]$pointer)) { try { $credential = [Runtime.InteropServices.Marshal]::PtrToStructure( $pointer, @@ -64,6 +68,14 @@ if ([OrcaCredentialReader]::CredRead('gemini:antigravity', 1, 0, [ref]$pointer)) } `.trim() +/** Builds the Windows credential reader from the same service and account used elsewhere. */ +function buildWindowsCredentialScript(): string { + return WINDOWS_CREDENTIAL_SCRIPT.replace( + WINDOWS_TARGET_PLACEHOLDER, + `${KEYRING_SERVICE}:${KEYRING_ACCOUNT}` + ) +} + /** Keeps native credential-store access behind fixed, platform-specific commands. */ export function getAntigravityKeyringCommand(platform: NodeJS.Platform): KeyringCommand | null { switch (platform) { @@ -84,7 +96,7 @@ export function getAntigravityKeyringCommand(platform: NodeJS.Platform): Keyring case 'win32': return { command: 'powershell.exe', - args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_CREDENTIAL_SCRIPT], + args: ['-NoProfile', '-NonInteractive', '-Command', buildWindowsCredentialScript()], output: 'base64' } case 'aix': @@ -145,6 +157,10 @@ export async function readAntigravityCredentials(): Promise bucket.bucketId.startsWith('gemini-'))) { return 'gemini-models' @@ -56,6 +58,7 @@ function getGroupId(group: QuotaSummaryGroup): string { .replaceAll(/^-|-$/g, '') } +/** Maps Antigravity window labels to Orca's session and weekly identifiers. */ function getWindowId(bucket: QuotaSummaryBucket): 'session' | 'weekly' | null { if (bucket.window === '5h' || bucket.bucketId.endsWith('-5h')) { return 'session' @@ -66,6 +69,7 @@ function getWindowId(bucket: QuotaSummaryBucket): 'session' | 'weekly' | null { return null } +/** Converts remaining quota into Orca's consumption-based window model. */ function toRateLimitWindow(bucket: QuotaSummaryBucket): RateLimitWindow { const remainingFraction = Math.min(1, Math.max(0, bucket.remainingFraction)) const resetsAt = bucket.resetTime ? new Date(bucket.resetTime).getTime() : Number.NaN @@ -77,6 +81,7 @@ function toRateLimitWindow(bucket: QuotaSummaryBucket): RateLimitWindow { } } +/** Parses one display group while discarding unsupported or malformed windows. */ function parseGroup(value: unknown): RateLimitGroup | null { if (!value || typeof value !== 'object') { return null @@ -109,6 +114,7 @@ function parseGroup(value: unknown): RateLimitGroup | null { } } +/** Selects the highest-consumption window for the provider-level summary. */ function mostConstrainedWindow(groups: RateLimitGroup[], id: string): RateLimitWindow | null { const windows = groups.flatMap((group) => group.windows.filter((entry) => entry.id === id).map((entry) => entry.window) diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index e98dba5c65b..99a5c4f876b 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -172,6 +172,76 @@ describe('fetchGeminiRateLimits', () => { }) }) + it('falls back when a current Antigravity token cannot resolve a project', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'stale-agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() + 60_000 + }) + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string, options?: RequestInit) => { + const authorization = (options?.headers as Record | undefined)?.Authorization + if (url.includes('loadCodeAssist')) { + return authorization === 'Bearer stale-agy-access-token' + ? Promise.resolve(makeResponse({}, 500)) + : Promise.resolve(makeResponse({ cloudaicompanionProject: 'legacy-project' })) + } + if (url.includes('retrieveUserQuota')) { + return Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + const quotaRequest = netFetchMock.mock.calls.find( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect((quotaRequest![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer auth-json-access-token' + }) + }) + + it('falls back when a current Antigravity token returns a quota error', async () => { + readAntigravityCredentialsMock.mockResolvedValue({ + access_token: 'stale-agy-access-token', + refresh_token: 'agy-refresh-token', + expiry_date: Date.now() + 60_000 + }) + setupAuthJsonValid() + netFetchMock.mockImplementation((url: string, options?: RequestInit) => { + const authorization = (options?.headers as Record | undefined)?.Authorization + if (url.includes('loadCodeAssist')) { + return Promise.resolve( + makeResponse({ + cloudaicompanionProject: + authorization === 'Bearer stale-agy-access-token' + ? 'stale-agy-project' + : 'legacy-project' + }) + ) + } + if (url.includes('retrieveUserQuota')) { + return authorization === 'Bearer stale-agy-access-token' + ? Promise.resolve(makeResponse({}, 500)) + : Promise.resolve(makeResponse(quotaResponse)) + } + return Promise.resolve(makeResponse({}, 404)) + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('ok') + const quotaRequests = netFetchMock.mock.calls.filter( + (call) => typeof call[0] === 'string' && call[0].includes('retrieveUserQuota') + ) + expect(quotaRequests).toHaveLength(2) + expect((quotaRequests[1]![1] as RequestInit).headers).toMatchObject({ + Authorization: 'Bearer auth-json-access-token' + }) + }) + it('returns quota via auth.json', async () => { setupAuthJsonValid() netFetchMock.mockImplementation((url: string) => { diff --git a/src/main/rate-limits/gemini-usage-fetcher.ts b/src/main/rate-limits/gemini-usage-fetcher.ts index a3ac14f236f..1818aad6d93 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.ts @@ -22,6 +22,7 @@ const RETRIEVE_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retri type QuotaBucket = { remainingFraction: number; resetTime: string; modelId: string } +/** Narrows raw quota entries before they are formatted for display. */ function isQuotaBucket(o: unknown): o is QuotaBucket { return ( typeof o === 'object' && @@ -33,6 +34,7 @@ function isQuotaBucket(o: unknown): o is QuotaBucket { ) } +/** Accepts both current wrapped quota responses and the legacy array shape. */ function parseQuotaResponse(data: unknown): QuotaBucket[] { let rawBuckets: unknown[] = [] if (Array.isArray(data)) { @@ -43,6 +45,7 @@ function parseQuotaResponse(data: unknown): QuotaBucket[] { return rawBuckets.filter((b) => isQuotaBucket(b)) } +/** Retrieves and normalizes quota buckets for one Google access token and project. */ async function fetchQuota(accessToken: string, projectId: string): Promise { const controller = new AbortController() const timeout = setTimeout(() => { @@ -83,6 +86,7 @@ async function fetchQuota(accessToken: string, projectId: string): Promise { + const credentials = await readAntigravityCredentials() + if (!credentials || credentials.expiry_date <= Date.now()) { + return null + } + + try { + // Why: AGY owns refreshing and persisting its native-keyring token. Using + // only a current token avoids writing Antigravity auth into Gemini's file. + const projectId = await loadProjectId(credentials.access_token).catch(() => { + return '' + }) + if (!projectId) { + return null + } + const result = await fetchQuota(credentials.access_token, projectId) + // Why: stale-but-current AGY credentials must not mask usable legacy + // Gemini sources when project or quota discovery fails. + return result.status === 'ok' ? result : null + } catch { + return null + } +} + +/** Discovers Gemini-compatible quotas in preferred-source order. */ export async function fetchGeminiRateLimits( geminiCliOAuthEnabled = false ): Promise { @@ -224,23 +255,9 @@ export async function fetchGeminiRateLimits( return antigravityRateLimits } - const antigravityCreds = await readAntigravityCredentials() - if (antigravityCreds && antigravityCreds.expiry_date > Date.now()) { - // Why: AGY owns refreshing and persisting its native-keyring token. Using - // only a current token avoids writing Antigravity auth into Gemini's file. - const projectId = await loadProjectId(antigravityCreds.access_token).catch(() => { - return '' - }) - return projectId - ? await fetchQuota(antigravityCreds.access_token, projectId) - : { - provider: 'gemini', - session: null, - weekly: null, - updatedAt: Date.now(), - error: 'Gemini project ID not found', - status: 'error' - } + const antigravityKeyringRateLimits = await fetchViaAntigravityKeyring() + if (antigravityKeyringRateLimits) { + return antigravityKeyringRateLimits } const authJson = await readAuthJson() diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index d2705445d52..46cf92aa1b8 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -191,6 +191,7 @@ type WindowGroupSection = { windows: GroupedWindowSection[] } +/** Localizes known model-family labels while preserving server-provided names. */ function getWindowGroupLabel(group: RateLimitGroup): string { if (group.id === 'gemini-models') { return translate('auto.components.status.bar.tooltip.3c639d6830', 'Gemini models') @@ -201,6 +202,7 @@ function getWindowGroupLabel(group: RateLimitGroup): string { return group.name } +/** Localizes known window labels while preserving server-provided names. */ function getGroupedWindowLabel(window: RateLimitGroup['windows'][number]): string { if (window.id === 'session') { return translate('auto.components.status.bar.tooltip.6ff013c8af', 'Five-hour')