feat: add native Cursor quota connection

This commit is contained in:
Aditya Vikram Singh 2026-08-29 23:48:42 +05:30
parent 93b7b9a7da
commit bce49926c3
7 changed files with 635 additions and 4 deletions

View file

@ -22,12 +22,18 @@ let package = Package(
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency")
],
linkerSettings: [
.linkedLibrary("sqlite3")
]
),
.testTarget(
name: "CodeBurnMenubarTests",
dependencies: ["CodeBurnMenubar"],
path: "Tests/CodeBurnMenubarTests"
path: "Tests/CodeBurnMenubarTests",
linkerSettings: [
.linkedLibrary("sqlite3")
]
)
]
)

View file

@ -18,10 +18,24 @@ enum CapacityDockProviderRefreshInteraction {
final class CapacityDockProviderQuotaService {
struct Dependencies: Sendable {
var refreshClinePass: @Sendable (String) async throws -> QuotaSummary
var refreshCursor: @Sendable () async throws -> QuotaSummary
init(
refreshClinePass: @escaping @Sendable (String) async throws -> QuotaSummary,
refreshCursor: @escaping @Sendable () async throws -> QuotaSummary = {
try await CursorSubscriptionService.refresh()
}
) {
self.refreshClinePass = refreshClinePass
self.refreshCursor = refreshCursor
}
static let live = Dependencies(
refreshClinePass: { apiKey in
try await ClinePassSubscriptionService.refresh(apiKey: apiKey)
},
refreshCursor: {
try await CursorSubscriptionService.refresh()
}
)
}
@ -39,6 +53,14 @@ final class CapacityDockProviderQuotaService {
credential: CapacityDockProviderCredential
) async throws -> QuotaSummary {
switch provider.id {
case "cursor":
do {
return try await dependencies.refreshCursor()
} catch is CancellationError {
throw CancellationError()
} catch {
throw CapacityDockProviderFetchFailure(error: error)
}
case "clinepass":
guard let apiKey = credential.sanitizedOverride.apiKey else {
throw CapacityDockProviderFetchFailure(
@ -98,6 +120,14 @@ struct CapacityDockProviderFetchFailure: LocalizedError, Equatable, Sendable {
return .transient
}
}
if let error = error as? CursorSubscriptionService.FetchError {
switch error.classification {
case .terminalAuth:
return .terminal
case .transient, .parseFailure:
return .transient
}
}
if error is URLError {
return .transient
}

View file

@ -0,0 +1,368 @@
import Foundation
import SQLite3
/// CodeBurn-owned Cursor quota adapter. It reads Cursor.app's existing local
/// session database in read-only mode, keeps the token in memory for one
/// request, and never copies it into CodeBurn's Keychain or support directory.
enum CursorSubscriptionService {
static let usageURL = URL(string: "https://cursor.com/api/usage-summary")!
private static let timeoutSeconds: TimeInterval = 15
enum FetchError: Error, Equatable, LocalizedError, Sendable {
case noCredentials
case expiredSession
case authenticationRejected
case rateLimited
case providerUnavailable
case parseFailure
case network
enum Classification: Equatable, Sendable {
case terminalAuth
case transient
case parseFailure
}
var classification: Classification {
switch self {
case .noCredentials, .expiredSession, .authenticationRejected:
return .terminalAuth
case .rateLimited, .providerUnavailable, .network:
return .transient
case .parseFailure:
return .parseFailure
}
}
var errorDescription: String? {
switch self {
case .noCredentials:
return "Sign in to the Cursor app, then click Retry."
case .expiredSession:
return "The Cursor app session is expired or invalid. Sign in again, then click Retry."
case .authenticationRejected:
return "Cursor rejected the current app session. Sign in again, then click Retry."
case .rateLimited:
return "Cursor rate-limited the quota request."
case .providerUnavailable:
return "Cursor is temporarily unavailable."
case .parseFailure:
return "Cursor returned an unrecognized quota response."
case .network:
return "Network error fetching Cursor quota."
}
}
}
struct Deps: Sendable {
var loadAccessToken: @Sendable () throws -> String?
var fetch: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse)
static let live = Deps(
loadAccessToken: {
try CursorAppSessionStore().loadAccessToken()
},
fetch: { request in
let configuration = URLSessionConfiguration.ephemeral
configuration.httpCookieStorage = nil
configuration.httpShouldSetCookies = false
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw FetchError.network }
return (data, http)
}
)
}
@MainActor
static func refresh(deps: Deps = .live) async throws -> QuotaSummary {
let accessToken: String
do {
guard let loaded = try deps.loadAccessToken()?.trimmingCharacters(in: .whitespacesAndNewlines),
!loaded.isEmpty else {
throw FetchError.noCredentials
}
accessToken = loaded
} catch let error as FetchError {
throw error
} catch {
throw FetchError.network
}
let session = try session(from: accessToken)
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = timeoutSeconds
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(session.cookieHeader, forHTTPHeaderField: "Cookie")
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await deps.fetch(request)
} catch let error as FetchError {
throw error
} catch {
throw FetchError.network
}
switch response.statusCode {
case 200:
break
case 401, 403:
throw FetchError.authenticationRejected
case 429:
throw FetchError.rateLimited
case 500...599:
throw FetchError.providerUnavailable
default:
throw FetchError.parseFailure
}
return try decode(data)
}
static func decode(_ data: Data) throws -> QuotaSummary {
let summary: UsageSummary
do {
summary = try JSONDecoder().decode(UsageSummary.self, from: data)
} catch {
throw FetchError.parseFailure
}
let plan = summary.individualUsage?.plan
let overall = summary.individualUsage?.overall
let pooled = summary.teamUsage?.pooled
let autoPercent = normalizedProviderPercent(plan?.autoPercentUsed)
let apiPercent = normalizedProviderPercent(plan?.apiPercentUsed)
let monthlyPercent: Double? = {
if let total = normalizedProviderPercent(plan?.totalPercentUsed) { return total }
if let autoPercent, let apiPercent { return (autoPercent + apiPercent) / 2 }
if let apiPercent { return apiPercent }
if let autoPercent { return autoPercent }
if let ratio = ratio(used: plan?.used, limit: plan?.limit) { return ratio }
if let ratio = ratio(used: overall?.used, limit: overall?.limit) { return ratio }
if let ratio = ratio(used: pooled?.used, limit: pooled?.limit) { return ratio }
if summary.isUnlimited == true { return 0 }
return nil
}()
guard let monthlyPercent else { throw FetchError.parseFailure }
let reset = parseDate(summary.billingCycleEnd)
let primary = QuotaSummary.Window(label: "Monthly", percent: monthlyPercent, resetsAt: reset)
var details = [primary]
if let autoPercent {
details.append(QuotaSummary.Window(label: "Auto", percent: autoPercent, resetsAt: reset))
}
if let apiPercent {
details.append(QuotaSummary.Window(label: "API", percent: apiPercent, resetsAt: reset))
}
if let onDemand = summary.individualUsage?.onDemand,
onDemand.enabled != false,
let percent = ratio(used: onDemand.used, limit: onDemand.limit) {
details.append(QuotaSummary.Window(label: "On-demand", percent: percent, resetsAt: reset))
}
if let percent = ratio(used: pooled?.used, limit: pooled?.limit) {
details.append(QuotaSummary.Window(label: "Team pool", percent: percent, resetsAt: reset))
}
return QuotaSummary(
providerFilter: .cursor,
connection: .connected,
primary: primary,
details: details,
planLabel: planLabel(summary.membershipType),
footerLines: ["Source: Cursor app"]
)
}
private struct Session {
let cookieHeader: String
}
private struct Claims: Decodable {
let sub: String?
let exp: Double?
}
private static func session(from token: String, now: Date = Date()) throws -> Session {
let components = token.split(separator: ".", omittingEmptySubsequences: false)
guard components.count == 3,
let payload = decodeBase64URL(String(components[1])),
let claims = try? JSONDecoder().decode(Claims.self, from: payload),
let subject = claims.sub,
let userID = subject.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init),
!userID.isEmpty,
let expiration = claims.exp,
Date(timeIntervalSince1970: expiration).timeIntervalSince(now) > 60 else {
throw FetchError.expiredSession
}
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
guard userID.unicodeScalars.allSatisfy(allowed.contains) else {
throw FetchError.expiredSession
}
return Session(cookieHeader: "WorkosCursorSessionToken=\(userID)%3A%3A\(token)")
}
private static func decodeBase64URL(_ value: String) -> Data? {
var base64 = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4)
return Data(base64Encoded: base64)
}
private static func normalizedProviderPercent(_ value: Double?) -> Double? {
guard let value, value.isFinite, value >= 0 else { return nil }
return min(1, value / 100)
}
private static func ratio(used: Int?, limit: Int?) -> Double? {
guard let used, let limit, used >= 0, limit > 0 else { return nil }
return min(1, max(0, Double(used) / Double(limit)))
}
private static func parseDate(_ raw: String?) -> Date? {
guard let raw else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: raw) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: raw)
}
private static func planLabel(_ raw: String?) -> String? {
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
return nil
}
switch value.lowercased() {
case "pro": return "Pro"
case "pro_plus", "pro plus": return "Pro Plus"
case "business": return "Business"
case "enterprise": return "Enterprise"
case "hobby", "free": return "Hobby"
case "ultra": return "Ultra"
default: return value
}
}
private struct UsageSummary: Decodable {
let billingCycleEnd: String?
let membershipType: String?
let isUnlimited: Bool?
let individualUsage: IndividualUsage?
let teamUsage: TeamUsage?
}
private struct IndividualUsage: Decodable {
let plan: Meter?
let onDemand: Meter?
let overall: Meter?
}
private struct TeamUsage: Decodable {
let pooled: Meter?
}
private struct Meter: Decodable {
let enabled: Bool?
let used: Int?
let limit: Int?
let autoPercentUsed: Double?
let apiPercentUsed: Double?
let totalPercentUsed: Double?
}
}
/// Narrow read-only adapter for Cursor's own VS Code state database.
struct CursorAppSessionStore: Sendable {
private static let accessTokenKey = "cursorAuth/accessToken"
let databaseURL: URL
init(databaseURL: URL = Self.defaultDatabaseURL()) {
self.databaseURL = databaseURL
}
static func defaultDatabaseURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
home
.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage", isDirectory: true)
.appendingPathComponent("state.vscdb", isDirectory: false)
}
func loadAccessToken() throws -> String? {
guard FileManager.default.fileExists(atPath: databaseURL.path) else { return nil }
do {
return try value(for: Self.accessTokenKey, immutable: false)
} catch let error as SQLiteFailure
where error.code == SQLITE_CANTOPEN && walSidecarsAreMissing {
return try value(for: Self.accessTokenKey, immutable: true)
} catch {
throw CursorAppSessionStoreError.unreadable
}
}
private func value(for key: String, immutable: Bool) throws -> String? {
var database: OpaquePointer?
let filename = immutable ? "\(databaseURL.absoluteURL.absoluteString)?immutable=1" : databaseURL.path
let flags = immutable ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI : SQLITE_OPEN_READONLY
let openResult = sqlite3_open_v2(filename, &database, flags, nil)
guard openResult == SQLITE_OK else {
let failure = SQLiteFailure(code: database.map(sqlite3_errcode) ?? openResult)
sqlite3_close(database)
throw failure
}
defer { sqlite3_close(database) }
sqlite3_busy_timeout(database, 250)
var statement: OpaquePointer?
let prepareResult = sqlite3_prepare_v2(
database,
"SELECT value FROM ItemTable WHERE key = ? LIMIT 1;",
-1,
&statement,
nil
)
guard prepareResult == SQLITE_OK else {
throw SQLiteFailure(code: sqlite3_errcode(database))
}
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, key, -1, sqliteTransient)
let stepResult = sqlite3_step(statement)
if stepResult == SQLITE_DONE { return nil }
guard stepResult == SQLITE_ROW else {
throw SQLiteFailure(code: sqlite3_errcode(database))
}
switch sqlite3_column_type(statement, 0) {
case SQLITE_TEXT:
guard let text = sqlite3_column_text(statement, 0) else { return nil }
return String(cString: text)
case SQLITE_BLOB:
guard let bytes = sqlite3_column_blob(statement, 0) else { return nil }
let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))
return String(data: data, encoding: .utf8)
?? String(data: data, encoding: .utf16LittleEndian)
default:
return nil
}
}
private var walSidecarsAreMissing: Bool {
!FileManager.default.fileExists(atPath: databaseURL.path + "-wal") &&
!FileManager.default.fileExists(atPath: databaseURL.path + "-shm")
}
private struct SQLiteFailure: Error {
let code: Int32
}
}
private enum CursorAppSessionStoreError: Error {
case unreadable
}
private let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

View file

@ -55,7 +55,7 @@ enum ProviderConnectionCatalog {
entry("claude", "Claude", [.automatic, .api, .web, .cli, .oauth],
[.localAppOrCLI, .oauth, .apiTokenOrCloudCredentials, .cookieOrWebSession], live: true),
entry("clinepass", "ClinePass", [.automatic, .api], [.apiTokenOrCloudCredentials], live: true),
entry("cursor", "Cursor", [.automatic, .cli, .web], [.localAppOrCLI, .cookieOrWebSession]),
entry("cursor", "Cursor", [.automatic, .cli, .web], [.localAppOrCLI, .cookieOrWebSession], live: true),
entry("opencode", "OpenCode", [.automatic, .web], [.cookieOrWebSession]),
entry("opencodego", "OpenCode Go", [.automatic, .api, .web],
[.localAppOrCLI, .apiTokenOrCloudCredentials, .cookieOrWebSession, .none]),
@ -221,4 +221,3 @@ enum ProviderConnectionSubmissionPolicy {
return .connect
}
}

View file

@ -28,6 +28,26 @@ struct CapacityDockProviderQuotaServiceTests {
#expect(capturedKeys == ["synthetic-clinepass-key"])
}
@Test("Cursor dispatches through passive local-session discovery")
func dispatchesCursor() async throws {
let expected = Self.summary(percent: 0.37)
let service = CapacityDockProviderQuotaService(dependencies: .init(
refreshClinePass: { _ in
Issue.record("Wrong adapter dispatched")
return Self.summary()
},
refreshCursor: { expected }
))
let provider = try #require(CapacityDockProvider(rawValue: "cursor"))
let result = try await service.fetch(
provider: provider,
credential: CapacityDockProviderCredential()
)
#expect(result == expected)
}
@Test("ClinePass requires its own saved API key")
func requiresClinePassKey() async throws {
let service = CapacityDockProviderQuotaService(dependencies: .init(

View file

@ -0,0 +1,208 @@
import Foundation
import SQLite3
import XCTest
@testable import CodeBurnMenubar
/// Fixture-driven coverage for CodeBurn's native Cursor quota adapter. Tests
/// use a synthetic JWT and temporary SQLite database; they never inspect the
/// operator's Cursor session or Keychain.
@MainActor
final class CursorQuotaTests: XCTestCase {
private final class RequestRecorder: @unchecked Sendable {
private(set) var requests: [URLRequest] = []
func record(_ request: URLRequest) { requests.append(request) }
}
nonisolated private static let successBody = """
{
"billingCycleStart": "2026-08-01T00:00:00Z",
"billingCycleEnd": "2026-09-01T00:00:00Z",
"membershipType": "pro",
"individualUsage": {
"plan": {
"enabled": true,
"used": 850,
"limit": 2000,
"remaining": 1150,
"autoPercentUsed": 20,
"apiPercentUsed": 65,
"totalPercentUsed": 42.5
}
}
}
"""
func testLocalCursorDatabaseIsReadWithoutWritingACodeBurnCredential() throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("codeburn-cursor-auth-\(UUID().uuidString)", isDirectory: true)
let databaseURL = root.appendingPathComponent("state.vscdb")
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let token = Self.syntheticJWT()
try Self.writeCursorDatabase(at: databaseURL, token: token)
try FileManager.default.setAttributes([.posixPermissions: 0o444], ofItemAtPath: databaseURL.path)
let loaded = try CursorAppSessionStore(databaseURL: databaseURL).loadAccessToken()
XCTAssertEqual(loaded, token)
}
func testExistingCursorAppSessionConnectsAndMapsMonthlyQuota() async throws {
let recorder = RequestRecorder()
let token = Self.syntheticJWT()
let deps = CursorSubscriptionService.Deps(
loadAccessToken: { token },
fetch: { request in
recorder.record(request)
return (
Data(Self.successBody.utf8),
Self.httpResponse(request, status: 200)
)
}
)
let summary = try await CursorSubscriptionService.refresh(deps: deps)
XCTAssertEqual(summary.connection, .connected)
XCTAssertEqual(summary.providerFilter, .cursor)
XCTAssertEqual(summary.primary?.label, "Monthly")
XCTAssertEqual(summary.primary?.percent ?? -1, 0.425, accuracy: 0.0001)
XCTAssertEqual(summary.details.map(\.label), ["Monthly", "Auto", "API"])
XCTAssertEqual(summary.details.map(\.percent), [0.425, 0.2, 0.65])
XCTAssertEqual(summary.planLabel, "Pro")
XCTAssertEqual(summary.footerLines, ["Source: Cursor app"])
XCTAssertEqual(recorder.requests.count, 1)
XCTAssertEqual(recorder.requests[0].url?.absoluteString, "https://cursor.com/api/usage-summary")
XCTAssertEqual(recorder.requests[0].httpMethod, "GET")
XCTAssertNil(recorder.requests[0].value(forHTTPHeaderField: "Authorization"))
XCTAssertTrue(
recorder.requests[0].value(forHTTPHeaderField: "Cookie")?
.hasPrefix("WorkosCursorSessionToken=user_123%3A%3A") == true
)
}
func testMissingOrExpiredCursorSessionIsTerminal() async throws {
let missing = CursorSubscriptionService.Deps(
loadAccessToken: { nil },
fetch: { request in
XCTFail("Network must not run without a Cursor app session")
return (Data(), Self.httpResponse(request, status: 500))
}
)
await assertFetchError(.noCredentials, deps: missing)
let expired = CursorSubscriptionService.Deps(
loadAccessToken: { Self.syntheticJWT(expiresAt: Date(timeIntervalSinceNow: -60)) },
fetch: { request in
XCTFail("Network must not run with an expired Cursor app session")
return (Data(), Self.httpResponse(request, status: 500))
}
)
await assertFetchError(.expiredSession, deps: expired)
}
func testCursorAuthenticationResponsesAreTerminal() async throws {
for status in [401, 403] {
let deps = CursorSubscriptionService.Deps(
loadAccessToken: { Self.syntheticJWT() },
fetch: { request in (Data(), Self.httpResponse(request, status: status)) }
)
await assertFetchError(.authenticationRejected, deps: deps)
}
}
func testCursorRateLimitAndMalformedPayloadRemainRetryable() async throws {
let rateLimited = CursorSubscriptionService.Deps(
loadAccessToken: { Self.syntheticJWT() },
fetch: { request in (Data(), Self.httpResponse(request, status: 429)) }
)
await assertFetchError(.rateLimited, deps: rateLimited)
let malformed = CursorSubscriptionService.Deps(
loadAccessToken: { Self.syntheticJWT() },
fetch: { request in (Data("not-json".utf8), Self.httpResponse(request, status: 200)) }
)
await assertFetchError(.parseFailure, deps: malformed)
}
private func assertFetchError(
_ expected: CursorSubscriptionService.FetchError,
deps: CursorSubscriptionService.Deps
) async {
do {
_ = try await CursorSubscriptionService.refresh(deps: deps)
XCTFail("Expected Cursor refresh to fail with \(expected)")
} catch let error as CursorSubscriptionService.FetchError {
XCTAssertEqual(error, expected)
} catch {
XCTFail("Unexpected error type: \(type(of: error))")
}
}
nonisolated private static func syntheticJWT(
expiresAt: Date = Date(timeIntervalSinceNow: 3_600)
) -> String {
let header = base64URL(Data(#"{"alg":"none","typ":"JWT"}"#.utf8))
let expiration = Int(expiresAt.timeIntervalSince1970)
let payload = base64URL(Data(#"{"sub":"auth0|user_123","exp":\#(expiration)}"#.utf8))
return "\(header).\(payload).synthetic-signature"
}
nonisolated private static func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
nonisolated private static func httpResponse(
_ request: URLRequest,
status: Int
) -> HTTPURLResponse {
HTTPURLResponse(
url: request.url ?? URL(string: "https://cursor.com/api/usage-summary")!,
statusCode: status,
httpVersion: nil,
headerFields: nil
)!
}
nonisolated private static func writeCursorDatabase(at url: URL, token: String) throws {
var database: OpaquePointer?
guard sqlite3_open(url.path, &database) == SQLITE_OK else {
sqlite3_close(database)
throw SQLiteFixtureError.open
}
defer { sqlite3_close(database) }
guard sqlite3_exec(
database,
"CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
nil,
nil,
nil
) == SQLITE_OK else { throw SQLiteFixtureError.create }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(
database,
"INSERT INTO ItemTable(key, value) VALUES (?, ?);",
-1,
&statement,
nil
) == SQLITE_OK else { throw SQLiteFixtureError.prepare }
defer { sqlite3_finalize(statement) }
sqlite3_bind_text(statement, 1, "cursorAuth/accessToken", -1, sqliteTransientForTests)
sqlite3_bind_text(statement, 2, token, -1, sqliteTransientForTests)
guard sqlite3_step(statement) == SQLITE_DONE else { throw SQLiteFixtureError.insert }
}
private enum SQLiteFixtureError: Error {
case open
case create
case prepare
case insert
}
}
private let sqliteTransientForTests = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

View file

@ -46,7 +46,7 @@ struct ProviderConnectionCatalogTests {
.filter(\.hasLiveCodeBurnQuotaAdapter)
.map(\.id)
.sorted()
#expect(live == ["antigravity", "claude", "clinepass", "codex", "copilot", "gemini", "kimi"])
#expect(live == ["antigravity", "claude", "clinepass", "codex", "copilot", "cursor", "gemini", "kimi"])
}
}