Merge pull request #134 from getagentseal/fix/menubar-keychain-appnap

fix(menubar): keychain credential lookup + App Nap hardening
This commit is contained in:
Resham Joshi 2026-04-22 05:27:25 -07:00 committed by GitHub
commit ab39fff98a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 11 additions and 48 deletions

View file

@ -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."

View file

@ -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<String> = ["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)