From 69407d832b0e566085c7e31b39574538ddf38a53 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:36:15 -0400 Subject: [PATCH 1/4] feat: connect Grok Bot Desktop and polish provider setup --- CHANGELOG.md | 31 ++ README.md | 5 +- Scripts/electron/desktop-keychain.swift | 8 +- docs/DESKTOP-CONNECTIONS.md | 99 ++++- docs/KB.md | 40 +- docs/PRIVACY.md | 97 ++++- .../0026-antigravity-desktop-local-quota.md | 58 +++ docs/adr/0027-grok-cursor-closed-reader.md | 49 +++ docs/adr/0028-grok-bot-desktop-reader.md | 58 +++ docs/adr/README.md | 3 + docs/electron/README.md | 37 +- src/main/composition.ts | 13 +- src/main/connections.ts | 138 ++++-- src/main/electron.ts | 15 +- src/main/ipc.ts | 2 +- src/main/preferences.ts | 11 +- src/main/providers/antigravity-desktop.ts | 184 ++++++++ src/main/providers/cursor-grok.ts | 95 +++++ src/main/providers/grok-bot-desktop.ts | 115 +++++ src/main/providers/transport.ts | 13 +- src/main/select-json.ts | 7 +- src/renderer/app.tsx | 46 +- src/shared/ipc.ts | 4 +- tests/antigravity-desktop.test.ts | 398 ++++++++++++++++++ tests/connections.test.ts | 27 +- tests/cursor-grok.test.ts | 287 +++++++++++++ tests/electron-lifecycle.test.ts | 24 +- tests/grok-bot-desktop.test.ts | 232 ++++++++++ tests/ipc.test.ts | 6 + tests/opencode-local-setup.test.ts | 76 ++++ tests/preferences.test.ts | 21 + tests/renderer.test.ts | 90 +++- 32 files changed, 2176 insertions(+), 113 deletions(-) create mode 100644 docs/adr/0026-antigravity-desktop-local-quota.md create mode 100644 docs/adr/0027-grok-cursor-closed-reader.md create mode 100644 docs/adr/0028-grok-bot-desktop-reader.md create mode 100644 src/main/providers/antigravity-desktop.ts create mode 100644 src/main/providers/cursor-grok.ts create mode 100644 src/main/providers/grok-bot-desktop.ts create mode 100644 tests/antigravity-desktop.test.ts create mode 100644 tests/cursor-grok.test.ts create mode 100644 tests/grok-bot-desktop.test.ts create mode 100644 tests/opencode-local-setup.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 64533cc..b302e67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,45 @@ 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 +- Connect new Grok Bot accounts through **Grok Bot Desktop (Beta)** after + explicit consent. Read only the active scoped encrypted access token and team + through a fixed Keychain item; use a bounded read-only allowance request with + MeterUsage's own headers. Reject identity/team/store changes and cancelled + results. Keep saved Cursor connections compatible and CLI selection explicit. + No token refresh, app writes or separate xAI sign-in is added. Native store, + Keychain and endpoint compatibility remain unverified; the published build + is unchanged. - Shorten heatmap hover/focus totals to values such as `42.4B tokens` and `1.6K sessions` across local and account history. Keep exact grouped counts in tooltips and accessibility labels. Summary counts compact from 1,000. ### Fixed +- Give 30-day provider activity the same 26-column, seven-row heatmap layout. + Keep older unavailable dates distinct from measured zero-token days. +- Add **Use local OpenCode** setup for key-free Go session history and restore + executable Bun-install discovery for the read-only SQL fallback. +- Name saved legacy Grok connections as Cursor sources while new connections + use Grok Bot Desktop. + +- Group Antigravity Desktop limits under compact model headings and shorten + remaining-allowance labels to match the CLI presentation. Preserve every + reported bucket, its used percentage and reset. Keep account allowance + separate from CLI activity, and describe unavailable saved connections + without claiming a live connection. +- Exclude Apple's system `CursorUIViewService` from the Cursor editor process + guard. Continue rejecting active Cursor processes and unsafe database state. - Show the running app bundle's version in Settings instead of the legacy hardcoded version. Source previews omit the version when none is supplied. diff --git a/README.md b/README.md index b6cd8ff..cc2a52b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ for Codex, Claude, side notch and Settings. The beta includes Codex browser sign-in/account history, optional Claude Desktop allowance, existing CLI sources, compact provider tabs and hover cards, provider reordering and readable token totals. Account coverage depends on the source. -Grok remains Beta and CLI-only; Antigravity desktop login is follow-up work. +Build 54 keeps Grok Beta CLI-only; Antigravity desktop login is follow-up work. +Current source additionally offers opt-in [Grok Bot Desktop Beta](docs/DESKTOP-CONNECTIONS.md#grok-bot-desktop-beta). +Sign in through Grok Bot Desktop first, then approve read-only access. +Native/live support, X and shared-pool coverage remain unverified. Build 54's Settings footer incorrectly says `0.2.41`; the packaged version is `0.3.0-beta.1`. See [known beta limits](docs/BETA.md#known-limits). diff --git a/Scripts/electron/desktop-keychain.swift b/Scripts/electron/desktop-keychain.swift index fc2d1c3..03a7bf1 100644 --- a/Scripts/electron/desktop-keychain.swift +++ b/Scripts/electron/desktop-keychain.swift @@ -2,14 +2,16 @@ import Foundation import LocalAuthentication import Security -// The helper has one fixed read. Background polling cannot open an auth dialog. +// The helper has two fixed provider modes. Background polling cannot open an auth dialog. let context = LAContext() context.interactionNotAllowed = !CommandLine.arguments.contains("--allow-ui") SecKeychainSetUserInteractionAllowed(!context.interactionNotAllowed) +let grok = CommandLine.arguments.contains("--grok-bot") +guard CommandLine.arguments.dropFirst().allSatisfy({ ["--allow-ui", "--grok-bot"].contains($0) }) else { exit(1) } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "Claude Safe Storage", - kSecAttrAccount as String: "Claude Key", + kSecAttrService as String: grok ? "Grok Bot Safe Storage" : "Claude Safe Storage", + kSecAttrAccount as String: grok ? "Grok Bot" : "Claude Key", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecUseAuthenticationContext as String: context diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index 47282e4..a765dc5 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 @@ -34,9 +41,14 @@ below it to apply the choice. Existing profiles keep their current source. Alternatively, choose **Claude Code CLI** and **Use CLI** to read local Claude Code activity. CLI allowance requires a companion usage file; this option does not provide Claude website token history. -- Grok: automatic consumer connection is unavailable. Existing command-based - collection remains available for existing users. It does not establish - browser/mobile consumer support. The setup does not ask for manual numbers. +- Grok: choose **Grok Bot Desktop (Beta)**. Sign in through Grok Bot Desktop + on this Mac first, then choose **Connect Grok Bot Desktop (Beta)** and approve + access and any macOS Keychain prompt. MeterUsage reads the active encrypted + access token and selected team, then requests allowance. It does not initiate + a separate xAI sign-in or renew access. After expiry, sign in through Bot and + reconnect. Existing **Grok CLI** collection remains an explicit alternative. + Native compatibility, X and shared-pool coverage remain unverified. + **Use CLI** first disconnects only the MeterUsage-owned account connection and waits for any Codex logout cleanup to succeed. A failed cleanup keeps local @@ -237,3 +249,84 @@ 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). + +## Grok Bot Desktop Beta + +New connections use the existing Grok Bot Desktop sign-in after explicit +consent. No CLI, copied credential or separate MeterUsage OAuth flow is needed. +The fixed candidate store is `Library/Application Support/Grok Bot/sand-secrets.json` +under the user's home. MeterUsage selects only the active account's scoped +access token and optional selected team. It rejects unsupported versions, +legacy/plaintext envelopes, duplicate keys, expired tokens and ambiguous scope. +Refresh tokens and other accounts' credential values are skipped. + +The bundled helper reads only `Grok Bot Safe Storage` / `Grok Bot` in macOS +Keychain. Explicit connection can show a Keychain prompt; polling forbids UI. +The reader opens no symlinks, bounds the JSON read, and checks store metadata, +account and team before and after the request. These checks detect observed +changes; they do not lock Bot or exclude other writers. It never modifies Bot +stores, refreshes credentials or copies them to disk. + +The fixed read is Connect JSON POST `{}` to +`https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus`, using +MeterUsage's own User-Agent, a bearer access token and an optional selected-team +header. Redirects are refused, each request has a 30-second timeout and a 1 MiB +response cap. Only numeric quota percentage and a reported reset are displayed. +Pooled enterprise allowance, missing quota and contradictory limit flags stay +unavailable. Trials require a future expiry; trial expiry is not a reset. +No cadence, X coverage or separate Cursor allowance is inferred from this pool. + +Only an opaque account/team fingerprint and internal source choice persist. +Cancellation and disconnect prevent private rereads and reject late results. +Connected quota has no local CLI history attached. Ordinary transport failures +can retain dated allowance only after the account and store are reverified. +Source changes clear account-bound quota. Disconnect changes only MeterUsage. + +This private compatibility contract was traced statically from Grok Bot 0.71.2 +and Electron 42.1.0. Store existence/format, the derived Keychain name, and +server acceptance of JSON with MeterUsage's own headers remain **UNVERIFIED** +until native checks. No app machine identity or client impersonation is used. +See [ADR 0028](adr/0028-grok-bot-desktop-reader.md). + +### Saved Cursor connections + +Previously saved Cursor connections retain their original reader until +explicitly disconnected or replaced. A missing internal source marker means +legacy Cursor; all new connections select Bot Desktop. The legacy reader still +requires closed/checkpointed Cursor, immutable SQLite, identity checks and the +fixed Cursor dashboard endpoints. It is never selected as a failed Bot fallback. +See [ADR 0027](adr/0027-grok-cursor-closed-reader.md). + +## OpenCode Go local setup + +Choose **Use local OpenCode** in Connections to collect local Go sessions. +No sign-in or API key is needed for that history. Allowance separately uses an +existing Go key saved by OpenCode. Only `opencode-go` sessions count. The SQL +fallback also discovers executable Bun installs at `~/.bun/bin/opencode`. + +All provider heatmaps share a 26-column, seven-row layout. A 30-day feed keeps +older days unavailable rather than showing invented zero-token readings. diff --git a/docs/KB.md b/docs/KB.md index 43cdaf5..0736440 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -25,9 +25,9 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. require code and asset-routing changes; see the [update-channel prerequisites](VERSIONS.md#update-channel-prerequisites). - Candidate gaps include Settings account overview, seven-day sparkline, reset - timing display, update feedback and tray refresh. Smaller display omissions, - OpenCode Bun fallback discovery, pending AGY/OpenCode onboarding and unresolved - notch-dimension/grid-geometry feedback remain distinct. See the candidate guide. + timing display, update feedback and tray refresh. Smaller display omissions + remain distinct. Current source adds AG Desktop/OpenCode setup, restores Bun + discovery and matches activity grid geometry. See the candidate guide. - Edition documentation was reconciled against revision build source `6ce695324e54bb09bf6e5ab36fd4733875c94427`. - Current product screenshots use build 54's renderer and synthetic demo data. @@ -41,13 +41,25 @@ 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 + sign-in or CLI, Claude Desktop or Claude Code CLI, Antigravity Desktop or + Antigravity CLI, and Grok CLI or opt-in Grok Bot Desktop Beta. 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. @@ -143,13 +155,21 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. profiles retain their preference. [ADR 0014](adr/0014-settings-onboarding.md) records this placement; native acceptance is separate from source validation. -- The Electron candidate implements opt-in desktop account setup in `src/main/connections.ts`, `src/main/providers/claude-desktop.ts` and the renderer. Codex discovers an existing app helper and uses a separate generated keyring-only profile with browser login. Claude uses a fixed native Keychain helper, read-only Desktop cache/cookie selection and the OAuth allowance endpoint after consent. Grok consumer connection remains unavailable. These are source and synthetic-fixture facts, not native or live-provider proof. [Desktop connections](DESKTOP-CONNECTIONS.md) and ADR 0011 define setup, data coverage, privacy and acceptance gaps. Connected-account cards omit local CLI activity, clear old quota on account changes and do not restore quota archives across launches. +- The Electron candidate implements opt-in desktop account setup in `src/main/connections.ts`, `src/main/providers/claude-desktop.ts` and the renderer. Codex discovers an existing app helper and uses a separate generated keyring-only profile with browser login. Claude uses a fixed native Keychain helper, read-only Desktop cache/cookie selection and the OAuth allowance endpoint after consent. New Grok Bot Desktop connections select the active scoped encrypted access token and optional team through a fixed Keychain item and bounded Connect allowance request. Saved Cursor connections retain their immutable SQLite reader and fixed identity-before-allowance requests. Both use opaque account binding. Nonempty WAL/journal files, ambiguous evidence and observed races fail closed. These are source and synthetic-fixture facts, not native or live-provider proof. [Desktop connections](DESKTOP-CONNECTIONS.md), ADR 0011, [ADR 0027](adr/0027-grok-cursor-closed-reader.md) and [ADR 0028](adr/0028-grok-bot-desktop-reader.md) define setup, data coverage, privacy and acceptance gaps. Connected-account cards omit local CLI activity, clear old quota on account changes and do not restore quota archives across launches. + +- Antigravity Desktop quota uses compact model headings and limit labels. + It preserves provider-reported bucket values and resets; duration metadata + is added only for explicit Weekly or 5-hour labels. Missing allowance under + a saved account remains unavailable. This presentation does not establish + that Desktop and the CLI use the same account. The Cursor process guard + excludes only Apple's `CursorUIViewService` under `/System/Library/`, while + retaining active-editor and database guards. - meterusage is a macOS menu-bar app that displays AI coding-assistant quota and usage signals. `README.md` describes provider clusters, quota cards, heatmaps, sparklines, alerts, diagnostics, and a scriptable JSON CLI. - 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. @@ -250,6 +270,12 @@ Reviewed Swift base: `4b913db3a3a8eb2602296489872af2ecaa5d0530`. Source checks d - Electron Diagnostics restores explicit **Send report** through the existing private support relay. Main generates the same allowlisted status summary used by Copy diagnostics with the running app version; renderer IPC accepts intent only. In-flight clicks are deduplicated, uncertain retries reuse the report UUID/snapshot, and a matching HTTP201 receipt supplies the support reference. No raw logs, account labels/IDs, paths, credentials or transcripts are uploaded. See [REPORTING.md](REPORTING.md). Transport fixtures do not prove live Linear delivery. -- Grok is labeled **Beta** in Electron usage cards, setup and provider settings. Official-source desktop research found X Subscription per-seat shared Grok/Cursor usage, unified local Antigravity authentication and API-key-based OpenCode Go setup. These do not establish new MeterUsage connections. See [desktop login research](DESKTOP-LOGIN-RESEARCH.md). +- Grok is labeled **Beta** in Electron usage cards, setup and provider settings. Official-source desktop research found X Subscription per-seat shared Grok/Cursor usage, unified local Antigravity authentication and API-key-based OpenCode Go setup. Those research findings alone do not establish connections. The source-only Bot Desktop Beta implementation is documented in [ADR 0028](adr/0028-grok-bot-desktop-reader.md), with saved Cursor compatibility in ADR 0027; universal shared-pool coverage remains unverified. See [desktop login research](DESKTOP-LOGIN-RESEARCH.md). - Diagnostic report attempts have a 10-minute main-process cooldown persisted as a private native preference before HTTP. Relaunch and failed sends retain the limit. Storage failure prevents transmission; Copy diagnostics remains available. Only the timestamp persists, with no report content, account identifier or receipt. See [REPORTING.md](REPORTING.md). + +- OpenCode Go has an explicit local setup action. Local Go session history needs + no key; allowance uses the existing Go key. CLI fallback includes executable + `~/.bun/bin/opencode`. Coverage remains Go-only. +- Activity heatmaps use one 26-column, seven-row layout. Thirty-day telemetry + leaves older dates unavailable. Local-day and OpenRouter UTC scope are retained. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index bfa96f3..b17996f 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -60,8 +60,9 @@ action. Selecting an option alone performs no account access. Applying CLI selection disconnects only MeterUsage-owned account state and waits for its logout cleanup before enabling local collection. Cleanup failure prevents the switch. Ordinary Disconnect never silently falls back to local data. The -selector adds no credential store, provider endpoint or authentication flow; -CLI sources retain the input and coverage limits above. +CLI transition adds no credential store, provider endpoint or authentication flow; +CLI sources retain the input and coverage limits above. The separately consented +Grok Bot Desktop Beta account option is described below. Codex browser sign-in uses the official app-server helper discovered in an existing Codex app, with existing command installations as a compatibility path. @@ -120,14 +121,94 @@ 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 +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. +Demo mode blocks live connection code before helper or key discovery. +[ADR 0011](adr/0011-desktop-account-connections.md) amends the Codex/Claude +candidate boundary; it does not change the published Swift release. + +Grok Bot Desktop offers the primary opt-in **Connect Grok Bot Desktop (Beta)** +flow under [ADR 0028](adr/0028-grok-bot-desktop-reader.md). After consent, the main +process opens only the fixed `Grok Bot/sand-secrets.json` store under Application +Support and the helper's fixed `Grok Bot Safe Storage` / `Grok Bot` Keychain item. +Only explicit Connect permits Keychain UI; polling forbids it. The bounded JSON +reader rejects symlinks, duplicate keys, unsupported versions and changed stores. +It selects only the active account's scoped access token and optional team; +refresh tokens and other accounts' credential values remain skipped. Plaintext +and legacy unscoped credentials are rejected. Decrypted byte buffers are cleared +when projected; in-memory strings remain subject to garbage collection. + +The full JWT subject hash must match the active account and envelope scopes, +and expiry must exceed sixty seconds. The optional team must be a safe +nonnegative integer. Both account and team bind the stored opaque fingerprint. +Connect JSON POST `{}` goes only to +`https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus` with a +bearer token, optional `x-cursor-team-id` and MeterUsage's own User-Agent. No Bot +machine identity, client impersonation, app-code invocation, token refresh or +inference is used. Redirects are refused; response and request bounds are 1 MiB +and thirty seconds. Account, team and store stability are checked after every +attempt while consent remains valid. Cancellation prevents further private reads. +Only projected quota and a reported reset reach the renderer/archive. Pooled +enterprise allowance and contradictory limit flags stay unavailable; trial expiry +is never treated as a reset. Native store/Keychain compatibility and server +acceptance of the own-client request remain unverified. MeterUsage does not +initiate an xAI OAuth flow or modify Bot's account state. + +Previously saved Grok Bot connections via Cursor retain the legacy reader under +[ADR 0027](adr/0027-grok-cursor-closed-reader.md). New connections always select +Bot Desktop; no failed Bot request falls back to Cursor. Sign in to Cursor, check +allowance, then quit Cursor before polling a saved legacy connection. +Pre/post process and filesystem guards reject +active Cursor, unavailable or ambiguous evidence, nonempty WAL/journal files +and observed changes. These snapshots detect races; they do not exclude other +processes or prove Cursor never ran. The original SQLite database is opened +with `immutable=1&mode=ro`, which disables reader writes, locking and sidecar +bookkeeping regardless of other processes. No credential stores are copied to +temporary files. Read access times are controlled by the filesystem. + +The main process selects only `ItemTable`'s `cursorAuth/accessToken`, requires +JWT expiry beyond 60 seconds, constructs the established +`WorkosCursorSessionToken` cookie, verifies the exact subject through fixed +`https://cursor.com/api/auth/me`, then sends POST `{}` to fixed +`https://cursor.com/api/dashboard/get-sand-usage-status` with +`Origin: https://cursor.com`. Redirects are refused; each request has a +30-second timeout and 1 MiB response cap. Token strings stay in memory and +request headers. They are never rendered, logged or persisted. No refresh or +model requests occur. Only an opaque account fingerprint/source preference and +projected aggregate quota persist; store fingerprints stay in memory. + +The account and store are checked before allowance and after each attempt. +Cancellation or disconnect prevents further credential reads and rejects late +results. Revoked access, missing quota and expiry clear account-bound archives; +ordinary transport failures can retain dated quota only after account/store +verification. After expiry, reopen Cursor and quit it to resume the saved source. +Disconnect and connect again to switch to Bot Desktop. No local CLI +history is attached. Trial expiry is not a replenishment timestamp. Universal +Grok Desktop, X and shared-pool coverage is unverified, as are native/live +compatibility and packaged acceptance. See [setup](DESKTOP-CONNECTIONS.md). ## Selective parsing and display boundary 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..941f107 --- /dev/null +++ b/docs/adr/0026-antigravity-desktop-local-quota.md @@ -0,0 +1,58 @@ +# 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. + +Present modern quota under compact model-group headings. Remove the known +remaining-allowance suffix and shorten explicit Five Hour labels. Keep all +reported buckets, values and resets; assign duration metadata only to explicit +Weekly or 5-hour labels. Unknown model names remain visible. A saved identity +with missing allowance is a saved connection, not proof of a live reading. + +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/0027-grok-cursor-closed-reader.md b/docs/adr/0027-grok-cursor-closed-reader.md new file mode 100644 index 0000000..e2bcf19 --- /dev/null +++ b/docs/adr/0027-grok-cursor-closed-reader.md @@ -0,0 +1,49 @@ +# ADR 0027: Read Grok Bot allowance through closed Cursor + +- Status: Implemented in source; native and live-provider acceptance pending +- Date: 2026-10-16 +- New connections: superseded by [ADR 0028](0028-grok-bot-desktop-reader.md); saved Cursor connections retain this reader +- Amends: ADR 0011's unavailable Grok connection; retains existing CLI choice + +Retain previously saved **Connect via Cursor (Beta)** connections. The user +signs in to Cursor, checks Bot allowance, then quits Cursor. MeterUsage never +stops Cursor. After expiry, reopen Cursor and quit it to resume a saved source. +Disconnect and connect again to switch to Bot Desktop under ADR 0028. This is a +compatibility interface, not a universal Grok Desktop, X or shared-pool contract. + +Open the original Cursor SQLite store with `immutable=1&mode=ro`. SQLite must +perform zero reader writes, including sidecar bookkeeping, regardless of other +processes. Do not copy credential stores or files to temporary disk. Reject +nonempty WAL/journal files rather than silently ignoring uncheckpointed data. +Use pre/post process and filesystem guards, and reject unavailable, ambiguous +or changed evidence. These observations detect races, not mutual exclusion; +they cannot prove Cursor never ran between observations. Operating-system +read access-time updates are outside SQLite's byte-write guarantee. + +The process guard excludes Apple's `CursorUIViewService` only when its +executable is under `/System/Library/`. The macOS cursor UI service is unrelated +to the editor. Other Cursor-named processes still fail closed. + +Select only `ItemTable` key `cursorAuth/accessToken`. Parse the JWT's final +pipe-separated subject component and require expiry more than 60 seconds away. +Keep account identity case-sensitive. Construct the established session cookie, +verify the subject through fixed `https://cursor.com/api/auth/me`, then POST +`{}` to fixed `https://cursor.com/api/dashboard/get-sand-usage-status` with +`Origin: https://cursor.com`. Refuse redirects, cap responses at 1 MiB and each +request at 30 seconds. No refresh or inference requests are permitted. + +Persist only an opaque account fingerprint/source preference and projected +quota. Tokens and store fingerprints stay in main-process memory. Revalidate +account and store before allowance and after attempts. Disconnect, cancellation +and source replacement prevent stale publication and further private reads. +Reuse existing coordinator backoff and account archive clearing. Never attach +local CLI history to connected-account quota. + +`includedLimitZero` overrides the legacy included-limit flag. Trials require a +future `sandTrialExpiresAt`; trial expiry is not a reset. Retain only reported +usage percent and optional reset timestamp without inferring a reset cadence. + +Synthetic reader, protocol, lifecycle, IPC and UI tests are required. Native +Cursor format, packaged Electron SQLite behavior, GUI setup and live provider +support remain unverified. The published Swift release and beta build 54 are +unchanged. See [setup](../DESKTOP-CONNECTIONS.md) and [privacy](../PRIVACY.md). diff --git a/docs/adr/0028-grok-bot-desktop-reader.md b/docs/adr/0028-grok-bot-desktop-reader.md new file mode 100644 index 0000000..b34f96a --- /dev/null +++ b/docs/adr/0028-grok-bot-desktop-reader.md @@ -0,0 +1,58 @@ +# ADR 0028: Read Grok Bot Desktop allowance after consent + +- Status: Source implemented; native and live-provider acceptance pending +- Date: 2026-10-16 +- Amends: ADR 0027 for new connections; saved Cursor sources remain compatible + +New Grok connections use **Connect Grok Bot Desktop (Beta)**. The user signs in +through Bot first, then permits MeterUsage's read-only access. MeterUsage does +not add an xAI sign-in flow, invoke app code, impersonate Bot or refresh tokens. + +The compatibility trace is pinned to installed Bot 0.71.2, Electron 42.1.0. +Static SHA-256 identities inside the app archive: + +- `main-app.cjs`: `90ec55edf69be56c6cfd4fffa98fc45210acccf913b6c41402b5b6cca6fd538e` +- `main-core.cjs`: `6422c2c4755d1722a9b6e842fb7117cf8eecce62d6c6a0bbdea53728999a9558` +- `proto.cjs`: `7727f3027a1bc01890cd07ed2aa4bba0f7b77a9962db110ce94056841bd2f168` + +Require version 1 of `Grok Bot/sand-secrets.json` under Application Support. +Select the string-valued `cursor-accounts` container, its active scope, and only +that account's `cursor-access-token` and optional `cursor-selected-team-id`. +Reject duplicate keys, symlinks, unsupported versions and changed files. Bound +the read to 4 MiB, including growth after stat. Never decode a refresh token or +another account's credential value. Require `scoped:v1` envelopes and bounded +Electron `v10` encryption. Reuse the existing Claude PBKDF2/AES decryptor. + +The helper adds one fixed mode for `Grok Bot Safe Storage` / `Grok Bot` while +preserving its Claude default pair. This pair is derived from matching Electron +42.1.0 source, which uses app name plus ` Safe Storage` as service and app name +as account. Explicit Connect may show Keychain UI; background polling cannot. +No arbitrary Keychain service/account selection or enumeration is permitted. + +Require the full JWT subject hash to match the active and envelope scopes, with +expiry beyond sixty seconds. Validate the optional team as a safe nonnegative +integer. Include both account and team in a provider-scoped opaque fingerprint. +Recheck identity, team and store after requests, including transport failures. +A cancellation or revoked connection prevents every subsequent private read. +Failed preference saves disable collection and restore source metadata safely. + +POST empty Connect JSON only to +`https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus` with +`Connect-Protocol-Version: 1`, bearer auth, optional `x-cursor-team-id` and +MeterUsage's own User-Agent. Refuse redirects, bound responses to 1 MiB and +requests to thirty seconds. Project finite 0-100 quota and optional reported +reset. Reject pooled enterprise allowance and contradictory included-limit +flags. A live trial may show quota, but its expiry is not a reset. Do not infer +cadence or create a second reading for Cursor's shared pool. + +Persist only the internal source marker and opaque identity. Missing source +metadata defaults to the legacy Cursor reader for existing saved connections. +New successful connections save Bot source. No failed Bot request falls back +to Cursor. Connected sources retain account-bound archive clearing and omit +local CLI history. Disconnect changes only MeterUsage state. + +Synthetic tests cover parser/decryption, transport bounds, consent, lifecycle, +races, rollback and saved Cursor behavior. Store existence/native format, derived +Keychain naming, packaged helper behavior and server acceptance of JSON without +Bot's internal metadata remain unverified. The published release is unchanged. +See [privacy](../PRIVACY.md) and [setup](../DESKTOP-CONNECTIONS.md). diff --git a/docs/adr/README.md b/docs/adr/README.md index c8a11cc..00e3f47 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,6 @@ 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 | +| [0027](0027-grok-cursor-closed-reader.md) | Opt-in Grok Bot via closed/checkpointed Cursor with immutable SQLite and account binding. | Source implemented; native/live unverified | +| [0028](0028-grok-bot-desktop-reader.md) | Use consented Grok Bot Desktop scoped credentials and a fixed read-only Connect allowance request for new Grok connections; retain saved Cursor sources. | Source and synthetic proof; native/live unverified | diff --git a/docs/electron/README.md b/docs/electron/README.md index 8e0766d..ebab97f 100644 --- a/docs/electron/README.md +++ b/docs/electron/README.md @@ -25,6 +25,9 @@ Usage. See [ADR 0023](../adr/0023-onboarding-source-choice.md). | Codex CLI | Existing authenticated CLI supplies quota/reset metadata; local rollouts supply tokens, sessions and estimated cost. | Activity on this Mac only. | | Claude Desktop | Explicit consent permits a fixed Keychain helper and read-only Desktop cache/cookie access for current account allowance. | Allowance only. No Desktop chat/token history, local cost, tier or extra-usage credit projection. Compatibility can fail when the active account has no allowance token; paid-account live acceptance remains open. | | Claude Code CLI | Local transcript counters, tier metadata and optional companion quota files. | Local activity; allowance requires the [companion](../COMPANION.md). | +| Antigravity Desktop | Explicit consent reads allowance from one running, signed-in desktop server. | Account allowance only. Local CLI activity stays separate. Live comparison was confirmed by the owner; broad compatibility is unverified. | +| Grok Bot Desktop (Beta), unreleased source | Explicit consent reads the active scoped encrypted Bot credential and requests allowance. | Native Keychain/store and endpoint compatibility remain unverified. Existing saved Cursor connections retain their source. | +| OpenCode Go local | **Use local OpenCode** enables local Go sessions without a sign-in or key. Existing Go authentication also supplies allowance. | Go sessions only. Other providers used through OpenCode are excluded. | | Grok CLI (Beta) | Existing CLI authentication and local session summaries. | Consumer website/mobile sign-in is unsupported. Session/message counts do not establish token totals. | **Use CLI** ends only MeterUsage-owned account state and waits for successful @@ -37,10 +40,10 @@ 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. +The current source adds an OpenCode Go local setup action and Antigravity +Desktop connection. These changes are separate from the published build 54. -- 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. @@ -48,9 +51,8 @@ their collection exists without a connection row. `/connect` in `~/.local/share/opencode/auth.json`. Independently, token history reads `~/.local/share/opencode/opencode.db`, with read-only CLI SQL fallback. Only `opencode-go` sessions count, not every provider used through OpenCode. - A quota key alone cannot supply local history. The candidate fallback omits - the legacy explicit `~/.bun/bin/opencode` probe; when direct DB reads fail and - Bun is absent from the GUI PATH, CLI fallback discovery can fail. + A quota key alone cannot supply local history. The OpenCode-only CLI fallback + checks executable `~/.bun/bin/opencode` installs as well as the existing paths. - OpenRouter uses existing keys for account quota/credit and aggregate activity. That activity is provider account scope, not a local chat count. - Cursor, Copilot CLI and Gemini CLI use optional existing local quota snapshots. @@ -148,10 +150,10 @@ The source comparison identifies five primary UI omissions/reductions: Smaller display gaps include today's session count in token-unavailable telemetry, average tokens/turn and aggregate long-chat count on the main burn card, provider-specific recovery hints, diagnostics copy feedback and a timestamp -on fresh quota-only notch cards. OpenCode Bun fallback discovery is a separate -provider regression. These findings do not establish how many users are affected. -AGY/OpenCode onboarding is pending requested scope, and pin/dots removals are -intentional. This documentation implements none of the proposed fixes. +on fresh quota-only notch cards. These findings do not establish how many users +are affected. Current source adds Antigravity Desktop and OpenCode local setup, +restores Bun discovery and matches heatmap geometry. Pin/dots removals are +intentional. Native and provider acceptance remain separate. ## Build and test @@ -220,3 +222,18 @@ 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). + +All activity heatmaps use the same 26-column, seven-row grid. Feeds with only +30 days keep older dates unavailable. The common layout does not extend the +source history or change local versus provider-account coverage. diff --git a/src/main/composition.ts b/src/main/composition.ts index 6618fdf..4f87a32 100644 --- a/src/main/composition.ts +++ b/src/main/composition.ts @@ -86,6 +86,12 @@ 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 grokIdentity = String(prefs.values.desktopGrokIdentity); 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 +112,10 @@ 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, grokIdentity !== "" ? { + slot: primary("grok"), accountBound: true, + quota: signal => options.connections ? options.connections.quota("grok", signal, grokIdentity) : Promise.reject(new Unavailable("notSignedIn", "Grok Bot")), + } : { 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..eb2201c 100644 --- a/src/main/connections.ts +++ b/src/main/connections.ts @@ -7,6 +7,9 @@ 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 { cursorGrokQuota, cursorIdentity, readCursorCredential } from "./providers/cursor-grok"; +import { readGrokBotCredential, grokBotQuota, grokBotTransport, type GrokBotRPC } from "./providers/grok-bot-desktop"; import { selectJSON } from "./select-json"; import { Unavailable, failed, resetCreditAvailable } from "../domain/models"; import { providerKey, type ConnectionState, type ConnectionProvider } from "../shared/ipc"; @@ -59,18 +62,21 @@ 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) {} + private grokController = 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, readonly antigravity: AntigravityRPC = antigravityRPC, readonly grokBot: GrokBotRPC = grokBotTransport()) {} 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: 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: this.configured(provider) ? "account" : "cli", ...(provider === "grok" && /^[a-f\d]{64}$/.test(String(this.prefs.values.desktopGrokIdentity)) ? { accountMethod: this.prefs.values.desktopGrokSource === "bot" ? "bot" as const : "cursor" as const } : {}), 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: ConnectionProvider) { const value = this.prefs.values[this.key(provider)]; 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: ConnectionProvider) { return this.prefs.values[this.key(provider)] !== ""; } + private key(provider: ConnectionProvider) { return provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : provider === "antigravity" ? "desktopAntigravityIdentity" : "desktopGrokIdentity"; } private binary() { for (const root of ["/Applications", join(this.launch.home, "Applications")]) { const path = join(root, "Codex.app/Contents/Resources/codex"); @@ -92,14 +98,74 @@ 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)) { - if (this.launch.demo || provider === "grok" || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); + private async cursor(signal: AbortSignal, guard: () => void, expected?: string) { + guard(); signal.throwIfAborted(); + const credential = await readCursorCredential(this.launch.home, this.now(), this.command, signal); + const check = () => { + guard(); signal.throwIfAborted(); + if (credential.expiresAt <= this.now() + 60000) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + }; + check(); + if (expected && credential.identity !== expected) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + let raw: string; + try { + cursorIdentity(await this.http(endpoints.cursorIdentity, "grok", credential.cookie, signal), credential); + check(); + const verified = await readCursorCredential(this.launch.home, this.now(), this.command, signal); + check(); + if (verified.identity !== credential.identity || verified.store !== credential.store) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + raw = await this.http(endpoints.cursorGrok, "grok", credential.cookie, signal); + } finally { + // Never reread a private store after cancellation or consent revocation. + check(); + const after = await readCursorCredential(this.launch.home, this.now(), this.command, signal); + if (after.identity !== credential.identity || after.store !== credential.store) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + check(); + } + return { identity: credential.identity, quota: cursorGrokQuota(raw, this.now()) }; + } + private async bot(allowUI: boolean, signal: AbortSignal, guard: () => void, expected?: string) { + const check = () => { guard(); signal.throwIfAborted(); }; + check(); + const credential = await readGrokBotCredential(this.launch.home, this.helper, this.command, this.now(), allowUI, signal, check); + const current = () => { check(); if (credential.expiresAt <= this.now() + 60000) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); }; + current(); + if (expected && credential.identity !== expected) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); + let raw: string; + try { current(); raw = await this.grokBot(credential.token, credential.team, signal); } + finally { + current(); + const after = await readGrokBotCredential(this.launch.home, this.helper, this.command, this.now(), false, signal, current); + current(); + if (after.identity !== credential.identity || after.store !== credential.store) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); + } + return { identity: credential.identity, quota: grokBotQuota(raw, this.now()) }; + } + async quota(provider: ConnectionProvider, signal?: AbortSignal, profile = String(this.prefs.values[provider === "antigravity" ? "desktopAntigravityIdentity" : provider === "grok" ? "desktopGrokIdentity" : "desktopCodexConnection"])) { + if (this.launch.demo || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); + if (provider === "grok") { + const generation = this.grokController; + const guard = () => { if (!this.enabled("grok") || this.grokController !== generation || this.prefs.values.desktopGrokIdentity !== profile) throw new Unavailable("notSignedIn", "Grok Bot"); }; + const source = this.prefs.values.desktopGrokSource; + const read = source === "bot" ? this.bot.bind(this, false) : this.cursor.bind(this); + const result = await read(signal ? AbortSignal.any([signal, generation.signal]) : generation.signal, () => { guard(); if (this.prefs.values.desktopGrokSource !== source) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); }, profile); + guard(); return result.quota; + } if (provider === "codex") { this.checkCodexProfile(profile); const raw = await codexRPC(this.binary(), { env: this.env(profile), signal }, undefined, configuration); 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; @@ -133,12 +199,11 @@ export class DesktopConnections { return object(root.result).outcome === "reset"; } finally { this.resetController = undefined; } } - async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => provider !== "claude") { + async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => provider === "codex" || provider === "antigravity") { if (this.stopped) throw new Error("Connection unavailable"); if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode"); if (this.launch.claudeTest && provider !== "claude") throw new Error("Connection unavailable in Claude test mode"); - if (provider === "grok") throw new Error("Automatic Grok connection is unavailable"); if (this.pending.has(provider) || this.enabled(provider)) throw new Error("Connection already active"); if (provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") { await this.recover(); @@ -148,6 +213,8 @@ export class DesktopConnections { const { controller, finish } = this.begin(provider); let candidateID: string | undefined; let savedIdentity: string | undefined; + const previousGrokSource = this.prefs.values.desktopGrokSource; + let grokSourceSaved = false; let claudeError = "Open Claude Desktop and sign in, then try connecting again"; try { if (!await consent()) return false; @@ -161,6 +228,18 @@ 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 if (provider === "grok") { + const result = await this.bot(true, controller.signal, () => { if (this.stopped || this.pending.get("grok")?.controller !== controller) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); }); + controller.signal.throwIfAborted(); savedIdentity = result.identity; + grokSourceSaved = true; await this.prefs.set("desktopGrokSource", "bot"); + controller.signal.throwIfAborted(); + await this.prefs.set("desktopGrokIdentity", 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 +253,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 +268,31 @@ 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[this.key(provider)] === 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); } 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); + if (provider === "grok" && grokSourceSaved) { + try { await this.prefs.set("desktopGrokSource", previousGrokSource); } + catch { this.prefs.values.desktopGrokSource = previousGrokSource; } + } + 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" : provider === "grok" ? "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect." : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError); } finally { finish(); } } private begin(provider: ConnectionProvider, local = false) { + if (provider === "grok") { this.grokController.abort(); this.grokController = new AbortController(); } 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" || provider === "grok" }); 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: ConnectionProvider, cleanupID?: string) { + const key = this.key(provider); 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); @@ -231,21 +316,20 @@ export class DesktopConnections { if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode"); if (this.launch.claudeTest && provider !== "claude") throw new Error("Connection unavailable in Claude test mode"); - if (provider === "grok" && !local || this.pending.has(provider)) throw new Error("Connection unavailable"); + if (provider === "grok" && this.pending.has(provider)) { const operation = this.pending.get(provider)!; operation.controller.abort(); await operation.done; } + if (this.stopped || this.pending.has(provider)) throw new Error("Connection unavailable"); const wasVisible = this.prefs.values[providerKey(provider)] === true; const id = String(this.prefs.values.desktopCodexCleanup || this.prefs.values.desktopCodexConnection); const { controller, finish } = this.begin(provider, local); try { - if (provider !== "grok") await this.disable(provider, provider === "codex" && id !== "" && id !== "off" ? id : undefined); + await this.disable(provider, provider === "codex" && id !== "" && id !== "off" ? id : undefined); if (provider === "codex" && id !== "" && id !== "off") await this.cleanup(id); if (local) { controller.signal.throwIfAborted(); await this.prefs.set(providerKey(provider), true); controller.signal.throwIfAborted(); - if (provider !== "grok") { - await this.prefs.set(provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity", ""); - controller.signal.throwIfAborted(); - } + await this.prefs.set(this.key(provider), ""); + controller.signal.throwIfAborted(); } this.errors.delete(provider); } catch { @@ -253,10 +337,10 @@ export class DesktopConnections { try { await this.prefs.set(providerKey(provider), wasVisible); } catch { this.prefs.values[providerKey(provider)] = wasVisible; } } - if (local && provider !== "grok" && !this.configured(provider)) { + if (local && !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" : local ? "Could not select Grok CLI" : "Could not disconnect Grok Bot Desktop"); } finally { finish(); } } async recover() { @@ -270,14 +354,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.grokController.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..226bc53 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -49,11 +49,12 @@ 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 connectionKeys = { codex: "desktopCodexConnection", claude: "desktopClaudeIdentity", antigravity: "desktopAntigravityIdentity", grok: "desktopGrokIdentity" } as const; + const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity, antigravity: prefs.values.desktopAntigravityIdentity, grok: prefs.values.desktopGrokIdentity }; 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", "grok"] as const).filter(p => markers[p] !== prefs.values[connectionKeys[p]]); 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[connectionKeys[p]]; coordinator.forgetQuota(p); } coordinator.sources = sources(); } publish(); @@ -64,7 +65,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", "grok"] 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; @@ -230,6 +231,10 @@ async function start(launch: Launch) { case "connect": { if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request"); const connected = await connections.connect(r.provider, url => shell.openExternal(url), async () => { + if (r.provider === "grok") { + const consent = await dialog.showMessageBox(source, { type: "question", title: "Connect Grok Bot Desktop (Beta)?", message: "Allow MeterUsage to read Grok Bot Desktop allowance?", detail: "Sign in through Grok Bot Desktop first. MeterUsage reads its fixed Grok Bot Safe Storage Keychain item and active encrypted access token and team in memory, then requests allowance from api2.cursor.sh. It does not start a separate xAI sign-in, refresh tokens or change Bot files. This Beta compatibility interface and shared-pool coverage are unverified. When access expires, sign in through Bot and reconnect.", buttons: ["Cancel", "Allow access"], defaultId: 0, cancelId: 0, noLink: true }); + return consent.response === 1; + } if (r.provider !== "claude") return true; const consent = await dialog.showMessageBox(source, { type: "question", title: "Connect Claude Desktop?", message: "Allow MeterUsage to read your Claude allowance?", detail: "MeterUsage will read Claude Desktop's current access token through macOS Keychain and request account allowance from Anthropic. It will not read chats, use refresh tokens or change Claude Desktop. This connection depends on an interface that can change.", buttons: ["Cancel", "Allow access"], defaultId: 0, cancelId: 0, noLink: true }); return consent.response === 1; @@ -363,7 +368,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", "Could not disconnect Grok Bot Desktop", "Connections are disabled in demo mode", "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."].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..1701348 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", desktopGrokIdentity: "string", desktopGrokSource: "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,15 +24,15 @@ 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: "", desktopGrokIdentity: "", desktopGrokSource: "cursor", ...Object.fromEntries(providers.flatMap(p => [[providerPreference(p), ["codex", "openCodeGo", "openRouter"].includes(p)], [trayPreference(p), true]])), }; const codexTestPreferences: Record = { - launchAtLogin: false, updateCheckEnabled: false, desktopClaudeIdentity: "off", + launchAtLogin: false, updateCheckEnabled: false, desktopClaudeIdentity: "off", desktopGrokIdentity: "off", ...Object.fromEntries(providers.filter(p => p !== "codex").map(p => [providerPreference(p), false])), }; const claudeTestPreferences: Record = { - launchAtLogin: false, updateCheckEnabled: false, desktopCodexConnection: "off", desktopCodexCleanup: "", + launchAtLogin: false, updateCheckEnabled: false, desktopCodexConnection: "off", desktopCodexCleanup: "", desktopGrokIdentity: "off", ...Object.fromEntries(providers.filter(p => p !== "claude").map(p => [providerPreference(p), false])), }; export function validAccounts(raw: unknown): raw is ManagedAccount[] { @@ -45,12 +45,13 @@ function validate(key: string, v: unknown): v is PreferenceValue { if (kind === "date" || kind === "number") return typeof v === "number" && Number.isFinite(v); if (kind === "boolean") return typeof v === "boolean"; if (typeof v !== "string" || v.length > 100 || /[\r\n\0]/.test(v)) return false; + if (key === "desktopGrokSource") return v === "cursor" || v === "bot"; if (key === "appearanceTheme") return ["system", "light", "dark"].includes(v); if (key === "accentTheme") return ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(v); 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" || key === "desktopGrokIdentity") 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..0cca122 --- /dev/null +++ b/src/main/providers/antigravity-desktop.ts @@ -0,0 +1,184 @@ +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, type QuotaGroup } 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[]; + const quotaGroups: QuotaGroup[] = []; + 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), rawGroupName = label(group.displayName ?? group.name); + const groupName = rawGroupName?.replace(/^Gemini Models$/i, "Gemini").replace(/^Claude and GPT models$/i, "Claude/GPT"); + const groupWindows: QuotaWindow[] = []; + const flat = 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 bucketName = (label(bucket.displayName ?? bucket.name) ?? label(bucket.bucketId ?? bucket.id))?.replace(/ Limit Remaining$/i, "").replace(/^Five Hour$/i, "5-hour"); + const parsed = reading(bucketName, fraction, bucket.resetTime, now); + for (const w of parsed) { + const duration = /^(?:Weekly|Weekly Limit)$/i.test(w.label) ? 10080 : /^(?:5-hour|5-hour Limit)$/i.test(w.label) ? 300 : undefined; + if (duration !== undefined) w.windowDurationMins = duration; + groupWindows.push({ ...w, label: w.label + (/limit/i.test(w.label) ? "" : " limit") }); + } + return parsed.map(w => ({ ...w, label: groupName ? `${groupName} · ${w.label}` : w.label })); + }) : []; + if (groupName && groupWindows.length) quotaGroups.push({ id: `desktop-${quotaGroups.length}`, title: groupName, windows: groupWindows }); + return flat; + }) : []; + } + if (!windows.length) throw noData(); + return { ...quota("antigravity", windows, now), groups: quotaGroups.reduce((count, group) => count + group.windows.length, 0) === windows.length ? quotaGroups : [], 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/main/providers/cursor-grok.ts b/src/main/providers/cursor-grok.ts new file mode 100644 index 0000000..50e722c --- /dev/null +++ b/src/main/providers/cursor-grok.ts @@ -0,0 +1,95 @@ +import { lstatSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; +import { selectJSON } from "../select-json"; +import { date, object } from "./parsers"; +import { runCommand, type Command } from "./transport"; +import { Unavailable, quota, window } from "../../domain/models"; + +const unavailable = () => new Unavailable("notSignedIn", "Grok Bot via Cursor"); +const subject = (value: unknown) => { + if (typeof value !== "string" || value.length > 512) throw unavailable(); + const id = value.split("|").at(-1); + if (!id || !/^[A-Za-z0-9._-]+$/.test(id)) throw unavailable(); + return id; +}; +const identity = (id: string) => createHash("sha256").update(`cursor-grok:${id}`).digest("hex"); +export function cursorCredential(token: string, now: number) { + try { + if (token.length > 16384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) throw unavailable(); + const payload = object(selectJSON(Buffer.from(token.split(".")[1], "base64url").toString("utf8"), { sub: true, exp: true })); + const id = subject(payload.sub); + if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp * 1000 <= now + 60000) throw unavailable(); + return { cookie: `WorkosCursorSessionToken=${id}%3A%3A${token}`, identity: identity(id), expiresAt: payload.exp * 1000 }; + } catch { throw unavailable(); } +} +export function cursorIdentity(raw: string, credential: ReturnType) { + try { + if (identity(subject(object(selectJSON(raw, { sub: true })).sub)) !== credential.identity) throw unavailable(); + } catch { throw unavailable(); } +} +async function closedCursor(command: Command, signal?: AbortSignal) { + // comm excludes process arguments, which can contain private data. + const raw = await command("/bin/ps", ["-axo", "pid=,comm="], { signal, timeoutMs: 5000, maxBytes: 2 * 1048576 }); + const lines = raw.trim().split("\n"); + if (!raw.trim() || lines.some(line => !/^\s*\d+\s+\S.*$/.test(line))) throw unavailable(); + if (lines.some(line => { + const executable = line.replace(/^\s*\d+\s+/, ""); + // Apple's system cursor UI service is unrelated to the Cursor editor. + return /cursor/i.test(executable) && !/^\/System\/Library\/.*\/CursorUIViewService$/.test(executable); + })) throw unavailable(); +} +function storeSnapshot(path: string) { + const directories: string[] = []; + for (let dir = dirname(path); ; dir = dirname(dir)) { directories.push(dir); if (dirname(dir) === dir) break; } + return JSON.stringify([...directories, path, path + "-wal", path + "-shm", path + "-journal"].map((file, index) => { + let stat; + try { stat = lstatSync(file, { bigint: true }); } + catch (error) { if (index > directories.length && (error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + if (index < directories.length ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n) throw unavailable(); + if (file === path && (stat.size < 100n || stat.size > 268435456n) || (file === path + "-wal" || file === path + "-journal") && stat.size !== 0n) throw unavailable(); + // Access times may change on reads. Identity, content-change and sidecar metadata must stay fixed. + return (index > 0 && index < directories.length ? [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid] : [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid, stat.nlink, stat.size, stat.mtimeNs, stat.ctimeNs]).map(String); + })); +} +export async function readCursorCredential(home: string, now: number, command: Command = runCommand, signal?: AbortSignal) { + try { + signal?.throwIfAborted(); + await closedCursor(command, signal); + const path = join(home, "Library/Application Support/Cursor/User/globalStorage/state.vscdb"), before = storeSnapshot(path); + const { DatabaseSync } = await import("node:sqlite"); + signal?.throwIfAborted(); + const uri = pathToFileURL(path); uri.searchParams.set("immutable", "1"); uri.searchParams.set("mode", "ro"); + // immutable disables SQLite writes, locks and sidecar bookkeeping even if another process races us. + const db = new DatabaseSync(uri.href, { readOnly: true, allowExtension: false }); + let token: string; + try { + const rows = db.prepare("SELECT CASE WHEN length(value) <= 32768 THEN value END AS value FROM ItemTable WHERE key = 'cursorAuth/accessToken' LIMIT 2").all(); + if (rows.length !== 1) throw unavailable(); + const value = rows[0].value; + if (typeof value === "string") token = value; + else if (value instanceof Uint8Array && value.length <= 32768) { + const bytes = Buffer.from(value); + const utf16 = bytes.length % 2 === 0 && bytes.every((b, i) => i % 2 ? b === 0 : b > 0 && b < 128); + token = bytes.toString(utf16 ? "utf16le" : "utf8"); + } else throw unavailable(); + } finally { db.close(); } + if (storeSnapshot(path) !== before) throw unavailable(); + await closedCursor(command, signal); + if (storeSnapshot(path) !== before) throw unavailable(); + signal?.throwIfAborted(); + return { ...cursorCredential(token, now), store: createHash("sha256").update(before).digest("hex") }; + } catch { throw unavailable(); } +} +export function cursorGrokQuota(raw: string, now: number) { + try { + const value = object(selectJSON(raw, { usagePercent: true, nextResetTimestampUtc: true, includedLimitZero: true, hasNonZeroIncludedLimit: true, sandTrialExpiresAt: true })); + for (const key of ["includedLimitZero", "hasNonZeroIncludedLimit"]) if (value[key] != null && typeof value[key] !== "boolean") throw new Unavailable("noData"); + const paid = typeof value.includedLimitZero === "boolean" ? !value.includedLimitZero : value.hasNonZeroIncludedLimit === true; + const trial = !paid && typeof value.sandTrialExpiresAt === "string" && (date(value.sandTrialExpiresAt) ?? 0) > now; + const percent = value.usagePercent, reset = paid && typeof value.nextResetTimestampUtc === "string" ? date(value.nextResetTimestampUtc) : undefined; + if ((!paid && !trial) || typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 || paid && value.nextResetTimestampUtc != null && reset === undefined) throw new Unavailable("noData"); + return quota("grok", [window(trial ? "Grok Bot via Cursor · Trial" : "Grok Bot via Cursor", percent, reset)], now); + } catch { throw new Unavailable("noData"); } +} diff --git a/src/main/providers/grok-bot-desktop.ts b/src/main/providers/grok-bot-desktop.ts new file mode 100644 index 0000000..78edb54 --- /dev/null +++ b/src/main/providers/grok-bot-desktop.ts @@ -0,0 +1,115 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { selectJSON } from "../select-json"; +import { object } from "./parsers"; +import { decryptDesktop } from "./claude-desktop"; +import { type Command } from "./transport"; +import { Unavailable, failed, quota, window } from "../../domain/models"; + +const unavailable = () => new Unavailable("notSignedIn", "Grok Bot Desktop"); +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const scopePattern = /^[a-f0-9]{64}$/; +const selected = (raw: string, shape: Parameters[1], onScalar?: (raw: string) => void) => object(selectJSON(raw, shape, onScalar, true)); +function envelope(raw: unknown, scope: string, password: string) { + if (typeof raw !== "string" || raw.length > 65536) throw unavailable(); + const match = /^scoped:v1:([a-f0-9]{64}):([A-Za-z0-9+/]+={0,2})$/.exec(raw); + if (!match || match[1] !== scope) throw unavailable(); + const bytes = Buffer.from(match[2], "base64"); + if (bytes.toString("base64") !== match[2]) throw unavailable(); + const plaintext = decryptDesktop(bytes, password); + try { return plaintext.toString("utf8"); } finally { plaintext.fill(0); } +} +// Only the active access-token/team values are decoded. Refresh tokens stay skipped. +export function grokBotCredential(raw: string, password: string, now: number, onScalar?: (raw: string) => void) { + try { + if (Buffer.byteLength(raw) > 4 * 1048576) throw unavailable(); + const root = selected(raw, { version: true, "cursor-accounts": true }, onScalar); + if (root.version !== 1 || typeof root["cursor-accounts"] !== "string") throw unavailable(); + const accounts = root["cursor-accounts"], active = selected(accounts, { active: true }, onScalar).active; + if (typeof active !== "string" || !scopePattern.test(active)) throw unavailable(); + const values = object(object(selected(accounts, { accounts: { [active]: { "cursor-access-token": true, "cursor-selected-team-id": true } } }, onScalar).accounts)[active]); + const token = envelope(values["cursor-access-token"], active, password); + if (token.length > 16384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) throw unavailable(); + const payload = selected(Buffer.from(token.split(".")[1], "base64url").toString("utf8"), { sub: true, exp: true }, onScalar); + if (typeof payload.sub !== "string" || payload.sub.length < 1 || payload.sub.length > 512 || /[\x00-\x20\x7f]/.test(payload.sub) || hash(payload.sub) !== active || typeof payload.exp !== "number" || !Number.isFinite(payload.exp * 1000) || payload.exp * 1000 <= now + 60000) throw unavailable(); + let team: string | undefined; + if (values["cursor-selected-team-id"] !== undefined) { + team = envelope(values["cursor-selected-team-id"], active, password); + if (!/^(?:0|[1-9]\d*)$/.test(team) || !Number.isSafeInteger(Number(team))) throw unavailable(); + } + return { token, team, identity: hash(`grok-bot-desktop\0${active}\0${team ?? "personal"}`), expiresAt: payload.exp * 1000 }; + } catch { throw unavailable(); } +} +function snapshot(path: string) { + const dirs: string[] = []; + for (let dir = dirname(path); ; dir = dirname(dir)) { dirs.push(dir); if (dirname(dir) === dir) break; } + return JSON.stringify([...dirs, path].map((file, index) => { + const s = lstatSync(file, { bigint: true }); + if (index < dirs.length ? !s.isDirectory() : !s.isFile() || s.nlink !== 1n || s.size <= 0 || s.size > 4 * 1048576) throw unavailable(); + return (index < dirs.length ? [s.dev, s.ino, s.mode, s.uid, s.gid] : [s.dev, s.ino, s.mode, s.uid, s.gid, s.nlink, s.size, s.mtimeNs, s.ctimeNs]).map(String); + })); +} +export async function readGrokBotCredential(home: string, helper: string, command: Command, now: number, allowUI: boolean, signal: AbortSignal, guard: () => void) { + const check = () => { guard(); signal.throwIfAborted(); }; + try { + check(); + const path = join(home, "Library/Application Support/Grok Bot/sand-secrets.json"), before = snapshot(path); + check(); const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + let raw: string; + try { + const stat = fstatSync(fd, { bigint: true }); + const descriptor = () => { const s = fstatSync(fd, { bigint: true }); return [s.dev, s.ino, s.mode, s.uid, s.gid, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String); }; + const expected = JSON.parse(before).at(-1); + if (!stat.isFile() || stat.nlink !== 1n || stat.size > 4 * 1048576 || JSON.stringify(descriptor()) !== JSON.stringify(expected)) throw unavailable(); + check(); const bytes = Buffer.alloc(4 * 1048576 + 1); + let length = 0, count: number; + do { check(); count = readSync(fd, bytes, length, bytes.length - length, null); length += count; } while (count && length < bytes.length); + if (length > 4 * 1048576) throw unavailable(); + raw = bytes.subarray(0, length).toString("utf8"); bytes.fill(0); + check(); if (JSON.stringify(descriptor()) !== JSON.stringify(expected)) throw unavailable(); + } finally { closeSync(fd); } + check(); if (snapshot(path) !== before) throw unavailable(); + check(); + const password = await command(helper, allowUI ? ["--grok-bot", "--allow-ui"] : ["--grok-bot"], { signal, timeoutMs: allowUI ? 60000 : 5000, maxBytes: 4096 }); + check(); const credential = grokBotCredential(raw, password, now); + check(); if (snapshot(path) !== before) throw unavailable(); + return { ...credential, store: hash(before + "\0" + raw) }; + } catch { throw unavailable(); } +} +export function grokBotQuota(raw: string, now: number) { + try { + const v = selected(raw, { usagePercent: true, nextResetTimestampUtc: true, includedLimitZero: true, hasNonZeroIncludedLimit: true, usesPooledEnterpriseAllowance: true, sandTrialExpiresAt: true }); + for (const key of ["includedLimitZero", "hasNonZeroIncludedLimit", "usesPooledEnterpriseAllowance"]) if (v[key] !== undefined && typeof v[key] !== "boolean") throw new Error(); + if (v.usesPooledEnterpriseAllowance === true || v.includedLimitZero === true && v.hasNonZeroIncludedLimit === true || v.includedLimitZero === false && v.hasNonZeroIncludedLimit === false) throw new Error(); + const paid = v.includedLimitZero === false || v.hasNonZeroIncludedLimit === true; + const timestamp = (value: unknown) => { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value)) return NaN; + const n = Date.parse(value); + return Number.isFinite(n) && new Date(n).toISOString().slice(0, 19) === value.slice(0, 19) ? n : NaN; + }; + const trial = !paid && timestamp(v.sandTrialExpiresAt) > now; + const reset = paid && v.nextResetTimestampUtc !== undefined ? timestamp(v.nextResetTimestampUtc) : undefined; + if ((!paid && !trial) || typeof v.usagePercent !== "number" || !Number.isFinite(v.usagePercent) || v.usagePercent < 0 || v.usagePercent > 100 || reset !== undefined && (!Number.isFinite(reset) || reset <= now)) throw new Error(); + return quota("grok", [window(trial ? "Grok Bot Desktop · Trial" : "Grok Bot Desktop", v.usagePercent, reset)], now); + } catch { throw new Unavailable("noData"); } +} +export type GrokBotRPC = (token: string, team: string | undefined, signal: AbortSignal) => Promise; +export function grokBotTransport(fetcher: typeof fetch = fetch): GrokBotRPC { + return async (token, team, signal) => { + const bounded = AbortSignal.any([signal, AbortSignal.timeout(30000)]); + try { + bounded.throwIfAborted(); + if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) || token.length > 16384 || team !== undefined && (!/^(?:0|[1-9]\d*)$/.test(team) || !Number.isSafeInteger(Number(team)))) throw unavailable(); + const response = await fetcher("https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus", { method: "POST", body: "{}", redirect: "error", cache: "no-store", signal: bounded, headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", "User-Agent": "MeterUsage/0.2.41", Authorization: `Bearer ${token}`, ...(team === undefined ? {} : { "x-cursor-team-id": team }) } }); + if (response.status !== 200 || response.redirected) { await response.body?.cancel(); if ([401, 403].includes(response.status)) throw unavailable(); throw failed("grok"); } + if (Number(response.headers.get("content-length")) > 1048576) { await response.body?.cancel(); throw failed("grok"); } + const reader = response.body?.getReader(); if (!reader) throw failed("grok"); + const chunks: Buffer[] = []; let bytes = 0; + try { + while (true) { bounded.throwIfAborted(); const { done, value } = await reader.read(); if (done) break; bytes += value.length; if (bytes > 1048576) throw failed("grok"); chunks.push(Buffer.from(value)); } + bounded.throwIfAborted(); return Buffer.concat(chunks).toString("utf8"); + } finally { await reader.cancel(); } + } catch (error) { throw error instanceof Unavailable ? error : failed("grok"); } + }; +} diff --git a/src/main/providers/transport.ts b/src/main/providers/transport.ts index 71a0622..5fbb37d 100644 --- a/src/main/providers/transport.ts +++ b/src/main/providers/transport.ts @@ -9,7 +9,7 @@ import { object } from "./parsers"; export interface CommandOptions { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeoutMs?: number; maxBytes?: number } export type Command = (binary: string, args: string[], options?: CommandOptions) => Promise; export function cliPath(name: string, home: string, env: NodeJS.ProcessEnv): string { - const paths = ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), join(home, ".local/bin"), ...(env.PATH ?? "/usr/bin:/bin").split(":")]; + const paths = ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), ...(name === "opencode" ? [join(home, ".bun/bin")] : []), join(home, ".local/bin"), ...(env.PATH ?? "/usr/bin:/bin").split(":")]; for (const dir of paths) { const path = join(dir, name); try { accessSync(path, constants.X_OK); return path; } catch { /* Probe common GUI install paths. */ } } throw new Unavailable("cliNotFound", name); } @@ -85,6 +85,8 @@ export const endpoints = { openCodeGo: "https://opencode.ai/zen/go/v1/usage", grok: "https://cli-chat-proxy.grok.com/v1/billing?format=credits", claudeAllowance: "https://api.anthropic.com/api/oauth/usage", + cursorIdentity: "https://cursor.com/api/auth/me", + cursorGrok: "https://cursor.com/api/dashboard/get-sand-usage-status", codexStatus: "https://status.openai.com/api/v2/components.json", claudeStatus: "https://status.claude.com/api/v2/components.json", } as const; @@ -95,13 +97,18 @@ export function httpTransport(fetcher: typeof fetch = fetch): HTTP { if (!(Object.values(endpoints) as string[]).includes(url)) throw failed(provider); const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000); try { - const response = await fetcher(url, { signal: boundedSignal, redirect: "error", cache: "no-store", headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41", ...(url === endpoints.claudeAllowance ? { "anthropic-beta": "oauth-2025-04-20" } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}) } }); + boundedSignal.throwIfAborted(); + const cursor = url === endpoints.cursorIdentity || url === endpoints.cursorGrok; + if (cursor && (provider !== "grok" || !key || key.length > 17000 || !/^WorkosCursorSessionToken=[A-Za-z0-9._-]+%3A%3A[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(key))) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + const response = await fetcher(url, { signal: boundedSignal, redirect: "error", cache: "no-store", ...(url === endpoints.cursorGrok ? { method: "POST", body: "{}" } : {}), headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41", ...(url === endpoints.claudeAllowance ? { "anthropic-beta": "oauth-2025-04-20" } : {}), ...(url === endpoints.cursorGrok ? { "Content-Type": "application/json", Origin: "https://cursor.com" } : {}), ...(key ? cursor ? { Cookie: key } : { Authorization: `Bearer ${key}` } : {}) } }); + boundedSignal.throwIfAborted(); + if (cursor && response.status === 403) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); if (response.status === 401 || response.status === 403 && url === endpoints.claudeAllowance) throw new Unavailable("notSignedIn", provider === "claude" ? "Claude Desktop" : provider === "openCodeGo" ? "OpenCode Go" : provider === "openRouter" ? "OpenRouter" : "Grok"); if (response.status === 403 && url === endpoints.openRouterActivity) throw new Unavailable("dataNotFound", "OpenRouter Management Key"); if (!response.ok || !response.body) throw failed(provider); const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; try { - while (true) { const part = await reader.read(); if (part.done) break; size += part.value.length; if (size > 16 * 1048576) throw failed(provider); chunks.push(part.value); } + while (true) { const part = await reader.read(); if (part.done) break; size += part.value.length; if (size > (cursor ? 1048576 : 16 * 1048576)) throw failed(provider); chunks.push(part.value); } } finally { await reader.cancel(); } return Buffer.concat(chunks).toString("utf8"); } catch (error) { diff --git a/src/main/select-json.ts b/src/main/select-json.ts index 827edaa..871c697 100644 --- a/src/main/select-json.ts +++ b/src/main/select-json.ts @@ -3,7 +3,7 @@ export type Selection = true | { [key: string]: Selection }; // Walk syntax without decoding values outside the declared selection. In // particular, Claude identity fields and transcript content are never passed // to JSON.parse or materialized as JavaScript values. -export function selectJSON(source: string, selection: Selection, onScalar?: (raw: string) => void): unknown { +export function selectJSON(source: string, selection: Selection, onScalar?: (raw: string) => void, rejectDuplicateKeys = false): unknown { let i = 0; const scalar = /(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/y; const bad = (): never => { throw new Error("Invalid selected JSON"); }; @@ -31,13 +31,16 @@ export function selectJSON(source: string, selection: Selection, onScalar?: (raw const object = c === "{", end = object ? "}" : "]"; const selected = typeof shape === "object"; const result: Record | unknown[] | undefined = selected ? object ? Object.create(null) : [] : undefined; + const keys = object && rejectDuplicateKeys ? new Set() : undefined; ws(); if (source[i] === end) { i++; return result; } while (true) { ws(); let key: string | undefined; if (object) { const keyStart = i; stringEnd(); // Object keys describe the selection; unknown values are skipped. - key = selected ? JSON.parse(source.slice(keyStart, i)) as string : undefined; + key = selected || keys ? JSON.parse(source.slice(keyStart, i)) as string : undefined; + if (keys?.has(key!)) return bad(); + keys?.add(key!); ws(); if (source[i++] !== ":") return bad(); } const child = selected ? object ? Object.hasOwn(shape, key!) ? shape[key!] : shape["*"] : shape["*"] : undefined; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 01dfb26..720a651 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -2,7 +2,7 @@ import { normalizeProviderOrder } from "../domain/provider-order"; import { ProviderOrder } from "./provider-order"; import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { type Bridge, type ViewState, type Request, type Surface, type ConnectionState, PanelSize, panelScales, providerKey, trayKey, statusPages } from "../shared/ipc"; -import { type AccountActivity, type Slot, type Provider, type QuotaWindow, type Telemetry, type Daily, type Loaded, type Session, type UsageWindow, clamp, providerNames, primary, slotKey, slotName, headlineWindow, lastBurn, totalTokens, localDay, shiftDay, tokens, resetCreditAvailable, severityNames } from "../domain/models"; +import { type AccountActivity, type Slot, type Provider, type QuotaWindow, type Telemetry, type Daily, type Loaded, type Session, type UsageWindow, clamp, providerNames, primary, slotKey, slotName, headlineWindow, lastBurn, totalTokens, localDay, shiftDay, utcDay, tokens, resetCreditAvailable, severityNames } from "../domain/models"; import { pace, burnActive, etaSeconds, formatETA, paceText } from "../domain/pacing"; import { attributionSessions, stripTotals, failoverNudge, heatmapCells, type HeatmapMode } from "../domain/overview"; import { effectiveWindows, notchEntries } from "../domain/notch"; @@ -74,7 +74,12 @@ function HeatmapCell({ label, readout, amount, peak, first = false, staticExport } function TelemetryView({ provider, t, stats, chart, heatmap, daily, now, staticExport }: { provider: Provider; t: Telemetry; stats: boolean; chart: boolean; heatmap: boolean; daily?: Daily[]; now: number; staticExport: boolean }) { const [mode, setMode] = useState("daily"); - const cells = daily ? heatmapCells(daily, now, mode) : t.dailyHistory; + const today = provider === "openRouter" ? utcDay(now) : localDay(now), offset = 25 * 7 + new Date(today).getUTCDay(); + const history = new Map(t.dailyHistory.map(d => [d.day, d])); + const cells: ({ day: number; tokens: number; sessionCount: number; available?: boolean } | null)[] = daily ? heatmapCells(daily, now, mode) : Array.from({ length: 182 }, (_, i) => { + const day = provider === "openRouter" ? today + (i - offset) * 86400000 : shiftDay(today, i - (25 * 7 + new Date(today).getDay())); + return day > today ? null : history.get(day) ?? { day, tokens: 0, sessionCount: 0, available: false }; + }); const measured = t.lifetimeTokens !== undefined; const amount = (d: { tokens: number; sessionCount: number }) => measured ? d.tokens : d.sessionCount; const metrics = [["Today tokens", t.todayTokens], ["Last 30 days", t.last30DaysTokens], ["Lifetime tokens", t.lifetimeTokens], ["Peak day", t.peakDailyTokens], ["Current streak", `${t.currentStreakDays} days`], ["Longest streak", `${t.longestStreakDays} days`], ["Recorded sessions", t.totalSessions], ["Recorded messages", t.totalMessages], ["Longest chat", t.longestChatSeconds === undefined ? undefined : formatETA(t.longestChatSeconds, true)]] as const; @@ -82,12 +87,12 @@ function TelemetryView({ provider, t, stats, chart, heatmap, daily, now, staticE const metricRows = (rows: typeof availableMetrics) =>
{rows.map(([k, v]) =>
{k}
{typeof v === "number" ? n(v) : v}
)}
; const peak = Math.max(1, ...t.dailyHistory.map(amount)), cellPeak = Math.max(1, ...cells.flatMap(d => d ? [amount(d)] : [])); const exact = new Intl.NumberFormat(), date = (day: number) => new Date(day).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric", ...(provider === "openRouter" ? { timeZone: "UTC" } : {}) }); - return
{stats && <>

Recorded activity

{metricRows(staticExport ? availableMetrics : availableMetrics.slice(0, 2))}{!staticExport && availableMetrics.length > 2 &&
More activity{metricRows(availableMetrics.slice(2))}
}}{chart && `${i * 300 / 29},${48 - amount(d) / peak * 45}`).join(" ")} fill="none" stroke="var(--accent)" strokeWidth="2" />}{heatmap && setMode(e.target.value as HeatmapMode)}>{["daily", "weekly", "cumulative"].map(m => )}}>{cells.map((d, i) => { + return
{stats && <>

Recorded activity

{metricRows(staticExport ? availableMetrics : availableMetrics.slice(0, 2))}{!staticExport && availableMetrics.length > 2 &&
More activity{metricRows(availableMetrics.slice(2))}
}}{chart && `${i * 300 / 29},${48 - amount(d) / peak * 45}`).join(" ")} fill="none" stroke="var(--accent)" strokeWidth="2" />}{heatmap && d?.available === false)} controls={daily && !staticExport && }>{cells.map((d, i) => { if (!d) return ; const scope = !daily || mode === "daily" ? date(d.day) : mode === "weekly" ? `Week of ${date(shiftDay(d.day, -new Date(d.day).getDay()))}` : `Through ${date(d.day)}`; - const label = `${scope}: ${measured ? `${exact.format(d.tokens)} tokens, ${exact.format(d.sessionCount)} sessions` : `${exact.format(d.sessionCount)} sessions, token totals unavailable`}`; - const readout = `${scope} · ${measured ? `${n(d.tokens)} tokens · ${n(d.sessionCount)} sessions` : `${n(d.sessionCount)} sessions · token totals unavailable`}`; - return ; + const label = `${scope}: ${d.available === false ? "Unavailable" : measured ? `${exact.format(d.tokens)} tokens, ${exact.format(d.sessionCount)} sessions` : `${exact.format(d.sessionCount)} sessions, token totals unavailable`}`; + const readout = `${scope} · ${d.available === false ? "Unavailable" : measured ? `${n(d.tokens)} tokens · ${n(d.sessionCount)} sessions` : `${n(d.sessionCount)} sessions · token totals unavailable`}`; + return ; })}}
; } function AccountHeatmap({ reading, staticExport }: { reading: Loaded; staticExport: boolean }) { @@ -107,7 +112,7 @@ export function ProviderCard({ slot, state, action, detail = false, staticExport const t = activity?.telemetry ?? usage?.telemetry, at = activity && lastBurn(activity.sessions), status = extract(s.statuses[slot.provider]); const activeWindow = detail && q && activity ? headlineWindow(slot.provider, effectiveWindows(q)) : undefined; const heatmap = s.appearance.heatmap && (slot.provider !== "claude" || s.appearance.claudeHeatmap) && (slot.provider !== "codex" || s.appearance.codexHeatmap); - return

{slotName(slot)}{slot.provider === "grok" && <> Beta}

{plan && {plan}}
{status &&

{status.stale ? `Last known ${status.severity === "operational" ? "Operational" : status.description} · ${formatETA((s.clock - status.checkedAt) / 1000, true)} ago · ${status.refreshError}` : status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

Last known · {new Date(q.capturedAt).toLocaleString()}
{reason(s.quotas[key])}

}{q.groups.length ? q.groups.map(g =>

{g.title}

{g.windows.map(w => )}
) : (detail ? effectiveWindows(q) : q.windows).map(w => )}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

≈ {money(q.credits.dollarBalance)} equivalent

}{q.credits.usedDollars !== undefined &&

{money(q.credits.usedDollars)} used{q.credits.limitDollars === undefined ? "" : ` of ${money(q.credits.limitDollars)}`}

}
}{q.resetCreditCount !== undefined &&

{q.resetCreditCount} earned reset credits

{q.resetCredits.map(c =>
{c.title}{!staticExport && fresh?.canConsumeReset === true && (!detail || s.appearance.resetButton) && resetCreditAvailable(c, s.clock) && }

{c.status ?? "Status not reported"}{c.expiresAt !== undefined && c.expiresAt <= s.clock ? " · expired" : ""} · {c.expiresAt === undefined ? "Expiry not reported" : `Expires ${new Date(c.expiresAt).toLocaleDateString()}`}

)}
} :

{reason(s.quotas[key])}

}{usage &&

{slot.provider === "openRouter" ? "Account activity" : "Activity on this Mac"}

Sessions
{n(usage.sessionCount)}
Messages
{n(usage.messageCount)}
{usage.tokens !== undefined &&
Measured tokens
{n(totalTokens(usage.tokens))}
}{usage.estimatedCostUSD !== undefined &&
Estimated cost
{money(usage.estimatedCostUSD)}
}

{usage.todayMessageCount > 0 || usage.todaySessionCount > 0 ? `Today: ${n(usage.todaySessionCount)} session${usage.todaySessionCount === 1 ? "" : "s"} · ${n(usage.todayMessageCount)} messages · updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago` : `Updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago · ${usage.tokens !== undefined ? "measured token totals" : "token totals unavailable"}`}

{usage.usageWindows && (staticExport ? :
Usage by period
)}
}{accountActivity && heatmap && }{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && (staticExport ? :
Active window breakdown
)}{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

{session.model} · {session.messageCount} turns · estimated {money(session.estimatedCostUSD)}

)}
}{!activity && !usage && (s.activities[key] || s.usages[key]) &&

{reason(s.activities[key] ?? s.usages[key])}

}{detail && !staticExport &&
}
; + return

{slotName(slot)}{slot.provider === "grok" && <> Beta}

{plan && {plan}}
{status &&

{status.stale ? `Last known ${status.severity === "operational" ? "Operational" : status.description} · ${formatETA((s.clock - status.checkedAt) / 1000, true)} ago · ${status.refreshError}` : status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

Last known · {new Date(q.capturedAt).toLocaleString()}
{reason(s.quotas[key])}

}{q.groups.length ? q.groups.map(g =>

{g.title}

{g.windows.map((w, index) => )}
) : (detail ? effectiveWindows(q) : q.windows).map((w, index) => )}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

≈ {money(q.credits.dollarBalance)} equivalent

}{q.credits.usedDollars !== undefined &&

{money(q.credits.usedDollars)} used{q.credits.limitDollars === undefined ? "" : ` of ${money(q.credits.limitDollars)}`}

}
}{q.resetCreditCount !== undefined &&

{q.resetCreditCount} earned reset credits

{q.resetCredits.map(c =>
{c.title}{!staticExport && fresh?.canConsumeReset === true && (!detail || s.appearance.resetButton) && resetCreditAvailable(c, s.clock) && }

{c.status ?? "Status not reported"}{c.expiresAt !== undefined && c.expiresAt <= s.clock ? " · expired" : ""} · {c.expiresAt === undefined ? "Expiry not reported" : `Expires ${new Date(c.expiresAt).toLocaleDateString()}`}

)}
} :

{reason(s.quotas[key])}

}{usage &&

{slot.provider === "openRouter" ? "Account activity" : "Activity on this Mac"}

Sessions
{n(usage.sessionCount)}
Messages
{n(usage.messageCount)}
{usage.tokens !== undefined &&
Measured tokens
{n(totalTokens(usage.tokens))}
}{usage.estimatedCostUSD !== undefined &&
Estimated cost
{money(usage.estimatedCostUSD)}
}

{usage.todayMessageCount > 0 || usage.todaySessionCount > 0 ? `Today: ${n(usage.todaySessionCount)} session${usage.todaySessionCount === 1 ? "" : "s"} · ${n(usage.todayMessageCount)} messages · updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago` : `Updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago · ${usage.tokens !== undefined ? "measured token totals" : "token totals unavailable"}`}

{usage.usageWindows && (staticExport ? :
Usage by period
)}
}{accountActivity && heatmap && }{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && (staticExport ? :
Active window breakdown
)}{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

{session.model} · {session.messageCount} turns · estimated {money(session.estimatedCostUSD)}

)}
}{!activity && !usage && (s.activities[key] || s.usages[key]) &&

{reason(s.activities[key] ?? s.usages[key])}

}{detail && !staticExport &&
}
; } function RollingUsage({ windows, provider }: { windows: UsageWindow[]; provider: Provider }) { const reference = windows.find(w => w.label === "last 30d"), cost = (reference?.estimatedCostUSD ?? 0) > 0; @@ -137,30 +142,33 @@ function UnifiedStrip({ state }: { state: ViewState }) { } const labels: Record = { launchAtLogin: "Launch at login", quotaAlertsEnabled: "Quota alerts", updateCheckEnabled: "Check for updates", sideNotchPanelEnabled: "Show side notch", menuBarCompactEnabled: "Compact menu bar", showHeatmap: "Daily heatmaps", showClaudeHeatmap: "Claude heatmap", showCodexHeatmap: "Codex heatmap", showPacingBurnRate: "Pacing and burn rate", showActivityTelemetry: "Activity statistics", showDailyActivityChart: "Daily activity chart", showSideNotchResetButton: "Codex limit resets in the side notch" }; function ConnectionRow({ connection: c, state, action }: { connection: ConnectionState; state: ViewState; action: (r: Request) => Promise }) { - const [method, setMethod] = useState(!c.localAvailable && c.provider !== "grok" && c.status !== "unsupported" ? "account" : c.source); + const [method, setMethod] = useState(!c.localAvailable && c.status !== "unsupported" ? "account" : c.source); const [expanded, setExpanded] = useState(false); 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" : c.provider === "grok" ? "Grok Bot Desktop (Beta)" : "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." + : c.provider === "grok" ? "Sign in through Grok Bot Desktop first, then allow read-only Keychain and allowance access. No CLI or separate MeterUsage xAI sign-in is required. Beta compatibility and shared-pool coverage remain unverified." : "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." - : c.status === "connected" ? reading?.status === "value" ? "Connected · Account allowance" : "Connected. Account allowance is unavailable." + : c.status === "connected" ? reading?.status === "value" ? "Connected · Account allowance" : "Connection saved. Account allowance is unavailable." : c.status === "failed" ? "Connection failed. Check the message above and try again." : "Not connected."; - const currentSource = c.source === "cli" ? localName : accountName; + const currentSource = c.source === "cli" ? localName : c.provider === "grok" && c.accountMethod === "cursor" ? "Grok Bot via Cursor (legacy Beta)" : accountName; return
{providerNames[c.provider]}{c.provider === "grok" && <> Beta}

Current source: {currentSource}

Collection: {collected ? "On" : "Off"} · Allowance: {reading?.status === "value" ? "Available" : reading?.status === "missing" ? reading.reason : "Not checked yet"}

{status}

; } 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: "disconnected" })); return

Choose how to connect

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

{state.snapshot.demo &&

Demo uses sample data. Account access is disabled.

} -
{connections.map(c => )}
+
{[...connections.map(c => ), +
+
OpenCode Go
+

Local Go activity needs no sign-in or key. An existing Go key also enables allowance.

+

{state.settings.values.showProviderOpenCodeGo === true ? "Local collection enabled" : "Local collection off"} · Activity: {state.snapshot.usages.openCodeGo?.status === "value" ? "Available" : reason(state.snapshot.usages.openCodeGo)} · Allowance: {state.snapshot.quotas.openCodeGo?.status === "value" ? "Available" : reason(state.snapshot.quotas.openCodeGo)}

+
]} +
{!state.snapshot.appearance.onboarding &&

You can skip setup and change these choices in Settings later.

}
; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 63dc508..918223d 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -5,8 +5,8 @@ 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 interface ConnectionState { provider: ConnectionProvider; status: "disconnected" | "connecting" | "connected" | "failed" | "unsupported"; source: "cli" | "account"; localAvailable: boolean } +export type ConnectionProvider = "codex" | "claude" | "antigravity" | "grok"; +export interface ConnectionState { provider: ConnectionProvider; status: "disconnected" | "connecting" | "connected" | "failed" | "unsupported"; source: "cli" | "account"; accountMethod?: "cursor" | "bot"; localAvailable: boolean } export interface SettingsState { providerOrder?: Provider[]; values: Record; diff --git a/tests/antigravity-desktop.test.ts b/tests/antigravity-desktop.test.ts new file mode 100644 index 0000000..b400718 --- /dev/null +++ b/tests/antigravity-desktop.test.ts @@ -0,0 +1,398 @@ +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("groups Desktop allowance with compact CLI labels while preserving every reported bucket", () => { + const groups = [ + { displayName: "Gemini Models", buckets: [{ bucketId: "weekly", displayName: "Weekly Limit Remaining", remainingFraction: 0.8, resetTime: reset }, { bucketId: "five-hour", displayName: "Five Hour Limit Remaining", remainingFraction: 0.6, resetTime: reset }] }, + { displayName: "Claude and GPT models", buckets: [{ bucketId: "weekly", displayName: "Weekly Limit Remaining", remainingFraction: 0.4, resetTime: reset }, { bucketId: "five-hour", displayName: "Five Hour Limit Remaining", remainingFraction: 0.2, resetTime: reset }] }, + { displayName: "Gemini 4 Argon", buckets: [{ bucketId: "argon", displayName: "Gemini 4 Argon Weekly Limit", remainingFraction: 0.5, resetTime: reset }] }, + { displayName: "Future models", buckets: [{ bucketId: "future-a", displayName: "Burst", remainingFraction: 0.3 }, { bucketId: "future-b", displayName: "Burst", remainingFraction: 0.7 }] }, + ]; + const q = antigravityDesktopQuota(JSON.stringify({ groups }), status(), now); + expect(q.groups.map(g => g.title)).toEqual(["Gemini", "Claude/GPT", "Gemini 4 Argon", "Future models"]); + expect(q.groups[0].windows.map(w => w.label)).toEqual(["Weekly limit", "5-hour limit"]); + expect(q.groups[0].windows.map(w => w.windowDurationMins)).toEqual([10080, 300]); + expect(q.groups[2].windows[0]).toMatchObject({ label: "Gemini 4 Argon Weekly Limit", usedPercent: 50, resetsAt: Date.parse(reset) }); + expect(q.groups[3].windows[0].usedPercent).toBeCloseTo(70); + expect(q.groups[3].windows[1].usedPercent).toBeCloseTo(30); + expect(q.groups[3].windows.every(w => w.windowDurationMins === undefined)).toBe(true); + expect(q.windows).toHaveLength(7); + expect(q.windows[0].usedPercent).toBeCloseTo(20); + expect(q.windows.every(w => !w.label.includes("Remaining"))).toBe(true); +}); +it("keeps unnamed buckets visible when a summary also has named groups", () => { + const raw = JSON.parse(summary()); + raw.response.groups.push({ buckets: [{ bucketId: "unknown", remainingFraction: 0.5 }] }); + const q = antigravityDesktopQuota(JSON.stringify(raw), status(), now); + expect(q.windows.map(w => w.label)).toEqual(["Models · Session", "unknown"]); + expect(q.groups).toEqual([]); +}); +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..09b062f 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: "grok", status: "unsupported", source: "cli", localAvailable: true }, + { provider: "antigravity", status: "disconnected", source: "cli", localAvailable: true }, + { provider: "grok", status: "disconnected", 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)); }); @@ -286,7 +287,7 @@ it("enables only existing Grok local collection without auth or cleanup", async const processes = vi.mocked(spawn).mock.calls.length; await connections.useLocal("grok"); await connections.useLocal("grok"); expect(preferences.values).toEqual({ ...before, showProviderGrok: true }); - expect(connections.state.find(s => s.provider === "grok")).toEqual({ provider: "grok", status: "unsupported", source: "cli", localAvailable: true }); + expect(connections.state.find(s => s.provider === "grok")).toEqual({ provider: "grok", status: "disconnected", source: "cli", localAvailable: true }); expect(command).not.toHaveBeenCalled(); expect(http).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(processes); }); @@ -341,12 +342,13 @@ it("keeps Grok collection disabled in memory if cancelled selection rollback can it("waits past the helper deadline for a queued Grok rollback before completing stop", async () => { const stored = savedPreferences(), launch = launchConfiguration([], {}, temp()); - let releaseSelection!: () => void, releaseQueued!: () => void, queuedStarted!: () => void; + let releaseSelection!: () => void, releaseQueued!: () => void, queuedStarted!: () => void, selectionStarted!: () => void; + const selecting = new Promise(resolve => { selectionStarted = resolve; }); const selection = new Promise(resolve => { releaseSelection = resolve; }); const queued = new Promise(resolve => { releaseQueued = resolve; }); const ready = new Promise(resolve => { queuedStarted = resolve; }); const command: Command = async (binary, args, options) => { - if (args[0] === "write" && args[2] === "showProviderGrok" && args[4] === "true") await selection; + if (args[0] === "write" && args[2] === "showProviderGrok" && args[4] === "true") { selectionStarted(); await selection; } if (args[0] === "write" && args[2] === "menuBarCompactEnabled") { queuedStarted(); await queued; } return stored(binary, args, options); }; @@ -354,6 +356,7 @@ it("waits past the helper deadline for a queued Grok rollback before completing await preferences.set("showProviderGrok", false); const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); const local = connections.useLocal("grok").catch(e => e); + await selecting; const other = preferences.set("menuBarCompactEnabled", false); let finished = false; const stopped = connections.stop(1).then(() => { finished = true; }); releaseSelection(); await ready; @@ -657,13 +660,13 @@ it("requires an explicit consent callback before Claude access", async () => { expect(command).not.toHaveBeenCalled(); expect(http).not.toHaveBeenCalled(); expect(connections.enabled("claude")).toBe(false); expect(preferences.values.desktopClaudeIdentity).toBe(""); }); -it("blocks demo and unavailable Grok access and never falls back after a configured disconnect", async () => { +it("blocks demo and unconsented Grok access and never falls back after a configured disconnect", async () => { const { launch, preferences } = await prefs(undefined, true); const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}, async () => { throw new Error("must not read"); }); await expect(connections.connect("claude", async () => {})).rejects.toThrow("demo mode"); await expect(connections.disconnect("codex")).rejects.toThrow("demo mode"); const live = await prefs(), real = new DesktopConnections(live.launch, live.preferences, "/not-a-helper", () => {}); - await expect(real.connect("grok", async () => {})).rejects.toThrow("unavailable"); + expect(await real.connect("grok", async () => {})).toBe(false); await real.disconnect("codex"); const source = compose(live.launch, live.preferences, { connections: real }).find(s => s.slot.provider === "codex")!; expect(source.activity).toBeUndefined(); expect(source.consumeReset).toBeUndefined(); @@ -679,8 +682,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: "account", 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 +701,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: "account", 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/cursor-grok.test.ts b/tests/cursor-grok.test.ts new file mode 100644 index 0000000..1c34cf5 --- /dev/null +++ b/tests/cursor-grok.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, lstatSync, symlinkSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import { cursorCredential, cursorIdentity, cursorGrokQuota, readCursorCredential } from "../src/main/providers/cursor-grok"; +import { endpoints, httpTransport, type HTTP, type Command } from "../src/main/providers/transport"; +import { DesktopConnections } from "../src/main/connections"; +import { Preferences } from "../src/main/preferences"; +import { compose } from "../src/main/composition"; +import { projectSettings } from "../src/main/ipc"; +import { Coordinator } from "../src/main/coordinator"; +import { Unavailable } from "../src/domain/models"; + +const now = Date.parse("2026-10-16T12:00:00Z"), roots: string[] = [], writers: DatabaseSync[] = [], coordinators: Coordinator[] = []; +const token = (sub = "auth0|fixture-user", exp = now / 1000 + 3600) => `${Buffer.from('{"alg":"fixture"}').toString("base64url")}.${Buffer.from(JSON.stringify({ sub, exp, privateField: "OMIT" })).toString("base64url")}.synthetic`; +const paid = JSON.stringify({ usagePercent: 25, includedLimitZero: false, nextResetTimestampUtc: "2026-10-20T12:00:00Z", privateField: "OMIT" }); +function fixture() { + const home = mkdtempSync(join(tmpdir(), "meterusage-cursor-fixture-")); roots.push(home); + const directory = join(home, "Library/Application Support/Cursor/User/globalStorage"), path = join(directory, "state.vscdb"); + mkdirSync(directory, { recursive: true }); + const db = new DatabaseSync(path); writers.push(db); + db.exec("CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value)"); + const set = (value: string | Uint8Array = token()) => db.prepare("INSERT OR REPLACE INTO ItemTable VALUES ('cursorAuth/accessToken', ?)").run(value); + set(); + return { home, db, path, directory, set }; +} +async function setup() { + const f = fixture(), stored = new Map(); + const command = vi.fn(async (_binary, args) => { + if (_binary === "/bin/ps") return " 1 /sbin/launchd\n 42 /synthetic/MeterUsage"; + const key = args[2]; + if (args[0] === "write") { stored.set(key, args[4]); return ""; } + if (!stored.has(key)) throw new Error("Absent fixture preference"); + return args[0] === "read-type" ? key === "showProviderGrok" ? "boolean" : "string" : stored.get(key)!; + }); + const launch = { home: f.home, data: join(f.home, "app-data"), env: {}, demo: false, candidate: false }; + const prefs = await Preferences.load(launch, command); + let handler: HTTP = async url => url === endpoints.cursorIdentity ? '{"sub":"auth0|fixture-user","email":"OMIT"}' : paid; + const http = vi.fn((...args) => handler(...args)); + const connections = new DesktopConnections(launch, prefs, "/synthetic/helper", () => {}, command, http, () => now); + const connect = async () => { + await prefs.set("desktopGrokSource", "cursor"); + await prefs.set("desktopGrokIdentity", cursorCredential(token(), now).identity); + await prefs.set("showProviderGrok", true); + return true; + }; + return { ...f, prefs, launch, command, stored, http, connections, connect, handler: (value: HTTP) => { handler = value; } }; +} +beforeEach(() => { vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Forbidden live network"); })); }); +afterEach(() => { + for (const c of coordinators.splice(0)) c.stop(); + for (const db of writers.splice(0)) db.close(); + vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); + for (const home of roots.splice(0)) rmSync(home, { recursive: true, force: true }); +}); +it("derives the established cookie and opaque identity with a strict >60 second expiry", () => { + const credential = cursorCredential(token(), now); + expect(credential.cookie).toBe(`WorkosCursorSessionToken=fixture-user%3A%3A${token()}`); + expect(credential.identity).toMatch(/^[a-f0-9]{64}$/); + expect(() => cursorIdentity('{"sub":"fixture-user"}', credential)).not.toThrow(); + expect(() => cursorIdentity('{"sub":"AUTH0|FIXTURE-USER"}', credential)).toThrow("Not signed in"); + for (const raw of ['{}', '{"email":"fixture@example.invalid"}', '{"sub":"other"}']) expect(() => cursorIdentity(raw, credential)).toThrow("Not signed in"); + for (const exp of [now / 1000 - 1, now / 1000 + 60, NaN, Infinity]) expect(() => cursorCredential(token(undefined, exp), now)).toThrow("Not signed in"); + expect(cursorCredential(token(undefined, now / 1000 + 60.001), now).identity).toBe(credential.identity); + for (const raw of ["", "not.jwt", token("a;cookie=bad"), token("x\r\nHeader"), token(""), "a.e30.b", token() + ";other=1", "a." + "a".repeat(17000) + ".b"]) expect(() => cursorCredential(raw, now)).toThrow("Not signed in"); +}); +const closed: Command = async () => " 1 /sbin/launchd\n 42 /synthetic/MeterUsage"; +function close(f: ReturnType) { f.db.close(); writers.splice(writers.indexOf(f.db), 1); } +function snapshot(directory: string) { + return readdirSync(directory).sort().map(name => { + const path = join(directory, name), bytes = readFileSync(path), s = lstatSync(path, { bigint: true }); + return { name, bytes: bytes.toString("base64"), hash: createHash("sha256").update(bytes).digest("hex"), stats: [s.dev, s.ino, s.mode, s.uid, s.gid, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String) }; + }); +} +it.each(["text", "utf8", "utf16", "checkpointed-wal", "empty-sidecars"])("reads a closed/checkpointed store (%s) with all bytes, hashes and change stats preserved", async mode => { + const f = fixture(); + f.db.prepare("INSERT INTO ItemTable VALUES ('unselected/private', ?)").run("OMIT_PRIVATE"); + f.set(mode === "utf8" ? Buffer.from(token()) : mode === "utf16" ? Buffer.from(token(), "utf16le") : token()); + if (mode === "checkpointed-wal") f.db.exec("PRAGMA journal_mode=WAL; PRAGMA wal_checkpoint(TRUNCATE)"); + close(f); + if (mode === "empty-sidecars") for (const suffix of ["-wal", "-shm", "-journal"]) writeFileSync(f.path + suffix, ""); + const before = snapshot(f.directory); + expect(await readCursorCredential(f.home, now, closed)).toMatchObject(cursorCredential(token(), now)); + expect(snapshot(f.directory)).toEqual(before); +}); +it.each(["wal", "journal"])("rejects nonempty %s with every file unchanged, including existing shm", async sidecar => { + const f = fixture(); + if (sidecar === "wal") { f.db.exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0"); f.set(token("uncheckpointed")); } + else { close(f); writeFileSync(f.path + "-journal", "synthetic hot journal"); } + const before = snapshot(f.directory); + await expect(readCursorCredential(f.home, now, closed)).rejects.toMatchObject({ code: "notSignedIn" }); + expect(snapshot(f.directory)).toEqual(before); +}); +it("does not mistake Apple's system cursor UI service for the Cursor editor", async () => { + const f = fixture(); close(f); + const command: Command = async () => " 1 /sbin/launchd\n 854 /System/Library/PrivateFrameworks/Fixture.framework/CursorUIViewService"; + const before = snapshot(f.directory); + expect(await readCursorCredential(f.home, now, command)).toMatchObject(cursorCredential(token(), now)); + expect(snapshot(f.directory)).toEqual(before); +}); +it.each([ + "/Applications/Cursor.app/Contents/MacOS/Cursor", + "/Applications/Cursor.app/Contents/Frameworks/Cursor Helper.app/Contents/MacOS/Cursor Helper (Renderer)", + "/Applications/Other.app/Contents/MacOS/CursorUIViewService", +])("still rejects a Cursor-named process outside Apple's system service: %s", async executable => { + const f = fixture(); close(f); + const command: Command = async () => ` 42 ${executable}`; + await expect(readCursorCredential(f.home, now, command)).rejects.toMatchObject({ code: "notSignedIn" }); +}); +it.each(["active", "post-active", "ambiguous", "unavailable", "stat-change", "sidecar-created", "symlink"])("fails closed for process/filesystem evidence: %s", async action => { + const f = fixture(); close(f); let calls = 0; + if (action === "symlink") { rmSync(f.path); symlinkSync("absent", f.path); } + const command: Command = async () => { + calls++; + if (action === "unavailable") throw new Error("OMIT provider details"); + if (action === "ambiguous") return "unknown process list"; + if (action === "active" || action === "post-active" && calls === 2) return " 42 /Applications/Cursor.app/Contents/MacOS/Cursor"; + if (calls === 2 && action === "stat-change") utimesSync(f.path, new Date(0), new Date(0)); + if (calls === 2 && action === "sidecar-created") writeFileSync(f.path + "-shm", "external race"); + return closed("", []); + }; + await expect(readCursorCredential(f.home, now, command)).rejects.toMatchObject({ code: "notSignedIn" }); + if (action === "active" || action === "ambiguous" || action === "unavailable") expect(calls).toBe(1); +}); +it("fails closed for absent, malformed, expired and duplicate stores without creating sidecars", async () => { + const f = fixture(); + f.db.prepare("DELETE FROM ItemTable").run(); await expect(readCursorCredential(f.home, now, closed)).rejects.toMatchObject({ code: "notSignedIn" }); + for (const value of ["bad", token(undefined, now / 1000 + 60)]) { f.set(value); await expect(readCursorCredential(f.home, now, closed)).rejects.toMatchObject({ code: "notSignedIn" }); } + f.db.exec("DROP TABLE ItemTable; CREATE TABLE ItemTable (key TEXT, value TEXT)"); + f.db.prepare("INSERT INTO ItemTable VALUES ('cursorAuth/accessToken', ?), ('cursorAuth/accessToken', ?)").run(token(), token()); + await expect(readCursorCredential(f.home, now, closed)).rejects.toMatchObject({ code: "notSignedIn" }); close(f); + const names = readdirSync(f.directory); writeFileSync(f.path, "bad"); + await expect(readCursorCredential(f.home, now, closed)).rejects.toMatchObject({ code: "notSignedIn" }); + expect(readdirSync(f.directory)).toEqual(names); rmSync(f.path); + await expect(readCursorCredential(f.home, now, closed)).rejects.toMatchObject({ code: "notSignedIn" }); +}); +it("never opens Cursor's store when cancelled during SQLite import", async () => { + const f = fixture(); close(f); + const controller = new AbortController(), opened = vi.fn(), queried = vi.fn(); + vi.doMock("node:sqlite", () => { + controller.abort(); + return { DatabaseSync: class { + constructor() { opened(); } + prepare() { queried(); return { all: () => [{ value: token() }] }; } + close() {} + } }; + }); + try { + await expect(readCursorCredential(f.home, now, closed, controller.signal)).rejects.toMatchObject({ code: "notSignedIn" }); + expect(controller.signal.aborted).toBe(true); + expect(opened).not.toHaveBeenCalled(); + expect(queried).not.toHaveBeenCalled(); + } finally { vi.doUnmock("node:sqlite"); } +}); +it("keeps established paid, legacy and trial semantics, missing quota and measured zero distinct", () => { + const q = cursorGrokQuota(paid, now); expect(q.windows).toEqual([{ label: "Grok Bot via Cursor", usedPercent: 25, resetsAt: Date.parse("2026-10-20T12:00:00Z") }]); + expect(JSON.stringify(q)).not.toContain("OMIT"); + expect(cursorGrokQuota('{"usagePercent":0,"hasNonZeroIncludedLimit":true}', now).windows[0].usedPercent).toBe(0); + const trial = cursorGrokQuota(JSON.stringify({ usagePercent: 100, includedLimitZero: true, hasNonZeroIncludedLimit: true, nextResetTimestampUtc: "2026-10-20T12:00:00Z", sandTrialExpiresAt: "2026-10-17T12:00:00Z" }), now).windows[0]; + expect(trial.label).toContain("Trial"); expect(trial.resetsAt).toBeUndefined(); expect(trial.windowDurationMins).toBeUndefined(); + for (const value of [{}, { usagePercent: 0 }, { includedLimitZero: false }, { usagePercent: 0, includedLimitZero: true }, { usagePercent: 0, includedLimitZero: true, sandTrialExpiresAt: "2026-10-16T12:00:00Z" }, { usagePercent: 0, sandTrialExpiresAt: "invalid" }, { usagePercent: 0, includedLimitZero: "false" }, { usagePercent: -1, includedLimitZero: false }, { usagePercent: 101, includedLimitZero: false }, { usagePercent: 0, includedLimitZero: false, nextResetTimestampUtc: "bad" }]) expect(() => cursorGrokQuota(JSON.stringify(value), now)).toThrow("No usage yet"); +}); +it("restores a saved legacy Cursor source, keeps its hash private and keeps CLI explicit", async () => { + const f = await setup(); + const providerBefore = snapshot(f.directory); + expect(await f.connect()).toBe(true); + expect(snapshot(f.directory)).toEqual(providerBefore); + const identity = f.prefs.values.desktopGrokIdentity; + expect(f.connections.state.find(c => c.provider === "grok")).toMatchObject({ source: "account", accountMethod: "cursor" }); + expect(identity).toMatch(/^[a-f\d]{64}$/); expect(f.connections.enabled("grok")).toBe(true); + const source = compose(f.launch, f.prefs, { connections: f.connections }).find(s => s.slot.provider === "grok")!; + expect(Object.keys(source).sort()).toEqual(["accountBound", "quota", "slot"]); expect((await source.quota!()).windows[0].usedPercent).toBe(25); + for (const value of [JSON.stringify(f.connections.state), JSON.stringify(projectSettings(f.prefs.values, [], f.home)), JSON.stringify([...f.stored])]) expect(value).not.toMatch(/fixture-user|synthetic|OMIT|WorkosCursor/); + expect(JSON.stringify(projectSettings(f.prefs.values, [], f.home))).not.toContain(identity as string); + const reloaded = await Preferences.load(f.launch, f.command); expect(reloaded.values.desktopGrokIdentity).toBe(identity); + await f.connections.disconnect("grok"); expect(f.prefs.values.desktopGrokIdentity).toBe("off"); + expect(compose(f.launch, f.prefs).find(s => s.slot.provider === "grok")!.usage).toBeUndefined(); + await expect(source.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + await f.connections.useLocal("grok"); expect(f.prefs.values.desktopGrokIdentity).toBe(""); expect(compose(f.launch, f.prefs).find(s => s.slot.provider === "grok")!.usage).toBeTypeOf("function"); + await expect(source.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); +}); +it("rejects mismatched server identities, missing quota and account switches for a saved Cursor source", async () => { + const f = await setup(); await f.connect(); + f.handler(async () => '{"sub":"other"}'); await expect(f.connections.quota("grok")).rejects.toThrow("Not signed in"); + expect(f.http.mock.calls.map(c => c[0])).toEqual([endpoints.cursorIdentity]); + f.handler(async url => url === endpoints.cursorIdentity ? '{"sub":"fixture-user"}' : '{}'); await expect(f.connections.quota("grok")).rejects.toMatchObject({ code: "noData" }); + f.set(token("other")); f.http.mockClear(); await expect(f.connections.quota("grok")).rejects.toMatchObject({ code: "notSignedIn" }); expect(f.http).not.toHaveBeenCalled(); +}); +it.each(["switch", "disconnect", "local", "reconnect", "abort"])("rejects stale quota results after %s", async action => { + const f = await setup(); await f.connect(); + let release!: (raw: string) => void, ready!: () => void; + const waiting = new Promise(r => { ready = r; }); + f.handler(async url => url === endpoints.cursorIdentity ? '{"sub":"fixture-user"}' : new Promise(r => { release = r; ready(); })); + const controller = new AbortController(), reading = f.connections.quota("grok", controller.signal), failure = expect(reading).rejects.toThrow(); await waiting; + if (action === "switch") f.set(token("other")); + if (action === "disconnect" || action === "reconnect") await f.connections.disconnect("grok"); + if (action === "local") await f.connections.useLocal("grok"); + if (action === "abort") controller.abort(); + if (action === "reconnect") { f.handler(async url => url === endpoints.cursorIdentity ? '{"sub":"fixture-user"}' : paid); await f.connect(); } + const processReads = f.command.mock.calls.filter(c => c[0] === "/bin/ps").length; + release(paid); await failure; + if (action !== "switch") expect(f.command.mock.calls.filter(c => c[0] === "/bin/ps")).toHaveLength(processReads); +}); +it("uses coordinator backoff for transport failures and clears unverified account archives", async () => { + const f = await setup(); await f.connect(); let clock = now; + const source = compose(f.launch, f.prefs, { connections: f.connections }).find(s => s.slot.provider === "grok")!; + const c = new Coordinator(f.launch, f.prefs, [source], () => clock); coordinators.push(c); + await c.refresh(true); expect(c.snapshot().archived.grok.windows[0].usedPercent).toBe(25); + f.handler(async url => { if (url === endpoints.cursorIdentity) return '{"sub":"fixture-user"}'; throw new Unavailable("offline"); }); + await c.refresh(true); const calls = f.http.mock.calls.length; + expect(c.snapshot().quotas.grok).toMatchObject({ status: "missing", code: "offline" }); + await c.refresh(true); expect(f.http).toHaveBeenCalledTimes(calls); + clock += 60000; await c.refresh(true); expect(f.http.mock.calls.length).toBeGreaterThan(calls); + clock += 120000; f.set(token("other")); await c.refresh(true); expect(c.snapshot().archived.grok).toBeUndefined(); + expect(c.diagnostics("fixture")).not.toMatch(/fixture-user|synthetic|OMIT|WorkosCursor/); +}); +it("bounds fixed-endpoint cookie requests and sanitizes auth, redirects, network and response-limit failures", async () => { + const cookie = cursorCredential(token(), now).cookie, calls: [unknown, RequestInit | undefined][] = []; + const http = httpTransport(async (url, init) => { calls.push([url, init]); return new Response(paid); }); + await http(endpoints.cursorIdentity, "grok", cookie); await http(endpoints.cursorGrok, "grok", cookie); + expect(calls[0][1]).toMatchObject({ redirect: "error", cache: "no-store", headers: { Cookie: cookie } }); + expect(calls[1][1]).toMatchObject({ method: "POST", body: "{}", headers: { Cookie: cookie, Origin: "https://cursor.com", "Content-Type": "application/json" } }); + expect(calls[0][1]!.headers).not.toHaveProperty("Authorization"); expect(calls[0][1]!.signal).toBeInstanceOf(AbortSignal); + const missing = httpTransport(vi.fn(async () => { throw new Error("must not fetch"); })); + await expect(missing(endpoints.cursorGrok, "grok")).rejects.toMatchObject({ code: "notSignedIn" }); + await expect(missing(endpoints.cursorGrok, "claude", cookie)).rejects.toMatchObject({ code: "notSignedIn" }); + for (const status of [301, 302, 307, 308, 401, 403, 429, 500]) { + const fetcher = vi.fn(async () => new Response("OMIT", { status, headers: { Location: "https://example.invalid" } })); + await expect(httpTransport(fetcher)(endpoints.cursorGrok, "grok", cookie)).rejects.toMatchObject({ code: status === 401 || status === 403 ? "notSignedIn" : "failed" }); expect(fetcher).toHaveBeenCalledTimes(1); + } + await expect(httpTransport(async () => { throw new Error("OMIT_RAW_ERROR"); })(endpoints.cursorIdentity, "grok", cookie)).rejects.toMatchObject({ code: "failed" }); + await expect(httpTransport(async () => { throw Object.assign(new Error("OMIT"), { cause: { code: "ENETUNREACH" } }); })(endpoints.cursorIdentity, "grok", cookie)).rejects.toMatchObject({ code: "offline" }); + const cancel = vi.fn(); + await expect(httpTransport(async () => new Response(new ReadableStream({ start(c) { c.enqueue(new Uint8Array(1048577)); }, cancel })))(endpoints.cursorGrok, "grok", cookie)).rejects.toMatchObject({ code: "failed" }); expect(cancel).toHaveBeenCalled(); + const controller = new AbortController(); controller.abort(); const untouched = vi.fn(async () => new Response(paid)); + await expect(httpTransport(untouched)(endpoints.cursorGrok, "grok", cookie, controller.signal)).rejects.toMatchObject({ code: "failed" }); expect(untouched).not.toHaveBeenCalled(); + vi.spyOn(AbortSignal, "timeout").mockImplementation(ms => { expect(ms).toBe(30000); return AbortSignal.abort(); }); + await expect(httpTransport(untouched)(endpoints.cursorIdentity, "grok", cookie)).rejects.toMatchObject({ code: "failed" }); expect(untouched).not.toHaveBeenCalled(); +}); +it("denies demo and isolated Codex/Claude profiles before Cursor reads", async () => { + const f = await setup(); + for (const restricted of [{ demo: true }, { codexTest: true }, { claudeTest: true }]) { + const c = new DesktopConnections({ ...f.launch, ...restricted }, f.prefs, "/synthetic/helper", () => {}, f.command, f.http); + const consent = vi.fn(async () => true); + await expect(c.connect("grok", async () => {}, consent)).rejects.toThrow(); await expect(c.quota("grok")).rejects.toMatchObject({ code: "notSignedIn" }); + expect(consent).not.toHaveBeenCalled(); expect(f.http).not.toHaveBeenCalled(); + } +}); + +it("keeps immutable SQLite bytes and sidecars unchanged even with a concurrent synthetic WAL writer", () => { + const f = fixture(); + f.db.exec("PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=0"); f.set(token("uncheckpointed")); + const before = snapshot(f.directory); + const uri = new URL("file://" + f.path); uri.searchParams.set("immutable", "1"); uri.searchParams.set("mode", "ro"); + const reader = new DatabaseSync(uri.href, { readOnly: true, allowExtension: false }); + try { + expect(reader.prepare("SELECT value FROM ItemTable WHERE key='cursorAuth/accessToken'").get()!.value).toBe(token()); + expect(() => reader.exec("CREATE TABLE forbidden (value)")).toThrow(); + } finally { reader.close(); } + expect(snapshot(f.directory)).toEqual(before); +}); +it.each(["before-bot", "during-bot"])("rejects a same-account store change %s", async stage => { + const f = await setup(); await f.connect(); + f.handler(async url => { + if (url === (stage === "before-bot" ? endpoints.cursorIdentity : endpoints.cursorGrok)) f.set(token(undefined, now / 1000 + 7200)); + return url === endpoints.cursorIdentity ? '{"sub":"fixture-user"}' : paid; + }); + await expect(f.connections.quota("grok")).rejects.toMatchObject({ code: "notSignedIn" }); + if (stage === "before-bot") expect(f.http.mock.calls.map(c => c[0])).toEqual([endpoints.cursorIdentity]); +}); +it("rejects expired credentials before HTTP and expiry during a saved Cursor request", async () => { + const f = await setup(); await f.connect(); f.set(token(undefined, now / 1000 + 60)); + await expect(f.connections.quota("grok")).rejects.toMatchObject({ code: "notSignedIn" }); expect(f.http).not.toHaveBeenCalled(); + f.set(); let clock = now; + const c = new DesktopConnections(f.launch, f.prefs, "/synthetic/helper", () => {}, f.command, f.http, () => clock); + f.handler(async url => { if (url === endpoints.cursorIdentity) return '{"sub":"fixture-user"}'; clock += 3600000; return paid; }); + await expect(c.quota("grok")).rejects.toMatchObject({ code: "notSignedIn" }); +}); + +it("keeps Grok disabled when saving disconnect fails and returns only a fixed message", async () => { + const f = await setup(); await f.connect(); + vi.spyOn(f.prefs, "set").mockRejectedValueOnce(new Error("OMIT_RAW_ERROR")); + await expect(f.connections.disconnect("grok")).rejects.toThrow("Could not disconnect Grok Bot Desktop"); + expect(f.connections.enabled("grok")).toBe(false); expect(f.prefs.values.desktopGrokIdentity).toBe("off"); +}); diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index a08ac7b..a03dfc6 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -43,7 +43,7 @@ it.each([ vi.stubGlobal("fetch", reportFetch); const notify = vi.fn(); const showErrorBox = vi.fn(); - const showMessageBox = vi.fn(async () => ({ response: 0 })); + const showMessageBox = vi.fn(async (_window: unknown, _options: { title: string; detail: string; defaultId: number; cancelId: number }) => ({ response: 0 })); const prepareReset = vi.fn(() => ({ token: "fixture-intent", account: "Codex", title: "Full reset" })); const confirmReset = vi.fn(async () => {}), cancelReset = vi.fn(); class TestWindow extends EventEmitter { @@ -247,6 +247,28 @@ 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 response of [0, 1]) { + showMessageBox.mockResolvedValueOnce({ response }); + connect.mockImplementationOnce(async (provider, _open, consent) => { expect(provider).toBe("grok"); expect(await consent()).toBe(response === 1); return false; }); + expect(await invoke(settings, { action: "connect", provider: "grok" })).toEqual({ ok: true }); + expect(showMessageBox.mock.lastCall?.[1]).toMatchObject({ title: "Connect Grok Bot Desktop (Beta)?", defaultId: 0, cancelId: 0 }); + expect(showMessageBox.mock.lastCall?.[1].detail).toContain("fixed Grok Bot Safe Storage Keychain item"); + } + connect.mockImplementationOnce(async () => { throw new Error("Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."); }); + expect(await invoke(settings, { action: "connect", provider: "grok" })).toEqual({ ok: false, error: "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect." }); + useLocal.mockImplementationOnce(async () => { values.desktopGrokIdentity = ""; connectionChanged(); }); + values.desktopGrokIdentity = "a".repeat(64); connectionChanged(); + expect(forgetQuota).toHaveBeenCalledWith("grok"); + expect(await invoke(settings, { action: "connectionLocal", provider: "grok" })).toEqual({ ok: true }); + expect(forgetQuota).toHaveBeenCalledWith("grok"); 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/grok-bot-desktop.test.ts b/tests/grok-bot-desktop.test.ts new file mode 100644 index 0000000..f35944d --- /dev/null +++ b/tests/grok-bot-desktop.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { createCipheriv, createHash, pbkdf2Sync } from "node:crypto"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { grokBotCredential, grokBotQuota, grokBotTransport, readGrokBotCredential, type GrokBotRPC } from "../src/main/providers/grok-bot-desktop"; +import { Unavailable } from "../src/domain/models"; +import { DesktopConnections } from "../src/main/connections"; +import { Preferences } from "../src/main/preferences"; +import { selectJSON } from "../src/main/select-json"; +import { projectSettings } from "../src/main/ipc"; +import { compose } from "../src/main/composition"; +import type { Command } from "../src/main/providers/transport"; +const now = Date.parse("2026-10-16T12:00:00Z"), roots: string[] = []; +const password = "fixture-safe-storage", subject = "auth0|fixture-bot-account"; +const hash = (s: string) => createHash("sha256").update(s).digest("hex"); +const scope = hash(subject); +const token = (sub = subject, exp = now / 1000 + 3600) => `${Buffer.from('{"alg":"fixture"}').toString("base64url")}.${Buffer.from(JSON.stringify({ sub, exp, secret: "SKIP-JWT" })).toString("base64url")}.synthetic`; +const paid = JSON.stringify({ usagePercent: 25, includedLimitZero: false, nextResetTimestampUtc: "2026-10-20T12:00:00Z", secret: "SKIP-RESPONSE" }); +function encrypted(value: string, account = scope, key = password) { + const cipher = createCipheriv("aes-128-cbc", pbkdf2Sync(key, "saltysalt", 1003, 16, "sha1"), Buffer.alloc(16, 32)); + return `scoped:v1:${account}:${Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]).toString("base64")}`; +} +function store(values: Record = {}, active: unknown = scope) { + return JSON.stringify({ version: 1, "cursor-accounts": JSON.stringify({ active, accounts: { [scope]: { "cursor-access-token": encrypted(token()), "cursor-refresh-token": "SKIP-REFRESH", ...values }, [hash("other")]: { "cursor-access-token": "SKIP-OTHER" } } }), "unknown": "SKIP-ROOT" }); +} +function fixture() { + const home = mkdtempSync(join(tmpdir(), "meterusage-bot-fixture-")); roots.push(home); + const directory = join(home, "Library/Application Support/Grok Bot"); mkdirSync(directory, { recursive: true }); + const path = join(directory, "sand-secrets.json"); writeFileSync(path, store()); + return { home, directory, path }; +} +async function setup() { + const f = fixture(), saved = new Map(); + const command = vi.fn(async (binary, args, options) => { + options?.signal?.throwIfAborted(); + if (binary === "/fixture/helper") { expect(args[0]).toBe("--grok-bot"); expect(options?.maxBytes).toBe(4096); return password; } + if (binary !== "/usr/bin/defaults") throw new Error("Forbidden command"); + if (args[0] === "write") { saved.set(args[2], args[4]); return ""; } + if (!saved.has(args[2])) throw new Error("Absent synthetic preference"); + return args[0] === "read-type" ? args[2] === "showProviderGrok" ? "boolean" : "string" : saved.get(args[2])!; + }); + const launch = { home: f.home, data: join(f.home, "app-data"), env: {}, demo: false, candidate: true }; + const prefs = await Preferences.load(launch, command); + let handler: GrokBotRPC = async () => paid; + const rpc = vi.fn((...args) => handler(...args)); + const connections = new DesktopConnections(launch, prefs, "/fixture/helper", () => {}, command, async () => { throw new Error("Forbidden legacy HTTP"); }, () => now, undefined, rpc); + const connect = () => connections.connect("grok", async () => { throw new Error("Forbidden browser"); }, async () => true); + return { ...f, launch, prefs, connections, command, rpc, connect, saved, handler: (h: GrokBotRPC) => { handler = h; } }; +} +beforeEach(() => vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Forbidden live network"); }))); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +it("decrypts only active scoped token/team and hashes full subject and team", () => { + const values = grokBotCredential(store(), password, now); + expect(values.token).toBe(token()); expect(values.identity).toMatch(/^[a-f\d]{64}$/); expect(values.team).toBeUndefined(); + const team = grokBotCredential(store({ "cursor-selected-team-id": encrypted("0") }), password, now); + expect(team.team).toBe("0"); expect(team.identity).not.toBe(values.identity); + const parsed: string[] = [], parse = JSON.parse; + vi.spyOn(JSON, "parse").mockImplementation((...args) => { parsed.push(args[0]); return parse(...args); }); + grokBotCredential(store(), password, now); + expect(parsed).not.toContain('"SKIP-REFRESH"'); expect(parsed).not.toContain('"SKIP-OTHER"'); expect(parsed).not.toContain('"SKIP-ROOT"'); expect(parsed).not.toContain('"SKIP-JWT"'); + expect(JSON.stringify({ identity: values.identity })).not.toContain(subject); +}); +it.each(["legacy", "plaintext", "scope", "version", "cipher", "wrong-key", "subject", "expired", "missing", "null-active", "team-invalid", "team-scope"])('rejects %s credential ambiguity', kind => { + let raw = store(), key = password; + if (kind === "legacy") raw = store({ "cursor-access-token": encrypted(token()).split(":").at(-1) }); + if (kind === "plaintext") raw = store({ "cursor-access-token": "plaintext:v1:" + token() }); + if (kind === "scope") raw = store({ "cursor-access-token": encrypted(token(), hash("other")) }); + if (kind === "version") raw = raw.replace('"version":1', '"version":2'); + if (kind === "cipher") raw = store({ "cursor-access-token": `scoped:v1:${scope}:${Buffer.from("v11unsupported").toString("base64")}` }); + if (kind === "wrong-key") key = "wrong fixture"; + if (kind === "subject") raw = store({ "cursor-access-token": encrypted(token("fixture-bot-account")) }); + if (kind === "expired") raw = store({ "cursor-access-token": encrypted(token(subject, now / 1000 + 60)) }); + if (kind === "missing") raw = store({ "cursor-access-token": null }); + if (kind === "null-active") raw = store({}, null); + if (kind === "team-invalid") raw = store({ "cursor-selected-team-id": encrypted("9007199254740992") }); + if (kind === "team-scope") raw = store({ "cursor-selected-team-id": encrypted("1", hash("other")) }); + expect(() => grokBotCredential(raw, key, now)).toThrow("Not signed in"); +}); +it("rejects duplicate keys including escaped and unselected keys without changing scanner defaults", () => { + const raws = ['{"version":1,"version":1}', '{"version":1,"\\u0076ersion":1}', '{"skip":{"a":1,"a":2}}']; + for (const raw of raws) expect(() => selectJSON(raw, { version: true }, undefined, true)).toThrow(); + expect(selectJSON('{"version":1,"version":2}', { version: true })).toEqual({ version: 2 }); + const accounts = JSON.stringify({ active: scope, accounts: { [scope]: { "cursor-access-token": encrypted(token()) } } }).replace('"active":', '"active":null,"active":'); + expect(() => grokBotCredential(JSON.stringify({ version: 1, "cursor-accounts": accounts }), password, now)).toThrow(); + for (const raw of ["{", "[]", store().slice(0, -1), " ".repeat(4 * 1048576 + 1)]) expect(() => grokBotCredential(raw, password, now)).toThrow(); +}); +it("reads bounded stable files and fixed Keychain mode without changing store bytes", async () => { + const f = await setup(), before = readFileSync(f.path), names = readdirSync(f.directory), guard = vi.fn(); + const c = await readGrokBotCredential(f.home, "/fixture/helper", f.command, now, true, new AbortController().signal, guard); + expect(c.identity).toBe(grokBotCredential(store(), password, now).identity); expect(readFileSync(f.path)).toEqual(before); expect(readdirSync(f.directory)).toEqual(names); + expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper").map(c => c[1])).toEqual([["--grok-bot", "--allow-ui"]]); +}); +it.each(["symlink", "parent-symlink", "replace", "helper-fail", "helper-rotated", "cancel-helper"])('rejects %s before exposing credentials', async kind => { + const f = fixture(), controller = new AbortController(); + if (kind === "symlink") { renameSync(f.path, f.path + ".old"); symlinkSync(f.path + ".old", f.path); } + if (kind === "parent-symlink") { renameSync(f.directory, f.directory + ".old"); symlinkSync(f.directory + ".old", f.directory); } + const command = vi.fn(async () => { + if (kind === "replace") { writeFileSync(f.path + ".new", store()); renameSync(f.path + ".new", f.path); } + if (kind === "helper-fail") throw new Error("SKIP-ERROR"); + if (kind === "cancel-helper") controller.abort(); + return kind === "helper-rotated" ? "rotated" : password; + }); + await expect(readGrokBotCredential(f.home, "helper", command, now, false, controller.signal, () => {})).rejects.toMatchObject({ code: "notSignedIn" }); + if (kind.includes("symlink")) expect(command).not.toHaveBeenCalled(); +}); +it("keeps measured zero, trial expiry and reported reset distinct without invented cadence", () => { + const q = grokBotQuota(paid, now); expect(q.windows[0]).toEqual({ label: "Grok Bot Desktop", usedPercent: 25, resetsAt: Date.parse("2026-10-20T12:00:00Z") }); expect(JSON.stringify(q)).not.toContain("SKIP"); + expect(grokBotQuota('{"usagePercent":0,"hasNonZeroIncludedLimit":true}', now).windows[0].usedPercent).toBe(0); + const trial = grokBotQuota('{"usagePercent":100,"includedLimitZero":true,"sandTrialExpiresAt":"2026-10-17T12:00:00Z","nextResetTimestampUtc":"2026-10-20T12:00:00Z"}', now).windows[0]; + expect(trial.label).toContain("Trial"); expect(trial.resetsAt).toBeUndefined(); expect(trial.windowDurationMins).toBeUndefined(); + for (const value of [{}, { usagePercent: 0 }, { usagePercent: 0, usesPooledEnterpriseAllowance: true, includedLimitZero: false }, { usagePercent: 0, includedLimitZero: true, hasNonZeroIncludedLimit: true }, { usagePercent: 0, includedLimitZero: true, sandTrialExpiresAt: "2026-10-16T12:00:00Z" }, { usagePercent: 101, includedLimitZero: false }, { usagePercent: -1, includedLimitZero: false }, { usagePercent: 0, includedLimitZero: false, nextResetTimestampUtc: "bad" }]) expect(() => grokBotQuota(JSON.stringify(value), now)).toThrow("No usage yet"); +}); +it("requests only the fixed Connect read with own headers and bounded sanitized failures", async () => { + const fetcher = vi.fn(async () => new Response(paid)), signal = new AbortController().signal; + expect(await grokBotTransport(fetcher)(token(), "2", signal)).toBe(paid); + expect(fetcher.mock.calls[0]).toEqual(["https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus", expect.objectContaining({ method: "POST", body: "{}", redirect: "error", cache: "no-store", headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", "User-Agent": "MeterUsage/0.2.41", Authorization: `Bearer ${token()}`, "x-cursor-team-id": "2" } })]); + for (const status of [301, 302, 307, 308, 401, 403, 429, 500]) await expect(grokBotTransport(async () => new Response("SKIP", { status }))(token(), undefined, signal)).rejects.toMatchObject({ code: [401, 403].includes(status) ? "notSignedIn" : "failed" }); + const cancel = vi.fn(); + await expect(grokBotTransport(async () => new Response(new ReadableStream({ start(c) { c.enqueue(new Uint8Array(1048577)); }, cancel })))(token(), undefined, signal)).rejects.toThrow(); expect(cancel).toHaveBeenCalled(); + await expect(grokBotTransport(async () => new Response("", { headers: { "content-length": "1048577" } }))(token(), undefined, signal)).rejects.toThrow(); + const untouched = vi.fn(async () => new Response(paid)); + await expect(grokBotTransport(untouched)(token(), undefined, AbortSignal.abort())).rejects.toThrow(); expect(untouched).not.toHaveBeenCalled(); + await expect(grokBotTransport(untouched)(token(), "1\r\nHeader", signal)).rejects.toThrow(); expect(untouched).not.toHaveBeenCalled(); + vi.spyOn(AbortSignal, "timeout").mockImplementation(ms => { expect(ms).toBe(30000); return AbortSignal.abort(); }); + await expect(grokBotTransport(untouched)(token(), undefined, signal)).rejects.toThrow(); expect(untouched).not.toHaveBeenCalled(); +}); +it("requires explicit consent and persists only opaque Bot identity/source after verified allowance", async () => { + const f = await setup(); expect(await f.connections.connect("grok", async () => {})).toBe(false); expect(await f.connections.connect("grok", async () => {}, async () => false)).toBe(false); + expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper")).toHaveLength(0); expect(f.rpc).not.toHaveBeenCalled(); + expect(await f.connect()).toBe(true); expect(f.prefs.values.desktopGrokSource).toBe("bot"); expect(f.connections.enabled("grok")).toBe(true); expect(f.connections.state.find(c => c.provider === "grok")).toMatchObject({ source: "account", accountMethod: "bot" }); + expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper").map(c => c[1])).toEqual([["--grok-bot", "--allow-ui"], ["--grok-bot"]]); + const source = compose(f.launch, f.prefs, { connections: f.connections }).find(s => s.slot.provider === "grok")!; + expect(Object.keys(source).sort()).toEqual(["accountBound", "quota", "slot"]); expect((await source.quota!()).windows[0].usedPercent).toBe(25); + for (const serialized of [JSON.stringify(projectSettings(f.prefs.values, [], f.home)), JSON.stringify([...f.saved]), JSON.stringify(f.connections.state)]) expect(serialized).not.toMatch(/fixture|SKIP|Bearer|scoped:|safe-storage/); + expect(JSON.stringify(projectSettings(f.prefs.values, [], f.home))).not.toContain(f.prefs.values.desktopGrokIdentity as string); + const reloaded = await Preferences.load(f.launch, f.command); expect(reloaded.values.desktopGrokSource).toBe("bot"); + await f.connections.disconnect("grok"); await expect(source.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + await f.connections.useLocal("grok"); expect(f.prefs.values.desktopGrokIdentity).toBe(""); +}); +it.each(["team", "account", "same-account-store", "remove", "cancel", "disconnect", "local", "stop"])('rejects late Bot connect after %s without revoked private rereads', async action => { + const f = await setup(); let release!: (raw: string) => void, ready!: () => void; const waiting = new Promise(r => { ready = r; }); + f.handler(async () => new Promise(r => { release = r; ready(); })); + const connecting = f.connect(), failure = expect(connecting).rejects.toThrow("Could not connect Grok Bot Desktop"); await waiting; + let cleanup: Promise | undefined; + if (action === "team") writeFileSync(f.path, store({ "cursor-selected-team-id": encrypted("1") })); + if (action === "account") writeFileSync(f.path, store({}, hash("other"))); + if (action === "remove") rmSync(f.path); + if (action === "same-account-store") writeFileSync(f.path, store({ "cursor-access-token": encrypted(token(subject, now / 1000 + 7200)) })); + if (action === "cancel") f.connections.cancel("grok"); + if (action === "disconnect") cleanup = f.connections.disconnect("grok"); + if (action === "local") cleanup = f.connections.useLocal("grok"); + if (action === "stop") cleanup = f.connections.stop(); + const reads = f.command.mock.calls.filter(c => c[0] === "/fixture/helper").length; + release(paid); await failure; await cleanup; + if (["cancel", "disconnect", "local", "stop"].includes(action)) expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper")).toHaveLength(reads); + expect(f.connections.enabled("grok")).toBe(false); expect(f.prefs.values.desktopGrokIdentity).not.toMatch(/^[a-f\d]{64}$/); +}); +it.each(["disconnect", "local", "abort", "team", "reconnect", "stop"])('rejects stale Bot quota after %s', async action => { + const f = await setup(); await f.connect(); let release!: (raw: string) => void, ready!: () => void; const waiting = new Promise(r => { ready = r; }); + f.handler(async () => new Promise(r => { release = r; ready(); })); + const controller = new AbortController(), reading = f.connections.quota("grok", controller.signal), failure = expect(reading).rejects.toThrow(); await waiting; + if (action === "disconnect" || action === "reconnect") await f.connections.disconnect("grok"); + if (action === "local") await f.connections.useLocal("grok"); + if (action === "abort") controller.abort(); + if (action === "stop") await f.connections.stop(); + if (action === "reconnect") { f.handler(async () => paid); await f.connect(); } + if (action === "team") writeFileSync(f.path, store({ "cursor-selected-team-id": encrypted("1") })); + const reads = f.command.mock.calls.filter(c => c[0] === "/fixture/helper").length; release(paid); await failure; + if (action !== "team") expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper")).toHaveLength(reads); +}); +it.each(["desktopGrokSource", "desktopGrokIdentity", "showProviderGrok", "cancel-save"])('rolls back %s failure and source metadata without leaving consent enabled', async stage => { + const f = await setup(), original = f.prefs.set.bind(f.prefs); let failed = false; + vi.spyOn(f.prefs, "set").mockImplementation(async (key, value) => { + if (key === stage && !failed) { failed = true; throw new Error("SKIP-RAW-ERROR"); } + await original(key, value); if (stage === "cancel-save" && key === "desktopGrokIdentity" && value !== "off") f.connections.cancel("grok"); + }); + await expect(f.connect()).rejects.toThrow("Could not connect Grok Bot Desktop"); expect(f.connections.enabled("grok")).toBe(false); expect(f.prefs.values.desktopGrokSource).toBe("cursor"); +}); +it("checks guard before every private read, and cancellation during helper prevents publication", async () => { + const f = fixture(), controller = new AbortController(), guard = vi.fn(() => { throw new Error("Revoked consent"); }), command = vi.fn(async () => password); + await expect(readGrokBotCredential(f.home, "helper", command, now, false, controller.signal, guard)).rejects.toThrow(); expect(command).not.toHaveBeenCalled(); + const c = await setup(); c.command.mockImplementation(async binary => { if (binary === "/fixture/helper") { c.connections.cancel("grok"); return password; } throw new Error("Absent preference"); }); + await expect(c.connect()).rejects.toThrow(); expect(c.rpc).not.toHaveBeenCalled(); +}); +it("preserves Claude's default fixed helper pair and denies restricted profiles before Bot reads", async () => { + const source = readFileSync(new URL("../Scripts/electron/desktop-keychain.swift", import.meta.url), "utf8"); + expect(source).toContain('grok ? "Grok Bot Safe Storage" : "Claude Safe Storage"'); expect(source).toContain('grok ? "Grok Bot" : "Claude Key"'); expect(source).not.toContain("SecItemAdd"); + const f = await setup(); + for (const restricted of [{ demo: true }, { codexTest: true }, { claudeTest: true }]) { + const c = new DesktopConnections({ ...f.launch, ...restricted }, f.prefs, "/fixture/helper", () => {}, f.command, undefined, () => now, undefined, f.rpc); + const consent = vi.fn(async () => true); await expect(c.connect("grok", async () => {}, consent)).rejects.toThrow(); expect(consent).not.toHaveBeenCalled(); + } + expect(f.rpc).not.toHaveBeenCalled(); expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper")).toHaveLength(0); +}); + +it("rejects oversized files and a swapped inode before helper access", async () => { + const f = fixture(), command = vi.fn(async () => password), signal = new AbortController().signal; + writeFileSync(f.path, " ".repeat(4 * 1048576 + 1)); + await expect(readGrokBotCredential(f.home, "helper", command, now, false, signal, () => {})).rejects.toThrow(); expect(command).not.toHaveBeenCalled(); + writeFileSync(f.path, store()); writeFileSync(f.path + ".other", store({ "cursor-selected-team-id": encrypted("1") })); + let checks = 0; + await expect(readGrokBotCredential(f.home, "helper", command, now, false, signal, () => { + if (++checks === 2) { renameSync(f.path, f.path + ".original"); renameSync(f.path + ".other", f.path); } + })).rejects.toThrow(); expect(command).not.toHaveBeenCalled(); +}); +it("rejects JWT expiry overflow and invalid calendar resets", () => { + expect(() => grokBotCredential(store({ "cursor-access-token": encrypted(token(subject, 1e308)) }), password, now)).toThrow(); + expect(() => grokBotQuota('{"usagePercent":0,"includedLimitZero":false,"nextResetTimestampUtc":"2027-02-30T12:00:00Z"}', now)).toThrow(); +}); +it("rejects Bot expiry before HTTP and expiry during allowance", async () => { + const f = await setup(); writeFileSync(f.path, store({ "cursor-access-token": encrypted(token(subject, now / 1000 + 60)) })); + await expect(f.connect()).rejects.toThrow(); expect(f.rpc).not.toHaveBeenCalled(); + writeFileSync(f.path, store()); let clock = now; + const c = new DesktopConnections(f.launch, f.prefs, "/fixture/helper", () => {}, f.command, undefined, () => clock, undefined, async () => { clock += 3600000; return paid; }); + await expect(c.connect("grok", async () => {}, async () => true)).rejects.toThrow(); expect(c.enabled("grok")).toBe(false); +}); +it("rechecks identity on transport failure and clears account allowance after a team switch", async () => { + const f = await setup(); await f.connect(); f.rpc.mockClear(); + f.handler(async () => { throw new Unavailable("offline"); }); + const before = f.command.mock.calls.filter(c => c[0] === "/fixture/helper").length; + await expect(f.connections.quota("grok")).rejects.toMatchObject({ code: "offline" }); + expect(f.command.mock.calls.filter(c => c[0] === "/fixture/helper")).toHaveLength(before + 2); + writeFileSync(f.path, store({ "cursor-selected-team-id": encrypted("1") })); f.rpc.mockClear(); + await expect(f.connections.quota("grok")).rejects.toMatchObject({ code: "notSignedIn" }); expect(f.rpc).not.toHaveBeenCalled(); +}); + +it("does not save a Bot connection when quota is unavailable", async () => { + const f = await setup(); f.handler(async () => "{}"); + await expect(f.connect()).rejects.toThrow("Could not connect Grok Bot Desktop"); + expect(f.connections.enabled("grok")).toBe(false); expect(f.prefs.values.desktopGrokIdentity).toBe(""); expect(f.prefs.values.desktopGrokSource).toBe("cursor"); +}); diff --git a/tests/ipc.test.ts b/tests/ipc.test.ts index 476cbf9..f49ee39 100644 --- a/tests/ipc.test.ts +++ b/tests/ipc.test.ts @@ -76,3 +76,9 @@ it("rejects removed strip and legacy pin edits and excludes strip mode from sett } expect(() => parseRequest({ action: "setPreference", key: "sideNotchPanelPinned", value: true })).toThrow(); }); + +it("keeps Grok Cursor credentials and identity outside renderer-controlled connection requests", () => { + for (const extra of [{ token: "synthetic" }, { cookie: "synthetic" }, { identity: "a".repeat(64) }, { path: "/synthetic" }, { url: "https://example.invalid" }, { consent: true }]) expect(() => parseRequest({ action: "connect", provider: "grok", ...extra })).toThrow("Invalid request"); + expect(() => parseRequest({ action: "setPreference", key: "desktopGrokIdentity", value: "a".repeat(64) })).toThrow("Invalid request"); + expect(projectSettings({ desktopGrokIdentity: "a".repeat(64) }, [], "/synthetic").values).not.toHaveProperty("desktopGrokIdentity"); +}); diff --git a/tests/opencode-local-setup.test.ts b/tests/opencode-local-setup.test.ts new file mode 100644 index 0000000..1746703 --- /dev/null +++ b/tests/opencode-local-setup.test.ts @@ -0,0 +1,76 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { DatabaseSync } from "node:sqlite"; +import { accessSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { compose } from "../src/main/composition"; +import { Preferences } from "../src/main/preferences"; +import type { Launch } from "../src/main/launch"; +import { cliPath, type Command, type HTTP } from "../src/main/providers/transport"; +import { openCodeQuery } from "../src/main/providers/usage"; + +const fixtureRoots = vi.hoisted(() => new Set()); +vi.mock("node:fs", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, accessSync: vi.fn((path: string, mode?: number) => { + if (![...fixtureRoots].some(root => path.startsWith(root + "/"))) throw new Error("Outside synthetic executable roots"); + return actual.accessSync(path, mode); + }) }; +}); +const now = Date.parse("2026-10-16T12:00:00Z"); +afterEach(() => { for (const root of fixtureRoots) rmSync(root, { recursive: true, force: true }); fixtureRoots.clear(); vi.clearAllMocks(); }); +function fixture() { + const home = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-opencode-local-")); fixtureRoots.add(home); + const launch: Launch = { home, data: join(home, "app-data"), env: { PATH: "/synthetic-empty-path" }, demo: false, candidate: false }; + return { home, launch }; +} +function executable(home: string, path = ".bun/bin/opencode", mode = 0o700) { + const binary = join(home, path); mkdirSync(join(binary, ".."), { recursive: true }); writeFileSync(binary, "synthetic executable fixture\n"); chmodSync(binary, mode); return binary; +} +async function source(launch: Launch, command: Command) { + const prefs = await Preferences.load(launch, async () => { throw new Error("No synthetic native preferences"); }); + const http = vi.fn(async () => { throw new Error("Forbidden provider HTTP"); }); + return { source: compose(launch, prefs, { command, http, now: () => now }).find(s => s.slot.provider === "openCodeGo")!, http }; +} +it("reads Go-only local DB activity without auth, allowance or HTTP and preserves database bytes", async () => { + const { home, launch } = fixture(), path = join(home, ".local/share/opencode/opencode.db"); mkdirSync(join(path, ".."), { recursive: true }); + const db = new DatabaseSync(path); + try { + db.exec("CREATE TABLE session(id TEXT, model TEXT, tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, tokens_cache_read INTEGER, tokens_cache_write INTEGER, cost REAL, time_created INTEGER, time_updated INTEGER, directory TEXT); CREATE TABLE message(session_id TEXT, body TEXT);"); + const insert = db.prepare("INSERT INTO session VALUES(?,?,?,?,?,?,?,?,?,?,?)"); + insert.run("go", '{"providerID":"opencode-go"}', 100, 20, 3, 4, 5, 0.2, now, now, "/synthetic/project"); + insert.run("other", '{"providerID":"other-provider"}', 9000, 9000, 0, 0, 0, 9, now, now, "/synthetic/other"); + db.exec("INSERT INTO message VALUES('go','PRIVATE_BODY'),('other','PRIVATE_BODY')"); + } finally { db.close(); } + const before = readFileSync(path), command = vi.fn(async () => { throw new Error("Forbidden CLI"); }); + const f = await source(launch, command); + await expect(f.source.quota!()).rejects.toMatchObject({ code: "dataNotFound" }); + const usage = await f.source.usage!(); + expect(usage).toMatchObject({ provider: "openCodeGo", sessionCount: 1, messageCount: 1, tokens: { input: 100, output: 20, reasoning: 3, cacheRead: 4, cacheWrite: 5 } }); + expect(JSON.stringify(usage)).not.toContain("PRIVATE_BODY"); + expect(existsSync(join(home, ".local/share/opencode/auth.json"))).toBe(false); + expect(readFileSync(path)).toEqual(before); expect(f.http).not.toHaveBeenCalled(); expect(command).not.toHaveBeenCalled(); +}); +it("finds a Bun-only OpenCode and falls back to only the fixed read-only JSON query", async () => { + const { home, launch } = fixture(), binary = executable(home), signal = new AbortController().signal; + const command = vi.fn(async () => JSON.stringify([{ input: 12, output: 2, messages: 1, created: now, updated: now }])); + const f = await source(launch, command); + await expect(f.source.quota!()).rejects.toMatchObject({ code: "dataNotFound" }); + expect((await f.source.usage!(signal)).tokens?.input).toBe(12); + expect(command).toHaveBeenCalledExactlyOnceWith(binary, ["db", "--format", "json", openCodeQuery], expect.objectContaining({ signal })); + expect(openCodeQuery).toContain("'opencode-go'"); expect(openCodeQuery).not.toMatch(/INSERT|UPDATE|DELETE|body|content|prompt/); + expect(f.http).not.toHaveBeenCalled(); +}); +it("keeps missing and nonexecutable OpenCode unavailable", async () => { + const { home, launch } = fixture(), command = vi.fn(); + expect(() => cliPath("opencode", home, launch.env)).toThrow("not found"); + executable(home, ".bun/bin/opencode", 0o600); + const f = await source(launch, command); + await expect(f.source.usage!()).rejects.toMatchObject({ code: "cliNotFound" }); + expect(command).not.toHaveBeenCalled(); expect(f.http).not.toHaveBeenCalled(); +}); +it("does not add Bun discovery or change the search order for other tools", () => { + const { home, launch } = fixture(); executable(home, ".bun/bin/codex"); + expect(() => cliPath("codex", home, launch.env)).toThrow("not found"); + expect(vi.mocked(accessSync).mock.calls.map(([path]) => path)).toEqual(["/opt/homebrew/bin/codex", "/usr/local/bin/codex", join(home, ".cargo/bin/codex"), join(home, ".local/bin/codex"), "/synthetic-empty-path/codex"]); +}); diff --git a/tests/preferences.test.ts b/tests/preferences.test.ts index 76fa446..434656e 100644 --- a/tests/preferences.test.ts +++ b/tests/preferences.test.ts @@ -88,3 +88,24 @@ it("persists the report cooldown as a native date and blocks requests after rela expect(afterRestart.state.retryAt).toBe(now + 600000); expect(writes).toEqual([["write", "dev.meterusage.fixture", "reportNextAllowedAt", "-date", "2026-10-15T12:10:00.000Z"]]); }); + +it("validates the Grok Cursor account fingerprint without accepting tokens or raw identity", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-grok-preferences-")); + const launch = { home: root, data: root, demo: true, candidate: true, env: {} }; + try { + const prefs = await Preferences.load(launch); + for (const value of ["a".repeat(64), "off", ""]) { await prefs.set("desktopGrokIdentity", value); expect((await Preferences.load(launch)).values.desktopGrokIdentity).toBe(value); } + for (const value of ["synthetic-account", "WorkosCursorSessionToken=synthetic", "A".repeat(64), 1]) await expect(prefs.set("desktopGrokIdentity", value)).rejects.toThrow("Invalid preference"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("defaults saved Grok source to Cursor and accepts only fixed internal methods", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-grok-source-")); + try { + const launch = { home: root, data: root, env: {}, demo: true, candidate: true }; + const prefs = await Preferences.load(launch); + expect(prefs.values.desktopGrokSource).toBe("cursor"); + for (const source of ["bot", "cursor"]) { await prefs.set("desktopGrokSource", source); expect((await Preferences.load(launch)).values.desktopGrokSource).toBe(source); } + for (const source of ["", "unknown", "Bot", 1]) await expect(prefs.set("desktopGrokSource", source)).rejects.toThrow("Invalid preference"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index fdcc2e5..d510faa 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -2,7 +2,7 @@ import { it, expect, vi } from "vite-plus/test"; import { createElement, useEffect, useRef, useState } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { ActivityHeatmap, App, Connections, Meter, ProviderCard, Settings, UsagePanel } from "../src/renderer/app"; -import { tokens, providers, primary, quota, value, window, localDay, shiftDay } from "../src/domain/models"; +import { tokens, providers, primary, quota, value, window, localDay, shiftDay, utcDay } from "../src/domain/models"; import type { ViewState } from "../src/shared/ipc"; import { openRouterUsage } from "../src/main/providers/usage"; import { telemetry } from "../src/domain/telemetry"; @@ -146,13 +146,14 @@ it("offers only supported methods and puts Done after the source choices", () => const markup = renderApp(view, "settings"); expect(markup).toContain('aria-label="Codex connection method"'); expect(markup).toContain('aria-label="Claude connection method"'); - expect(markup).not.toContain('aria-label="Grok connection method"'); - expect(markup).toContain('Grok CLI'); + expect(markup).toContain('aria-label="Grok connection method"'); + expect(markup).toContain('>Grok CLI'); + expect(markup).toContain('>Grok Bot Desktop (Beta)'); expect(markup).toContain('>Browser sign-in'); expect(markup).toContain('>Claude Desktop'); expect(markup).toContain('>Claude Code CLI'); const grok = markup.slice(markup.indexOf('id="connection-grok"')).split('')[0]; - expect(grok).not.toContain("Done')).toBeGreaterThan(markup.indexOf('id="connection-grok"')); }); it("keeps a selected CLI switch explicit while showing the still-connected account", () => { @@ -166,6 +167,15 @@ it("keeps a selected CLI switch explicit while showing the still-connected accou expect(markup).toContain('Use CLI disconnects this MeterUsage account connection.'); expect(markup).not.toContain('>Disconnect'); }); +it("describes a saved account with missing allowance without claiming a live connection", () => { + const view = state(); view.snapshot.demo = false; + view.connections = [{ provider: "antigravity", source: "account", localAvailable: true, status: "connected" }]; + view.snapshot.quotas.antigravity = { status: "missing", reason: "Not signed in to Antigravity Desktop", code: "notSignedIn" }; + const markup = renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} })); + expect(markup).toContain("Connection saved. Account allowance is unavailable."); + expect(markup).toContain("Allowance: Not signed in to Antigravity Desktop"); + expect(markup).not.toContain("Connected. Account allowance is unavailable."); +}); it("restores the saved CLI source without claiming account sign-in", () => { const view = state(); view.snapshot.demo = false; view.settings.values.showProviderClaude = true; @@ -872,3 +882,75 @@ 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"); +}); + +it("explains direct Grok Bot Desktop Beta setup without requiring Cursor or a CLI", () => { + const view = state(); view.snapshot.demo = false; + view.connections = [{ provider: "grok", source: "account", localAvailable: true, status: "disconnected" }]; + const markup = renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} })); + for (const text of ["Connect Grok Bot Desktop (Beta)", "Sign in through Grok Bot Desktop first", "No CLI or separate MeterUsage xAI sign-in", "shared-pool coverage remain unverified"]) expect(markup).toContain(text); +}); + + +it("offers key-free local OpenCode Go setup and dispatches the existing collection preference", () => { + const view = state(), action = vi.fn(async () => {}); + view.snapshot.demo = false; + const markup = renderToStaticMarkup(createElement(Connections, { state: view, action })); + expect(markup).toContain('id="connection-openCodeGo"'); + expect(markup).toContain("Use local OpenCode"); + expect(markup).toContain("Local Go activity needs no sign-in or key."); + expect(markup).toContain("An existing Go key also enables allowance."); + const root = Connections({ state: view, action }); + const group = root.props.children.find((node: any) => node?.props?.className === "settings-group"); + const local = group.props.children.find((node: any) => node?.props?.id === "connection-openCodeGo"); + local.props.children[0].props.children[1].props.onClick(); + expect(action).toHaveBeenLastCalledWith({ action: "setPreference", key: "showProviderOpenCodeGo", value: true }); + view.settings.values.showProviderOpenCodeGo = true; + expect(renderToStaticMarkup(createElement(Connections, { state: view, action }))).toContain("Local collection enabled"); + view.snapshot.demo = true; + expect(renderToStaticMarkup(createElement(Connections, { state: view, action }))).toMatch(/]*disabled)[^>]*>Stop local OpenCode/); +}); + +it.each(providers)("gives %s a matching 26-week grid for 30-day feeds without inventing older zeros", provider => { + const view = state(), day = provider === "openRouter" ? utcDay(now) : localDay(now), daily = [{ day, tokens: tokens({ output: 1234 }), sessionCount: 2, estimatedCostUSD: 0 }]; + view.snapshot.activities = {}; + view.snapshot.usages = { [provider]: value({ provider, sessionCount: 2, messageCount: 3, todaySessionCount: 2, todayMessageCount: 3, capturedAt: now, telemetry: telemetry([], daily, now, provider === "openRouter") }) }; + view.snapshot.appearance.heatmap = view.snapshot.appearance.codexHeatmap = view.snapshot.appearance.claudeHeatmap = true; + const markup = renderToStaticMarkup(createElement(ProviderCard, { slot: primary(provider), state: view, action: async () => {} })); + expect(markup).toContain('class="heatmap full-history"'); + expect(markup).toContain("26 weeks"); + const unavailable = (markup.match(/class="heatmap-cell unavailable-day"/g) ?? []).length; + const liveDays = 182 - (6 - (provider === "openRouter" ? new Date(day).getUTCDay() : new Date(day).getDay())); + expect(unavailable).toBe(liveDays - 30); + expect((markup.match(/class="heatmap-cell"/g) ?? []).length).toBe(30); + expect(markup).toContain("1,234 tokens, 2 sessions"); + const older = new Date(shiftDay(day, -31)).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric", ...(provider === "openRouter" ? {timeZone:"UTC"} : {}) }); + expect(markup).toContain(`${older}: Unavailable`); + expect(markup).not.toContain(`${older}: 0 tokens`); +}); + + +it.each(["cursor", "bot"] as const)("names the saved Grok %s account source accurately", accountMethod => { + const view = state(); view.snapshot.demo = false; + view.connections = [{ provider: "grok", source: "account", accountMethod, localAvailable: true, status: "connected" }]; + const markup = renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} })); + expect(markup).toContain(`Current source: ${accountMethod === "cursor" ? "Grok Bot via Cursor (legacy Beta)" : "Grok Bot Desktop (Beta)"}`); + expect(markup).toContain('>Grok Bot Desktop (Beta)'); +}); From cbceb9b52adf9307474483cfdccbf2367f75d317 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:57:51 -0400 Subject: [PATCH 2/4] fix: connect Grok Bot and add separate API billing --- CHANGELOG.md | 10 +- README.md | 10 +- Scripts/electron/desktop-keychain.swift | 2 +- docs/API_USAGE_BETA.md | 223 +++++++----------- docs/DESKTOP-CONNECTIONS.md | 26 +- docs/KB.md | 16 +- docs/PRIVACY.md | 34 ++- docs/adr/0028-grok-bot-desktop-reader.md | 1 + docs/adr/0029-api-billing-snapshots.md | 25 ++ .../0030-grok-bot-encrypted-compatibility.md | 22 ++ docs/adr/README.md | 2 + docs/electron/README.md | 5 +- electron-builder.yml | 2 + src/domain/api-billing.ts | 8 + src/main/api-usage-helper.ts | 20 ++ src/main/connections.ts | 24 +- src/main/coordinator.ts | 8 +- src/main/electron.ts | 3 +- src/main/providers/api-billing.ts | 134 +++++++++++ src/main/providers/grok-bot-desktop.ts | 44 +++- src/renderer/app.tsx | 21 +- src/shared/state.ts | 2 + tests/api-billing-integration.test.ts | 104 ++++++++ tests/api-billing.test.ts | 160 +++++++++++++ tests/electron-lifecycle.test.ts | 7 + tests/grok-bot-desktop.test.ts | 58 ++++- vite.config.ts | 1 + 27 files changed, 784 insertions(+), 188 deletions(-) create mode 100644 docs/adr/0029-api-billing-snapshots.md create mode 100644 docs/adr/0030-grok-bot-encrypted-compatibility.md create mode 100644 src/domain/api-billing.ts create mode 100644 src/main/api-usage-helper.ts create mode 100644 src/main/providers/api-billing.ts create mode 100644 tests/api-billing-integration.test.ts create mode 100644 tests/api-billing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b302e67..b5b94e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ follows [Semantic Versioning](https://semver.org/). ### Added +- Add separate OpenAI API and Anthropic API billing cards backed by projected + owner-only snapshots. A bundled one-run helper uses existing environment-only + organization Admin keys. Keep API billing distinct from subscription quota + and estimates; show report periods, capture age and stale state. Live billing + acceptance remains pending. - 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 @@ -18,7 +23,7 @@ follows [Semantic Versioning](https://semver.org/). ### Changed - Connect new Grok Bot accounts through **Grok Bot Desktop (Beta)** after - explicit consent. Read only the active scoped encrypted access token and team + explicit consent. Read only the active account-bound encrypted access token and team through a fixed Keychain item; use a bounded read-only allowance request with MeterUsage's own headers. Reject identity/team/store changes and cancelled results. Keep saved Cursor connections compatible and CLI selection explicit. @@ -31,6 +36,9 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Read Grok Bot's account-bound encrypted legacy v10 access values and correct + its fixed Keychain account to Grok Bot Key. Preserve scope, expiry, identity, + cancellation and store checks; reject plaintext and unsupported encryption. - Give 30-day provider activity the same 26-column, seven-row heatmap layout. Keep older unavailable dates distinct from measured zero-token days. - Add **Use local OpenCode** setup for key-free Go session history and restore diff --git a/README.md b/README.md index cc2a52b..e55b333 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ reordering and readable token totals. Account coverage depends on the source. Build 54 keeps Grok Beta CLI-only; Antigravity desktop login is follow-up work. Current source additionally offers opt-in [Grok Bot Desktop Beta](docs/DESKTOP-CONNECTIONS.md#grok-bot-desktop-beta). Sign in through Grok Bot Desktop first, then approve read-only access. -Native/live support, X and shared-pool coverage remain unverified. +Native/live allowance, X and shared-pool coverage remain unverified. +Current source also adds separate [OpenAI API and Anthropic API billing snapshots](docs/API_USAGE_BETA.md) +through a bundled environment-only helper. OpenRouter keeps its existing +account balance and aggregate activity integration. Build 54's Settings footer incorrectly says `0.2.41`; the packaged version is `0.3.0-beta.1`. See [known beta limits](docs/BETA.md#known-limits). @@ -75,8 +78,9 @@ CLI/local collection. The [Electron source choices](docs/electron/README.md#sour also describe optional Codex browser sign-in and Claude Desktop allowance. Local activity covers this Mac. Provider-reported account data has its own -coverage limits. Missing tokens remain unknown; costs are estimates and provider -dashboards remain the billing source of record. Read the [privacy contract](docs/PRIVACY.md) +coverage limits. Missing tokens remain unknown. Local token costs are estimates; optional API +billing cards label provider-reported amounts separately. Provider dashboards +remain the billing source of record. Read the [privacy contract](docs/PRIVACY.md) before changing a data source or sharing screenshots. ## Can both editions coexist? diff --git a/Scripts/electron/desktop-keychain.swift b/Scripts/electron/desktop-keychain.swift index 03a7bf1..acaa3cb 100644 --- a/Scripts/electron/desktop-keychain.swift +++ b/Scripts/electron/desktop-keychain.swift @@ -11,7 +11,7 @@ guard CommandLine.arguments.dropFirst().allSatisfy({ ["--allow-ui", "--grok-bot" let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: grok ? "Grok Bot Safe Storage" : "Claude Safe Storage", - kSecAttrAccount as String: grok ? "Grok Bot" : "Claude Key", + kSecAttrAccount as String: grok ? "Grok Bot Key" : "Claude Key", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecUseAuthenticationContext as String: context diff --git a/docs/API_USAGE_BETA.md b/docs/API_USAGE_BETA.md index d1d9246..2d2708b 100644 --- a/docs/API_USAGE_BETA.md +++ b/docs/API_USAGE_BETA.md @@ -1,141 +1,86 @@ -# API usage tracking beta - -> **Status: Research proposal; not implemented.** meterusage does not currently fetch OpenAI API or Anthropic API organization usage or cost data. - -This proposal adds provider-reported API spend to meterusage without mixing it with Codex or Claude subscription usage. It is a documentation-only research record. `README.md` and `docs/PRIVACY.md` stay unchanged until an implementation is approved and verified. - -## Supported provider findings - -### OpenAI API - -OpenAI documents organization-level usage endpoints and an organization costs endpoint. The costs response supports time buckets and grouping fields. Access requires an organization Admin API key, which has broader authority than a project API key. - -Official sources: - -- [Usage API reference](https://platform.openai.com/docs/api-reference/usage) -- [Costs endpoint reference](https://platform.openai.com/docs/api-reference/usage/costs) -- [Administration overview](https://platform.openai.com/docs/api-reference/administration) - -### Anthropic API - -Anthropic documents organization usage and cost reports through the Admin API. The reports support time buckets and grouping by fields such as workspace and model. Access requires an organization Admin API key. Anthropic states that Priority Tier costs are not included in the cost report. - -Official sources: - -- [Usage and cost API](https://platform.claude.com/docs/en/manage-claude/usage-cost-api) - -### Other providers - -Other providers remain research candidates. They should enter this beta only when they expose a documented aggregate usage or billing API with a clear authentication contract. Per-request token fields and local price calculations are not substitutes for a provider-reported billing total. - -## Proposed beta boundary - -The first beta would support only OpenAI API and Anthropic API organization spend. It would be disabled by default and would not change the menu-bar label. - -The beta would report: - -- current UTC-month spend from the provider's cost report -- daily spend for the last seven days -- request and token totals when the provider reports them -- the last successful snapshot time -- loading, stale, unavailable, and authentication-error states - -Provider cost reports would remain distinct from meterusage's locally estimated token costs. The interface must label provider-reported amounts as billing data and local calculations as estimates. - -## Proposed snapshot-helper flow - -```mermaid -flowchart LR - A[User starts local helper] --> B[Helper reads admin key from its process environment] - B --> C[Official provider usage and cost API] - C --> D[Drop project, workspace, key, and account identifiers] - D --> E[Write owner-readable normalized snapshot] - E --> F[meterusage reads snapshot] - F --> G[OpenAI API or Anthropic API spend card] +# API billing snapshots + +Current Electron source adds separate OpenAI API and Anthropic API billing cards. +The published beta1 does not contain them. OpenRouter keeps its existing account +balance, key limits and aggregate activity integration. + +## What the cards report + +OpenAI API and Anthropic API show provider-reported USD cost buckets for the +current UTC month through the helper request time. They are separate from Codex +and Claude subscriptions and from locally estimated token costs. Empty reports +remain unavailable; a provider-reported zero is displayed as zero. These cards +do not invent token counts, budgets or quota percentages. + +The card shows the report period and exact snapshot time. A snapshot becomes +stale after 24 hours or a UTC-month change. A prior month's snapshot retains +its own period. Refresh rereads the file; it does not fetch billing or make an +old snapshot fresh. Anthropic's cost report excludes Priority Tier costs. Neither +provider's report replaces its dashboard or invoice. + +## Set up the helper + +The optional helper needs an existing organization Admin API key. A normal +project API key and a ChatGPT or Claude subscription sign-in do not grant this +access. Keep these high-authority keys out of MeterUsage and chat. + +Use a trusted secret manager or an existing process environment to make only +`OPENAI_ADMIN_KEY` or `ANTHROPIC_ADMIN_KEY` available to the short-lived helper. +Do not put a key in command arguments, shell history, a profile or a repository. +The helper removes its selected key from its own environment before fetching. +It cannot remove a key from the parent shell. + +In a build containing this feature, the helper is bundled with the installed app. +Run one provider per invocation: + +```sh +ELECTRON_RUN_AS_NODE=1 /Applications/MeterUsage.app/Contents/MacOS/meterusage \ + /Applications/MeterUsage.app/Contents/Resources/api-usage-helper.cjs openai +ELECTRON_RUN_AS_NODE=1 /Applications/MeterUsage.app/Contents/MacOS/meterusage \ + /Applications/MeterUsage.app/Contents/Resources/api-usage-helper.cjs anthropic ``` -The recommended beta uses a separate, short-lived local helper. The helper reads an existing Admin API key from its process environment, calls the official API, removes identifiers, and writes a normalized snapshot with owner-only file permissions. meterusage reads the snapshot and does not receive or persist the key. - -The snapshot should contain only the fields needed for display and freshness checks: - -- provider identity -- bucket start and end timestamps -- currency and amount -- aggregate request, input-token, output-token, and cache-token counts when available -- snapshot creation time -- schema version - -The helper must not write a key, authorization header, raw provider response, organization identifier, project identifier, workspace identifier, user identifier, or API-key identifier to disk or logs. - -This proposal does not define a command for supplying credentials. Any later setup documentation must preserve the current rule against asking users to paste keys into meterusage. - -## Separate product identities - -API billing must use identities that are separate from the existing Codex and Claude providers. Suggested display names are `OpenAI API` and `Anthropic API`. - -This separation prevents an API cost snapshot from replacing or being presented as: - -- Codex subscription quota -- Claude subscription activity or optional quota windows -- OpenRouter usage and account balance -- locally estimated model costs - -The storage key, settings toggle, refresh status, diagnostics entry, and card must use the API-specific identity throughout the data path. - -## Security and privacy constraints - -Organization Admin API keys can expose organization-wide usage and cost data. The beta must treat them as high-authority credentials. - -- meterusage must not provide a key-entry field. -- meterusage must not read, display, log, cache, or persist an Admin API key. -- The helper must keep the key in memory only for one fetch run. -- The helper must use documented HTTPS endpoints and reject non-success responses without writing their raw bodies to logs. -- Snapshots must exclude account, organization, project, workspace, user, and key identifiers. -- Snapshots must use owner-only file permissions and an atomic replacement write. -- Diagnostics must report provider, freshness, and error category without including request or response bodies. -- Fixtures and tests must use synthetic values. -- A future implementation requires security and privacy review before release. - -The existing OpenRouter path proves that meterusage can show authenticated aggregate usage while retaining only display-safe fields. It does not remove the need for a separate review because OpenAI and Anthropic require organization-level admin credentials for these reports. - -## UX concept - -Each API provider would have an optional card in the popover and a separate Settings toggle. The card would lead with current-month spend, followed by a seven-day daily trend and secondary usage totals. It would always show the snapshot timestamp. - -State labels should be explicit: - -- `Not configured` when no snapshot exists -- `Updated
; } -export function Meter({ w, now, activityAt, pacing }: { w: QuotaWindow; now: number; activityAt?: number; pacing: boolean }) { +export function Meter({ w, now, activityAt, pacing, quotaBurn = false }: { w: QuotaWindow; now: number; activityAt?: number; pacing: boolean; quotaBurn?: boolean }) { const raw = pacing ? pace(w, now) : undefined, eta = raw && etaSeconds(raw, w.resetsAt, now); - const text = raw ? paceText(raw) : w.pacingBaseline ? "Collecting usage after reset" : undefined; - return
{w.label}{Math.round(w.usedPercent)}% used
{pacing && text}{eta !== undefined ? formatETA(eta) : w.resetsAt !== undefined ? `Resets ${new Date(w.resetsAt).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : "Reset not reported"}
; + const burn = pacing && quotaBurn ? measuredBurn(w, now) : undefined; + const text = quotaBurn ? burn ? `Quota burn · ${burn.percentPerHour > 0 && burn.percentPerHour < .1 ? "<0.1" : new Intl.NumberFormat("en", { maximumFractionDigits: 1 }).format(burn.percentPerHour)} pp/h` : "Collecting quota burn rate" : raw ? paceText(raw) : w.pacingBaseline && durationMins(w) ? "Collecting usage after reset" : undefined; + const burnETA = burn?.projectedExhaustion; + const reset = w.resetsAt !== undefined ? `Resets ${new Date(w.resetsAt).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : "Reset not reported"; + return
{w.label}{Math.round(w.usedPercent)}% used
{pacing && text}{burnETA !== undefined ? `Est. ${formatETA((burnETA - now) / 1000)}` : eta !== undefined ? formatETA(eta) : reset}
; } export function ActivityHeatmap({ title, scope, period, label, fullHistory, unavailable = false, controls, children }: { title: string; scope: string; period?: string; label: string; fullHistory?: boolean; unavailable?: boolean; controls?: ReactNode; children?: ReactNode }) { const grid = useRef(null); @@ -113,7 +117,7 @@ export function ProviderCard({ slot, state, action, detail = false, staticExport const t = activity?.telemetry ?? usage?.telemetry, at = activity && lastBurn(activity.sessions), status = extract(s.statuses[slot.provider]); const activeWindow = detail && q && activity ? headlineWindow(slot.provider, effectiveWindows(q)) : undefined; const heatmap = s.appearance.heatmap && (slot.provider !== "claude" || s.appearance.claudeHeatmap) && (slot.provider !== "codex" || s.appearance.codexHeatmap); - return

{slotName(slot)}{slot.provider === "grok" && <> Beta}

{plan && {plan}}
{status &&

{status.stale ? `Last known ${status.severity === "operational" ? "Operational" : status.description} · ${formatETA((s.clock - status.checkedAt) / 1000, true)} ago · ${status.refreshError}` : status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

Last known · {new Date(q.capturedAt).toLocaleString()}
{reason(s.quotas[key])}

}{q.groups.length ? q.groups.map(g =>

{g.title}

{g.windows.map((w, index) => )}
) : (detail ? effectiveWindows(q) : q.windows).map((w, index) => )}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

≈ {money(q.credits.dollarBalance)} equivalent

}{q.credits.usedDollars !== undefined &&

{money(q.credits.usedDollars)} used{q.credits.limitDollars === undefined ? "" : ` of ${money(q.credits.limitDollars)}`}

}
}{q.resetCreditCount !== undefined &&

{q.resetCreditCount} earned reset credits

{q.resetCredits.map(c =>
{c.title}{!staticExport && fresh?.canConsumeReset === true && (!detail || s.appearance.resetButton) && resetCreditAvailable(c, s.clock) && }

{c.status ?? "Status not reported"}{c.expiresAt !== undefined && c.expiresAt <= s.clock ? " · expired" : ""} · {c.expiresAt === undefined ? "Expiry not reported" : `Expires ${new Date(c.expiresAt).toLocaleDateString()}`}

)}
} :

{reason(s.quotas[key])}

}{usage &&

{slot.provider === "openRouter" ? "Account activity" : "Activity on this Mac"}

Sessions
{n(usage.sessionCount)}
Messages
{n(usage.messageCount)}
{usage.tokens !== undefined &&
Measured tokens
{n(totalTokens(usage.tokens))}
}{usage.estimatedCostUSD !== undefined &&
Estimated cost
{money(usage.estimatedCostUSD)}
}

{usage.todayMessageCount > 0 || usage.todaySessionCount > 0 ? `Today: ${n(usage.todaySessionCount)} session${usage.todaySessionCount === 1 ? "" : "s"} · ${n(usage.todayMessageCount)} messages · updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago` : `Updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago · ${usage.tokens !== undefined ? "measured token totals" : "token totals unavailable"}`}

{usage.usageWindows && (staticExport ? :
Usage by period
)}
}{accountActivity && heatmap && }{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && (staticExport ? :
Active window breakdown
)}{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

{session.model} · {session.messageCount} turns · estimated {money(session.estimatedCostUSD)}

)}
}{!activity && !usage && (s.activities[key] || s.usages[key]) &&

{reason(s.activities[key] ?? s.usages[key])}

}{detail && !staticExport &&
}
; + return

{slotName(slot)}{slot.provider === "grok" && <> Beta}

{plan && {plan}}
{status &&

{status.stale ? `Last known ${status.severity === "operational" ? "Operational" : status.description} · ${formatETA((s.clock - status.checkedAt) / 1000, true)} ago · ${status.refreshError}` : status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

Last known · {new Date(q.capturedAt).toLocaleString()}
{reason(s.quotas[key])}

}{q.groups.length ? q.groups.map(g =>

{g.title}

{g.windows.map((w, index) => c.provider === "grok" && c.source === "account" && c.accountMethod === "bot")} />)}
) : (detail ? effectiveWindows(q) : q.windows).map((w, index) => c.provider === "grok" && c.source === "account" && c.accountMethod === "bot")} />)}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

≈ {money(q.credits.dollarBalance)} equivalent

}{q.credits.usedDollars !== undefined &&

{money(q.credits.usedDollars)} used{q.credits.limitDollars === undefined ? "" : ` of ${money(q.credits.limitDollars)}`}

}
}{q.resetCreditCount !== undefined &&

{q.resetCreditCount} earned reset credits

{q.resetCredits.map(c =>
{c.title}{!staticExport && fresh?.canConsumeReset === true && (!detail || s.appearance.resetButton) && resetCreditAvailable(c, s.clock) && }

{c.status ?? "Status not reported"}{c.expiresAt !== undefined && c.expiresAt <= s.clock ? " · expired" : ""} · {c.expiresAt === undefined ? "Expiry not reported" : `Expires ${new Date(c.expiresAt).toLocaleDateString()}`}

)}
} :

{reason(s.quotas[key])}

}{usage &&

{slot.provider === "openRouter" ? "Account activity" : "Activity on this Mac"}

Sessions
{n(usage.sessionCount)}
Messages
{n(usage.messageCount)}
{usage.tokens !== undefined &&
Measured tokens
{n(totalTokens(usage.tokens))}
}{usage.estimatedCostUSD !== undefined &&
Estimated cost
{money(usage.estimatedCostUSD)}
}

{usage.todayMessageCount > 0 || usage.todaySessionCount > 0 ? `Today: ${n(usage.todaySessionCount)} session${usage.todaySessionCount === 1 ? "" : "s"} · ${n(usage.todayMessageCount)} messages · updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago` : `Updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago · ${usage.tokens !== undefined ? "measured token totals" : "token totals unavailable"}`}

{usage.usageWindows && (staticExport ? :
Usage by period
)}
}{accountActivity && heatmap && }{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && (staticExport ? :
Active window breakdown
)}{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

{session.model} · {session.messageCount} turns · estimated {money(session.estimatedCostUSD)}

)}
}{!activity && !usage && (s.activities[key] || s.usages[key]) &&

{reason(s.activities[key] ?? s.usages[key])}

}{detail && !staticExport &&
}
; } function RollingUsage({ windows, provider }: { windows: UsageWindow[]; provider: Provider }) { const reference = windows.find(w => w.label === "last 30d"), cost = (reference?.estimatedCostUSD ?? 0) > 0; @@ -195,7 +199,7 @@ export function ApiBillingCards({ state }: { state: ViewState }) { })}; } function ApiBillingConnections({ state }: { state: ViewState }) { - return

API billing Beta

Run the bundled API snapshot helper with an organization Admin key in its environment. MeterUsage reads the snapshot; no credentials are entered or stored in the app.

{apiBillingProviders.map(provider => { + return

API billing Beta

Run the bundled API snapshot helper with an organization Admin key in its environment. MeterUsage reads the snapshot; no credentials are entered or stored in the app.

{apiBillingProviders.map(provider => { const reading = state.snapshot.apiBilling?.[provider]; return
{apiBillingNames[provider]}{reading?.status === "value" ? :

{state.snapshot.demo ? "Snapshot access disabled in this profile." : reading?.code === "unavailable" ? "Snapshot unavailable. Run the helper to replace it." : "Not configured. Run the helper, then refresh."}

}{provider === "anthropic" &&

Priority Tier excluded.

}
; })}
; diff --git a/src/renderer/style.css b/src/renderer/style.css index 1e8a1dc..26afb22 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +.api-billing-setup pre { margin: 6px 0 10px; padding: 10px; background: var(--well); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; font-size: 11px; line-height: 1.5; } :root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; font-kerning: normal; font-variant-numeric: tabular-nums; line-height: 1.45; color-scheme: light dark; background: transparent; } * { box-sizing: border-box; scrollbar-width: none; } *::-webkit-scrollbar { display: none; } body { margin: 0; } h1,h2,h3,p { margin: 0; } h1 { font-size: 17px; font-weight: 650; } h2 { font-size: 14px; font-weight: 650; } h3 { font-size: 11px; font-weight: 600; color: var(--muted); margin: 12px 0 7px; } p + p { margin-top: 5px; } button,input,select { font: inherit; } button { font-size: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 3px 6px; background: var(--well); color: var(--text); cursor: pointer; } button:active { background: var(--border); } button:disabled { opacity: .45; cursor: default; } button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } input,select { color: var(--text); background: var(--well); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; } input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; } .app { --grok: #1e1e22; --claude: #c25e00; --antigravity: #2563eb; --opencode: #0f766e; --openrouter: #6d28d9; --canvas: #fafafc; --surface: #fff; --well: #ececf1; --border: #00000018; --text: #242426; --muted: #696973; --good: #238636; --warn: #a86800; --alert: #c93636; color: var(--text); background: var(--canvas); } diff --git a/tests/api-billing-setup.test.ts b/tests/api-billing-setup.test.ts new file mode 100644 index 0000000..3635ad0 --- /dev/null +++ b/tests/api-billing-setup.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; +import { readFileSync } from "node:fs"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ApiBillingSetup } from "../src/renderer/api-billing-setup"; + +describe("API billing setup instructions", () => { + it("renders collapsed setup guidance without a credential field", () => { + const html = renderToStaticMarkup(createElement(ApiBillingSetup)); + expect(html).toContain(" { + const guide = readFileSync(new URL("../docs/API_USAGE_BETA.md", import.meta.url), "utf8"); + const helper = readFileSync(new URL("../src/main/api-usage-helper.ts", import.meta.url), "utf8"); + expect(guide).toContain("api-usage-helper.cjs openai"); expect(guide).toContain("api-usage-helper.cjs anthropic"); + expect(guide).toContain("OPENAI_ADMIN_KEY"); expect(guide).toContain("ANTHROPIC_ADMIN_KEY"); + expect(guide).toContain("Refresh does not contact"); + expect(helper).toContain("env[name]"); expect(helper).toContain("delete env[name]"); + expect(helper).not.toMatch(/process\.argv\.slice\(.*KEY/); + }); +}); diff --git a/tests/grok-burn.test.ts b/tests/grok-burn.test.ts new file mode 100644 index 0000000..e2fa7f4 --- /dev/null +++ b/tests/grok-burn.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { primary, quota, Unavailable, window, type Quota, type QuotaWindow } from "../src/domain/models"; +import { measuredBurn, observeQuota, pace } from "../src/domain/pacing"; +import { Coordinator } from "../src/main/coordinator"; +import type { Source } from "../src/main/composition"; +import { readArchive, saveArchive } from "../src/main/history"; +import { launchConfiguration } from "../src/main/launch"; +import { Preferences } from "../src/main/preferences"; + +const at = Date.parse("2026-10-16T12:00:00Z"), minute = 60000, hour = 3600000; +const roots: string[] = [], coordinators: Coordinator[] = []; +const temp = () => { const root = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-fixture-")); roots.push(root); return root; }; +afterEach(() => { for (const c of coordinators.splice(0)) c.stop(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +const reading = (used: number, capturedAt = at, resetsAt?: number) => quota("grok", [window("Allowance", used, resetsAt)], capturedAt); +const observe = (used: number, capturedAt: number, previous?: Quota, resetsAt?: number, now = capturedAt) => observeQuota(reading(used, capturedAt, resetsAt), previous, now); +const burn = (q: Quota, now = q.capturedAt) => measuredBurn(q.windows[0], now); + +async function setup(source: Source, now: () => number, root = temp()) { + const launch = launchConfiguration(["--demo", "--candidate-profile", root], {}), prefs = await Preferences.load(launch); + prefs.values.desktopGrokSource = "bot"; + const c = new Coordinator(launch, prefs, [source], now); coordinators.push(c); + const live = () => { const state = c.quotas.grok; expect(state.status).toBe("value"); if (state.status !== "value") throw new Error("Expected quota"); return state.value; }; + return { c, prefs, live, root }; +} + +describe("measured percentage burn independent of cycle pace", () => { + it("requires distinct observations spanning a minute, without inventing a cycle", () => { + const first = observe(20, at); + expect(burn(first)).toBeUndefined(); + expect(burn(observe(21, at + minute - 1, first))).toBeUndefined(); + expect(burn(observe(21, at, first))).toBeUndefined(); + const next = observe(22, at + minute, first); + expect(burn(next)).toEqual({ percentPerHour: 120, projectedExhaustion: at + minute + 78 / 120 * hour }); + expect(pace(next.windows[0], next.capturedAt)).toBeUndefined(); + expect(next.windows[0].windowDurationMins).toBeUndefined(); + }); + + it("retains the first sample across shorter polls and measures zero only after a minute", () => { + const first = observe(20, at), short = observe(20, at + minute / 2, first); + expect(burn(short)).toBeUndefined(); + const unchanged = observe(20, at + minute, short); + expect(burn(unchanged)).toEqual({ percentPerHour: 0 }); + expect(burn(observe(21, at + 2 * minute, unchanged))?.percentPerHour).toBe(30); + }); + + it("uses a reported reset only to bound exhaustion, with no fabricated cadence", () => { + for (const reset of [undefined, at + hour, at + 10 * minute]) { + const next = observe(22, at + minute, observe(20, at, undefined, reset), reset); + expect(burn(next)?.percentPerHour).toBe(120); + expect(burn(next)?.projectedExhaustion !== undefined).toBe(reset === undefined || reset === at + hour); + } + const full = observe(100, at + minute, observe(99, at)); + expect(burn(full)).toEqual({ percentPerHour: 60 }); + const nearlyFull = observe(99, at + minute, observe(98, at)); + expect(burn(nearlyFull, at + 2 * minute)).toEqual({ percentPerHour: 60 }); + }); + + it.each(["decrease", "reset", "reset-added", "reset-removed", "expired"])("clears measurement on %s", change => { + const reset = at + hour, first = observe(20, at, undefined, reset), next = observe(22, at + minute, first, reset); + const renewed = observe(change === "decrease" ? 5 : 23, at + 2 * minute, next, + change === "reset" ? reset + hour : change === "reset-removed" ? undefined : change === "expired" ? at + 2 * minute : reset); + if (change === "reset-added") { + expect(burn(observe(23, at + 2 * minute, observe(22, at + minute, observe(20, at)), reset))).toBeUndefined(); + } else expect(burn(renewed)).toBeUndefined(); + expect(burn(next, reset)).toBeUndefined(); + }); + + it.each(["stale", "future", "out-of-order", "duplicate", "nonfinite"])("rejects %s readings and cannot revive them as time passes", kind => { + const first = observe(20, at), valid = observe(22, at + minute, first), now = at + 2 * minute; + const timestamp = kind === "stale" ? now - 5 * minute - 1 : kind === "future" ? now + 1 : kind === "out-of-order" ? at + minute - 1 : kind === "duplicate" ? valid.capturedAt : NaN; + const invalid = observe(23, timestamp, valid, undefined, now); + expect(invalid.windows[0].pacingBaseline).toBeUndefined(); + expect(burn(invalid, now + minute)).toBeUndefined(); + const fresh = observe(24, now + minute, invalid); + expect(burn(fresh)).toBeUndefined(); + expect(burn(observe(25, now + 2 * minute, fresh))?.percentPerHour).toBe(60); + }); + + it("expires old measurements and reanchors after a long gap", () => { + const first = observe(20, at), next = observe(22, at + minute, first); + expect(burn(next, at + 5 * minute)?.percentPerHour).toBe(120); + expect(burn(next, at + 5 * minute + 1)).toBeUndefined(); + expect(burn(next, at + minute - 1)).toBeUndefined(); + const resumed = observe(23, at + 7 * minute, next); + expect(burn(resumed)).toBeUndefined(); + expect(burn(observe(24, at + 8 * minute, resumed))?.percentPerHour).toBe(60); + }); + + it("rejects malformed baseline, percentage and reset fields", () => { + const good = observe(22, at + minute, observe(20, at)).windows[0]; + const invalid: QuotaWindow[] = [ + { ...good, usedPercent: NaN }, { ...good, usedPercent: -1 }, { ...good, usedPercent: 101 }, { ...good, resetsAt: Infinity }, + ...[{ usedPercent: 30, capturedAt: at, observedAt: at + minute }, { usedPercent: NaN, capturedAt: at, observedAt: at + minute }, + { usedPercent: 20, capturedAt: at + minute, observedAt: at }, { usedPercent: 20, capturedAt: at, observedAt: Infinity }, + { usedPercent: 20, capturedAt: at }, { usedPercent: 20, observedAt: at + minute }].map(pacingBaseline => ({ ...good, pacingBaseline })), + ]; + for (const w of invalid) expect(measuredBurn(w, at + minute)).toBeUndefined(); + expect(measuredBurn(good, NaN)).toBeUndefined(); + }); + + it("keeps group/window identities separate and ignores incoming baselines", () => { + const make = (time: number, used: number): Quota => ({ ...reading(used, time), groups: [ + { id: "a", title: "A", windows: [window("Allowance", used)] }, + { id: "b", title: "B", windows: [window("Allowance", used + 10)] }, + ] }); + const first = observeQuota(make(at, 20), undefined, at), next = make(at + minute, 22); + next.groups[0].windows[0].usedPercent = 21; next.groups[1].windows[0].label = "New window"; + const result = observeQuota(next, first, next.capturedAt); + expect(measuredBurn(result.groups[0].windows[0], next.capturedAt)?.percentPerHour).toBe(60); + expect(measuredBurn(result.groups[1].windows[0], next.capturedAt)).toBeUndefined(); + expect(burn(observeQuota({ ...next, windows: [observe(22, at + minute, observe(20, at)).windows[0]] }, undefined, next.capturedAt))).toBeUndefined(); + }); +}); + +describe("Grok Bot coordinator lifecycle and archive isolation", () => { + it("publishes live measured data but strips measurements from in-memory and persisted archives", async () => { + let clock = at, used = 20; + const { c, live, root } = await setup({ slot: primary("grok"), accountBound: true, quota: async () => reading(used, clock) }, () => clock); + await c.refresh(); expect(burn(live())).toBeUndefined(); + clock += minute; used = 22; await c.refresh(); + expect(burn(live())?.percentPerHour).toBe(120); + const snapshot = c.snapshot(); + expect(snapshot.quotas.grok.status === "value" && burn(snapshot.quotas.grok.value)?.percentPerHour).toBe(120); + expect(burn(snapshot.archived.grok, clock)).toBeUndefined(); + expect(readFileSync(join(root, "quota-archive.json"), "utf8")).not.toContain("pacingBaseline"); + expect(readArchive(join(root, "quota-archive.json")).grok.windows[0].usedPercent).toBe(22); + }); + + it("does not seed measurements from a legacy archive or a new coordinator session", async () => { + let clock = at + minute; + const root = temp(), saved = observe(22, clock, observe(20, at)); + launchConfiguration(["--demo", "--candidate-profile", root], {}); + saved.groups = [{ id: "a", title: "A", windows: saved.windows }]; + saveArchive(join(root, "quota-archive.json"), [{ slot: primary("grok"), quota: saved }]); + const source: Source = { slot: primary("grok"), accountBound: true, quota: async () => reading(24, clock) }; + const { c, live } = await setup(source, () => clock, root); + expect(burn(c.snapshot().archived.grok, clock)).toBeUndefined(); + expect(measuredBurn(c.snapshot().archived.grok.groups[0].windows[0], clock)).toBeUndefined(); + clock += minute; await c.refresh(); expect(burn(live())).toBeUndefined(); + clock += minute; await c.refresh(); expect(burn(live())).toEqual({ percentPerHour: 0 }); + c.stop(); + const next = new Coordinator(c.launch, c.preferences, [source], () => clock); coordinators.push(next); + clock += minute; await next.refresh(); + const state = next.quotas.grok; + expect(state.status === "value" && burn(state.value)).toBeUndefined(); + }); + + it("clears measurements on reconnect, disconnect and quota forgetting", async () => { + let clock = at, used = 20; + const source: Source = { slot: primary("grok"), accountBound: true, quota: async () => reading(used, clock) }; + const { c, live } = await setup(source, () => clock); + const collect = async () => { clock += minute; await c.refresh(); clock += minute; used++; await c.refresh(); expect(burn(live())?.percentPerHour).toBeGreaterThan(0); }; + await collect(); + c.sources = [{ ...source }]; clock += minute; await c.refresh(); expect(burn(live())).toBeUndefined(); + await collect(); + c.sources = []; expect(c.quotas.grok).toBeUndefined(); expect(burn(c.archived.grok, clock)).toBeUndefined(); + c.sources = [source]; clock += minute; await c.refresh(); expect(burn(live())).toBeUndefined(); + await collect(); c.forgetQuota("grok"); expect(c.archived.grok).toBeUndefined(); + clock += minute; await c.refresh(); expect(burn(live())).toBeUndefined(); + }); + + it("rejects an old account's in-flight response after source replacement", async () => { + let clock = at, hold = false, started!: () => void, release!: (q: Quota) => void; + const ready = new Promise(resolve => { started = resolve; }); + const old: Source = { slot: primary("grok"), accountBound: true, quota: async () => { + if (!hold) return reading(80, clock); + started(); return new Promise(resolve => { release = resolve; }); + } }; + const { c, live } = await setup(old, () => clock); + await c.refresh(); clock += minute; await c.refresh(); expect(burn(live())?.percentPerHour).toBe(0); + hold = true; const pending = c.refresh(); await ready; + c.forgetQuota("grok"); c.sources = [{ slot: primary("grok"), accountBound: true, quota: async () => reading(10, clock) }]; + clock += minute; release(reading(90, clock)); await pending; + expect(live().windows[0].usedPercent).toBe(10); expect(burn(live())).toBeUndefined(); + clock += minute; await c.refresh(); expect(burn(live())).toEqual({ percentPerHour: 0 }); + }); + + it.each(["offline", "notSignedIn", "noData"] as const)("does not revive measurements after %s", async failure => { + let clock = at, fail = false; + const { c, live } = await setup({ slot: primary("grok"), accountBound: true, quota: async () => { + if (fail) throw new Unavailable(failure); return reading(20, clock); + } }, () => clock); + await c.refresh(); clock += minute; await c.refresh(); expect(burn(live())?.percentPerHour).toBe(0); + fail = true; clock += minute; await c.refresh(); expect(c.quotas.grok.status).toBe("missing"); + if (failure === "offline") expect(burn(c.archived.grok, clock)).toBeUndefined(); + else expect(c.archived.grok).toBeUndefined(); + fail = false; clock += minute; await c.refresh(); expect(burn(live())).toBeUndefined(); + }); + + it("leaves legacy Cursor and non-account Grok sources without observations", async () => { + let clock = at; + const source: Source = { slot: primary("grok"), accountBound: true, quota: async () => reading(20, clock) }; + const { c, prefs, live } = await setup(source, () => clock); + prefs.values.desktopGrokSource = "cursor"; + await c.refresh(); clock += minute; await c.refresh(); expect(live().windows[0].pacingBaseline).toBeUndefined(); + prefs.values.desktopGrokSource = "bot"; c.sources = [{ ...source, accountBound: false }]; + await c.refresh(); clock += minute; await c.refresh(); expect(live().windows[0].pacingBaseline).toBeUndefined(); + }); +}); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index d510faa..3701be0 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -8,6 +8,7 @@ import { openRouterUsage } from "../src/main/providers/usage"; import { telemetry } from "../src/domain/telemetry"; import { trayTooltip } from "../src/domain/overview"; import { readFileSync } from "node:fs"; +import { observeQuota } from "../src/domain/pacing"; vi.mock("react", async importOriginal => { const actual = await importOriginal(); @@ -116,7 +117,7 @@ it("offers simple account setup and truthful Grok availability without directori const markup = renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} })); expect(markup).toContain('aria-label="Disconnect Claude"'); expect(markup).toContain("Sign in to Codex"); expect(markup).toContain("Disconnect"); expect(markup).toContain("Account allowance"); - expect(markup).toContain("Grok website and mobile usage are not supported"); expect(markup).not.toMatch(/directory|terminal|API key/); + expect(markup).toContain("Grok website and mobile usage are not supported"); expect(markup.split('
')[0]).not.toMatch(/directory|terminal|API key/); view.connections[0].status = "failed"; const failed = renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} })); expect(failed).toContain("Connection failed. Check the message above and try again."); expect(failed).toContain("Sign in to Codex"); @@ -229,6 +230,34 @@ const state = (): ViewState => ({ snapshot: { usages: {}, plans: {}, statuses: {}, appearance: { theme: "dark", accent: "blue", heatmap: false, claudeHeatmap: false, codexHeatmap: false, pacing: true, telemetry: false, chart: false, resetButton: false, compactTray: true, notch: true, pinned: false, onboarding: true }, archiveWriteFailed: false, clearingCache: false, }, settings: { values: {}, accounts: [] }, systemDark: true, notch: { expanded: true, cardOnRight: false, selected: "codex", dragging: false } }); +it("shows measured Grok quota burn on menu and detail cards without inventing tokens or billing", () => { + const view = state(), first = quota("grok", [window("Grok Bot Desktop", 10, now + 7200000)], now - 120000); + const observed = observeQuota(quota("grok", [window("Grok Bot Desktop", 12, now + 7200000)], now), observeQuota(first, undefined, first.capturedAt), now); + view.connections = [{ provider: "grok", source: "account", accountMethod: "bot", status: "connected", localAvailable: true }]; + view.snapshot.quotas.grok = value(observed); + const render = (detail = false) => renderToStaticMarkup(createElement(ProviderCard, { slot: primary("grok"), state: view, action: async () => {}, detail })); + for (const detail of [false, true]) { + const markup = render(detail); + expect(markup).toContain("Quota burn · 60 pp/h"); expect(markup).toContain("Est. 1h 28m left"); + expect(markup).toContain("Percentage points of quota used per hour"); expect(markup).not.toContain("tokens/min"); expect(markup).not.toContain("$"); + } + view.snapshot.appearance.pacing = false; + expect(render()).not.toContain("Quota burn"); expect(render()).not.toContain("Est. "); expect(render()).toContain("Resets "); + view.snapshot.appearance.pacing = true; + view.snapshot.archived.grok = observed; view.snapshot.quotas.grok = { status: "missing", code: "offline", reason: "Offline" }; + expect(render()).toContain("Last known"); expect(render()).not.toContain("Quota burn"); expect(render()).not.toContain("Collecting usage after reset"); +}); + +it("collects Grok observations before displaying a rate and preserves measured zero", () => { + const first = observeQuota(quota("grok", [window("Grok Bot Desktop", 87)], now - 60000), undefined, now - 60000); + const render = (w: typeof first.windows[number]) => renderToStaticMarkup(createElement(Meter, { w, now, pacing: true, quotaBurn: true })); + expect(render(first.windows[0])).toContain("Collecting quota burn rate"); expect(render(first.windows[0])).not.toContain("pp/h"); + const unchanged = observeQuota(quota("grok", [window("Grok Bot Desktop", 87)], now), first, now); + expect(render(unchanged.windows[0])).toContain("Quota burn · 0 pp/h"); expect(render(unchanged.windows[0])).not.toContain("Est. "); + const tiny = observeQuota(quota("grok", [window("Grok Bot Desktop", 87.0001)], now), first, now); + expect(render(tiny.windows[0])).toContain("<0.1 pp/h"); +}); + it("hides forecasts when pacing is disabled and retains the reported reset time", () => { const render = (pacing: boolean) => renderToStaticMarkup(createElement(Meter, { w, now, pacing })); expect(render(true)).toContain("left"); From c2d092cb92180b37a686582a904dbd0129bc371c Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:22:54 -0400 Subject: [PATCH 4/4] feat: simplify API billing setup and preload tray usage --- CHANGELOG.md | 12 +- README.md | 4 +- Scripts/electron/desktop-keychain.swift | 18 ++- docs/API_USAGE_BETA.md | 60 +++++++--- docs/KB.md | 24 +++- docs/PRIVACY.md | 26 ++++- docs/adr/0032-one-time-api-setup.md | 35 ++++++ docs/adr/README.md | 2 + docs/electron/README.md | 8 +- src/main/api-billing-fetch.ts | 43 +++++++ src/main/electron.ts | 11 +- src/main/ipc.ts | 2 + src/renderer/api-billing-setup.tsx | 38 +++--- src/renderer/app.tsx | 12 +- src/renderer/style.css | 5 +- src/shared/ipc.ts | 2 + tests/api-billing-fetch.test.ts | 147 ++++++++++++++++++++++++ tests/api-billing-integration.test.ts | 19 +-- tests/api-billing-setup.test.ts | 72 +++++++++--- tests/electron-lifecycle.test.ts | 77 ++++++++++++- tests/ipc.test.ts | 18 +++ tests/renderer.test.ts | 9 +- 22 files changed, 547 insertions(+), 97 deletions(-) create mode 100644 docs/adr/0032-one-time-api-setup.md create mode 100644 src/main/api-billing-fetch.ts create mode 100644 tests/api-billing-fetch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e73db..c8c102a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,6 @@ follows [Semantic Versioning](https://semver.org/). - Show Grok Bot Desktop quota burn in percentage points per hour from recent same-account readings. Collect for at least a minute, discard old/reset observations and show an estimated run-out only when measured. -- Add expandable API billing setup with provider Admin-key locations, hidden - Terminal prompts, helper commands and fixed result guidance. - - Add separate OpenAI API and Anthropic API billing cards backed by projected owner-only snapshots. A bundled one-run helper uses existing environment-only organization Admin keys. Keep API billing distinct from subscription quota @@ -28,6 +25,12 @@ follows [Semantic Versioning](https://semver.org/). ### Changed +- Move API setup to its own Settings sidebar page. OpenAI and Anthropic API + setup now accepts a one-time Admin key and fetches billing directly, with no + Terminal steps. Existing legacy Admin keys can be reused through a read-only + Keychain action; newly pasted keys are not saved. Account connections and + onboarding stay separate. + - Connect new Grok Bot accounts through **Grok Bot Desktop (Beta)** after explicit consent. Read only the active account-bound encrypted access token and team through a fixed Keychain item; use a bounded read-only allowance request with @@ -42,6 +45,9 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Preload the tray usage window hidden after completed onboarding, before + connection recovery or provider collection. The first click reuses the loaded + renderer instead of starting its initial load. - Read Grok Bot's account-bound encrypted legacy v10 access values and correct its fixed Keychain account to Grok Bot Key. Preserve scope, expiry, identity, cancellation and store checks; reject plaintext and unsupported encryption. diff --git a/README.md b/README.md index e55b333..daa2543 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,9 @@ Current source additionally offers opt-in [Grok Bot Desktop Beta](docs/DESKTOP-C Sign in through Grok Bot Desktop first, then approve read-only access. Native/live allowance, X and shared-pool coverage remain unverified. Current source also adds separate [OpenAI API and Anthropic API billing snapshots](docs/API_USAGE_BETA.md) -through a bundled environment-only helper. OpenRouter keeps its existing +through Settings → API connections with a saved legacy Admin key, a one-time +Admin key, or the bundled environment-only helper. Existing Keychain entries +are preserved; pasted keys are not saved. OpenRouter keeps its existing account balance and aggregate activity integration. Build 54's Settings footer incorrectly says `0.2.41`; the packaged version is `0.3.0-beta.1`. See [known beta limits](docs/BETA.md#known-limits). diff --git a/Scripts/electron/desktop-keychain.swift b/Scripts/electron/desktop-keychain.swift index acaa3cb..5d44852 100644 --- a/Scripts/electron/desktop-keychain.swift +++ b/Scripts/electron/desktop-keychain.swift @@ -2,22 +2,28 @@ import Foundation import LocalAuthentication import Security -// The helper has two fixed provider modes. Background polling cannot open an auth dialog. +// Only fixed provider items may be read. Background polling cannot open an auth dialog. +let modes = CommandLine.arguments.dropFirst().filter { $0 != "--allow-ui" } +guard modes.count <= 1, modes.allSatisfy({ ["--grok-bot", "--api-openai", "--api-anthropic"].contains($0) }), + CommandLine.arguments.dropFirst().filter({ $0 == "--allow-ui" }).count <= 1 else { exit(1) } let context = LAContext() context.interactionNotAllowed = !CommandLine.arguments.contains("--allow-ui") SecKeychainSetUserInteractionAllowed(!context.interactionNotAllowed) let grok = CommandLine.arguments.contains("--grok-bot") -guard CommandLine.arguments.dropFirst().allSatisfy({ ["--allow-ui", "--grok-bot"].contains($0) }) else { exit(1) } -let query: [String: Any] = [ +let api = modes.first?.hasPrefix("--api-") == true +var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: grok ? "Grok Bot Safe Storage" : "Claude Safe Storage", - kSecAttrAccount as String: grok ? "Grok Bot Key" : "Claude Key", + kSecAttrService as String: api ? "com.meterusage.api-keys" : grok ? "Grok Bot Safe Storage" : "Claude Safe Storage", + kSecAttrAccount as String: api ? modes.first == "--api-openai" ? "openAI" : "anthropic" : grok ? "Grok Bot Key" : "Claude Key", kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, kSecUseAuthenticationContext as String: context ] +if api { query[kSecAttrSynchronizable as String] = false } var result: CFTypeRef? -guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, +let status = SecItemCopyMatching(query as CFDictionary, &result) +if api && status == errSecItemNotFound { exit(0) } +guard status == errSecSuccess, let password = result as? Data, !password.isEmpty else { exit(1) } // Only the owning main process receives stdout. Never run this helper for QA. FileHandle.standardOutput.write(password) diff --git a/docs/API_USAGE_BETA.md b/docs/API_USAGE_BETA.md index 5cd023d..ce0f2f6 100644 --- a/docs/API_USAGE_BETA.md +++ b/docs/API_USAGE_BETA.md @@ -7,7 +7,7 @@ balance, key limits and aggregate activity integration. ## What the cards report OpenAI API and Anthropic API show provider-reported USD cost buckets for the -current UTC month through the helper request time. They are separate from Codex +current UTC month through the request time. They are separate from Codex and Claude subscriptions and from locally estimated token costs. Empty reports remain unavailable; a provider-reported zero is displayed as zero. These cards do not invent token counts, budgets or quota percentages. @@ -18,21 +18,44 @@ its own period. Refresh rereads the file; it does not fetch billing or make an old snapshot fresh. Anthropic's cost report excludes Priority Tier costs. Neither provider's report replaces its dashboard or invoice. -## Set up the helper - -The optional helper needs an existing organization Admin API key. A normal -project API key and a ChatGPT or Claude subscription sign-in do not grant this -access. In OpenAI Platform, create an Admin key under Organization settings → -Admin keys. In Anthropic Console, create an Admin key under Settings → Admin -keys. Keep these high-authority keys out of MeterUsage and chat. - -In MeterUsage Settings, expand **Set up API billing snapshots**. Open Terminal -on your Mac, paste the matching command below, then paste the Admin key at the -hidden prompt and press Return. The commands use macOS zsh. The entered key is -not a command, so it does not enter shell history. The subshell supplies only the -selected environment variable to the helper and exits afterward. MeterUsage and -the parent shell do not store the entered key. Never put a key in command text, -a profile, a repository or chat. +## Set up in Settings + +1. Open **Settings → API connections**. +2. If you saved an Admin key in the previous MeterUsage, click **Use saved key** for that provider. Approve macOS Keychain access if asked. This fetches billing without entering the key again. +3. Otherwise, create an organization Admin key in **OpenAI Platform → Organization settings → Admin keys**, or **Anthropic Console → Settings → Admin keys**. +4. Paste the key into the matching provider's masked field and click **Fetch billing**. + +MeterUsage clears the field on submit and updates the billing snapshot after a +successful request. Pasted keys are not saved. Use **Use saved key** again to +refresh with an existing Keychain key; otherwise, enter a one-time key. Leaving the API page clears unsubmitted input. A normal project key or +ChatGPT or Claude subscription sign-in cannot read organization billing. + +The key passes through the trusted Settings bridge to a one-time main-process +request to that provider. A pasted key never enters preferences, Keychain, diagnostic +reports or billing snapshots. There is no background billing fetch. Demo and +isolated profiles cannot fetch billing. Keep keys out of chat and documentation. + +Authentication failure means the key is invalid or lacks organization access. +Data unavailable means no supported report could be fetched. Check the provider +billing dashboard; a failed fetch preserves the previous snapshot. Refresh +rereads snapshots without contacting the billing API. + +Saved-key access reads only the previous app's fixed `com.meterusage.api-keys` +Keychain service and `openAI` or `anthropic` account. The key stays in the main +process and native helper; it is never returned to the UI or copied elsewhere. +The original entry is not changed or deleted. Missing and denied entries show +a fixed error, with no environment or other-account fallback. Keychain is read +only after the explicit action, never at startup or during polling. + +## Advanced: use the helper + +The bundled environment-only helper remains available. Open Terminal on your +Mac, paste the matching command below, then paste the Admin key at the hidden +prompt and press Return. The commands use macOS zsh. The entered key is not a +command, so it does not enter shell history. The subshell supplies only the +selected environment variable to the helper and exits afterward. The parent +shell and MeterUsage do not save the entered key. Never put a key in command +text, a profile, a repository or chat. OpenAI API: @@ -91,8 +114,9 @@ integration. ## Data and credential boundary -Only the helper receives the environment key. MeterUsage reads only normalized -numeric snapshots. A snapshot contains schema/provider, capture and period +The Settings action receives a one-time key in memory; the advanced helper +receives its key through the environment. Both use the same fixed-endpoint +collector and write only normalized numeric snapshots. A snapshot contains schema/provider, capture and period timestamps, USD currency, and daily start/end/amount values. It excludes raw responses and organization, project, workspace, user and key identifiers. Keys, authorization headers and provider error bodies are never written to diff --git a/docs/KB.md b/docs/KB.md index bfe3516..2348e37 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -68,7 +68,7 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. test profiles cannot switch to local collection. See [ADR 0023](adr/0023-onboarding-source-choice.md). -- Candidate Settings uses category navigation for Connections, Appearance, +- Candidate Settings uses category navigation for Connections, API connections, Appearance, General, Providers, Side notch, Usage details and Diagnostics. Connection summaries show the current source, collection state and reading availability; Configure reveals the existing setup actions. Category changes preserve pending @@ -280,11 +280,21 @@ Reviewed Swift base: `4b913db3a3a8eb2602296489872af2ecaa5d0530`. Source checks d - Activity heatmaps use one 26-column, seven-row layout. Thirty-day telemetry leaves older dates unavailable. Local-day and OpenRouter UTC scope are retained. +Completed-onboarding launches preload the usage window hidden before connection +recovery and provider collection. The first tray click reuses that renderer; +fresh provider data still depends on its source. Incomplete onboarding continues +to open Settings. No window is shown or focused by preload. + ## API billing and Grok compatibility follow-up - OpenAI API and Anthropic API are independent billing snapshots, not quota - provider slots. A bundled one-run helper uses environment-only Admin keys, - fixed cost endpoints and projected owner-only USD files. Cards retain the + provider slots. Settings → API connections accepts a one-time masked Admin + key and fetches billing without Terminal. The field clears on submit or + leaving the page; pasted keys are never saved. **Use saved key** reads only the + selected provider's legacy `com.meterusage.api-keys` Keychain entry without + changing it or returning the key to the renderer. It runs only on request. A bundled environment-only helper + remains available. Both paths use fixed cost endpoints and projected + owner-only USD files. Cards retain the reported UTC period, exact capture time and explicit stale state. Empty reports do not become zero usage. Real organization billing remains unverified. See [API billing](API_USAGE_BETA.md) and [ADR 0029](adr/0029-api-billing-snapshots.md). @@ -300,6 +310,8 @@ Reviewed Swift base: `4b913db3a3a8eb2602296489872af2ecaa5d0530`. Source checks d Missing/stale reads, reconnects, source changes and resets clear measurement. Archives retain quota but discard these observation baselines. No token or dollar rate is derived. See [ADR 0031](adr/0031-grok-quota-burn.md). -- API setup includes key-free macOS zsh commands that read the organization - Admin key at a hidden prompt inside a short-lived subshell. The app still - receives only normalized billing snapshots. Real billing remains unverified. +- API setup has its own Settings category, separate from account connections + and onboarding. Fetch billing updates the snapshot automatically; ordinary + Refresh only rereads it. The advanced helper retains key-free macOS zsh + commands and a hidden prompt. See [ADR 0032](adr/0032-one-time-api-setup.md). + Real organization billing remains unverified. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 37352b7..7944170 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -4,8 +4,8 @@ MeterUsage reads quota and usage from existing local provider stores, provider CLIs and aggregate endpoints. It has no telemetry service, analytics or automatic crash-upload endpoint. Electron Diagnostics has an explicit optional **Send report** action described below. The Electron candidate adds opt-in account connections; -Codex sign-in occurs in the provider browser page, not a MeterUsage password form. It does not ask for credentials or send -prompts, code or tool payloads to a provider. +Codex sign-in occurs in the provider browser page, not a MeterUsage password form. The optional API billing page accepts a one-time organization Admin key as +described below. MeterUsage does not send prompts, code or tool payloads to a provider. The published Swift app retains the existing local/CLI boundaries. The TypeScript/Electron candidate adds the opt-in boundaries described below. The candidate has source/fixture checks, but native behavior and @@ -217,18 +217,32 @@ OpenAI API and Anthropic API billing use separate normalized snapshots under reads the selected organization Admin key from its environment for one fetch, removes that key from its own environment and calls only the documented OpenAI `/v1/organization/costs` or Anthropic `/v1/organizations/cost_report` -HTTPS endpoint. It rejects redirects, caps response size and pagination, and -never starts inference. The app does not read, receive or store these keys. +HTTPS endpoint. The Settings → API connections page also accepts a one-time +Admin key in a masked field. An explicit Fetch billing action passes it through +the registered Settings bridge to the main process, using the same collector. +The field clears on submit; leaving the page clears unsubmitted input. Keys are +not saved in preferences, Keychain, snapshots, diagnostics or renderer state +publications. An explicit **Use saved key** action instead reads the selected +provider's existing `com.meterusage.api-keys` generic-password item, account +`openAI` or `anthropic`, through the fixed native helper. Only that action may +request Keychain UI. The original item is not changed or deleted; the key stays +in main-process/helper memory and never reaches the renderer. Missing, denied +and ambiguous requests do not fall back to another credential. Startup and +polling never read these Keychain items. Fixed errors exclude key values and provider response bodies. +Only one request per provider may run; quit cancels pending fetches. Requests +reject redirects, cap response size and pagination, and never start inference. The helper writes only schema/provider, capture/period timestamps, currency and daily USD totals. Identifiers, headers and raw responses/errors are discarded. Files and their final directory are owner-only; replacement is atomic and a failed request preserves the previous file. The app validates the fixed files and publishes only projected fields. Demo and isolated provider tests never -read live API billing files. Polling rereads snapshots and never fetches billing. +read live API billing files. Demo, candidate and isolated profiles cannot use +the Settings fetch action. Polling rereads snapshots and never fetches billing. Reports older than 24 hours or from a prior UTC month are marked stale. Anthropic Priority Tier costs are excluded. See [setup](API_USAGE_BETA.md) and -[ADR 0029](adr/0029-api-billing-snapshots.md). +[ADR 0029](adr/0029-api-billing-snapshots.md) and +[ADR 0032](adr/0032-one-time-api-setup.md). ## Selective parsing and display boundary diff --git a/docs/adr/0032-one-time-api-setup.md b/docs/adr/0032-one-time-api-setup.md new file mode 100644 index 0000000..170fd2f --- /dev/null +++ b/docs/adr/0032-one-time-api-setup.md @@ -0,0 +1,35 @@ +# ADR 0032: Set up API billing with a one-time key + +- Status: Accepted implementation direction; live billing acceptance pending +- Date: 2026-10-17 + +Place API billing setup in **Settings → API connections**, separate from app +account connections and onboarding. OpenAI API and Anthropic API each have a +masked organization Admin key field and an explicit **Fetch billing** action. +**Use saved key** reuses the same provider's Admin key from the previous app. +Project keys and consumer subscription sign-in do not grant billing access. + +This narrowly amends ADR 0029's environment-only, no-app-input boundary. The +registered Settings document may send a validated one-time key to the main +process. The existing collector requests only the selected provider's fixed +cost endpoint and writes the same projected owner-only snapshot. No key is +saved to preferences, Keychain or any file, included in diagnostics/state +publication, or returned in an error. The UI clears input on submit or leaving +the page. The advanced environment helper remains available. + +On explicit saved-key use, the main process reads only the legacy generic +password service `com.meterusage.api-keys`, account `openAI` or `anthropic`, +through the existing native helper. It may request macOS access. It never +changes or deletes the item, copies the key, returns it to the renderer or +fallbacks to another source. Missing and denied reads fail with fixed errors. +No startup or background Keychain access is added. + +Permit one fetch per provider at a time, refuse demo/candidate/isolated profiles, +and cancel pending requests on quit. Failed collection preserves the previous +snapshot. Successful collection updates the displayed snapshot automatically. +Ordinary Refresh continues to read saved snapshots without API access. No +credential store or background billing scheduler is added. + +Synthetic tests and demo layout checks do not establish real organization +permissions, report completeness or invoice accuracy. Keep those acceptance +checks separate from source and installed-app verification. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0d9b157..061ddac 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -42,3 +42,5 @@ compatibility and acceptance requirements remain in force. The | [0030](0030-grok-bot-encrypted-compatibility.md) | Accept account-bound encrypted Bot legacy values and use the observed fixed Keychain account. | Bounded source correction; live allowance acceptance pending | | [0031](0031-grok-quota-burn.md) | Measure Grok Bot quota burn from recent same-account percentages without inventing token counts or cycle duration. | Source implementation; native and live rate acceptance separate | + +| [0032](0032-one-time-api-setup.md) | Separate API setup from account connections and accept a one-time Admin key for explicit billing fetches, without saving credentials. | Source implementation; real billing acceptance pending | diff --git a/docs/electron/README.md b/docs/electron/README.md index 481b128..bace21e 100644 --- a/docs/electron/README.md +++ b/docs/electron/README.md @@ -55,13 +55,15 @@ Desktop connection. These changes are separate from the published build 54. checks executable `~/.bun/bin/opencode` installs as well as the existing paths. - OpenRouter uses existing keys for account quota/credit and aggregate activity. - Separate OpenAI API and Anthropic API cards read owner-only billing snapshots - from the bundled one-run helper. Admin keys stay in its environment; the app - has no key field or billing credential store. See [API billing](../API_USAGE_BETA.md). + from **Settings → API connections → Fetch billing** or the bundled one-run + helper. Settings can reuse the previous app's saved Keychain Admin key or + accept a masked, one-time key. Neither path saves new credentials. See [API billing](../API_USAGE_BETA.md). That activity is provider account scope, not a local chat count. - Cursor, Copilot CLI and Gemini CLI use optional existing local quota snapshots. Presence alone does not provide cloud quota or a token ledger. -Do not paste credentials into MeterUsage or documentation. The full input and +Enter an API Admin key only in its dedicated Settings field or the advanced +helper prompt. Never paste credentials into diagnostic reports or documentation. The full input and request list is in [privacy](../PRIVACY.md#provider-inputs-and-requests). ### Advanced local accounts diff --git a/src/main/api-billing-fetch.ts b/src/main/api-billing-fetch.ts new file mode 100644 index 0000000..e65ded9 --- /dev/null +++ b/src/main/api-billing-fetch.ts @@ -0,0 +1,43 @@ +import type { ApiBillingProvider } from "../domain/api-billing"; +import type { Launch } from "./launch"; +import { collectApiBilling, writeApiBilling } from "./providers/api-billing"; + +export const apiBillingFetchErrors = { + settings: "Open Settings to fetch API billing.", + profile: "API billing fetch is unavailable in demo or isolated profiles.", + busy: "API billing fetch is already in progress for this provider.", + auth: "API billing authentication failed", + unavailable: "API billing data unavailable", + savedMissing: "No saved Admin key for this provider. Paste an organization Admin key to fetch billing.", + savedAccess: "Could not read the saved Admin key. Approve MeterUsage access in macOS Keychain and try again.", +} as const; + +export class ApiBillingFetch { + private readonly busy = new Map(); + private stopped = false; + constructor(private readonly launch: Launch, private readonly refresh: () => Promise, private readonly fetcher: typeof fetch = fetch, private readonly savedKey?: (provider: ApiBillingProvider, signal: AbortSignal) => Promise) {} + + async run(provider: ApiBillingProvider, adminKey?: string) { + if (this.launch.demo || this.launch.candidate || this.launch.profile || this.launch.codexTest || this.launch.claudeTest) throw new Error(apiBillingFetchErrors.profile); + if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); + if (this.busy.has(provider)) throw new Error(apiBillingFetchErrors.busy); + const controller = new AbortController(); + this.busy.set(provider, controller); + try { + if (adminKey === undefined) { + try { adminKey = (await this.savedKey?.(provider, controller.signal))?.trim(); } + catch { throw new Error(apiBillingFetchErrors.savedAccess); } + if (!adminKey) throw new Error(apiBillingFetchErrors.savedMissing); + } + if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); + const snapshot = await collectApiBilling(provider, adminKey, Date.now(), (url, init) => this.fetcher(url, { ...init, signal: AbortSignal.any([controller.signal, ...(init?.signal ? [init.signal] : [])]) })); + if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); + writeApiBilling(this.launch.home, snapshot); + await this.refresh(); + } catch (error) { + throw new Error(!this.stopped && error instanceof Error && [apiBillingFetchErrors.auth, apiBillingFetchErrors.savedMissing, apiBillingFetchErrors.savedAccess].some(message => message === error.message) ? error.message : apiBillingFetchErrors.unavailable); + } finally { this.busy.delete(provider); } + } + + stop() { this.stopped = true; for (const controller of this.busy.values()) controller.abort(); } +} diff --git a/src/main/electron.ts b/src/main/electron.ts index 399853d..cf3850c 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -9,8 +9,10 @@ import { compose } from "./composition"; import { DesktopConnections } from "./connections"; import { desktopReadErrors } from "./providers/claude-desktop"; import { grokBotConnectionErrors } from "./providers/grok-bot-desktop"; +import { runCommand } from "./providers/transport"; import { Coordinator } from "./coordinator"; import { IssueReports } from "./issue-reports"; +import { ApiBillingFetch, apiBillingFetchErrors } from "./api-billing-fetch"; import { NotchFold } from "./notch-fold"; import { Updater } from "./updater"; import { runJSON } from "./cli"; @@ -65,6 +67,7 @@ async function start(launch: Launch) { if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); }, sources); const issueReports = new IssueReports(() => coordinator.diagnostics(app.getVersion()), publish, prefs); + const apiBillingFetch = new ApiBillingFetch(launch, () => coordinator.refresh(), fetch, (provider, signal) => runCommand(desktopHelper, [`--api-${provider}`, "--allow-ui"], { signal, timeoutMs: 60000, maxBytes: 8192 })); // Do not restore a legacy archive under a newly connected account. for (const p of ["codex", "claude", "antigravity", "grok"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); const notch = { expanded: false, cardOnRight: false, selected: "codex", dragging: false }; @@ -222,6 +225,10 @@ async function start(launch: Launch) { switch (r.action) { case "state": return { ok: true, state: state(surface) }; case "refresh": await coordinator.refresh(); break; + case "apiBillingFetch": + case "apiBillingFetchSaved": + if (surface !== "settings") throw new Error(apiBillingFetchErrors.settings); + await apiBillingFetch.run(r.provider, r.action === "apiBillingFetch" ? r.adminKey : undefined); break; case "settings": showSettings(); break; case "statusPage": if (surface !== "flyout") throw new Error("Invalid request"); @@ -369,7 +376,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), ...Object.values(grokBotConnectionErrors), "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", "Could not disconnect Grok Bot Desktop", "Connections are disabled in demo mode", "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."].includes(e.message) ? e.message : "Could not complete action" }; } + catch (e) { return { ok: false, error: e instanceof Error && [...Object.values(apiBillingFetchErrors), "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), ...Object.values(grokBotConnectionErrors), "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", "Could not disconnect Grok Bot Desktop", "Connections are disabled in demo mode", "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."].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(); }); @@ -383,6 +390,7 @@ async function start(launch: Launch) { event.preventDefault(); if (quitting) return; quitting = true; + apiBillingFetch.stop(); unsubscribe(); coordinator.stop(); updater.reset(); stopHoverCheck(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel(); void connections.stop().finally(() => { @@ -392,6 +400,7 @@ async function start(launch: Launch) { }); nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; create("tray"); restoreAnchor(); + if (prefs.values.onboardingCompleted === true) create("flyout"); // Recovery failures remain visible in connection state, with collection disabled. await connections.recover().catch(() => {}); if (quitting) return; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 351e625..1250a82 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -23,6 +23,8 @@ 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 "apiBillingFetch": fields = ["provider", "adminKey"]; valid = Object.keys(r).length === 3 && (r.provider === "openai" || r.provider === "anthropic") && typeof r.adminKey === "string" && r.adminKey.length >= 1 && r.adminKey.length <= 8192 && !/[\s\x00-\x1f\x7f]/.test(r.adminKey); break; + case "apiBillingFetchSaved": fields = ["provider"]; valid = r.provider === "openai" || r.provider === "anthropic"; 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; diff --git a/src/renderer/api-billing-setup.tsx b/src/renderer/api-billing-setup.tsx index 8f710e0..a8b7a3f 100644 --- a/src/renderer/api-billing-setup.tsx +++ b/src/renderer/api-billing-setup.tsx @@ -1,16 +1,24 @@ -export function ApiBillingSetup() { - return
- Set up API billing snapshots -

Use an organization Admin API key. Project API keys and ChatGPT or Claude subscription sign-in do not grant organization billing access.

-
    -
  • OpenAI: create an Admin key in Platform settings under Organization → Admin keys.
  • -
  • Anthropic: create an Admin key in Console → Settings → Admin keys.
  • -
-

Open Terminal on your Mac and paste the matching command below. At the hidden prompt, paste your Admin key and press Return. Your key will not appear on screen or in command history. Do not paste it into the command itself.

- {([['OpenAI', 'OPENAI_ADMIN_KEY', 'openai'], ['Anthropic', 'ANTHROPIC_ADMIN_KEY', 'anthropic']] as const).map(([name, key, provider]) =>

{name}

{`(\n  IFS= read -rs '${key}?Paste ${name} Admin key (hidden): ' || exit 1\n  print\n  export ${key}\n  ELECTRON_RUN_AS_NODE=1 /Applications/MeterUsage.app/Contents/MacOS/meterusage \\\n    /Applications/MeterUsage.app/Contents/Resources/api-usage-helper.cjs ${provider}\n)`}
)} -

These commands contain no key. Only the short-lived helper receives it through its environment. The parent shell and MeterUsage do not store it.

-

When Terminal says API billing snapshot updated, click Refresh in MeterUsage. Refresh rereads the snapshot; it does not contact the billing API. Rerun the command to fetch newer billing.

-

Authentication failed means the key lacks access or is invalid. Data unavailable means no supported report could be fetched; check your organization billing dashboard.

-

OpenRouter account activity and key limits use the existing OpenRouter connection.

-
; +import { useRef, useState } from "react"; +import { apiBillingNames, type ApiBillingProvider } from "../domain/api-billing"; +import type { Request } from "../shared/ipc"; + +export function ApiBillingSetup({ provider, disabled, action }: { provider: ApiBillingProvider; disabled: boolean; action: (request: Request) => Promise }) { + const input = useRef(null), pending = useRef(false); + const [busy, setBusy] = useState(false); + const fetchBilling = async (saved: boolean) => { + if (disabled || pending.current) return; + const adminKey = input.current?.value.trim(); + if (input.current) input.current.value = ""; + if (!saved && !adminKey) return; + pending.current = true; setBusy(true); + try { await action(saved ? { action: "apiBillingFetchSaved", provider } : { action: "apiBillingFetch", provider, adminKey: adminKey! }); } + finally { pending.current = false; setBusy(false); } + }; + return
{ event.preventDefault(); await fetchBilling(false); }}> + +

Reuse this provider's Admin key saved by the previous MeterUsage. Approve macOS Keychain access if asked.

+

{provider === "openai" ? "OpenAI Platform → Organization settings → Admin keys." : "Anthropic Console → Settings → Admin keys."}

+ + +
; } diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 92cc09b..90cd45e 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -198,11 +198,11 @@ export function ApiBillingCards({ state }: { state: ViewState }) { return

{apiBillingNames[provider]} API billing

{reading.value.daily.length ? money(reading.value.daily.reduce((sum, day) => sum + day.amountUSD, 0)) : "No cost buckets reported"}
{provider === "anthropic" &&

Priority Tier excluded.

}
; })}; } -function ApiBillingConnections({ state }: { state: ViewState }) { - return

API billing Beta

Run the bundled API snapshot helper with an organization Admin key in its environment. MeterUsage reads the snapshot; no credentials are entered or stored in the app.

{apiBillingProviders.map(provider => { +export function ApiBillingConnections({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { + return

API connections Beta

View organization API spend. Use an Admin key, not a project key or ChatGPT or Claude subscription sign-in.

Use a saved key from the previous MeterUsage, or paste a one-time key. Pasted keys are cleared on submit and never saved.

{state.snapshot.demo &&

API access is disabled in this profile.

}
{apiBillingProviders.map(provider => { const reading = state.snapshot.apiBilling?.[provider]; - return
{apiBillingNames[provider]}{reading?.status === "value" ? :

{state.snapshot.demo ? "Snapshot access disabled in this profile." : reading?.code === "unavailable" ? "Snapshot unavailable. Run the helper to replace it." : "Not configured. Run the helper, then refresh."}

}{provider === "anthropic" &&

Priority Tier excluded.

}
; - })}
; + return
{apiBillingNames[provider]}{reading?.status === "value" ? :

{reading?.code === "unavailable" ? "Billing snapshot unavailable. Fetch again to replace it." : "No billing snapshot yet."}

}{provider === "anthropic" &&

Priority Tier costs are excluded.

}
; + })}

Fetch billing updates the snapshot automatically. Refresh rereads saved snapshots. OpenRouter keeps its existing connection.

; } export function Connections({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { 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: "disconnected" })); @@ -217,13 +217,12 @@ export function Connections({ state, action }: { state: ViewState; action: (r: R

{state.settings.values.showProviderOpenCodeGo === true ? "Local collection enabled" : "Local collection off"} · Activity: {state.snapshot.usages.openCodeGo?.status === "value" ? "Available" : reason(state.snapshot.usages.openCodeGo)} · Allowance: {state.snapshot.quotas.openCodeGo?.status === "value" ? "Available" : reason(state.snapshot.quotas.openCodeGo)}

]} - {!state.snapshot.appearance.onboarding &&

You can skip setup and change these choices in Settings later.

}
; } export function Settings({ state, action, saveOrder }: { state: ViewState; action: (r: Request) => Promise; saveOrder: (order: Provider[]) => Promise }) { const [category, setCategory] = useState("Connections"); - const categories = ["Connections", "Appearance", "General", "Providers", "Side notch", "Usage details", "Diagnostics"]; + const categories = ["Connections", "API connections", "Appearance", "General", "Providers", "Side notch", "Usage details", "Diagnostics"]; const values = state.settings.values; const reportCoolingDown = (state.issueReport?.retryAt ?? 0) > state.snapshot.clock; const set = (key: string, value: string | boolean | number) => void action({ action: "setPreference", key, value }); @@ -235,6 +234,7 @@ export function Settings({ state, action, saveOrder }: { state: ViewState; actio {!state.snapshot.appearance.onboarding &&

Your usage, at a glance

Choose your existing CLI setup, browser sign-in or Desktop connection below. Each provider shows the methods it supports. Sign-in is optional.

} +