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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.49.7 — Unreleased

### Added
- 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!

Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,13 @@ extension UsageMenuCardView.Model {
if let email = snapshot?.accountEmail(for: provider), !email.isEmpty {
return email
}
// Provider-specific by design: Cursor app auth can expose only a subject ID, so its card needs this fallback.
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
{
Expand Down
334 changes: 334 additions & 0 deletions Sources/CodexBarCore/Providers/Cursor/CursorAppAuth.swift
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Import FoundationNetworking for Linux app-auth builds

When this file is compiled on Linux, HTTPCookie and HTTPCookiePropertyKey are provided by FoundationNetworking rather than Foundation; after moving app-auth code out of CursorStatusProbe.swift, the conditional import FoundationNetworking no longer covers this reference. The Linux CodexBarCore/CLI build will fail with cannot find type 'HTTPCookie' in scope, so add the same conditional import to this file.

Useful? React with 👍 / 👎.

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
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ 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.
settings?.cursor?.cookieSource != .off
// Linux supports manual cookies; browser and Cursor.app imports remain macOS-only.
settings?.cursor?.cookieSource == .manual &&
CookieHeaderNormalizer.normalize(settings?.cursor?.manualCookieHeader) != nil
#else
false
#endif
Expand Down Expand Up @@ -148,7 +149,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.provider(.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")
Expand Down
Loading