Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/main/antigravity-accounts/status.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
31 changes: 31 additions & 0 deletions src/main/antigravity-accounts/status.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 6 additions & 0 deletions src/main/ipc/antigravity-accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ipcMain } from 'electron'
import { getAntigravityAccountStatus } from '../antigravity-accounts/status'

export function registerAntigravityAccountHandlers(): void {
ipcMain.handle('antigravityAccounts:getStatus', () => getAntigravityAccountStatus())
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const {
registerClaudeAccountHandlersMock,
registerMiniMaxCredentialsHandlersMock,
registerGrokAccountHandlersMock,
registerAntigravityAccountHandlersMock,
registerClipboardHandlersMock,
setTrustedClipboardRendererWebContentsIdMock,
registerUpdaterHandlersMock,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}))
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/main/ipc/register-core-handlers/register-core-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -148,6 +149,7 @@ export function registerCoreHandlers(
registerClaudeAccountHandlers(claudeAccounts)
registerMiniMaxCredentialsHandlers(rateLimits)
registerGrokAccountHandlers()
registerAntigravityAccountHandlers()
registerRateLimitHandlers(rateLimits, codexAccounts)
registerGitHubHandlers(store, stats)
registerGitLabHandlers(store)
Expand Down
53 changes: 53 additions & 0 deletions src/main/rate-limits/antigravity-cli-oauth-extractor.test.ts
Original file line number Diff line number Diff line change
@@ -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 }
])
})
})
178 changes: 178 additions & 0 deletions src/main/rate-limits/antigravity-cli-oauth-extractor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { execFile } from 'node:child_process'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct node:child_process import. src/shared/child-process/child-process-import-boundary.test.ts ratchets every direct importer: this file is not in __fixtures__/child-process-import-allowlist.txt, so the has no direct importer outside the allowlist and holds the offender count at the pin assertions both fail in CI. The existing gemini-cli-oauth-extractor.ts is grandfathered into the allowlist; a new sibling is not.

Technical details
# Route the agy binary lookup through the child-process chokepoint

## Affected sites
- src/main/rate-limits/antigravity-cli-oauth-extractor.ts:1 — `import { execFile } from 'node:child_process'` + `promisify(execFile)`
- src/shared/child-process/child-process-import-boundary.test.ts — the ratchet that fails on this import

## Required outcome
- The `which`/`where` probe in `resolveAgyBinary` goes through `runProcess` from `src/shared/child-process/run-process.ts`, which already pins `windowsHide`, `.cmd` resolution, and arg quoting. `runProcess` resolves with `{ code, stdout, stderr, ... }` and never rejects on a non-zero exit (the `which` failure case), so the existing `try/catch` around the lookup can become a plain `code !== 0` / empty-`stdout` check.

## Suggested approach (optional)
- Replace `execFileAsync(lookup, args, { encoding: 'utf-8', windowsHide: true })` with `await runProcess({ file: lookup, args })` and read `result.stdout` (a string). Note `resolveSpawn` encodes `.cmd` args, so keep the win32 `where.exe` path as-is — it is an exe, not a `.cmd`.

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<boolean> {
try {
await access(filePath)
return true
} catch {
return false
}
}

async function resolveAgyBinary(): Promise<string | null> {
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<AntigravityOAuthClient[]> {
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<AntigravityOAuthClient[]> {
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
}
Loading
Loading