diff --git a/CHANGELOG.md b/CHANGELOG.md index 64533cc..964ade3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Add an explicit Antigravity Desktop connection in Electron setup and Settings. + Read account allowance from one running, signed-in desktop language server + without requiring `agy`. Keep CLI selection explicit and local activity + separate. Synthetic fixtures cover identity changes, unavailable quota, + transport limits and cancellation. Native and live-provider acceptance remain + pending; the published beta is unchanged. + ### Changed - Shorten heatmap hover/focus totals to values such as `42.4B tokens` and diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index 47282e4..746111f 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -19,6 +19,13 @@ The **Connect using** selector offers the methods supported by each provider. Selecting a method does not start sign-in or change collection. Use the button below it to apply the choice. Existing profiles keep their current source. +- Antigravity: choose **Antigravity Desktop**, then **Connect Antigravity Desktop**. + Keep one desktop account open and signed in. The candidate reads account + allowance from its running local service without requiring `agy`. Separate + language servers are ambiguous, even if they belong to the same account; + close extra instances before connecting. **Antigravity CLI** remains an + explicit alternative for the existing local integration. Desktop allowance + includes no CLI-local history. Native and live-provider checks are pending. - Codex: choose **Sign in to Codex** and complete browser sign-in. The candidate finds the helper in an existing Codex app in Applications, with existing command installations as a compatibility path. Users do not open a terminal @@ -237,3 +244,27 @@ confirm allowance is removed. Test denied access and account/organization switches only with the account owner's approval. Never run the Keychain helper directly or capture its output. See [ADR 0016](adr/0016-claude-live-test-profile.md). + +## Antigravity Desktop source boundary + +The source adapter discovers one same-user Antigravity desktop language server +and its PID-owned IPv4 loopback listeners. It reads only the candidate process +arguments for its current CSRF token. Fixed `GetUserStatus` and +`RetrieveUserQuotaSummary` requests use HTTPS first; HTTP is limited to the +declared, owned extension-server port. TLS certificate exemption is local to +that verified loopback request. Redirects are rejected. Each reply is limited +to 1 MiB, each request to three seconds, and collection to fifteen seconds. + +Account identity must match before and after allowance collection. Only an +opaque fingerprint enters preferences. CSRF credentials, account email and raw +replies stay in main-process memory, outside settings, diagnostics and logs. +The integration neither writes provider stores nor refreshes credentials. +Changed identity, provider closure, expired buckets, missing fractions and +empty summaries leave allowance unavailable and clear prior account quota. +An empty modern summary never falls back to legacy data. Legacy model quota +is read only when the modern RPC explicitly reports unsupported; it cannot +establish weekly allowance. Disconnect and cancellation reject late results. + +This is source and synthetic-fixture coverage. It is absent from the published +build 54 beta. Native discovery, local transport and live allowance comparison +remain unverified. See [ADR 0026](adr/0026-antigravity-desktop-local-quota.md). diff --git a/docs/KB.md b/docs/KB.md index 43cdaf5..09f0daf 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -41,14 +41,26 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. ## Product and source facts +- Unreleased Antigravity Desktop setup reuses the existing connection controls, + preferences, IPC and account-bound source. It requires one running same-user + desktop language server with a CSRF token and PID-owned IPv4 loopback port. + Fixed quota/status RPCs use request-scoped TLS handling, redirect rejection, + 1 MiB response limits and bounded time. Account fingerprints are opaque; + credentials stay in main-process memory. Empty modern quota and missing + fractions remain unavailable. Local CLI usage stays detached. Synthetic + fixtures cover account switches, closure, transport failures and connection + races. Native/live-provider proof and release inclusion remain pending. + See [ADR 0026](adr/0026-antigravity-desktop-local-quota.md). + - Heatmap hover/focus readouts use compact token and session totals, including local, account, session-only, daily, weekly and cumulative grids. Exact grouped counts remain in tooltips and accessible labels. Summary counts compact from 1,000. This follow-up is separate from the published build 54 artifact. - Onboarding and Settings offer provider-specific source choices: Codex browser - sign-in or CLI, Claude Desktop or Claude Code CLI, and Grok CLI only. Choosing - an option does not change collection until its action is used. Use CLI waits + sign-in or CLI, Claude Desktop or Claude Code CLI, Antigravity Desktop or + Antigravity CLI, and Grok CLI only. Choosing an option does not change + collection until its action is used. Use CLI waits for MeterUsage-owned connection cleanup before selecting existing local collection. Ordinary Disconnect still prevents automatic local fallback. CLI data covers this Mac; Claude CLI allowance needs a companion usage file. @@ -149,7 +161,7 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. - The published app is Swift Package Manager based, targets macOS 13 or later, and includes an executable target and a test target. `Package.swift` is the source for this package structure. Its AppKit and SwiftUI imports require macOS for package build and test checks; Swift itself supports other platforms. `AGENTS.md` describes a generic macOS SSH route for Linux development without private host aliases. - `README.md` links directly to the prebuilt v0.2.41 app ZIP as the default installation path. Installing and running it does not require Swift, Xcode, Command Line Tools, or Git; its Swift runtime ships with macOS. `Scripts/make-app.sh` also downloads that pinned release by default, checks its SHA-256 and code signature, and prepares `dist/MeterUsage.app` without compiling or installing developer tools. It copies and verifies the replacement in staging on the destination filesystem before replacing existing output, so copy or verification failures preserve the previous generated app. It rejects unsupported platforms and never falls back to a source build. Source builds require `--build-from-source` and are documented in `CONTRIBUTING.md`. The v0.2.41 asset targets Apple silicon and macOS 13 or later, is ad-hoc signed, and is not notarized. GitHub's source archives contain source and scripts, not an app bundle. ADR 0009 records the script's default. - Swift provider data sources are implemented under `Sources/MeterUsage/Services/`; the candidate adapters live under `src/main/providers/` and `src/main/composition.ts`. `docs/PRIVACY.md` describes the boundary for local files, provider CLIs, documented network endpoints, and data reduction before display. -- Antigravity quota and usage share a bounded Docker/Podman runtime resolver. It prefers a healthy runtime and can start only an already-existing `podman-machine-default` after an inspect check; it never creates a machine or pulls an image. Concurrent recovery calls are serialized; failed starts have a 60-second cooldown. Internet or authentication failures are not repaired by restarting a healthy runtime. +- Antigravity CLI quota and usage share a bounded Docker/Podman runtime resolver. It prefers a healthy runtime and can start only an already-existing `podman-machine-default` after an inspect check; it never creates a machine or pulls an image. Concurrent recovery calls are serialized; failed starts have a 60-second cooldown. Internet or authentication failures are not repaired by restarting a healthy runtime. - Demo mode uses synthetic data. `README.md` and `docs/DEMO.md` describe it as the path for screenshots and local UI inspection without provider accounts. - `Resources/Info.plist` and `CHANGELOG.md` record version 0.2.41, build 46, dated 2026-10-05. This release collects fresh quota observations after an accepted manual Codex reset, derives pacing and ETA from later usage growth, and removes the redeemed reset credit from archived quota data. The previous release was 0.2.40, which added accent colour themes, Codex cost estimates, and refreshed model prices. Repository version and release-note entries do not prove that a GitHub Release or its downloadable asset has been published. - `CONTRIBUTING.md` requires focused changes, synthetic fixtures, and `swift build`, `swift test`, and `Scripts/make-app.sh --build-from-source` before a code pull request. `Tests/Scripts/make-app-tests.sh` covers default preparation, explicit source selection, and download, verification, argument, and platform failures without network or compiler access. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index bfa96f3..c397914 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -120,11 +120,31 @@ Collection timeout also clears account-bound allowance when the identity check may not have completed. Disconnect removes consent metadata and prevents the post-request credential reread without changing Claude Desktop. -Connected Codex/Claude sources do not attach local CLI activity to the connected -account. Quota archives are cleared on connection changes and candidate launch; +An explicit **Connect Antigravity Desktop** action permits discovery of one +same-user desktop language server and reading only its candidate process +arguments for the current CSRF credential. Multiple matching servers are +rejected. MeterUsage verifies PID-owned IPv4 loopback listeners before each +fixed `GetUserStatus` or `RetrieveUserQuotaSummary` request. HTTPS is preferred; +a certificate exemption applies only to that request to `127.0.0.1`. HTTP is +allowed only on the declared, PID-owned extension-server port. Redirects are +rejected, responses are limited to 1 MiB, each request to three seconds and +collection to fifteen seconds. No cloud credential file is opened or copied. + +The Antigravity account email is checked before and after quota collection. +Only a provider-scoped SHA-256 fingerprint is saved in MeterUsage preferences, +excluded from settings publication and diagnostics. CSRF credentials and raw +responses stay in main-process memory and are not logged, cached or persisted. +No provider store is changed and no credential is refreshed. Connection +changes, cancellation and disconnect invalidate old reads. Missing or expired +quota stays unavailable. Empty modern summaries cannot select stale legacy +quota; legacy model fractions are allowed only for an explicitly unsupported +modern RPC. See [ADR 0026](adr/0026-antigravity-desktop-local-quota.md). + +Connected Codex/Claude/Antigravity sources do not attach local CLI activity to +the connected account. Quota archives are cleared on connection changes and candidate launch; authentication or absent-data failures clear the connected archive. Tokens, -passwords, raw caches, account identifiers and responses are not logged or -persisted by MeterUsage. In-memory strings remain subject to garbage collection. +passwords, raw caches, plaintext account identifiers and responses are not +logged or persisted by MeterUsage. In-memory strings remain subject to garbage collection. Demo mode blocks live connection code before helper or key discovery. Grok has no new consumer connection in this candidate. [ADR 0011](adr/0011-desktop-account-connections.md) amends the candidate boundary; it does not change the published Swift release. diff --git a/docs/adr/0026-antigravity-desktop-local-quota.md b/docs/adr/0026-antigravity-desktop-local-quota.md new file mode 100644 index 0000000..adebbe3 --- /dev/null +++ b/docs/adr/0026-antigravity-desktop-local-quota.md @@ -0,0 +1,52 @@ +# ADR 0026: Read Antigravity Desktop quota through its local service + +- Status: Source implementation; native and provider acceptance pending +- Date: 2026-10-16 +- Amends: [ADR 0011](0011-desktop-account-connections.md), for opt-in Antigravity Desktop allowance + +## Decision + +Offer **Connect Antigravity Desktop** beside the existing explicit CLI choice. +Use the running desktop language server's fixed `GetUserStatus` and +`RetrieveUserQuotaSummary` RPCs. Do not require or start `agy`, download a helper, +copy OAuth credentials, refresh tokens or write to provider stores. + +Discover executable names before reading arguments for one matching same-user +PID. Require its CSRF token and its own IPv4 loopback listener. Multiple +matching desktop servers are ambiguous, including multiple windows that start +separate servers. Reject them instead of choosing an account. Recheck process, +listener and token ownership before each request. + +Prefer HTTPS. Limit certificate verification exemption to an individual request +to the verified `127.0.0.1` listener. HTTP is allowed only on the declared, +PID-owned extension-server port. Use fixed RPC paths and bodies, reject +redirects, limit responses to 1 MiB, requests to three seconds and a full read +to fifteen seconds. Never expose provider errors or process output. + +Require `GetUserStatus.userStatus.email` before and after quota collection. +Retain only its provider-scoped SHA-256 fingerprint in MeterUsage preferences; +exclude it from renderer settings and diagnostics. This extends ADR 0011's +opt-in identity exception to Antigravity. Keep CSRF credentials and raw replies +in main-process memory only. Reject changed or missing identity, cancellation, +disconnect and results from an earlier connection, including reconnection to +the same account. + +A successful modern response is authoritative. Empty summaries and missing, +invalid, disabled or expired buckets stay unavailable. Use legacy +`GetUserStatus` model fractions only after an explicit unsupported response +(HTTP 404/501 or RPC code 12/`unimplemented`). Do not infer weekly quota or +turn a missing fraction into depleted allowance. + +Connected allowance has no CLI-local activity attached. Disconnect disables +collection without selecting CLI. **Use CLI** explicitly restores the existing +local source. Clear account quota on source changes, failed identity/data reads +and launch, using the existing connection/coordinator flow. + +## Acceptance + +Synthetic fixtures establish discovery filtering, transport bounds, quota +projection, account changes, archive clearing and cancellation/save races. +They do not establish native process discovery, installed-app compatibility, +local TLS behavior or live allowance accuracy. Those checks remain required +before claiming Antigravity Desktop support in a released artifact. The +published beta and legacy Swift release are unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index c8a11cc..54e1efd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,4 @@ compatibility and acceptance requirements remain in force. The | [0022](0022-codex-account-token-history.md) | Read provider account daily tokens separately from local activity through the managed ChatGPT helper. | Accepted implementation direction; independent review, native and live acceptance pending | | [0023](0023-onboarding-source-choice.md) | Offer supported CLI and account methods in onboarding, with explicit cleanup before switching to local collection. | Accepted implementation direction; native interaction acceptance pending | | [0025](0025-card-settings-diagnostic-reports.md) | Keep Settings in usage cards and restore explicit sanitized support reports in Diagnostics. | Accepted implementation direction; native and live delivery acceptance separate | +| [0026](0026-antigravity-desktop-local-quota.md) | Opt-in Antigravity Desktop quota through its owned local service. | Source implementation; native/provider acceptance pending | diff --git a/docs/electron/README.md b/docs/electron/README.md index 8e0766d..3c2ae6d 100644 --- a/docs/electron/README.md +++ b/docs/electron/README.md @@ -37,10 +37,11 @@ to local collection. See [desktop connections](../DESKTOP-CONNECTIONS.md) and ### Existing provider inputs All nine adapters remain wired in [composition](../../src/main/composition.ts). -Antigravity and OpenCode onboarding guidance/actions remain requested work; -their collection exists without a connection row. +OpenCode onboarding guidance/actions remain requested work; its collection +exists without a connection row. Antigravity Desktop setup is implemented only +in unreleased source, as noted below. -- Antigravity quota uses existing authenticated `agy` container state, Docker or +- Antigravity CLI quota uses existing authenticated `agy` container state, Docker or Podman, image `antigravity-cli:local` and volumes `antigravity-config` and `antigravity-bin`. Native database/history usage is independent of that quota runtime. No separate MeterUsage browser login or API-key form exists. @@ -220,3 +221,14 @@ source retirement and stable cutover. This opt-in beta does not close those chec Desktop account research for Grok/Cursor, Antigravity and OpenCode Go is recorded in [DESKTOP-LOGIN-RESEARCH.md](../DESKTOP-LOGIN-RESEARCH.md). It separates documented sign-in from implemented quota collection. + +### Unreleased Antigravity Desktop setup + +Current source adds **Connect Antigravity Desktop** to the existing source +selector. One running, signed-in desktop language server supplies account +allowance without `agy`; local CLI history is excluded. Multiple matching +servers and missing or expired data remain unavailable. **Use CLI** selects +the existing local integration explicitly. This adapter has synthetic-fixture +coverage only and is absent from published build 54. Native process discovery, +local transport and live quota comparison remain acceptance gaps. See +[desktop connections](../DESKTOP-CONNECTIONS.md#antigravity-desktop-source-boundary). diff --git a/src/main/composition.ts b/src/main/composition.ts index 6618fdf..edc4098 100644 --- a/src/main/composition.ts +++ b/src/main/composition.ts @@ -86,6 +86,11 @@ export function compose(launch: Launch, prefs: Preferences, options: { command?: } codex.status = async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now()); claude.status = async signal => parseStatus("claude", await http(endpoints.claudeStatus, "claude", undefined, signal), now()); + const antigravityIdentity = String(prefs.values.desktopAntigravityIdentity); + const antigravity: Source = antigravityIdentity !== "" ? { + slot: primary("antigravity"), accountBound: true, + quota: signal => options.connections ? options.connections.quota("antigravity", signal, antigravityIdentity) : Promise.reject(new Unavailable("notSignedIn", "Antigravity Desktop")), + } : { slot: primary("antigravity"), quota: signal => runtime.quota(now(), signal), usage: signal => runtime.usage(join(launch.home, ".gemini/antigravity-cli"), now(), signal) }; const sources: Source[] = [codex, { slot: primary("openRouter"), quota: async signal => { const key = openRouterKey(launch); if (!key) throw new Unavailable("dataNotFound", "OpenRouter API key"); @@ -106,9 +111,7 @@ export function compose(launch: Launch, prefs: Preferences, options: { command?: if (existsSync(path)) { try { const { DatabaseSync } = await import("node:sqlite"), db = new DatabaseSync(path, { readOnly: true }); try { const rows = parseOpenCodeRows(JSON.stringify(db.prepare(openCodeQuery).all())); if (rows.length) return openCodeUsage(rows, now()); } finally { db.close(); } } catch { /* The supported CLI is the existing fallback. */ } } return openCodeUsage(parseOpenCodeRows(await command(cliPath("opencode", launch.home, env), ["db", "--format", "json", openCodeQuery], { env, signal })), now()); }, - }, { - slot: primary("antigravity"), quota: signal => runtime.quota(now(), signal), usage: signal => runtime.usage(join(launch.home, ".gemini/antigravity-cli"), now(), signal), - }, { + }, antigravity, { slot: primary("grok"), quota: async signal => { const key = grokToken(launch); if (!key) throw new Unavailable("dataNotFound", "Grok credentials"); return parseGrok(await http(endpoints.grok, "grok", key, signal), now()); }, usage: async signal => { const configured = text(env.GROK_HOME), root = configured ? isAbsolute(configured) ? configured : join(launch.home, configured) : join(launch.home, ".grok"); return scanGrok(join(root, "sessions"), now(), signal); }, }, claude]; for (const p of ["cursor", "copilot", "gemini"] as const) { diff --git a/src/main/connections.ts b/src/main/connections.ts index 4e5fa6c..48f5ddd 100644 --- a/src/main/connections.ts +++ b/src/main/connections.ts @@ -7,6 +7,7 @@ import { boundedChild, cliEnvironment, cliPath, codexRPC, endpoints, httpTranspo import { object, parseCodex } from "./providers/parsers"; import { parseCodexAccountUsage } from "./providers/codex-account-usage"; import { desktopQuota, readDesktopCredential, desktopReadErrors } from "./providers/claude-desktop"; +import { readAntigravityDesktop, antigravityRPC, type AntigravityRPC } from "./providers/antigravity-desktop"; import { selectJSON } from "./select-json"; import { Unavailable, failed, resetCreditAvailable } from "../domain/models"; import { providerKey, type ConnectionState, type ConnectionProvider } from "../shared/ipc"; @@ -59,18 +60,19 @@ export function connectCodex(binary: string, env: NodeJS.ProcessEnv, open: (url: } export class DesktopConnections { - private pending = new Map; local: boolean }>(); + private pending = new Map; settleBeforeQuit: boolean }>(); private errors = new Set(); private stopped = false; + private antigravityController = new AbortController(); private resetController?: AbortController; private stopping?: Promise; private cleanupController = new AbortController(); - constructor(readonly launch: Launch, readonly prefs: Preferences, readonly helper: string, readonly changed: () => void, readonly command: Command = runCommand, readonly http: HTTP = httpTransport(), readonly now = Date.now) {} + constructor(readonly launch: Launch, readonly prefs: Preferences, readonly helper: string, readonly changed: () => void, readonly command: Command = runCommand, readonly http: HTTP = httpTransport(), readonly now = Date.now, readonly antigravity: AntigravityRPC = antigravityRPC) {} get state(): ConnectionState[] { - return (["codex", "claude", "grok"] as const).map(provider => ({ provider, status: provider === "grok" || this.launch.codexTest && provider !== "codex" || this.launch.claudeTest && provider !== "claude" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected", source: provider !== "grok" && this.configured(provider) ? "account" : "cli", localAvailable: !this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest })); + return (["codex", "claude", "antigravity", "grok"] as const).map(provider => ({ provider, status: provider === "grok" || this.launch.codexTest && provider !== "codex" || this.launch.claudeTest && provider !== "claude" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected", source: provider !== "grok" && this.configured(provider) ? "account" : "cli", localAvailable: !this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest })); } - enabled(provider: "codex" | "claude") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; return (!this.launch.codexTest || provider === "codex") && (!this.launch.claudeTest || provider === "claude") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } - configured(provider: "codex" | "claude") { return this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"] !== ""; } + enabled(provider: "codex" | "claude" | "antigravity") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : "desktopAntigravityIdentity"]; return (!this.launch.codexTest || provider === "codex") && (!this.launch.claudeTest || provider === "claude") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } + configured(provider: "codex" | "claude" | "antigravity") { return this.prefs.values[provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : "desktopAntigravityIdentity"] !== ""; } private binary() { for (const root of ["/Applications", join(this.launch.home, "Applications")]) { const path = join(root, "Codex.app/Contents/Resources/codex"); @@ -92,7 +94,7 @@ export class DesktopConnections { private checkCodexProfile(profile: string) { if (this.launch.demo || !this.enabled("codex") || this.prefs.values.desktopCodexConnection !== profile) throw new Unavailable("notSignedIn", "codex"); } - async quota(provider: ConnectionProvider, signal?: AbortSignal, profile = String(this.prefs.values.desktopCodexConnection)) { + async quota(provider: ConnectionProvider, signal?: AbortSignal, profile = String(this.prefs.values[provider === "antigravity" ? "desktopAntigravityIdentity" : "desktopCodexConnection"])) { if (this.launch.demo || provider === "grok" || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); if (provider === "codex") { this.checkCodexProfile(profile); @@ -100,6 +102,15 @@ export class DesktopConnections { this.checkCodexProfile(profile); signal?.throwIfAborted(); return parseCodex(raw, this.now()); } + if (provider === "antigravity") { + const controller = this.antigravityController; + const active = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + if (this.prefs.values.desktopAntigravityIdentity !== profile) throw new Unavailable("notSignedIn", "Antigravity Desktop"); + const result = await readAntigravityDesktop(this.command, this.antigravity, this.now(), active, profile); + active.throwIfAborted(); + if (!this.enabled(provider) || this.prefs.values.desktopAntigravityIdentity !== profile) throw new Unavailable("notSignedIn", "Antigravity Desktop"); + return result.quota; + } const credential = await this.claude(false, signal); if (credential.identity !== this.prefs.values.desktopClaudeIdentity) throw new Unavailable("notSignedIn", "Claude Desktop"); let raw: string; @@ -161,6 +172,11 @@ export class DesktopConnections { const q = parseCodex(await connectCodex(binary, this.env(id), open, controller.signal), this.now()); if (!q.windows.length) throw new Unavailable("noData"); controller.signal.throwIfAborted(); + } else if (provider === "antigravity") { + const result = await readAntigravityDesktop(this.command, this.antigravity, this.now(), controller.signal); + controller.signal.throwIfAborted(); savedIdentity = result.identity; + await this.prefs.set("desktopAntigravityIdentity", result.identity); + controller.signal.throwIfAborted(); } else { claudeError = "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again."; const credential = await this.claude(true, controller.signal, stage => { @@ -174,7 +190,7 @@ export class DesktopConnections { claudeError = "Could not save the Claude Desktop connection preferences. Try connecting again."; controller.signal.throwIfAborted(); savedIdentity = credential.identity; await this.prefs.set("desktopClaudeIdentity", credential.identity); } - await this.prefs.set(provider === "codex" ? "showProviderCodex" : "showProviderClaude", true); + await this.prefs.set(providerKey(provider), true); controller.signal.throwIfAborted(); if (candidateID) { await this.prefs.set("desktopCodexConnection", candidateID); @@ -189,25 +205,26 @@ export class DesktopConnections { try { await this.cleanup(candidateID); } catch { this.errors.add(provider); throw new Error("Could not clear the cancelled Codex connection"); } } - if (savedIdentity && this.prefs.values.desktopClaudeIdentity === savedIdentity) { + if (savedIdentity && this.prefs.values[provider === "antigravity" ? "desktopAntigravityIdentity" : "desktopClaudeIdentity"] === savedIdentity) { // Disabling clears consent in memory first; keep a failed rollback write private. - try { await this.disable("claude"); } catch { /* Report the fixed preference-save message. */ } + try { await this.disable(provider === "antigravity" ? "antigravity" : "claude"); } catch { /* Report the fixed preference-save message. */ } } - this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError); + this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : provider === "antigravity" ? "Open one Antigravity Desktop account and sign in, then try connecting again" : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError); } finally { finish(); } } private begin(provider: ConnectionProvider, local = false) { if (provider === "codex") this.resetController?.abort(); + if (provider === "antigravity") { this.antigravityController.abort(); this.antigravityController = new AbortController(); } const controller = new AbortController(); let settle!: () => void; const done = new Promise(resolve => { settle = resolve; }); - this.pending.set(provider, { controller, done, local }); this.errors.delete(provider); this.changed(); + this.pending.set(provider, { controller, done, settleBeforeQuit: local || provider === "antigravity" }); this.errors.delete(provider); this.changed(); return { controller, finish: () => { this.pending.delete(provider); if (!this.stopped) this.changed(); settle(); } }; } cancel(provider: ConnectionProvider) { this.pending.get(provider)?.controller.abort(); } - private async disable(provider: "codex" | "claude", cleanupID?: string) { - const key = provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"; + private async disable(provider: "codex" | "claude" | "antigravity", cleanupID?: string) { + const key = provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : "desktopAntigravityIdentity"; this.prefs.values[key] = "off"; if (!this.stopped) this.changed(); // Save cleanup first so a crash during the disabling write stays fail-closed. if (cleanupID) await this.prefs.set("desktopCodexCleanup", cleanupID); @@ -243,7 +260,7 @@ export class DesktopConnections { await this.prefs.set(providerKey(provider), true); controller.signal.throwIfAborted(); if (provider !== "grok") { - await this.prefs.set(provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity", ""); + await this.prefs.set(provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : "desktopAntigravityIdentity", ""); controller.signal.throwIfAborted(); } } @@ -256,7 +273,7 @@ export class DesktopConnections { if (local && provider !== "grok" && !this.configured(provider)) { try { await this.disable(provider); } catch { /* Disabling in memory keeps a failed rollback private. */ } } - this.errors.add(provider); throw new Error(provider === "codex" ? "Could not disconnect Codex" : provider === "claude" ? "Could not disconnect Claude Desktop" : "Could not select Grok CLI"); + this.errors.add(provider); throw new Error(provider === "codex" ? "Could not disconnect Codex" : provider === "claude" ? "Could not disconnect Claude Desktop" : provider === "antigravity" ? "Could not disconnect Antigravity Desktop" : "Could not select Grok CLI"); } finally { finish(); } } async recover() { @@ -270,14 +287,14 @@ export class DesktopConnections { } stop(timeoutMs = 5000): Promise { if (this.stopping) return this.stopping; - this.stopped = true; this.resetController?.abort(); + this.stopped = true; this.resetController?.abort(); this.antigravityController.abort(); const pending = [...this.pending.values()]; for (const operation of pending) operation.controller.abort(); let timer: ReturnType; const expired = new Promise(resolve => { timer = setTimeout(() => { this.cleanupController.abort(); resolve(); }, timeoutMs); }); - // The helper deadline must not cut off a queued source-selection rollback. + // The helper deadline must not cut off a queued source or desktop-consent rollback. this.stopping = Promise.race([Promise.all(pending.map(p => p.done)).then(() => {}), expired]) - .then(() => Promise.all(pending.filter(p => p.local).map(p => p.done))).then(() => {}) + .then(() => Promise.all(pending.filter(p => p.settleBeforeQuit).map(p => p.done))).then(() => {}) .finally(() => clearTimeout(timer)); return this.stopping; } diff --git a/src/main/electron.ts b/src/main/electron.ts index 79a25a0..232e63e 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -49,11 +49,11 @@ async function start(launch: Launch) { app.dock?.hide(); const prefs = await Preferences.load(launch); const documents = new Map(), windows = new Map(); - const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity }; + const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity, antigravity: prefs.values.desktopAntigravityIdentity }; const connections = new DesktopConnections(launch, prefs, desktopHelper, () => { - const changed = (["codex", "claude"] as const).filter(p => markers[p] !== prefs.values[p === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]); + const changed = (["codex", "claude", "antigravity"] as const).filter(p => markers[p] !== prefs.values[p === "codex" ? "desktopCodexConnection" : p === "claude" ? "desktopClaudeIdentity" : "desktopAntigravityIdentity"]); if (changed.length) { - for (const p of changed) { markers[p] = prefs.values[p === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; coordinator.forgetQuota(p); } + for (const p of changed) { markers[p] = prefs.values[p === "codex" ? "desktopCodexConnection" : p === "claude" ? "desktopClaudeIdentity" : "desktopAntigravityIdentity"]; coordinator.forgetQuota(p); } coordinator.sources = sources(); } publish(); @@ -64,7 +64,7 @@ async function start(launch: Launch) { }, sources); const issueReports = new IssueReports(() => coordinator.diagnostics(app.getVersion()), publish, prefs); // Do not restore a legacy archive under a newly connected account. - for (const p of ["codex", "claude"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); + for (const p of ["codex", "claude", "antigravity"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); const notch = { expanded: false, cardOnRight: false, selected: "codex", dragging: false }; let notchHeight = 200, anchor: { x: number; y: number } | undefined; let flyoutHeight = 700, settingsHeight: number | undefined; @@ -363,7 +363,7 @@ async function start(launch: Launch) { const source = BrowserWindow.fromWebContents(event.sender), surface = [...windows].find(([, w]) => w === source)?.[0]; if (!source || !surface) return { ok: false, error: "Unknown window" }; try { return await handle(parseRequest(raw), source, surface); } - catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", ...Object.values(desktopReadErrors), "Could not read Claude account allowance. Try connecting again later.", "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.", "Could not save the Claude Desktop connection preferences. Try connecting again.", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Could not disconnect Claude Desktop", "Could not select Grok CLI", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; } + catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", ...Object.values(desktopReadErrors), "Could not read Claude account allowance. Try connecting again later.", "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.", "Could not save the Claude Desktop connection preferences. Try connecting again.", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Could not disconnect Claude Desktop", "Open one Antigravity Desktop account and sign in, then try connecting again", "Could not disconnect Antigravity Desktop", "Could not select Grok CLI", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; } }); const unsubscribe = coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); screen.on("display-metrics-changed", () => { placeNotch(); sizeFlyout(); sizeSettings(); publish(); }); screen.on("display-removed", () => { placeNotch(); sizeFlyout(); sizeSettings(); publish(); }); diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 94dd632..351e625 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -23,7 +23,7 @@ export function parseRequest(raw: unknown): Request { break; case "providerOrder": fields = ["order"]; valid = validProviderOrder(r.order); break; case "accountAdd": fields = ["provider"]; valid = r.provider === "codex" || r.provider === "claude"; break; - case "connect": case "disconnect": case "connectionCancel": case "connectionLocal": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "grok"].includes(r.provider); break; + case "connect": case "disconnect": case "connectionCancel": case "connectionLocal": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "antigravity", "grok"].includes(r.provider); break; case "statusPage": fields = ["provider"]; valid = typeof r.provider === "string" && Object.hasOwn(statusPages, r.provider); break; case "accountUpdate": fields = ["id", ...("label" in r ? ["label"] : []), ...("enabled" in r ? ["enabled"] : [])]; valid = id(r.id) && fields.length > 1 && (!("label" in r) || string(r.label, 100)) && (!("enabled" in r) || typeof r.enabled === "boolean"); break; case "accountRemove": case "accountPath": fields = ["id"]; valid = id(r.id); break; diff --git a/src/main/preferences.ts b/src/main/preferences.ts index 0e4cc52..52116b6 100644 --- a/src/main/preferences.ts +++ b/src/main/preferences.ts @@ -15,7 +15,7 @@ type Kind = "boolean" | "number" | "string" | "data" | "date"; const kinds: Record = { refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", panelSize: "string", managedAccounts: "data", providerOrder: "data", sideNotchPanelCorner: "string", updateLastCheckDate: "date", reportNextAllowedAt: "date", updateDismissedVersion: "string", updateAnnouncedVersion: "string", - desktopCodexConnection: "string", desktopCodexCleanup: "string", desktopClaudeIdentity: "string", + desktopCodexConnection: "string", desktopCodexCleanup: "string", desktopClaudeIdentity: "string", desktopAntigravityIdentity: "string", ...Object.fromEntries(["launchAtLogin", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "quotaAlertsEnabled", "sideNotchPanelEnabled", "sideNotchPanelPinned", "menuBarCompactEnabled", "onboardingCompleted", "updateCheckEnabled", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart", "showSideNotchResetButton", ...providers.flatMap(p => [providerPreference(p), trayPreference(p)])].map(k => [k, "boolean" as const])), }; export const initialPreferences: Record = { @@ -24,7 +24,7 @@ export const initialPreferences: Record = { sideNotchPanelEnabled: false, sideNotchPanelPinned: false, menuBarCompactEnabled: true, onboardingCompleted: false, updateCheckEnabled: true, showPacingBurnRate: true, showActivityTelemetry: true, showDailyActivityChart: true, showSideNotchResetButton: true, updateDismissedVersion: "", updateAnnouncedVersion: "", - desktopCodexConnection: "", desktopCodexCleanup: "", desktopClaudeIdentity: "", + desktopCodexConnection: "", desktopCodexCleanup: "", desktopClaudeIdentity: "", desktopAntigravityIdentity: "", ...Object.fromEntries(providers.flatMap(p => [[providerPreference(p), ["codex", "openCodeGo", "openRouter"].includes(p)], [trayPreference(p), true]])), }; const codexTestPreferences: Record = { @@ -50,7 +50,7 @@ function validate(key: string, v: unknown): v is PreferenceValue { if (key === "panelSize") return Object.hasOwn(panelScales, v); if (key === "sideNotchPanelCorner") return v === "" || /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(v); if (key === "desktopCodexConnection" || key === "desktopCodexCleanup") return v === "" || key === "desktopCodexConnection" && v === "off" || /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(v); - if (key === "desktopClaudeIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); + if (key === "desktopClaudeIdentity" || key === "desktopAntigravityIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); return true; } export class Preferences { diff --git a/src/main/providers/antigravity-desktop.ts b/src/main/providers/antigravity-desktop.ts new file mode 100644 index 0000000..be83cd5 --- /dev/null +++ b/src/main/providers/antigravity-desktop.ts @@ -0,0 +1,173 @@ +import { createHash } from "node:crypto"; +import { userInfo } from "node:os"; +import { request as httpRequest, type RequestOptions, type ClientRequest, type IncomingMessage } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { Unavailable, quota, window, type QuotaWindow } from "../../domain/models"; +import { selectJSON } from "../select-json"; +import { object, displayText } from "./parsers"; +import { runCommand, type Command } from "./transport"; + +const unavailable = () => new Unavailable("notSignedIn", "Antigravity Desktop"); +const noData = () => new Unavailable("noData"); +const methods = { GetUserStatus: { metadata: { ideName: "antigravity", extensionName: "antigravity", ideVersion: "unknown", locale: "en" } }, RetrieveUserQuotaSummary: { forceRefresh: true } }; +type Method = keyof typeof methods; +interface DesktopServer { pid: number; executable: string; csrf: string; extensionPort?: number; extensionCSRF?: string; ports: number[] } +export interface AntigravityEndpoint { pid: number; port: number; csrf: string; scheme: "https:" | "http:"; extensionPort?: number } +export type AntigravityRPC = (endpoint: AntigravityEndpoint, method: Method, signal: AbortSignal) => Promise; +const portNumber = (raw: string) => { const n = Number(raw); return /^\d+$/.test(raw) && n > 0 && n <= 65535 ? n : undefined; }; +const token = (raw: string | undefined) => raw !== undefined && /^[\x21-\x7e]{1,512}$/.test(raw) ? raw : undefined; +const executable = (path: string) => /^\/.*\/(?:Antigravity|Antigravity IDE)\.app\/Contents\/[^\r\n]*\/language_server(?:_[a-zA-Z0-9_-]+)?$/.test(path); +function flag(args: string, name: string) { + const matches = [...args.matchAll(new RegExp(`(?:^|\\s)--${name}(?:=|\\s+)([^\\s]+)`, "g"))]; + if (matches.length > 1) throw unavailable(); + return matches[0]?.[1]; +} + +// Enumerate executable names first. Only a matching, same-user PID exposes arguments. +export async function discoverAntigravity(command: Command = runCommand, signal?: AbortSignal): Promise { + const options = { signal, timeoutMs: 3000, maxBytes: 1048576 }, uid = userInfo().uid; + const processes = await command("/bin/ps", ["-axo", "pid=,uid=,comm="], options); + const candidates = processes.split("\n").flatMap(line => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + return match && Number(match[2]) === uid && executable(match[3]) ? [{ pid: Number(match[1]), executable: match[3] }] : []; + }); + if (candidates.length !== 1 || !Number.isSafeInteger(candidates[0].pid) || candidates[0].pid <= 0) throw unavailable(); + const candidate = candidates[0]; + const commandLine = (await command("/bin/ps", ["-ww", "-p", String(candidate.pid), "-o", "command="], { ...options, maxBytes: 16384 })).trim(); + if (!commandLine.startsWith(candidate.executable + " ")) throw unavailable(); + const args = commandLine.slice(candidate.executable.length), csrf = token(flag(args, "csrf_token")); + if (!csrf) throw unavailable(); + const declaredPort = flag(args, "extension_server_port"), extensionPort = declaredPort === undefined ? undefined : portNumber(declaredPort); + const declaredToken = flag(args, "extension_server_csrf_token"), extensionCSRF = token(declaredToken); + if (declaredPort !== undefined && extensionPort === undefined || declaredToken !== undefined && !extensionCSRF) throw unavailable(); + const listeners = await command("/usr/sbin/lsof", ["-nP", "-a", "-p", String(candidate.pid), "-iTCP", "-sTCP:LISTEN", "-Fpn"], { ...options, maxBytes: 32768 }); + let owner: number | undefined; + const ports = new Set(); + for (const line of listeners.split("\n")) { + if (line.startsWith("p")) owner = Number(line.slice(1)); + const match = line.match(/^n127\.0\.0\.1:(\d+)$/), port = match && portNumber(match[1]); + if (owner === candidate.pid && port) ports.add(port); + } + if (!ports.size || ports.size > 8) throw unavailable(); + signal?.throwIfAborted(); + return { ...candidate, csrf, extensionPort, extensionCSRF, ports: [...ports].sort((a, b) => a - b) }; +} +function endpoints(server: DesktopServer): AntigravityEndpoint[] { + const common = { pid: server.pid, extensionPort: server.extensionPort }; + const secure: AntigravityEndpoint[] = server.ports.map(port => ({ ...common, port, csrf: server.csrf, scheme: "https:" })); + if (server.extensionPort && server.ports.includes(server.extensionPort)) { + secure.push({ ...common, port: server.extensionPort, csrf: server.extensionCSRF ?? server.csrf, scheme: "http:" }); + } + return secure; +} +class UnsupportedQuotaRPC extends Error {} +type Send = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest; +// TLS relaxation belongs only to this literal loopback request, never a global agent. +export function antigravityRPC(endpoint: AntigravityEndpoint, method: Method, signal: AbortSignal, send: Send = (options, callback) => (options.protocol === "https:" ? httpsRequest : httpRequest)(options, callback)): Promise { + return new Promise((resolve, reject) => { + if (!Object.hasOwn(methods, method) || !["https:", "http:"].includes(endpoint.scheme) || !Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535 || !token(endpoint.csrf) || endpoint.scheme === "http:" && endpoint.port !== endpoint.extensionPort || signal.aborted) { reject(noData()); return; } + const body = JSON.stringify(methods[method]); + let settled = false, request: ClientRequest | undefined; + const finish = (error?: Error, raw?: string) => { + if (settled) return; settled = true; clearTimeout(timer); signal.removeEventListener("abort", abort); + if (error) { request?.destroy(); reject(error); } else resolve(raw!); + }; + const abort = () => finish(noData()); + const timer = setTimeout(abort, 3000); + signal.addEventListener("abort", abort, { once: true }); + try { + request = send({ protocol: endpoint.scheme, hostname: "127.0.0.1", port: endpoint.port, path: `/exa.language_server_pb.LanguageServerService/${method}`, method: "POST", agent: false, ...(endpoint.scheme === "https:" ? { rejectUnauthorized: false } : {}), headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "Connect-Protocol-Version": "1", "X-Codeium-Csrf-Token": endpoint.csrf } }, response => { + const code = response.statusCode; + if (code !== 200) { response.resume(); finish(method === "RetrieveUserQuotaSummary" && (code === 404 || code === 501) ? new UnsupportedQuotaRPC() : noData()); return; } + let bytes = 0; const chunks: Buffer[] = []; + if (Number(response.headers["content-length"]) > 1048576) { response.destroy(); finish(noData()); return; } + response.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 1048576) { response.destroy(); finish(noData()); } else chunks.push(chunk); }); + response.on("error", () => finish(noData())); + response.on("aborted", () => finish(noData())); + response.on("end", () => finish(undefined, Buffer.concat(chunks).toString("utf8"))); + }); + request.on("error", () => finish(noData())); + if (settled) request.destroy(); else request.end(body); + } catch { finish(noData()); } + }); +} +const label = (raw: unknown) => { const value = displayText(raw); return value && !/[\x00-\x1f\x7f]/.test(value) ? value : undefined; }; +const bucketSelection = { bucketId: true, id: true, displayName: true, name: true, disabled: true, remainingFraction: true, remaining: { remainingFraction: true, case: true, value: true }, resetTime: true } as const; +const summarySelection = { groups: { '*': { displayName: true, name: true, buckets: { '*': bucketSelection } } } } as const; +const statusSelection = { code: true, userStatus: { email: true, userTier: { name: true }, planStatus: { planInfo: { planName: true, planDisplayName: true, displayName: true, productName: true, planShortName: true } }, cascadeModelConfigData: { clientModelConfigs: { '*': { label: true, quotaInfo: { remainingFraction: true, resetTime: true } } } } } } as const; +function success(root: Record) { + if (root.code !== undefined && ![0, "0", "ok", "OK", "success", "SUCCESS"].includes(root.code as string | number)) throw noData(); +} +export function antigravityIdentity(raw: string) { + const root = object(selectJSON(raw, statusSelection)); success(root); + const status = object(root.userStatus), email = status.email; + if (typeof email !== "string" || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw unavailable(); + return createHash("sha256").update("antigravity-desktop\0" + email.toLowerCase()).digest("hex"); +} +function reading(name: string | undefined, fraction: unknown, reset: unknown, now: number): QuotaWindow[] { + if (!name || typeof fraction !== "number" || !Number.isFinite(fraction) || fraction < 0 || fraction > 1) return []; + const resetsAt = typeof reset === "string" ? Date.parse(reset) : undefined; + if (reset != null && (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= now)) return []; + return [window(name, (1 - fraction) * 100, resetsAt)]; +} +export function antigravityDesktopQuota(raw: string, statusRaw: string, now: number, legacy = false) { + const statusRoot = object(selectJSON(statusRaw, statusSelection)); success(statusRoot); + const status = object(statusRoot.userStatus), plan = object(object(status.planStatus).planInfo); + let windows: QuotaWindow[]; + if (legacy) { + const configs = object(status.cascadeModelConfigData).clientModelConfigs; + windows = Array.isArray(configs) ? configs.flatMap(config => { const c = object(config), q = object(c.quotaInfo); return reading(label(c.label), q.remainingFraction, q.resetTime, now); }) : []; + } else { + const root = object(selectJSON(raw, { code: true, ...summarySelection, response: summarySelection, summary: summarySelection })); + if ([12, "12", "unimplemented"].includes(root.code as number | string)) throw new UnsupportedQuotaRPC(); + success(root); + const payload = object(root.response ?? root.summary ?? root), groups = payload.groups; + windows = Array.isArray(groups) ? groups.flatMap(value => { + const group = object(value); + return Array.isArray(group.buckets) ? group.buckets.flatMap(value => { + const bucket = object(value), remaining = object(bucket.remaining); + if (!label(bucket.bucketId ?? bucket.id) || bucket.disabled != null && bucket.disabled !== false) return []; + const fraction = bucket.remainingFraction ?? remaining.remainingFraction ?? (remaining.case === "remainingFraction" ? remaining.value : undefined); + const groupName = label(group.displayName ?? group.name), bucketName = label(bucket.displayName ?? bucket.name) ?? label(bucket.bucketId ?? bucket.id); + return reading(groupName && bucketName ? `${groupName} · ${bucketName}` : bucketName, fraction, bucket.resetTime, now); + }) : []; + }) : []; + } + if (!windows.length) throw noData(); + return { ...quota("antigravity", windows, now), planType: label(object(status.userTier).name) ?? [plan.planDisplayName, plan.displayName, plan.productName, plan.planName, plan.planShortName].map(label).find(Boolean) }; +} + +export async function readAntigravityDesktop(command: Command = runCommand, rpc: AntigravityRPC = antigravityRPC, now = Date.now(), signal?: AbortSignal, expectedIdentity?: string) { + const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000); + try { + bounded.throwIfAborted(); + const server = await discoverAntigravity(command, bounded); + const request = async (endpoint: AntigravityEndpoint, method: Method) => { + bounded.throwIfAborted(); + const current = await discoverAntigravity(command, bounded); + if (current.executable !== server.executable || !endpoints(current).some(e => e.pid === endpoint.pid && e.port === endpoint.port && e.scheme === endpoint.scheme && e.csrf === endpoint.csrf)) throw unavailable(); + return rpc(endpoint, method, bounded); + }; + let selected: { endpoint: AntigravityEndpoint; status: string; identity: string } | undefined; + for (const endpoint of endpoints(server)) { + let status: string; + try { status = await request(endpoint, "GetUserStatus"); } catch { bounded.throwIfAborted(); continue; } + // A responding endpoint with missing/changed identity cannot select a different account. + const identity = antigravityIdentity(status); + if (expectedIdentity && identity !== expectedIdentity) throw unavailable(); + selected = { endpoint, status, identity }; break; + } + if (!selected) throw unavailable(); + let q; + try { q = antigravityDesktopQuota(await request(selected.endpoint, "RetrieveUserQuotaSummary"), selected.status, now); } + catch (error) { + if (!(error instanceof UnsupportedQuotaRPC)) throw error; + q = antigravityDesktopQuota("", selected.status, now, true); + } + if (antigravityIdentity(await request(selected.endpoint, "GetUserStatus")) !== selected.identity) throw unavailable(); + const current = await discoverAntigravity(command, bounded); + if (current.pid !== server.pid || current.csrf !== server.csrf || current.executable !== server.executable || !current.ports.includes(selected.endpoint.port)) throw unavailable(); + bounded.throwIfAborted(); + return { identity: selected.identity, quota: q }; + } catch (error) { throw error instanceof Unavailable ? error : unavailable(); } +} diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 01dfb26..9be462a 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -142,12 +142,14 @@ function ConnectionRow({ connection: c, state, action }: { connection: Connectio const busy = c.status === "connecting", collected = state.settings.values[providerKey(c.provider)] === true; const localActive = c.source === "cli" && collected; const localName = c.provider === "claude" ? "Claude Code CLI" : `${providerNames[c.provider]} CLI`; - const accountName = c.provider === "claude" ? "Claude Desktop" : "Browser sign-in"; + const accountName = c.provider === "claude" ? "Claude Desktop" : c.provider === "antigravity" ? "Antigravity Desktop" : "Browser sign-in"; const help = method === "cli" ? c.provider === "claude" ? "Reads Claude Code activity on this Mac. Allowance needs a companion usage file." + : c.provider === "antigravity" ? "Uses your existing agy CLI for allowance and local activity on this Mac." : c.provider === "grok" ? "Reads your existing Grok CLI allowance and local session counts. Grok website and mobile usage are not supported." : "Uses your existing Codex CLI sign-in for allowance and reads token activity on this Mac." : c.provider === "claude" ? "Uses the account signed in to Claude Desktop. You approve access in macOS; allowance depends on your plan." + : c.provider === "antigravity" ? "Uses one running, signed-in Antigravity Desktop app for account allowance. Local CLI activity stays separate." : "Sign in through your browser for account allowance and provider-reported token history. Requires an existing Codex app or CLI."; const reading = state.snapshot.quotas[c.provider]; const status = busy ? "Updating connection…" : c.source === "cli" ? localActive ? "CLI selected. Data appears when available." : "CLI selected. Collection is off." @@ -169,13 +171,13 @@ function ConnectionRow({ connection: c, state, action }: { connection: Connectio {busy ? : method === "cli" ? : c.status === "connected" ? - : c.status !== "unsupported" && } + : c.status !== "unsupported" && } ; } export function Connections({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { - const connections: ConnectionState[] = state.connections ?? ["codex", "claude", "grok"].map(provider => ({ provider: provider as ConnectionState["provider"], source: provider === "grok" ? "cli" : "account", localAvailable: !state.snapshot.demo, status: provider === "grok" ? "unsupported" : "disconnected" })); + const connections: ConnectionState[] = state.connections ?? ["codex", "claude", "antigravity", "grok"].map(provider => ({ provider: provider as ConnectionState["provider"], source: provider === "grok" ? "cli" : "account", localAvailable: !state.snapshot.demo, status: provider === "grok" ? "unsupported" : "disconnected" })); return

Choose how to connect

Choose a method for each provider. Changing the selection takes effect when you use its button.

diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 63dc508..94ab7d2 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -5,7 +5,7 @@ export const statusPages = { codex: "https://status.openai.com/", claude: "https export type Surface = "flyout" | "settings" | "notch" | "tray" | "share"; export enum PanelSize { Small = "small", Medium = "medium", Large = "large" } export const panelScales = { [PanelSize.Small]: 0.8, [PanelSize.Medium]: 1, [PanelSize.Large]: 1.15 }; -export type ConnectionProvider = "codex" | "claude" | "grok"; +export type ConnectionProvider = "codex" | "claude" | "antigravity" | "grok"; export interface ConnectionState { provider: ConnectionProvider; status: "disconnected" | "connecting" | "connected" | "failed" | "unsupported"; source: "cli" | "account"; localAvailable: boolean } export interface SettingsState { providerOrder?: Provider[]; diff --git a/tests/antigravity-desktop.test.ts b/tests/antigravity-desktop.test.ts new file mode 100644 index 0000000..e21494b --- /dev/null +++ b/tests/antigravity-desktop.test.ts @@ -0,0 +1,372 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http"; +import { userInfo } from "node:os"; +import { readAntigravityDesktop, antigravityDesktopQuota, antigravityIdentity, discoverAntigravity, antigravityRPC, type AntigravityEndpoint, type AntigravityRPC } from "../src/main/providers/antigravity-desktop"; +import { type Command } from "../src/main/providers/transport"; +import { Preferences } from "../src/main/preferences"; +import { DesktopConnections } from "../src/main/connections"; +import { compose } from "../src/main/composition"; +import { Coordinator } from "../src/main/coordinator"; +import { projectSettings, parseRequest } from "../src/main/ipc"; +import type { Launch } from "../src/main/launch"; +import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const now = Date.parse("2026-10-16T00:00:00Z"), reset = "2026-10-16T05:00:00Z"; +const path = "/Applications/Antigravity.app/Contents/Resources/app/extensions/antigravity/bin/language_server_macos_arm"; +const csrf = "fixture-csrf-secret", email = "synthetic@example.invalid"; +const status = (account = email) => JSON.stringify({ userStatus: { email: account, userTier: { name: "Pro" }, cascadeModelConfigData: { clientModelConfigs: [{ label: "Legacy model", quotaInfo: { remainingFraction: 0.25, resetTime: reset } }] } }, privateField: "SKIP-ME" }); +const summary = (bucket: Record = {}) => JSON.stringify({ response: { groups: [{ displayName: "Models", buckets: [{ bucketId: "model-session", displayName: "Session", remainingFraction: 0.8, resetTime: reset, ...bucket }] }] }, privateField: "SKIP-ME" }); +const endpoint: AntigravityEndpoint = { pid: 123, port: 4321, scheme: "https:", csrf }; +const roots: string[] = []; +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +function fixture() { + const f = { processes: `123 ${userInfo().uid} ${path}\n`, commandLine: `${path} --csrf_token=${csrf} --extension_server_port 4321`, listeners: "p123\nn127.0.0.1:4321\n", status: status(), summary: summary(), saved: new Map() }; + const command = vi.fn(async (binary, args, options) => { + options?.signal?.throwIfAborted(); + if (binary === "/usr/bin/defaults") { + const [action, , key, , value] = args; + if (action === "write") { f.saved.set(key, value); return ""; } + if (!f.saved.has(key)) throw new Error("Absent synthetic preference"); + return action === "read-type" ? "string" : f.saved.get(key)!; + } + expect(options?.timeoutMs).toBe(3000); expect(options?.maxBytes).toBeLessThanOrEqual(1048576); + if (binary === "/bin/ps" && args[0] === "-axo") return f.processes; + if (binary === "/bin/ps" && args[0] === "-ww") { expect(args).toEqual(["-ww", "-p", "123", "-o", "command="]); return f.commandLine; } + if (binary === "/usr/sbin/lsof") { expect(args).toEqual(["-nP", "-a", "-p", "123", "-iTCP", "-sTCP:LISTEN", "-Fpn"]); return f.listeners; } + throw new Error("Forbidden non-fixture command"); + }); + const rpc = vi.fn(async (_endpoint, method, signal) => { signal.throwIfAborted(); return method === "GetUserStatus" ? f.status : f.summary; }); + return { f, command, rpc }; +} +function sendFixture(code = 200, chunks: string[] = [summary()], headers: Record = {}, stall = false) { + const requests: { options: RequestOptions; body: string }[] = [], destroyed = vi.fn(); + const send = (options: RequestOptions, callback: (response: IncomingMessage) => void) => { + const request = new EventEmitter() as ClientRequest; + request.destroy = (() => { destroyed(); return request; }) as typeof request.destroy; + request.end = ((body: string) => { + requests.push({ options, body }); + if (!stall) queueMicrotask(() => { + const response = new PassThrough() as unknown as IncomingMessage; + response.statusCode = code; response.headers = headers; + callback(response); + for (const chunk of chunks) response.push(Buffer.from(chunk)); + response.push(null); + }); + return request; + }) as typeof request.end; + return request; + }; + return { send, requests, destroyed }; +} +async function connectionFixture() { + const f = fixture(), home = mkdtempSync(join(tmpdir(), "meterusage-agy-fixture-")); roots.push(home); + const launch: Launch = { home, data: join(home, "app-data"), env: {}, demo: false, candidate: true }; + const prefs = await Preferences.load(launch, f.command); + const connections = new DesktopConnections(launch, prefs, "/fixture/no-helper", () => {}, f.command, async () => { throw new Error("Forbidden provider HTTP"); }, () => now, f.rpc); + return { ...f, home, launch, prefs, connections }; +} + +it("discovers only one same-user desktop executable, then its arguments and owned loopback listener", async () => { + const { f, command } = fixture(); + f.processes += `999 ${userInfo().uid} /Applications/Other.app/Contents/private\n998 ${userInfo().uid + 1} ${path}\n`; + expect(await discoverAntigravity(command)).toMatchObject({ pid: 123, csrf, ports: [4321] }); + expect(command.mock.calls).toHaveLength(3); + expect(command.mock.calls.some(([, args]) => args.includes("999") || args.includes("998"))).toBe(false); +}); +it.each(["none", "multiple", "other-user", "cli", "suffix-spoof"])("rejects %s executable discovery without reading process arguments", async kind => { + const { f, command } = fixture(); + f.processes = kind === "none" ? "" : kind === "multiple" ? f.processes + `124 ${userInfo().uid} ${path}\n` : kind === "other-user" ? `123 ${userInfo().uid + 1} ${path}` : kind === "cli" ? `123 ${userInfo().uid} /opt/bin/agy` : `123 ${userInfo().uid} ${path.replace("/Antigravity.app/", "/NotAntigravity.app/")}`; + await expect(discoverAntigravity(command)).rejects.toMatchObject({ code: "notSignedIn" }); + expect(command).toHaveBeenCalledTimes(1); +}); +it.each(["missing-token", "duplicate-token", "wrong-process", "invalid-port", "no-listener", "wrong-owner", "wildcard", "remote"])("rejects %s discovery", async kind => { + const { f, command, rpc } = fixture(); + if (kind === "missing-token") f.commandLine = path + " --extension_server_port 4321"; + if (kind === "duplicate-token") f.commandLine += " --csrf_token=other"; + if (kind === "wrong-process") f.commandLine = "/foreign/process --csrf_token=" + csrf; + if (kind === "invalid-port") f.commandLine = f.commandLine.replace("4321", "70000"); + if (kind === "no-listener") f.listeners = ""; + if (kind === "wrong-owner") f.listeners = "p124\nn127.0.0.1:4321"; + if (kind === "wildcard") f.listeners = "p123\nn*:4321"; + if (kind === "remote") f.listeners = "p123\nn192.0.2.1:4321"; + await expect(readAntigravityDesktop(command, rpc, now)).rejects.toMatchObject({ code: "notSignedIn" }); + expect(rpc).not.toHaveBeenCalled(); +}); +it("reads modern quota between matching identities, retaining only projected allowance and an opaque hash", async () => { + const { command, rpc } = fixture(), result = await readAntigravityDesktop(command, rpc, now); + expect(result.identity).toMatch(/^[a-f0-9]{64}$/); + expect(result.quota).toMatchObject({ provider: "antigravity", planType: "Pro", capturedAt: now }); + expect(result.quota.windows[0].usedPercent).toBeCloseTo(20); + expect(result.quota.windows[0]).toMatchObject({ label: "Models · Session", resetsAt: Date.parse(reset) }); + expect(rpc.mock.calls.map(c => c[1])).toEqual(["GetUserStatus", "RetrieveUserQuotaSummary", "GetUserStatus"]); + expect(JSON.stringify(result)).not.toMatch(/SKIP-ME|fixture-csrf|synthetic@/); + expect(antigravityIdentity(status(email.toUpperCase()))).toBe(result.identity); +}); +it.each(["root", "summary", "response", "oneof", "nested", "zero"])("parses the established %s summary shape", shape => { + const data = JSON.parse(summary()), groups = data.response.groups; + if (shape === "oneof") { delete groups[0].buckets[0].remainingFraction; groups[0].buckets[0].remaining = { case: "remainingFraction", value: 0.6 }; } + if (shape === "nested") { delete groups[0].buckets[0].remainingFraction; groups[0].buckets[0].remaining = { remainingFraction: 0.6 }; } + if (shape === "zero") groups[0].buckets[0].remainingFraction = 0; + const raw = JSON.stringify(shape === "root" ? { groups } : shape === "summary" ? { summary: { groups } } : data); + const q = antigravityDesktopQuota(raw, status(), now); + expect(q.windows[0].usedPercent).toBeCloseTo(shape === "zero" ? 100 : ["nested", "oneof"].includes(shape) ? 40 : 20); +}); +it.each([undefined, null, -0.1, 1.1, "0.5", Number.NaN])("keeps a missing or invalid fraction (%s) unavailable in modern and legacy quota", fraction => { + expect(() => antigravityDesktopQuota(summary({ remainingFraction: fraction }), status(), now)).toThrow("No usage yet"); + const legacy = JSON.parse(status()); legacy.userStatus.cascadeModelConfigData.clientModelConfigs[0].quotaInfo.remainingFraction = fraction; + expect(() => antigravityDesktopQuota("", JSON.stringify(legacy), now, true)).toThrow("No usage yet"); +}); +it.each(["{}", '{"response":{"groups":[]}}', '{"summary":{}}', '{"response":{},"groups":[{}]}', '{"code":7}', "malformed"])("does not fall back to legacy quota for modern response %s", async raw => { + const { f, command, rpc } = fixture(); f.summary = raw; + await expect(readAntigravityDesktop(command, rpc, now)).rejects.toMatchObject({ code: expect.stringMatching(/noData|notSignedIn/) }); + expect(rpc.mock.calls.map(c => c[1])).toEqual(["GetUserStatus", "RetrieveUserQuotaSummary"]); +}); +it.each([{ disabled: true }, { resetTime: "bad" }, { resetTime: "2026-10-15T00:00:00Z" }, { bucketId: undefined }])("keeps disabled, expired or unidentified quota unavailable: %j", bucket => { + expect(() => antigravityDesktopQuota(summary(bucket), status(), now)).toThrow("No usage yet"); +}); +it("uses legacy quota only for an explicitly unsupported modern RPC, without inventing weekly allowance", async () => { + const { command, rpc } = fixture(); + const legacy: AntigravityRPC = async (e, method, signal) => method === "RetrieveUserQuotaSummary" ? antigravityRPC(e, method, signal, sendFixture(404).send) : rpc(e, method, signal); + const result = await readAntigravityDesktop(command, legacy, now); + expect(result.quota.windows).toEqual([{ label: "Legacy model", usedPercent: 75, resetsAt: Date.parse(reset) }]); + expect(result.quota.windows.some(w => /weekly/i.test(w.label))).toBe(false); +}); +it.each(["before", "after", "missing", "rejected-status", "closure", "port-owner", "csrf-change"])("rejects %s identity/ownership changes", async change => { + const { f, command, rpc } = fixture(); let reads = 0; + rpc.mockImplementation(async (_e, method) => { + if (method === "GetUserStatus") { + reads++; + if (change === "before" || change === "after" && reads > 1) return status("other@example.invalid"); + if (change === "missing") return '{"userStatus":{}}'; + if (change === "rejected-status") return '{"code":16,"userStatus":{"email":"synthetic@example.invalid"}}'; + return f.status; + } + if (change === "closure") f.processes = ""; + if (change === "port-owner") f.listeners = "p124\nn127.0.0.1:4321"; + if (change === "csrf-change") f.commandLine = f.commandLine.replace(csrf, "changed-token"); + return f.summary; + }); + await expect(readAntigravityDesktop(command, rpc, now, undefined, antigravityIdentity(status()))).rejects.toBeInstanceOf(Error); + if (["before", "missing", "rejected-status"].includes(change)) expect(rpc.mock.calls.map(c => c[1])).toEqual(["GetUserStatus"]); +}); +it("prefers HTTPS and allows HTTP only on the declared PID-owned extension port with its token", async () => { + const { f, command, rpc } = fixture(); + f.listeners += "n127.0.0.1:5432\n"; f.commandLine += " --extension_server_csrf_token=extension-secret"; + rpc.mockImplementation(async (e, method) => { if (e.scheme === "https:") throw new Error("Synthetic TLS failure"); return method === "GetUserStatus" ? f.status : f.summary; }); + await readAntigravityDesktop(command, rpc, now); + expect(rpc.mock.calls.slice(0, 3).map(c => [c[0].scheme, c[0].port])).toEqual([["https:", 4321], ["https:", 5432], ["http:", 4321]]); + expect(rpc.mock.calls[2][0].csrf).toBe("extension-secret"); + f.commandLine = f.commandLine.replace("port 4321", "port 6000"); rpc.mockClear(); + await expect(readAntigravityDesktop(command, rpc, now)).rejects.toMatchObject({ code: "notSignedIn" }); + expect(rpc.mock.calls.every(c => c[0].scheme === "https:")).toBe(true); +}); +it("sanitizes process and transport exceptions", async () => { + const { command, rpc } = fixture(); + rpc.mockRejectedValue(new Error(email + csrf)); + await expect(readAntigravityDesktop(command, rpc, now)).rejects.toThrow("Not signed in to Antigravity Desktop"); + command.mockRejectedValue(new Error(email + csrf)); + await expect(readAntigravityDesktop(command, rpc, now)).rejects.toThrow("Not signed in to Antigravity Desktop"); +}); + +it("sends only the fixed loopback RPC body, path, headers and request-local TLS exception", async () => { + const f = sendFixture(); + await antigravityRPC(endpoint, "RetrieveUserQuotaSummary", new AbortController().signal, f.send); + expect(f.requests).toHaveLength(1); + expect(f.requests[0]).toMatchObject({ options: { hostname: "127.0.0.1", protocol: "https:", port: 4321, path: "/exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary", method: "POST", agent: false, rejectUnauthorized: false, headers: { "X-Codeium-Csrf-Token": csrf, "Connect-Protocol-Version": "1" } }, body: '{"forceRefresh":true}' }); +}); +it.each([301, 302, 307, 308, 401, 429, 500])("rejects HTTP %s without following Location or retaining raw errors", async code => { + const f = sendFixture(code, [email + csrf], { location: "https://provider.invalid/steal" }); + await expect(antigravityRPC(endpoint, "RetrieveUserQuotaSummary", new AbortController().signal, f.send)).rejects.toThrow("No usage yet"); + expect(f.requests).toHaveLength(1); expect(f.destroyed).toHaveBeenCalled(); +}); +it.each(["stream", "length"])("bounds response %s bytes", async kind => { + const f = sendFixture(200, kind === "stream" ? ["a".repeat(700000), "a".repeat(400000)] : [], kind === "length" ? { "content-length": "1048577" } : {}); + await expect(antigravityRPC(endpoint, "GetUserStatus", new AbortController().signal, f.send)).rejects.toThrow("No usage yet"); + expect(f.destroyed).toHaveBeenCalled(); +}); +it.each(["timeout", "abort"])("bounds transport %s and destroys the request", async kind => { + vi.useFakeTimers(); const f = sendFixture(200, [], {}, true), controller = new AbortController(); + const pending = antigravityRPC(endpoint, "GetUserStatus", controller.signal, f.send); + const result = expect(pending).rejects.toThrow("No usage yet"); + if (kind === "timeout") await vi.advanceTimersByTimeAsync(3000); else controller.abort(); + await result; expect(f.destroyed).toHaveBeenCalled(); +}); +it("refuses undeclared HTTP, invalid methods/ports/tokens and pre-cancellation before sending", async () => { + const f = sendFixture(); + for (const e of [{ ...endpoint, scheme: "http:" as const }, { ...endpoint, port: 70000 }, { ...endpoint, csrf: "bad\r\nheader" }]) await expect(antigravityRPC(e, "GetUserStatus", new AbortController().signal, f.send)).rejects.toThrow(); + await expect(antigravityRPC(endpoint, "Other" as "GetUserStatus", new AbortController().signal, f.send)).rejects.toThrow(); + await expect(antigravityRPC(endpoint, "GetUserStatus", AbortSignal.abort(), f.send)).rejects.toThrow(); + expect(f.requests).toHaveLength(0); +}); + +it("connects through the existing controls, persists only an opaque identity and keeps local history detached", async () => { + const { launch, home, prefs, connections, f, command } = await connectionFixture(); + expect(connections.state.find(c => c.provider === "antigravity")).toMatchObject({ source: "cli", status: "disconnected" }); + const open = vi.fn(async () => {}); + await connections.connect("antigravity", open); + const identity = prefs.values.desktopAntigravityIdentity; + expect(identity).toBe(antigravityIdentity(status())); expect(open).not.toHaveBeenCalled(); + expect(connections.state.find(c => c.provider === "antigravity")).toMatchObject({ source: "account", status: "connected" }); + const source = compose(launch, prefs, { connections }).find(s => s.slot.provider === "antigravity")!; + expect(source.accountBound).toBe(true); expect(source).not.toHaveProperty("activity"); expect(source).not.toHaveProperty("usage"); expect(source).not.toHaveProperty("plan"); + await expect(source.quota!()).resolves.toMatchObject({ provider: "antigravity" }); + const published = JSON.stringify({ state: connections.state, settings: projectSettings(prefs.values, [], home) }); + expect(published).not.toContain(String(identity)); expect(published).not.toContain("desktopAntigravityIdentity"); + expect(JSON.stringify([...f.saved])).not.toMatch(/fixture-csrf|synthetic@|SKIP-ME/); + expect(readdirSync(home)).toEqual([]); + const restored = await Preferences.load(launch, command); expect(restored.values.desktopAntigravityIdentity).toBe(identity); + await connections.disconnect("antigravity"); + expect(prefs.values.desktopAntigravityIdentity).toBe("off"); + await expect(source.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + expect(compose(launch, prefs, { connections }).find(s => s.slot.provider === "antigravity")).not.toHaveProperty("usage"); + await connections.useLocal("antigravity"); expect(prefs.values.desktopAntigravityIdentity).toBe(""); + expect(compose(launch, prefs, { connections }).find(s => s.slot.provider === "antigravity")).toHaveProperty("usage"); +}); +it.each(["identity", "visibility", "rollback"])("fails closed and hides raw preference errors on %s save failure", async stage => { + const { prefs, connections } = await connectionFixture(), save = prefs.set.bind(prefs); + vi.spyOn(prefs, "set").mockImplementation(async (key, value) => { + if (stage === "identity" && key === "desktopAntigravityIdentity" || stage !== "identity" && key === "showProviderAntigravity" || stage === "rollback" && key === "desktopAntigravityIdentity" && value === "off") throw new Error(email + csrf); + return save(key, value); + }); + await expect(connections.connect("antigravity", async () => {})).rejects.toThrow("Open one Antigravity Desktop account"); + expect(connections.enabled("antigravity")).toBe(false); + expect(prefs.values.desktopAntigravityIdentity).toBe(stage === "identity" ? "" : "off"); +}); +it.each(["cancel", "stop"])("rejects a late connect result after %s", async action => { + const { connections, prefs, rpc, f } = await connectionFixture(); + let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); + rpc.mockImplementation(async (_e, method) => { if (method === "RetrieveUserQuotaSummary") { await gate; return f.summary; } return f.status; }); + const pending = connections.connect("antigravity", async () => {}), result = expect(pending).rejects.toThrow("Open one Antigravity Desktop account"); + await vi.waitFor(() => expect(rpc.mock.calls.some(c => c[1] === "RetrieveUserQuotaSummary")).toBe(true)); + if (action === "cancel") connections.cancel("antigravity"); else void connections.stop(); + release(); await result; + expect(connections.enabled("antigravity")).toBe(false); expect(prefs.values.desktopAntigravityIdentity).toBe(""); + expect(rpc.mock.calls.map(c => c[1])).toEqual(["GetUserStatus", "RetrieveUserQuotaSummary"]); +}); +it("cancels quota on disconnect and rejects old results even after reconnecting the same identity", async () => { + const { connections, prefs, rpc, f } = await connectionFixture(); await connections.connect("antigravity", async () => {}); + let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); + rpc.mockImplementationOnce(async () => f.status).mockImplementationOnce(async () => { await gate; return f.summary; }); + const pending = connections.quota("antigravity"), result = expect(pending).rejects.toThrow(); + await vi.waitFor(() => expect(rpc).toHaveBeenCalledTimes(5)); + const oldSignal = rpc.mock.calls[4][2]; + await connections.disconnect("antigravity"); expect(oldSignal.aborted).toBe(true); + await connections.connect("antigravity", async () => {}); + release(); await result; expect(connections.enabled("antigravity")).toBe(true); + expect(prefs.values.desktopAntigravityIdentity).toBe(antigravityIdentity(status())); +}); +it("clears account allowance/archive after empty quota or changed identity and keeps private fields out of diagnostics", async () => { + const { launch, prefs, connections, f } = await connectionFixture(); await connections.connect("antigravity", async () => {}); + const source = compose(launch, prefs, { connections }).find(s => s.slot.provider === "antigravity")!; + const coordinator = new Coordinator(launch, prefs, [source], () => now); + await coordinator.refresh(); expect(coordinator.snapshot().quotas.antigravity.status).toBe("value"); + const archive = join(launch.data, "quota-archive.json"), saved = readFileSync(archive, "utf8"); + expect(JSON.parse(saved)).toHaveLength(1); expect(saved).not.toMatch(/fixture-csrf|synthetic@|SKIP-ME/); + f.summary = '{"response":{"groups":[]}}'; await coordinator.refresh(); + expect(coordinator.snapshot().quotas.antigravity).toMatchObject({ status: "missing", code: "noData" }); + expect(coordinator.snapshot().archived.antigravity).toBeUndefined(); + f.summary = summary(); await coordinator.refresh(); + f.status = status("other@example.invalid"); await coordinator.refresh(); + expect(coordinator.snapshot().quotas.antigravity).toMatchObject({ status: "missing", code: "notSignedIn" }); + expect(coordinator.snapshot().archived.antigravity).toBeUndefined(); + expect(coordinator.diagnostics("fixture")).not.toMatch(/fixture-csrf|synthetic@|other@|desktopAntigravityIdentity/); + expect(JSON.parse(readFileSync(archive, "utf8"))).toEqual([]); + coordinator.stop(); +}); +it("denies demo and other-provider isolated profiles before discovery or RPC", async () => { + for (const mode of ["demo", "codexTest", "claudeTest"] as const) { + const { launch, prefs, command, rpc } = await connectionFixture(); launch[mode] = true; prefs.values.desktopAntigravityIdentity = antigravityIdentity(status()); + const connections = new DesktopConnections(launch, prefs, "/fixture/helper", () => {}, command, async () => "", () => now, rpc); + const calls = command.mock.calls.length; + await expect(connections.connect("antigravity", async () => {})).rejects.toThrow(); + await expect(connections.quota("antigravity")).rejects.toThrow(); + expect(command).toHaveBeenCalledTimes(calls); expect(rpc).not.toHaveBeenCalled(); + } +}); +it("allows only the fixed connection actions and refuses renderer preference/endpoint injection", () => { + for (const action of ["connect", "disconnect", "connectionCancel", "connectionLocal"]) expect(parseRequest({ action, provider: "antigravity" })).toEqual({ action, provider: "antigravity" }); + for (const request of [{ action: "setPreference", key: "desktopAntigravityIdentity", value: "off" }, { action: "connect", provider: "antigravity", port: 1234 }, { action: "connect", provider: "antigravity", token: csrf }]) expect(() => parseRequest(request)).toThrow(); +}); + +it.each(["identity", "visibility"])("rolls back cancellation during the %s preference write", async stage => { + const { connections, prefs } = await connectionFixture(), save = prefs.set.bind(prefs); + let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); + let saving = false; + vi.spyOn(prefs, "set").mockImplementation(async (key, value) => { + await save(key, value); + if (stage === "identity" && key === "desktopAntigravityIdentity" && value !== "off" || stage === "visibility" && key === "showProviderAntigravity") { saving = true; await gate; } + }); + const pending = connections.connect("antigravity", async () => {}), result = expect(pending).rejects.toThrow("Open one Antigravity Desktop account"); + await vi.waitFor(() => expect(saving).toBe(true)); + await expect(connections.connect("antigravity", async () => {})).rejects.toThrow("already active"); + await expect(connections.disconnect("antigravity")).rejects.toThrow("unavailable"); + connections.cancel("antigravity"); release(); await result; + expect(prefs.values.desktopAntigravityIdentity).toBe("off"); expect(connections.enabled("antigravity")).toBe(false); +}); +it("rejects a source captured for a previous account after reconnecting another account", async () => { + const { connections, prefs, f, launch, rpc } = await connectionFixture(); + await connections.connect("antigravity", async () => {}); + const oldSource = compose(launch, prefs, { connections }).find(s => s.slot.provider === "antigravity")!; + await connections.disconnect("antigravity"); f.status = status("second@example.invalid"); + await connections.connect("antigravity", async () => {}); + const calls = rpc.mock.calls.length; + await expect(oldSource.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); expect(rpc).toHaveBeenCalledTimes(calls); + await expect(connections.quota("antigravity")).resolves.toMatchObject({ provider: "antigravity" }); +}); + +it("applies the full-read deadline through discovery and pending RPCs", async () => { + const { command, rpc } = fixture(), deadline = new AbortController(); + const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(deadline.signal); + rpc.mockImplementation(async (_e, _method, signal) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new Error(csrf)), { once: true }))); + const pending = readAntigravityDesktop(command, rpc, now), result = expect(pending).rejects.toThrow("Not signed in to Antigravity Desktop"); + await vi.waitFor(() => expect(rpc).toHaveBeenCalledTimes(1)); + deadline.abort(); await result; + expect(timeout).toHaveBeenCalledWith(15000); +}); +it("rejects a malformed disabled flag instead of publishing its fraction", () => { + expect(() => antigravityDesktopQuota(summary({ disabled: "true" }), status(), now)).toThrow("No usage yet"); +}); + +it("reuses display guards for provider labels and plan text before renderer/archive publication", () => { + const raw = JSON.parse(summary()), identity = JSON.parse(status()); + raw.response.groups[0].displayName = email; + raw.response.groups[0].buckets[0].displayName = "Bearer synthetic-token"; + identity.userStatus.userTier.name = email; + const q = antigravityDesktopQuota(JSON.stringify(raw), JSON.stringify(identity), now); + expect(q.windows[0].label).toBe("model-session"); expect(q.planType).toBeUndefined(); + expect(JSON.stringify(q)).not.toMatch(/synthetic@|Bearer/); +}); + +it("settles queued Antigravity consent rollback before shutdown returns", async () => { + const { f, command, launch, prefs, connections } = await connectionFixture(); + const write = command.getMockImplementation()!; + let started!: () => void, release!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + const queued = new Promise(resolve => { release = resolve; }); + command.mockImplementation(async (binary, args, options) => { + if (binary === "/usr/bin/defaults" && args[0] === "write" && args[2] === "menuBarCompactEnabled") { started(); await queued; } + return write(binary, args, options); + }); + const set = prefs.set.bind(prefs); + let other!: Promise; + vi.spyOn(prefs, "set").mockImplementation(async (key, value) => { + const saved = set(key, value); + if (key === "desktopAntigravityIdentity" && value !== "off") other = set("menuBarCompactEnabled", false); + return saved; + }); + const connecting = connections.connect("antigravity", async () => {}).catch(error => error); + await ready; + let stopped = false; + const stopping = connections.stop(1).then(() => { stopped = true; }); + try { + await new Promise(resolve => setTimeout(resolve, 20)); + expect(stopped).toBe(false); + } finally { release(); await other; await connecting; await stopping; } + expect((await connecting).message).toBe("Open one Antigravity Desktop account and sign in, then try connecting again"); + expect(f.saved.get("desktopAntigravityIdentity")).toBe("off"); + expect((await Preferences.load(launch, command)).values.desktopAntigravityIdentity).toBe("off"); +}); diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 9bff488..76d7539 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -99,7 +99,7 @@ function savedPreferences(): Command { } it("allows only fixed provider connection requests and approved HTTPS login hosts", () => { - for (const provider of ["codex", "claude", "grok"]) expect(parseRequest({ action: "connect", provider })).toEqual({ action: "connect", provider }); + for (const provider of ["codex", "claude", "antigravity", "grok"]) expect(parseRequest({ action: "connect", provider })).toEqual({ action: "connect", provider }); for (const raw of [{ action: "connect", provider: "claude", path: "/tmp" }, { action: "connect", provider: "other" }, { action: "setPreference", key: "desktopClaudeIdentity", value: "off" }]) expect(() => parseRequest(raw)).toThrow(); expect(codexLoginURL("https://auth.openai.com/authorize?state=fixture")).toContain("auth.openai.com"); for (const raw of ["http://auth.openai.com", "https://auth.openai.com.evil.invalid", "https://user@chatgpt.com", "https://chatgpt.com:444", "file:///tmp"]) expect(() => codexLoginURL(raw)).toThrow(); @@ -141,10 +141,11 @@ it("publishes CLI versus account choice without exposing saved identities", asyn expect(connections.state).toEqual([ { provider: "codex", status: "disconnected", source: "cli", localAvailable: true }, { provider: "claude", status: "disconnected", source: "cli", localAvailable: true }, + { provider: "antigravity", status: "disconnected", source: "cli", localAvailable: true }, { provider: "grok", status: "unsupported", source: "cli", localAvailable: true }, ]); preferences.values.desktopCodexConnection = "off"; preferences.values.desktopClaudeIdentity = "a".repeat(64); - expect(connections.state.map(s => s.source)).toEqual(["account", "account", "cli"]); + expect(connections.state.map(s => s.source)).toEqual(["account", "account", "cli", "cli"]); expect(JSON.stringify(connections.state)).not.toContain("a".repeat(64)); }); @@ -679,8 +680,8 @@ it("denies non-Codex connections and quota in a live test profile even with pers const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, command, http); const consent = vi.fn(async () => true), open = vi.fn(async () => {}); expect(connections.enabled("claude")).toBe(false); - expect(connections.state).toEqual([{ provider: "codex", status: "disconnected", source: "account", localAvailable: false }, { provider: "claude", status: "unsupported", source: "account", localAvailable: false }, { provider: "grok", status: "unsupported", source: "cli", localAvailable: false }]); - for (const provider of ["claude", "grok"] as const) { + expect(connections.state).toEqual([{ provider: "codex", status: "disconnected", source: "account", localAvailable: false }, { provider: "claude", status: "unsupported", source: "account", localAvailable: false }, { provider: "antigravity", status: "unsupported", source: "cli", localAvailable: false }, { provider: "grok", status: "unsupported", source: "cli", localAvailable: false }]); + for (const provider of ["claude", "antigravity", "grok"] as const) { await expect(connections.connect(provider, open, consent)).rejects.toThrow("unavailable"); await expect(connections.quota(provider)).rejects.toMatchObject({ code: "notSignedIn" }); await expect(connections.disconnect(provider)).rejects.toThrow("unavailable"); @@ -698,8 +699,8 @@ it("denies non-Claude connections, quota and cleanup in a Claude test profile", const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, command, http); const consent = vi.fn(async () => true), open = vi.fn(async () => {}); expect(connections.enabled("codex")).toBe(false); - expect(connections.state).toEqual([{ provider: "codex", status: "unsupported", source: "account", localAvailable: false }, { provider: "claude", status: "disconnected", source: "account", localAvailable: false }, { provider: "grok", status: "unsupported", source: "cli", localAvailable: false }]); - for (const provider of ["codex", "grok"] as const) { + expect(connections.state).toEqual([{ provider: "codex", status: "unsupported", source: "account", localAvailable: false }, { provider: "claude", status: "disconnected", source: "account", localAvailable: false }, { provider: "antigravity", status: "unsupported", source: "cli", localAvailable: false }, { provider: "grok", status: "unsupported", source: "cli", localAvailable: false }]); + for (const provider of ["codex", "antigravity", "grok"] as const) { await expect(connections.connect(provider, open, consent)).rejects.toThrow("unavailable"); await expect(connections.quota(provider)).rejects.toMatchObject({ code: "notSignedIn" }); await expect(connections.disconnect(provider)).rejects.toThrow("unavailable"); diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index a08ac7b..09a4ec5 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -247,6 +247,14 @@ it.each([ expect(await invoke(settings, { action: "providerOrder", order })).toEqual({ ok: true }); expect(values.providerOrder).toEqual(order); expect(settings.contents.send.mock.lastCall?.[1].settings.providerOrder).toEqual(order); + for (const request of [ + { action: "connect", provider: "antigravity", message: "Open one Antigravity Desktop account and sign in, then try connecting again" }, + { action: "disconnect", provider: "antigravity", message: "Could not disconnect Antigravity Desktop" }, + ] as const) { + if (request.action === "connect") connect.mockRejectedValueOnce(new Error(request.message)); + else disconnect.mockRejectedValueOnce(new Error(request.message)); + expect(await invoke(settings, { action: request.action, provider: request.provider })).toEqual({ ok: false, error: request.message }); + } for (const message of [ "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", "Could not read Claude Desktop's account configuration.", diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index fdcc2e5..e640646 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -872,3 +872,21 @@ it("labels Grok as Beta in usage, notch, sharing, setup and provider settings", expect(setup).toContain('Grok Beta'); expect(setup).toContain('Configure Grok'); }); + +it("keeps Antigravity Desktop explicit and dispatches only the selected connection action", () => { + const view = state(), action = vi.fn(async () => {}), method = vi.fn(); + view.snapshot.demo = false; + view.connections = [{ provider: "antigravity", source: "cli", localAvailable: true, status: "disconnected" }]; + const row = Connections({ state: view, action }).props.children[3].props.children[0]; + vi.mocked(useState).mockReturnValueOnce(["account", method]).mockReturnValueOnce([true, () => {}]); + const rendered = row.type(row.props), detail = rendered.props.children[1]; + detail.props.children[0].props.children[1].props.onChange({ target: { value: "account" } }); + expect(method).toHaveBeenCalledWith("account"); expect(action).not.toHaveBeenCalled(); + detail.props.children[4].props.children[1].props.onClick(); + expect(action).toHaveBeenCalledWith({ action: "connect", provider: "antigravity" }); + view.connections[0].source = "account"; + const markup = renderToStaticMarkup(createElement(Connections, { state: view, action })); + expect(markup).toContain('aria-label="Antigravity connection method"'); + expect(markup).toContain('>Antigravity Desktop'); expect(markup).toContain('>Antigravity CLI'); + expect(markup).toContain("Connect Antigravity Desktop"); expect(markup).not.toContain("Sign in to Codex"); +});