-
Notifications
You must be signed in to change notification settings - Fork 1.4k
MiniMax: Token Plan recharge credits and web session enrichment #1981
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
Closed
Yuxin-Qiao
wants to merge
7
commits into
steipete:main
from
Yuxin-Qiao:feat/minimax-credit-web-session
Closed
Changes from 6 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
4e3e744
MiniMax: fetch Token Plan recharge credits via web session.
Yuxin-Qiao 4232e48
Align browser gate tests with upstream explicit-retry behavior.
Yuxin-Qiao 45599ee
Restore Codex empty-credits guard in menu card costs.
Yuxin-Qiao 814add1
Align MiniMax web fetch tests with credit-only PR scope.
Yuxin-Qiao 2b6d15e
Drop usage_summary expectation from credit-only web fetch test.
Yuxin-Qiao 0f39934
Fix MiniMax token-plan inactive status surfacing
Yuxin-Qiao 3f42c35
Soft-fail inactive MiniMax token plan replies.
Yuxin-Qiao 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
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
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
272 changes: 272 additions & 0 deletions
272
Sources/CodexBarCore/Providers/MiniMax/MiniMaxDesktopCookieImporter.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,272 @@ | ||
| import Foundation | ||
| #if os(macOS) | ||
| import CommonCrypto | ||
| import Security | ||
| import SQLite3 | ||
|
|
||
| enum MiniMaxDesktopCookieImporter { | ||
| private static let log = CodexBarLog.logger(LogCategories.minimaxCookie) | ||
| private static let sourceLabel = "MiniMax Agent" | ||
| private static let webCookieHosts: Set<String> = [ | ||
| "www.minimaxi.com", | ||
| "www.minimax.io", | ||
| "platform.minimaxi.com", | ||
| "platform.minimax.io", | ||
| ] | ||
| private static let safeStorageLabels: [(service: String, account: String)] = [ | ||
| ("MiniMax Safe Storage", "MiniMax"), | ||
| ("Chromium Safe Storage", "MiniMax"), | ||
| ] | ||
|
|
||
| static func cookiesDatabaseURL(fileManager: FileManager = .default) -> URL { | ||
| fileManager.homeDirectoryForCurrentUser | ||
| .appendingPathComponent("Library/Application Support/MiniMax/Cookies") | ||
| } | ||
|
|
||
| static func importSession( | ||
| databaseURL: URL? = nil, | ||
| fileManager: FileManager = .default, | ||
| decryptionKeys: [Data]? = nil) -> MiniMaxCookieImporter.SessionInfo? | ||
| { | ||
| let url = databaseURL ?? self.cookiesDatabaseURL(fileManager: fileManager) | ||
| guard fileManager.isReadableFile(atPath: url.path) else { return nil } | ||
| do { | ||
| let records = try self.loadRecords(from: url, decryptionKeys: decryptionKeys) | ||
| guard !records.isEmpty else { return nil } | ||
| let cookies = self.makeHTTPCookies(from: records) | ||
| guard !cookies.isEmpty else { return nil } | ||
| self.log.debug( | ||
| "Imported MiniMax desktop cookies", | ||
| metadata: ["count": "\(cookies.count)", "names": self.cookieNames(from: cookies)]) | ||
| return MiniMaxCookieImporter.SessionInfo(cookies: cookies, sourceLabel: self.sourceLabel) | ||
| } catch { | ||
| self.log.debug("MiniMax desktop cookie import failed: \(error.localizedDescription)") | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| private struct Record { | ||
| let domain: String | ||
| let name: String | ||
| let value: String | ||
| } | ||
|
|
||
| private static func loadRecords(from url: URL, decryptionKeys: [Data]? = nil) throws -> [Record] { | ||
| var db: OpaquePointer? | ||
| guard sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { | ||
| let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" | ||
| sqlite3_close(db) | ||
| throw MiniMaxDesktopCookieImportError.sqliteFailed(message) | ||
| } | ||
| defer { sqlite3_close(db) } | ||
| sqlite3_busy_timeout(db, 250) | ||
|
|
||
| let sql = """ | ||
| SELECT host_key, name, value, encrypted_value | ||
| FROM cookies | ||
| WHERE host_key LIKE '%minimax%' | ||
| """ | ||
| var stmt: OpaquePointer? | ||
| guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { | ||
| let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" | ||
| throw MiniMaxDesktopCookieImportError.sqliteFailed(message) | ||
| } | ||
| defer { sqlite3_finalize(stmt) } | ||
|
|
||
| var records: [Record] = [] | ||
| while sqlite3_step(stmt) == SQLITE_ROW { | ||
| guard let domain = self.columnText(stmt, index: 0), | ||
| let name = self.columnText(stmt, index: 1), | ||
| self.matchesMiniMaxDomain(domain) | ||
| else { | ||
| continue | ||
| } | ||
| let plain = self.columnText(stmt, index: 2) ?? "" | ||
| let value: String? = if !plain.isEmpty { | ||
| plain | ||
| } else if let encrypted = self.readBlob(stmt, index: 3) { | ||
| self.decrypt(encrypted, keys: decryptionKeys ?? self.derivedKeys()) | ||
| } else { | ||
| nil | ||
| } | ||
| guard let value, !value.isEmpty else { continue } | ||
| records.append(Record(domain: domain, name: name, value: value)) | ||
| } | ||
| return self.deduplicated(records) | ||
| } | ||
|
|
||
| private static func deduplicated(_ records: [Record]) -> [Record] { | ||
| var merged: [String: Record] = [:] | ||
| for record in records { | ||
| let key = "\(record.name)|\(record.domain)" | ||
| merged[key] = record | ||
| } | ||
| return Array(merged.values).sorted { | ||
| if $0.name == $1.name { return $0.domain < $1.domain } | ||
| if $0.name == "_token" { return true } | ||
| if $1.name == "_token" { return false } | ||
| return $0.name < $1.name | ||
| } | ||
| } | ||
|
|
||
| private static func matchesMiniMaxDomain(_ domain: String) -> Bool { | ||
| let normalized = domain.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| .trimmingCharacters(in: CharacterSet(charactersIn: ".")) | ||
| .lowercased() | ||
| if self.webCookieHosts.contains(normalized) { return true } | ||
| return normalized == "minimaxi.com" || normalized == "minimax.io" | ||
| } | ||
|
|
||
| private static func makeHTTPCookies(from records: [Record]) -> [HTTPCookie] { | ||
| records.compactMap { record in | ||
| let domain = record.domain.hasPrefix(".") ? String(record.domain.dropFirst()) : record.domain | ||
| guard let cookie = HTTPCookie(properties: [ | ||
| .domain: domain, | ||
| .name: record.name, | ||
| .path: "/", | ||
| .value: record.value, | ||
| .secure: "TRUE", | ||
| ]) else { | ||
| return nil | ||
| } | ||
| return cookie | ||
| } | ||
| } | ||
|
|
||
| private static func cookieNames(from cookies: [HTTPCookie]) -> String { | ||
| cookies.map { "\($0.name)@\($0.domain)" }.sorted().joined(separator: ", ") | ||
| } | ||
|
|
||
| private static func columnText(_ stmt: OpaquePointer?, index: Int32) -> String? { | ||
| guard sqlite3_column_type(stmt, index) != SQLITE_NULL, | ||
| let value = sqlite3_column_text(stmt, index) | ||
| else { | ||
| return nil | ||
| } | ||
| return String(cString: value) | ||
| } | ||
|
|
||
| private static func readBlob(_ stmt: OpaquePointer?, index: Int32) -> Data? { | ||
| guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil } | ||
| let length = Int(sqlite3_column_bytes(stmt, index)) | ||
| guard length > 0, let bytes = sqlite3_column_blob(stmt, index) else { return nil } | ||
| return Data(bytes: bytes, count: length) | ||
| } | ||
|
|
||
| private static func derivedKeys() -> [Data] { | ||
| var keys: [Data] = [] | ||
| for label in self.safeStorageLabels { | ||
| if let password = self.safeStoragePassword(service: label.service, account: label.account) { | ||
| keys.append(self.deriveKey(from: password)) | ||
| } | ||
| } | ||
| return keys | ||
| } | ||
|
|
||
| private static func safeStoragePassword(service: String, account: String) -> String? { | ||
| var query: [String: Any] = [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: account, | ||
| kSecMatchLimit as String: kSecMatchLimitOne, | ||
| kSecReturnData as String: true, | ||
| ] | ||
| KeychainNoUIQuery.apply(to: &query) | ||
|
|
||
| var result: AnyObject? | ||
| let status = KeychainSecurity.copyMatching(query as CFDictionary, &result) | ||
| guard status == errSecSuccess, let data = result as? Data else { return nil } | ||
| return String(data: data, encoding: .utf8) | ||
| } | ||
|
|
||
| private static func deriveKey(from password: String) -> Data { | ||
| let salt = Data("saltysalt".utf8) | ||
| var key = Data(count: kCCKeySizeAES128) | ||
| let keyLength = key.count | ||
| _ = 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) | ||
| } | ||
| } | ||
| } | ||
| return key | ||
| } | ||
|
|
||
| private static func decrypt(_ encryptedValue: Data, keys: [Data]) -> String? { | ||
| for key in keys { | ||
| if let value = self.decrypt(encryptedValue, key: key) { | ||
| return value | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private static func decrypt(_ encryptedValue: Data, key: Data) -> String? { | ||
| guard encryptedValue.count > 3 else { return nil } | ||
| let prefix = String(data: encryptedValue.prefix(3), encoding: .utf8) | ||
| guard prefix == "v10" else { return nil } | ||
|
|
||
| let payload = Data(encryptedValue.dropFirst(3)) | ||
| let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128) | ||
| var outLength = 0 | ||
| var out = Data(count: payload.count + kCCBlockSizeAES128) | ||
| let outCapacity = out.count | ||
|
|
||
| let status = out.withUnsafeMutableBytes { outBytes in | ||
| payload.withUnsafeBytes { payloadBytes in | ||
| key.withUnsafeBytes { keyBytes in | ||
| iv.withUnsafeBytes { ivBytes in | ||
| CCCrypt( | ||
| CCOperation(kCCDecrypt), | ||
| CCAlgorithm(kCCAlgorithmAES), | ||
| CCOptions(kCCOptionPKCS7Padding), | ||
| keyBytes.baseAddress, | ||
| key.count, | ||
| ivBytes.baseAddress, | ||
| payloadBytes.baseAddress, | ||
| payload.count, | ||
| outBytes.baseAddress, | ||
| outCapacity, | ||
| &outLength) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| guard status == kCCSuccess else { return nil } | ||
| out.count = outLength | ||
|
|
||
| if let value = String(data: out, encoding: .utf8), !value.isEmpty { | ||
| return value | ||
| } | ||
| if out.count > 32 { | ||
| let trimmed = out.dropFirst(32) | ||
| if let value = String(data: trimmed, encoding: .utf8), !value.isEmpty { | ||
| return value | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| enum MiniMaxDesktopCookieImportError: LocalizedError { | ||
| case sqliteFailed(String) | ||
|
|
||
| var errorDescription: String? { | ||
| switch self { | ||
| case let .sqliteFailed(message): | ||
| "MiniMax desktop cookie database read failed: \(message)" | ||
| } | ||
| } | ||
| } | ||
| #endif | ||
Oops, something went wrong.
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 the user has disabled Keychain access and the MiniMax desktop cookie DB contains encrypted values,
importSession()still reaches thisSecItemCopyMatchingcall to derive the Safe Storage key. The query is non-interactive, but it bypasses the app's KeychainAccessGate setting and performs the keychain read that setting is supposed to suppress; return nil before building/issuing the query whenKeychainAccessGate.isDisabled, as the other cookie importers do.Useful? React with 👍 / 👎.