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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions mac/Sources/CodeBurnMenubar/Data/CursorSafeStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,28 @@ enum CursorSafeStorage {
private static let lock = NSLock()
// The token a stored blob opened to, so a background refresh reads each blob once.
private nonisolated(unsafe) static var opened: (stored: String, jwt: String)?
// One user refresh unlocks for Cursor and Grok Bot: an allowed read inside
// this window takes the previous allowed read's answer, a Deny included.
static let promptReuseSeconds: TimeInterval = 30
private static let promptLock = NSLock()
private nonisolated(unsafe) static var prompted: (stored: String, jwt: String?, at: Date)?

/// Keychain consent is user-initiated, as for Claude: `allowPrompt` is a
/// Retry or Reconnect click. A background refresh reads only after such a
/// read succeeded, and its first locked read turns that off again.
static func unlock(_ stored: String, allowPrompt: Bool, defaults: UserDefaults = .standard) throws -> String {
static func unlock(
_ stored: String,
allowPrompt: Bool,
defaults: UserDefaults = .standard,
keychain: (() -> String?)? = nil
) throws -> String {
guard stored.hasPrefix(prefix) else { return stored }
if !allowPrompt, let last = lock.withLock({ opened }), last.stored == stored { return last.jwt }
let password: () -> String? = keychain ?? readPassword
guard allowPrompt || defaults.bool(forKey: consentKey),
let jwt = readPassword().flatMap({ decrypt(stored, password: $0) }) else {
let jwt = allowPrompt
? promptedRead(stored, password: password)
: password().flatMap({ decrypt(stored, password: $0) }) else {
defaults.removeObject(forKey: consentKey)
throw CursorSubscriptionService.FetchError.safeStorageLocked
}
Expand All @@ -33,6 +46,19 @@ enum CursorSafeStorage {
static func forget(defaults: UserDefaults) {
defaults.removeObject(forKey: consentKey)
lock.withLock { opened = nil }
promptLock.withLock { prompted = nil }
}

private static func promptedRead(_ stored: String, password: () -> String?) -> String? {
promptLock.withLock { () -> String? in
if let last = prompted, last.stored == stored,
Date().timeIntervalSince(last.at) < promptReuseSeconds {
return last.jwt
}
let jwt = password().flatMap { decrypt(stored, password: $0) }
prompted = (stored: stored, jwt: jwt, at: Date())
return jwt
}
}

/// PBKDF2-HMAC-SHA1(password, "saltysalt", 1003, 16) + AES-128-CBC, IV 16 spaces.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ enum GrokBotSubscriptionService {
var isInstalled: @Sendable () -> Bool
var loadAccessToken: @Sendable () throws -> String?
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
var unlock: @Sendable (String, Bool) throws -> String = {
try CursorSafeStorage.unlock($0, allowPrompt: $1)
}

static let live = Deps(
isInstalled: { GrokBotSubscriptionService.isInstalled() },
Expand Down Expand Up @@ -119,14 +122,19 @@ enum GrokBotSubscriptionService {
// Synchronous SQLite with a busy timeout: keep it off the main
// actor so a lock held by Cursor cannot stall the UI.
let loadAccessToken = deps.loadAccessToken
let raw = try await Task.detached { try loadAccessToken() }.value
guard let loaded = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
!loaded.isEmpty else {
throw FetchError.noCredentials
}
let unlock = deps.unlock
let allowPrompt = CapacityDockProviderRefreshInteraction.isUserInitiated
let raw = try await Task.detached {
let loaded = try loadAccessToken()?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let loaded, !loaded.isEmpty else { return nil as String? }
return try unlock(loaded, allowPrompt)
}.value
guard let loaded = raw else { throw FetchError.noCredentials }
accessToken = loaded
} catch let error as FetchError {
throw error
} catch CursorSubscriptionService.FetchError.safeStorageLocked {
throw CursorSubscriptionService.FetchError.safeStorageLocked
} catch {
throw FetchError.appDataUnreadable
}
Expand Down
126 changes: 126 additions & 0 deletions mac/Tests/CodeBurnMenubarTests/GrokBotSubscriptionServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,129 @@ struct GrokBotSubscriptionServiceTests {
func record(_ request: URLRequest) { values.append(request) }
}
}

private final class KeychainReads: @unchecked Sendable {
private let lock = NSLock()
private var reads = 0
private let password: String?

init(password: String?) { self.password = password }

func read() -> String? {
lock.withLock { reads += 1 }
return password
}

var count: Int { lock.withLock { reads } }
}

/// Serialized: the one-read window in `CursorSafeStorage` is process-wide.
/// Fake Keychain and a test-only defaults suite; never the real ones.
@Suite("Grok Bot with a Cursor safe-storage session", .serialized)
@MainActor
struct GrokBotSafeStorageTests {
private actor RequestCapture {
private(set) var values: [URLRequest] = []
func record(_ request: URLRequest) { values.append(request) }
}

nonisolated private static let password = "cursor-key-not-a-secret"

nonisolated private static func deps(
stored: String,
suiteName: String,
keychain: KeychainReads,
record: RequestCapture
) -> GrokBotSubscriptionService.Deps {
GrokBotSubscriptionService.Deps(
isInstalled: { true },
loadAccessToken: { stored },
fetch: { request in
await record.record(request)
let response = HTTPURLResponse(
url: GrokBotSubscriptionService.usageURL, statusCode: 200, httpVersion: nil, headerFields: nil
)!
return (Data(#"{"usagePercent":30,"hasNonZeroIncludedLimit":true}"#.utf8), response)
},
unlock: { token, allowPrompt in
try CursorSafeStorage.unlock(
token,
allowPrompt: allowPrompt,
defaults: TestDefaults.open(suiteName),
keychain: keychain.read
)
}
)
}

@Test("a wrapped Cursor session is unlocked before the request")
func unlocksBeforeTheRequest() async throws {
let (_, suiteName) = TestDefaults.make("CodeBurnMenubarTests.GrokBot.\(#function)")
defer { TestDefaults.forget(suiteName) }
let stored = "cursor-safe-storage:v1:djEwWOtCQ9RSqI/bp3Cb+ehqZNzuvUmlpYA4EqgJuLmz/UY="
let keychain = KeychainReads(password: Self.password)
let requests = RequestCapture()

let result = try await CapacityDockProviderRefreshInteraction.userInitiated {
try await GrokBotSubscriptionService.refresh(deps: Self.deps(
stored: stored, suiteName: suiteName, keychain: keychain, record: requests
))
}
let captured = await requests.values

#expect(result.connection == .connected)
#expect(captured.first?.value(forHTTPHeaderField: "Authorization") == "Bearer grokbot.unlock.signature")
#expect(keychain.count == 1)
}

@Test("a locked keychain reports the Cursor locked message without a request")
func lockedKeychainSkipsTheRequest() async throws {
let (_, suiteName) = TestDefaults.make("CodeBurnMenubarTests.GrokBot.\(#function)")
defer { TestDefaults.forget(suiteName) }
let stored = "cursor-safe-storage:v1:djEwwcEvTGxG0eQsz8tltHi4nMFhfyawXN57+EQmOx9kp2c="
let requests = RequestCapture()

await #expect(throws: CursorSubscriptionService.FetchError.safeStorageLocked) {
try await CapacityDockProviderRefreshInteraction.userInitiated {
try await GrokBotSubscriptionService.refresh(deps: Self.deps(
stored: stored, suiteName: suiteName, keychain: KeychainReads(password: nil), record: requests
))
}
}
let captured = await requests.values
#expect(captured.isEmpty)
}

@Test("one user refresh reads the keychain once for Cursor and Grok Bot, a Deny included")
func cursorAndGrokBotShareOneRead() async throws {
let (defaults, suiteName) = TestDefaults.make("CodeBurnMenubarTests.GrokBot.\(#function)")
defer { TestDefaults.forget(suiteName) }
let requests = RequestCapture()

let shared = "cursor-safe-storage:v1:djEwkOuI7I/ibOHkoPuEN9h3NuhddBfU0Ks47rbipX7Gl3I="
let allowed = KeychainReads(password: Self.password)
_ = try CursorSafeStorage.unlock(shared, allowPrompt: true, defaults: defaults, keychain: allowed.read)
_ = try await CapacityDockProviderRefreshInteraction.userInitiated {
try await GrokBotSubscriptionService.refresh(deps: Self.deps(
stored: shared, suiteName: suiteName, keychain: allowed, record: requests
))
}
let captured = await requests.values
#expect(allowed.count == 1)
#expect(captured.first?.value(forHTTPHeaderField: "Authorization") == "Bearer grokbot.shared.signature")

let denied = "cursor-safe-storage:v1:djEwlwr7B7sxKrGwF4YhlPPou9rEvdZQtIIdoejyoaqz+dY="
let refused = KeychainReads(password: nil)
#expect(throws: CursorSubscriptionService.FetchError.safeStorageLocked) {
try CursorSafeStorage.unlock(denied, allowPrompt: true, defaults: defaults, keychain: refused.read)
}
await #expect(throws: CursorSubscriptionService.FetchError.safeStorageLocked) {
try await CapacityDockProviderRefreshInteraction.userInitiated {
try await GrokBotSubscriptionService.refresh(deps: Self.deps(
stored: denied, suiteName: suiteName, keychain: refused, record: requests
))
}
}
#expect(refused.count == 1)
}
}
27 changes: 21 additions & 6 deletions src/quota/grokbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
// nextResetTimestampUtc, hasNonZeroIncludedLimit,
// usesPooledEnterpriseAllowance, and grokPlanLabel on newer builds.
//
// Credential: the Cursor IDE's OWN access token, unencrypted in its VS Code
// state database under `cursorAuth/accessToken` and already read read-only by
// src/quota/cursor.ts, whose lookup is reused here rather than duplicated.
// Credential: the Cursor IDE's OWN access token, kept in its VS Code state
// database under `cursorAuth/accessToken` and already read read-only by
// src/quota/cursor.ts, whose lookup and safe-storage unlock are reused here.
// Grok Bot keeps its own copy of the same Cursor session in Electron safe
// storage, which CodeBurn never decrypts (see src/quota/codex.ts), so this
// reading is only the app's reading when the IDE is signed into the account
Expand All @@ -18,8 +18,15 @@ import { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { cursorAccessTokenFromDatabase, cursorDatabasePath } from './cursor.js'
import { quotaRequestSignal, sanitizeError } from './security.js'
import {
cursorAccessTokenFromDatabase,
cursorDatabasePath,
cursorLockedMessage,
isCursorSafeStorageToken,
unwrapCursorStoredToken,
type CursorKeychainReader,
} from './cursor.js'
import { quotaRequestSignal, readKeychainPassword, sanitizeError } from './security.js'
import type { QuotaProvider, QuotaWindow } from './types.js'

const USAGE_ENDPOINT = 'https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus'
Expand Down Expand Up @@ -54,13 +61,15 @@ export type GrokbotQuotaDeps = {
databasePath: string
/** Resolves the Cursor app's access token; `null` when it is not signed in. */
loadAccessToken: (databasePath: string) => Promise<string | null>
readKeychain: CursorKeychainReader
}

function defaultDeps(): GrokbotQuotaDeps {
return {
fetch: globalThis.fetch,
databasePath: cursorDatabasePath(),
loadAccessToken: cursorAccessTokenFromDatabase,
readKeychain: (service, accounts) => readKeychainPassword(service, accounts),
}
}

Expand Down Expand Up @@ -108,7 +117,7 @@ export function decodeGrokbotUsage(body: unknown): QuotaProvider | 'noAllowance'
export type GrokbotQuotaResult = { quota: QuotaProvider; retryAfterSeconds?: number }

export async function fetchGrokbotQuota(
options: Partial<GrokbotQuotaDeps> & { signal?: AbortSignal } = {},
options: Partial<GrokbotQuotaDeps> & { signal?: AbortSignal; allowKeychain?: boolean } = {},
): Promise<GrokbotQuotaResult> {
const deps = { ...defaultDeps(), ...options }
let token: string | null
Expand All @@ -122,6 +131,12 @@ export async function fetchGrokbotQuota(
}
if (!token) return { quota: empty('disconnected', SIGNED_OUT_FOOTER) }

// Same consent rule as the Cursor reader: only a caller that says so may raise the prompt.
if (isCursorSafeStorageToken(token) && !options.allowKeychain) return { quota: empty('terminalFailure', [cursorLockedMessage()]) }
const unwrapped = await unwrapCursorStoredToken(token, deps.readKeychain)
if ('locked' in unwrapped) return { quota: empty('terminalFailure', [cursorLockedMessage()]) }
token = unwrapped.token

try {
const response = await deps.fetch(USAGE_ENDPOINT, {
method: 'POST',
Expand Down
16 changes: 10 additions & 6 deletions src/quota/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import { fetchClinePassQuota } from './clinepass.js'
import { fetchCodexQuota } from './codex.js'
import { fetchCommandCodeQuota } from './commandcode.js'
import { fetchCopilotQuota } from './copilot.js'
import { fetchCursorQuota } from './cursor.js'
import { fetchCursorQuota, type CursorKeychainReader } from './cursor.js'
import { fetchDevinQuota } from './devin.js'
import { fetchGeminiQuota } from './gemini.js'
import { fetchGrokQuota } from './grok.js'
import { fetchGrokbotQuota, grokbotInstalled } from './grokbot.js'
import { fetchKimiQuota } from './kimi.js'
import { KEYCHAIN_TIMEOUT_MS } from './security.js'
import { KEYCHAIN_TIMEOUT_MS, readKeychainPassword, type KeychainOutcome } from './security.js'
import type { ProviderName, QuotaProvider } from './types.js'
import type { DateRange } from '../types.js'
import { fetchZaiQuota } from './zai.js'
Expand Down Expand Up @@ -72,7 +72,7 @@ export type ClaudeProfileToday = {
}

export type QuotaReport = { providers: QuotaCommandProvider[]; claudeProfiles?: ClaudeProfileReport[] }
export type ProviderReader = (signal: AbortSignal) => Promise<QuotaProvider>
export type ProviderReader = (signal: AbortSignal, readCursorKeychain: CursorKeychainReader) => Promise<QuotaProvider>

const READERS: { id: ProviderName; name: string; read: ProviderReader }[] = [
{ id: 'claude', name: 'Claude', read: async signal => {
Expand All @@ -86,11 +86,11 @@ const READERS: { id: ProviderName; name: string; read: ProviderReader }[] = [
{ id: 'copilot', name: 'GitHub Copilot', read: async signal => (await fetchCopilotQuota({ signal })).quota },
{ id: 'antigravity', name: 'Antigravity', read: () => fetchAntigravityQuota() },
{ id: 'kimi', name: 'Kimi', read: async signal => (await fetchKimiQuota({ signal })).quota },
{ id: 'cursor', name: 'Cursor', read: async signal => (await fetchCursorQuota({ signal, allowKeychain: true })).quota },
{ id: 'cursor', name: 'Cursor', read: async (signal, readCursorKeychain) => (await fetchCursorQuota({ signal, allowKeychain: true, readKeychain: readCursorKeychain })).quota },
{ id: 'zai', name: 'Z.ai', read: async signal => (await fetchZaiQuota({ signal })).quota },
{ id: 'zcode', name: 'ZCode', read: async signal => (await fetchZcodeQuota({ signal })).quota },
{ id: 'grok', name: 'Grok', read: async signal => (await fetchGrokQuota({ signal })).quota },
{ id: 'grokbot', name: 'Grok Bot', read: async signal => (await fetchGrokbotQuota({ signal })).quota },
{ id: 'grokbot', name: 'Grok Bot', read: async (signal, readCursorKeychain) => (await fetchGrokbotQuota({ signal, allowKeychain: true, readKeychain: readCursorKeychain })).quota },
{ id: 'clinepass', name: 'ClinePass', read: async signal => (await fetchClinePassQuota({ signal })).quota },
{ id: 'devin', name: 'Devin', read: async () => (await fetchDevinQuota()).quota },
{ id: 'commandcode', name: 'Command Code', read: async signal => (await fetchCommandCodeQuota({ signal })).quota },
Expand Down Expand Up @@ -237,12 +237,16 @@ export async function collectQuota(options: {
// The claude row reads the first configured directory, so that directory's profile is
// the claude row itself.
const claudeRowSource = profileSources.find(source => source === discovered[0])
// Cursor and Grok Bot open the same Cursor session in one run: one keychain read, one prompt.
let cursorKeychain: Promise<KeychainOutcome> | undefined
const readCursorKeychain: CursorKeychainReader = (service, accounts) =>
(cursorKeychain ??= readKeychainPassword(service, accounts))
const providersTask = Promise.all(readers.map(async entry => {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const timedOut = new Promise<'timeout'>(resolve => { controller.signal.addEventListener('abort', () => resolve('timeout')) })
try {
const quota = await Promise.race([entry.read(controller.signal), timedOut])
const quota = await Promise.race([entry.read(controller.signal, readCursorKeychain), timedOut])
if (quota === 'timeout') {
return { id: entry.id, name: entry.name, available: false, windows: [], error: 'Timed out.' }
}
Expand Down
Loading
Loading