diff --git a/CHANGELOG.md b/CHANGELOG.md index 64533cc..465f281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ follows [Semantic Versioning](https://semver.org/). ### Changed +- Add opt-in Grok Bot **Connect via Cursor (Beta)** in Electron Settings. + Sign in to Cursor, check Bot allowance, then quit it before connecting. + Immutable SQLite performs no reader writes; process/filesystem guards reject + active Cursor, nonempty WAL/journal files, ambiguous evidence and observable + changes. Verify account identity before bounded allowance requests, keep + credentials only in memory, and require reopening Cursor then quit/reconnect + after expiry. Native/live support and universal shared-pool coverage 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. diff --git a/README.md b/README.md index b6cd8ff..911de46 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 via Cursor Beta](docs/DESKTOP-CONNECTIONS.md#grok-bot-via-cursor-beta). +Sign in to Cursor, check Bot allowance, then quit Cursor before connecting. +Native/live support and Grok Desktop, 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/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index 47282e4..14a4cd5 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -34,9 +34,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 via Cursor (Beta)**. Sign in to Cursor on this Mac, + check Bot allowance, then quit Cursor before choosing **Beta Connect via + Cursor** and approving access. No CLI is required. Cursor must have closed + with its SQLite store checkpointed; an active process, nonempty WAL/journal, + ambiguous evidence or observed store changes prevent access. MeterUsage never + stops Cursor. When access expires, reopen Cursor, then quit and reconnect. + Existing **Grok CLI** collection remains an explicit alternative. Neither + method establishes universal Grok Desktop, X or shared-pool coverage. **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 +242,41 @@ 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). + +## Grok Bot via Cursor Beta + +This source-only compatibility connection uses Cursor's current desktop token +in main-process memory. It selects only `cursorAuth/accessToken` from `ItemTable` +in `Library/Application Support/Cursor/User/globalStorage/state.vscdb`. It opens +the original database with SQLite `immutable=1&mode=ro`. No credential store or +file is copied to temporary disk, no refresh occurs, and SQLite performs no +writes or sidecar bookkeeping. Filesystem access-time metadata remains subject +to the operating system's read policy. + +Pre/post process and filesystem guards require closed/checkpointed evidence. +They detect observable races; they provide no mutual exclusion and do not prove +Cursor never ran between observations. Immutable SQLite guarantees this reader +does not write even if another process races it. Nonempty WAL or rollback +journal files are rejected because immutable reads ignore uncheckpointed data. +A changed database, sidecar, directory identity or ambiguous evidence fails +closed. The guards run again before Bot allowance and after the request. + +Each request first verifies the JWT subject against fixed HTTPS +`cursor.com/api/auth/me`, then POSTs `{}` to +`cursor.com/api/dashboard/get-sand-usage-status` with Cursor's required Origin. +Redirects are refused, requests have a 30-second timeout and a 1 MiB response +limit, and credentials must remain valid for more than 60 seconds. Identity is +case-sensitive. Only its opaque fingerprint and the source choice persist. +The current identity and store fingerprint must match before results are used. + +Included quota honors `includedLimitZero` before the legacy included-limit +flag. A trial requires a future `sandTrialExpiresAt`; trial expiry is never a +reset timestamp. Missing data stays unavailable. Reset cadence is not inferred. +Connected allowance has no local CLI activity attached. Disconnect and source +changes clear account-bound quota and reject late results; ordinary failures +use the existing coordinator backoff. There is no automatic token renewal. + +Synthetic tests cover the reader, guards, account binding, consent, cancellation, +disconnect, expiry and request bounds. Native Cursor formats, Electron SQLite, +visible setup and live provider support remain **UNVERIFIED**. This change is +not in published build 54. See [ADR 0027](adr/0027-grok-cursor-closed-reader.md). diff --git a/docs/KB.md b/docs/KB.md index 43cdaf5..e0b2386 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -47,7 +47,8 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. 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, and Grok CLI or opt-in + Grok Bot via Cursor 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,7 +144,7 @@ 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. Grok Bot adds an opt-in closed/checkpointed Cursor Beta reader using immutable SQLite, fixed identity-before-allowance requests and 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 and [ADR 0027](adr/0027-grok-cursor-closed-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. - 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. @@ -250,6 +251,6 @@ 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 Cursor Beta implementation is separately documented in [ADR 0027](adr/0027-grok-cursor-closed-reader.md); 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). diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index bfa96f3..0c73f78 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 +Cursor 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. @@ -125,9 +126,39 @@ account. Quota archives are cleared on connection changes and candidate launch; authentication or absent-data failures clear the connected archive. Tokens, passwords, raw caches, account identifiers and responses are not logged or persisted by MeterUsage. In-memory strings remain subject to garbage collection. -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 offers a separate opt-in **Connect via Cursor (Beta)** connection under +[ADR 0027](adr/0027-grok-cursor-closed-reader.md). Sign in to Cursor, check Bot +allowance, then quit Cursor. 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. Reopen Cursor, quit it, then reconnect after expiry. 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/0027-grok-cursor-closed-reader.md b/docs/adr/0027-grok-cursor-closed-reader.md new file mode 100644 index 0000000..1801a58 --- /dev/null +++ b/docs/adr/0027-grok-cursor-closed-reader.md @@ -0,0 +1,43 @@ +# ADR 0027: Read Grok Bot allowance through closed Cursor + +- Status: Implemented in source; native and live-provider acceptance pending +- Date: 2026-10-16 +- Amends: ADR 0011's unavailable Grok connection; retains existing CLI choice + +Offer opt-in **Connect via Cursor (Beta)** without a CLI requirement. The user +signs in to Cursor, checks Bot allowance, then quits Cursor. MeterUsage never +stops Cursor. Expiry requires reopening Cursor, then quit/reconnect. 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. + +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/README.md b/docs/adr/README.md index c8a11cc..a239274 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,4 @@ compatibility and acceptance requirements remain in force. The | [0022](0022-codex-account-token-history.md) | Read provider account daily tokens separately from local activity through the managed ChatGPT helper. | Accepted implementation direction; independent review, native and live acceptance pending | | [0023](0023-onboarding-source-choice.md) | Offer supported CLI and account methods in onboarding, with explicit cleanup before switching to local collection. | Accepted implementation direction; native interaction acceptance pending | | [0025](0025-card-settings-diagnostic-reports.md) | Keep Settings in usage cards and restore explicit sanitized support reports in Diagnostics. | Accepted implementation direction; native and live delivery acceptance separate | +| [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 | diff --git a/src/main/composition.ts b/src/main/composition.ts index 6618fdf..cfe9d0b 100644 --- a/src/main/composition.ts +++ b/src/main/composition.ts @@ -86,6 +86,7 @@ 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 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"); @@ -108,7 +109,10 @@ export function compose(launch: Launch, prefs: Preferences, options: { command?: }, }, { slot: primary("antigravity"), quota: signal => runtime.quota(now(), signal), usage: signal => runtime.usage(join(launch.home, ".gemini/antigravity-cli"), now(), signal), - }, { + }, grokIdentity !== "" ? { + slot: primary("grok"), accountBound: true, + quota: signal => options.connections ? options.connections.quota("grok", signal, grokIdentity) : Promise.reject(new Unavailable("notSignedIn", "Grok Bot via Cursor")), + } : { 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..0b3ae7e 100644 --- a/src/main/connections.ts +++ b/src/main/connections.ts @@ -7,6 +7,7 @@ import { boundedChild, cliEnvironment, cliPath, codexRPC, endpoints, httpTranspo import { object, parseCodex } from "./providers/parsers"; import { parseCodexAccountUsage } from "./providers/codex-account-usage"; import { desktopQuota, readDesktopCredential, desktopReadErrors } from "./providers/claude-desktop"; +import { cursorGrokQuota, cursorIdentity, readCursorCredential } from "./providers/cursor-grok"; import { selectJSON } from "./select-json"; import { Unavailable, failed, resetCreditAvailable } from "../domain/models"; import { providerKey, type ConnectionState, type ConnectionProvider } from "../shared/ipc"; @@ -65,12 +66,14 @@ export class DesktopConnections { private resetController?: AbortController; private stopping?: Promise; private cleanupController = new AbortController(); + 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) {} 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", "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", 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" : "desktopGrokIdentity"; } private binary() { for (const root of ["/Applications", join(this.launch.home, "Applications")]) { const path = join(root, "Codex.app/Contents/Resources/codex"); @@ -92,8 +95,40 @@ 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()) }; + } + async quota(provider: ConnectionProvider, signal?: AbortSignal, profile = String(this.prefs.values[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 via Cursor"); }; + const result = await this.cursor(signal ? AbortSignal.any([signal, generation.signal]) : generation.signal, guard, profile); + guard(); return result.quota; + } if (provider === "codex") { this.checkCodexProfile(profile); const raw = await codexRPC(this.binary(), { env: this.env(profile), signal }, undefined, configuration); @@ -133,12 +168,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") { 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(); @@ -161,6 +195,11 @@ export class DesktopConnections { const q = parseCodex(await connectCodex(binary, this.env(id), open, controller.signal), this.now()); if (!q.windows.length) throw new Unavailable("noData"); controller.signal.throwIfAborted(); + } else if (provider === "grok") { + const result = await this.cursor(controller.signal, () => { if (this.stopped) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); }); + controller.signal.throwIfAborted(); savedIdentity = result.identity; + 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 +213,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,15 +228,16 @@ 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); + this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : provider === "grok" ? "Could not connect Grok Bot via Cursor. Sign in to Cursor, check Bot allowance, then quit Cursor and 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(); const controller = new AbortController(); let settle!: () => void; @@ -206,8 +246,8 @@ export class DesktopConnections { 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 +271,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 +292,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" : local ? "Could not select Grok CLI" : "Could not disconnect Grok Bot via Cursor"); } finally { finish(); } } async recover() { @@ -270,7 +309,7 @@ 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(); const pending = [...this.pending.values()]; for (const operation of pending) operation.controller.abort(); let timer: ReturnType; diff --git a/src/main/electron.ts b/src/main/electron.ts index 79a25a0..f6e21e4 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", grok: "desktopGrokIdentity" } as const; + const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity, 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", "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", "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: "Beta Connect via Cursor?", message: "Allow MeterUsage to read Grok Bot allowance via Cursor?", detail: "Sign in to Cursor and check Bot allowance, then quit Cursor before connecting. MeterUsage reads the current access token in memory and requests identity and Bot allowance from cursor.com. It never stops Cursor, refreshes tokens or changes Cursor files. This Beta compatibility interface can change. Grok Desktop, X and shared-pool coverage are unverified. When the token expires, reopen Cursor, then quit 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", "Could not select Grok CLI", "Could not disconnect Grok Bot via Cursor", "Connections are disabled in demo mode", "Could not connect Grok Bot via Cursor. Sign in to Cursor, check Bot allowance, then quit Cursor and 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/preferences.ts b/src/main/preferences.ts index 0e4cc52..2bce0f3 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", desktopGrokIdentity: "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: "", desktopGrokIdentity: "", ...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[] { @@ -50,7 +50,7 @@ function validate(key: string, v: unknown): v is PreferenceValue { if (key === "panelSize") return Object.hasOwn(panelScales, v); if (key === "sideNotchPanelCorner") return v === "" || /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(v); if (key === "desktopCodexConnection" || key === "desktopCodexCleanup") return v === "" || key === "desktopCodexConnection" && v === "off" || /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(v); - if (key === "desktopClaudeIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); + if (key === "desktopClaudeIdentity" || key === "desktopGrokIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); return true; } export class Preferences { diff --git a/src/main/providers/cursor-grok.ts b/src/main/providers/cursor-grok.ts new file mode 100644 index 0000000..146c017 --- /dev/null +++ b/src/main/providers/cursor-grok.ts @@ -0,0 +1,91 @@ +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 => /cursor/i.test(line.replace(/^\s*\d+\s+/, "")))) 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/transport.ts b/src/main/providers/transport.ts index 71a0622..398180b 100644 --- a/src/main/providers/transport.ts +++ b/src/main/providers/transport.ts @@ -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/renderer/app.tsx b/src/renderer/app.tsx index 01dfb26..086294f 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -137,17 +137,18 @@ 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 === "grok" ? "Grok Bot via Cursor (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 === "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 === "grok" ? "Sign in to Cursor, check Bot allowance, then quit Cursor before connecting. No CLI required. Grok Desktop, X and shared-pool coverage are unverified. When access expires, reopen Cursor, then quit and reconnect." : "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." @@ -157,10 +158,10 @@ function ConnectionRow({ connection: c, state, action }: { connection: Connectio 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", "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.

diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 9bff488..4cff818 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -141,7 +141,7 @@ 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: "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"]); @@ -286,7 +286,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 +341,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 +355,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 +659,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,7 +681,7 @@ 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 }]); + 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: "account", localAvailable: false }]); for (const provider of ["claude", "grok"] as const) { await expect(connections.connect(provider, open, consent)).rejects.toThrow("unavailable"); await expect(connections.quota(provider)).rejects.toMatchObject({ code: "notSignedIn" }); @@ -698,7 +700,7 @@ 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 }]); + 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: "account", localAvailable: false }]); for (const provider of ["codex", "grok"] as const) { await expect(connections.connect(provider, open, consent)).rejects.toThrow("unavailable"); await expect(connections.quota(provider)).rejects.toMatchObject({ code: "notSignedIn" }); diff --git a/tests/cursor-grok.test.ts b/tests/cursor-grok.test.ts new file mode 100644 index 0000000..19b617d --- /dev/null +++ b/tests/cursor-grok.test.ts @@ -0,0 +1,297 @@ +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 = () => connections.connect("grok", async () => { throw new Error("No browser"); }, async () => 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.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("requires consent, persists only the hash, restores connection and keeps CLI explicit", 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.http).not.toHaveBeenCalled(); + const providerBefore = snapshot(f.directory); + expect(await f.connect()).toBe(true); + expect(snapshot(f.directory)).toEqual(providerBefore); + const identity = f.prefs.values.desktopGrokIdentity; + 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 before Bot requests", async () => { + const f = await setup(); + f.handler(async () => '{"sub":"other"}'); await expect(f.connect()).rejects.toThrow("Could not connect Grok Bot via Cursor"); + expect(f.http.mock.calls.map(c => c[0])).toEqual([endpoints.cursorIdentity]); expect(f.prefs.values.desktopGrokIdentity).toBe(""); + f.handler(async url => url === endpoints.cursorIdentity ? '{"sub":"fixture-user"}' : '{}'); await expect(f.connect()).rejects.toThrow("Could not connect"); + f.handler(async url => url === endpoints.cursorIdentity ? '{"sub":"fixture-user"}' : paid); await f.connect(); + 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", "remove", "cancel", "disconnect", "local", "stop"])("rejects late connect responses after %s", async action => { + const f = await setup(); 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 connecting = f.connect(), failure = expect(connecting).rejects.toThrow("Could not connect"); await waiting; + let cleanup: Promise | undefined; + if (action === "switch") f.set(token("other")); + if (action === "remove") f.db.prepare("DELETE FROM ItemTable").run(); + 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 processReads = f.command.mock.calls.filter(c => c[0] === "/bin/ps").length; + release(paid); await failure; await cleanup; + if (["cancel", "disconnect", "local", "stop"].includes(action)) expect(f.command.mock.calls.filter(c => c[0] === "/bin/ps")).toHaveLength(processReads); + expect(f.connections.enabled("grok")).toBe(false); expect(f.prefs.values.desktopGrokIdentity).not.toMatch(/^[a-f\d]{64}$/); +}); +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("rolls back canceled or failed preference saves without leaving collection enabled", async () => { + const f = await setup(), original = f.prefs.set.bind(f.prefs); + vi.spyOn(f.prefs, "set").mockImplementation(async (key, value) => { + await original(key, value); + if (key === "desktopGrokIdentity" && value !== "off") f.connections.cancel("grok"); + }); + await expect(f.connect()).rejects.toThrow("Could not connect"); expect(f.prefs.values.desktopGrokIdentity).toBe("off"); + vi.mocked(f.prefs.set).mockImplementation(async (key, value) => { if (key === "showProviderGrok") throw new Error("OMIT_RAW_ERROR"); return original(key, value); }); + await expect(f.connect()).rejects.toThrow("Could not connect"); expect(f.prefs.values.desktopGrokIdentity).toBe("off"); expect(f.connections.enabled("grok")).toBe(false); +}); +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(); + 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.connect()).rejects.toThrow("Could not connect"); + if (stage === "before-bot") expect(f.http.mock.calls.map(c => c[0])).toEqual([endpoints.cursorIdentity]); + expect(f.connections.enabled("grok")).toBe(false); +}); +it("rejects expired credentials before HTTP and expiry during a request without persisting a connection", async () => { + const f = await setup(); f.set(token(undefined, now / 1000 + 60)); + await expect(f.connect()).rejects.toThrow("Could not connect"); 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.connect("grok", async () => {}, async () => true)).rejects.toThrow("Could not connect"); + expect(c.enabled("grok")).toBe(false); +}); + +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 via Cursor"); + 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..3ebd059 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,20 @@ 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 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: "Beta Connect via Cursor?", defaultId: 0, cancelId: 0 }); + expect(showMessageBox.mock.lastCall?.[1].detail).toContain("quit Cursor before connecting"); + } + connect.mockImplementationOnce(async () => { throw new Error("Could not connect Grok Bot via Cursor. Sign in to Cursor, check Bot allowance, then quit Cursor and reconnect."); }); + expect(await invoke(settings, { action: "connect", provider: "grok" })).toEqual({ ok: false, error: "Could not connect Grok Bot via Cursor. Sign in to Cursor, check Bot allowance, then quit Cursor and 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/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/preferences.test.ts b/tests/preferences.test.ts index 76fa446..9a8936e 100644 --- a/tests/preferences.test.ts +++ b/tests/preferences.test.ts @@ -88,3 +88,13 @@ 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 }); } +}); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index fdcc2e5..d4796bd 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -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 via Cursor (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", () => { @@ -872,3 +873,10 @@ it("labels Grok as Beta in usage, notch, sharing, setup and provider settings", expect(setup).toContain('Grok Beta'); expect(setup).toContain('Configure Grok'); }); + +it("explains closed-Cursor Beta setup and expiry without requiring 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 ["Beta Connect via Cursor", "quit Cursor before connecting", "No CLI required", "shared-pool coverage are unverified", "reopen Cursor, then quit and reconnect"]) expect(markup).toContain(text); +});