From 0976023cd0e8393619e1677e8c56bf6781b84a8f Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Tue, 8 Sep 2026 16:43:32 +0400 Subject: [PATCH] feat(accounts): show Antigravity CLI sign-in and usage Read the agy session on disk, refresh its OAuth token from the CLI binary, and fetch daily Cloud Code quota so Accounts and the status bar stop treating Antigravity as a Gemini OAuth mirror. Co-authored-by: Cursor --- src/main/antigravity-accounts/status.test.ts | 68 +++++ src/main/antigravity-accounts/status.ts | 31 +++ src/main/ipc/antigravity-accounts.ts | 6 + .../register-core-handlers.test.ts | 7 + .../register-core-handlers.ts | 2 + .../antigravity-cli-oauth-extractor.test.ts | 53 ++++ .../antigravity-cli-oauth-extractor.ts | 178 ++++++++++++ .../antigravity-oauth-sources.test.ts | 133 +++++++++ .../rate-limits/antigravity-oauth-sources.ts | 169 ++++++++++++ .../antigravity-quota-summary.test.ts | 67 +++++ .../rate-limits/antigravity-quota-summary.ts | 129 +++++++++ .../antigravity-usage-fetcher.test.ts | 197 +++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 180 ++++++++++++ .../antigravity-usage-mirror.test.ts | 70 ----- .../rate-limits/antigravity-usage-mirror.ts | 29 -- .../rate-limit-service-test-harness.ts | 9 + .../service-account-target-selection.test.ts | 8 + .../service-antigravity-usage.test.ts | 67 ++++- .../service-inactive-account-previews.test.ts | 8 + .../service-live-claude-usage.test.ts | 8 + .../rate-limits/service-minimax-usage.test.ts | 8 + .../service-refresh-orchestration.test.ts | 8 + .../service-window-activation.test.ts | 8 + .../service/service-configuration.ts | 1 + .../service/service-full-cycle-application.ts | 20 +- .../service/service-full-cycle-preparation.ts | 88 +++--- src/main/rate-limits/service/service-state.ts | 2 + src/preload/api-types.ts | 2 + src/preload/api/agent-account-api.ts | 6 +- .../api/antigravity-accounts-bridge.ts | 8 + src/preload/index.ts | 2 + .../components/settings/AccountsPane.test.tsx | 8 + .../src/components/settings/AccountsPane.tsx | 5 + .../AntigravityAccountsSection.test.tsx | 157 +++++++++++ .../settings/AntigravityAccountsSection.tsx | 261 ++++++++++++++++++ .../settings/accounts-search.test.ts | 23 +- .../components/settings/accounts-search.ts | 26 ++ .../status-bar-provider-visibility.test.ts | 50 +--- .../status-bar-provider-visibility.ts | 19 +- .../usage-provider-settings-target.test.ts | 2 +- .../usage-provider-settings-target.ts | 4 +- .../status-bar/use-status-bar-controller.ts | 10 +- src/renderer/src/i18n/locales/en.json | 25 +- .../web/preload-api/web-agent-accounts-api.ts | 14 + .../web/web-preload-api-composition.test.ts | 1 + src/renderer/src/web/web-preload-api.ts | 2 + src/shared/rate-limit-state-factory.ts | 1 + src/shared/rate-limit-types.test.ts | 1 + src/shared/rate-limit-types.ts | 9 + 49 files changed, 1978 insertions(+), 212 deletions(-) create mode 100644 src/main/antigravity-accounts/status.test.ts create mode 100644 src/main/antigravity-accounts/status.ts create mode 100644 src/main/ipc/antigravity-accounts.ts create mode 100644 src/main/rate-limits/antigravity-cli-oauth-extractor.test.ts create mode 100644 src/main/rate-limits/antigravity-cli-oauth-extractor.ts create mode 100644 src/main/rate-limits/antigravity-oauth-sources.test.ts create mode 100644 src/main/rate-limits/antigravity-oauth-sources.ts create mode 100644 src/main/rate-limits/antigravity-quota-summary.test.ts create mode 100644 src/main/rate-limits/antigravity-quota-summary.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.test.ts create mode 100644 src/main/rate-limits/antigravity-usage-fetcher.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.test.ts delete mode 100644 src/main/rate-limits/antigravity-usage-mirror.ts create mode 100644 src/preload/api/antigravity-accounts-bridge.ts create mode 100644 src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx create mode 100644 src/renderer/src/components/settings/AntigravityAccountsSection.tsx diff --git a/src/main/antigravity-accounts/status.test.ts b/src/main/antigravity-accounts/status.test.ts new file mode 100644 index 000000000000..c83527626408 --- /dev/null +++ b/src/main/antigravity-accounts/status.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getAntigravityAccountStatus } from './status' +import { + isAntigravitySessionUsable, + readAntigravityAuthSession +} from '../rate-limits/antigravity-oauth-sources' + +vi.mock('../rate-limits/antigravity-oauth-sources', () => ({ + isAntigravitySessionUsable: vi.fn(), + readAntigravityAuthSession: vi.fn() +})) + +describe('getAntigravityAccountStatus', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isAntigravitySessionUsable).mockReturnValue(true) + }) + + it('reports unsigned status when the Antigravity token file is missing', () => { + vi.mocked(readAntigravityAuthSession).mockReturnValue({ status: 'missing' }) + + expect(getAntigravityAccountStatus()).toEqual({ + signedIn: false, + email: null, + tokenFresh: false, + error: null + }) + }) + + it('reports auth read errors without exposing token fields', () => { + vi.mocked(readAntigravityAuthSession).mockReturnValue({ + status: 'error', + error: 'Antigravity auth file is invalid' + }) + + expect(getAntigravityAccountStatus()).toEqual({ + signedIn: false, + email: null, + tokenFresh: false, + error: 'Antigravity auth file is invalid' + }) + }) + + it('returns non-secret signed-in metadata', () => { + vi.mocked(readAntigravityAuthSession).mockReturnValue({ + status: 'ok', + session: { + accessToken: 'secret-token', + refreshToken: 'secret-refresh', + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + authMethod: 'consumer', + email: 'dev@example.com' + } + }) + vi.mocked(isAntigravitySessionUsable).mockReturnValue(true) + + const status = getAntigravityAccountStatus() + + expect(status).toEqual({ + signedIn: true, + email: 'dev@example.com', + tokenFresh: true, + error: null + }) + expect(JSON.stringify(status)).not.toContain('secret-token') + expect(JSON.stringify(status)).not.toContain('secret-refresh') + }) +}) diff --git a/src/main/antigravity-accounts/status.ts b/src/main/antigravity-accounts/status.ts new file mode 100644 index 000000000000..4d72d519b604 --- /dev/null +++ b/src/main/antigravity-accounts/status.ts @@ -0,0 +1,31 @@ +import type { AntigravityAccountStatus } from '../../shared/rate-limit-types' +import { + isAntigravitySessionUsable, + readAntigravityAuthSession +} from '../rate-limits/antigravity-oauth-sources' + +export function getAntigravityAccountStatus(): AntigravityAccountStatus { + const readResult = readAntigravityAuthSession() + if (readResult.status === 'missing') { + return { + signedIn: false, + email: null, + tokenFresh: false, + error: null + } + } + if (readResult.status === 'error') { + return { + signedIn: false, + email: null, + tokenFresh: false, + error: readResult.error + } + } + return { + signedIn: true, + email: readResult.session.email, + tokenFresh: isAntigravitySessionUsable(readResult.session), + error: null + } +} diff --git a/src/main/ipc/antigravity-accounts.ts b/src/main/ipc/antigravity-accounts.ts new file mode 100644 index 000000000000..55e1a203f5f6 --- /dev/null +++ b/src/main/ipc/antigravity-accounts.ts @@ -0,0 +1,6 @@ +import { ipcMain } from 'electron' +import { getAntigravityAccountStatus } from '../antigravity-accounts/status' + +export function registerAntigravityAccountHandlers(): void { + ipcMain.handle('antigravityAccounts:getStatus', () => getAntigravityAccountStatus()) +} diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 5648cca02191..d8ce9866582e 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -37,6 +37,7 @@ const { registerClaudeAccountHandlersMock, registerMiniMaxCredentialsHandlersMock, registerGrokAccountHandlersMock, + registerAntigravityAccountHandlersMock, registerClipboardHandlersMock, setTrustedClipboardRendererWebContentsIdMock, registerUpdaterHandlersMock, @@ -103,6 +104,7 @@ const { registerClaudeAccountHandlersMock: vi.fn(), registerMiniMaxCredentialsHandlersMock: vi.fn(), registerGrokAccountHandlersMock: vi.fn(), + registerAntigravityAccountHandlersMock: vi.fn(), registerClipboardHandlersMock: vi.fn(), setTrustedClipboardRendererWebContentsIdMock: vi.fn(), registerUpdaterHandlersMock: vi.fn(), @@ -337,6 +339,10 @@ vi.mock('../grok-accounts', () => ({ registerGrokAccountHandlers: registerGrokAccountHandlersMock })) +vi.mock('../antigravity-accounts', () => ({ + registerAntigravityAccountHandlers: registerAntigravityAccountHandlersMock +})) + vi.mock('../../window/attach-main-window-services', () => ({ registerUpdaterHandlers: registerUpdaterHandlersMock })) @@ -517,6 +523,7 @@ describe('registerCoreHandlers', () => { expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) expect(registerGrokAccountHandlersMock).toHaveBeenCalled() + expect(registerAntigravityAccountHandlersMock).toHaveBeenCalled() expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits, codexAccounts) expect(registerGitHubHandlersMock).toHaveBeenCalledWith(store, stats) expect(registerLinearHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 98cad9b25a61..4077645f898a 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -63,6 +63,7 @@ import { registerAgentTrustHandlers } from '../agent-trust' import { registerClaudeAccountHandlers } from '../claude-accounts' import { registerMiniMaxCredentialsHandlers } from '../minimax-credentials' import { registerGrokAccountHandlers } from '../grok-accounts' +import { registerAntigravityAccountHandlers } from '../antigravity-accounts' import { registerUpdaterHandlers } from '../../window/attach-main-window-services' import { registerClipboardHandlers, @@ -148,6 +149,7 @@ export function registerCoreHandlers( registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) registerGrokAccountHandlers() + registerAntigravityAccountHandlers() registerRateLimitHandlers(rateLimits, codexAccounts) registerGitHubHandlers(store, stats) registerGitLabHandlers(store) diff --git a/src/main/rate-limits/antigravity-cli-oauth-extractor.test.ts b/src/main/rate-limits/antigravity-cli-oauth-extractor.test.ts new file mode 100644 index 000000000000..a670d30b223c --- /dev/null +++ b/src/main/rate-limits/antigravity-cli-oauth-extractor.test.ts @@ -0,0 +1,53 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + resetAntigravityOAuthClientCache, + scanAgyBinaryForOAuthClients +} from './antigravity-cli-oauth-extractor' + +describe('scanAgyBinaryForOAuthClients', () => { + let dir: string + + afterEach(() => { + resetAntigravityOAuthClientCache() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('extracts Google OAuth client ids and secrets from a binary blob', async () => { + dir = mkdtempSync(path.join(tmpdir(), 'orca-agy-bin-')) + mkdirSync(dir, { recursive: true }) + const binaryPath = path.join(dir, 'agy') + const clientId = '1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com' + const clientSecret = 'GOCSPX-TestSecret_abc-123' + writeFileSync(binaryPath, `noise${clientId}\0more${clientSecret}\0tail`) + + await expect(scanAgyBinaryForOAuthClients(binaryPath)).resolves.toEqual([ + { clientId, clientSecret } + ]) + }) + + it('splits concatenated GOCSPX secrets and tries every id/secret pair', async () => { + dir = mkdtempSync(path.join(tmpdir(), 'orca-agy-bin-')) + mkdirSync(dir, { recursive: true }) + const binaryPath = path.join(dir, 'agy') + const clientIdA = '1234567890-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com' + const clientIdB = '9876543210-zyxwvutsrqponmlkjihgfedcba.apps.googleusercontent.com' + const secretA = 'GOCSPX-TestSecret_aaa-111' + const secretB = 'GOCSPX-TestSecret_bbb-222' + writeFileSync( + binaryPath, + `noise${clientIdA}\0${clientIdB}\0${secretA}${secretB}https://cloudcode.example\0tail` + ) + + await expect(scanAgyBinaryForOAuthClients(binaryPath)).resolves.toEqual([ + { clientId: clientIdA, clientSecret: secretA }, + { clientId: clientIdA, clientSecret: secretB }, + { clientId: clientIdB, clientSecret: secretA }, + { clientId: clientIdB, clientSecret: secretB } + ]) + }) +}) diff --git a/src/main/rate-limits/antigravity-cli-oauth-extractor.ts b/src/main/rate-limits/antigravity-cli-oauth-extractor.ts new file mode 100644 index 000000000000..6aed828bf113 --- /dev/null +++ b/src/main/rate-limits/antigravity-cli-oauth-extractor.ts @@ -0,0 +1,178 @@ +import { execFile } from 'node:child_process' +import { access, open, stat } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +const CLIENT_ID_RE = /[0-9]{10,}-[a-z0-9]+\.apps\.googleusercontent\.com/g +const SECRET_PREFIX = 'GOCSPX-' +const SECRET_BODY_RE = /[A-Za-z0-9_-]/ +const MIN_SECRET_BODY = 12 +const MAX_SECRET_BODY = 28 +const CHUNK_SIZE = 1024 * 1024 +const OVERLAP = 200 +const MAX_REFRESH_CLIENTS = 12 + +export type AntigravityOAuthClient = { + clientId: string + clientSecret: string +} + +let cachedClients: AntigravityOAuthClient[] | null = null +let cachedBinaryPath: string | null = null + +async function fileExists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch { + return false + } +} + +async function resolveAgyBinary(): Promise { + const [lookup, args] = process.platform === 'win32' ? ['where.exe', ['agy']] : ['which', ['agy']] + try { + const { stdout } = await execFileAsync(lookup, args, { + encoding: 'utf-8', + windowsHide: true + }) + const fromPath = stdout.trim().split(/\r?\n/)[0] + if (fromPath && (await fileExists(fromPath))) { + return fromPath + } + } catch { + // ignore which/where failure + } + + const home = homedir() + const fallbacks = + process.platform === 'win32' + ? [ + process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, 'agy', 'bin', 'agy.exe') + : null + ] + : [ + path.join(home, '.local', 'bin', 'agy'), + '/opt/homebrew/bin/agy', + '/usr/local/bin/agy', + path.join(home, 'bin', 'agy') + ] + + for (const candidate of fallbacks) { + if (candidate && (await fileExists(candidate))) { + return candidate + } + } + return null +} + +function collectMatches(haystack: string, pattern: RegExp, into: string[]): void { + pattern.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(haystack))) { + const value = match[0] + if (!into.includes(value)) { + into.push(value) + } + } +} + +function readSecretAt(haystack: string, start: number): string | null { + if (!haystack.startsWith(SECRET_PREFIX, start)) { + return null + } + let end = start + SECRET_PREFIX.length + const maxEnd = end + MAX_SECRET_BODY + while (end < haystack.length && end < maxEnd && SECRET_BODY_RE.test(haystack[end]!)) { + if ( + haystack.startsWith('https://', end) || + haystack.startsWith('http://', end) || + haystack.startsWith(SECRET_PREFIX, end) + ) { + break + } + end += 1 + } + const secret = haystack.slice(start, end) + return secret.length - SECRET_PREFIX.length >= MIN_SECRET_BODY ? secret : null +} + +function collectSecrets(haystack: string, into: string[]): void { + let searchFrom = 0 + while (searchFrom < haystack.length) { + const start = haystack.indexOf(SECRET_PREFIX, searchFrom) + if (start === -1) { + return + } + const secret = readSecretAt(haystack, start) + if (secret && !into.includes(secret)) { + into.push(secret) + } + searchFrom = start + SECRET_PREFIX.length + } +} + +function cartesianClients(clientIds: string[], clientSecrets: string[]): AntigravityOAuthClient[] { + const paired: AntigravityOAuthClient[] = [] + for (const clientId of clientIds) { + for (const clientSecret of clientSecrets) { + paired.push({ clientId, clientSecret }) + if (paired.length >= MAX_REFRESH_CLIENTS) { + return paired + } + } + } + return paired +} + +export async function scanAgyBinaryForOAuthClients( + binaryPath: string +): Promise { + const handle = await open(binaryPath, 'r') + try { + const fileStat = await stat(binaryPath) + const buffer = Buffer.alloc(CHUNK_SIZE) + const clientIds: string[] = [] + const clientSecrets: string[] = [] + let previous = '' + let position = 0 + while (position < fileStat.size) { + const { bytesRead } = await handle.read(buffer, 0, CHUNK_SIZE, position) + if (bytesRead === 0) { + break + } + const text = previous + buffer.toString('latin1', 0, bytesRead) + collectMatches(text, CLIENT_ID_RE, clientIds) + collectSecrets(text, clientSecrets) + previous = text.slice(-OVERLAP) + position += bytesRead + } + + return cartesianClients(clientIds, clientSecrets) + } finally { + await handle.close() + } +} + +export async function extractAntigravityOAuthClients(): Promise { + const binaryPath = await resolveAgyBinary() + if (!binaryPath) { + return [] + } + if (cachedClients && cachedBinaryPath === binaryPath) { + return cachedClients + } + const clients = await scanAgyBinaryForOAuthClients(binaryPath) + cachedBinaryPath = binaryPath + cachedClients = clients + return clients +} + +export function resetAntigravityOAuthClientCache(): void { + cachedClients = null + cachedBinaryPath = null +} diff --git a/src/main/rate-limits/antigravity-oauth-sources.test.ts b/src/main/rate-limits/antigravity-oauth-sources.test.ts new file mode 100644 index 000000000000..672a3c5eb8ec --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-sources.test.ts @@ -0,0 +1,133 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + getAntigravityOAuthTokenPath, + isAntigravityAccessTokenFresh, + isAntigravitySessionUsable, + readAntigravityAuthSession, + readAntigravityDefaultProjectId, + saveAntigravityCredentials +} from './antigravity-oauth-sources' + +describe('readAntigravityAuthSession', () => { + let home: string + + afterEach(() => { + if (home) { + rmSync(home, { recursive: true, force: true }) + } + }) + + function writeToken(body: unknown): void { + home = mkdtempSync(path.join(tmpdir(), 'orca-agy-auth-')) + const dir = path.dirname(getAntigravityOAuthTokenPath(home)) + mkdirSync(dir, { recursive: true }) + writeFileSync(getAntigravityOAuthTokenPath(home), JSON.stringify(body)) + } + + it('reports missing when the token file is absent', () => { + home = mkdtempSync(path.join(tmpdir(), 'orca-agy-auth-')) + expect(readAntigravityAuthSession(home)).toEqual({ status: 'missing' }) + }) + + it('reports missing when the token file has no credentials', () => { + writeToken({ auth_method: 'consumer', token: {} }) + expect(readAntigravityAuthSession(home)).toEqual({ status: 'missing' }) + }) + + it('reports invalid JSON without exposing the path', () => { + writeToken(null) + writeFileSync(getAntigravityOAuthTokenPath(home), '{') + expect(readAntigravityAuthSession(home)).toEqual({ + status: 'error', + error: 'Antigravity auth file is invalid' + }) + }) + + it('reads a consumer session and optional Google account email', () => { + writeToken({ + auth_method: 'consumer', + token: { + access_token: 'access-1', + refresh_token: 'refresh-1', + token_type: 'Bearer', + expiry: '2099-01-01T00:00:00.000Z' + } + }) + mkdirSync(path.join(home, '.gemini'), { recursive: true }) + writeFileSync( + path.join(home, '.gemini', 'google_accounts.json'), + JSON.stringify({ active: 'dev@example.com', old: [] }) + ) + + expect(readAntigravityAuthSession(home)).toEqual({ + status: 'ok', + session: { + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + authMethod: 'consumer', + email: 'dev@example.com' + } + }) + }) + + it('treats a refresh-only session as signed in', () => { + writeToken({ + token: { refresh_token: 'refresh-only', expiry: '2020-01-01T00:00:00.000Z' } + }) + const result = readAntigravityAuthSession(home) + expect(result.status).toBe('ok') + if (result.status !== 'ok') { + return + } + expect(isAntigravityAccessTokenFresh(result.session)).toBe(false) + expect(isAntigravitySessionUsable(result.session)).toBe(true) + }) +}) + +describe('saveAntigravityCredentials', () => { + it('rewrites the token file without dropping auth_method', async () => { + const home = mkdtempSync(path.join(tmpdir(), 'orca-agy-save-')) + try { + mkdirSync(path.dirname(getAntigravityOAuthTokenPath(home)), { recursive: true }) + await saveAntigravityCredentials( + { + accessToken: 'new-access', + refreshToken: 'refresh-1', + expiresAtMs: Date.parse('2099-06-01T00:00:00.000Z'), + authMethod: 'consumer', + email: null + }, + home + ) + const result = readAntigravityAuthSession(home) + expect(result).toMatchObject({ + status: 'ok', + session: { + accessToken: 'new-access', + refreshToken: 'refresh-1', + authMethod: 'consumer' + } + }) + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) +}) + +describe('readAntigravityDefaultProjectId', () => { + it('returns the trimmed project id when the cache file exists', async () => { + const home = mkdtempSync(path.join(tmpdir(), 'orca-agy-proj-')) + try { + const cacheDir = path.join(home, '.gemini', 'antigravity-cli', 'cache') + mkdirSync(cacheDir, { recursive: true }) + writeFileSync(path.join(cacheDir, 'default_project_id.txt'), 'default-cli-project\n') + await expect(readAntigravityDefaultProjectId(home)).resolves.toBe('default-cli-project') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/rate-limits/antigravity-oauth-sources.ts b/src/main/rate-limits/antigravity-oauth-sources.ts new file mode 100644 index 000000000000..a95532495997 --- /dev/null +++ b/src/main/rate-limits/antigravity-oauth-sources.ts @@ -0,0 +1,169 @@ +import { existsSync, readFileSync } from 'node:fs' +import { readFile, rename, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import path from 'node:path' + +const TOKEN_SKEW_MS = 5 * 60 * 1000 + +export function getAntigravityCliDir(home = homedir()): string { + return path.join(home, '.gemini', 'antigravity-cli') +} + +export function getAntigravityOAuthTokenPath(home = homedir()): string { + return path.join(getAntigravityCliDir(home), 'antigravity-oauth-token') +} + +export type AntigravityAuthSession = { + accessToken: string | null + refreshToken: string | null + expiresAtMs: number | null + authMethod: string | null + email: string | null +} + +export type AntigravityAuthReadResult = + | { status: 'missing' } + | { status: 'error'; error: string } + | { status: 'ok'; session: AntigravityAuthSession } + +type AntigravityTokenPayload = { + access_token?: unknown + refresh_token?: unknown + token_type?: unknown + expiry?: unknown +} + +type AntigravityOAuthFile = { + auth_method?: unknown + token?: AntigravityTokenPayload +} + +function getAuthReadError(err: unknown): string { + if (err instanceof SyntaxError) { + return 'Antigravity auth file is invalid' + } + // Why: filesystem errors often include the home path; account status must not. + return 'Unable to read Antigravity auth file' +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function parseExpiryMs(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 1e12 ? value : value * 1000 + } + if (typeof value !== 'string' || value.length === 0) { + return null + } + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function readActiveGoogleAccountEmail(home: string): string | null { + try { + const raw = readFileSync(path.join(home, '.gemini', 'google_accounts.json'), 'utf-8') + const parsed: unknown = JSON.parse(raw) + if (typeof parsed !== 'object' || parsed === null || !('active' in parsed)) { + return null + } + const active = (parsed as { active?: unknown }).active + return typeof active === 'string' && active.includes('@') ? active : null + } catch { + return null + } +} + +function sessionFromFile( + parsed: AntigravityOAuthFile, + home: string +): AntigravityAuthSession | null { + const token = parsed.token + if (!token) { + return null + } + const accessToken = asNonEmptyString(token.access_token) + const refreshToken = asNonEmptyString(token.refresh_token) + if (!accessToken && !refreshToken) { + return null + } + return { + accessToken, + refreshToken, + expiresAtMs: parseExpiryMs(token.expiry), + authMethod: asNonEmptyString(parsed.auth_method), + email: readActiveGoogleAccountEmail(home) + } +} + +export function readAntigravityAuthSession(home = homedir()): AntigravityAuthReadResult { + const tokenPath = getAntigravityOAuthTokenPath(home) + if (!existsSync(tokenPath)) { + return { status: 'missing' } + } + try { + const parsed: unknown = JSON.parse(readFileSync(tokenPath, 'utf-8')) + if (typeof parsed !== 'object' || parsed === null) { + return { status: 'error', error: 'Antigravity auth file is invalid' } + } + const session = sessionFromFile(parsed as AntigravityOAuthFile, home) + if (!session) { + return { status: 'missing' } + } + return { status: 'ok', session } + } catch (err) { + return { status: 'error', error: getAuthReadError(err) } + } +} + +export function isAntigravityAccessTokenFresh(session: AntigravityAuthSession): boolean { + if (!session.accessToken) { + return false + } + if (session.expiresAtMs === null) { + return true + } + return session.expiresAtMs - Date.now() > TOKEN_SKEW_MS +} + +export function isAntigravitySessionUsable(session: AntigravityAuthSession): boolean { + return Boolean(session.refreshToken) || isAntigravityAccessTokenFresh(session) +} + +export async function saveAntigravityCredentials( + session: AntigravityAuthSession, + home = homedir() +): Promise { + const tokenPath = getAntigravityOAuthTokenPath(home) + const payload: AntigravityOAuthFile = { + ...(session.authMethod ? { auth_method: session.authMethod } : {}), + token: { + ...(session.accessToken ? { access_token: session.accessToken } : {}), + ...(session.refreshToken ? { refresh_token: session.refreshToken } : {}), + token_type: 'Bearer', + ...(session.expiresAtMs !== null + ? { expiry: new Date(session.expiresAtMs).toISOString() } + : {}) + } + } + const tmpPath = `${tokenPath}.${process.pid}.tmp` + await writeFile(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8') + await rename(tmpPath, tokenPath) +} + +export async function readAntigravityDefaultProjectId(home = homedir()): Promise { + try { + const raw = await readFile( + path.join(getAntigravityCliDir(home), 'cache', 'default_project_id.txt'), + 'utf-8' + ) + const projectId = raw.trim() + return projectId.length > 0 ? projectId : null + } catch (err) { + if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { + return null + } + throw err + } +} 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 000000000000..9b31b2f8654a --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-summary.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { parseAntigravityQuotaSummary } from './antigravity-quota-summary' + +const summary = { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + window: 'weekly', + resetTime: '2099-01-10T18:00:00Z', + remainingFraction: 0.197 + }, + { + bucketId: 'gemini-5h', + window: '5h', + resetTime: '2099-01-08T14:00:00Z', + remainingFraction: 0.83 + } + ] + }, + { + displayName: 'Claude and GPT models', + buckets: [ + { + bucketId: '3p-weekly', + window: 'weekly', + resetTime: '2099-01-11T12:00:00Z', + remainingFraction: 1 + }, + { + bucketId: '3p-5h', + window: '5h', + remainingFraction: 0.4 + } + ] + } + ] +} + +describe('parseAntigravityQuotaSummary', () => { + it('maps group windows onto session, weekly, and named buckets', () => { + const parsed = parseAntigravityQuotaSummary(summary) + expect(parsed?.buckets.map((bucket) => [bucket.name, bucket.usedPercent])).toEqual([ + ['Gemini Models · Weekly', 80], + ['Gemini Models · Five hour', 17], + ['Claude and GPT models · Weekly', 0], + ['Claude and GPT models · Five hour', 60] + ]) + expect(parsed?.session?.usedPercent).toBe(60) + expect(parsed?.weekly?.usedPercent).toBe(80) + expect(parsed?.session?.windowMinutes).toBe(300) + expect(parsed?.weekly?.windowMinutes).toBe(10080) + }) + + it('reads a wrapped response.groups payload', () => { + const parsed = parseAntigravityQuotaSummary({ response: summary }) + expect(parsed?.weekly?.usedPercent).toBe(80) + }) + + it('returns null when no remainingFraction buckets are present', () => { + expect( + parseAntigravityQuotaSummary({ groups: [{ displayName: 'Gemini Models', buckets: [] }] }) + ).toBe(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 000000000000..7dbf81a87f32 --- /dev/null +++ b/src/main/rate-limits/antigravity-quota-summary.ts @@ -0,0 +1,129 @@ +import type { RateLimitBucket, RateLimitWindow } from '../../shared/rate-limit-types' + +const SESSION_WINDOW_MINUTES = 300 +const WEEKLY_WINDOW_MINUTES = 10080 + +export type AntigravityQuotaSummary = { + buckets: RateLimitBucket[] + session: RateLimitWindow | null + weekly: RateLimitWindow | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function asFiniteFraction(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) { + return null + } + return value +} + +function remainingFraction(bucket: Record): number | null { + const direct = + asFiniteFraction(bucket.remainingFraction) ?? asFiniteFraction(bucket.remaining_fraction) + if (direct !== null) { + return direct + } + if (!isRecord(bucket.remaining)) { + return null + } + return ( + asFiniteFraction(bucket.remaining.remainingFraction) ?? + asFiniteFraction(bucket.remaining.remaining_fraction) + ) +} + +function resetFields(resetTime: unknown): Pick { + if (typeof resetTime !== 'string' || resetTime.length === 0) { + return { resetsAt: null, resetDescription: null } + } + const resetsAt = Date.parse(resetTime) + if (!Number.isFinite(resetsAt)) { + return { resetsAt: null, resetDescription: null } + } + const date = new Date(resetsAt) + const isToday = date.toDateString() === new Date().toDateString() + return { + resetsAt, + resetDescription: isToday + ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + : date.toLocaleDateString(undefined, { + weekday: 'short', + hour: 'numeric', + minute: '2-digit' + }) + } +} + +function isWeeklyBucket(bucketId: string, window: string): boolean { + return window === 'weekly' || bucketId.includes('weekly') +} + +function windowLabel(weekly: boolean): string { + return weekly ? 'Weekly' : 'Five hour' +} + +function toWindow(bucket: RateLimitBucket): RateLimitWindow { + const { name: _name, ...window } = bucket + return window +} + +function tightest(buckets: RateLimitBucket[]): RateLimitWindow | null { + if (buckets.length === 0) { + return null + } + return toWindow( + buckets.reduce((worst, bucket) => (bucket.usedPercent > worst.usedPercent ? bucket : worst)) + ) +} + +function readGroups(data: unknown): unknown[] { + if (isRecord(data) && Array.isArray(data.groups)) { + return data.groups + } + if (isRecord(data) && isRecord(data.response) && Array.isArray(data.response.groups)) { + return data.response.groups + } + return [] +} + +export function parseAntigravityQuotaSummary(data: unknown): AntigravityQuotaSummary | null { + const buckets: RateLimitBucket[] = [] + for (const groupValue of readGroups(data)) { + if (!isRecord(groupValue) || !Array.isArray(groupValue.buckets)) { + continue + } + const groupName = + typeof groupValue.displayName === 'string' && groupValue.displayName.length > 0 + ? groupValue.displayName + : 'Antigravity' + for (const bucketValue of groupValue.buckets) { + if (!isRecord(bucketValue)) { + continue + } + const remaining = remainingFraction(bucketValue) + if (remaining === null) { + continue + } + const bucketId = typeof bucketValue.bucketId === 'string' ? bucketValue.bucketId : '' + const window = typeof bucketValue.window === 'string' ? bucketValue.window : '' + const weekly = isWeeklyBucket(bucketId, window) + buckets.push({ + name: `${groupName} · ${windowLabel(weekly)}`, + usedPercent: Math.min(100, Math.max(0, Math.round((1 - remaining) * 100))), + windowMinutes: weekly ? WEEKLY_WINDOW_MINUTES : SESSION_WINDOW_MINUTES, + ...resetFields(bucketValue.resetTime) + }) + } + } + if (buckets.length === 0) { + return null + } + return { + buckets, + session: tightest(buckets.filter((bucket) => bucket.windowMinutes === SESSION_WINDOW_MINUTES)), + weekly: tightest(buckets.filter((bucket) => bucket.windowMinutes === WEEKLY_WINDOW_MINUTES)) + } +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..a829c2665fbb --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeResponse } from './gemini-usage-fetcher.test-fixtures' + +const { + readSessionMock, + extractClientsMock, + refreshMock, + saveMock, + readProjectMock, + netFetchMock +} = vi.hoisted(() => ({ + readSessionMock: vi.fn(), + extractClientsMock: vi.fn(), + refreshMock: vi.fn(), + saveMock: vi.fn(), + readProjectMock: vi.fn(), + netFetchMock: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: readSessionMock, + isAntigravityAccessTokenFresh: (session: { + accessToken: string | null + expiresAtMs: number | null + }) => + Boolean(session.accessToken) && + (session.expiresAtMs === null || session.expiresAtMs > Date.now() + 5 * 60 * 1000), + isAntigravitySessionUsable: vi.fn(), + saveAntigravityCredentials: saveMock, + readAntigravityDefaultProjectId: readProjectMock +})) + +vi.mock('./antigravity-cli-oauth-extractor', () => ({ + extractAntigravityOAuthClients: extractClientsMock +})) + +vi.mock('./gemini-oauth-sources', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + refreshAccessToken: refreshMock + } +}) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock } })) + +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' + +const quotaSummary = { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + window: 'weekly', + resetTime: '2099-01-10T18:00:00Z', + remainingFraction: 0.2 + }, + { + bucketId: 'gemini-5h', + window: '5h', + resetTime: '2099-01-08T14:00:00Z', + remainingFraction: 0.5 + } + ] + } + ] +} + +const freshSession = { + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + authMethod: 'consumer' as const, + email: 'dev@example.com' +} + +describe('fetchAntigravityRateLimits', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-09-08T12:00:00.000Z')) + readSessionMock.mockReset() + extractClientsMock.mockReset() + refreshMock.mockReset() + saveMock.mockReset() + readProjectMock.mockReset() + netFetchMock.mockReset() + saveMock.mockResolvedValue(undefined) + readProjectMock.mockResolvedValue('default-cli-project') + extractClientsMock.mockResolvedValue([ + { clientId: 'client-1.apps.googleusercontent.com', clientSecret: 'GOCSPX-test' } + ]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns unavailable when the Antigravity CLI is not signed in', async () => { + readSessionMock.mockReturnValue({ status: 'missing' }) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.error).toContain('not signed in') + expect(netFetchMock).not.toHaveBeenCalled() + }) + + it('reads quota from a fresh Antigravity session', async () => { + readSessionMock.mockReturnValue({ status: 'ok', session: freshSession }) + netFetchMock.mockResolvedValue(makeResponse(quotaSummary)) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('ok') + expect(result.provider).toBe('antigravity') + expect(result.weekly?.usedPercent).toBe(80) + expect(result.session?.usedPercent).toBe(50) + expect(result.buckets).toHaveLength(2) + expect(extractClientsMock).not.toHaveBeenCalled() + expect(String(netFetchMock.mock.calls[0]?.[0])).toContain('daily-cloudcode-pa') + expect(String(netFetchMock.mock.calls[0]?.[0])).toContain('retrieveUserQuotaSummary') + expect(netFetchMock.mock.calls[0]?.[1]?.headers['User-Agent']).toBe('antigravity') + }) + + it('refreshes an expired access token before reading quota', async () => { + readSessionMock.mockReturnValue({ + status: 'ok', + session: { ...freshSession, expiresAtMs: Date.parse('2026-09-05T00:00:00.000Z') } + }) + refreshMock.mockResolvedValue({ + accessToken: 'new-access', + newRefreshToken: null, + expiresIn: 3600 + }) + netFetchMock.mockResolvedValue(makeResponse(quotaSummary)) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('ok') + expect(saveMock).toHaveBeenCalled() + expect(netFetchMock.mock.calls[0]?.[1]?.headers.Authorization).toBe('Bearer new-access') + }) + + it('retries quota after a 401 by refreshing the access token', async () => { + readSessionMock.mockReturnValue({ status: 'ok', session: freshSession }) + refreshMock.mockResolvedValue({ + accessToken: 'new-access', + newRefreshToken: null, + expiresIn: 3600 + }) + netFetchMock + .mockResolvedValueOnce(makeResponse({ error: { message: 'unauthorized' } }, 401)) + .mockResolvedValueOnce(makeResponse(quotaSummary)) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('ok') + expect(result.weekly?.usedPercent).toBe(80) + expect(netFetchMock.mock.calls[1]?.[1]?.headers.Authorization).toBe('Bearer new-access') + }) + + it('treats a Code Assist license refusal as unavailable, not a refresh failure', async () => { + readSessionMock.mockReturnValue({ status: 'ok', session: freshSession }) + netFetchMock.mockResolvedValue( + makeResponse( + { + error: { + message: + 'You do not have a valid license of this product. Please contact your administrator' + } + }, + 403 + ) + ) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('unavailable') + expect(result.error).toContain('signed in') + expect(result.error).not.toContain('Refresh failed') + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + + it('reports a stale session when the access token cannot be refreshed', async () => { + readSessionMock.mockReturnValue({ + status: 'ok', + session: { ...freshSession, expiresAtMs: Date.parse('2026-09-05T00:00:00.000Z') } + }) + extractClientsMock.mockResolvedValue([]) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('error') + expect(result.error).toContain('expired') + expect(netFetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts new file mode 100644 index 000000000000..570f98230c4f --- /dev/null +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -0,0 +1,180 @@ +import { net } from 'electron' +import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import { + extractAntigravityOAuthClients, + type AntigravityOAuthClient +} from './antigravity-cli-oauth-extractor' +import { parseAntigravityQuotaSummary } from './antigravity-quota-summary' +import { refreshAccessToken } from './gemini-oauth-sources' +import { + isAntigravityAccessTokenFresh, + readAntigravityAuthSession, + readAntigravityDefaultProjectId, + saveAntigravityCredentials, + type AntigravityAuthSession +} from './antigravity-oauth-sources' + +const API_TIMEOUT_MS = 10_000 +const QUOTA_SUMMARY_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary', + 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary' +] as const + +const LICENSE_UNAVAILABLE_REASON = + 'Antigravity CLI is signed in. Orca could not read a Code Assist quota for this account.' + +function result( + status: ProviderRateLimits['status'], + error: string | null, + extras: Partial = {} +): ProviderRateLimits { + return { + provider: 'antigravity', + session: extras.session ?? null, + weekly: extras.weekly ?? null, + buckets: extras.buckets, + updatedAt: Date.now(), + error, + status, + usageMetadata: extras.usageMetadata + } +} + +function isLicenseRefusal(message: string): boolean { + return /valid license of this product/i.test(message) +} + +function quotaHeaders(accessToken: string): Record { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + 'User-Agent': 'antigravity' + } +} + +async function fetchQuotaSummary(accessToken: string, projectId: string | null): Promise { + const body = JSON.stringify(projectId ? { project: projectId } : {}) + let last: Response | undefined + for (const url of QUOTA_SUMMARY_URLS) { + const response = await net.fetch(url, { + method: 'POST', + headers: quotaHeaders(accessToken), + body, + signal: AbortSignal.timeout(API_TIMEOUT_MS) + }) + last = response + if (response.ok || response.status === 401 || response.status < 500) { + return response + } + } + return last! +} + +async function refreshSession( + session: AntigravityAuthSession, + clients: AntigravityOAuthClient[] +): Promise { + const refreshToken = session.refreshToken + if (!refreshToken) { + return null + } + for (const client of clients) { + const refreshed = await refreshAccessToken(refreshToken, client.clientId, client.clientSecret) + if (!refreshed.accessToken) { + continue + } + const next: AntigravityAuthSession = { + ...session, + accessToken: refreshed.accessToken, + refreshToken: refreshed.newRefreshToken ?? refreshToken, + expiresAtMs: refreshed.expiresIn + ? Date.now() + refreshed.expiresIn * 1000 + : session.expiresAtMs + } + await saveAntigravityCredentials(next) + return next + } + return null +} + +async function ensureFreshSession( + session: AntigravityAuthSession, + forceRefresh: boolean +): Promise { + if (!forceRefresh && isAntigravityAccessTokenFresh(session)) { + return session + } + const clients = await extractAntigravityOAuthClients() + return refreshSession(session, clients) +} + +export async function fetchAntigravityRateLimits(): Promise { + const auth = readAntigravityAuthSession() + if (auth.status === 'missing') { + return result('unavailable', 'Antigravity CLI is not signed in') + } + if (auth.status === 'error') { + return result('error', auth.error, { + usageMetadata: { failureKind: 'parse', source: 'oauth' } + }) + } + + let session = await ensureFreshSession(auth.session, false) + if (!session?.accessToken) { + return result( + 'error', + 'Antigravity session expired. Run agy on this computer to refresh sign-in.', + { usageMetadata: { failureKind: 'stale-token', source: 'oauth' } } + ) + } + + try { + const projectId = await readAntigravityDefaultProjectId() + let response = await fetchQuotaSummary(session.accessToken, projectId) + if (response.status === 401) { + const refreshed = await ensureFreshSession(session, true) + if (!refreshed?.accessToken) { + return result( + 'error', + 'Antigravity session expired. Run agy on this computer to refresh sign-in.', + { usageMetadata: { failureKind: 'stale-token', source: 'oauth' } } + ) + } + session = refreshed + response = await fetchQuotaSummary(session.accessToken, projectId) + } + if (!response.ok) { + const body = await response.text() + if (response.status === 403 && isLicenseRefusal(body)) { + return result('unavailable', LICENSE_UNAVAILABLE_REASON, { + usageMetadata: { failureKind: 'usage-unavailable', source: 'oauth' } + }) + } + return result('error', `Quota fetch failed (${response.status})`, { + usageMetadata: { failureKind: response.status === 401 ? 'stale-token' : 'server' } + }) + } + const parsed = parseAntigravityQuotaSummary((await response.json()) as unknown) + if (!parsed) { + return result( + 'unavailable', + 'Antigravity CLI is signed in. Orca could not read a usage percentage for this account.', + { usageMetadata: { failureKind: 'usage-unavailable', source: 'oauth' } } + ) + } + return { + provider: 'antigravity', + session: parsed.session, + weekly: parsed.weekly, + buckets: parsed.buckets, + updatedAt: Date.now(), + error: null, + status: 'ok', + usageMetadata: { source: 'oauth' } + } + } catch (err) { + return result('error', err instanceof Error ? err.message : 'Unknown error', { + usageMetadata: { failureKind: 'network' } + }) + } +} diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts deleted file mode 100644 index 84c92e1ad22f..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { ProviderRateLimits, ProviderRateLimitStatus } from '../../shared/rate-limit-types' -import { deriveAntigravityRateLimits } from './antigravity-usage-mirror' - -function geminiSnapshot( - status: ProviderRateLimitStatus, - error: string | null, - usedPercent: number | null = null -): ProviderRateLimits { - return { - provider: 'gemini', - session: - usedPercent === null - ? null - : { usedPercent, windowMinutes: 300, resetsAt: null, resetDescription: null }, - weekly: null, - updatedAt: 1_700_000_000_000, - error, - status - } -} - -describe('deriveAntigravityRateLimits', () => { - it('mirrors a successful Gemini read as shared Code Assist quota', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('ok', null, 42)) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('ok') - expect(antigravity.session?.usedPercent).toBe(42) - expect(antigravity.error).toBeNull() - }) - - it('reports unavailable without quoting the Gemini failure', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('error', 'Gemini project ID not found') - ) - - expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.session).toBeNull() - expect(antigravity.weekly).toBeNull() - }) - - it('does not blame a missing sign-in when the quota read itself failed', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - // Why: the reported symptom is a connected sign-in whose Code Assist read failed. - expect(antigravity.error).toContain('could not be read right now') - expect(antigravity.error).not.toContain('sign-in is connected') - }) - - it('keeps the Gemini timestamp so activation freshness checks are not forced to refetch', () => { - const antigravity = deriveAntigravityRateLimits(geminiSnapshot('error', 'Token refresh failed')) - - expect(antigravity.updatedAt).toBe(1_700_000_000_000) - }) - - it('points at the missing sign-in when the Gemini opt-in is off', () => { - const antigravity = deriveAntigravityRateLimits( - geminiSnapshot('unavailable', 'Gemini CLI OAuth is disabled in settings') - ) - - expect(antigravity.status).toBe('unavailable') - expect(antigravity.error).not.toContain('Gemini CLI OAuth is disabled in settings') - expect(antigravity.error).toContain('Antigravity usage is not available') - expect(antigravity.error).toContain('Gemini CLI sign-in is connected') - }) -}) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts deleted file mode 100644 index 3cea6decc111..000000000000 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ProviderRateLimits } from '../../shared/rate-limit-types' - -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. -const ANTIGRAVITY_NO_SIGN_IN_REASON = - 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. -const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' - -export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { - if (gemini.status === 'ok') { - return { ...gemini, provider: 'antigravity' } - } - return { - provider: 'antigravity', - session: null, - weekly: null, - // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. - updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' - } -} diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts index 041ede99b628..56ff218a31d8 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -4,10 +4,12 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' import type { RateLimitService } from './service' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' import { readGrokAuthSession } from './grok-auth' +import { readAntigravityAuthSession } from './antigravity-oauth-sources' import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher' import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' @@ -89,6 +91,9 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0)) vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + unavailableProvider('antigravity') + ) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -106,8 +111,12 @@ export function resetRateLimitProviderMocks(): void { error: null, status: 'unavailable' }) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + unavailableProvider('antigravity', 'Antigravity CLI is not signed in') + ) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) + vi.mocked(readAntigravityAuthSession).mockReturnValue({ status: 'missing' }) } type RateLimitWindow = Parameters[0] diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts index 9241831bdf6a..2e34f285f00b 100644 --- a/src/main/rate-limits/service-account-target-selection.test.ts +++ b/src/main/rate-limits/service-account-target-selection.test.ts @@ -44,6 +44,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index e0975af0db07..fb085387747b 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -3,10 +3,13 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-usage-fetcher' +import { readAntigravityAuthSession } from './antigravity-oauth-sources' import { errorProvider, okProvider, - resetRateLimitProviderMocks + resetRateLimitProviderMocks, + unavailableProvider } from './rate-limit-service-test-harness' vi.mock('./claude-fetcher', () => ({ @@ -43,10 +46,29 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) +const signedInSession = { + status: 'ok' as const, + session: { + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresAtMs: Date.parse('2099-01-01T00:00:00.000Z'), + authMethod: 'consumer', + email: 'dev@example.com' + } +} + describe('RateLimitService Antigravity usage', () => { beforeEach(() => { resetRateLimitProviderMocks() @@ -66,25 +88,35 @@ describe('RateLimitService Antigravity usage', () => { expect(state.antigravity?.status).toBe('unavailable') expect(state.antigravity?.error).not.toContain('Gemini project ID not found') expect(state.antigravity?.session).toBeNull() - // Why: the real Gemini failure must still surface under its own provider. expect(state.gemini?.status).toBe('error') expect(state.gemini?.error).toBe('Gemini project ID not found') }) - it('keeps mirroring a successful Gemini read under the Antigravity provider', async () => { + it('fetches Antigravity independently of Gemini usage', async () => { + vi.mocked(readAntigravityAuthSession).mockReturnValue(signedInSession) vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 11, Date.now()) + ) const service = new RateLimitService() await service.refresh() const state = service.getState() + expect(fetchAntigravityRateLimits).toHaveBeenCalled() + expect(state.gemini?.session?.usedPercent).toBe(42) expect(state.antigravity?.status).toBe('ok') expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) + expect(state.antigravity?.session?.usedPercent).toBe(11) + expect(state.antigravityAuthConfigured).toBe(true) }) - it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { + it('keeps a successful Antigravity snapshot when Gemini later fails', async () => { + vi.mocked(readAntigravityAuthSession).mockReturnValue(signedInSession) vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + okProvider('antigravity', 11, Date.now()) + ) const service = new RateLimitService() await service.refresh() @@ -93,8 +125,27 @@ describe('RateLimitService Antigravity usage', () => { ) await service.refresh() - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. - expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + expect(service.getState().antigravity?.status).toBe('ok') + expect(service.getState().antigravity?.session?.usedPercent).toBe(11) + expect(service.getState().gemini?.status).toBe('error') + }) + + it('treats a signed-in CLI without Code Assist quota as unavailable, not unsigned', async () => { + vi.mocked(readAntigravityAuthSession).mockReturnValue(signedInSession) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + unavailableProvider( + 'antigravity', + 'Antigravity CLI is signed in. Orca could not read a Code Assist quota for this account.' + ) + ) + const service = new RateLimitService() + + await service.refresh() + + const state = service.getState() + expect(state.antigravityAuthConfigured).toBe(true) + expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.error).toContain('signed in') + expect(state.antigravity?.error).not.toContain('not signed in') }) }) diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts index 3c6293513826..e53069206942 100644 --- a/src/main/rate-limits/service-inactive-account-previews.test.ts +++ b/src/main/rate-limits/service-inactive-account-previews.test.ts @@ -51,6 +51,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts index 668cfc113b9a..53521fcccf2e 100644 --- a/src/main/rate-limits/service-live-claude-usage.test.ts +++ b/src/main/rate-limits/service-live-claude-usage.test.ts @@ -48,6 +48,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 819b4c93f5d2..d27e2d18171c 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -45,6 +45,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index 44f4b05b3bac..9e36851ccdc7 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -52,6 +52,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index ec813a53d94a..6007205a66c2 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -53,6 +53,14 @@ vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) +vi.mock('./antigravity-usage-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + +vi.mock('./antigravity-oauth-sources', () => ({ + readAntigravityAuthSession: vi.fn(() => ({ status: 'missing' })) +})) + vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) diff --git a/src/main/rate-limits/service/service-configuration.ts b/src/main/rate-limits/service/service-configuration.ts index 655ba9b93d89..8266e5a76080 100644 --- a/src/main/rate-limits/service/service-configuration.ts +++ b/src/main/rate-limits/service/service-configuration.ts @@ -126,6 +126,7 @@ export abstract class RateLimitServiceConfiguration extends RateLimitServiceAcco minimaxCookieConfigured: hasMiniMaxSessionCookie(), minimaxApiKeyConfigured: hasMiniMaxApiKey(), grokAuthConfigured: this.grokAuthConfigured, + antigravityAuthConfigured: this.antigravityAuthConfigured, claudeTarget: this.claudeFetchTarget, codexTarget: this.codexFetchTarget, inactiveClaudeAccounts: this.buildInactiveArray( diff --git a/src/main/rate-limits/service/service-full-cycle-application.ts b/src/main/rate-limits/service/service-full-cycle-application.ts index 7104face38fa..dd5f1ebe9ec5 100644 --- a/src/main/rate-limits/service/service-full-cycle-application.ts +++ b/src/main/rate-limits/service/service-full-cycle-application.ts @@ -1,5 +1,4 @@ import { RateLimitServiceFullCyclePreparation } from './service-full-cycle-preparation' -import { deriveAntigravityRateLimits } from '../antigravity-usage-mirror' import type { ProviderRateLimits } from './service-types' export abstract class RateLimitServiceFullCycleApplication extends RateLimitServiceFullCyclePreparation { @@ -32,7 +31,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ geminiResult, opencodeGoResult, kimiResult, - miniMaxResult + miniMaxResult, + antigravityResult ], grokResultPromise } = prepared @@ -79,8 +79,20 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. - const antigravity = deriveAntigravityRateLimits(gemini) + const antigravity = + antigravityResult.status === 'fulfilled' + ? antigravityResult.value + : ({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + antigravityResult.reason instanceof Error + ? antigravityResult.reason.message + : 'Unknown error', + status: 'error' + } satisfies ProviderRateLimits) const opencodeGo = opencodeGoResult.status === 'fulfilled' diff --git a/src/main/rate-limits/service/service-full-cycle-preparation.ts b/src/main/rate-limits/service/service-full-cycle-preparation.ts index 7551652bca1d..ac92e73c5ce4 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,8 +1,10 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-usage-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' +import { readAntigravityAuthSession } from '../antigravity-oauth-sources' import { fetchMiniMaxRateLimits } from '../minimax/minimax-fetcher' import { fetchOpenCodeGoRateLimits } from '../opencode-go-usage-fetcher' import { RateLimitServiceFetchPolicy } from './service-fetch-policy' @@ -36,6 +38,7 @@ export type FetchAllCyclePrepared = { PromiseSettledResult, PromiseSettledResult, PromiseSettledResult, + PromiseSettledResult, PromiseSettledResult ] grokResultPromise: Promise< @@ -86,6 +89,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ // Why: getState() is hot (renderer pushes + mobile snapshots); keep Grok's sync auth-file probe on fetch cycles instead. const grokAuthReadResult = readGrokAuthSession() this.grokAuthConfigured = grokAuthReadResult.status === 'ok' + const antigravityAuthReadResult = readAntigravityAuthSession() + this.antigravityAuthConfigured = antigravityAuthReadResult.status === 'ok' // Discard stale data on config change — it belongs to a different session/workspace. const currentConfigHash = `${cookie}|${workspaceIdOverride}` @@ -138,42 +143,50 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) - const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] = - await Promise.allSettled([ - claudeFetchGated - ? Promise.resolve(previousState.claude as ProviderRateLimits) - : fetchClaudeRateLimits({ - authPreparation: claudeAuthPreparation, - allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), - allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), - networkProxySettings: this.networkProxySettingsResolver?.(), - signal - }), - codexFetchGated - ? Promise.resolve(previousState.codex as ProviderRateLimits) - : (missingWslCodexHome ?? - fetchCodexRateLimits({ - codexHomePath, - allowPtyFallback: this.shouldAllowCodexPtyFallback(), - signal - })), - fetchGeminiRateLimits(geminiCliOAuthEnabled), - fetchOpenCodeGoRateLimits( - cookie, - workspaceIdOverride || undefined, - this.networkProxySettingsResolver?.() - ), - this.fetchKimiWithResolvedHome(), - miniMaxConfigResult.error - ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) - : fetchMiniMaxRateLimits({ - cookie: miniMaxCookie, - groupId: miniMaxGroupId, - models: miniMaxModels, - endpointMode: miniMaxEndpoint, - apiKey: miniMaxApiKey - }) - ]) + const [ + claudeResult, + codexResult, + geminiResult, + opencodeGoResult, + kimiResult, + miniMaxResult, + antigravityResult + ] = await Promise.allSettled([ + claudeFetchGated + ? Promise.resolve(previousState.claude as ProviderRateLimits) + : fetchClaudeRateLimits({ + authPreparation: claudeAuthPreparation, + allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation), + allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(), + networkProxySettings: this.networkProxySettingsResolver?.(), + signal + }), + codexFetchGated + ? Promise.resolve(previousState.codex as ProviderRateLimits) + : (missingWslCodexHome ?? + fetchCodexRateLimits({ + codexHomePath, + allowPtyFallback: this.shouldAllowCodexPtyFallback(), + signal + })), + fetchGeminiRateLimits(geminiCliOAuthEnabled), + fetchOpenCodeGoRateLimits( + cookie, + workspaceIdOverride || undefined, + this.networkProxySettingsResolver?.() + ), + this.fetchKimiWithResolvedHome(), + miniMaxConfigResult.error + ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error)) + : fetchMiniMaxRateLimits({ + cookie: miniMaxCookie, + groupId: miniMaxGroupId, + models: miniMaxModels, + endpointMode: miniMaxEndpoint, + apiKey: miniMaxApiKey + }), + fetchAntigravityRateLimits() + ]) if (signal.aborted) { return null @@ -200,7 +213,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ geminiResult, opencodeGoResult, kimiResult, - miniMaxResult + miniMaxResult, + antigravityResult ], grokResultPromise } diff --git a/src/main/rate-limits/service/service-state.ts b/src/main/rate-limits/service/service-state.ts index c7e9aa4751a0..3e3198bf2f8b 100644 --- a/src/main/rate-limits/service/service-state.ts +++ b/src/main/rate-limits/service/service-state.ts @@ -21,6 +21,7 @@ import { DEFAULT_POLL_MS } from './service-types' import { readGrokAuthSession } from '../grok-auth' +import { readAntigravityAuthSession } from '../antigravity-oauth-sources' export abstract class RateLimitServiceState { protected state: InternalRateLimitState = { @@ -34,6 +35,7 @@ export abstract class RateLimitServiceState { grok: null } protected grokAuthConfigured = readGrokAuthSession().status === 'ok' + protected antigravityAuthConfigured = readAntigravityAuthSession().status === 'ok' protected pollInterval: number = DEFAULT_POLL_MS protected timer: ReturnType | null = null protected deferredStartupRefreshTimer: ReturnType | null = null diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 632e53005ae3..9d70903ac562 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3,6 +3,7 @@ import type { ClaudeAccountsApi, CodexAccountsApi, CodexConfigSyncApi, + AntigravityAccountsApi, GrokAccountsApi, MinimaxCredentialsApi } from './api/agent-account-api' @@ -141,6 +142,7 @@ export type PreloadApi = { rateLimits: RateLimitsApi minimaxCredentials: MinimaxCredentialsApi grokAccounts: GrokAccountsApi + antigravityAccounts: AntigravityAccountsApi ssh: SshApi automations: AutomationsApi wsl: RuntimeApi['wsl'] diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index ff98244299da..822c9b14685c 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -3,7 +3,7 @@ import type { CodexRateLimitAccountsState } from '../../shared/managed-account-types' import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' -import type { GrokAccountStatus } from '../../shared/rate-limit-types' +import type { AntigravityAccountStatus, GrokAccountStatus } from '../../shared/rate-limit-types' export type CodexAccountsApi = { list: () => Promise @@ -58,6 +58,10 @@ export type GrokAccountsApi = { getStatus: () => Promise } +export type AntigravityAccountsApi = { + getStatus: () => Promise +} + export type MinimaxCredentialsApi = { // Why: cookie + API key each live in their own safeStorage file, so the // status separates them. 'configured' stays as the OR so existing callers diff --git a/src/preload/api/antigravity-accounts-bridge.ts b/src/preload/api/antigravity-accounts-bridge.ts new file mode 100644 index 000000000000..32a83ee12b36 --- /dev/null +++ b/src/preload/api/antigravity-accounts-bridge.ts @@ -0,0 +1,8 @@ +import { ipcRenderer } from 'electron' +import type { AntigravityAccountStatus } from '../../shared/rate-limit-types' +import type { PreloadApi } from '../api-types' + +export const antigravityAccountsApi = { + getStatus: (): Promise => + ipcRenderer.invoke('antigravityAccounts:getStatus') +} satisfies PreloadApi['antigravityAccounts'] diff --git a/src/preload/index.ts b/src/preload/index.ts index 27d3ca8e0620..068658dd45fd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -78,6 +78,7 @@ import { runtimeEnvironmentsApi } from './api/runtime-environments-bridge' import { rateLimitsApi } from './api/rate-limits-bridge' import { minimaxCredentialsApi } from './api/minimax-credentials-bridge' import { grokAccountsApi } from './api/grok-accounts-bridge' +import { antigravityAccountsApi } from './api/antigravity-accounts-bridge' import { sshApi } from './api/ssh-bridge' import { automationsApi } from './api/automations-bridge' import { e2eApi } from './api/e2e-bridge' @@ -176,6 +177,7 @@ const api = { rateLimits: rateLimitsApi, minimaxCredentials: minimaxCredentialsApi, grokAccounts: grokAccountsApi, + antigravityAccounts: antigravityAccountsApi, ssh: sshApi, automations: automationsApi, e2e: e2eApi, diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index bf7656856d5d..429205c8ad56 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -167,4 +167,12 @@ describe('AccountsPane', () => { markup.slice(markup.lastIndexOf(' { + const markup = renderPane(getDefaultSettings('/tmp')) + + expect(markup).toContain('id="accounts-antigravity"') + expect(markup).toContain('Antigravity CLI docs') + expect(markup).toContain('~/.gemini/antigravity-cli') + }) }) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index eb19fc543061..6c876d5be07f 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -15,6 +15,7 @@ import { watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' import { + getAccountsAntigravitySearchEntries, getAccountsClaudeSearchEntries, getAccountsCodexSearchEntries, getAccountsGeminiSearchEntries, @@ -35,6 +36,7 @@ import { providerAccountMatchesView } from './provider-account-visibility' import { Separator } from '../ui/separator' +import { AntigravityAccountsSection } from './AntigravityAccountsSection' import { GrokAccountsSection } from './GrokAccountsSection' import type { AccountsPaneProps, @@ -369,6 +371,9 @@ export function AccountsPane({ matchesSettingsSearch(searchQuery, getAccountsGeminiSearchEntries()) ? renderGeminiAccountsSection(model) : null, + matchesSettingsSearch(searchQuery, getAccountsAntigravitySearchEntries()) ? ( + + ) : null, matchesSettingsSearch(searchQuery, getAccountsOpencodeSearchEntries()) ? renderOpenCodeAccountsSection(model) : null, diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx new file mode 100644 index 000000000000..51dea46feb55 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getStatus: vi.fn(), + refreshRateLimits: vi.fn(), + antigravityUsage: vi.fn<() => unknown>(() => null) +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => React.createElement('span', { 'data-testid': 'antigravity-icon' }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => { + let result = fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + refreshRateLimits: mocks.refreshRateLimits, + settingsSearchQuery: '', + rateLimits: { antigravity: mocks.antigravityUsage() } + }) +})) + +import { AntigravityAccountsSection } from './AntigravityAccountsSection' + +describe('AntigravityAccountsSection', () => { + beforeEach(() => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + tokenFresh: true, + error: null + }) + mocks.refreshRateLimits.mockResolvedValue(undefined) + mocks.antigravityUsage.mockReturnValue(null) + Object.defineProperty(window, 'api', { + configurable: true, + value: { antigravityAccounts: { getStatus: mocks.getStatus } } + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('shows the existing Antigravity CLI sign-in', async () => { + render() + + expect(await screen.findByText('dev@example.com')).toBeInTheDocument() + expect( + screen.getByText('Signed in. Orca reads the Antigravity CLI session stored on disk.') + ).toBeInTheDocument() + }) + + it('explains when the CLI is signed in but usage is unavailable', async () => { + mocks.antigravityUsage.mockReturnValue({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + 'Antigravity CLI is signed in. Orca could not read a Code Assist quota for this account.', + status: 'unavailable' + }) + + render() + + expect( + await screen.findByText( + 'Antigravity CLI is signed in. Orca could not read a Code Assist quota for this account.' + ) + ).toBeInTheDocument() + expect(screen.queryByText('0%')).not.toBeInTheDocument() + }) + + it('explains when a signed-in refresh returns an error instead of hiding usage', async () => { + mocks.antigravityUsage.mockReturnValue({ + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: 'Quota fetch failed (403)', + status: 'error' + }) + + render() + + expect(await screen.findByText('Quota fetch failed (403)')).toBeInTheDocument() + }) + + it('shows named Antigravity quota buckets after a successful refresh', async () => { + mocks.antigravityUsage.mockReturnValue({ + provider: 'antigravity', + session: { usedPercent: 17, windowMinutes: 300, resetsAt: null, resetDescription: '2:36 PM' }, + weekly: { usedPercent: 80, windowMinutes: 10080, resetsAt: null, resetDescription: 'Thu' }, + buckets: [ + { + name: 'Gemini Models · Weekly', + usedPercent: 80, + windowMinutes: 10080, + resetsAt: null, + resetDescription: 'Thu' + }, + { + name: 'Gemini Models · Five hour', + usedPercent: 17, + windowMinutes: 300, + resetsAt: null, + resetDescription: '2:36 PM' + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + }) + + render() + + expect(await screen.findByText('Gemini Models · Weekly')).toBeInTheDocument() + expect(screen.getByText('80%')).toBeInTheDocument() + expect(screen.getByText('Gemini Models · Five hour')).toBeInTheDocument() + expect(screen.getByText('17%')).toBeInTheDocument() + }) + + it('explains the host-scoped Antigravity recovery flow without requiring a chat message', async () => { + mocks.getStatus.mockResolvedValue({ + signedIn: true, + email: 'dev@example.com', + tokenFresh: false, + error: null + }) + + render() + + expect( + await screen.findByText( + 'Session expired — run agy on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' + ) + ).toBeInTheDocument() + expect(screen.queryByText(/agy login/i)).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx new file mode 100644 index 000000000000..4a7c2d3dc179 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useState, type JSX } from 'react' +import { ExternalLink, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' +import { AgentIcon } from '@/lib/agent-catalog' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { useAppStore } from '../../store' +import { Badge } from '../ui/badge' +import { Button } from '../ui/button' +import type { AntigravityAccountStatus, RateLimitWindow } from '../../../../shared/rate-limit-types' +import { SearchableSetting } from './SearchableSetting' + +const ANTIGRAVITY_CLI_DOCS_URL = 'https://www.antigravity.google/docs/cli/install' + +function UsagePercentRow({ window }: { window: RateLimitWindow }): JSX.Element { + return ( +
+ + {Math.round(window.usedPercent)}% + + {window.resetDescription ? ( + + {translate( + 'auto.components.settings.AntigravityAccountsSection.resets', + 'Resets {{when}}', + { when: window.resetDescription } + )} + + ) : null} +
+ ) +} + +export function AntigravityAccountsSection(): JSX.Element { + const refreshRateLimits = useAppStore((s) => s.refreshRateLimits) + const antigravityUsage = useAppStore((s) => s.rateLimits.antigravity) + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const loadStatus = useCallback(async (): Promise => { + try { + const next = await window.api.antigravityAccounts.getStatus() + setStatus(next) + } catch (error) { + console.error('Failed to load Antigravity account status:', error) + setStatus({ + signedIn: false, + email: null, + tokenFresh: false, + error: error instanceof Error ? error.message : 'Unable to read Antigravity sign-in' + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadStatus() + }, [loadStatus, antigravityUsage?.updatedAt]) + + const handleRefreshUsage = async (): Promise => { + setRefreshing(true) + try { + await refreshRateLimits() + await loadStatus() + } finally { + setRefreshing(false) + } + } + + const signedIn = status?.signedIn === true + const tokenFresh = status?.tokenFresh === true + const usageBuckets = antigravityUsage?.buckets ?? [] + const sessionWindow = usageBuckets.length > 0 ? null : (antigravityUsage?.session ?? null) + const weeklyWindow = usageBuckets.length > 0 ? null : (antigravityUsage?.weekly ?? null) + const hasUsage = usageBuckets.length > 0 || sessionWindow !== null || weeklyWindow !== null + const usageFailure = + signedIn && + !hasUsage && + (antigravityUsage?.status === 'unavailable' || + antigravityUsage?.status === 'error' || + antigravityUsage?.status === 'ok') + ? (antigravityUsage.error ?? + translate( + 'auto.components.settings.AntigravityAccountsSection.usageUnknown', + 'Orca found the Antigravity CLI sign-in but could not read a usage percentage.' + )) + : null + + return ( +
+
+
+

+ + {translate('auto.components.settings.AntigravityAccountsSection.title', 'Antigravity')} +

+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.description', + 'Shows the Google sign-in from your Antigravity CLI session (~/.gemini/antigravity-cli).' + )} +

+
+ + {translate( + 'auto.components.settings.AntigravityAccountsSection.docs', + 'Antigravity CLI docs' + )} + + +
+ +
+ +
+ {loading ? ( +

+ {translate('auto.components.settings.AntigravityAccountsSection.loading', 'Loading…')} +

+ ) : signedIn ? ( + <> +

+ {status?.email ?? + translate( + 'auto.components.settings.AntigravityAccountsSection.signedIn', + 'Signed in' + )} +

+

+ {tokenFresh + ? translate( + 'auto.components.settings.AntigravityAccountsSection.signedInHint', + 'Signed in. Orca reads the Antigravity CLI session stored on disk.' + ) + : translate( + 'auto.components.settings.AntigravityAccountsSection.expiredHint', + 'Session expired — run agy on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.' + )} +

+ + ) : ( + <> +

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.notSignedIn', + 'Not signed in to Antigravity CLI' + )} +

+

+ {translate( + 'auto.components.settings.AntigravityAccountsSection.signInHint', + 'In a terminal, run agy and complete Google sign-in, then click Refresh usage here.' + )} +

+ + )} + {status?.error ?

{status.error}

: null} +
+ +
+ + {usageBuckets.length > 0 ? ( + +
+ {usageBuckets.map((bucket) => ( +
+ + {bucket.name} + + +
+ ))} +
+
+ ) : sessionWindow || weeklyWindow ? ( + <> + {sessionWindow ? ( + + + + ) : null} + {weeklyWindow ? ( + + + + ) : null} + + ) : usageFailure ? ( + +

{usageFailure}

+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-search.test.ts b/src/renderer/src/components/settings/accounts-search.test.ts index 7e18f59722e8..1278fd2d8ffd 100644 --- a/src/renderer/src/components/settings/accounts-search.test.ts +++ b/src/renderer/src/components/settings/accounts-search.test.ts @@ -15,7 +15,28 @@ vi.mock('./settings-search-keywords', () => ({ translateSearchKeyword: (_key: string, fallback: string) => [fallback] })) -import { getAccountsMiniMaxSearchEntries, getAccountsPaneSearchEntries } from './accounts-search' +import { + getAccountsAntigravitySearchEntries, + getAccountsMiniMaxSearchEntries, + getAccountsPaneSearchEntries +} from './accounts-search' + +describe('getAccountsAntigravitySearchEntries', () => { + it('indexes the Antigravity CLI session section', () => { + const entries = getAccountsAntigravitySearchEntries() + expect(entries).toHaveLength(1) + const [entry] = entries + expect(entry.title).toBe('Antigravity') + expect(entry.keywords).toEqual( + expect.arrayContaining(['antigravity', 'agy', 'cli', 'oauth', 'login']) + ) + }) + + it('is included in the rolled-up pane search entries', () => { + const titles = getAccountsPaneSearchEntries().map((entry) => entry.title) + expect(titles).toContain('Antigravity') + }) +}) describe('getAccountsMiniMaxSearchEntries', () => { it('returns a single entry that targets the MiniMax session cookie flow', () => { diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index efcf16c7d698..05186d708de9 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -127,6 +127,31 @@ export const getAccountsGeminiSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => [ + { + title: translate('auto.components.settings.accounts.search.antigravityTitle', 'Antigravity'), + description: translate( + 'auto.components.settings.accounts.search.antigravityDescription', + 'Shows the Google sign-in from your Antigravity CLI session.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.antigravityKeyword', + 'antigravity' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.agyKeyword', 'agy'), + ...translateSearchKeyword('auto.components.settings.accounts.search.8630464352', 'cli'), + ...translateSearchKeyword('auto.components.settings.accounts.search.933deaf732', 'oauth'), + ...translateSearchKeyword('auto.components.settings.accounts.search.a9f3d7b5c8', 'login'), + ...translateSearchKeyword( + 'auto.components.settings.accounts.search.e949b08ffb', + 'rate limit' + ), + ...translateSearchKeyword('auto.components.settings.accounts.search.86edc96bc9', 'status bar') + ] + } +]) + export const getAccountsOpencodeSearchEntries = createLocalizedCatalog(() => [ { title: translate( @@ -221,6 +246,7 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsClaudeSearchEntries(), ...getAccountsCodexSearchEntries(), ...getAccountsGeminiSearchEntries(), + ...getAccountsAntigravitySearchEntries(), ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries() diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index ab2a0eca23f1..c36cb33f3def 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -72,7 +72,7 @@ function usageSettings(overrides: Partial = {}): UsagePro claudeManagedAccounts: [], opencodeSessionCookie: '', geminiCliOAuthEnabled: false, - antigravityUsageConfigured: false, + antigravityAuthConfigured: false, minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, grokAuthConfigured: false, @@ -123,11 +123,7 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) - // Why: antigravity durability requires the Gemini OAuth opt-in; the - // checked item alone must not suppress the usage setup CTA. - expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( - false - ) + expect(hasUsageProviderSettings(usageSettings({ antigravityAuthConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ minimaxApiKeyConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) @@ -163,21 +159,13 @@ describe('hasUsageProviderSettingsForProvider', () => { expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) }) - it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + it('treats antigravityAuthConfigured as the durable Antigravity signal', () => { expect( hasUsageProviderSettingsForProvider( 'antigravity', - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityAuthConfigured: true }) ) ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. - expect( - hasUsageProviderSettingsForProvider( - 'antigravity', - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(false) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -345,11 +333,11 @@ describe('getVisibleUsageProvider', () => { ).toBe(null) }) - it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + it('keeps Antigravity visible while the snapshot is pending when the CLI session is present', () => { const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityAuthConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -359,24 +347,16 @@ describe('getVisibleUsageProvider', () => { }) }) - it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { - // Why: without the OAuth opt-in the mirrored snapshot is permanently - // 'unavailable'; the default-on item must not pin a dead bar. - expect( - getVisibleUsageProvider( - 'antigravity', - null, - usageSettings({ antigravityUsageConfigured: true }) - ) - ).toBe(null) + it('hides Antigravity when no CLI session is configured', () => { + expect(getVisibleUsageProvider('antigravity', null, usageSettings())).toBe(null) expect( getVisibleUsageProvider( 'antigravity', provider('unavailable', { provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' + error: 'Antigravity CLI is not signed in' }), - usageSettings({ antigravityUsageConfigured: true }) + usageSettings() ) ).toBe(null) }) @@ -474,7 +454,7 @@ describe('isUsageEmptyState', () => { ).toBe(true) }) - it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { + it('does not show the setup CTA while Antigravity CLI usage is awaiting a snapshot', () => { expect( isUsageEmptyState( { @@ -487,14 +467,12 @@ describe('isUsageEmptyState', () => { grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }) }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityAuthConfigured: true }) ) ).toBe(false) }) - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + it('still shows the setup CTA when Antigravity is unchecked and unsigned', () => { expect( isUsageEmptyState( { @@ -507,7 +485,7 @@ describe('isUsageEmptyState', () => { grok: provider('unavailable', { provider: 'grok' }), minimax: provider('unavailable', { provider: 'minimax' }) }, - usageSettings({ antigravityUsageConfigured: true }) + usageSettings() ) ).toBe(true) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 19258592f1fd..b39cccea447b 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,16 +8,11 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { - // Why: Antigravity has no separate persisted usage credential in Orca. The - // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. - antigravityUsageConfigured: boolean - // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. + // Why: MiniMax/Grok/Antigravity sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean minimaxApiKeyConfigured: boolean grokAuthConfigured: boolean + antigravityAuthConfigured: boolean } type UsageProviderSnapshots = { @@ -75,11 +70,10 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || - settings?.grokAuthConfigured === true + settings?.grokAuthConfigured === true || + settings?.antigravityAuthConfigured === true ) } @@ -103,10 +97,7 @@ export function hasUsageProviderSettingsForProvider( return Boolean(settings.opencodeSessionCookie?.trim()) } if (providerId === 'antigravity') { - // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays - // 'unavailable' until the user opts into Gemini CLI OAuth. Without that - // gate the default-on checked item would pin a permanently dead bar. - return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + return settings.antigravityAuthConfigured === true } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true || settings.minimaxApiKeyConfigured === true diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts index cbcfcda04929..4146a3aa6eb7 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.test.ts @@ -6,7 +6,7 @@ describe('getUsageProviderAccountsSectionId', () => { expect(getUsageProviderAccountsSectionId('claude')).toBe('accounts-claude') expect(getUsageProviderAccountsSectionId('codex')).toBe('accounts-codex') expect(getUsageProviderAccountsSectionId('gemini')).toBe('accounts-gemini') - expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-gemini') + expect(getUsageProviderAccountsSectionId('antigravity')).toBe('accounts-antigravity') expect(getUsageProviderAccountsSectionId('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts index 59ea71042490..ab46ba77035b 100644 --- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts +++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts @@ -9,9 +9,9 @@ export function getUsageProviderAccountsSectionId( case 'codex': return 'accounts-codex' case 'gemini': - case 'antigravity': - // Why: Antigravity usage currently shares Gemini's OAuth configuration. return 'accounts-gemini' + case 'antigravity': + return 'accounts-antigravity' case 'opencode-go': return 'accounts-opencode-go' case 'minimax': diff --git a/src/renderer/src/components/status-bar/use-status-bar-controller.ts b/src/renderer/src/components/status-bar/use-status-bar-controller.ts index 33db1976b0da..b131fd3a2eca 100644 --- a/src/renderer/src/components/status-bar/use-status-bar-controller.ts +++ b/src/renderer/src/components/status-bar/use-status-bar-controller.ts @@ -102,18 +102,12 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits // Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH. - // Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal. - // Why: Antigravity visibility also requires geminiCliOAuthEnabled because its usage snapshot mirrors the Gemini fetch. - const antigravityUsageConfigured = - statusBarItems.includes('antigravity') && - isStatusBarItemAvailable('antigravity', detectedAgentIds) - // Why: thread non-GlobalSettings durability flags so bars stay visible across reloads and snapshot refreshes. const usageSettings = { ...settings, - antigravityUsageConfigured, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, minimaxApiKeyConfigured: rateLimits.minimaxApiKeyConfigured, - grokAuthConfigured: rateLimits.grokAuthConfigured + grokAuthConfigured: rateLimits.grokAuthConfigured, + antigravityAuthConfigured: rateLimits.antigravityAuthConfigured } const visibleClaude = getVisibleUsageProvider('claude', claude, usageSettings) const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8e6d862e3b5e..02bed20221f1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8866,7 +8866,11 @@ "b2c4e7f1a8": "endpoint", "3a9b6d2c4e": "api key", "5d8f1a3b7c": "china", - "7e2a4b8c1d": "overseas" + "7e2a4b8c1d": "overseas", + "antigravityTitle": "Antigravity", + "antigravityDescription": "Shows the Google sign-in from your Antigravity CLI session.", + "antigravityKeyword": "antigravity", + "agyKeyword": "agy" } }, "advanced": { @@ -11840,6 +11844,25 @@ "targetDescription": "Higher values increase contrast where possible. Background colors stay unchanged.", "subtle": "Subtle", "strong": "Strong" + }, + "AntigravityAccountsSection": { + "title": "Antigravity", + "description": "Shows the Google sign-in from your Antigravity CLI session (~/.gemini/antigravity-cli).", + "docs": "Antigravity CLI docs", + "loading": "Loading…", + "signedIn": "Signed in", + "signedInHint": "Signed in. Orca reads the Antigravity CLI session stored on disk.", + "expiredHint": "Session expired — run agy on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed.", + "notSignedIn": "Not signed in to Antigravity CLI", + "signInHint": "In a terminal, run agy and complete Google sign-in, then click Refresh usage here.", + "refresh": "Refresh usage", + "sessionUsage": "Session usage", + "sessionUsageHint": "Same 5-hour and weekly windows as agy /usage.", + "weeklyUsage": "Weekly usage", + "weeklyUsageHint": "Weekly Antigravity CLI quota window.", + "resets": "Resets {{when}}", + "usage": "Usage", + "usageUnknown": "Orca found the Antigravity CLI sign-in but could not read a usage percentage." } }, "right": { diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 26b11ad68dc4..6074cd3b8f07 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -27,6 +27,20 @@ export function createGrokAccountsApi(): NonNullable['grokAc } } +export function createAntigravityAccountsApi(): NonNullable< + Partial['antigravityAccounts'] +> { + const unsigned = { + signedIn: false, + email: null, + tokenFresh: false, + error: null + } + return { + getStatus: () => Promise.resolve(unsigned) + } +} + export function createAccountsApi(): never { const empty = { accounts: [], diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts index b1199332d95a..994733b13b75 100644 --- a/src/renderer/src/web/web-preload-api-composition.test.ts +++ b/src/renderer/src/web/web-preload-api-composition.test.ts @@ -55,6 +55,7 @@ describe('web preload API composition', () => { 'rateLimits', 'minimaxCredentials', 'grokAccounts', + 'antigravityAccounts', 'codexAccounts', 'claudeAccounts', 'cli', diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a53b5e0ca061..733960c69d1c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3,6 +3,7 @@ import type { StatsSummary } from '../../../shared/process-stats-types' import { createWebE2EApi } from './preload-api/web-e2e-api' import { createAccountsApi, + createAntigravityAccountsApi, createGrokAccountsApi, createMiniMaxCredentialsApi } from './preload-api/web-agent-accounts-api' @@ -107,6 +108,7 @@ function createWebPreloadApi(): Partial { rateLimits: createRateLimitsApi(), minimaxCredentials: createMiniMaxCredentialsApi(), grokAccounts: createGrokAccountsApi(), + antigravityAccounts: createAntigravityAccountsApi(), codexAccounts: createAccountsApi(), claudeAccounts: createAccountsApi(), cli: createCliApi(), diff --git a/src/shared/rate-limit-state-factory.ts b/src/shared/rate-limit-state-factory.ts index bf8d97e541ed..6660271499e1 100644 --- a/src/shared/rate-limit-state-factory.ts +++ b/src/shared/rate-limit-state-factory.ts @@ -14,6 +14,7 @@ export function createEmptyRateLimitState(overrides: Partial = { minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, grokAuthConfigured: false, + antigravityAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index f11dc638d414..a4cdc33a056c 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -19,6 +19,7 @@ describe('RateLimitState', () => { minimaxCookieConfigured: false, minimaxApiKeyConfigured: false, grokAuthConfigured: false, + antigravityAuthConfigured: false, claudeTarget: { runtime: 'host', wslDistro: null }, codexTarget: { runtime: 'host', wslDistro: null }, inactiveClaudeAccounts: [], diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 5744e3e67497..ea79f97a6136 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -115,6 +115,13 @@ export type GrokAccountStatus = { error: string | null } +export type AntigravityAccountStatus = { + signedIn: boolean + email: string | null + tokenFresh: boolean + error: string | null +} + export type RateLimitState = { claude: ProviderRateLimits | null codex: ProviderRateLimits | null @@ -140,6 +147,8 @@ export type RateLimitState = { minimaxApiKeyConfigured: boolean /** True when main finds a Grok CLI session file (~/.grok/auth.json or GROK_HOME). */ grokAuthConfigured: boolean + /** True when main finds an Antigravity CLI token (~/.gemini/antigravity-cli). */ + antigravityAuthConfigured: boolean claudeTarget: RateLimitRuntimeTarget codexTarget: RateLimitRuntimeTarget inactiveClaudeAccounts: InactiveAccountUsage[]