diff --git a/src/main/rate-limits/antigravity-fetcher.test.ts b/src/main/rate-limits/antigravity-fetcher.test.ts new file mode 100644 index 000000000000..7c9ded14bf39 --- /dev/null +++ b/src/main/rate-limits/antigravity-fetcher.test.ts @@ -0,0 +1,353 @@ +import { EventEmitter } from 'node:events' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { discoverMock, httpsRequestMock } = vi.hoisted(() => ({ + discoverMock: vi.fn(), + httpsRequestMock: vi.fn() +})) + +// Why: mock at the same two module boundaries the fetcher itself crosses — +// process discovery and the local HTTPS transport — instead of spawning real +// processes or opening real sockets in unit tests. +vi.mock('./antigravity-process-discovery', () => ({ + discoverAntigravityRuntime: discoverMock +})) + +vi.mock('node:https', () => ({ + request: httpsRequestMock +})) + +import { fetchAntigravityRateLimits, parseAntigravityQuotaSummary } from './antigravity-fetcher' + +type MockReq = EventEmitter & { + write: (chunk: unknown) => void + end: () => void + destroy: () => void +} + +function mockJsonResponse(statusCode: number, body: unknown): void { + httpsRequestMock.mockImplementation( + (_options: unknown, callback: (res: EventEmitter & { statusCode: number }) => void) => { + const req = new EventEmitter() as MockReq + req.write = vi.fn() + req.end = vi.fn() + req.destroy = vi.fn() + const res = new EventEmitter() as EventEmitter & { statusCode: number } + res.statusCode = statusCode + callback(res) + queueMicrotask(() => { + res.emit( + 'data', + Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf-8') + ) + res.emit('end') + }) + return req + } + ) +} + +function mockConnectionRefused(): void { + httpsRequestMock.mockImplementation(() => { + const req = new EventEmitter() as MockReq + req.write = vi.fn() + req.end = vi.fn() + req.destroy = vi.fn() + queueMicrotask(() => req.emit('error', new Error('ECONNREFUSED'))) + return req + }) +} + +const REAL_QUOTA_SUMMARY_RESPONSE = { + response: { + groups: [ + { + displayName: 'Gemini Models', + description: 'Models within this group: Gemini Flash, Gemini Pro', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit Remaining', + window: 'weekly', + remainingFraction: 0.93352824, + resetTime: '2026-09-30T08:49:24Z' + }, + { + bucketId: 'gemini-5h', + displayName: 'Five Hour Limit Remaining', + window: '5h', + remainingFraction: 1, + resetTime: '2026-09-27T10:17:03Z' + } + ] + }, + { + displayName: 'Claude and GPT models', + description: 'Models within this group: Claude Opus, Claude Sonnet, GPT-OSS', + buckets: [ + { + bucketId: '3p-weekly', + displayName: 'Weekly Limit Remaining', + window: 'weekly', + remainingFraction: 1, + resetTime: '2026-10-04T05:53:29Z' + }, + { + bucketId: '3p-5h', + displayName: 'Five Hour Limit Remaining', + window: '5h', + remainingFraction: 1, + resetTime: '2026-09-27T10:53:29Z' + } + ] + } + ], + description: 'Within each group, models share a weekly limit and a 5-hour limit.' + } +} + +describe('parseAntigravityQuotaSummary', () => { + it('parses the Gemini Models weekly bucket', () => { + const buckets = parseAntigravityQuotaSummary(REAL_QUOTA_SUMMARY_RESPONSE) + const geminiWeekly = buckets.find((b) => b.name === 'Gemini Weekly') + expect(geminiWeekly).toMatchObject({ + usedPercent: 7, + windowMinutes: 10_080 + }) + expect(geminiWeekly?.resetsAt).toBe(new Date('2026-09-30T08:49:24Z').getTime()) + }) + + it('parses the Gemini Models five-hour bucket', () => { + const buckets = parseAntigravityQuotaSummary(REAL_QUOTA_SUMMARY_RESPONSE) + const geminiFiveHour = buckets.find((b) => b.name === 'Gemini 5h') + expect(geminiFiveHour).toMatchObject({ usedPercent: 0, windowMinutes: 300 }) + }) + + it('parses the Claude/GPT models weekly bucket', () => { + const buckets = parseAntigravityQuotaSummary(REAL_QUOTA_SUMMARY_RESPONSE) + const thirdPartyWeekly = buckets.find((b) => b.name === 'Claude/GPT Weekly') + expect(thirdPartyWeekly).toMatchObject({ usedPercent: 0, windowMinutes: 10_080 }) + }) + + it('parses the Claude/GPT models five-hour bucket', () => { + const buckets = parseAntigravityQuotaSummary(REAL_QUOTA_SUMMARY_RESPONSE) + const thirdPartyFiveHour = buckets.find((b) => b.name === 'Claude/GPT 5h') + expect(thirdPartyFiveHour).toMatchObject({ usedPercent: 0, windowMinutes: 300 }) + }) + + it('returns exactly four buckets for a complete response', () => { + expect(parseAntigravityQuotaSummary(REAL_QUOTA_SUMMARY_RESPONSE)).toHaveLength(4) + }) + + it('skips a missing bucket instead of throwing', () => { + const missingFiveHour = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { + bucketId: 'gemini-weekly', + displayName: 'Weekly Limit Remaining', + window: 'weekly', + remainingFraction: 0.5, + resetTime: '2026-09-30T08:49:24Z' + } + // gemini-5h intentionally absent + ] + } + ] + } + } + const buckets = parseAntigravityQuotaSummary(missingFiveHour) + expect(buckets).toHaveLength(1) + expect(buckets[0]?.name).toBe('Gemini Weekly') + }) + + it('skips a bucket with a non-numeric remainingFraction instead of throwing', () => { + const malformedBucket = { + response: { + groups: [ + { + displayName: 'Gemini Models', + buckets: [ + { bucketId: 'gemini-weekly', window: 'weekly', remainingFraction: 'not-a-number' }, + { + bucketId: 'gemini-5h', + window: '5h', + remainingFraction: 1, + resetTime: 'garbage-date' + } + ] + } + ] + } + } + const buckets = parseAntigravityQuotaSummary(malformedBucket) + expect(buckets).toHaveLength(1) + expect(buckets[0]?.name).toBe('Gemini 5h') + // Why: an unparsable reset timestamp must degrade to "no countdown", not throw. + expect(buckets[0]?.resetsAt).toBeNull() + }) + + it('returns no buckets for a completely malformed response shape', () => { + expect(parseAntigravityQuotaSummary({ unexpected: 'shape' })).toEqual([]) + expect(parseAntigravityQuotaSummary(null)).toEqual([]) + expect(parseAntigravityQuotaSummary('not even an object')).toEqual([]) + expect(parseAntigravityQuotaSummary(undefined)).toEqual([]) + }) + + it('still parses a bucket whose window has already reset', () => { + const alreadyReset = { + response: { + groups: [ + { + displayName: 'Claude and GPT models', + buckets: [ + { + bucketId: '3p-weekly', + window: 'weekly', + remainingFraction: 1, + resetTime: '2020-01-01T00:00:00Z' + } + ] + } + ] + } + } + const buckets = parseAntigravityQuotaSummary(alreadyReset) + expect(buckets).toHaveLength(1) + expect(buckets[0]?.resetsAt).toBe(new Date('2020-01-01T00:00:00Z').getTime()) + }) + + it('classifies an unrecognized group name as the non-Gemini pool rather than dropping it', () => { + const futureGroupName = { + response: { + groups: [ + { + displayName: 'Some Future Model Family', + buckets: [{ bucketId: 'future-5h', window: '5h', remainingFraction: 1 }] + } + ] + } + } + const buckets = parseAntigravityQuotaSummary(futureGroupName) + expect(buckets.map((b) => b.name)).toEqual(['Claude/GPT 5h']) + }) +}) + +describe('fetchAntigravityRateLimits', () => { + beforeEach(() => { + discoverMock.mockReset() + httpsRequestMock.mockReset() + }) + + it('reports unavailable (not an error) when Antigravity is not running', async () => { + discoverMock.mockResolvedValue(null) + const result = await fetchAntigravityRateLimits() + expect(result.status).toBe('unavailable') + expect(result.usageMetadata?.failureKind).toBe('cli-unavailable') + expect(result.buckets).toBeUndefined() + }) + + it('fetches and parses real quota data end to end when Antigravity is running', async () => { + discoverMock.mockResolvedValue({ pid: 123, csrfToken: 'token-abc', ports: [51364] }) + mockJsonResponse(200, REAL_QUOTA_SUMMARY_RESPONSE) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('ok') + expect(result.error).toBeNull() + expect(result.buckets).toHaveLength(4) + expect(result.buckets?.map((b) => b.name).sort()).toEqual( + ['Claude/GPT 5h', 'Claude/GPT Weekly', 'Gemini 5h', 'Gemini Weekly'].sort() + ) + // Why: the CSRF token authorizes the call, not TLS — verify it is sent. + expect(httpsRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + port: 51364, + path: '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary', + headers: expect.objectContaining({ 'X-Codeium-Csrf-Token': 'token-abc' }) + }), + expect.any(Function) + ) + }) + + it('falls back to the next candidate port when the first refuses the connection', async () => { + discoverMock.mockResolvedValue({ pid: 123, csrfToken: 'token-abc', ports: [51365, 51364] }) + let callCount = 0 + httpsRequestMock.mockImplementation( + ( + options: { port: number }, + callback: (res: EventEmitter & { statusCode: number }) => void + ) => { + callCount += 1 + const req = new EventEmitter() as MockReq + req.write = vi.fn() + req.end = vi.fn() + req.destroy = vi.fn() + if (options.port === 51365) { + queueMicrotask(() => req.emit('error', new Error('ECONNREFUSED'))) + } else { + const res = new EventEmitter() as EventEmitter & { statusCode: number } + res.statusCode = 200 + callback(res) + queueMicrotask(() => { + res.emit('data', Buffer.from(JSON.stringify(REAL_QUOTA_SUMMARY_RESPONSE))) + res.emit('end') + }) + } + return req + } + ) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('ok') + expect(callCount).toBe(2) + }) + + it('reports error (not unavailable) when the runtime is found but every request fails', async () => { + discoverMock.mockResolvedValue({ pid: 123, csrfToken: 'token-abc', ports: [51364] }) + mockConnectionRefused() + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('network') + expect(result.error).toContain('ECONNREFUSED') + }) + + it('reports error when the runtime responds with a non-200 status', async () => { + discoverMock.mockResolvedValue({ pid: 123, csrfToken: 'token-abc', ports: [51364] }) + mockJsonResponse(500, '{}') + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('error') + expect(result.error).toContain('500') + }) + + it('reports error when the response is valid JSON but has no usable buckets (malformed shape)', async () => { + discoverMock.mockResolvedValue({ pid: 123, csrfToken: 'token-abc', ports: [51364] }) + mockJsonResponse(200, { response: { groups: [] } }) + + const result = await fetchAntigravityRateLimits() + + expect(result.status).toBe('error') + }) + + it('never depends on Gemini state or credentials (provider isolation)', async () => { + // Why: the fetcher's only dependencies are process discovery and the local + // HTTPS transport — asserting that is a structural guard against #9122/ + // #19561 regressing (Antigravity silently re-coupling to the Gemini fetch). + discoverMock.mockResolvedValue({ pid: 123, csrfToken: 'token-abc', ports: [51364] }) + mockJsonResponse(200, REAL_QUOTA_SUMMARY_RESPONSE) + + const result = await fetchAntigravityRateLimits() + + expect(result.provider).toBe('antigravity') + expect(Object.keys(result)).not.toContain('geminiProjectId') + expect(discoverMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/rate-limits/antigravity-fetcher.ts b/src/main/rate-limits/antigravity-fetcher.ts new file mode 100644 index 000000000000..dec7e629c36f --- /dev/null +++ b/src/main/rate-limits/antigravity-fetcher.ts @@ -0,0 +1,241 @@ +import * as https from 'node:https' +import type { ProviderRateLimits, RateLimitBucket } from '../../shared/rate-limit-types' +import { + ANTIGRAVITY_GEMINI_FIVE_HOUR_BUCKET_NAME, + ANTIGRAVITY_GEMINI_WEEKLY_BUCKET_NAME, + ANTIGRAVITY_THIRD_PARTY_FIVE_HOUR_BUCKET_NAME, + ANTIGRAVITY_THIRD_PARTY_WEEKLY_BUCKET_NAME +} from '../../shared/antigravity-usage-buckets' +import { discoverAntigravityRuntime } from './antigravity-process-discovery' + +// Why: this is the same RPC the Antigravity IDE's own "Settings > Models & +// Usage" panel calls on its local runtime — not Gemini CLI's `retrieveUserQuota`, +// which only ever describes shared Google Code Assist quota (see #9122/#19561: +// Orca previously republished a Gemini CLI read under the Antigravity provider). +const RPC_PATH = '/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary' +const REQUEST_TIMEOUT_MS = 3_000 +const FIVE_HOUR_WINDOW_MINUTES = 300 +const WEEKLY_WINDOW_MINUTES = 10_080 + +type RawQuotaBucket = { + bucketId?: unknown + displayName?: unknown + window?: unknown + remainingFraction?: unknown + resetTime?: unknown +} + +type RawQuotaGroup = { + displayName?: unknown + buckets?: unknown +} + +type RawQuotaSummaryResponse = { + response?: { + groups?: unknown + } +} + +function postConnectRpc(port: number, csrfToken: string, path: string): Promise { + return new Promise((resolve, reject) => { + const payload = Buffer.from('{}', 'utf-8') + const req = https.request( + { + hostname: '127.0.0.1', + port, + path, + method: 'POST', + // Why: the local runtime terminates TLS with a fresh, per-launch + // self-signed certificate on loopback only. The CSRF token in the + // header (not the certificate) is what proves the caller is allowed + // to talk to it — the same trust model Antigravity's own webview + // clients use to reach this port. + rejectUnauthorized: false, + timeout: REQUEST_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': payload.length, + 'Connect-Protocol-Version': '1', + 'X-Codeium-Csrf-Token': csrfToken + } + }, + (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + if (res.statusCode !== 200) { + reject( + new Error(`Antigravity quota request failed (HTTP ${res.statusCode ?? 'unknown'})`) + ) + return + } + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown) + } catch { + reject(new Error('Antigravity quota response was not valid JSON')) + } + }) + } + ) + req.on('error', reject) + req.on('timeout', () => { + req.destroy(new Error('Antigravity quota request timed out')) + }) + req.write(payload) + req.end() + }) +} + +function classifyGroup(displayName: unknown): 'gemini' | 'third-party' | null { + if (typeof displayName !== 'string' || displayName.trim().length === 0) { + return null + } + return /gemini/i.test(displayName) ? 'gemini' : 'third-party' +} + +// Why: `bucketId` ("gemini-weekly", "3p-5h") is the most stable signal — it is +// an identifier, not display copy — but displayName/window are checked too so +// a future rename on either side doesn't silently drop a bucket. +function classifyBucketWindow(bucket: RawQuotaBucket): 'weekly' | '5h' | null { + const bucketId = typeof bucket.bucketId === 'string' ? bucket.bucketId : '' + const window = typeof bucket.window === 'string' ? bucket.window : '' + const displayName = typeof bucket.displayName === 'string' ? bucket.displayName : '' + if (window === 'weekly' || bucketId.endsWith('-weekly') || /weekly/i.test(displayName)) { + return 'weekly' + } + if (window === '5h' || bucketId.endsWith('-5h') || /five.?hour/i.test(displayName)) { + return '5h' + } + return null +} + +function bucketNameFor(group: 'gemini' | 'third-party', windowKind: 'weekly' | '5h'): string { + if (group === 'gemini') { + return windowKind === 'weekly' + ? ANTIGRAVITY_GEMINI_WEEKLY_BUCKET_NAME + : ANTIGRAVITY_GEMINI_FIVE_HOUR_BUCKET_NAME + } + return windowKind === 'weekly' + ? ANTIGRAVITY_THIRD_PARTY_WEEKLY_BUCKET_NAME + : ANTIGRAVITY_THIRD_PARTY_FIVE_HOUR_BUCKET_NAME +} + +function toRateLimitBucket( + group: 'gemini' | 'third-party', + windowKind: 'weekly' | '5h', + bucket: RawQuotaBucket +): RateLimitBucket | null { + const remainingFraction = bucket.remainingFraction + if (typeof remainingFraction !== 'number' || !Number.isFinite(remainingFraction)) { + return null + } + const clamped = Math.min(1, Math.max(0, remainingFraction)) + const usedPercent = Math.round((1 - clamped) * 100) + const resetTime = typeof bucket.resetTime === 'string' ? bucket.resetTime : null + const resetsAtTime = resetTime ? new Date(resetTime).getTime() : Number.NaN + return { + name: bucketNameFor(group, windowKind), + usedPercent, + windowMinutes: windowKind === 'weekly' ? WEEKLY_WINDOW_MINUTES : FIVE_HOUR_WINDOW_MINUTES, + resetsAt: Number.isFinite(resetsAtTime) ? resetsAtTime : null, + resetDescription: null + } +} + +/** + * Parses a `RetrieveUserQuotaSummaryResponse` (wrapped in `{ response: ... }` + * by the local runtime's Connect-RPC envelope) into the four named buckets + * Orca's usage UI renders. Unrecognized or missing groups/buckets are skipped + * rather than thrown — a partial, honest reading beats none. + */ +export function parseAntigravityQuotaSummary(data: unknown): RateLimitBucket[] { + const groupList = (data as RawQuotaSummaryResponse | null | undefined)?.response?.groups + if (!Array.isArray(groupList)) { + return [] + } + const buckets: RateLimitBucket[] = [] + for (const rawGroup of groupList) { + if (!rawGroup || typeof rawGroup !== 'object') { + continue + } + const group = rawGroup as RawQuotaGroup + const groupKind = classifyGroup(group.displayName) + const rawBuckets = group.buckets + if (!groupKind || !Array.isArray(rawBuckets)) { + continue + } + for (const rawBucket of rawBuckets) { + if (!rawBucket || typeof rawBucket !== 'object') { + continue + } + const windowKind = classifyBucketWindow(rawBucket as RawQuotaBucket) + if (!windowKind) { + continue + } + const bucket = toRateLimitBucket(groupKind, windowKind, rawBucket as RawQuotaBucket) + if (bucket) { + buckets.push(bucket) + } + } + } + return buckets +} + +async function fetchViaPort(port: number, csrfToken: string): Promise { + const data = await postConnectRpc(port, csrfToken, RPC_PATH) + const buckets = parseAntigravityQuotaSummary(data) + if (buckets.length === 0) { + throw new Error('Antigravity quota response had no recognizable Gemini/Claude/GPT buckets') + } + return buckets +} + +/** + * Fetches Antigravity's own quota from its local runtime (the same + * `RetrieveUserQuotaSummary` call backing Settings > Models & Usage), fully + * independent of the Gemini CLI fetcher. Returns `unavailable` when the + * Antigravity IDE (or its background language_server process) is not running + * on this machine, and `error` when it is running but the request failed. + */ +export async function fetchAntigravityRateLimits(): Promise { + const runtime = await discoverAntigravityRuntime().catch(() => null) + if (!runtime) { + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: + 'Antigravity usage is not available. Open the Antigravity IDE on this computer (its background runtime must be running), then retry.', + status: 'unavailable', + usageMetadata: { failureKind: 'cli-unavailable' } + } + } + + let lastError: unknown = null + for (const port of runtime.ports) { + try { + const buckets = await fetchViaPort(port, runtime.csrfToken) + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } catch (err) { + lastError = err + } + } + return { + provider: 'antigravity', + session: null, + weekly: null, + updatedAt: Date.now(), + error: lastError instanceof Error ? lastError.message : 'Antigravity quota fetch failed', + status: 'error', + usageMetadata: { failureKind: 'network' } + } +} diff --git a/src/main/rate-limits/antigravity-process-discovery.test.ts b/src/main/rate-limits/antigravity-process-discovery.test.ts new file mode 100644 index 000000000000..e193c520f4f3 --- /dev/null +++ b/src/main/rate-limits/antigravity-process-discovery.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() })) + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock })) + +import { discoverAntigravityRuntime } from './antigravity-process-discovery' + +type ProcessResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string + timedOut: boolean +} + +function ok(stdout: string): ProcessResult { + return { code: 0, signal: null, stdout, stderr: '', timedOut: false } +} + +function failed(): ProcessResult { + return { code: 1, signal: null, stdout: '', stderr: 'boom', timedOut: false } +} + +const REAL_ANTIGRAVITY_PROCESS_JSON = JSON.stringify({ + ProcessId: 78072, + CommandLine: + 'C:\\Users\\dev\\AppData\\Local\\Programs\\Antigravity\\resources\\bin\\language_server.exe --standalone --override_ide_name antigravity --subclient_type hub --app_data_dir antigravity --https_server_port 0 --csrf_token 00000000-0000-4000-8000-000000000000' +}) + +describe('discoverAntigravityRuntime (Windows)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + runProcessMock.mockReset() + }) + + it('finds the running Antigravity language_server, its CSRF token, and its listening ports', async () => { + runProcessMock + .mockResolvedValueOnce(ok(REAL_ANTIGRAVITY_PROCESS_JSON)) + .mockResolvedValueOnce(ok(JSON.stringify([51365, 51364]))) + + const runtime = await discoverAntigravityRuntime() + + expect(runtime).toEqual({ + pid: 78072, + csrfToken: '00000000-0000-4000-8000-000000000000', + ports: [51365, 51364] + }) + // Why: every spawn must go through the shared run-process chokepoint + // (windowsHide + argv handling), never a direct child_process import. + expect(runProcessMock).toHaveBeenCalledWith( + expect.objectContaining({ program: 'powershell.exe' }) + ) + }) + + it('handles Get-CimInstance returning a bare object (single match) instead of an array', async () => { + runProcessMock + .mockResolvedValueOnce(ok(REAL_ANTIGRAVITY_PROCESS_JSON)) + .mockResolvedValueOnce(ok(JSON.stringify(51364))) + + const runtime = await discoverAntigravityRuntime() + + expect(runtime).toEqual({ + pid: 78072, + csrfToken: '00000000-0000-4000-8000-000000000000', + ports: [51364] + }) + }) + + it('returns null when no language_server process is running at all', async () => { + runProcessMock.mockResolvedValueOnce(ok('[]')) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('ignores a language_server process that does not belong to Antigravity', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + JSON.stringify({ + ProcessId: 999, + CommandLine: 'C:\\Some\\Other\\App\\language_server.exe --app_data_dir some-other-ide' + }) + ) + ) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('returns null when the command line has no CSRF token', async () => { + runProcessMock.mockResolvedValueOnce( + ok( + JSON.stringify({ + ProcessId: 78072, + CommandLine: + 'C:\\...\\language_server.exe --app_data_dir antigravity --https_server_port 0' + }) + ) + ) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('returns null when the process is found but has no listening ports', async () => { + runProcessMock + .mockResolvedValueOnce(ok(REAL_ANTIGRAVITY_PROCESS_JSON)) + .mockResolvedValueOnce(ok('[]')) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('returns null when the PowerShell command itself fails', async () => { + runProcessMock.mockResolvedValueOnce(failed()) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('returns null when run-process rejects (e.g. powershell.exe missing)', async () => { + runProcessMock.mockRejectedValueOnce(new Error('spawn powershell.exe ENOENT')) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('returns null on unparsable PowerShell output instead of throwing', async () => { + runProcessMock.mockResolvedValueOnce(ok('not json at all')) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) + + it('returns null when the command times out', async () => { + runProcessMock.mockResolvedValueOnce({ + code: null, + signal: null, + stdout: '', + stderr: '', + timedOut: true + }) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) +}) + +describe('discoverAntigravityRuntime (macOS/Linux)', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + runProcessMock.mockReset() + }) + + it('finds the running Antigravity language_server via pgrep/lsof', async () => { + runProcessMock + .mockResolvedValueOnce( + ok( + '4242 /Applications/Antigravity.app/Contents/Resources/bin/language_server_macos --app_data_dir antigravity --csrf_token abc-123-def' + ) + ) + .mockResolvedValueOnce( + ok( + 'COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\nlanguage 4242 dev 10u IPv4 0x0 0t0 TCP 127.0.0.1:51364 (LISTEN)\n' + ) + ) + + const runtime = await discoverAntigravityRuntime() + + expect(runtime).toEqual({ pid: 4242, csrfToken: 'abc-123-def', ports: [51364] }) + expect(runProcessMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ program: 'pgrep' })) + }) + + it('returns null when pgrep finds no matching process (exit 1, empty stdout)', async () => { + runProcessMock.mockResolvedValueOnce(failed()) + + expect(await discoverAntigravityRuntime()).toBeNull() + }) +}) diff --git a/src/main/rate-limits/antigravity-process-discovery.ts b/src/main/rate-limits/antigravity-process-discovery.ts new file mode 100644 index 000000000000..3078501a668f --- /dev/null +++ b/src/main/rate-limits/antigravity-process-discovery.ts @@ -0,0 +1,198 @@ +import { runProcess } from '../../shared/child-process/run-process' + +const COMMAND_TIMEOUT_MS = 4_000 +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024 + +export type AntigravityProcessInfo = { + pid: number + csrfToken: string + ports: number[] +} + +// Why: the local runtime's binary name has changed across Antigravity releases +// (language_server_windows_x64.exe -> language_server.exe); matching a prefix +// instead of an exact name keeps discovery working across both. +function isAntigravityCommandLine(commandLine: string): boolean { + return ( + /--app_data_dir[=\s]+antigravity\b/i.test(commandLine) || + /[\\/]antigravity[\\/]/i.test(commandLine) + ) +} + +function extractCsrfToken(commandLine: string): string | null { + return commandLine.match(/--csrf_token[=\s]+([a-zA-Z0-9-]+)/)?.[1] ?? null +} + +function isSafePid(pid: unknown): pid is number { + return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 +} + +async function runShellCommand(script: string): Promise { + // Why: run-process.ts (not a direct child_process import) is Orca's single + // spawn chokepoint — it resolves the correct Windows binary and always sets + // windowsHide (see src/shared/child-process/process-spec.ts). + const result = await runProcess({ + program: 'powershell.exe', + args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + timeoutMs: COMMAND_TIMEOUT_MS, + maxOutputBytes: MAX_OUTPUT_BYTES + }).catch(() => null) + if (!result || result.timedOut || result.code !== 0) { + return null + } + return result.stdout +} + +async function runPosixCommand(program: string, args: string[]): Promise { + const result = await runProcess({ + program, + args, + timeoutMs: COMMAND_TIMEOUT_MS, + maxOutputBytes: MAX_OUTPUT_BYTES + }).catch(() => null) + if (!result || result.timedOut) { + return null + } + // Why not gate on exit code: pgrep exits 1 for "no match", which is a + // legitimate "not running" answer here, not a failure to distinguish from one. + return result.stdout +} + +// ─── Windows ──────────────────────────────────────────────────────────────── + +async function findWindowsProcess(): Promise<{ pid: number; csrfToken: string } | null> { + // Why: -Filter is a static WQL string with no interpolated data. + const stdout = await runShellCommand( + 'Get-CimInstance Win32_Process -Filter "Name LIKE \'language_server%\'" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress' + ) + if (!stdout) { + return null + } + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return null + } + const rows = Array.isArray(parsed) ? parsed : [parsed] + for (const row of rows) { + if (!row || typeof row !== 'object') { + continue + } + const commandLine = (row as { CommandLine?: unknown }).CommandLine + const pid = (row as { ProcessId?: unknown }).ProcessId + if ( + typeof commandLine !== 'string' || + !isSafePid(pid) || + !isAntigravityCommandLine(commandLine) + ) { + continue + } + const csrfToken = extractCsrfToken(commandLine) + if (csrfToken) { + return { pid, csrfToken } + } + } + return null +} + +async function findWindowsListeningPorts(pid: number): Promise { + if (!isSafePid(pid)) { + return [] + } + const stdout = await runShellCommand( + `Get-NetTCPConnection -OwningProcess ${pid} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty LocalPort | ConvertTo-Json -Compress` + ) + if (!stdout) { + return [] + } + const trimmed = stdout.trim() + if (!trimmed) { + return [] + } + try { + const parsed = JSON.parse(trimmed) as unknown + const values = Array.isArray(parsed) ? parsed : [parsed] + return values.filter((v): v is number => typeof v === 'number' && Number.isInteger(v)) + } catch { + return [] + } +} + +// ─── macOS / Linux ────────────────────────────────────────────────────────── + +function unixProcessPattern(): string { + return process.platform === 'darwin' ? 'language_server_macos' : 'language_server_linux' +} + +async function findUnixProcess(): Promise<{ pid: number; csrfToken: string } | null> { + const stdout = await runPosixCommand('pgrep', ['-af', unixProcessPattern()]) + if (!stdout) { + return null + } + for (const line of stdout.split('\n')) { + if (!isAntigravityCommandLine(line)) { + continue + } + const pidMatch = line.trim().match(/^(\d+)\s/) + const pid = pidMatch ? Number(pidMatch[1]) : Number.NaN + const csrfToken = extractCsrfToken(line) + if (isSafePid(pid) && csrfToken) { + return { pid, csrfToken } + } + } + return null +} + +async function findUnixListeningPorts(pid: number): Promise { + if (!isSafePid(pid)) { + return [] + } + const stdout = await runPosixCommand('lsof', [ + '-nP', + '-a', + '-iTCP', + '-sTCP:LISTEN', + '-p', + String(pid) + ]) + if (!stdout) { + return [] + } + const ports = new Set() + const portPattern = /(?:127\.0\.0\.1|\*|\[::1?\]):(\d+)\s+\(LISTEN\)/g + let match: RegExpExecArray | null + while ((match = portPattern.exec(stdout)) !== null) { + const port = Number(match[1]) + if (Number.isInteger(port)) { + ports.add(port) + } + } + return [...ports].sort((a, b) => a - b) +} + +// ─── Entry point ──────────────────────────────────────────────────────────── + +/** + * Locates the Antigravity IDE's local `language_server` runtime process and + * the CSRF token + listening ports needed to call its Connect-RPC API. + * Returns null when Antigravity is not currently running. + */ +export async function discoverAntigravityRuntime(): Promise { + const found = process.platform === 'win32' ? await findWindowsProcess() : await findUnixProcess() + if (!found) { + return null + } + const ports = + process.platform === 'win32' + ? await findWindowsListeningPorts(found.pid) + : await findUnixListeningPorts(found.pid) + if (ports.length === 0) { + return null + } + return { pid: found.pid, csrfToken: found.csrfToken, ports } +} 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 021de01108d3..76020e879672 100644 --- a/src/main/rate-limits/rate-limit-service-test-harness.ts +++ b/src/main/rate-limits/rate-limit-service-test-harness.ts @@ -4,6 +4,7 @@ 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-fetcher' import { fetchKimiRateLimits } from './kimi-fetcher' import { fetchMiniMaxRateLimits } from './minimax/minimax-fetcher' import { fetchGrokRateLimits } from './grok-fetcher' @@ -92,6 +93,9 @@ export function mockFreshBackgroundProviderFetches(): void { vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0)) vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok')) vi.mocked(fetchCursorRateLimits).mockImplementation(async () => unavailableProvider('cursor')) + vi.mocked(fetchAntigravityRateLimits).mockImplementation(async () => + unavailableProvider('antigravity') + ) } /** Shared `beforeEach` body: healthy stubs for every provider the service polls. */ @@ -110,6 +114,7 @@ export function resetRateLimitProviderMocks(): void { status: 'unavailable' }) vi.mocked(fetchCursorRateLimits).mockResolvedValue(unavailableProvider('cursor')) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(unavailableProvider('antigravity')) vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false) vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' }) vi.mocked(readCursorAuthSession).mockResolvedValue({ status: 'missing' }) 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 2deeadfcdaad..8c76c8c1e598 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,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index 9f2fc2c8244c..d175b4fd297d 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -3,10 +3,12 @@ import { RateLimitService } from './service' import { fetchClaudeRateLimits } from './claude-fetcher' import { fetchCodexRateLimits } from './codex-fetcher' import { fetchGeminiRateLimits } from './gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from './antigravity-fetcher' import { errorProvider, okProvider, - resetRateLimitProviderMocks + resetRateLimitProviderMocks, + unavailableProvider } from './rate-limit-service-test-harness' vi.mock('./claude-fetcher', () => ({ @@ -43,6 +45,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) @@ -55,6 +61,26 @@ vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) +function antigravityBucketsProvider(usedPercent: number): ReturnType { + return { + provider: 'antigravity', + session: null, + weekly: null, + buckets: [ + { + name: 'Gemini Weekly', + usedPercent, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + } + ], + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + describe('RateLimitService Antigravity usage', () => { beforeEach(() => { resetRateLimitProviderMocks() @@ -62,47 +88,72 @@ describe('RateLimitService Antigravity usage', () => { vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) }) - it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + it('reads its own quota independently of a failing Gemini fetch (provider isolation)', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Gemini project ID not found') ) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue(antigravityBucketsProvider(7)) const service = new RateLimitService() await service.refresh() const state = service.getState() - 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. + // Why: the real Gemini failure must never leak into the Antigravity snapshot + // (#9122/#19561) — Antigravity now has its own local-runtime fetcher. + expect(state.antigravity?.status).toBe('ok') + expect(state.antigravity?.error).toBeNull() + expect(state.antigravity?.buckets?.[0]?.usedPercent).toBe(7) 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('stays unavailable when a successful Gemini read exists but Antigravity is not running (provider isolation)', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 42, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + unavailableProvider('antigravity', 'Antigravity usage is not available.') + ) const service = new RateLimitService() await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('ok') - expect(state.antigravity?.provider).toBe('antigravity') - expect(state.antigravity?.session?.usedPercent).toBe(42) + // Why: Antigravity must never mirror Gemini's numbers — a Gemini success + // says nothing about whether the Antigravity IDE is even running. + expect(state.antigravity?.status).toBe('unavailable') + expect(state.antigravity?.buckets).toBeUndefined() + expect(state.gemini?.status).toBe('ok') }) - it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { - vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + it('surfaces its own fetch error without touching Gemini state', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValue(okProvider('gemini', 5, Date.now())) + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + errorProvider('antigravity', 'Antigravity quota request failed (HTTP 500)') + ) const service = new RateLimitService() + await service.refresh() - vi.mocked(fetchGeminiRateLimits).mockResolvedValue( - errorProvider('gemini', 'Token refresh failed') + const state = service.getState() + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.error).toBe('Antigravity quota request failed (HTTP 500)') + expect(state.gemini?.status).toBe('ok') + expect(state.gemini?.error).toBeNull() + }) + + it('publishes a fresh Antigravity reading on every cycle', async () => { + vi.mocked(fetchAntigravityRateLimits).mockResolvedValueOnce(antigravityBucketsProvider(10)) + const service = new RateLimitService() + await service.refresh() + expect(service.getState().antigravity?.buckets?.[0]?.usedPercent).toBe(10) + + vi.mocked(fetchAntigravityRateLimits).mockResolvedValue( + unavailableProvider('antigravity', 'Antigravity usage is not available.') ) await service.refresh() - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. + // Why: stale-retention would otherwise keep showing the last-known numbers + // as though the Antigravity IDE were still running. expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + expect(service.getState().antigravity?.buckets).toBeUndefined() }) }) diff --git a/src/main/rate-limits/service-cursor-usage.test.ts b/src/main/rate-limits/service-cursor-usage.test.ts index 37984b89a522..920729fa962c 100644 --- a/src/main/rate-limits/service-cursor-usage.test.ts +++ b/src/main/rate-limits/service-cursor-usage.test.ts @@ -25,6 +25,7 @@ vi.mock('./grok-fetcher', () => ({ fetchGrokRateLimits: vi.fn() })) vi.mock('./grok-auth', () => ({ readGrokAuthSession: vi.fn(() => ({ status: 'missing' })) })) vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ fetchAntigravityRateLimits: vi.fn() })) vi.mock('../minimax/minimax-cookie-store', () => ({ hasMiniMaxSessionCookie: vi.fn(() => false) })) type JwtSegment = Record 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 300292efcc21..963e464eeb18 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,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) 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 3c51a09aa5ff..a0934317523f 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,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts index 8b2253364723..f2eed60fb690 100644 --- a/src/main/rate-limits/service-minimax-usage.test.ts +++ b/src/main/rate-limits/service-minimax-usage.test.ts @@ -45,6 +45,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts index b184d51eda6a..6dc3477c9241 100644 --- a/src/main/rate-limits/service-refresh-orchestration.test.ts +++ b/src/main/rate-limits/service-refresh-orchestration.test.ts @@ -52,6 +52,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts index 7c83bd7d0260..188b8e1d397e 100644 --- a/src/main/rate-limits/service-window-activation.test.ts +++ b/src/main/rate-limits/service-window-activation.test.ts @@ -53,6 +53,10 @@ vi.mock('./cursor-fetcher', () => ({ fetchCursorRateLimits: vi.fn() })) +vi.mock('./antigravity-fetcher', () => ({ + fetchAntigravityRateLimits: vi.fn() +})) + vi.mock('./cursor-auth', () => ({ readCursorAuthSession: vi.fn() })) 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 9e9d9506a051..dfa447b3139a 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 { settleSiblingProviderResult } from './service-sibling-provider-result' import type { ProviderRateLimits } from './service-types' @@ -36,7 +35,8 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + antigravityResultPromise } = prepared if (signal.aborted) { return @@ -81,9 +81,6 @@ 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 opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -158,7 +155,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ this.trackActiveFailureStreak('codex', codex) } this.trackActiveFailureStreak('gemini', gemini) - this.trackActiveFailureStreak('antigravity', antigravity) if (shouldApplyOpencode) { this.trackActiveFailureStreak('opencode-go', opencodeGo) } @@ -185,7 +181,6 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, kimi: this.applyStalePolicy(kimi, previousState.kimi), - antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), minimax: shouldApplyMiniMax ? miniMaxConfigChanged ? miniMax @@ -193,12 +188,17 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ : this.state.minimax }) - const [grokSettled, cursorSettled] = await Promise.all([grokResultPromise, cursorResultPromise]) + const [grokSettled, cursorSettled, antigravitySettled] = await Promise.all([ + grokResultPromise, + cursorResultPromise, + antigravityResultPromise + ]) if (signal.aborted) { return } const grok = settleSiblingProviderResult('grok', grokSettled) const cursor = settleSiblingProviderResult('cursor', cursorSettled) + const antigravity = settleSiblingProviderResult('antigravity', antigravitySettled) // Why: the stale policy keeps a recent snapshot through a failed refresh, but // a snapshot belonging to a different Cursor account must not survive the // switch — the Accounts pane would name the new account beside the old @@ -212,10 +212,12 @@ export abstract class RateLimitServiceFullCycleApplication extends RateLimitServ previousCursorAccount !== cursorAccount this.trackActiveFailureStreak('grok', grok) this.trackActiveFailureStreak('cursor', cursor) + this.trackActiveFailureStreak('antigravity', antigravity) this.updateState({ ...this.state, grok: this.applyStalePolicy(grok, previousState.grok), - cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor) + cursor: cursorAccountChanged ? cursor : this.applyStalePolicy(cursor, previousState.cursor), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity) }) } } 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 df36011ab312..74112c959a8f 100644 --- a/src/main/rate-limits/service/service-full-cycle-preparation.ts +++ b/src/main/rate-limits/service/service-full-cycle-preparation.ts @@ -1,6 +1,7 @@ import { fetchClaudeRateLimits } from '../claude-fetcher' import { fetchCodexRateLimits } from '../codex-fetcher' import { fetchGeminiRateLimits } from '../gemini-usage-fetcher' +import { fetchAntigravityRateLimits } from '../antigravity-fetcher' import { fetchGrokRateLimits } from '../grok-fetcher' import { readGrokAuthSession } from '../grok-auth' import { fetchCursorRateLimits } from '../cursor-fetcher' @@ -44,6 +45,7 @@ export type FetchAllCyclePrepared = { ] grokResultPromise: Promise cursorResultPromise: Promise + antigravityResultPromise: Promise } export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServiceFetchPolicy { @@ -155,6 +157,14 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ (reason) => ({ status: 'rejected', reason }) as const ) + // Why: Antigravity reads its own local runtime (not Gemini's OAuth file), + // so it resolves on its own promise like Grok/Cursor instead of sitting in + // the main allSettled tuple. + const antigravityResultPromise = fetchAntigravityRateLimits().then( + (value) => ({ status: 'fulfilled', value }) as const, + (reason) => ({ status: 'rejected', reason }) as const + ) + // Why: skip automated Claude fetches while a Retry-After window is open or a live session feed is fresher than the OAuth poll would be. const claudeFetchGated = !options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude) @@ -231,7 +241,8 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ miniMaxResult ], grokResultPromise, - cursorResultPromise + cursorResultPromise, + antigravityResultPromise } } } diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index 3a9bc0cba485..38218cd2ae98 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -12,6 +12,7 @@ import { formatRateLimitWindowChipLabel } from '@/lib/window-label-formatter' import { formatUsagePercentageLabel } from './usage-percentage-label' import { translate } from '@/i18n/i18n' import { isCursorUsageBucket } from '../../../../shared/cursor-usage-buckets' +import { isAntigravityUsageBucket } from '../../../../shared/antigravity-usage-buckets' function MiniBar({ usedPct, @@ -97,10 +98,13 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string { // Why: Gemini exposes extra experimental buckets that made the pre-existing verbose footer noisy. const STATUS_BAR_BUCKET_NAMES = new Set(['Flash', 'Pro', '1.5 Pro']) -// Why: the allowlist above is Gemini's. Cursor's pools are its whole meter — filtering -// them out leaves a signed-in account with an icon and no number at all. +// Why: the allowlist above is Gemini's. Cursor's and Antigravity's buckets are +// their whole meter — filtering them out leaves a signed-in/running account +// with an icon and no number at all. function isVisibleStatusBarBucket(name: string): boolean { - return STATUS_BAR_BUCKET_NAMES.has(name) || isCursorUsageBucket(name) + return ( + STATUS_BAR_BUCKET_NAMES.has(name) || isCursorUsageBucket(name) || isAntigravityUsageBucket(name) + ) } function VerboseProviderUsage({ 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 b144ddbdeaea..21728265b057 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 @@ -125,11 +125,9 @@ 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 - ) + // Why: Antigravity reads its own local runtime now, so the checked item + // alone is a durable signal — it no longer depends on Gemini OAuth. + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe(true) // Why: an OPENCODE_API_KEY or a key OpenCode saved on /connect is invisible // to the renderer, so main's presence flag is the only durable signal. expect(hasUsageProviderSettings(usageSettings({ opencodeGoApiKeyConfigured: true }))).toBe(true) @@ -175,21 +173,21 @@ 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 a checked Antigravity item as its own durable signal, independent of Gemini', () => { + // Why: Antigravity reads its own local runtime (RetrieveUserQuotaSummary), + // so its visibility no longer depends on the Gemini CLI OAuth opt-in. expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) ) ).toBe(true) - // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it - // is permanently unavailable, so the checked item alone is not durable. expect( hasUsageProviderSettingsForProvider( 'antigravity', usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(false) + ).toBe(true) expect( hasUsageProviderSettingsForProvider( 'antigravity', @@ -357,11 +355,13 @@ 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 its status item is checked', () => { + // Why: Antigravity reads its own local runtime, so visibility no longer + // depends on the Gemini CLI OAuth opt-in. const visible = getVisibleUsageProvider( 'antigravity', null, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + usageSettings({ antigravityUsageConfigured: true }) ) expect(visible).toMatchObject({ provider: 'antigravity', @@ -371,26 +371,22 @@ 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 its status item is unchecked', () => { + expect(getVisibleUsageProvider('antigravity', null, usageSettings())).toBe(null) + }) + + it('surfaces an honest "not running" Antigravity snapshot instead of hiding it', () => { + const unavailable = provider('unavailable', { + provider: 'antigravity', + error: 'Antigravity usage is not available. Open the Antigravity IDE on this computer.' + }) expect( getVisibleUsageProvider( 'antigravity', - provider('unavailable', { - provider: 'antigravity', - error: 'Gemini CLI OAuth is disabled in settings' - }), + unavailable, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(null) + ).toBe(unavailable) }) }) @@ -520,27 +516,8 @@ describe('isUsageEmptyState', () => { }) it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { - expect( - isUsageEmptyState( - { - claude: provider('unavailable', { provider: 'claude' }), - codex: provider('unavailable', { provider: 'codex' }), - gemini: provider('unavailable'), - opencodeGo: provider('unavailable', { provider: 'opencode-go' }), - kimi: provider('unavailable', { provider: 'kimi' }), - antigravity: null, - grok: provider('unavailable', { provider: 'grok' }), - minimax: provider('unavailable', { provider: 'minimax' }), - cursor: provider('unavailable', { provider: 'cursor' }) - }, - usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) - ) - ).toBe(false) - }) - - it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { - // Why: the default-on Antigravity item is not configured usage on its own; - // it must not hide the teaching CTA from users who set nothing up. + // Why: Antigravity reads its own local runtime, so a checked item alone + // (no Gemini OAuth) is already a durable "usage is configured" signal. expect( isUsageEmptyState( { @@ -556,6 +533,6 @@ describe('isUsageEmptyState', () => { }, usageSettings({ antigravityUsageConfigured: true }) ) - ).toBe(true) + ).toBe(false) }) }) 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 086540781d71..f31804a450a6 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,11 +8,10 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { - // Why: Antigravity has no separate persisted usage credential in Orca. The - // checked status-bar item is the durable user signal; StatusBar only sets - // this after PATH detection says the agent is available. Durability further - // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, - // which never yields data while that opt-in is off. + // Why: Antigravity has no separate persisted usage credential in Orca — it + // reads its own local runtime (RetrieveUserQuotaSummary) instead of a + // stored token. The checked status-bar item is the durable user signal; + // StatusBar only sets this after PATH detection says the agent is available. antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean @@ -81,8 +80,7 @@ export function hasUsageProviderSettings( settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || settings?.opencodeGoApiKeyConfigured === true || - // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is - // already covered by the gemini term above. + settings?.antigravityUsageConfigured === true || settings?.minimaxCookieConfigured === true || settings?.minimaxApiKeyConfigured === true || settings?.grokAuthConfigured === true || @@ -113,10 +111,7 @@ export function hasUsageProviderSettingsForProvider( ) } 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.antigravityUsageConfigured === 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..0783ac5d31ab 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,6 @@ 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('opencode-go')).toBe('accounts-opencode-go') expect(getUsageProviderAccountsSectionId('minimax')).toBe('accounts-minimax') expect(getUsageProviderAccountsSectionId('grok')).toBe('accounts-grok') @@ -15,4 +14,8 @@ describe('getUsageProviderAccountsSectionId', () => { it('does not invent an Accounts section for CLI-owned Kimi credentials', () => { expect(getUsageProviderAccountsSectionId('kimi')).toBeNull() }) + + it('does not deep-link Antigravity into Gemini settings now that it reads its own runtime', () => { + expect(getUsageProviderAccountsSectionId('antigravity')).toBeNull() + }) }) 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 39380e135950..41dfeb4e2d99 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,11 @@ 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': + // Why: Antigravity reads its own local runtime, not an Orca-managed + // credential — there is no settings section to deep-link into. + return null 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 b7f57c7f1e7b..bb81edbbc8d2 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 @@ -103,7 +103,6 @@ export function useStatusBarController(floatingTerminalOpen: boolean) { // 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) diff --git a/src/shared/antigravity-usage-buckets.ts b/src/shared/antigravity-usage-buckets.ts new file mode 100644 index 000000000000..47c4dec24262 --- /dev/null +++ b/src/shared/antigravity-usage-buckets.ts @@ -0,0 +1,23 @@ +/** + * Antigravity's own quota (from the local runtime's `RetrieveUserQuotaSummary` + * call) groups models into two pools that each carry an independent weekly and + * five-hour limit. There is no single "session"/"weekly" pair the way most + * other providers report, so all four numbers are surfaced as named buckets + * (the same mechanism Gemini's per-model buckets and Cursor's plan pools use) + * instead of new fields on ProviderRateLimits. + */ +export const ANTIGRAVITY_GEMINI_FIVE_HOUR_BUCKET_NAME = 'Gemini 5h' +export const ANTIGRAVITY_GEMINI_WEEKLY_BUCKET_NAME = 'Gemini Weekly' +export const ANTIGRAVITY_THIRD_PARTY_FIVE_HOUR_BUCKET_NAME = 'Claude/GPT 5h' +export const ANTIGRAVITY_THIRD_PARTY_WEEKLY_BUCKET_NAME = 'Claude/GPT Weekly' + +export const ANTIGRAVITY_USAGE_BUCKET_NAMES = [ + ANTIGRAVITY_GEMINI_FIVE_HOUR_BUCKET_NAME, + ANTIGRAVITY_GEMINI_WEEKLY_BUCKET_NAME, + ANTIGRAVITY_THIRD_PARTY_FIVE_HOUR_BUCKET_NAME, + ANTIGRAVITY_THIRD_PARTY_WEEKLY_BUCKET_NAME +] as const + +export function isAntigravityUsageBucket(name: string): boolean { + return (ANTIGRAVITY_USAGE_BUCKET_NAMES as readonly string[]).includes(name) +}