From 3d063c9100556c79276e73a7b96b90a5823d5885 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 22 Apr 2026 05:27:07 -0700 Subject: [PATCH] fix(menubar): keychain account filter + App Nap hardening + single query Remove hardcoded "default" account allowlist from keychain credential lookup. Claude Code 2.1.x writes the macOS login username, not "default", so the filter silently dropped valid credentials on every install. Collapse the two-phase keychain enumeration into a single SecItemCopyMatching call (one keychain prompt instead of four on debug builds). Harden App Nap opt-out: disable automaticTerminationSupport and suddenTermination at the process level so AppKit cannot override the beginActivity token. Closes #115 --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 3 +- .../Data/SubscriptionClient.swift | 56 +++---------------- 2 files changed, 11 insertions(+), 48 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index e55aad6..05cff1a 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -38,9 +38,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var backgroundActivity: NSObjectProtocol? func applicationDidFinishLaunching(_ notification: Notification) { - // Menubar accessory -- no Dock icon, no app switcher entry. NSApp.setActivationPolicy(.accessory) + ProcessInfo.processInfo.automaticTerminationSupportEnabled = false + ProcessInfo.processInfo.disableSuddenTermination() backgroundActivity = ProcessInfo.processInfo.beginActivity( options: [.userInitiated, .automaticTerminationDisabled, .suddenTerminationDisabled], reason: "CodeBurn menubar polls AI coding cost every 15 seconds while idle in the background." diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift index 79c5794..3f71e30 100644 --- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionClient.swift @@ -10,10 +10,6 @@ private let betaHeader = "oauth-2025-04-20" private let userAgent = "claude-code/2.1.0" private let requestTimeout: TimeInterval = 30 -/// Claude Code writes Keychain items with `kSecAttrAccount = "default"`. Filtering on this -/// prevents a planted Keychain item from another app (or a stale install with a mangled -/// account) from being accepted as our source of OAuth credentials. -private let expectedKeychainAccounts: Set = ["default"] private let maxCredentialBytes = 64 * 1024 enum SubscriptionError: Error, LocalizedError { @@ -72,55 +68,21 @@ struct SubscriptionClient { return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) } - /// Two-phase keychain enumeration: (1) list persistent refs + accounts, (2) fetch each - /// item's data by ref. The combination kSecMatchLimitAll + kSecReturnData errors with -50, - /// so the data fetch has to be per-item. private static func readKeychainCredentials() throws -> StoredCredentials? { - let listQuery: [String: Any] = [ + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, - kSecMatchLimit as String: kSecMatchLimitAll, - kSecReturnAttributes as String: true, - kSecReturnPersistentRef as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, ] - var listResult: CFTypeRef? - let listStatus = SecItemCopyMatching(listQuery as CFDictionary, &listResult) - if listStatus == errSecItemNotFound { - NSLog("CodeBurn: keychain query found no items for service \(keychainService)") + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + NSLog("CodeBurn: keychain query failed status=\(status)") return nil } - guard listStatus == errSecSuccess, let rows = listResult as? [[String: Any]] else { - NSLog("CodeBurn: keychain enumerate failed status=\(listStatus)") - return nil - } - - var best: StoredCredentials? = nil - for row in rows { - guard let ref = row[kSecValuePersistentRef as String] as? Data else { continue } - let account = (row[kSecAttrAccount as String] as? String) ?? "" - // Ignore rows whose account doesn't match Claude Code's known writer. Stops another - // app's item (or a legacy install with an unexpected account) from being accepted. - guard expectedKeychainAccounts.contains(account) else { continue } - let dataQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecValuePersistentRef as String: ref, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - var dataResult: CFTypeRef? - let dataStatus = SecItemCopyMatching(dataQuery as CFDictionary, &dataResult) - guard dataStatus == errSecSuccess, let data = dataResult as? Data else { continue } - let sanitized = sanitizeKeychainData(data) - guard let parsed = try? parseCredentials(data: sanitized) else { continue } - if let current = best { - if (parsed.expiresAt ?? .distantPast) > (current.expiresAt ?? .distantPast) { - best = parsed - } - } else { - best = parsed - } - } - return best + return try parseCredentials(data: sanitizeKeychainData(data)) } /// Claude Code's keychain writer line-wraps long string values (newline + leading spaces)