From eebec16a1b1fdc223ac3557b8d290e8329a39fa3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:53:08 -0700 Subject: [PATCH 1/4] Add verified native Antigravity accounts on the owning runtime --- docs/reference/antigravity-native-accounts.md | 88 ++++++ .../antigravity/native-account-host.test.ts | 24 ++ src/main/antigravity/native-account-host.ts | 28 ++ .../antigravity/native-account-launch.test.ts | 104 +++++++ src/main/antigravity/native-account-launch.ts | 47 +++ .../native-account-service.test.ts | 159 +++++++++++ .../antigravity/native-account-service.ts | 192 +++++++++++++ .../antigravity/native-account-store.test.ts | 93 ++++++ src/main/antigravity/native-account-store.ts | 106 +++++++ .../native-account-test-fixtures.ts | 57 ++++ .../native-credential-backend.test.ts | 84 ++++++ .../antigravity/native-credential-backend.ts | 113 ++++++++ .../native-credential-codec.test.ts | 94 ++++++ .../antigravity/native-credential-codec.ts | 119 ++++++++ .../native-macos-credentials.real.test.ts | 41 +++ .../native-macos-credentials.test.ts | 94 ++++++ .../antigravity/native-macos-credentials.ts | 86 ++++++ src/main/ipc/pty/ipc/spawn-env.ts | 9 + src/main/ipc/pty/provider/local-configure.ts | 8 + src/main/ipc/pty/runtime/spawn-options.ts | 9 + .../rpc/methods/antigravity-accounts.ts | 31 ++ src/main/runtime/rpc/methods/index.ts | 2 + .../src/components/settings/AccountsPane.tsx | 11 + .../AntigravityAccountsSection.test.tsx | 117 ++++++++ .../settings/AntigravityAccountsSection.tsx | 270 ++++++++++++++++++ ...ccounts-pane-provider-setting-sections.tsx | 4 +- .../components/settings/accounts-search.ts | 12 + src/renderer/src/i18n/locales/en.json | 28 +- ...untime-antigravity-accounts-client.test.ts | 80 ++++++ .../runtime-antigravity-accounts-client.ts | 28 ++ src/shared/antigravity-account-types.ts | 25 ++ src/shared/protocol-version.ts | 2 + .../antigravity-accounts-params.ts | 15 + .../rpc-params-catalog.generated.ts | 8 + 34 files changed, 2184 insertions(+), 4 deletions(-) create mode 100644 docs/reference/antigravity-native-accounts.md create mode 100644 src/main/antigravity/native-account-host.test.ts create mode 100644 src/main/antigravity/native-account-host.ts create mode 100644 src/main/antigravity/native-account-launch.test.ts create mode 100644 src/main/antigravity/native-account-launch.ts create mode 100644 src/main/antigravity/native-account-service.test.ts create mode 100644 src/main/antigravity/native-account-service.ts create mode 100644 src/main/antigravity/native-account-store.test.ts create mode 100644 src/main/antigravity/native-account-store.ts create mode 100644 src/main/antigravity/native-account-test-fixtures.ts create mode 100644 src/main/antigravity/native-credential-backend.test.ts create mode 100644 src/main/antigravity/native-credential-backend.ts create mode 100644 src/main/antigravity/native-credential-codec.test.ts create mode 100644 src/main/antigravity/native-credential-codec.ts create mode 100644 src/main/antigravity/native-macos-credentials.real.test.ts create mode 100644 src/main/antigravity/native-macos-credentials.test.ts create mode 100644 src/main/antigravity/native-macos-credentials.ts create mode 100644 src/main/runtime/rpc/methods/antigravity-accounts.ts create mode 100644 src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx create mode 100644 src/renderer/src/components/settings/AntigravityAccountsSection.tsx create mode 100644 src/renderer/src/runtime/runtime-antigravity-accounts-client.test.ts create mode 100644 src/renderer/src/runtime/runtime-antigravity-accounts-client.ts create mode 100644 src/shared/antigravity-account-types.ts create mode 100644 src/shared/rpc-contract/antigravity-accounts-params.ts diff --git a/docs/reference/antigravity-native-accounts.md b/docs/reference/antigravity-native-accounts.md new file mode 100644 index 000000000000..27df573b6a53 --- /dev/null +++ b/docs/reference/antigravity-native-accounts.md @@ -0,0 +1,88 @@ +# Native Antigravity Accounts + +Accounts reads the credential authority on the runtime that owns execution. A client chooses +an owning Orca runtime and a host/distro target before sending an operation; it never replaces +the client's Mac Keychain item for another host. The RPC capability is +`accounts.antigravity-native.v1`. Older paired hosts are refused before account mutations. +The RPC returns account summaries only, never credential JSON, access tokens or refresh tokens. + +## Supported authority + +Normal macOS agy uses service `gemini`, account `antigravity`. Its go-keyring values use the +base64 or legacy hex wrapper. Orca passes writes through `security -i` stdin, validates bounded +output and reads the entire native value back. The command buffer limit is checked before +writing. A missing native item falls back to the CLI-specific +`~/.gemini/antigravity-cli/antigravity-oauth-token` file. The distinct legacy jetski fallback +is not imported. + +The compiled CLI bypasses keyring storage when SSH/WSL environment detectors or WSL kernel +identity apply. A runtime running under that evidenced bypass reads/writes its own CLI file; +it does not contact the client keychain. The file must be private and regular. A macOS +`cache/antigravity-keyring-unavailable` marker makes authority uncertain: Orca refuses instead +of assuming that the keychain or file wins. + +Native Windows Credential Manager, native Linux Secret Service, and operations directed from +Windows Orca to a selected WSL distro are explicitly unsupported pending verified adapters. +Windows file bypass is also refused until private ACL protection is verified. +Windows' `gemini:antigravity` raw blob and 2560-byte limit are different from the Mac wrapper; +Linux uses the login collection with `service=gemini`, `username=antigravity`. No dependency, +PowerShell compilation, credential-home flag, or cross-host fallback is invented here. +A separate SSH relay has no Accounts RPC; use a paired owning runtime that implements it. + +## Identity and snapshots + +A Google ID token supplies the normalized Google issuer and stable subject. The authentication +method also scopes identity. The label uses a verified email when available; email is never the +identity key. Account record IDs are random and survive token, expiry, refresh-token and email +rotation. Profiles without a stable subject can be displayed but cannot be saved for switching. + +Snapshots preserve the exact native JSON, including fields that Orca does not interpret. The +host's vault under `userData/antigravity-accounts/vault` requires meaningful OS encryption and +private permissions. Weak or unavailable encryption is refused. Unreadable/corrupt ciphertext +is preserved; it is never treated as an empty vault. This does not migrate the experimental +candidate's incompatible array vault or token-hash IDs. + +One host service serializes Add, Select, Remove, launch checks and refresh reconciliation. +It re-reads the vault after asynchronous native reads and captures external CLI refreshes into +the same stable account. Selection reconciles the outgoing snapshot, checks the expected native +bytes before writing, and checks native readback before publishing the selected ID. It avoids +writing an old snapshot over an already-active account. The current or selected account cannot +be removed; deletion checks the latest native value again before committing. + +A selected account is checked before new Orca PTY launches, including desktop daemon and +headless runtime paths. An externally changed native identity blocks the launch and asks the +user to select again. Existing sessions can retain their original credentials in memory. +Shell commands typed manually into a running terminal are outside the Orca launch guard. + +## Sign-in and concurrency limits + +Sign-in uses the supported ordinary agy browser/code flow. Users run agy on the owning host; +to add a different account they use its `/logout` command, complete the next sign-in, then save +the actual resulting account in Orca. This implementation does not advertise an Orca-managed +login or invent an agy `login`/`--login` flag. Browser completion and a second real Google +account remain user-driven; tests do not sign out or change the developer's real native item. + +Native keyring does not expose compare-and-swap. Orca's queue serializes its own calls, and +bounded before/after checks detect observed conflicts; another independently running agy or +Orca process can still write between the final check and the write or launch. A failed +verification may mean the native item changed but selection was not persisted. Refresh and +explicit selection resolve that state; automatic rollback could destroy a newer CLI refresh +and is deliberately avoided. The file backend has the same external-writer limit. + +## Evidence and contributor credit + +The foundation adapts the reviewed codec/macOS adapter from #21784 and account-service concepts +from #21797 (nwparker), with fresh identity, persistence, serialization and conflict handling. +The signed-in Accounts card and quota-error visibility acknowledge #19588 by @artile; quota +transport is reused from current main rather than its obsolete extraction code. Targeted +multi-account UI/target concepts acknowledge #23761 by @Tai-DT, replacing its placeholder login +and unused settings selection. The Accounts legacy-Gemini clarification acknowledges #21682 +and the original relevant migration contribution by @siddqamar, as requested in #17345. +No stale development stack was cherry-picked. + +Live proof uses a disposable Mac service/account item, a fully isolated hidden Electron home, +and synthetic accounts. A private task-only copy was also selected through the real service; +installed agy 1.2.14 consumed that verified file credential under its SSH bypass and returned +`command.name=usage`, `num_turns=0`, no conversation. The real native item remained unchanged. +This proves the Mac adapter mechanics and actual CLI file authority, not a second-account +native-keychain switch, native Windows/Linux switching, or WSL/SSH relay deployment. diff --git a/src/main/antigravity/native-account-host.test.ts b/src/main/antigravity/native-account-host.test.ts new file mode 100644 index 000000000000..6a375348fc39 --- /dev/null +++ b/src/main/antigravity/native-account-host.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import { getAntigravityAccountService } from './native-account-host' +import { createAntigravityHostCredentialBackend } from './native-credential-backend' + +vi.mock('./native-credential-backend', () => ({ createAntigravityHostCredentialBackend: vi.fn() })) + +describe('Antigravity runtime authority guard', () => { + it.each(['Ubuntu', 'Debian', null])( + 'refuses a client-selected WSL distro (%s) before resolving a host backend', + (wslDistro) => { + expect(() => getAntigravityAccountService({ runtime: 'wsl', wslDistro })).toThrow( + 'host account was not changed' + ) + expect(createAntigravityHostCredentialBackend).not.toHaveBeenCalled() + } + ) + + it('rejects a distro accidentally attached to a host target', () => { + expect(() => getAntigravityAccountService({ runtime: 'host', wslDistro: 'Ubuntu' })).toThrow( + 'not supported yet' + ) + expect(createAntigravityHostCredentialBackend).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/antigravity/native-account-host.ts b/src/main/antigravity/native-account-host.ts new file mode 100644 index 000000000000..1047cc2aa9f0 --- /dev/null +++ b/src/main/antigravity/native-account-host.ts @@ -0,0 +1,28 @@ +import { join } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import type { AntigravityAccountTarget } from '../../shared/antigravity-account-types' +import { createEncryptedAntigravityAccountStore } from './native-account-store' +import { createAntigravityHostCredentialBackend } from './native-credential-backend' +import { AntigravityAccountService } from './native-account-service' + +let service: AntigravityAccountService | null = null + +export function getAntigravityAccountVaultPath(): string { + return join(getAppEnvironment().getPath('userData'), 'antigravity-accounts', 'vault') +} + +export function getAntigravityAccountService( + target: AntigravityAccountTarget +): AntigravityAccountService { + if (target.runtime !== 'host' || target.wslDistro) { + throw new Error( + 'Antigravity account management for a client-selected WSL distro is not supported yet. Use agy inside that distro; the host account was not changed.' + ) + } + const env = getAppEnvironment() + service ??= new AntigravityAccountService( + createEncryptedAntigravityAccountStore(getAntigravityAccountVaultPath()), + createAntigravityHostCredentialBackend(env.getPath('home')) + ) + return service +} diff --git a/src/main/antigravity/native-account-launch.test.ts b/src/main/antigravity/native-account-launch.test.ts new file mode 100644 index 000000000000..fed9c20557fa --- /dev/null +++ b/src/main/antigravity/native-account-launch.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync } from 'node:fs' +import { prepareAntigravityAccountForLaunch } from './native-account-launch' +import { createEncryptedAntigravityAccountStore } from './native-account-store' +import { getAntigravityAccountService } from './native-account-host' + +const { prepareForLaunch } = vi.hoisted(() => ({ prepareForLaunch: vi.fn() })) +vi.mock('node:fs', () => ({ existsSync: vi.fn() })) +vi.mock('../../shared/app-environment', () => ({ + getAppEnvironment: () => ({ getPath: () => '/task/home' }) +})) +vi.mock('./native-account-store', () => ({ createEncryptedAntigravityAccountStore: vi.fn() })) +vi.mock('./native-account-host', () => ({ + getAntigravityAccountVaultPath: () => '/task/vault', + getAntigravityAccountService: vi.fn(() => ({ prepareForLaunch })) +})) + +beforeEach(() => { + vi.mocked(existsSync).mockReset().mockReturnValue(true) + vi.mocked(getAntigravityAccountService).mockClear() + vi.mocked(createEncryptedAntigravityAccountStore).mockReturnValue({ + read: () => ({ accounts: [], selectedAccountId: 'selected' }), + write: vi.fn() + }) + prepareForLaunch.mockReset().mockResolvedValue(undefined) +}) + +describe('native account verification before agy launch', () => { + it('checks the selected native account before a new host agy launch', async () => { + await prepareAntigravityAccountForLaunch({ + launchAgent: 'antigravity', + env: { HOME: '/task/home' } + }) + expect(prepareForLaunch).toHaveBeenCalledOnce() + }) + + it('recognizes the ordinary agy executable through the shared command recognizer', async () => { + await prepareAntigravityAccountForLaunch({ + command: 'agy --conversation synthetic', + env: { HOME: '/task/home' } + }) + expect(prepareForLaunch).toHaveBeenCalledOnce() + }) + + it('preserves owning-host and distro boundaries without reading client credentials', async () => { + await prepareAntigravityAccountForLaunch({ + launchAgent: 'antigravity', + connectionId: 'ssh-owner' + }) + await prepareAntigravityAccountForLaunch({ launchAgent: 'antigravity', isWsl: true }) + await prepareAntigravityAccountForLaunch({ launchAgent: 'codex' }) + expect(existsSync).not.toHaveBeenCalled() + expect(getAntigravityAccountService).not.toHaveBeenCalled() + }) + + it('does not read native credentials when no account selection exists', async () => { + vi.mocked(existsSync).mockReturnValue(false) + await prepareAntigravityAccountForLaunch({ launchAgent: 'antigravity' }) + expect(getAntigravityAccountService).not.toHaveBeenCalled() + }) + + it('blocks a launch using an overridden credential home without silently replacing its account', async () => { + await expect( + prepareAntigravityAccountForLaunch({ + launchAgent: 'antigravity', + env: { HOME: '/other/authority' } + }) + ).rejects.toThrow('different credential authority') + expect(prepareForLaunch).not.toHaveBeenCalled() + }) + + it('propagates failed native identity verification so the PTY cannot launch as another account', async () => { + prepareForLaunch.mockRejectedValue(new Error('native identity changed')) + await expect( + prepareAntigravityAccountForLaunch({ + launchAgent: 'antigravity', + env: { HOME: '/task/home' } + }) + ).rejects.toThrow('native identity changed') + }) + + it('rejects deleting the credential home before launch', async () => { + await expect( + prepareAntigravityAccountForLaunch({ launchAgent: 'antigravity', envToDelete: ['HOME'] }) + ).rejects.toThrow('different credential authority') + expect(prepareForLaunch).not.toHaveBeenCalled() + }) + + it('does not merge removed authority detectors back into a complete launch environment', async () => { + vi.stubEnv('SSH_CLIENT', 'task-host') + try { + await expect( + prepareAntigravityAccountForLaunch({ + launchAgent: 'antigravity', + env: { HOME: '/task/home' }, + envIsComplete: true + }) + ).rejects.toThrow('different credential authority') + expect(prepareForLaunch).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/main/antigravity/native-account-launch.ts b/src/main/antigravity/native-account-launch.ts new file mode 100644 index 000000000000..ba74f4fd7646 --- /dev/null +++ b/src/main/antigravity/native-account-launch.ts @@ -0,0 +1,47 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { getAppEnvironment } from '../../shared/app-environment' +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { isAntigravityFileStorageHost } from './native-credential-backend' +import { createEncryptedAntigravityAccountStore } from './native-account-store' +import { getAntigravityAccountService, getAntigravityAccountVaultPath } from './native-account-host' + +export async function prepareAntigravityAccountForLaunch(args: { + launchAgent?: string + command?: string + connectionId?: string | null + isWsl?: boolean + env?: NodeJS.ProcessEnv + envIsComplete?: boolean + envToDelete?: readonly string[] +}): Promise { + const agent = + args.launchAgent ?? + (args.command ? recognizeAgentProcessFromCommandLine(args.command)?.agent : null) + // Client snapshots never select accounts for a relay or a client-selected distro. + if (agent !== 'antigravity' || args.connectionId || args.isWsl) { + return + } + const path = getAntigravityAccountVaultPath() + if (!existsSync(path)) { + return + } + if (!createEncryptedAntigravityAccountStore(path).read().selectedAccountId) { + return + } + const env = args.envIsComplete ? { ...args.env } : { ...process.env, ...args.env } + for (const key of args.envToDelete ?? []) { + delete env[key] + } + const home = env.HOME ?? env.USERPROFILE + if ( + args.envToDelete?.some((key) => ['HOME', 'USERPROFILE'].includes(key)) || + (home && resolve(home) !== resolve(getAppEnvironment().getPath('home'))) || + isAntigravityFileStorageHost(env) !== isAntigravityFileStorageHost(process.env) + ) { + throw new Error( + 'This agy launch uses a different credential authority from the selected Antigravity account.' + ) + } + await getAntigravityAccountService({ runtime: 'host' }).prepareForLaunch() +} diff --git a/src/main/antigravity/native-account-service.test.ts b/src/main/antigravity/native-account-service.test.ts new file mode 100644 index 000000000000..17d4c908901e --- /dev/null +++ b/src/main/antigravity/native-account-service.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' +import { AntigravityAccountService } from './native-account-service' +import { parseAntigravityNativeCredential } from './native-credential-codec' +import { credential, harness } from './native-account-test-fixtures' + +describe('Antigravity native account identity and selection', () => { + it('keeps one stable account through access, refresh, expiry, ID-token and email rotation', async () => { + const h = harness() + const first = await h.service.addCurrentAccount() + const id = first.activeAccountId + const updated = credential('a', 2, 'renamed@example.invalid') + h.setNative(updated) + const refreshed = await h.service.listAccounts() + expect(refreshed.accounts).toHaveLength(1) + expect(refreshed.activeAccountId).toBe(id) + expect(refreshed.accounts[0].email).toBe('renamed@example.invalid') + expect(h.getVault().accounts[0].credentials).toBe(updated) + expect((await h.service.addCurrentAccount()).accounts).toHaveLength(1) + expect(JSON.stringify(refreshed)).not.toContain('synthetic-2') + expect(JSON.stringify(refreshed)).not.toContain('refresh-2') + }) + + it('reconciles before switching away, and never restores a stale token on selecting the active account', async () => { + const h = harness() + const a = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('b')) + const b = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('b', 2)) + await h.service.selectAccount(b) + expect(h.backend.write).not.toHaveBeenCalled() + expect(h.getVault().accounts.find((account) => account.id === b)?.credentials).toBe( + credential('b', 2) + ) + await h.service.selectAccount(a) + expect(h.getNative()).toBe(credential('a')) + await h.service.selectAccount(b) + expect(h.getNative()).toBe(credential('b', 2)) + }) + + it('persists selection and verifies it across service restart before launch', async () => { + const h = harness() + const id = (await h.service.addCurrentAccount()).activeAccountId! + await h.service.selectAccount(id) + const restarted = new AntigravityAccountService(h.store, h.backend) + await restarted.prepareForLaunch() + h.setNative(credential('another')) + const state = await restarted.listAccounts() + expect(state.selectedAccountId).toBe(id) + expect(state.activeAccountId).toBeNull() + await expect(restarted.prepareForLaunch()).rejects.toThrow('native Antigravity account changed') + expect(h.backend.write).not.toHaveBeenCalled() + }) + + it('protects the current and selected account against deletion, including after external sign-out', async () => { + const h = harness() + const id = (await h.service.addCurrentAccount()).activeAccountId! + await expect(h.service.removeAccount(id)).rejects.toThrow('Select another') + await h.service.selectAccount(id) + h.setNative(null) + await expect(h.service.removeAccount(id)).rejects.toThrow('Select another') + expect(h.getVault().accounts).toHaveLength(1) + }) + + it('does not fabricate a stable identity from rotating secrets or an email', async () => { + const h = harness( + JSON.stringify({ + auth_method: 'consumer', + email: 'a@example.invalid', + token: { access_token: 'unknown' } + }) + ) + expect((await h.service.listAccounts()).currentAccount).toEqual({ + email: null, + subject: null, + authMethod: 'consumer', + identityKnown: false + }) + await expect(h.service.addCurrentAccount()).rejects.toThrow('no stable Google identity') + expect(h.getVault().accounts).toHaveLength(0) + }) + + it('fails on native conflict without publishing a selection, and recovers the mutation queue', async () => { + const h = harness() + const id = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('b')) + vi.mocked(h.backend.write).mockImplementationOnce(async () => { + throw new Error('native conflict') + }) + await expect(h.service.selectAccount(id)).rejects.toThrow('native conflict') + expect(h.getVault().selectedAccountId).toBeNull() + expect((await h.service.listAccounts()).currentAccount?.identityKnown).toBe(true) + }) + + it('does not trust write success when the native readback differs', async () => { + const h = harness() + const id = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('b')) + vi.mocked(h.backend.write).mockResolvedValueOnce() + await expect(h.service.selectAccount(id)).rejects.toThrow('could not be verified') + expect(h.getVault().selectedAccountId).toBeNull() + }) + + it('serializes concurrent add, remove, select and external refresh reconciliation', async () => { + const h = harness() + const a = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('b')) + const b = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('c')) + const gate = Promise.withResolvers() + vi.mocked(h.backend.read).mockImplementationOnce(async () => { + await gate.promise + return parseAntigravityNativeCredential(credential('c')) + }) + const add = h.service.addCurrentAccount() + const remove = h.service.removeAccount(a) + const select = h.service.selectAccount(b) + const refresh = h.service.listAccounts() + gate.resolve() + const results = await Promise.all([add, remove, select, refresh]) + expect(results[3].accounts).toHaveLength(2) + expect(results[3].accounts.some((account) => account.subject === 'c')).toBe(true) + expect(results[3].activeAccountId).toBe(b) + expect(h.getVault().accounts.some((account) => account.id === a)).toBe(false) + }) + + it('reads the latest vault after a native await so another persisted entry is not lost', async () => { + const h = harness() + await h.service.addCurrentAccount() + const original = h.getVault() + const gate = Promise.withResolvers() + vi.mocked(h.backend.read).mockImplementationOnce(async () => { + await gate.promise + return parseAntigravityNativeCredential(credential('a', 2)) + }) + const listing = h.service.listAccounts() + await Promise.resolve() + original.accounts.push({ + ...original.accounts[0], + id: 'other', + subject: 'b', + credentials: credential('b') + }) + h.store.write(original) + gate.resolve() + expect((await listing).accounts).toHaveLength(2) + expect(h.getVault().accounts[0].credentials).toBe(credential('a', 2)) + }) + + it('guards deletion when an external CLI selects the account during the final native check', async () => { + const h = harness() + const id = (await h.service.addCurrentAccount()).activeAccountId! + h.setNative(credential('b')) + vi.mocked(h.backend.read) + .mockResolvedValueOnce(parseAntigravityNativeCredential(credential('b'))) + .mockResolvedValueOnce(parseAntigravityNativeCredential(credential('a', 2))) + await expect(h.service.removeAccount(id)).rejects.toThrow('Select another') + expect(h.getVault().accounts).toHaveLength(1) + }) +}) diff --git a/src/main/antigravity/native-account-service.ts b/src/main/antigravity/native-account-service.ts new file mode 100644 index 000000000000..b615b074b01c --- /dev/null +++ b/src/main/antigravity/native-account-service.ts @@ -0,0 +1,192 @@ +import { randomUUID } from 'node:crypto' +import type { AntigravityAccountState } from '../../shared/antigravity-account-types' +import { + parseAntigravityNativeCredential, + type AntigravityNativeCredential +} from './native-credential-codec' +import type { + AntigravityAccountStore, + AntigravityAccountVault, + StoredAntigravityAccount +} from './native-account-store' + +export type AntigravityCredentialBackend = { + read(): Promise + write(contents: string, expected: string | null): Promise +} + +function matches(account: StoredAntigravityAccount, current: AntigravityNativeCredential): boolean { + if (current.identity && account.subject) { + return account.subject === current.identity.subject && account.authMethod === current.authMethod + } + return account.credentials === current.contents +} + +export class AntigravityAccountService { + private mutation: Promise = Promise.resolve() + + constructor( + private readonly store: AntigravityAccountStore, + private readonly backend: AntigravityCredentialBackend, + private readonly now: () => number = Date.now + ) {} + + listAccounts(): Promise { + return this.serialize(async () => this.state(await this.reconcile())) + } + + addCurrentAccount(): Promise { + return this.serialize(async () => { + const { vault, current } = await this.reconcile() + if (!current) { + throw new Error('Sign in with agy on this execution host, then save the current account.') + } + if (!current.identity) { + throw new Error( + 'The current Antigravity credential has no stable Google identity; it cannot be saved for switching.' + ) + } + let account = vault.accounts.find((entry) => matches(entry, current)) + if (!account) { + const timestamp = this.now() + account = { + id: randomUUID(), + email: current.identity.email, + subject: current.identity.subject, + authMethod: current.authMethod, + credentials: current.contents, + createdAt: timestamp, + updatedAt: timestamp + } + vault.accounts.push(account) + this.store.write(vault) + } + return this.state({ vault, current }) + }) + } + + selectAccount(id: string): Promise { + return this.serialize(async () => { + const { vault, current } = await this.reconcile() + const selected = vault.accounts.find((account) => account.id === id) + if (!selected) { + throw new Error('Antigravity account was not found.') + } + parseAntigravityNativeCredential(selected.credentials) + if (!current || !matches(selected, current)) { + await this.backend.write(selected.credentials, current?.contents ?? null) + } + const readback = await this.backend.read() + if (!readback || !matches(selected, readback)) { + throw new Error( + 'Antigravity account switching could not be verified; refresh before retrying.' + ) + } + const latest = this.store.read() + if (!latest.accounts.some((account) => account.id === id && matches(account, readback))) { + throw new Error('Antigravity snapshots changed during selection; refresh before retrying.') + } + latest.selectedAccountId = id + this.updateSnapshot(latest, readback) + this.store.write(latest) + return this.state({ vault: latest, current: readback }) + }) + } + + removeAccount(id: string): Promise { + return this.serialize(async () => { + const { vault, current } = await this.reconcile() + const account = vault.accounts.find((entry) => entry.id === id) + if (!account) { + throw new Error('Antigravity account was not found.') + } + if (vault.selectedAccountId === id || (current && matches(account, current))) { + throw new Error('Select another Antigravity account before removing this account.') + } + const readback = await this.backend.read() + const latest = this.store.read() + const latestAccount = latest.accounts.find((entry) => entry.id === id) + if (!latestAccount) { + throw new Error('Antigravity account snapshots changed; refresh before retrying.') + } + if (latest.selectedAccountId === id || (readback && matches(latestAccount, readback))) { + throw new Error('Select another Antigravity account before removing this account.') + } + if (readback) { + this.updateSnapshot(latest, readback) + } + latest.accounts = latest.accounts.filter((entry) => entry.id !== id) + this.store.write(latest) + return this.state({ vault: latest, current: readback }) + }) + } + + prepareForLaunch(): Promise { + return this.serialize(async () => { + const { vault, current } = await this.reconcile() + if (!vault.selectedAccountId) { + return + } + const selected = vault.accounts.find((account) => account.id === vault.selectedAccountId) + if (!selected || !current || !matches(selected, current)) { + throw new Error( + 'The native Antigravity account changed. Select the account again in Accounts before launching agy.' + ) + } + }) + } + + private async reconcile() { + const current = await this.backend.read() + // Re-read after native I/O so a delayed read never restores an older vault. + const vault = this.store.read() + if (current && this.updateSnapshot(vault, current)) { + this.store.write(vault) + } + return { vault, current } + } + + private updateSnapshot( + vault: AntigravityAccountVault, + current: AntigravityNativeCredential + ): boolean { + const account = vault.accounts.find((entry) => matches(entry, current)) + if (!account || account.credentials === current.contents) { + return false + } + account.credentials = current.contents + account.email = current.identity?.email ?? account.email + account.updatedAt = this.now() + return true + } + + private state({ + vault, + current + }: { + vault: AntigravityAccountVault + current: AntigravityNativeCredential | null + }): AntigravityAccountState { + return { + accounts: vault.accounts.map(({ credentials: _credentials, ...account }) => account), + activeAccountId: current + ? (vault.accounts.find((entry) => matches(entry, current))?.id ?? null) + : null, + selectedAccountId: vault.selectedAccountId, + currentAccount: current + ? { + email: current.identity?.email ?? null, + subject: current.identity?.subject ?? null, + authMethod: current.authMethod, + identityKnown: current.identity !== null + } + : null + } + } + + private serialize(operation: () => Promise): Promise { + const next = this.mutation.then(operation, operation) + this.mutation = next.catch(() => undefined) + return next + } +} diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts new file mode 100644 index 000000000000..d7fd92518afc --- /dev/null +++ b/src/main/antigravity/native-account-store.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store' +import { createEncryptedAntigravityAccountStore } from './native-account-store' +import { credential, harness } from './native-account-test-fixtures' + +let dir: string +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-agy-vault-test-')) + setSecretStore({ + isEncryptionAvailable: () => true, + describeProtectionGap: () => null, + encryptString: (value) => Buffer.from(`sealed:${Buffer.from(value).toString('base64')}`), + decryptString: (value) => { + if (!value.toString().startsWith('sealed:')) { + throw new Error('synthetic decrypt failure') + } + return Buffer.from(value.toString().slice(7), 'base64').toString() + } + }) +}) +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + _resetSecretStoreForTests() +}) + +describe('protected Antigravity account snapshots', () => { + it('persists a selected account with exact provider fields and private permissions across restart', async () => { + const h = harness() + const state = await h.service.addCurrentAccount() + const vault = h.getVault() + vault.selectedAccountId = state.activeAccountId + const path = join(dir, 'accounts', 'vault') + createEncryptedAntigravityAccountStore(path).write(vault) + expect(readFileSync(path, 'utf8')).not.toContain('synthetic-1') + if (process.platform !== 'win32') { + expect(statSync(path).mode & 0o077).toBe(0) + } + expect(createEncryptedAntigravityAccountStore(path).read()).toEqual(vault) + expect(createEncryptedAntigravityAccountStore(path).read().accounts[0].credentials).toBe( + credential('a') + ) + }) + + it.each([false, true])( + 'refuses unavailable or weak encryption without changing saved bytes (available=%s)', + async (available) => { + const h = harness() + await h.service.addCurrentAccount() + const path = join(dir, 'vault') + const store = createEncryptedAntigravityAccountStore(path) + store.write(h.getVault()) + const before = readFileSync(path) + setSecretStore({ + isEncryptionAvailable: () => available, + describeProtectionGap: () => 'unprotected', + encryptString: () => { + throw new Error('must not encrypt') + }, + decryptString: () => { + throw new Error('must not decrypt') + } + }) + expect(() => store.write({ accounts: [], selectedAccountId: null })).toThrow( + 'Protected secret storage' + ) + expect(() => store.read()).toThrow('Protected secret storage') + expect(readFileSync(path)).toEqual(before) + } + ) + + it('keeps an unreadable vault intact rather than treating it as empty', () => { + const path = join(dir, 'vault') + writeFileSync(path, 'broken ciphertext', { mode: 0o600 }) + expect(() => createEncryptedAntigravityAccountStore(path).read()).toThrow('preserved') + expect(readFileSync(path, 'utf8')).toBe('broken ciphertext') + }) + + it.skipIf(process.platform === 'win32')( + 'refuses a broadly readable persisted snapshot', + async () => { + const h = harness() + await h.service.addCurrentAccount() + const path = join(dir, 'vault') + const store = createEncryptedAntigravityAccountStore(path) + store.write(h.getVault()) + chmodSync(path, 0o644) + expect(() => store.read()).toThrow('preserved') + } + ) +}) diff --git a/src/main/antigravity/native-account-store.ts b/src/main/antigravity/native-account-store.ts new file mode 100644 index 000000000000..9c062033d514 --- /dev/null +++ b/src/main/antigravity/native-account-store.ts @@ -0,0 +1,106 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, statSync } from 'node:fs' +import { dirname } from 'node:path' +import { getSecretStore } from '../../shared/secret-store' +import { writeCredentialFileAtomic } from '../integration-credential-file' +import type { AntigravityAccountSummary } from '../../shared/antigravity-account-types' +import { parseAntigravityNativeCredential } from './native-credential-codec' + +export type StoredAntigravityAccount = AntigravityAccountSummary & { credentials: string } +export type AntigravityAccountVault = { + accounts: StoredAntigravityAccount[] + selectedAccountId: string | null +} +export type AntigravityAccountStore = { + read(): AntigravityAccountVault + write(vault: AntigravityAccountVault): void +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isAccount(value: unknown): value is StoredAntigravityAccount { + return ( + isRecord(value) && + typeof value.id === 'string' && + value.id.length > 0 && + (value.email === null || typeof value.email === 'string') && + (value.subject === null || typeof value.subject === 'string') && + typeof value.authMethod === 'string' && + typeof value.createdAt === 'number' && + typeof value.updatedAt === 'number' && + typeof value.credentials === 'string' + ) +} + +function requireProtection(): void { + const secrets = getSecretStore() + if (!secrets.isEncryptionAvailable() || secrets.describeProtectionGap() !== null) { + throw new Error( + 'Protected secret storage is unavailable; Antigravity accounts were not changed.' + ) + } +} + +export function createEncryptedAntigravityAccountStore(path: string): AntigravityAccountStore { + return { + read() { + if (!existsSync(path)) { + return { accounts: [], selectedAccountId: null } + } + requireProtection() + try { + const stat = lstatSync(path) + if ( + !stat.isFile() || + stat.size > 4 * 1024 * 1024 || + (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) + ) { + throw new Error('unsafe vault') + } + const value: unknown = JSON.parse(getSecretStore().decryptString(readFileSync(path))) + if ( + !isRecord(value) || + !Array.isArray(value.accounts) || + !value.accounts.every(isAccount) || + (value.selectedAccountId !== null && typeof value.selectedAccountId !== 'string') + ) { + throw new Error('invalid vault') + } + const ids = new Set(value.accounts.map((account) => account.id)) + if ( + ids.size !== value.accounts.length || + (value.selectedAccountId !== null && !ids.has(value.selectedAccountId)) + ) { + throw new Error('invalid selection') + } + for (const account of value.accounts) { + const credential = parseAntigravityNativeCredential(account.credentials) + if ( + credential.authMethod !== account.authMethod || + credential.identity?.subject !== account.subject + ) { + throw new Error('inconsistent identity') + } + } + return { accounts: value.accounts, selectedAccountId: value.selectedAccountId } + } catch { + throw new Error( + 'Antigravity account snapshots could not be read; the existing vault was preserved.' + ) + } + }, + write(vault) { + requireProtection() + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + try { + writeCredentialFileAtomic(path, getSecretStore().encryptString(JSON.stringify(vault))) + if (process.platform !== 'win32' && (statSync(path).mode & 0o077) !== 0) { + throw new Error('unsafe permissions') + } + } catch { + throw new Error('Antigravity account snapshots could not be saved.') + } + } + } +} diff --git a/src/main/antigravity/native-account-test-fixtures.ts b/src/main/antigravity/native-account-test-fixtures.ts new file mode 100644 index 000000000000..d8bc7f4694da --- /dev/null +++ b/src/main/antigravity/native-account-test-fixtures.ts @@ -0,0 +1,57 @@ +import { vi } from 'vitest' +import { + AntigravityAccountService, + type AntigravityCredentialBackend +} from './native-account-service' +import type { AntigravityAccountStore, AntigravityAccountVault } from './native-account-store' +import { parseAntigravityNativeCredential } from './native-credential-codec' + +export function credential(subject: string, generation = 1, email = `${subject}@example.invalid`) { + const claims = { + iss: 'https://accounts.google.com', + sub: subject, + email, + email_verified: true, + iat: generation + } + return JSON.stringify({ + auth_method: 'consumer', + id_token: `e30.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.synthetic`, + token: { + access_token: `synthetic-${generation}`, + refresh_token: `refresh-${generation}`, + expiry: generation + }, + future_field: { generation, preserved: true } + }) +} + +export function harness(initial: string | null = credential('a')) { + let native = initial + let vault: AntigravityAccountVault = { accounts: [], selectedAccountId: null } + const store: AntigravityAccountStore = { + read: vi.fn(() => structuredClone(vault)), + write: vi.fn((next) => { + vault = structuredClone(next) + }) + } + const backend: AntigravityCredentialBackend = { + read: vi.fn(async () => (native ? parseAntigravityNativeCredential(native) : null)), + write: vi.fn(async (contents, expected) => { + if (native !== expected) { + throw new Error('native conflict') + } + native = contents + }) + } + return { + store, + backend, + service: new AntigravityAccountService(store, backend), + setNative: (contents: string | null) => { + native = contents + }, + getNative: () => native, + getVault: () => structuredClone(vault) + } +} diff --git a/src/main/antigravity/native-credential-backend.test.ts b/src/main/antigravity/native-credential-backend.test.ts new file mode 100644 index 000000000000..b1fd466af7fb --- /dev/null +++ b/src/main/antigravity/native-credential-backend.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { credential } from './native-account-test-fixtures' +import { + createAntigravityFileCredentialBackend, + createAntigravityHostCredentialBackend, + isAntigravityFileStorageHost +} from './native-credential-backend' + +vi.mock('./native-macos-credentials', () => ({ + readAntigravityMacOSCredential: vi.fn(), + writeAntigravityMacOSCredential: vi.fn() +})) +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +describe('execution-host native credential authority', () => { + it('performs a real isolated file write/readback and detects a stale before-write credential', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-agy-backend-test-')) + try { + const path = join(dir, '.gemini', 'antigravity-cli', 'antigravity-oauth-token') + const backend = createAntigravityFileCredentialBackend(path) + await backend.write(credential('a'), null) + expect((await backend.read())?.identity?.subject).toBe('a') + await backend.write(credential('b'), credential('a')) + expect(readFileSync(path, 'utf8')).toBe(credential('b')) + await expect(backend.write(credential('a'), credential('a'))).rejects.toThrow( + 'changed during selection' + ) + expect(readFileSync(path, 'utf8')).toBe(credential('b')) + if (process.platform !== 'win32') { + expect(statSync(path).mode & 0o077).toBe(0) + chmodSync(path, 0o644) + await expect(backend.read()).rejects.toThrow('read safely') + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it.each(['SSH_TTY', 'SSH_CLIENT', 'SSH_CONNECTION', 'WSL_DISTRO_NAME', 'WSL_INTEROP'])( + 'uses the owning-host file bypass detector %s', + (key) => { + expect(isAntigravityFileStorageHost({ [key]: 'present' })).toBe(true) + expect(isAntigravityFileStorageHost({ [key]: '' })).toBe(false) + } + ) + + it('recognizes WSL kernel evidence and never guesses file mode from Linux alone', () => { + expect(isAntigravityFileStorageHost({}, '6.6-microsoft-standard-WSL2')).toBe(true) + expect(isAntigravityFileStorageHost({}, '6.8-linux')).toBe(false) + }) + + it('refuses Windows file bypass until private ACL protection is verified', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + vi.stubEnv('SSH_CLIENT', 'task-host') + expect(() => createAntigravityHostCredentialBackend('/task-home')).toThrow( + 'private file permissions need a verified adapter' + ) + }) + + it.each(['win32', 'linux'] as const)( + 'capability-refuses unverified native %s without mutating client credentials', + (platform) => { + for (const key of [ + 'SSH_TTY', + 'SSH_CLIENT', + 'SSH_CONNECTION', + 'WSL_DISTRO_NAME', + 'WSL_INTEROP' + ]) { + vi.stubEnv(key, '') + } + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + expect(() => createAntigravityHostCredentialBackend('/task-home')).toThrow( + 'not supported on this host yet' + ) + } + ) +}) diff --git a/src/main/antigravity/native-credential-backend.ts b/src/main/antigravity/native-credential-backend.ts new file mode 100644 index 000000000000..2dc430ef0da8 --- /dev/null +++ b/src/main/antigravity/native-credential-backend.ts @@ -0,0 +1,113 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { writeCredentialFileAtomic } from '../integration-credential-file' +import { parseAntigravityNativeCredential } from './native-credential-codec' +import { + readAntigravityMacOSCredential, + writeAntigravityMacOSCredential +} from './native-macos-credentials' +import type { AntigravityCredentialBackend } from './native-account-service' + +const CONFLICT = + 'The native Antigravity credential changed during selection; refresh before retrying.' + +export function isAntigravityFileStorageHost(env: NodeJS.ProcessEnv, kernelRelease = ''): boolean { + return ( + ['SSH_TTY', 'SSH_CLIENT', 'SSH_CONNECTION', 'WSL_DISTRO_NAME', 'WSL_INTEROP'].some((name) => + Boolean(env[name]) + ) || /microsoft|wsl/i.test(kernelRelease) + ) +} + +export function createAntigravityFileCredentialBackend(path: string): AntigravityCredentialBackend { + async function read() { + if (!existsSync(path)) { + return null + } + try { + const stat = lstatSync(path) + if ( + !stat.isFile() || + stat.size > 64 * 1024 || + (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) + ) { + throw new Error('unsafe credential file') + } + return parseAntigravityNativeCredential(readFileSync(path, 'utf8')) + } catch { + throw new Error('The Antigravity execution-host credential file could not be read safely.') + } + } + return { + read, + async write(contents, expected) { + parseAntigravityNativeCredential(contents) + if ((await read())?.contents !== (expected ?? undefined)) { + throw new Error(CONFLICT) + } + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) + writeCredentialFileAtomic(path, Buffer.from(contents, 'utf8')) + if ((await read())?.contents !== contents) { + throw new Error(CONFLICT) + } + } + } +} + +export function createAntigravityHostCredentialBackend(home: string): AntigravityCredentialBackend { + if (process.platform === 'win32') { + throw new Error( + 'Native Antigravity account switching is not supported on this host yet. Windows credential storage and private file permissions need a verified adapter.' + ) + } + const root = join(home, '.gemini', 'antigravity-cli') + const file = createAntigravityFileCredentialBackend(join(root, 'antigravity-oauth-token')) + let kernelRelease = '' + if (process.platform === 'linux') { + try { + kernelRelease = readFileSync('/proc/sys/kernel/osrelease', 'utf8') + } catch { + /* Environment detectors remain authoritative. */ + } + } + if (isAntigravityFileStorageHost(process.env, kernelRelease)) { + return file + } + if (process.platform !== 'darwin') { + throw new Error( + 'Native Antigravity account switching is not supported on this host yet. Windows Credential Manager and Linux Secret Service need a verified adapter.' + ) + } + const marker = join(root, 'cache', 'antigravity-keyring-unavailable') + async function read() { + if (existsSync(marker)) { + throw new Error( + 'Antigravity has a keyring fallback marker; Orca cannot verify which credential store agy will use.' + ) + } + return (await readAntigravityMacOSCredential()) ?? (await file.read()) + } + return { + read, + async write(contents, expected) { + if ((await read())?.contents !== (expected ?? undefined)) { + throw new Error(CONFLICT) + } + const native = await readAntigravityMacOSCredential() + if (existsSync(marker)) { + throw new Error(CONFLICT) + } + if (native) { + if (native.contents !== expected) { + throw new Error(CONFLICT) + } + await writeAntigravityMacOSCredential(contents) + } else { + await file.write(contents, expected) + } + if ((await read())?.contents !== contents) { + throw new Error(CONFLICT) + } + } + } +} diff --git a/src/main/antigravity/native-credential-codec.test.ts b/src/main/antigravity/native-credential-codec.test.ts new file mode 100644 index 000000000000..30ce78230a53 --- /dev/null +++ b/src/main/antigravity/native-credential-codec.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { + decodeAntigravityKeychainValue, + encodeAntigravityKeychainValue, + parseAntigravityNativeCredential +} from './native-credential-codec' + +function credential(claims: Record = {}): string { + return JSON.stringify({ + token: { access_token: 'synthetic-access', refresh_token: 'synthetic-refresh' }, + auth_method: 'consumer', + id_token: `e30.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.synthetic`, + future_field: { preserved: true } + }) +} + +describe('native Antigravity credential format', () => { + it('reads the installed macOS go-keyring wrapper and preserves the entire native blob', () => { + const contents = credential({ + iss: 'https://accounts.google.com', + sub: 'synthetic-subject', + email: 'test@example.invalid', + email_verified: true + }) + const decoded = decodeAntigravityKeychainValue( + ` ${encodeAntigravityKeychainValue(contents)}\n` + ) + expect(parseAntigravityNativeCredential(decoded)).toEqual({ + contents, + authMethod: 'consumer', + identity: { + issuer: 'https://accounts.google.com', + subject: 'synthetic-subject', + email: 'test@example.invalid' + } + }) + }) + + it('accepts older hex wrappers, plain JSON, and UTF-8 account labels', () => { + const contents = credential({ name: 'テスト' }) + const hex = `go-keyring-encoded:${Buffer.from(contents).toString('hex').toUpperCase()}` + expect(decodeAntigravityKeychainValue(hex)).toBe(contents) + expect(decodeAntigravityKeychainValue(contents)).toBe(contents) + expect(decodeAntigravityKeychainValue(encodeAntigravityKeychainValue(contents))).toBe(contents) + }) + + it.each([ + 'go-keyring-base64:', + 'go-keyring-base64:e30=secret', + 'go-keyring-base64:!!!!', + 'go-keyring-base64:/w==', + 'go-keyring-encoded:0', + 'go-keyring-encoded:zz', + 'go-keyring-encoded:ff', + 'x'.repeat(65537) + ])('rejects corrupt keyring encodings without echoing their contents (%#)', (value) => { + expect(() => decodeAntigravityKeychainValue(value)).toThrow( + 'Antigravity credentials could not be decoded.' + ) + }) + + it.each(['synthetic-secret', 'null', '[]', '{}', '{"token":{"access_token":""}}'])( + 'rejects malformed credential records with a fixed, secret-free error (%#)', + (contents) => { + expect(() => parseAntigravityNativeCredential(contents)).toThrow( + 'Antigravity credentials could not be decoded.' + ) + } + ) + + it('does not borrow identity when native claims are missing or from another issuer', () => { + expect(parseAntigravityNativeCredential(credential()).identity).toBeNull() + expect( + parseAntigravityNativeCredential(credential({ iss: 'other', sub: 'same' })).identity + ).toBeNull() + }) + + it('does not present an unverified email as the signed-in account', () => { + expect( + parseAntigravityNativeCredential( + credential({ iss: 'accounts.google.com', sub: 'synthetic', email: 'test@example.invalid' }) + ).identity + ).toEqual({ issuer: 'https://accounts.google.com', subject: 'synthetic', email: null }) + }) + + it('retains credentials without an ID token, expiry or refresh token', () => { + const contents = '{ "auth_method": "future", "token": { "access_token": "synthetic" } }' + expect(parseAntigravityNativeCredential(contents)).toEqual({ + contents, + authMethod: 'future', + identity: null + }) + }) +}) diff --git a/src/main/antigravity/native-credential-codec.ts b/src/main/antigravity/native-credential-codec.ts new file mode 100644 index 000000000000..7bc01d402924 --- /dev/null +++ b/src/main/antigravity/native-credential-codec.ts @@ -0,0 +1,119 @@ +const MAX_CREDENTIAL_BYTES = 64 * 1024 + +function invalidCredential(): Error { + return new Error('Antigravity credentials could not be decoded.') +} + +function decodeUtf8(bytes: Buffer): string { + const text = bytes.toString('utf8') + if (!Buffer.from(text, 'utf8').equals(bytes)) { + throw invalidCredential() + } + return text +} + +export function decodeAntigravityKeychainValue(value: string): string { + if (Buffer.byteLength(value) > MAX_CREDENTIAL_BYTES) { + throw invalidCredential() + } + const trimmed = value.trim() + if (trimmed.startsWith('go-keyring-base64:')) { + const encoded = trimmed.slice('go-keyring-base64:'.length) + const bytes = Buffer.from(encoded, 'base64') + if (!encoded || bytes.toString('base64') !== encoded) { + throw invalidCredential() + } + return decodeUtf8(bytes) + } + if (trimmed.startsWith('go-keyring-encoded:')) { + const encoded = trimmed.slice('go-keyring-encoded:'.length) + if (!/^(?:[\da-f]{2})+$/i.test(encoded)) { + throw invalidCredential() + } + return decodeUtf8(Buffer.from(encoded, 'hex')) + } + return trimmed +} + +export function encodeAntigravityKeychainValue(contents: string): string { + if (Buffer.byteLength(contents) > MAX_CREDENTIAL_BYTES) { + throw invalidCredential() + } + return `go-keyring-base64:${Buffer.from(contents, 'utf8').toString('base64')}` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export type AntigravityCredentialIdentity = { + issuer: 'https://accounts.google.com' + subject: string + email: string | null +} + +// These claims label a locally stored account; they never authorize an operation. +function readIdentity(idToken: unknown): AntigravityCredentialIdentity | null { + if (typeof idToken !== 'string') { + return null + } + const parts = idToken.split('.') + if (parts.length !== 3 || !/^[\w-]+$/.test(parts[1])) { + return null + } + try { + const claims: unknown = JSON.parse(decodeUtf8(Buffer.from(parts[1], 'base64url'))) + if ( + !isRecord(claims) || + !['accounts.google.com', 'https://accounts.google.com'].includes(String(claims.iss)) || + typeof claims.sub !== 'string' || + !claims.sub.trim() + ) { + return null + } + return { + issuer: 'https://accounts.google.com', + subject: claims.sub, + email: + claims.email_verified === true && typeof claims.email === 'string' && claims.email.trim() + ? claims.email + : null + } + } catch { + return null + } +} + +export type AntigravityNativeCredential = { + contents: string + authMethod: string + identity: AntigravityCredentialIdentity | null +} + +export function parseAntigravityNativeCredential(contents: string): AntigravityNativeCredential { + if (Buffer.byteLength(contents) > MAX_CREDENTIAL_BYTES) { + throw invalidCredential() + } + let value: unknown + try { + value = JSON.parse(contents) + } catch { + throw invalidCredential() + } + if ( + !isRecord(value) || + typeof value.auth_method !== 'string' || + !value.auth_method.trim() || + !isRecord(value.token) || + typeof value.token.access_token !== 'string' || + !value.token.access_token.trim() + ) { + throw invalidCredential() + } + return { + // Keep the exact native blob: a CLI upgrade may depend on fields Orca does not know. + contents, + authMethod: value.auth_method, + identity: readIdentity(value.id_token ?? value.token.id_token) + } +} diff --git a/src/main/antigravity/native-macos-credentials.real.test.ts b/src/main/antigravity/native-macos-credentials.real.test.ts new file mode 100644 index 000000000000..46ff6ab044a6 --- /dev/null +++ b/src/main/antigravity/native-macos-credentials.real.test.ts @@ -0,0 +1,41 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { credential } from './native-account-test-fixtures' +import { + readAntigravityMacOSCredential, + writeAntigravityMacOSCredential +} from './native-macos-credentials' + +const enabled = + process.platform === 'darwin' && process.env.ORCA_REAL_AGY_NATIVE_BACKEND_TEST === '1' + +describe.skipIf(!enabled)('isolated macOS native credential backend', () => { + it('writes and reads two complete profiles in a disposable item without changing agy authority', async () => { + const target = { + service: `orca-antigravity-backend-test.${randomUUID()}`, + account: 'task-only' + } + expect(target.service).not.toBe('gemini') + expect(target.account).not.toBe('antigravity') + expect(await readAntigravityMacOSCredential(target)).toBeNull() + try { + await writeAntigravityMacOSCredential(credential('synthetic-a'), target) + expect((await readAntigravityMacOSCredential(target))?.contents).toBe( + credential('synthetic-a') + ) + await writeAntigravityMacOSCredential(credential('synthetic-b', 2), target) + expect((await readAntigravityMacOSCredential(target))?.contents).toBe( + credential('synthetic-b', 2) + ) + } finally { + const removed = await runProcess({ + program: '/usr/bin/security', + args: ['delete-generic-password', '-s', target.service, '-a', target.account], + timeoutMs: 3000 + }) + expect(removed.code).toBe(0) + } + expect(await readAntigravityMacOSCredential(target)).toBeNull() + }, 30_000) +}) diff --git a/src/main/antigravity/native-macos-credentials.test.ts b/src/main/antigravity/native-macos-credentials.test.ts new file mode 100644 index 000000000000..32f70f579511 --- /dev/null +++ b/src/main/antigravity/native-macos-credentials.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, type ProcessResult } from '../../shared/child-process/run-process' +import { encodeAntigravityKeychainValue } from './native-credential-codec' +import { + readAntigravityMacOSCredential, + writeAntigravityMacOSCredential +} from './native-macos-credentials' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) + +const contents = JSON.stringify({ auth_method: 'consumer', token: { access_token: 'synthetic' } }) +const success: ProcessResult = { + code: 0, + signal: null, + stdout: encodeAntigravityKeychainValue(contents), + stderr: '', + timedOut: false +} + +beforeEach(() => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + vi.mocked(runProcess).mockReset().mockResolvedValue(success) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe('Antigravity macOS credential access', () => { + it('reads only the native agy item', async () => { + expect((await readAntigravityMacOSCredential())?.contents).toBe(contents) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ + program: '/usr/bin/security', + args: ['find-generic-password', '-s', 'gemini', '-a', 'antigravity', '-w'], + timeoutMs: 3000 + }) + ) + }) + + it('distinguishes a missing item from denied, timed-out or clipped reads', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ ...success, code: 44, stdout: '' }) + expect(await readAntigravityMacOSCredential()).toBeNull() + for (const failure of [{ code: 36 }, { timedOut: true }, { outputTruncated: true }]) { + vi.mocked(runProcess).mockResolvedValueOnce({ ...success, ...failure }) + await expect(readAntigravityMacOSCredential()).rejects.toThrow('could not be read') + } + }) + + it('never exposes child-process exception output', async () => { + vi.mocked(runProcess).mockRejectedValue(new Error('synthetic-secret')) + await expect(readAntigravityMacOSCredential()).rejects.toThrow( + 'The Antigravity macOS credential store could not be accessed.' + ) + }) + + it('writes credentials through stdin and verifies the complete native blob', async () => { + await writeAntigravityMacOSCredential(contents) + expect(runProcess).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + args: ['-i'], + input: `add-generic-password -U -s "gemini" -a "antigravity" -w ${encodeAntigravityKeychainValue(contents)}\n` + }) + ) + expect(runProcess).toHaveBeenCalledTimes(2) + }) + + it('does not claim a successful switch when readback differs', async () => { + vi.mocked(runProcess) + .mockResolvedValueOnce(success) + .mockResolvedValueOnce({ + ...success, + stdout: encodeAntigravityKeychainValue(contents.replace('synthetic', 'another')) + }) + await expect(writeAntigravityMacOSCredential(contents)).rejects.toThrow('could not be verified') + }) + + it('rejects oversized writes before touching the active login', async () => { + const large = contents.replace('synthetic', 'x'.repeat(4096)) + await expect(writeAntigravityMacOSCredential(large)).rejects.toThrow('command limit') + expect(runProcess).not.toHaveBeenCalled() + }) + + it.each(['win32', 'linux'] as const)( + 'does not answer for a different host (%s)', + async (platform) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + await expect(readAntigravityMacOSCredential()).rejects.toThrow('unavailable on this host') + await expect(writeAntigravityMacOSCredential(contents)).rejects.toThrow( + 'unavailable on this host' + ) + expect(runProcess).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/antigravity/native-macos-credentials.ts b/src/main/antigravity/native-macos-credentials.ts new file mode 100644 index 000000000000..2b57959be4b7 --- /dev/null +++ b/src/main/antigravity/native-macos-credentials.ts @@ -0,0 +1,86 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { + decodeAntigravityKeychainValue, + encodeAntigravityKeychainValue, + parseAntigravityNativeCredential, + type AntigravityNativeCredential +} from './native-credential-codec' + +function requireMacOS(): void { + if (process.platform !== 'darwin') { + throw new Error('The Antigravity macOS credential store is unavailable on this host.') + } +} + +async function runSecurity(args: string[], input?: string) { + try { + return await runProcess({ + program: '/usr/bin/security', + args, + input, + timeoutMs: 3000, + maxOutputBytes: 64 * 1024 + }) + } catch { + // Child-process errors may embed credential-bearing output. + throw new Error('The Antigravity macOS credential store could not be accessed.') + } +} + +export type AntigravityMacOSCredentialTarget = { + service: string + account: string + keychainPath?: string +} +const nativeTarget: AntigravityMacOSCredentialTarget = { service: 'gemini', account: 'antigravity' } + +export async function readAntigravityMacOSCredential( + target = nativeTarget +): Promise { + requireMacOS() + const result = await runSecurity([ + 'find-generic-password', + '-s', + target.service, + '-a', + target.account, + '-w', + ...(target.keychainPath ? [target.keychainPath] : []) + ]) + if (!result.timedOut && !result.signal && !result.outputTruncated) { + if (result.code === 44) { + return null + } + if (result.code === 0) { + return parseAntigravityNativeCredential(decodeAntigravityKeychainValue(result.stdout)) + } + } + throw new Error('The Antigravity macOS credential store could not be read.') +} + +export async function writeAntigravityMacOSCredential( + contents: string, + target = nativeTarget +): Promise { + requireMacOS() + parseAntigravityNativeCredential(contents) + const encoded = encodeAntigravityKeychainValue(contents) + const values = [target.service, target.account, target.keychainPath ?? ''] + if (values.some((value) => /[\r\n"\\]/.test(value))) { + throw new Error('The Antigravity macOS credential target is invalid.') + } + const keychain = target.keychainPath ? ` "${target.keychainPath}"` : '' + const input = `add-generic-password -U -s "${target.service}" -a "${target.account}" -w ${encoded}${keychain}\n` + // security's interactive command buffer is limited; never risk a truncated credential write. + if (Buffer.byteLength(input) > 4096) { + throw new Error('Antigravity credentials exceed the macOS credential command limit.') + } + const result = await runSecurity(['-i'], input) + if (result.code !== 0 || result.timedOut || result.signal || result.outputTruncated) { + throw new Error('The Antigravity macOS credential store could not be updated.') + } + const actual = await readAntigravityMacOSCredential(target) + if (actual?.contents !== contents) { + throw new Error('The Antigravity macOS credential update could not be verified.') + } +} diff --git a/src/main/ipc/pty/ipc/spawn-env.ts b/src/main/ipc/pty/ipc/spawn-env.ts index 94f1acf363e1..9ca00a873b7f 100644 --- a/src/main/ipc/pty/ipc/spawn-env.ts +++ b/src/main/ipc/pty/ipc/spawn-env.ts @@ -20,6 +20,7 @@ import { parseValidPaneKey } from '../pane/key-state' import { shouldRefreshNativeClaudeAgentTeamsEnv } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { assemblePtyIpcSpawnCodexEnv } from './spawn-env-codex' +import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise { const args = ctx.args @@ -143,4 +144,12 @@ export async function assemblePtyIpcSpawnEnv(ctx: PtyIpcSpawnState): Promise isPwshAvailableAsync(), buildSpawnEnv: async (id, baseEnv, ctx) => { + await prepareAntigravityAccountForLaunch({ + launchAgent: ctx?.launchAgent, + command: ctx?.command, + isWsl: ctx?.isWsl, + env: baseEnv, + envIsComplete: true + }) const codexSelectionTarget: CodexAccountSelectionTarget = ctx?.isWsl === true ? { runtime: 'wsl', wslDistro: ctx.wslDistro ?? null } diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364d..d413c0556f09 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -34,6 +34,7 @@ import { } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareAntigravityAccountForLaunch } from '../../../antigravity/native-account-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -102,6 +103,14 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + await prepareAntigravityAccountForLaunch({ + launchAgent: args.launchAgent, + command: ctx.launchCommand, + connectionId: args.connectionId, + isWsl: ctx.codexSelectionTarget.runtime === 'wsl', + env: ctx.env, + envToDelete: ctx.spawnOptions.envToDelete + }) promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/runtime/rpc/methods/antigravity-accounts.ts b/src/main/runtime/rpc/methods/antigravity-accounts.ts new file mode 100644 index 000000000000..18d6205c6523 --- /dev/null +++ b/src/main/runtime/rpc/methods/antigravity-accounts.ts @@ -0,0 +1,31 @@ +import { defineMethod } from '../core' +import { + AntigravityAccountMutationParams, + AntigravityAccountTargetParams +} from '../../../../shared/rpc-contract/antigravity-accounts-params' +import { getAntigravityAccountService } from '../../../antigravity/native-account-host' + +export const ANTIGRAVITY_ACCOUNT_METHODS = [ + defineMethod({ + name: 'accounts.antigravityList', + params: AntigravityAccountTargetParams, + handler: async (target) => getAntigravityAccountService(target).listAccounts() + }), + defineMethod({ + name: 'accounts.antigravityAddCurrent', + params: AntigravityAccountTargetParams, + handler: async (target) => getAntigravityAccountService(target).addCurrentAccount() + }), + defineMethod({ + name: 'accounts.antigravitySelect', + params: AntigravityAccountMutationParams, + handler: async ({ target, accountId }) => + getAntigravityAccountService(target).selectAccount(accountId) + }), + defineMethod({ + name: 'accounts.antigravityRemove', + params: AntigravityAccountMutationParams, + handler: async ({ target, accountId }) => + getAntigravityAccountService(target).removeAccount(accountId) + }) +] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index cbf6e0d8e0c4..3376d9bf994c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -17,6 +17,7 @@ import { NOTIFICATION_METHODS } from './notifications' import { STATS_METHODS } from './stats' import { DIAGNOSTICS_METHODS } from './diagnostics' import { ACCOUNT_METHODS } from './accounts' +import { ANTIGRAVITY_ACCOUNT_METHODS } from './antigravity-accounts' import { PREFLIGHT_METHODS } from './preflight' import { COMPUTER_METHODS } from './computer' import { SESSION_TAB_METHODS } from './session-tabs' @@ -77,6 +78,7 @@ export const ALL_RPC_METHODS = [ ...STATS_METHODS, ...DIAGNOSTICS_METHODS, ...ACCOUNT_METHODS, + ...ANTIGRAVITY_ACCOUNT_METHODS, ...PREFLIGHT_METHODS, ...COMPUTER_METHODS, ...SESSION_TAB_METHODS, diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 1e479ab7e25a..634131a3849c 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -21,6 +21,7 @@ import { getAccountsGeminiSearchEntries, getAccountsCursorSearchEntries, getAccountsGrokSearchEntries, + getAccountsAntigravitySearchEntries, getAccountsLocationSearchEntries, getAccountsMiniMaxSearchEntries, getAccountsOpencodeSearchEntries, @@ -38,6 +39,8 @@ import { providerAccountMatchesView } from './provider-account-visibility' import { GrokAccountsSection } from './GrokAccountsSection' +import { AntigravityAccountsSection } from './AntigravityAccountsSection' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { CursorAccountsSection } from './CursorAccountsSection' import type { AccountsPaneProps, @@ -384,6 +387,14 @@ 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..6cf3788148b8 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx @@ -0,0 +1,117 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AntigravityAccountsSection } from './AntigravityAccountsSection' +import { callAntigravityAccounts } from '@/runtime/runtime-antigravity-accounts-client' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { AntigravityAccountState } from '../../../../shared/antigravity-account-types' + +vi.mock('@/runtime/runtime-antigravity-accounts-client', () => ({ + callAntigravityAccounts: vi.fn() +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) + +const state: AntigravityAccountState = { + accounts: [ + { + id: 'a', + email: 'synthetic@example.invalid', + subject: 'google-subject', + authMethod: 'consumer', + createdAt: 1, + updatedAt: 2 + } + ], + activeAccountId: 'a', + selectedAccountId: 'a', + currentAccount: { + email: 'synthetic@example.invalid', + subject: 'google-subject', + authMethod: 'consumer', + identityKnown: true + } +} +const owner = { kind: 'local' } as const +const target = { runtime: 'host' } as const + +beforeEach(() => { + vi.mocked(callAntigravityAccounts).mockReset().mockResolvedValue(state) + vi.mocked(callRuntimeRpc).mockReset() +}) + +afterEach(() => cleanup()) + +describe('native Antigravity Accounts', () => { + it('shows the native identity and supported CLI sign-in instructions without inventing a login', async () => { + render() + await screen.findByText('Native account') + expect(screen.getAllByText('synthetic@example.invalid')).toHaveLength(2) + expect(screen.getByText(/use \/logout in agy/)).toBeTruthy() + expect(screen.getByRole('link', { name: 'Sign-in instructions' }).getAttribute('href')).toBe( + 'https://antigravity.google/docs/cli/install/' + ) + expect(screen.getByRole('button', { name: 'Remove' }).hasAttribute('disabled')).toBe(true) + }) + + it('keeps account identity visible when quota refresh fails', async () => { + vi.mocked(callRuntimeRpc).mockRejectedValue(new Error('Quota is unavailable')) + render() + await screen.findByText('Native account') + await userEvent.click(screen.getByRole('button', { name: 'Refresh usage' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Quota is unavailable') + expect(screen.getByText('Native account')).toBeTruthy() + expect(screen.getAllByText('synthetic@example.invalid')).toHaveLength(2) + }) + + it('forwards the selected owning host and distro, and displays refusal without local fallback', async () => { + vi.mocked(callAntigravityAccounts).mockRejectedValue(new Error('Unsupported owning distro')) + render( + + ) + expect(await screen.findByRole('alert')).toHaveTextContent('Unsupported owning distro') + expect(callAntigravityAccounts).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'remote' }, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + 'List' + ) + expect(screen.queryByRole('button', { name: 'Save current account' })).toBeNull() + }) + + it('disables account controls synchronously during a selection and never claims success on conflict', async () => { + const deferred = Promise.withResolvers() + render() + await screen.findByText('Native account') + vi.mocked(callAntigravityAccounts).mockReturnValueOnce(deferred.promise) + await userEvent.click(screen.getByRole('button', { name: 'Selected' })) + expect( + screen.getByRole('button', { name: 'Save current account' }).hasAttribute('disabled') + ).toBe(true) + deferred.reject(new Error('Native conflict')) + expect(await screen.findByRole('alert')).toHaveTextContent('Native conflict') + await waitFor(() => + expect( + screen.getByRole('button', { name: 'Save current account' }).hasAttribute('disabled') + ).toBe(false) + ) + }) + + it('re-reads native identity after an unsuccessful selection rather than retaining stale native state', async () => { + render() + await screen.findByText('Native account') + vi.mocked(callAntigravityAccounts) + .mockRejectedValueOnce(new Error('Readback failed')) + .mockResolvedValueOnce({ ...state, activeAccountId: null, currentAccount: null }) + await userEvent.click(screen.getByRole('button', { name: 'Selected' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Readback failed') + expect(screen.queryByText('Native account')).toBeNull() + expect(screen.getByText(/The native account changed/)).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx new file mode 100644 index 000000000000..63f9ef078746 --- /dev/null +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -0,0 +1,270 @@ +import { useEffect, useRef, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { AgentIcon } from '@/lib/agent-catalog' +import { callAntigravityAccounts } from '@/runtime/runtime-antigravity-accounts-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import type { + AntigravityAccountState, + AntigravityAccountTarget +} from '../../../../shared/antigravity-account-types' +import type { ProviderRateLimits, RateLimitState } from '../../../../shared/rate-limit-types' +import { Button } from '../ui/button' +import { Badge } from '../ui/badge' + +export function AntigravityAccountsSection({ + owner, + target, + label +}: { + owner: RuntimeClientTarget + target: AntigravityAccountTarget + label: string +}): React.JSX.Element { + const [state, setState] = useState(null) + const [usage, setUsage] = useState(null) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + const pending = useRef(false) + const mounted = useRef(true) + const ownerKind = owner.kind + const environmentId = owner.kind === 'environment' ? owner.environmentId : null + const runtime = target.runtime + const wslDistro = target.wslDistro ?? null + + useEffect(() => { + mounted.current = true + let cancelled = false + const currentOwner: RuntimeClientTarget = + ownerKind === 'environment' && environmentId + ? { kind: 'environment', environmentId } + : { kind: 'local' } + void callAntigravityAccounts(currentOwner, { runtime, wslDistro }, 'List').then( + (next) => { + if (!cancelled) { + setState(next) + } + }, + (cause: unknown) => { + if (!cancelled) { + setError( + cause instanceof Error ? cause.message : 'Antigravity accounts could not be loaded.' + ) + } + } + ) + return () => { + cancelled = true + mounted.current = false + } + }, [ownerKind, environmentId, runtime, wslDistro]) + + async function run( + action: 'List' | 'AddCurrent' | 'Select' | 'Remove' | 'Usage', + accountId?: string + ) { + if (pending.current) { + return + } + pending.current = true + setBusy(true) + setError(null) + try { + if (action === 'Usage') { + const before = await callAntigravityAccounts(owner, target, 'List') + const snapshot = await callRuntimeRpc<{ rateLimits: RateLimitState }>( + owner, + 'accounts.list', + { refreshUsage: true } + ) + const after = await callAntigravityAccounts(owner, target, 'List') + if (mounted.current) { + setState(after) + } + if ( + !before.currentAccount?.subject || + before.currentAccount.subject !== after.currentAccount?.subject || + before.currentAccount.authMethod !== after.currentAccount.authMethod + ) { + throw new Error('The native account changed while reading usage. Refresh usage again.') + } + if (mounted.current) { + setUsage(snapshot.rateLimits.antigravity) + } + } else { + if (action === 'Select') { + setUsage(null) + } + const next = await callAntigravityAccounts(owner, target, action, accountId) + if (mounted.current) { + setState(next) + } + } + } catch (cause) { + if (action === 'Select' || action === 'Remove' || action === 'AddCurrent') { + try { + const observed = await callAntigravityAccounts(owner, target, 'List') + if (mounted.current) { + setState(observed) + } + } catch { + if (mounted.current) { + setState(null) + } + } + } + if (mounted.current) { + setError(cause instanceof Error ? cause.message : 'Antigravity account action failed.') + } + } finally { + pending.current = false + if (mounted.current) { + setBusy(false) + } + } + } + + return ( +
+
+

+ + Antigravity +

+

+ {translate('accounts.antigravity.scope', 'Manage the native agy account on {{host}}.', { + host: label + })} +

+
+

+ {translate( + 'accounts.antigravity.signIn', + 'Start agy on this host and complete its browser sign-in, then save the current account. To add a different account, use /logout in agy and sign in again.' + )}{' '} + + {translate('accounts.antigravity.docs', 'Sign-in instructions')} + +

+ {error && ( +

+ {error} +

+ )} + {!state && error && ( + + )} + {state && ( +
+

+ {state.currentAccount + ? (state.currentAccount.email ?? + translate('accounts.antigravity.identityUnknown', 'Signed-in identity unavailable')) + : translate('accounts.antigravity.signedOut', 'No native agy account is signed in.')} +

+ {state.selectedAccountId && state.activeAccountId !== state.selectedAccountId && ( +

+ {translate( + 'accounts.antigravity.changed', + 'The native account changed. Select a saved account again before launching agy.' + )} +

+ )} +
+ + + + {busy && ( + + )} +
+ {usage && ( +

+ {usage.error ?? + translate( + 'accounts.antigravity.usageReading', + 'Session: {{session}} · Weekly: {{weekly}}', + { + session: usage.session ? `${Math.round(usage.session.usedPercent)}%` : '—', + weekly: usage.weekly ? `${Math.round(usage.weekly.usedPercent)}%` : '—' + } + )} +

+ )} + {state.accounts.map((account) => ( +
+
+

+ {account.email ?? translate('accounts.antigravity.saved', 'Saved Google account')} +

+ {state.activeAccountId === account.id && ( + + {translate('accounts.antigravity.nativeActive', 'Native account')} + + )} +
+
+ + +
+
+ ))} +
+ )} +

+ {translate( + 'accounts.antigravity.sessions', + 'Selection applies to new agy sessions on this host. Existing sessions may keep their previous account.' + )} +

+
+ ) +} diff --git a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx index 61428c5cd3b3..4111bfe10622 100644 --- a/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx +++ b/src/renderer/src/components/settings/accounts-pane-provider-setting-sections.tsx @@ -15,12 +15,12 @@ export function renderGeminiAccountsSection(model: AccountsPaneSectionModel): Re

- {translate('auto.components.settings.AccountsPane.0c64dc2a64', 'Gemini')} + {translate('auto.components.settings.AccountsPane.0c64dc2a64', 'Gemini CLI (legacy)')}

{translate( 'auto.components.settings.AccountsPane.973741a871', - 'Configure Gemini provider settings.' + 'Keep existing Gemini CLI sessions available. Gemini credentials do not sign you into Antigravity.' )}

diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 60fa795caa99..44394f5c6466 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -217,6 +217,17 @@ export const getAccountsGrokSearchEntries = createLocalizedCatalog(() => [ } ]) +export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => [ + { + title: translate('accounts.antigravity.searchTitle', 'Antigravity Accounts'), + description: translate( + 'accounts.antigravity.searchDescription', + 'Save and select native agy Google accounts on the execution host.' + ), + keywords: ['antigravity', 'agy', 'google', 'accounts', 'login', 'usage'] + } +]) + export const getAccountsCursorSearchEntries = createLocalizedCatalog(() => [ { title: translate('auto.components.settings.accounts.search.cursor.title', 'Cursor Usage'), @@ -257,5 +268,6 @@ export const getAccountsPaneSearchEntries = createLocalizedCatalog((): SettingsS ...getAccountsOpencodeSearchEntries(), ...getAccountsMiniMaxSearchEntries(), ...getAccountsGrokSearchEntries(), + ...getAccountsAntigravitySearchEntries(), ...getAccountsCursorSearchEntries() ]) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f29e560803a..56b922ed834c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -6610,8 +6610,8 @@ "96f3649526": "Use Gemini CLI credentials (experimental)", "d676c41fc6": "Extracts OAuth credentials from your local Gemini CLI installation to authenticate with Google. This uses credentials issued to the Gemini CLI app, not Orca. May break if Google updates the CLI. Use at your own risk.", "0c7f915b01": "Use Gemini CLI credentials", - "973741a871": "Configure Gemini provider settings.", - "0c64dc2a64": "Gemini", + "973741a871": "Keep existing Gemini CLI sessions available. Gemini credentials do not sign you into Antigravity.", + "0c64dc2a64": "Gemini CLI (legacy)", "db209ee572": "Remove", "8a0f870153": "Re-authenticate", "3d245ef7d9": "Codex reported this sign-in is out of date", @@ -18742,5 +18742,29 @@ "addTypedPath": "Add", "noDirectories": "No folders added yet.", "removePath": "Remove {{name}}" + }, + "accounts": { + "antigravity": { + "scope": "Manage the native agy account on {{host}}.", + "signIn": "Start agy on this host and complete its browser sign-in, then save the current account. To add a different account, use /logout in agy and sign in again.", + "docs": "Sign-in instructions", + "retry": "Retry", + "identityUnknown": "Signed-in identity unavailable", + "signedOut": "No native agy account is signed in.", + "changed": "The native account changed. Select a saved account again before launching agy.", + "save": "Save current account", + "refresh": "Refresh accounts", + "usage": "Refresh usage", + "working": "Updating account", + "usageReading": "Session: {{session}} · Weekly: {{weekly}}", + "saved": "Saved Google account", + "nativeActive": "Native account", + "selected": "Selected", + "select": "Select", + "remove": "Remove", + "sessions": "Selection applies to new agy sessions on this host. Existing sessions may keep their previous account.", + "searchTitle": "Antigravity Accounts", + "searchDescription": "Save and select native agy Google accounts on the execution host." + } } } diff --git a/src/renderer/src/runtime/runtime-antigravity-accounts-client.test.ts b/src/renderer/src/runtime/runtime-antigravity-accounts-client.test.ts new file mode 100644 index 000000000000..75afc4f84259 --- /dev/null +++ b/src/renderer/src/runtime/runtime-antigravity-accounts-client.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { callAntigravityAccounts } from './runtime-antigravity-accounts-client' +import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client' + +vi.mock('./runtime-rpc-client', () => ({ + assertRuntimeEnvironmentCapability: vi.fn(), + callRuntimeRpc: vi.fn() +})) +beforeEach(() => { + vi.mocked(assertRuntimeEnvironmentCapability).mockReset().mockResolvedValue() + vi.mocked(callRuntimeRpc).mockReset().mockResolvedValue({ accounts: [] }) +}) + +describe('Antigravity account execution-host routing', () => { + it('refuses an old remote host before any account operation reaches a local or remote store', async () => { + vi.mocked(assertRuntimeEnvironmentCapability).mockRejectedValue( + new Error('old host unsupported') + ) + await expect( + callAntigravityAccounts( + { kind: 'environment', environmentId: 'host-a' }, + { runtime: 'host' }, + 'Select', + 'account-a' + ) + ).rejects.toThrow('old host unsupported') + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('sends selection only to the specified owning host and includes the exact distro', async () => { + await callAntigravityAccounts( + { kind: 'environment', environmentId: 'host-b' }, + { runtime: 'wsl', wslDistro: 'Ubuntu' }, + 'Select', + 'account-b' + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'host-b' }, + 'accounts.antigravitySelect', + { target: { runtime: 'wsl', wslDistro: 'Ubuntu' }, accountId: 'account-b' }, + { timeoutMs: 20_000 } + ) + }) + + it('does not silently switch to the client store when the owning host rejects selection', async () => { + vi.mocked(callRuntimeRpc).mockRejectedValue(new Error('host unavailable')) + await expect( + callAntigravityAccounts( + { kind: 'environment', environmentId: 'host-a' }, + { runtime: 'host' }, + 'Select', + 'account-a' + ) + ).rejects.toThrow('host unavailable') + expect(callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + + it('keeps host and distro lists separate and never drops the target', async () => { + await callAntigravityAccounts({ kind: 'local' }, { runtime: 'host' }, 'List') + await callAntigravityAccounts( + { kind: 'local' }, + { runtime: 'wsl', wslDistro: 'Debian' }, + 'AddCurrent' + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 1, + { kind: 'local' }, + 'accounts.antigravityList', + { runtime: 'host' }, + { timeoutMs: 20_000 } + ) + expect(callRuntimeRpc).toHaveBeenNthCalledWith( + 2, + { kind: 'local' }, + 'accounts.antigravityAddCurrent', + { runtime: 'wsl', wslDistro: 'Debian' }, + { timeoutMs: 20_000 } + ) + }) +}) diff --git a/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts b/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts new file mode 100644 index 000000000000..c3c926452e9e --- /dev/null +++ b/src/renderer/src/runtime/runtime-antigravity-accounts-client.ts @@ -0,0 +1,28 @@ +import type { + AntigravityAccountState, + AntigravityAccountTarget +} from '../../../shared/antigravity-account-types' +import { ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeClientTarget } from './runtime-client-target' +import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client' + +export async function callAntigravityAccounts( + owner: RuntimeClientTarget, + target: AntigravityAccountTarget, + action: 'List' | 'AddCurrent' | 'Select' | 'Remove', + accountId?: string +): Promise { + if (owner.kind === 'environment') { + await assertRuntimeEnvironmentCapability( + owner.environmentId, + ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, + 'This execution host does not support native Antigravity Accounts yet. Update Orca on that host.' + ) + } + return callRuntimeRpc( + owner, + `accounts.antigravity${action}`, + action === 'List' || action === 'AddCurrent' ? target : { target, accountId }, + { timeoutMs: 20_000 } + ) +} diff --git a/src/shared/antigravity-account-types.ts b/src/shared/antigravity-account-types.ts new file mode 100644 index 000000000000..328d4e088df7 --- /dev/null +++ b/src/shared/antigravity-account-types.ts @@ -0,0 +1,25 @@ +export type AntigravityAccountSummary = { + id: string + email: string | null + subject: string | null + authMethod: string + createdAt: number + updatedAt: number +} + +export type AntigravityAccountState = { + accounts: AntigravityAccountSummary[] + activeAccountId: string | null + currentAccount: { + email: string | null + subject: string | null + authMethod: string + identityKnown: boolean + } | null + selectedAccountId: string | null +} + +export type AntigravityAccountTarget = { + runtime: 'host' | 'wsl' + wslDistro?: string | null +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5d3217f00f3d..7bf67847621f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -130,6 +130,7 @@ export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY = 'worktree.archive-failure-blocking.v1' as const export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const +export const ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY = 'accounts.antigravity-native.v1' as const // Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised. export const TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY = 'terminal.create-idempotency.v2' as const @@ -424,6 +425,7 @@ export const RUNTIME_CAPABILITIES = [ WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, + ANTIGRAVITY_ACCOUNTS_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY, SKILL_INSTALL_CAPABILITY, SKILL_BUNDLE_INSTALL_CAPABILITY, diff --git a/src/shared/rpc-contract/antigravity-accounts-params.ts b/src/shared/rpc-contract/antigravity-accounts-params.ts new file mode 100644 index 000000000000..da63e6f8ffe1 --- /dev/null +++ b/src/shared/rpc-contract/antigravity-accounts-params.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +export const AntigravityAccountTargetParams = z + .object({ + runtime: z.enum(['host', 'wsl']), + wslDistro: z.string().min(1).max(255).nullable().optional() + }) + .strict() + +export const AntigravityAccountMutationParams = z + .object({ + target: AntigravityAccountTargetParams, + accountId: z.string().min(1).max(128) + }) + .strict() diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 9b6680b08c28..0a4f0b649694 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -42,6 +42,10 @@ import { AiVaultPrepareSessionResumeParams, AiVaultSessionTitlesParams } from './ai-vault-params' +import { + AntigravityAccountMutationParams, + AntigravityAccountTargetParams +} from './antigravity-accounts-params' import { ArtifactsDeleteParams, ListOptions, SourceRequest, WriteRequest } from './artifacts-params' import { AutomationCreate, @@ -558,6 +562,10 @@ import { export const RPC_PARAMS_BY_METHOD = { 'accounts.addClaudeFromConfigDir': AddClaudeFromConfigDirParams, 'accounts.addCodexFromHome': AddCodexFromHomeParams, + 'accounts.antigravityAddCurrent': AntigravityAccountTargetParams, + 'accounts.antigravityList': AntigravityAccountTargetParams, + 'accounts.antigravityRemove': AntigravityAccountMutationParams, + 'accounts.antigravitySelect': AntigravityAccountMutationParams, 'accounts.consumeCodexResetCredit': ConsumeCodexResetCreditParams, 'accounts.list': ListAccountsParams, 'accounts.removeClaude': RemoveAccountParams, From c568c9256e86220716db74d912d21816a651a19b Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:05:06 -0700 Subject: [PATCH 2/4] Keep Antigravity usage tied to its observed native account --- docs/reference/antigravity-native-accounts.md | 2 + .../AntigravityAccountsSection.test.tsx | 51 +++++++++++++++++++ .../settings/AntigravityAccountsSection.tsx | 20 ++++++-- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/reference/antigravity-native-accounts.md b/docs/reference/antigravity-native-accounts.md index 27df573b6a53..29fe943afded 100644 --- a/docs/reference/antigravity-native-accounts.md +++ b/docs/reference/antigravity-native-accounts.md @@ -5,6 +5,8 @@ an owning Orca runtime and a host/distro target before sending an operation; it the client's Mac Keychain item for another host. The RPC capability is `accounts.antigravity-native.v1`. Older paired hosts are refused before account mutations. The RPC returns account summaries only, never credential JSON, access tokens or refresh tokens. +Displayed quota is tied to the subject and authentication method observed during its refresh; +an external identity change hides the previous account's quota without an automatic fetch. ## Supported authority diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx index 6cf3788148b8..c6596a9732a1 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.test.tsx @@ -37,6 +37,28 @@ const state: AntigravityAccountState = { } const owner = { kind: 'local' } as const const target = { runtime: 'host' } as const +const usage = { + rateLimits: { + antigravity: { + provider: 'antigravity', + session: { usedPercent: 31, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: null, + updatedAt: 1, + error: null, + status: 'ok' + } + } +} +const changedState: AntigravityAccountState = { + ...state, + activeAccountId: null, + currentAccount: { + email: 'second@example.invalid', + subject: 'other-google-subject', + authMethod: 'consumer', + identityKnown: true + } +} beforeEach(() => { vi.mocked(callAntigravityAccounts).mockReset().mockResolvedValue(state) @@ -46,6 +68,35 @@ beforeEach(() => { afterEach(() => cleanup()) describe('native Antigravity Accounts', () => { + it.each(['Refresh accounts', 'Save current account'])( + 'hides the previous account quota when %s observes a different native identity', + async (action) => { + vi.mocked(callRuntimeRpc).mockResolvedValue(usage) + render() + await screen.findByText('Native account') + await userEvent.click(screen.getByRole('button', { name: 'Refresh usage' })) + await screen.findByText('Session: 31% · Weekly: —') + vi.mocked(callAntigravityAccounts).mockResolvedValueOnce(changedState) + await userEvent.click(screen.getByRole('button', { name: action })) + await screen.findByText('second@example.invalid') + expect(screen.queryByText('Session: 31% · Weekly: —')).toBeNull() + } + ) + + it('clears previous quota when an identity changes during a new usage refresh', async () => { + vi.mocked(callRuntimeRpc).mockResolvedValue(usage) + render() + await screen.findByText('Native account') + await userEvent.click(screen.getByRole('button', { name: 'Refresh usage' })) + await screen.findByText('Session: 31% · Weekly: —') + vi.mocked(callAntigravityAccounts) + .mockResolvedValueOnce(state) + .mockResolvedValueOnce(changedState) + await userEvent.click(screen.getByRole('button', { name: 'Refresh usage' })) + expect(await screen.findByRole('alert')).toHaveTextContent('native account changed') + expect(screen.queryByText('Session: 31% · Weekly: —')).toBeNull() + }) + it('shows the native identity and supported CLI sign-in instructions without inventing a login', async () => { render() await screen.findByText('Native account') diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx index 63f9ef078746..9341ccbcb5c1 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -22,7 +22,16 @@ export function AntigravityAccountsSection({ label: string }): React.JSX.Element { const [state, setState] = useState(null) - const [usage, setUsage] = useState(null) + const [usageSnapshot, setUsageSnapshot] = useState<{ + subject: string + authMethod: string + limits: ProviderRateLimits | null + } | null>(null) + const current = state?.currentAccount + const usage = + current?.subject === usageSnapshot?.subject && current?.authMethod === usageSnapshot?.authMethod + ? usageSnapshot?.limits + : null const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const pending = useRef(false) @@ -71,6 +80,7 @@ export function AntigravityAccountsSection({ setError(null) try { if (action === 'Usage') { + setUsageSnapshot(null) const before = await callAntigravityAccounts(owner, target, 'List') const snapshot = await callRuntimeRpc<{ rateLimits: RateLimitState }>( owner, @@ -89,11 +99,15 @@ export function AntigravityAccountsSection({ throw new Error('The native account changed while reading usage. Refresh usage again.') } if (mounted.current) { - setUsage(snapshot.rateLimits.antigravity) + setUsageSnapshot({ + subject: before.currentAccount.subject, + authMethod: before.currentAccount.authMethod, + limits: snapshot.rateLimits.antigravity + }) } } else { if (action === 'Select') { - setUsage(null) + setUsageSnapshot(null) } const next = await callAntigravityAccounts(owner, target, action, accountId) if (mounted.current) { From 726199c54befc8f432a71fd5219ffef538b87325 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 03:12:51 -0700 Subject: [PATCH 3/4] Refuse oversized encrypted Antigravity snapshots before writing --- .../antigravity/native-account-store.test.ts | 50 ++++++++++++++++++- src/main/antigravity/native-account-store.ts | 10 +++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/main/antigravity/native-account-store.test.ts b/src/main/antigravity/native-account-store.test.ts index d7fd92518afc..f6d3398c679e 100644 --- a/src/main/antigravity/native-account-store.test.ts +++ b/src/main/antigravity/native-account-store.test.ts @@ -2,8 +2,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store' +import { + _resetSecretStoreForTests, + getSecretStore, + setSecretStore +} from '../../shared/secret-store' import { createEncryptedAntigravityAccountStore } from './native-account-store' +import { AntigravityAccountService } from './native-account-service' import { credential, harness } from './native-account-test-fixtures' let dir: string @@ -27,6 +32,49 @@ afterEach(() => { }) describe('protected Antigravity account snapshots', () => { + it('preserves readable bytes when adding an account or refreshing a token exceeds the encrypted cap', async () => { + function paddedCredential(subject: string, bytes: number) { + const prefix = `${credential(subject).slice(0, -1)},"padding":"` + return `${prefix}${'x'.repeat(bytes - Buffer.byteLength(`${prefix}"}`))}"}` + } + const h = harness() + await h.service.addCurrentAccount() + const template = h.getVault().accounts[0] + const vault = { + selectedAccountId: null, + accounts: Array.from({ length: 52 }, (_, i) => ({ + ...template, + id: `large-${i}`, + subject: `large-${i}`, + credentials: paddedCredential(`large-${i}`, 60000) + })) + } + const cap = 4 * 1024 * 1024 + const encryptedSize = () => getSecretStore().encryptString(JSON.stringify(vault)).length + for (const account of vault.accounts.slice(0, -1)) { + const growth = Math.min(5000, Math.floor(((cap - encryptedSize() - 1024) * 3) / 4)) + if (growth <= 0) { + break + } + account.credentials = paddedCredential(account.subject, 60000 + growth) + } + expect(encryptedSize()).toBeLessThanOrEqual(cap) + expect(cap - encryptedSize()).toBeLessThan(2000) + const path = join(dir, 'vault') + const store = createEncryptedAntigravityAccountStore(path) + store.write(vault) + const before = readFileSync(path) + const service = new AntigravityAccountService(store, h.backend) + h.setNative(paddedCredential('new-account', 60000)) + await expect(service.addCurrentAccount()).rejects.toThrow('could not be saved') + expect(readFileSync(path)).toEqual(before) + expect(store.read().accounts).toHaveLength(52) + h.setNative(paddedCredential('large-51', 65000)) + await expect(service.listAccounts()).rejects.toThrow('could not be saved') + expect(readFileSync(path)).toEqual(before) + expect(store.read().accounts).toHaveLength(52) + }) + it('persists a selected account with exact provider fields and private permissions across restart', async () => { const h = harness() const state = await h.service.addCurrentAccount() diff --git a/src/main/antigravity/native-account-store.ts b/src/main/antigravity/native-account-store.ts index 9c062033d514..15c8c0007a0f 100644 --- a/src/main/antigravity/native-account-store.ts +++ b/src/main/antigravity/native-account-store.ts @@ -15,6 +15,8 @@ export type AntigravityAccountStore = { write(vault: AntigravityAccountVault): void } +const MAX_VAULT_BYTES = 4 * 1024 * 1024 + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -53,7 +55,7 @@ export function createEncryptedAntigravityAccountStore(path: string): Antigravit const stat = lstatSync(path) if ( !stat.isFile() || - stat.size > 4 * 1024 * 1024 || + stat.size > MAX_VAULT_BYTES || (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) ) { throw new Error('unsafe vault') @@ -94,7 +96,11 @@ export function createEncryptedAntigravityAccountStore(path: string): Antigravit requireProtection() mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) try { - writeCredentialFileAtomic(path, getSecretStore().encryptString(JSON.stringify(vault))) + const encrypted = getSecretStore().encryptString(JSON.stringify(vault)) + if (encrypted.length > MAX_VAULT_BYTES) { + throw new Error('vault exceeds readable size') + } + writeCredentialFileAtomic(path, encrypted) if (process.platform !== 'win32' && (statSync(path).mode & 0o077) !== 0) { throw new Error('unsafe permissions') } From fd9d405a7c7249ec8df5204357f174f1e6d537cb Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 04:33:39 -0700 Subject: [PATCH 4/4] fix(antigravity): localize account heading and search terms --- .../components/settings/AntigravityAccountsSection.tsx | 2 +- src/renderer/src/components/settings/accounts-search.ts | 9 ++++++++- src/renderer/src/i18n/locales/en.json | 9 ++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx index 9341ccbcb5c1..2ec75d9af664 100644 --- a/src/renderer/src/components/settings/AntigravityAccountsSection.tsx +++ b/src/renderer/src/components/settings/AntigravityAccountsSection.tsx @@ -143,7 +143,7 @@ export function AntigravityAccountsSection({

- Antigravity + {translate('accounts.antigravity.title', 'Antigravity')}

{translate('accounts.antigravity.scope', 'Manage the native agy account on {{host}}.', { diff --git a/src/renderer/src/components/settings/accounts-search.ts b/src/renderer/src/components/settings/accounts-search.ts index 44394f5c6466..49296504e748 100644 --- a/src/renderer/src/components/settings/accounts-search.ts +++ b/src/renderer/src/components/settings/accounts-search.ts @@ -224,7 +224,14 @@ export const getAccountsAntigravitySearchEntries = createLocalizedCatalog(() => 'accounts.antigravity.searchDescription', 'Save and select native agy Google accounts on the execution host.' ), - keywords: ['antigravity', 'agy', 'google', 'accounts', 'login', 'usage'] + keywords: [ + ...translateSearchKeyword('accounts.antigravity.keyword.antigravity', 'antigravity'), + ...translateSearchKeyword('accounts.antigravity.keyword.agy', 'agy'), + ...translateSearchKeyword('accounts.antigravity.keyword.google', 'google'), + ...translateSearchKeyword('accounts.antigravity.keyword.accounts', 'accounts'), + ...translateSearchKeyword('auto.components.settings.accounts.search.a9f3d7b5c8', 'login'), + ...translateSearchKeyword('auto.components.settings.accounts.search.cursor.kw.usage', 'usage') + ] } ]) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 56b922ed834c..3cba90047e10 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18764,7 +18764,14 @@ "remove": "Remove", "sessions": "Selection applies to new agy sessions on this host. Existing sessions may keep their previous account.", "searchTitle": "Antigravity Accounts", - "searchDescription": "Save and select native agy Google accounts on the execution host." + "searchDescription": "Save and select native agy Google accounts on the execution host.", + "title": "Antigravity", + "keyword": { + "antigravity": "antigravity", + "agy": "agy", + "google": "google", + "accounts": "accounts" + } } } }