Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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 @@ -4,6 +4,7 @@

### Added
- Agent Sessions: list and focus live local or SSH-discovered Codex and Claude Code sessions from the menu and CLI.
- MiniMax: fetch Token Plan recharge credit balance from the console when a web session cookie is available, including MiniMax Agent desktop cookies and explicit manual or env cookies on API-token refreshes. Thanks @Yuxin-Qiao!

### Fixed
- Ollama: recognize current WorkOS AuthKit sessions during browser-cookie discovery and manual cookie validation. Thanks @joeVenner!
Expand Down
8 changes: 7 additions & 1 deletion Sources/CodexBar/MenuCardView+Costs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ extension UsageMenuCardView.Model {
L("Reported by OpenAI Admin API organization usage.")
case .mistral:
L("Reported by Mistral billing usage.")
case .minimax:
L("Estimated from MiniMax usage summary and published pay-as-you-go pricing.")
default:
nil
}
Expand Down Expand Up @@ -304,11 +306,15 @@ extension UsageMenuCardView.Model {

if provider == .minimax, cost.period == "MiniMax points balance" {
let balance = String(format: "%.0f", cost.used)
let expiresLine = cost.resetsAt.map {
String(format: L("Expires: %@"), UsageFormatter.preciseDateTimeDescription(from: $0))
}
return ProviderCostSection(
title: L("Credits"),
percentUsed: nil,
spendLine: "\(L("Balance")): \(balance)",
percentLine: nil)
percentLine: nil,
personalSpendLine: expiresLine)
}

if provider == .openai || provider == .claude || provider == .litellm, cost.limit <= 0 {
Expand Down
20 changes: 19 additions & 1 deletion Sources/CodexBar/MenuCardView+MiniMax.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ extension UsageMenuCardView.Model {
percent: displayPercent,
percentStyle: percentStyle,
statusText: service.isUnlimited ? "∞ Unlimited" : nil,
resetText: Self.localizedMiniMaxResetDescription(service.resetDescription),
resetText: Self.miniMaxResetText(for: service, input: input),
detailText: nil,
detailLeftText: usageLabel,
detailRightText: nil,
Expand Down Expand Up @@ -112,6 +112,24 @@ extension UsageMenuCardView.Model {
return trimmed.isEmpty ? windowType : trimmed
}

private static func miniMaxResetText(for service: MiniMaxServiceUsage, input: Input) -> String {
if service.isUnlimited {
return self.localizedMiniMaxResetDescription(service.resetDescription)
}
if let resetsAt = service.resetsAt {
switch input.resetTimeDisplayStyle {
case .absolute:
let text = UsageFormatter.resetDescription(from: resetsAt, now: input.now)
return L("Resets %@", text)
case .countdown:
let phrase = MiniMaxServiceUsage.resetCountdownPhrase(from: resetsAt, now: input.now)
if phrase == "now" { return L("Resets now") }
return L("Resets in %@", phrase)
}
}
return Self.localizedMiniMaxResetDescription(service.resetDescription)
}

private static func localizedMiniMaxResetDescription(_ text: String) -> String {
let prefix = "Resets in "
guard text.hasPrefix(prefix) else { return text }
Expand Down
25 changes: 21 additions & 4 deletions Sources/CodexBarCore/Providers/MiniMax/MiniMaxAPIRegion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable {
private static let codingPlanQuery = "cycle_type=3"
private static let remainsPath = "v1/api/openplatform/coding_plan/remains"
private static let tokenPlanRemainsPath = "v1/token_plan/remains"
private static let tokenPlanCreditPath = "backend/account/token_plan_credit"
private static let tokenPlanUsageSummaryPath = "backend/account/token_plan/usage_summary"
private static let billingHistoryPath = "account/amount"
private static let usageDashboardPath = "console/usage"

public var displayName: String {
switch self {
Expand Down Expand Up @@ -37,6 +40,23 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable {
}
}

public var webBaseURLString: String {
switch self {
case .global:
"https://www.minimax.io"
case .chinaMainland:
"https://www.minimaxi.com"
}
}

public var tokenPlanCreditURL: URL {
URL(string: self.webBaseURLString)!.appendingPathComponent(Self.tokenPlanCreditPath)
}

public var tokenPlanUsageSummaryURL: URL {
URL(string: self.webBaseURLString)!.appendingPathComponent(Self.tokenPlanUsageSummaryPath)
}

public var codingPlanURL: URL {
var components = URLComponents(string: self.baseURLString)!
components.path = "/" + Self.codingPlanPath
Expand All @@ -63,10 +83,7 @@ public enum MiniMaxAPIRegion: String, CaseIterable, Sendable {
}

public var dashboardURL: URL {
var components = URLComponents(string: self.baseURLString)!
components.path = "/" + Self.codingPlanPath
components.query = Self.codingPlanQuery
return components.url!
URL(string: self.baseURLString)!.appendingPathComponent(Self.usageDashboardPath)
}

public func billingHistoryURL(page: Int, limit: Int) -> URL {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ public enum MiniMaxCookieImporter {
private static let cookieDomains = [
"platform.minimax.io",
"openplatform.minimax.io",
"www.minimax.io",
"minimax.io",
"platform.minimaxi.com",
"openplatform.minimaxi.com",
"www.minimaxi.com",
"minimaxi.com",
]

Expand Down
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor disabled Keychain access for MiniMax cookies

When the user has disabled Keychain access and the MiniMax desktop cookie DB contains encrypted values, importSession() still reaches this SecItemCopyMatching call 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 when KeychainAccessGate.isDisabled, as the other cookie importers do.

Useful? React with 👍 / 👎.

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
Loading
Loading