From 0d28d71458724022b20ecea7dec7d3ade820822f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 19:57:27 -0700 Subject: [PATCH 1/4] feat: prefer Cursor app sessions --- CHANGELOG.md | 1 + Sources/CodexBar/MenuCardView.swift | 6 + .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Providers/Cursor/CursorAppAuth.swift | 331 ++++++++++++++++++ .../Cursor/CursorProviderDescriptor.swift | 8 +- .../CursorStatusProbe+SessionResolution.swift | 245 +++++++++---- .../Providers/Cursor/CursorStatusProbe.swift | 233 +++--------- .../CursorMenuCardModelTests.swift | 39 +++ .../CursorStatusProbeTests.swift | 280 +++++++++++++-- docs/cursor.md | 38 +- 10 files changed, 880 insertions(+), 302 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c20cc49e5..4f45ceac60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - z.ai: add 7-day and 30-day model-usage chart ranges with dataset-consistent legends, colors, and daily tooltips (#2524). Thanks @LeoLin990405! +- Cursor: prefer Cursor.app's read-only local session in Automatic mode, persist validated sessions securely, and surface account mismatches before falling back to browser cookies (#2398). Thanks @markmay for the direction! - Refresh: add a default-off global Low Power Mode that limits automatic provider, local usage, and storage work to once every 30 minutes while keeping manual refresh immediate (#2518). Thanks @Carl723000! - CLI: `codexbar hooks watch` continuously polls providers and fires hooks on real quota/status transitions for headless installs, with in-memory baselines, event rate limits, `--interval` (default 300s, minimum 60s), `--provider`, and JSON output (#2536). Thanks @OfficialAbhinavSingh! - Claude: compact multi-account menu for claude-swap — with four or more accounts the active account keeps its full card while the others become one-line rows sorted by remaining headroom, constrained accounts surface in red/amber, the healthiest switch target gets a star, and the healthy tail folds behind a summary row. Click a row to expand its full card. diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 3e21d355f8..b691b7aaf9 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -999,6 +999,12 @@ extension UsageMenuCardView.Model { if let email = snapshot?.accountEmail(for: provider), !email.isEmpty { return email } + if provider == .cursor, + let accountID = snapshot?.identity(for: .cursor)?.accountID?.trimmingCharacters(in: .whitespacesAndNewlines), + !accountID.isEmpty + { + return accountID.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init) ?? accountID + } if metadata.usesAccountFallback || accountIsAuthoritative, let email = account.email, !email.isEmpty { diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index 9d482635fb..bd470eb48c 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -23,6 +23,7 @@ public enum LogCategories { public static let cookieHeaderStore = "cookie-header-store" public static let copilotTokenStore = "copilot-token-store" public static let creditsPurchase = "creditsPurchase" + public static let cursor = "cursor" public static let cursorLogin = "cursor-login" public static let deepSeekSettings = "deepseek-settings" public static let deepSeekUsage = "deepseek-usage" diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift b/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift new file mode 100644 index 0000000000..d848937d38 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift @@ -0,0 +1,331 @@ +import Foundation +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +#if os(macOS) || os(Linux) +struct CursorSessionIdentity: Equatable, Sendable { + let subject: String? + let email: String? + + var requestUsageUserID: String? { + Self.normalizedSubject(self.subject) + } + + var displayLabel: String? { + Self.normalizedEmail(self.email) ?? Self.normalizedSubject(self.subject) + } + + func differs(from other: Self) -> Bool? { + if let lhs = Self.normalizedSubject(self.subject), + let rhs = Self.normalizedSubject(other.subject) + { + return lhs != rhs + } + if let lhs = Self.normalizedEmail(self.email), + let rhs = Self.normalizedEmail(other.email) + { + return lhs != rhs + } + return nil + } + + static func from(cookieHeader: String) -> Self? { + for component in cookieHeader.split(separator: ";") { + let pair = component.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2 else { continue } + let name = pair[0].trimmingCharacters(in: .whitespacesAndNewlines) + guard name == "WorkosCursorSessionToken" else { continue } + + let encodedValue = pair[1].trimmingCharacters(in: .whitespacesAndNewlines) + let value = encodedValue.removingPercentEncoding ?? encodedValue + let parts = value.components(separatedBy: "::") + guard parts.count >= 2 else { continue } + let token = parts.last ?? "" + if let identity = try? Self(jwt: token) { + return identity + } + + let userID = parts.first + if let userID, !userID.isEmpty { + return Self(subject: userID, email: nil) + } + } + return nil + } + + init(subject: String?, email: String?) { + self.subject = subject + self.email = email + } + + init(jwt: String) throws { + let json = try Self.payload(jwt: jwt) + self.init(subject: json["sub"] as? String, email: json["email"] as? String) + } + + static func payload(jwt: String) throws -> [String: Any] { + let parts = jwt.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count >= 2 else { + throw CursorStatusProbeError.parseFailed("Cursor.app access token is not a JWT") + } + + var payload = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + payload += String(repeating: "=", count: (4 - payload.count % 4) % 4) + + guard let data = Data(base64Encoded: payload), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + throw CursorStatusProbeError.parseFailed("Cursor.app access token has an invalid payload") + } + return json + } + + private static func normalizedSubject(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init)?.lowercased() + } + + private static func normalizedEmail(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value.lowercased() + } +} + +struct CursorAppAuthSession: Equatable, Sendable { + static let persistedCookieMarker = "CodexBar Cursor.app local auth" + + let accessToken: String + + static func from(cookieHeader: String) -> Self? { + for component in cookieHeader.split(separator: ";") { + let pair = component.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2, + pair[0].trimmingCharacters(in: .whitespacesAndNewlines) == "WorkosCursorSessionToken" + else { continue } + let encodedValue = pair[1].trimmingCharacters(in: .whitespacesAndNewlines) + let value = encodedValue.removingPercentEncoding ?? encodedValue + let parts = value.components(separatedBy: "::") + guard parts.count >= 2, + let token = parts.last, + token.split(separator: ".", omittingEmptySubsequences: false).count >= 2 + else { return nil } + return Self(accessToken: token) + } + return nil + } + + var identity: CursorSessionIdentity? { + try? CursorSessionIdentity(jwt: self.accessToken) + } + + var isUsable: Bool { + guard !self.accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + (try? self.userID()) != nil, + let expiresAt = try? self.expiresAt() + else { + return false + } + return expiresAt.timeIntervalSinceNow > 60 + } + + func cookieHeader() throws -> String { + try "WorkosCursorSessionToken=\(self.userID())%3A%3A\(self.accessToken)" + } + + func userID() throws -> String { + let json = try self.payload() + guard let subject = json["sub"] as? String, + let userID = subject.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init), + !userID.isEmpty + else { + throw CursorStatusProbeError.parseFailed("Cursor.app access token is missing a user ID") + } + + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) + guard userID.unicodeScalars.allSatisfy(allowed.contains) else { + throw CursorStatusProbeError.parseFailed("Cursor.app access token has an invalid user ID") + } + return userID + } + + func expiresAt() throws -> Date { + let json = try self.payload() + guard let expiration = json["exp"] as? NSNumber else { + throw CursorStatusProbeError.parseFailed("Cursor.app access token is missing an expiration") + } + return Date(timeIntervalSince1970: expiration.doubleValue) + } + + func makeCookie() throws -> HTTPCookie { + let properties: [HTTPCookiePropertyKey: Any] = try [ + .name: "WorkosCursorSessionToken", + .value: "\(self.userID())%3A%3A\(self.accessToken)", + .domain: "cursor.com", + .path: "/", + .expires: self.expiresAt(), + .secure: true, + .comment: Self.persistedCookieMarker, + ] + guard let cookie = HTTPCookie(properties: properties) else { + throw CursorStatusProbeError.parseFailed("Cursor.app session cookie could not be created") + } + return cookie + } + + static func isPersistedCookie(_ cookie: HTTPCookie) -> Bool { + cookie.name == "WorkosCursorSessionToken" && cookie.comment == self.persistedCookieMarker + } + + private func payload() throws -> [String: Any] { + try CursorSessionIdentity.payload(jwt: self.accessToken) + } +} + +protocol CursorAppAuthSessionProviding: Sendable { + func loadSession() throws -> CursorAppAuthSession? +} + +struct CursorAppAuthStore: CursorAppAuthSessionProviding { + private static let defaultDBPath: String = Self.resolveDefaultDBPath() + + private let dbPath: String + + init(dbPath: String? = nil) { + self.dbPath = dbPath ?? Self.defaultDBPath + } + + static func resolveDefaultDBPath( + home: String = NSHomeDirectory(), + environment: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default) -> String + { + #if os(macOS) + _ = environment + _ = fileManager + return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + #elseif os(Linux) + let configHome = environment[CodexBarConfigStore.xdgConfigHomeEnvironmentKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let expandedConfigHome = configHome.map { ($0 as NSString).expandingTildeInPath } + let base: String = if let expandedConfigHome, + !expandedConfigHome.isEmpty, + (expandedConfigHome as NSString).isAbsolutePath + { + expandedConfigHome + } else { + "\(home)/.config" + } + return "\(base)/Cursor/User/globalStorage/state.vscdb" + #else + _ = home + _ = environment + _ = fileManager + return "" + #endif + } + + func loadSession() throws -> CursorAppAuthSession? { + guard FileManager.default.fileExists(atPath: self.dbPath) else { return nil } + guard let accessToken = try self.value(for: "cursorAuth/accessToken"), + !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + return CursorAppAuthSession(accessToken: accessToken) + } + + private func value(for key: String) throws -> String? { + do { + return try self.value(for: key, immutable: false) + } catch let failure as SQLiteReadFailure { + // An idle WAL database can retain WAL mode in its header after both sidecars disappear. + // Immutable mode reads that main file without recreating sidecars. Never use it while a WAL exists, + // because doing so would ignore live, uncheckpointed Cursor state. + guard failure.code == SQLITE_CANTOPEN, self.walSidecarsAreMissing else { + throw CursorStatusProbeError.networkError("SQLite error reading Cursor app auth: \(failure.message)") + } + do { + return try self.value(for: key, immutable: true) + } catch let fallbackFailure as SQLiteReadFailure { + throw CursorStatusProbeError.networkError( + "SQLite error reading Cursor app auth: \(fallbackFailure.message)") + } + } + } + + private func value(for key: String, immutable: Bool) throws -> String? { + var db: OpaquePointer? + let databaseURL = URL(fileURLWithPath: self.dbPath, isDirectory: false).absoluteURL + let filename = immutable ? "\(databaseURL.absoluteString)?immutable=1" : self.dbPath + let flags = immutable ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI : SQLITE_OPEN_READONLY + let openResult = sqlite3_open_v2(filename, &db, flags, nil) + guard openResult == SQLITE_OK else { + let failure = Self.sqliteFailure(db: db, resultCode: openResult) + sqlite3_close(db) + throw failure + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let query = "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;" + var stmt: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, query, -1, &stmt, nil) + guard prepareResult == SQLITE_OK else { + throw Self.sqliteFailure(db: db, resultCode: prepareResult) + } + defer { sqlite3_finalize(stmt) } + + sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT) + let stepResult = sqlite3_step(stmt) + guard stepResult == SQLITE_ROW else { + if stepResult == SQLITE_DONE { + return nil + } + throw Self.sqliteFailure(db: db, resultCode: stepResult) + } + return Self.decodeSQLiteValue(stmt: stmt, index: 0) + } + + private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { + switch sqlite3_column_type(stmt, index) { + case SQLITE_TEXT: + guard let c = sqlite3_column_text(stmt, index) else { return nil } + return String(cString: c) + case SQLITE_BLOB: + guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } + let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) + return String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .utf16LittleEndian) + default: + return nil + } + } + + private var walSidecarsAreMissing: Bool { + !FileManager.default.fileExists(atPath: self.dbPath + "-wal") && + !FileManager.default.fileExists(atPath: self.dbPath + "-shm") + } + + private static func sqliteFailure(db: OpaquePointer?, resultCode: Int32) -> SQLiteReadFailure { + let code = db.map(sqlite3_errcode) ?? resultCode + let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + return SQLiteReadFailure(code: code, message: message) + } + + private struct SQLiteReadFailure: Error { + let code: Int32 + let message: String + } +} + +private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +#endif diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index 6dd650f6d7..e84e29e430 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -58,7 +58,13 @@ struct CursorStatusFetchStrategy: ProviderFetchStrategy { func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { let probe = CursorStatusProbe(browserDetection: context.browserDetection) let manual = Self.manualCookieHeader(from: context) - let snap = try await probe.fetch(cookieHeaderOverride: manual) + let logger: ((String) -> Void)? = context.verbose + ? { message in CodexBarLog.logger(LogCategories.cursor).verbose(message) } + : nil + let snap = try await probe.fetch( + cookieHeaderOverride: manual, + allowAppAuthFallback: context.sourceMode != .web, + logger: logger) return self.makeResult( usage: snap.toUsageSnapshot(), sourceLabel: "web") diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift index 82b819a4b3..5c96025c96 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift @@ -7,7 +7,7 @@ extension CursorStatusProbe { let allowAppAuthFallback: Bool let logger: ((String) -> Void)? let log: (String) -> Void - let perform: @Sendable (String, String?) async throws -> Value + let perform: @Sendable (String, CursorSessionIdentity?) async throws -> Value } private enum CachedSessionFetchResult { @@ -15,6 +15,19 @@ extension CursorStatusProbe { case resumeFallback } + private struct AppSessionFetchContext { + let cachedEntry: CookieHeaderCache.Entry? + let storedCookies: [HTTPCookie] + let cacheObservation: CookieHeaderCache.ConditionalMutationObservation + let perform: @Sendable (String, CursorSessionIdentity?) async throws -> Value + let log: (String) -> Void + } + + private enum AppSessionFetchResult { + case succeeded(Value) + case resumeFallback(storedCookies: [HTTPCookie]) + } + /// Resolve a working Cursor session, preserving selected-account and cache-ownership rules. func resolveSession( cookieHeaderOverride: String? = nil, @@ -23,12 +36,10 @@ extension CursorStatusProbe { logger: ((String) -> Void)? = nil, perform: @escaping @Sendable ( _ cookieHeader: String, - _ requestUsageUserIDFallback: String?) async throws -> Value) + _ identityFallback: CursorSessionIdentity?) async throws -> Value) async throws -> Value { let log: (String) -> Void = { msg in logger?("[cursor] \(msg)") } - var firstRecoverableError: CursorStatusProbeError? - if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { log("Using manual cookie header") return try await perform(override, nil) @@ -36,24 +47,52 @@ extension CursorStatusProbe { // A browser fallback started by this refresh must not overwrite a concurrently committed login. var cacheObservation = CookieHeaderCache.observeForConditionalMutation(provider: .cursor) + let cachedEntry = allowCachedSessions ? CookieHeaderCache.load(provider: .cursor) : nil + var storedCookies = allowCachedSessions ? await CursorSessionStore.shared.getCookies() : [] + if !allowAppAuthFallback { + storedCookies.removeAll(where: CursorAppAuthSession.isPersistedCookie) + } + + if allowAppAuthFallback { + let context = AppSessionFetchContext( + cachedEntry: cachedEntry, + storedCookies: storedCookies, + cacheObservation: cacheObservation, + perform: perform, + log: log) + switch try await self.fetchPreferredAppSession(context: context) { + case let .succeeded(value): + return value + case let .resumeFallback(updatedStoredCookies): + storedCookies = updatedStoredCookies + } + } if allowCachedSessions, - let cached = CookieHeaderCache.load(provider: .cursor), + let cached = cachedEntry, !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - let context = CachedSessionFetchContext( - cookieHeaderOverride: cookieHeaderOverride, - allowAppAuthFallback: allowAppAuthFallback, - logger: logger, - log: log, - perform: perform) - switch try await self.fetchCachedSession(cached, context: context) { - case let .succeeded(value): - return value - case .resumeFallback: - #if os(macOS) - cacheObservation = cacheObservation.afterOwnedClear() - #endif + if cached.sourceLabel == Self.appAuthSourceLabel { + if CookieHeaderCache.clearIfCurrent(provider: .cursor, expected: cached) { + #if os(macOS) + cacheObservation = cacheObservation.afterOwnedClear() + #endif + } + } else { + let context = CachedSessionFetchContext( + cookieHeaderOverride: cookieHeaderOverride, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger, + log: log, + perform: perform) + switch try await self.fetchCachedSession(cached, context: context) { + case let .succeeded(value): + return value + case .resumeFallback: + #if os(macOS) + cacheObservation = cacheObservation.afterOwnedClear() + #endif + } } } @@ -106,65 +145,22 @@ extension CursorStatusProbe { if allowCachedSessions, let value = try await self.fetchStoredSession( + storedCookies: storedCookies, perform: perform, log: log, cacheObservation: cacheObservation) { return value } - - // Transient errors for an explicit session must not silently switch accounts. - if let firstRecoverableError { - throw firstRecoverableError - } - - if allowAppAuthFallback, - let appSession = try? self.appAuthStore.loadSession(), - appSession.isUsable - { - log("Using Cursor.app local auth fallback") - let cookieHeader = try appSession.cookieHeader() - let fetchedValue: Value? - do { - fetchedValue = try await perform(cookieHeader, appSession.userID()) - } catch let error as CursorStatusProbeError { - fetchedValue = nil - if case .notLoggedIn = error { - log("Cursor.app local auth was rejected") - } else { - firstRecoverableError = firstRecoverableError ?? error - } - } catch { - fetchedValue = nil - firstRecoverableError = firstRecoverableError ?? .networkError(error.localizedDescription) - } - if let fetchedValue { - #if os(macOS) - let context = ResolvedSessionReconciliationContext( - cookieHeader: cookieHeader, - sourceLabel: "Cursor.app local auth", - cacheObservation: cacheObservation, - perform: perform, - log: log) - return try await self.reconcileResolvedSession(value: fetchedValue, context: context) - #else - return fetchedValue - #endif - } - } - - if let firstRecoverableError { - throw firstRecoverableError - } throw CursorStatusProbeError.noSessionCookie } private func fetchStoredSession( - perform: @escaping @Sendable (String, String?) async throws -> Value, + storedCookies: [HTTPCookie], + perform: @escaping @Sendable (String, CursorSessionIdentity?) async throws -> Value, log: @escaping (String) -> Void, cacheObservation: CookieHeaderCache.ConditionalMutationObservation) async throws -> Value? { - let storedCookies = await CursorSessionStore.shared.getCookies() guard !storedCookies.isEmpty else { return nil } log("Using stored session cookies") @@ -198,6 +194,127 @@ extension CursorStatusProbe { #endif } + private static let appAuthSourceLabel = "Cursor.app local auth" + + private func fetchPreferredAppSession( + context: AppSessionFetchContext) async throws -> AppSessionFetchResult + { + let loadedAppSession: CursorAppAuthSession? + do { + loadedAppSession = try self.appAuthStore.loadSession() + } catch { + loadedAppSession = nil + context.log("Cursor.app local auth read failed: \(error.localizedDescription)") + } + + // A session read directly from Cursor owns freshness. Only use CodexBar's persisted copy when the + // Cursor database has no session at all; an expired app session must fall through to browser cookies. + let persistedAppSession: CursorAppAuthSession? = if loadedAppSession == nil { + Self.persistedAppSession(cachedEntry: context.cachedEntry, storedCookies: context.storedCookies) + } else { + nil + } + guard let appSession = loadedAppSession ?? persistedAppSession else { + return .resumeFallback(storedCookies: context.storedCookies) + } + guard appSession.isUsable else { + if loadedAppSession != nil { + context.log("Cursor.app local auth is expired or invalid; falling back to browser cookies") + } + let storedCookies = Self.removingAppSession(appSession, from: context.storedCookies) + await CursorSessionStore.shared.setCookies(storedCookies) + return .resumeFallback(storedCookies: storedCookies) + } + + let appIdentity = appSession.identity + Self.logIdentityMismatchIfNeeded( + appIdentity: appIdentity, + cachedEntry: context.cachedEntry, + storedCookies: context.storedCookies, + log: context.log) + context.log("Using Cursor.app local auth") + let cookieHeader = try appSession.cookieHeader() + do { + let value = try await context.perform(cookieHeader, appIdentity) + await self.persistAppAuthSession(appSession) + #if os(macOS) + let reconciliation = ResolvedSessionReconciliationContext( + cookieHeader: cookieHeader, + sourceLabel: Self.appAuthSourceLabel, + cacheObservation: context.cacheObservation, + perform: context.perform, + log: context.log) + let reconciled = try await self.reconcileResolvedSession(value: value, context: reconciliation) + return .succeeded(reconciled) + #else + return .succeeded(value) + #endif + } catch let error as CursorStatusProbeError { + guard case .notLoggedIn = error else { throw error } + context.log("Cursor.app local auth was rejected; falling back to browser cookies") + let storedCookies = Self.removingAppSession(appSession, from: context.storedCookies) + await CursorSessionStore.shared.setCookies(storedCookies) + return .resumeFallback(storedCookies: storedCookies) + } catch { + throw CursorStatusProbeError.networkError(error.localizedDescription) + } + } + + private static func persistedAppSession( + cachedEntry: CookieHeaderCache.Entry?, + storedCookies: [HTTPCookie]) -> CursorAppAuthSession? + { + if let cachedEntry, + cachedEntry.sourceLabel == Self.appAuthSourceLabel, + let session = CursorAppAuthSession.from(cookieHeader: cachedEntry.cookieHeader) + { + return session + } + let storedHeader = storedCookies + .filter(CursorAppAuthSession.isPersistedCookie) + .map { "\($0.name)=\($0.value)" } + .joined(separator: "; ") + return CursorAppAuthSession.from(cookieHeader: storedHeader) + } + + private static func removingAppSession( + _ appSession: CursorAppAuthSession, + from cookies: [HTTPCookie]) -> [HTTPCookie] + { + cookies.filter { cookie in + guard CursorAppAuthSession.isPersistedCookie(cookie) else { return true } + let value = cookie.value.removingPercentEncoding ?? cookie.value + return value.components(separatedBy: "::").last != appSession.accessToken + } + } + + private static func logIdentityMismatchIfNeeded( + appIdentity: CursorSessionIdentity?, + cachedEntry: CookieHeaderCache.Entry?, + storedCookies: [HTTPCookie], + log: (String) -> Void) + { + guard let appIdentity else { return } + let browserIdentity: CursorSessionIdentity? = if let cachedEntry, + cachedEntry.sourceLabel != Self.appAuthSourceLabel + { + CursorSessionIdentity.from(cookieHeader: cachedEntry.cookieHeader) + } else { + CursorSessionIdentity.from( + cookieHeader: storedCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")) + } + guard let browserIdentity, + appIdentity.differs(from: browserIdentity) == true + else { return } + + let appLabel = appIdentity.displayLabel ?? "unknown account" + let browserLabel = browserIdentity.displayLabel ?? "unknown account" + let message = "Cursor.app account \(appLabel) differs from browser session \(browserLabel); " + + "using Cursor.app account \(appLabel)" + CodexBarLog.logger(LogCategories.cursor).warning(message) + log(message) + } + private func fetchCachedSession( _ cached: CookieHeaderCache.Entry, context: CachedSessionFetchContext) async throws -> CachedSessionFetchResult diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index dc00625e9f..d21e5d16fc 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -3,11 +3,6 @@ import Foundation import FoundationNetworking #endif import SweetCookieKit -#if canImport(SQLite3) -import SQLite3 -#elseif canImport(CSQLite3) -import CSQLite3 -#endif #if os(macOS) || os(Linux) @@ -381,175 +376,6 @@ public struct CursorUserInfo: Codable, Sendable { } } -// MARK: - Cursor App Auth - -struct CursorAppAuthSession: Equatable { - let accessToken: String - - var isUsable: Bool { - guard !self.accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - (try? self.userID()) != nil, - let expiresAt = try? self.expiresAt() - else { - return false - } - return expiresAt.timeIntervalSinceNow > 60 - } - - func cookieHeader() throws -> String { - try "WorkosCursorSessionToken=\(self.userID())%3A%3A\(self.accessToken)" - } - - func userID() throws -> String { - let json = try self.payload() - guard let subject = json["sub"] as? String, - let userID = subject.split(separator: "|", omittingEmptySubsequences: true).last.map(String.init), - !userID.isEmpty - else { - throw CursorStatusProbeError.parseFailed("Cursor.app access token is missing a user ID") - } - - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) - guard userID.unicodeScalars.allSatisfy(allowed.contains) else { - throw CursorStatusProbeError.parseFailed("Cursor.app access token has an invalid user ID") - } - - return userID - } - - private func expiresAt() throws -> Date { - let json = try self.payload() - guard let expiration = json["exp"] as? NSNumber else { - throw CursorStatusProbeError.parseFailed("Cursor.app access token is missing an expiration") - } - return Date(timeIntervalSince1970: expiration.doubleValue) - } - - private func payload() throws -> [String: Any] { - let parts = self.accessToken.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count >= 2 else { - throw CursorStatusProbeError.parseFailed("Cursor.app access token is not a JWT") - } - - var payload = String(parts[1]) - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - payload += String(repeating: "=", count: (4 - payload.count % 4) % 4) - - guard let data = Data(base64Encoded: payload), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { - throw CursorStatusProbeError.parseFailed("Cursor.app access token has an invalid payload") - } - - return json - } -} - -protocol CursorAppAuthSessionProviding: Sendable { - func loadSession() throws -> CursorAppAuthSession? -} - -struct CursorAppAuthStore: CursorAppAuthSessionProviding { - private static let defaultDBPath: String = Self.resolveDefaultDBPath() - - private let dbPath: String - - init(dbPath: String? = nil) { - self.dbPath = dbPath ?? Self.defaultDBPath - } - - static func resolveDefaultDBPath( - home: String = NSHomeDirectory(), - environment: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default) -> String - { - #if os(macOS) - _ = environment - _ = fileManager - return "\(home)/Library/Application Support/Cursor/User/globalStorage/state.vscdb" - #elseif os(Linux) - let configHome = environment[CodexBarConfigStore.xdgConfigHomeEnvironmentKey]? - .trimmingCharacters(in: .whitespacesAndNewlines) - let expandedConfigHome = configHome.map { ($0 as NSString).expandingTildeInPath } - let base: String = if let expandedConfigHome, - !expandedConfigHome.isEmpty, - (expandedConfigHome as NSString).isAbsolutePath - { - expandedConfigHome - } else { - "\(home)/.config" - } - return "\(base)/Cursor/User/globalStorage/state.vscdb" - #else - _ = home - _ = environment - _ = fileManager - return "" - #endif - } - - func loadSession() throws -> CursorAppAuthSession? { - guard FileManager.default.fileExists(atPath: self.dbPath) else { return nil } - - guard let accessToken = try self.value(for: "cursorAuth/accessToken"), - !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return nil - } - - return CursorAppAuthSession(accessToken: accessToken) - } - - private func value(for key: String) throws -> String? { - var db: OpaquePointer? - guard sqlite3_open_v2(self.dbPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { - let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" - sqlite3_close(db) - throw CursorStatusProbeError.networkError("SQLite error reading Cursor app auth: \(message)") - } - defer { sqlite3_close(db) } - sqlite3_busy_timeout(db, 250) - - let query = "SELECT value FROM ItemTable WHERE key = ? LIMIT 1;" - var stmt: OpaquePointer? - guard sqlite3_prepare_v2(db, query, -1, &stmt, nil) == SQLITE_OK else { - let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" - throw CursorStatusProbeError.networkError("SQLite error preparing Cursor app auth read: \(message)") - } - defer { sqlite3_finalize(stmt) } - - sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT) - let stepResult = sqlite3_step(stmt) - guard stepResult == SQLITE_ROW else { - if stepResult == SQLITE_DONE { - return nil - } - let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" - throw CursorStatusProbeError.networkError("SQLite error reading Cursor app auth: \(message)") - } - - return Self.decodeSQLiteValue(stmt: stmt, index: 0) - } - - private static func decodeSQLiteValue(stmt: OpaquePointer?, index: Int32) -> String? { - switch sqlite3_column_type(stmt, index) { - case SQLITE_TEXT: - guard let c = sqlite3_column_text(stmt, index) else { return nil } - return String(cString: c) - case SQLITE_BLOB: - guard let bytes = sqlite3_column_blob(stmt, index) else { return nil } - let data = Data(bytes: bytes, count: Int(sqlite3_column_bytes(stmt, index))) - return String(data: data, encoding: .utf8) - ?? String(data: data, encoding: .utf16LittleEndian) - default: - return nil - } - } -} - -private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - // MARK: - Cursor Status Snapshot public struct CursorStatusSnapshot: Sendable { @@ -832,8 +658,14 @@ public actor CursorSessionStore { self.saveToDisk() } + func persistAppSession(_ session: CursorAppAuthSession) { + guard let cookie = try? session.makeCookie() else { return } + self.setCookies([cookie]) + } + public func getCookies() -> [HTTPCookie] { self.loadFromDiskIfNeeded() + self.pruneExpiredCookies() return self.sessionCookies } @@ -845,6 +677,7 @@ public actor CursorSessionStore { public func hasValidSession() -> Bool { self.loadFromDiskIfNeeded() + self.pruneExpiredCookies() return !self.sessionCookies.isEmpty } @@ -891,9 +724,11 @@ public actor CursorSessionStore { } return serializable } - guard !cookieData.isEmpty, - let data = try? JSONSerialization.data(withJSONObject: cookieData, options: [.prettyPrinted]) - else { + guard !cookieData.isEmpty else { + try? FileManager.default.removeItem(at: self.fileURL) + return + } + guard let data = try? JSONSerialization.data(withJSONObject: cookieData, options: [.prettyPrinted]) else { return } // These are Cursor auth session cookies. Write them owner-only (0600) with the permission @@ -932,6 +767,16 @@ public actor CursorSessionStore { return HTTPCookie(properties: cookieProps) } } + + private func pruneExpiredCookies(now: Date = Date()) { + let active = self.sessionCookies.filter { cookie in + guard let expiresDate = cookie.expiresDate else { return true } + return expiresDate > now + } + guard active.count != self.sessionCookies.count else { return } + self.sessionCookies = active + self.saveToDisk() + } } // MARK: - Cursor Cost Report @@ -966,6 +811,7 @@ public struct CursorStatusProbe: Sendable { let browserCookieImportOrder: BrowserCookieImportOrder private let urlSession: any ProviderHTTPTransport let appAuthStore: any CursorAppAuthSessionProviding + let persistAppAuthSession: @Sendable (CursorAppAuthSession) async -> Void public init( baseURL: URL = URL(string: "https://cursor.com")!, @@ -979,7 +825,10 @@ public struct CursorStatusProbe: Sendable { browserDetection: browserDetection, browserCookieImportOrder: Self.defaultBrowserCookieImportOrder, urlSession: urlSession, - appAuthStore: CursorAppAuthStore()) + appAuthStore: CursorAppAuthStore(), + persistAppAuthSession: { session in + await CursorSessionStore.shared.persistAppSession(session) + }) } init( @@ -988,7 +837,8 @@ public struct CursorStatusProbe: Sendable { browserDetection: BrowserDetection, browserCookieImportOrder: BrowserCookieImportOrder = Self.defaultBrowserCookieImportOrder, urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared, - appAuthStore: any CursorAppAuthSessionProviding) + appAuthStore: any CursorAppAuthSessionProviding, + persistAppAuthSession: @escaping @Sendable (CursorAppAuthSession) async -> Void = { _ in }) { self.baseURL = baseURL self.timeout = timeout @@ -996,13 +846,14 @@ public struct CursorStatusProbe: Sendable { self.browserCookieImportOrder = browserCookieImportOrder self.urlSession = urlSession self.appAuthStore = appAuthStore + self.persistAppAuthSession = persistAppAuthSession } /// Fetch Cursor usage using a first-party web session derived from Cursor.app's access token. func fetchWithAppAuthSession(_ session: CursorAppAuthSession) async throws -> CursorStatusSnapshot { try await self.fetchWithCookieHeader( session.cookieHeader(), - requestUsageUserIDFallback: session.userID()) + identityFallback: session.identity) } /// Fetch Cursor usage with manual cookie header (for debugging). @@ -1023,10 +874,10 @@ public struct CursorStatusProbe: Sendable { allowCachedSessions: allowCachedSessions, allowAppAuthFallback: allowAppAuthFallback, logger: logger) - { cookieHeader, requestUsageUserIDFallback in + { cookieHeader, identityFallback in try await self.fetchWithCookieHeader( cookieHeader, - requestUsageUserIDFallback: requestUsageUserIDFallback) + identityFallback: identityFallback) } } @@ -1258,7 +1109,7 @@ public struct CursorStatusProbe: Sendable { let cookieHeader: String let sourceLabel: String let cacheObservation: CookieHeaderCache.ConditionalMutationObservation - let perform: @Sendable (String, String?) async throws -> Value + let perform: @Sendable (String, CursorSessionIdentity?) async throws -> Value let log: (String) -> Void } @@ -1286,7 +1137,7 @@ public struct CursorStatusProbe: Sendable { func resolveImportedSession( _ session: CursorCookieImporter.SessionInfo, - perform: @escaping @Sendable (String, String?) async throws -> Value, + perform: @escaping @Sendable (String, CursorSessionIdentity?) async throws -> Value, log: @escaping (String) -> Void, cacheObservation: CookieHeaderCache.ConditionalMutationObservation) async throws -> ResolvedSessionFetchOutcome @@ -1395,7 +1246,7 @@ public struct CursorStatusProbe: Sendable { private func fetchWithCookieHeader( _ cookieHeader: String, - requestUsageUserIDFallback: String? = nil, + identityFallback: CursorSessionIdentity? = nil, deadline: Date? = nil) async throws -> CursorStatusSnapshot { enum FetchPart: Sendable { @@ -1443,7 +1294,7 @@ public struct CursorStatusProbe: Sendable { // Uses try? to avoid breaking the flow for users where this endpoint fails or returns unexpected data. var requestUsage: CursorUsageResponse? var requestUsageRawJSON: String? - if let userId = userInfo?.sub ?? requestUsageUserIDFallback { + if let userId = userInfo?.sub ?? identityFallback?.requestUsageUserID { do { let (usage, usageRawJSON) = try await self.fetchRequestUsage( userId: userId, @@ -1466,7 +1317,8 @@ public struct CursorStatusProbe: Sendable { usageSummary, userInfo: userInfo, rawJSON: combinedRawJSON, - requestUsage: requestUsage) + requestUsage: requestUsage, + identityFallback: identityFallback) } private func fetchUsageSummary( @@ -1566,7 +1418,8 @@ public struct CursorStatusProbe: Sendable { _ summary: CursorUsageSummary, userInfo: CursorUserInfo?, rawJSON: String?, - requestUsage: CursorUsageResponse? = nil) -> CursorStatusSnapshot + requestUsage: CursorUsageResponse? = nil, + identityFallback: CursorSessionIdentity? = nil) -> CursorStatusSnapshot { func parseBillingCycleDate(_ dateString: String?) -> Date? { guard let dateString else { return nil } @@ -1668,8 +1521,8 @@ public struct CursorStatusProbe: Sendable { billingCycleStart: billingCycleStart, billingCycleEnd: billingCycleEnd, membershipType: summary.membershipType, - accountEmail: userInfo?.email, - accountID: userInfo?.sub, + accountEmail: userInfo?.email ?? identityFallback?.email, + accountID: userInfo?.sub ?? identityFallback?.subject, accountName: userInfo?.name, rawJSON: rawJSON, requestsUsed: requestsUsed, diff --git a/Tests/CodexBarTests/CursorMenuCardModelTests.swift b/Tests/CodexBarTests/CursorMenuCardModelTests.swift index bb0c4b8293..8e50833ec4 100644 --- a/Tests/CodexBarTests/CursorMenuCardModelTests.swift +++ b/Tests/CodexBarTests/CursorMenuCardModelTests.swift @@ -4,6 +4,45 @@ import Testing @testable import CodexBar struct CursorMenuCardModelTests { + @Test + func `chosen app session account identity is visible on the card`() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.cursor]) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .cursor, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Cursor Pro", + accountID: "auth0|app-user")) + + let model = UsageMenuCardView.Model.make(.init( + provider: .cursor, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "web@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.email == "app-user") + } + @Test func `team pool shows personal spend and changes height fingerprint`() throws { let now = Date(timeIntervalSince1970: 0) diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index c501d1975f..202a43190e 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -741,6 +741,22 @@ struct CursorStatusProbeTests { await store.clearCookies() } + + @Test + func `session store persists Cursor app auth with source and expiry metadata`() async throws { + let store = CursorSessionStore.shared + await store.clearCookies() + let token = try makeCursorAppAuthToken(expiration: Date(timeIntervalSinceNow: 3600)) + + await store.persistAppSession(CursorAppAuthSession(accessToken: token)) + await store.resetForTesting(clearDisk: false) + let cookie = try #require(await (store.getCookies()).first) + + #expect(cookie.value.contains(token)) + #expect(CursorAppAuthSession.isPersistedCookie(cookie)) + #expect(cookie.expiresDate != nil) + await store.clearCookies() + } } private final class CursorStatusProbeTestSession { @@ -810,6 +826,60 @@ extension CursorStatusProbeTests { #expect(session == CursorAppAuthSession(accessToken: "app-token")) } + @Test + func `app auth store reads idle WAL database without creating sidecars`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-app-auth-wal-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let dbURL = directory.appendingPathComponent("state.vscdb") + var db: OpaquePointer? + try #require(sqlite3_open(dbURL.path, &db) == SQLITE_OK) + let sql = """ + CREATE TABLE ItemTable(key TEXT PRIMARY KEY, value BLOB); + INSERT INTO ItemTable VALUES('cursorAuth/accessToken', 'wal-token'); + PRAGMA journal_mode = WAL; + PRAGMA wal_checkpoint(TRUNCATE); + """ + try #require(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK) + try #require(sqlite3_close(db) == SQLITE_OK) + + let walURL = URL(fileURLWithPath: dbURL.path + "-wal") + let sharedMemoryURL = URL(fileURLWithPath: dbURL.path + "-shm") + for url in [walURL, sharedMemoryURL] where FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + + let session = try #require(try CursorAppAuthStore(dbPath: dbURL.path).loadSession()) + #expect(session.accessToken == "wal-token") + #expect(!FileManager.default.fileExists(atPath: walURL.path)) + #expect(!FileManager.default.fileExists(atPath: sharedMemoryURL.path)) + } + + @Test + func `app auth store reads uncheckpointed active WAL state`() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cursor-app-auth-active-wal-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let dbURL = directory.appendingPathComponent("state.vscdb") + var db: OpaquePointer? + try #require(sqlite3_open(dbURL.path, &db) == SQLITE_OK) + let sql = """ + CREATE TABLE ItemTable(key TEXT PRIMARY KEY, value BLOB); + PRAGMA journal_mode = WAL; + INSERT INTO ItemTable VALUES('cursorAuth/accessToken', 'active-wal-token'); + """ + try #require(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK) + defer { sqlite3_close(db) } + + #expect(FileManager.default.fileExists(atPath: dbURL.path + "-wal")) + let session = try #require(try CursorAppAuthStore(dbPath: dbURL.path).loadSession()) + #expect(session.accessToken == "active-wal-token") + } + @Test func `fetch ignores user info failure when usage summary succeeds`() async throws { let testSession = CursorStatusProbeTestSession { request in @@ -903,6 +973,7 @@ extension CursorStatusProbeTests { @Test func `fetch uses Cursor app local auth when browser cookies are unavailable`() async throws { let accessToken = try makeCursorAppAuthToken() + let persistence = CursorAppSessionRecorder() let expectedCookie = "WorkosCursorSessionToken=user_test%3A%3A\(accessToken)" let testSession = CursorStatusProbeTestSession { request in let requestURL = try #require(request.url) @@ -957,7 +1028,9 @@ extension CursorStatusProbeTests { browserCookieImportOrder: [], urlSession: testSession.urlSession, appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( - accessToken: accessToken))).fetch(allowCachedSessions: false) + accessToken: accessToken)), + persistAppAuthSession: { session in persistence.record(session) }) + .fetch(allowCachedSessions: false) #expect(abs(snapshot.planPercentUsed - 19.4) < 0.0001) #expect(snapshot.planUsedUSD == 3.88) @@ -973,6 +1046,102 @@ extension CursorStatusProbeTests { "/api/usage", "/api/usage-summary", ]) + #expect(persistence.snapshot() == [CursorAppAuthSession(accessToken: accessToken)]) + } + + @Test + func `automatic auth uses cookies when app session is absent`() async throws { + await CursorSessionStore.shared.clearCookies() + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } + CookieHeaderCache.store( + provider: .cursor, + cookieHeader: "WorkosCursorSessionToken=browser-session", + sourceLabel: "Chrome") + + let probe = CursorStatusProbe( + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + appAuthStore: CursorAppAuthSessionProviderStub(session: nil)) + let header = try await probe.resolveSession { cookieHeader, _ in cookieHeader } + + #expect(header == "WorkosCursorSessionToken=browser-session") + } + + @Test + func `automatic auth keeps app session when cookie identity matches`() async throws { + await CursorSessionStore.shared.clearCookies() + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } + let appToken = try makeCursorAppAuthToken(subject: "auth0|same-user", email: "same@example.com") + let browserToken = try makeCursorAppAuthToken(subject: "workos|same-user", email: "same@example.com") + CookieHeaderCache.store( + provider: .cursor, + cookieHeader: "WorkosCursorSessionToken=same-user%3A%3A\(browserToken)", + sourceLabel: "Chrome") + let logs = CursorStringRecorder() + let probe = CursorStatusProbe( + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession(accessToken: appToken))) + + let label = try await probe.resolveSession(logger: { logs.record($0) }, perform: { _, identity in + identity?.displayLabel ?? "browser" + }) + + #expect(label == "same@example.com") + #expect(!logs.snapshot().contains(where: { $0.contains("differs from browser session") })) + } + + @Test + func `automatic auth logs mismatched cookie identity and exposes chosen app account`() async throws { + await CursorSessionStore.shared.clearCookies() + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } + let appToken = try makeCursorAppAuthToken(subject: "auth0|app-user", email: "app@example.com") + let browserToken = try makeCursorAppAuthToken(subject: "workos|browser-user", email: "web@example.com") + CookieHeaderCache.store( + provider: .cursor, + cookieHeader: "WorkosCursorSessionToken=browser-user%3A%3A\(browserToken)", + sourceLabel: "Chrome") + let logs = CursorStringRecorder() + let probe = CursorStatusProbe( + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession(accessToken: appToken))) + + let label = try await probe.resolveSession(logger: { logs.record($0) }, perform: { _, identity in + identity?.displayLabel ?? "browser" + }) + + #expect(label == "app@example.com") + #expect(logs.snapshot().contains(where: { + $0.contains("Cursor.app account app@example.com differs from browser session web@example.com") + })) + } + + @Test + func `automatic auth falls back to cookies when app session is expired`() async throws { + await CursorSessionStore.shared.clearCookies() + CookieHeaderCache.clear(provider: .cursor) + defer { CookieHeaderCache.clear(provider: .cursor) } + CookieHeaderCache.store( + provider: .cursor, + cookieHeader: "WorkosCursorSessionToken=browser-session", + sourceLabel: "Chrome") + let expiredToken = try makeCursorAppAuthToken(expiration: Date(timeIntervalSinceNow: -60)) + let logs = CursorStringRecorder() + let probe = CursorStatusProbe( + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession(accessToken: expiredToken))) + + let header = try await probe.resolveSession( + logger: { logs.record($0) }, + perform: { cookieHeader, _ in cookieHeader }) + + #expect(header == "WorkosCursorSessionToken=browser-session") + #expect(logs.snapshot().contains(where: { $0.contains("expired or invalid") })) } @Test @@ -1001,12 +1170,30 @@ extension CursorStatusProbeTests { } @Test - func `fetch prefers stored session cookies before Cursor app auth fallback`() async throws { + func `explicit web resolution skips a persisted Cursor app session`() async throws { let store = CursorSessionStore.shared await store.clearCookies() - defer { - Task { await store.clearCookies() } + CookieHeaderCache.clear(provider: .cursor) + let token = try makeCursorAppAuthToken() + await store.persistAppSession(CursorAppAuthSession(accessToken: token)) + + let probe = CursorStatusProbe( + browserDetection: BrowserDetection(cacheTTL: 0), + browserCookieImportOrder: [], + appAuthStore: CursorAppAuthSessionProviderStub(session: nil)) + await #expect(throws: CursorStatusProbeError.self) { + _ = try await probe.resolveSession(allowAppAuthFallback: false) { _, _ in + Issue.record("Explicit web resolution unexpectedly consumed the persisted app session") + return "unexpected" + } } + await store.clearCookies() + } + + @Test + func `fetch prefers Cursor app auth before stored session cookies`() async throws { + let store = CursorSessionStore.shared + await store.clearCookies() guard let cookie = HTTPCookie(properties: [ .name: "WorkosCursorSessionToken", @@ -1020,10 +1207,12 @@ extension CursorStatusProbeTests { } await store.setCookies([cookie]) + let accessToken = try makeCursorAppAuthToken() let testSession = CursorStatusProbeTestSession { request in let requestURL = try #require(request.url) #expect(request.value(forHTTPHeaderField: "Authorization") == nil) - #expect(request.value(forHTTPHeaderField: "Cookie") == "WorkosCursorSessionToken=stored-session") + let expectedCookie = "WorkosCursorSessionToken=user_test%3A%3A\(accessToken)" + #expect(request.value(forHTTPHeaderField: "Cookie") == expectedCookie) switch requestURL.path { case "/api/usage-summary": @@ -1045,10 +1234,15 @@ extension CursorStatusProbeTests { case "/api/auth/me": return makeCursorStatusProbeResponse( url: requestURL, - body: #"{"email":"stored@example.com","name":"Stored User"}"#, + body: #"{"email":"app@example.com","name":"App User","sub":"auth0|user_test"}"#, + statusCode: 200) + case "/api/usage": + return makeCursorStatusProbeResponse( + url: requestURL, + body: #"{"gpt-4":{}}"#, statusCode: 200) default: - Issue.record("Stored-session precedence test unexpectedly requested \(requestURL.path)") + Issue.record("App-session precedence test unexpectedly requested \(requestURL.path)") throw URLError(.badURL) } } @@ -1056,7 +1250,6 @@ extension CursorStatusProbeTests { CookieHeaderCache.clear(provider: .cursor) defer { CookieHeaderCache.clear(provider: .cursor) } let baseURL = try #require(URL(string: "https://cursor.test")) - let accessToken = try makeCursorAppAuthToken() let snapshot = try await CursorStatusProbe( baseURL: baseURL, browserDetection: BrowserDetection(cacheTTL: 0), @@ -1066,11 +1259,13 @@ extension CursorStatusProbeTests { accessToken: accessToken))).fetch() #expect(snapshot.planPercentUsed == 30.0) - #expect(snapshot.accountEmail == "stored@example.com") + #expect(snapshot.accountEmail == "app@example.com") #expect(testSession.requestPaths.sorted() == [ "/api/auth/me", + "/api/usage", "/api/usage-summary", ]) + await store.clearCookies() } @Test @@ -1205,8 +1400,6 @@ extension CursorStatusProbeTests { CookieHeaderCache.clear(provider: .cursor) } - let accessToken = try makeCursorAppAuthToken() - let appCookie = "WorkosCursorSessionToken=user_test%3A%3A\(accessToken)" let testSession = CursorStatusProbeTestSession { request in let requestURL = try #require(request.url) let cookie = request.value(forHTTPHeaderField: "Cookie") @@ -1216,9 +1409,6 @@ extension CursorStatusProbeTests { url: requestURL, body: #"{"error":"temporary"}"#, statusCode: 500) - case _ where cookie == appCookie: - Issue.record("Transient cached-session failure unexpectedly switched to Cursor.app auth") - throw URLError(.userAuthenticationRequired) default: throw URLError(.badURL) } @@ -1230,14 +1420,12 @@ extension CursorStatusProbeTests { browserDetection: BrowserDetection(cacheTTL: 0), browserCookieImportOrder: [], urlSession: testSession.urlSession, - appAuthStore: CursorAppAuthSessionProviderStub(session: CursorAppAuthSession( - accessToken: accessToken))) + appAuthStore: CursorAppAuthSessionProviderStub(session: nil)) await #expect(throws: CursorStatusProbeError.self) { _ = try await probe.fetch() } #expect(testSession.requestCookies.contains("cached=bad")) - #expect(!testSession.requestCookies.contains(appCookie)) } @Test @@ -1248,16 +1436,8 @@ extension CursorStatusProbeTests { #expect(CursorStatusProbe.commitBrowserLoginSession(selectedSession)) defer { CookieHeaderCache.clear(provider: .cursor) } - let accessToken = try makeCursorAppAuthToken() - let appSession = CursorAppAuthSession(accessToken: accessToken) - let appCookie = try appSession.cookieHeader() let testSession = CursorStatusProbeTestSession { request in let requestURL = try #require(request.url) - let cookie = request.value(forHTTPHeaderField: "Cookie") - if cookie == appCookie { - Issue.record("Rejected selected session unexpectedly switched to Cursor.app auth") - throw URLError(.userAuthenticationRequired) - } return makeCursorStatusProbeResponse( url: requestURL, body: #"{"error":"unauthorized"}"#, @@ -1270,7 +1450,7 @@ extension CursorStatusProbeTests { browserDetection: BrowserDetection(cacheTTL: 0), browserCookieImportOrder: [], urlSession: testSession.urlSession, - appAuthStore: CursorAppAuthSessionProviderStub(session: appSession)) + appAuthStore: CursorAppAuthSessionProviderStub(session: nil)) await #expect(throws: CursorStatusProbeError.self) { _ = try await probe.fetch() @@ -1279,7 +1459,6 @@ extension CursorStatusProbeTests { _ = try await probe.fetch() } #expect(testSession.requestCookies.contains("selected=expired")) - #expect(!testSession.requestCookies.contains(appCookie)) #expect(CookieHeaderCache.load(provider: .cursor)?.authenticationFailurePolicy == .stopFallback) } @@ -1378,14 +1557,15 @@ extension CursorStatusProbeTests { private func makeCursorAppAuthToken( subject: String = "auth0|user_test", + email: String? = nil, expiration: Date = Date(timeIntervalSinceNow: 3600)) throws -> String { - let payload = try JSONSerialization.data( - withJSONObject: [ - "exp": Int(expiration.timeIntervalSince1970), - "sub": subject, - ], - options: [.sortedKeys]) + var claims: [String: Any] = [ + "exp": Int(expiration.timeIntervalSince1970), + "sub": subject, + ] + claims["email"] = email + let payload = try JSONSerialization.data(withJSONObject: claims, options: [.sortedKeys]) let encodedPayload = payload.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") @@ -1393,6 +1573,40 @@ private func makeCursorAppAuthToken( return "header.\(encodedPayload).signature" } +private final class CursorStringRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [String] = [] + + func record(_ value: String) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [String] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } +} + +private final class CursorAppSessionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [CursorAppAuthSession] = [] + + func record(_ value: CursorAppAuthSession) { + self.lock.lock() + defer { self.lock.unlock() } + self.values.append(value) + } + + func snapshot() -> [CursorAppAuthSession] { + self.lock.lock() + defer { self.lock.unlock() } + return self.values + } +} + private struct CursorAppAuthSessionProviderStub: CursorAppAuthSessionProviding { let session: CursorAppAuthSession? diff --git a/docs/cursor.md b/docs/cursor.md index e16bdbacc7..ff3fff4514 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -8,15 +8,30 @@ read_when: # Cursor provider -Cursor is primarily web-backed. Usage is fetched via browser cookies, with legacy stored-session cookies and Cursor.app local auth as fallbacks. +Cursor can reuse Cursor.app's local session or a cursor.com browser session. Automatic mode prefers a usable +Cursor.app session and falls back to cookies when the app token is missing, expired, invalid, or rejected. ## Data sources + fallback order -1) **Cached cookie header** (preferred) +1) **Cursor.app local auth** (preferred in Automatic mode) + - Reads Cursor.app's VS Code-style global state DB for `ItemTable` key `cursorAuth/accessToken`. + - Files consulted by SQLite: + - macOS main DB: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` + - Active WAL sidecars when present: `state.vscdb-wal` and `state.vscdb-shm` + - Linux main DB: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) + - The database is opened read-only. Active WAL state is read normally; an idle WAL-mode main file with no + sidecars uses SQLite immutable mode so CodexBar does not recreate files in Cursor's directory. + - The token is used only while its JWT expiry is more than 60 seconds away. CodexBar never refreshes it. + - A validated derived session is also persisted owner-only at + `~/Library/Application Support/CodexBar/cursor-session.json` through the standard credential-file writer. + - When an already-cached cookie exposes a different email or subject, CodexBar logs the mismatch and keeps the + chosen Cursor.app identity on the usage snapshot/card. It does not combine app usage with browser identity. + +2) **Cached cookie header** - Stored after successful browser import. - Keychain cache: `com.steipete.codexbar.cache` (account `cookie.cursor`). -2) **Browser cookie import** +3) **Browser cookie import** - Cookie order from provider metadata (default: Safari → Chrome → Firefox). - Domain filters: `cursor.com`, `cursor.sh`. - Cookie names required (any one counts): @@ -24,19 +39,13 @@ Cursor is primarily web-backed. Usage is fetched via browser cookies, with legac - `__Secure-next-auth.session-token` - `next-auth.session-token` -3) **Stored session cookies** (fallback) +4) **Stored session cookies** (fallback) - Legacy sessions captured by older CodexBar releases remain readable. - Stored at: `~/Library/Application Support/CodexBar/cursor-session.json`. -4) **Cursor.app local auth** (last fallback) - - Reads Cursor.app's VS Code-style global state DB for the local app bearer token. - - File: - - macOS: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` - - Linux: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) - - Used only after cookie/session sources fail so existing account-selection precedence stays stable. - - On Linux, this is the primary automatic source because browser import is macOS-only. - - Derives Cursor's first-party web-session cookie, then uses the same usage and account endpoints as browser sessions. - - Account identity comes from that authenticated session; cached app profile fields are not mixed across accounts. +Explicit `--source web` skips Cursor.app local auth and uses only the cookie ladder. A configured Manual cookie +header remains an explicit override. `codexbar usage --provider cursor --source auto --verbose` prints the selected +automatic path and is the quickest live-read check after Cursor login. Manual option: - Preferences → Providers → Cursor → Cookie source → Manual. @@ -90,7 +99,7 @@ The cost summary's Cursor section is opt-in: it only fetches when **Show cost su Unlike Claude and Codex cost (scanned from local session logs on this machine), Cursor cost is remote, account-wide data from the cursor.com dashboard, so it covers usage from every machine on the account. Auth reuses the exact status-probe session resolution and cookie-source policy: -- **Auto**: cached cookie header → browser cookie import → stored WebKit session → Cursor.app local auth. +- **Auto**: Cursor.app local auth → cached cookie header → browser cookie import → stored session. - **Manual**: a non-empty pasted cookie header is required and forwarded as-is, so cost and status share the same session; an empty header fails closed instead of falling back to another account. - **Off**: the fetch is skipped in the app; `codexbar cost --provider cursor` fails explicitly and `/cost` returns a provider error row. @@ -114,6 +123,7 @@ Caching: the app holds the snapshot for an in-memory hourly TTL, keyed by the hi - Reset: billing cycle end date. ## Key files +- `Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift` - `Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift` - `Sources/CodexBar/CursorLoginRunner.swift` (login flow) - `Sources/CodexBar/Providers/Cursor/CursorLoginFlow.swift` (menu integration) From dea90853f950257921573ee4575ea65ada249a4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 19:57:55 -0700 Subject: [PATCH 2/4] test: stabilize Codex credits coalescing --- ...odexBackgroundRefreshCoalescingTests.swift | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift index 98cc19efa0..450a81d01f 100644 --- a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -7,7 +7,7 @@ import Testing @MainActor struct CodexBackgroundRefreshCoalescingTests { @Test - func `rapid regular refreshes coalesce concurrent Codex credits fetches`() async throws { + func `rapid scheduled refreshes coalesce concurrent Codex credits fetches`() async throws { let settings = try self.makeSettingsStore( suite: "CodexBackgroundRefreshCoalescingTests-credits-coalescing") settings.statusChecksEnabled = false @@ -19,52 +19,25 @@ struct CodexBackgroundRefreshCoalescingTests { let store = self.makeStore(settings: settings) let blocker = BlockingCreditsLoader() - let firstCompletion = RefreshCompletionProbe() - let secondCompletion = RefreshCompletionProbe() - store._test_providerRefreshOverride = { _ in } - defer { store._test_providerRefreshOverride = nil } store._test_codexCreditsLoaderOverride = { try await blocker.awaitResult() } defer { store._test_codexCreditsLoaderOverride = nil } - let firstRefreshTask = Task { - await store.refresh(forceTokenUsage: false) - await firstCompletion.markCompleted() - } + store.scheduleCreditsRefreshIfNeeded() let didStartFirstCreditsRefresh = await blocker.waitUntilStartedWithin(count: 1) #expect(didStartFirstCreditsRefresh) guard didStartFirstCreditsRefresh else { - await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [firstRefreshTask]) - return - } - let didCompleteFirstRefresh = await firstCompletion.waitUntilCompleted() - #expect(didCompleteFirstRefresh) - guard didCompleteFirstRefresh else { - await self.cancelCreditsWork(store: store, blocker: blocker, tasks: [firstRefreshTask]) + await self.cancelCreditsWork(store: store, blocker: blocker, tasks: []) return } + let creditsTask = try #require(store.creditsRefreshTask) - let secondRefreshTask = Task { - await store.refresh(forceTokenUsage: false) - await secondCompletion.markCompleted() - } - - let didCompleteSecondRefresh = await secondCompletion.waitUntilCompleted() - #expect(didCompleteSecondRefresh) - guard didCompleteSecondRefresh else { - await self.cancelCreditsWork( - store: store, - blocker: blocker, - tasks: [firstRefreshTask, secondRefreshTask]) - return - } + store.scheduleCreditsRefreshIfNeeded() #expect(await blocker.startedCount() == 1) await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) - - await firstRefreshTask.value - await secondRefreshTask.value + await creditsTask.value } @Test From fd5d4f21a8815a30b7424bae2655e4319672ca0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 20:59:51 -0700 Subject: [PATCH 3/4] fix: gate Cursor app sessions to macOS --- CHANGELOG.md | 2 +- .../Providers/Cursor/CursorAppAuth.swift | 10 ++-- .../Cursor/CursorProviderDescriptor.swift | 2 +- .../CursorStatusProbe+SessionResolution.swift | 32 +++++++++--- .../Providers/Cursor/CursorStatusProbe.swift | 50 +++++++++++++++++-- docs/cursor.md | 8 ++- 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 143e6f780c..93785c79cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.49.7 — Unreleased ### Added -- Cursor: prefer Cursor.app's read-only local session in Automatic mode, persist validated sessions securely, and surface account mismatches before falling back to browser cookies (#2398). Thanks @markmay for the direction! +- Cursor: on macOS, prefer Cursor.app's read-only local session in Automatic mode, persist validated sessions securely, and surface account mismatches before falling back to browser cookies (#2398). Thanks @markmay for the direction! - Cost history: add a Tokens/Cost switch to daily status-menu charts, defaulting Codex to exact local token totals and marking incomplete local history as refreshing (#2930). Thanks @Carl723000! - Menu bar layout: add a compact run-out forecast token that shows only the predicted duration (#2865). Thanks @gnattu! diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift b/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift index 895e67a59c..9962f616fa 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift @@ -1,11 +1,9 @@ import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif + +#if os(macOS) #if canImport(SQLite3) import SQLite3 -#elseif canImport(CSQLite3) -import CSQLite3 +#endif #endif #if os(macOS) || os(Linux) @@ -102,7 +100,9 @@ struct CursorSessionIdentity: Equatable, Sendable { return value.lowercased() } } +#endif +#if os(macOS) struct CursorAppAuthSession: Equatable, Sendable { static let persistedCookieMarker = "CodexBar Cursor.app local auth" diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index 295d9709f8..d0073a3477 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -94,7 +94,7 @@ public enum CursorProviderDescriptor { supportsCostCommand: self.supportsCostCommand, browserSupportExemption: { _, _, settings in #if os(Linux) - // Linux uses Cursor app auth and manual cookies; browser import remains macOS-only. + // Linux supports manual cookies; browser and Cursor.app imports remain macOS-only. settings?.cursor?.cookieSource != .off #else false diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift index 30e1e84654..0bc06fd942 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe+SessionResolution.swift @@ -1,4 +1,7 @@ import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif #if os(macOS) || os(Linux) extension CursorStatusProbe { @@ -15,6 +18,7 @@ extension CursorStatusProbe { case resumeFallback } + #if os(macOS) private struct AppSessionFetchContext { let cachedEntry: CookieHeaderCache.Entry? let storedCookies: [HTTPCookie] @@ -27,6 +31,7 @@ extension CursorStatusProbe { case succeeded(Value) case resumeFallback(storedCookies: [HTTPCookie]) } + #endif /// Resolve a working Cursor session, preserving selected-account and cache-ownership rules. func resolveSession( @@ -51,6 +56,7 @@ extension CursorStatusProbe { coordinator: self.conditionalMutationCoordinator) let cachedEntry = allowCachedSessions ? CookieHeaderCache.load(provider: .cursor) : nil var storedCookies = allowCachedSessions ? await CursorSessionStore.shared.getCookies() : [] + #if os(macOS) if !allowAppAuthFallback { storedCookies.removeAll(where: CursorAppAuthSession.isPersistedCookie) } @@ -71,16 +77,16 @@ extension CursorStatusProbe { storedCookies = updatedStoredCookies } } + #endif if allowCachedSessions, let cached = cachedEntry, !cached.cookieHeader.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + #if os(macOS) if cached.sourceLabel == Self.appAuthSourceLabel { if CookieHeaderCache.clearIfCurrent(provider: .cursor, expected: cached) { - #if os(macOS) cacheObservation = cacheObservation.afterOwnedClear() - #endif } } else { let context = CachedSessionFetchContext( @@ -93,11 +99,23 @@ extension CursorStatusProbe { case let .succeeded(value): return value case .resumeFallback: - #if os(macOS) cacheObservation = cacheObservation.afterOwnedClear() - #endif } } + #else + let context = CachedSessionFetchContext( + cookieHeaderOverride: cookieHeaderOverride, + allowAppAuthFallback: allowAppAuthFallback, + logger: logger, + log: log, + perform: perform) + switch try await self.fetchCachedSession(cached, context: context) { + case let .succeeded(value): + return value + case .resumeFallback: + break + } + #endif } #if os(macOS) @@ -198,6 +216,7 @@ extension CursorStatusProbe { #endif } + #if os(macOS) private static let appAuthSourceLabel = "Cursor.app local auth" private func fetchPreferredAppSession( @@ -241,7 +260,6 @@ extension CursorStatusProbe { do { let value = try await context.perform(cookieHeader, appIdentity) await self.persistAppAuthSession(appSession) - #if os(macOS) let reconciliation = ResolvedSessionReconciliationContext( cookieHeader: cookieHeader, sourceLabel: Self.appAuthSourceLabel, @@ -250,9 +268,6 @@ extension CursorStatusProbe { log: context.log) let reconciled = try await self.reconcileResolvedSession(value: value, context: reconciliation) return .succeeded(reconciled) - #else - return .succeeded(value) - #endif } catch let error as CursorStatusProbeError { guard case .notLoggedIn = error else { throw error } context.log("Cursor.app local auth was rejected; falling back to browser cookies") @@ -311,6 +326,7 @@ extension CursorStatusProbe { CodexBarLog.logger(LogCategories.provider(.cursor)).warning(message) log(message) } + #endif private func fetchCachedSession( _ cached: CookieHeaderCache.Entry, diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index cef227a552..db3ab1df44 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -627,8 +627,8 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { #if os(macOS) "Not logged in to Cursor. Please log in via the CodexBar menu." #else - "Not logged in to Cursor. Sign in to the Cursor app on this machine or paste a Cookie header copied " - + "from cursor.com into ~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." + "Not logged in to Cursor. Paste a Cookie header copied from cursor.com into " + + "~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." #endif case let .networkError(msg): "Cursor API error: \(msg)" @@ -640,8 +640,8 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { + "Please log in to cursor.com in \(cursorCookieImportOrder.loginHint). " + "You can also sign in to Cursor from the CodexBar menu (Add / switch account)." #else - "No Cursor session found. Sign in to the Cursor app on this machine or paste a Cookie header copied " - + "from cursor.com into ~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." + "No Cursor session found. Paste a Cookie header copied from cursor.com into " + + "~/.config/codexbar/config.json (legacy: ~/.codexbar/config.json)." #endif } } @@ -674,10 +674,12 @@ public actor CursorSessionStore { self.saveToDisk() } + #if os(macOS) func persistAppSession(_ session: CursorAppAuthSession) { guard let cookie = try? session.makeCookie() else { return } self.setCookies([cookie]) } + #endif public func getCookies() -> [HTTPCookie] { self.loadFromDiskIfNeeded() @@ -826,8 +828,10 @@ public struct CursorStatusProbe: Sendable { let browserDetection: BrowserDetection let browserCookieImportOrder: BrowserCookieImportOrder private let urlSession: any ProviderHTTPTransport + #if os(macOS) let appAuthStore: any CursorAppAuthSessionProviding let persistAppAuthSession: @Sendable (CursorAppAuthSession) async -> Void + #endif let conditionalMutationCoordinator: CookieHeaderCache.ConditionalMutationCoordinator public init( @@ -836,6 +840,7 @@ public struct CursorStatusProbe: Sendable { browserDetection: BrowserDetection, urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared) { + #if os(macOS) self.init( baseURL: baseURL, timeout: timeout, @@ -847,6 +852,15 @@ public struct CursorStatusProbe: Sendable { await CursorSessionStore.shared.persistAppSession(session) }, conditionalMutationCoordinator: .shared) + #else + self.init( + baseURL: baseURL, + timeout: timeout, + browserDetection: browserDetection, + browserCookieImportOrder: Self.defaultBrowserCookieImportOrder, + urlSession: urlSession, + conditionalMutationCoordinator: .shared) + #endif } package init( @@ -856,6 +870,7 @@ public struct CursorStatusProbe: Sendable { urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared, conditionalMutationCoordinator: CookieHeaderCache.ConditionalMutationCoordinator) { + #if os(macOS) self.init( baseURL: baseURL, timeout: timeout, @@ -867,8 +882,18 @@ public struct CursorStatusProbe: Sendable { await CursorSessionStore.shared.persistAppSession(session) }, conditionalMutationCoordinator: conditionalMutationCoordinator) + #else + self.init( + baseURL: baseURL, + timeout: timeout, + browserDetection: browserDetection, + browserCookieImportOrder: Self.defaultBrowserCookieImportOrder, + urlSession: urlSession, + conditionalMutationCoordinator: conditionalMutationCoordinator) + #endif } + #if os(macOS) init( baseURL: URL = URL(string: "https://cursor.com")!, timeout: TimeInterval = 15.0, @@ -895,6 +920,23 @@ public struct CursorStatusProbe: Sendable { session.cookieHeader(), identityFallback: session.identity) } + #else + init( + baseURL: URL = URL(string: "https://cursor.com")!, + timeout: TimeInterval = 15.0, + browserDetection: BrowserDetection, + browserCookieImportOrder: BrowserCookieImportOrder = Self.defaultBrowserCookieImportOrder, + urlSession: any ProviderHTTPTransport = ProviderHTTPClient.shared, + conditionalMutationCoordinator: CookieHeaderCache.ConditionalMutationCoordinator = .shared) + { + self.baseURL = baseURL + self.timeout = timeout + self.browserDetection = browserDetection + self.browserCookieImportOrder = browserCookieImportOrder + self.urlSession = urlSession + self.conditionalMutationCoordinator = conditionalMutationCoordinator + } + #endif /// Fetch Cursor usage with manual cookie header (for debugging). public func fetchWithManualCookies(_ cookieHeader: String) async throws -> CursorStatusSnapshot { diff --git a/docs/cursor.md b/docs/cursor.md index ff3fff4514..717e3e96c7 100644 --- a/docs/cursor.md +++ b/docs/cursor.md @@ -8,7 +8,7 @@ read_when: # Cursor provider -Cursor can reuse Cursor.app's local session or a cursor.com browser session. Automatic mode prefers a usable +On macOS, Cursor can reuse Cursor.app's local session or a cursor.com browser session. Automatic mode prefers a usable Cursor.app session and falls back to cookies when the app token is missing, expired, invalid, or rejected. ## Data sources + fallback order @@ -18,7 +18,6 @@ Cursor.app session and falls back to cookies when the app token is missing, expi - Files consulted by SQLite: - macOS main DB: `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` - Active WAL sidecars when present: `state.vscdb-wal` and `state.vscdb-shm` - - Linux main DB: `$XDG_CONFIG_HOME/Cursor/User/globalStorage/state.vscdb` (default `~/.config/Cursor/...`) - The database is opened read-only. Active WAL state is read normally; an idle WAL-mode main file with no sidecars uses SQLite immutable mode so CodexBar does not recreate files in Cursor's directory. - The token is used only while its JWT expiry is more than 60 seconds away. CodexBar never refreshes it. @@ -43,7 +42,7 @@ Cursor.app session and falls back to cookies when the app token is missing, expi - Legacy sessions captured by older CodexBar releases remain readable. - Stored at: `~/Library/Application Support/CodexBar/cursor-session.json`. -Explicit `--source web` skips Cursor.app local auth and uses only the cookie ladder. A configured Manual cookie +On macOS, explicit `--source web` skips Cursor.app local auth and uses only the cookie ladder. A configured Manual cookie header remains an explicit override. `codexbar usage --provider cursor --source auto --verbose` prints the selected automatic path and is the quickest live-read check after Cursor login. @@ -77,8 +76,7 @@ Manual option: - Firefox: `~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite` ## Linux CLI -- `codexbar usage --provider cursor` reads the signed-in Cursor app's access token from the Linux global state DB and reuses the same `cursor.com` usage endpoints as macOS. -- Automatic browser cookie import and the external-browser Add/Switch flow are macOS app features. +- Cursor.app session import, automatic browser cookie import, and the external-browser Add/Switch flow are macOS app features. - Manual cookie headers from `~/.config/codexbar/config.json` (or legacy `~/.codexbar/config.json`) work on Linux. ## Local storage footprint From 3c4bc06c1e8a552f83491f4e3a8999c8e8b2d910 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 21:27:39 -0700 Subject: [PATCH 4/4] test: update Cursor Linux platform coverage --- .../Cursor/CursorProviderDescriptor.swift | 3 +- TestsLinux/CursorLinuxTests.swift | 39 +++++++------------ 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift index d0073a3477..88977a03e8 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorProviderDescriptor.swift @@ -95,7 +95,8 @@ public enum CursorProviderDescriptor { browserSupportExemption: { _, _, settings in #if os(Linux) // Linux supports manual cookies; browser and Cursor.app imports remain macOS-only. - settings?.cursor?.cookieSource != .off + settings?.cursor?.cookieSource == .manual && + CookieHeaderNormalizer.normalize(settings?.cursor?.manualCookieHeader) != nil #else false #endif diff --git a/TestsLinux/CursorLinuxTests.swift b/TestsLinux/CursorLinuxTests.swift index f78acb1ba5..ba2ea364a1 100644 --- a/TestsLinux/CursorLinuxTests.swift +++ b/TestsLinux/CursorLinuxTests.swift @@ -6,32 +6,8 @@ import Testing struct CursorLinuxTests { @Test - func `Cursor database path honors absolute XDG config home`() { - let path = CursorAppAuthStore.resolveDefaultDBPath( - home: "/home/test", - environment: ["XDG_CONFIG_HOME": "/custom/config"]) - #expect(path == "/custom/config/Cursor/User/globalStorage/state.vscdb") - } - - @Test - func `Cursor database path falls back to dot config`() { - let path = CursorAppAuthStore.resolveDefaultDBPath( - home: "/home/test", - environment: [:]) - #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") - } - - @Test - func `Cursor database path rejects relative XDG config home`() { - let path = CursorAppAuthStore.resolveDefaultDBPath( - home: "/home/test", - environment: ["XDG_CONFIG_HOME": "relative/config"]) - #expect(path == "/home/test/.config/Cursor/User/globalStorage/state.vscdb") - } - - @Test - func `Cursor automatic source does not require macOS web support`() { - #expect(!CodexBarCLI.sourceModeRequiresWebSupport( + func `Cursor automatic source without manual cookies requires macOS web support`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( .auto, provider: .cursor, settings: ProviderSettingsSnapshot.make( @@ -54,6 +30,17 @@ struct CursorLinuxTests { manualCookieHeader: "WorkosCursorSessionToken=test")))) } + @Test + func `empty Cursor manual cookie still requires macOS web support`() { + #expect(CodexBarCLI.sourceModeRequiresWebSupport( + .web, + provider: .cursor, + settings: ProviderSettingsSnapshot.make( + cursor: .init( + cookieSource: .manual, + manualCookieHeader: " ")))) + } + @Test func `disabled Cursor web source still requires macOS web support`() { #expect(CodexBarCLI.sourceModeRequiresWebSupport(