From 49afa43c77469d11965be3575319ff1756319b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20P=C3=A9rez?= Date: Sat, 14 Feb 2026 11:24:09 -0300 Subject: [PATCH] Fix Arc Cursor cookie import and errors --- .../Providers/Cursor/CursorStatusProbe.swift | 329 +++++++++++++++++- .../CursorStatusProbeTests.swift | 44 +++ 2 files changed, 372 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift index 84cca3c3d..7327bfe97 100644 --- a/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Cursor/CursorStatusProbe.swift @@ -1,5 +1,10 @@ import Foundation import SweetCookieKit +#if os(macOS) +import CommonCrypto +import Security +import SQLite3 +#endif #if os(macOS) @@ -15,6 +20,11 @@ public enum CursorCookieImporter { "WorkosCursorSessionToken", "__Secure-next-auth.session-token", "next-auth.session-token", + "session", + "__recent_auth", + "__wuid", + "workos_id", + "__kduid", ] public struct SessionInfo: Sendable { @@ -37,6 +47,7 @@ public enum CursorCookieImporter { logger: ((String) -> Void)? = nil) throws -> SessionInfo { let log: (String) -> Void = { msg in logger?("[cursor-cookie] \(msg)") } + var sawAccessDenied = false // Filter to cookie-eligible browsers to avoid unnecessary keychain prompts let installedBrowsers = cursorCookieImportOrder.cookieImportCandidates(using: browserDetection) @@ -44,13 +55,24 @@ public enum CursorCookieImporter { for browserSource in installedBrowsers { do { let query = BrowserCookieQuery(domains: cookieDomains) + if Self.usesArcSpecificChromiumImport(browserSource) { + if let arcSession = try Self.importArcChromiumSession( + browser: browserSource, + query: query, + logger: log) + { + log("Found \(arcSession.cookies.count) Cursor cookies in \(arcSession.sourceLabel)") + return arcSession + } + continue + } let sources = try Self.cookieClient.records( matching: query, in: browserSource, logger: log) for source in sources where !source.records.isEmpty { let httpCookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin) - if httpCookies.contains(where: { Self.sessionCookieNames.contains($0.name) }) { + if Self.hasLikelySessionCookie(in: httpCookies) { log("Found \(httpCookies.count) Cursor cookies in \(source.label)") return SessionInfo(cookies: httpCookies, sourceLabel: source.label) } else { @@ -59,10 +81,19 @@ public enum CursorCookieImporter { } } catch { BrowserCookieAccessGate.recordIfNeeded(error) + if let browserCookieError = error as? BrowserCookieError, + case .accessDenied = browserCookieError + { + sawAccessDenied = true + } log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)") } } + if sawAccessDenied { + throw CursorStatusProbeError.browserCookieAccessDenied + } + throw CursorStatusProbeError.noSessionCookie } @@ -75,6 +106,291 @@ public enum CursorCookieImporter { return false } } + + static func hasLikelySessionCookie(in cookies: [HTTPCookie]) -> Bool { + cookies.contains { cookie in + self.sessionCookieNames.contains(cookie.name) + } + } + + private static func usesArcSpecificChromiumImport(_ browser: Browser) -> Bool { + switch browser { + case .arc, .arcBeta, .arcCanary: + true + default: + false + } + } + + private static func importArcChromiumSession( + browser: Browser, + query: BrowserCookieQuery, + logger: ((String) -> Void)?) throws -> SessionInfo? + { + let stores = Self.cookieClient + .stores(for: browser) + .filter { $0.browser == browser && $0.databaseURL != nil } + + guard !stores.isEmpty else { return nil } + + let key = try Self.arcSafeStorageKey(for: browser) + for store in stores { + guard let databaseURL = store.databaseURL else { continue } + let records = try Self.readChromiumCookieRecords( + from: databaseURL, + browser: browser, + query: query, + key: key) + guard !records.isEmpty else { continue } + let cookies = BrowserCookieClient.makeHTTPCookies(records, origin: query.origin) + if Self.hasLikelySessionCookie(in: cookies) { + return SessionInfo(cookies: cookies, sourceLabel: store.label) + } + logger?("\(store.label) cookies found, but no Cursor session cookie present") + } + return nil + } + + private static func arcSafeStorageKey(for browser: Browser) throws -> Data { + let labels = browser.safeStorageLabels + for label in labels { + if let password = Self.findGenericPassword(service: label.service, account: label.account), + let key = Self.deriveChromiumKey(password: password) + { + return key + } + } + + throw BrowserCookieError.accessDenied( + browser: browser, + details: "\(browser.displayName) Safe Storage keychain item is not accessible.") + } + + private static func findGenericPassword(service: String, account: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func deriveChromiumKey(password: String) -> Data? { + let salt = Data("saltysalt".utf8) + var key = Data(count: kCCKeySizeAES128) + let keyLength = key.count + let status = key.withUnsafeMutableBytes { keyBytes in + password.utf8CString.withUnsafeBytes { passBytes in + salt.withUnsafeBytes { saltBytes in + CCKeyDerivationPBKDF( + CCPBKDFAlgorithm(kCCPBKDF2), + passBytes.bindMemory(to: Int8.self).baseAddress, + passBytes.count - 1, + saltBytes.bindMemory(to: UInt8.self).baseAddress, + salt.count, + CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1), + 1003, + keyBytes.bindMemory(to: UInt8.self).baseAddress, + keyLength) + } + } + } + guard status == kCCSuccess else { return nil } + return key + } + + private static func readChromiumCookieRecords( + from sourceDB: URL, + browser: Browser, + query: BrowserCookieQuery, + key: Data) throws -> [BrowserCookieRecord] + { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-cursor-arc-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let copiedDB = tempDir.appendingPathComponent("Cookies") + try FileManager.default.copyItem(at: sourceDB, to: copiedDB) + for suffix in ["-wal", "-shm"] { + let sourceSidecar = URL(fileURLWithPath: sourceDB.path + suffix) + guard FileManager.default.fileExists(atPath: sourceSidecar.path) else { continue } + let copiedSidecar = URL(fileURLWithPath: copiedDB.path + suffix) + try? FileManager.default.copyItem(at: sourceSidecar, to: copiedSidecar) + } + + var db: OpaquePointer? + guard sqlite3_open_v2(copiedDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + throw BrowserCookieError.loadFailed( + browser: browser, + details: "Failed to open \(browser.displayName) cookies database.") + } + defer { sqlite3_close(db) } + + let sql = """ + SELECT host_key, name, path, expires_utc, is_secure, is_httponly, value, encrypted_value + FROM cookies + WHERE host_key LIKE '%cursor.com%' OR host_key LIKE '%cursor.sh%' + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { + throw BrowserCookieError.loadFailed( + browser: browser, + details: "Failed to query \(browser.displayName) cookies database.") + } + defer { sqlite3_finalize(statement) } + + var records: [BrowserCookieRecord] = [] + while sqlite3_step(statement) == SQLITE_ROW { + let hostKey = Self.sqliteText(statement, index: 0) ?? "" + guard Self.hostMatchesCursorDomains(hostKey, patterns: query.domains) else { continue } + + guard let name = Self.sqliteText(statement, index: 1), + let path = Self.sqliteText(statement, index: 2) + else { continue } + + let expiresUTC = sqlite3_column_int64(statement, 3) + let isSecure = sqlite3_column_int(statement, 4) != 0 + let isHTTPOnly = sqlite3_column_int(statement, 5) != 0 + let plainValue = Self.sqliteText(statement, index: 6) + let encryptedValue = Self.sqliteBlob(statement, index: 7) + + let value: String + if let plainValue, !plainValue.isEmpty { + value = plainValue + } else if let encryptedValue, + let decrypted = Self.decryptChromiumV10Value( + encryptedValue, + key: key, + hostKey: hostKey), + !decrypted.isEmpty + { + value = decrypted + } else { + continue + } + + let expires = Self.chromiumExpiryDate(expiresUTC: expiresUTC) + if !query.includeExpired, let expires, expires < query.referenceDate { + continue + } + + records.append(BrowserCookieRecord( + domain: Self.normalizedDomain(hostKey), + name: name, + path: path, + value: value, + expires: expires, + isSecure: isSecure, + isHTTPOnly: isHTTPOnly)) + } + + return records + } + + private static func decryptChromiumV10Value( + _ encryptedValue: Data, + key: Data, + hostKey: String) -> String? + { + guard encryptedValue.count > 3 else { return nil } + guard String(data: encryptedValue.prefix(3), encoding: .utf8) == "v10" else { return nil } + + let payload = encryptedValue.dropFirst(3) + let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) + var output = Data(count: payload.count + kCCBlockSizeAES128) + var outputLength: size_t = 0 + let outputCapacity = output.count + + let status = output.withUnsafeMutableBytes { outputBytes in + payload.withUnsafeBytes { inputBytes in + key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + CCCrypt( + CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), + CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, + key.count, + ivBytes.baseAddress, + inputBytes.baseAddress, + payload.count, + outputBytes.baseAddress, + outputCapacity, + &outputLength) + } + } + } + } + + guard status == kCCSuccess else { return nil } + output.count = outputLength + + if output.count > 32 { + let digest = Self.sha256(Data(hostKey.utf8)) + if output.prefix(32) == digest { + return String(data: output.dropFirst(32), encoding: .utf8) + } + } + + return String(data: output, encoding: .utf8) + } + + private static func chromiumExpiryDate(expiresUTC: Int64) -> Date? { + guard expiresUTC > 0 else { return nil } + let seconds = (Double(expiresUTC) / 1_000_000.0) - 11_644_473_600.0 + guard seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + private static func hostMatchesCursorDomains(_ hostKey: String, patterns: [String]) -> Bool { + let host = Self.normalizedDomain(hostKey).lowercased() + let normalizedPatterns = patterns.map { Self.normalizedDomain($0).lowercased() } + guard !normalizedPatterns.isEmpty else { return true } + return normalizedPatterns.contains { pattern in + host == pattern || host.hasSuffix("." + pattern) + } + } + + private static func normalizedDomain(_ domain: String) -> String { + let trimmed = domain.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix(".") { + return String(trimmed.dropFirst()) + } + return trimmed + } + + private static func sqliteText(_ statement: OpaquePointer?, index: Int32) -> String? { + guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil } + guard let text = sqlite3_column_text(statement, index) else { return nil } + return String(cString: text) + } + + private static func sqliteBlob(_ statement: OpaquePointer?, index: Int32) -> Data? { + guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil } + guard let bytes = sqlite3_column_blob(statement, index) else { return nil } + let count = Int(sqlite3_column_bytes(statement, index)) + return Data(bytes: bytes, count: count) + } + + private static func sha256(_ data: Data) -> Data { + var digest = Data(count: Int(CC_SHA256_DIGEST_LENGTH)) + digest.withUnsafeMutableBytes { digestBytes in + data.withUnsafeBytes { dataBytes in + _ = CC_SHA256( + dataBytes.baseAddress, + CC_LONG(data.count), + digestBytes.bindMemory(to: UInt8.self).baseAddress) + } + } + return digest + } } // MARK: - Cursor API Models @@ -346,6 +662,7 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { case notLoggedIn case networkError(String) case parseFailed(String) + case browserCookieAccessDenied case noSessionCookie public var errorDescription: String? { @@ -356,6 +673,8 @@ public enum CursorStatusProbeError: LocalizedError, Sendable { "Cursor API error: \(msg)" case let .parseFailed(msg): "Could not parse Cursor usage: \(msg)" + case .browserCookieAccessDenied: + "Could not access browser cookies. Allow Keychain access when prompted, then try again." case .noSessionCookie: "No Cursor session found. Please log in to cursor.com in \(cursorCookieImportOrder.loginHint)." } @@ -511,6 +830,7 @@ public struct CursorStatusProbe: Sendable { async throws -> CursorStatusSnapshot { let log: (String) -> Void = { msg in logger?("[cursor] \(msg)") } + var browserImportError: CursorStatusProbeError? if let override = CookieHeaderNormalizer.normalize(cookieHeaderOverride) { log("Using manual cookie header") @@ -544,6 +864,9 @@ public struct CursorStatusProbe: Sendable { cookieHeader: session.cookieHeader, sourceLabel: session.sourceLabel) return snapshot + } catch let error as CursorStatusProbeError { + browserImportError = error + log("Browser cookie import failed: \(error.localizedDescription)") } catch { log("Browser cookie import failed: \(error.localizedDescription)") } @@ -566,6 +889,10 @@ public struct CursorStatusProbe: Sendable { } } + if let browserImportError { + throw browserImportError + } + throw CursorStatusProbeError.noSessionCookie } diff --git a/Tests/CodexBarTests/CursorStatusProbeTests.swift b/Tests/CodexBarTests/CursorStatusProbeTests.swift index b92ec265e..34fdab707 100644 --- a/Tests/CodexBarTests/CursorStatusProbeTests.swift +++ b/Tests/CodexBarTests/CursorStatusProbeTests.swift @@ -117,6 +117,50 @@ struct CursorStatusProbeTests { #expect(userInfo.sub == "auth0|12345") } + @Test + func detectsLikelySessionCookieNames() throws { + let names = [ + "WorkosCursorSessionToken", + "__Secure-next-auth.session-token", + "next-auth.session-token", + "session", + "__recent_auth", + "__wuid", + "workos_id", + "__kduid", + ] + + for name in names { + let cookie = try #require(HTTPCookie(properties: [ + .name: name, + .value: "value", + .domain: "cursor.com", + .path: "/", + ])) + #expect(CursorCookieImporter.hasLikelySessionCookie(in: [cookie]) == true) + } + } + + @Test + func ignoresNonSessionCookieNames() throws { + let names = [ + "_ga", + "_fbp", + "cursor_anonymous_id", + "_forum_session", + ] + + for name in names { + let cookie = try #require(HTTPCookie(properties: [ + .name: name, + .value: "value", + .domain: "cursor.com", + .path: "/", + ])) + #expect(CursorCookieImporter.hasLikelySessionCookie(in: [cookie]) == false) + } + } + // MARK: - Snapshot Conversion @Test