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[]