-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Prefer Cursor app sessions in Automatic mode #2598
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
0d28d71
feat: prefer Cursor app sessions
dea9085
test: stabilize Codex credits coalescing
cdb9b37
fix: prefer Cursor app sessions
steipete fd5d4f2
fix: gate Cursor app sessions to macOS
steipete 3c4bc06
test: update Cursor Linux platform coverage
steipete File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
334 changes: 334 additions & 0 deletions
334
Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,334 @@ | ||
| import Foundation | ||
|
|
||
| #if os(macOS) | ||
| #if canImport(SQLite3) | ||
| import SQLite3 | ||
| #endif | ||
| #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() | ||
| } | ||
| } | ||
| #endif | ||
|
|
||
| #if os(macOS) | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this file is compiled on Linux,
HTTPCookieandHTTPCookiePropertyKeyare provided by FoundationNetworking rather than Foundation; after moving app-auth code out ofCursorStatusProbe.swift, the conditionalimport FoundationNetworkingno longer covers this reference. The Linux CodexBarCore/CLI build will fail withcannot find type 'HTTPCookie' in scope, so add the same conditional import to this file.Useful? React with 👍 / 👎.