Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
28 changes: 20 additions & 8 deletions Sources/CodexBar/CookieHeaderStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,26 @@ struct KeychainCookieHeaderStore: CookieHeaderStoring {
throw CookieHeaderStoreError.keychainStatus(updateStatus)
}

var addQuery = query
for (key, value) in attributes {
addQuery[key] = value
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
Self.log.error("Keychain add failed: \(addStatus)")
throw CookieHeaderStoreError.keychainStatus(addStatus)
// Use ACL helper to add with CodexBar trusted, preventing future prompts
do {
try KeychainACLHelper.addGenericPasswordWithTrustedAccess(
service: self.service,
account: self.account,
data: data,
label: "\(self.account) cookie header"
)
} catch {
Self.log.error("Keychain add with trusted access failed: \(error)")
// Fallback to standard add
var addQuery = query
for (key, value) in attributes {
addQuery[key] = value
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
Self.log.error("Keychain add failed: \(addStatus)")
throw CookieHeaderStoreError.keychainStatus(addStatus)
}
}

// Update cache
Expand Down
28 changes: 20 additions & 8 deletions Sources/CodexBar/CopilotTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,26 @@ struct KeychainCopilotTokenStore: CopilotTokenStoring {
throw CopilotTokenStoreError.keychainStatus(updateStatus)
}

var addQuery = query
for (key, value) in attributes {
addQuery[key] = value
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
Self.log.error("Keychain add failed: \(addStatus)")
throw CopilotTokenStoreError.keychainStatus(addStatus)
// Use ACL helper to add with CodexBar trusted, preventing future prompts
do {
try KeychainACLHelper.addGenericPasswordWithTrustedAccess(
service: self.service,
account: self.account,
data: data,
label: "GitHub Copilot Token"
)
} catch {
Self.log.error("Keychain add with trusted access failed: \(error)")
// Fallback to standard add
var addQuery = query
for (key, value) in attributes {
addQuery[key] = value
}
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
Self.log.error("Keychain add failed: \(addStatus)")
throw CopilotTokenStoreError.keychainStatus(addStatus)
}
}
}

Expand Down
211 changes: 211 additions & 0 deletions Sources/CodexBar/KeychainACLHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import CodexBarCore
import Foundation
import Security

/// Helper to manage keychain ACLs and reduce repeated permission prompts.
///
/// The core issue: macOS prompts for keychain access every time an app accesses
/// a keychain item unless that app is in the item's "Always allow" list.
///
/// This helper provides:
/// 1. Creating keychain items with CodexBar already in the trusted apps list
/// 2. Updating existing items to add CodexBar to the trusted list (after user approval)
enum KeychainACLHelper {
private static let log = CodexBarLog.logger(LogCategories.keychainACL)

/// Error types for ACL operations
enum ACLError: LocalizedError {
case accessCopyFailed(OSStatus)
case aclListCopyFailed(OSStatus)
case aclContentsCopyFailed(OSStatus)
case aclSetContentsFailed(OSStatus)
case noACLFound
case appPathNotFound
case trustedAppCreationFailed(OSStatus)
case accessModificationFailed(OSStatus)

var errorDescription: String? {
switch self {
case .accessCopyFailed(let status):
return "Failed to copy keychain access: \(status)"
case .aclListCopyFailed(let status):
return "Failed to copy ACL list: \(status)"
case .aclContentsCopyFailed(let status):
return "Failed to copy ACL contents: \(status)"
case .aclSetContentsFailed(let status):
return "Failed to set ACL contents: \(status)"
case .noACLFound:
return "No ACL found for keychain item"
case .appPathNotFound:
return "Could not determine app bundle path"
case .trustedAppCreationFailed(let status):
return "Failed to create trusted app reference: \(status)"
case .accessModificationFailed(let status):
return "Failed to modify access: \(status)"
}
}
}

/// Get the path to the current app bundle
private static var appPath: String? {
Bundle.main.bundlePath
}

/// Create a SecAccess that includes CodexBar in the trusted apps list.
/// Items created with this access won't prompt for CodexBar.
static func createAccessWithCodexBarTrusted(description: String) throws -> SecAccess {
guard let appPath = self.appPath else {
throw ACLError.appPathNotFound
}

var trustedApp: SecTrustedApplication?
let trustedStatus = SecTrustedApplicationCreateFromPath(appPath, &trustedApp)
guard trustedStatus == errSecSuccess, let app = trustedApp else {
throw ACLError.trustedAppCreationFailed(trustedStatus)
}

var access: SecAccess?
let accessStatus = SecAccessCreate(description as CFString, [app] as CFArray, &access)
guard accessStatus == errSecSuccess, let createdAccess = access else {
throw ACLError.accessCopyFailed(accessStatus)
}

return createdAccess
}

/// Add a generic password to keychain with CodexBar pre-authorized.
/// This prevents future prompts for this item.
static func addGenericPasswordWithTrustedAccess(
service: String,
account: String,
data: Data,
label: String? = nil
) throws {
let access = try self.createAccessWithCodexBarTrusted(
description: label ?? "\(service):\(account)"
)

var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecAttrAccess as String: access,
]

if let label = label {
query[kSecAttrLabel as String] = label
}

// Delete existing item first
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(deleteQuery as CFDictionary)

let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
self.log.error("Failed to add keychain item with trusted access: \(status)")
throw ACLError.accessModificationFailed(status)
}

self.log.info("Added keychain item with CodexBar trusted: \(service):\(account)")
}

/// Update an existing keychain item to add CodexBar to the trusted apps list.
/// This requires reading the item first (which may prompt), then rewriting it.
///
/// Note: This only works for items that CodexBar has write access to.
/// External items (like "Chrome Safe Storage") cannot be modified this way.
static func addCodexBarToTrustedApps(
service: String,
account: String
) throws {
// First, read the existing item (this may prompt the user)
var result: CFTypeRef?
let readQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
kSecReturnAttributes as String: true,
kSecReturnRef as String: true,
]

let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &result)
guard readStatus == errSecSuccess else {
self.log.error("Failed to read keychain item for ACL update: \(readStatus)")
throw ACLError.accessCopyFailed(readStatus)
}

guard let itemDict = result as? [String: Any],
let itemData = itemDict[kSecValueData as String] as? Data
else {
throw ACLError.noACLFound
}

// Rewrite the item with CodexBar in the trusted list
try self.addGenericPasswordWithTrustedAccess(
service: service,
account: account,
data: itemData,
label: itemDict[kSecAttrLabel as String] as? String
)

self.log.info("Updated keychain item ACL to trust CodexBar: \(service):\(account)")
}

/// Migrate all CodexBar-owned keychain items to use trusted access.
/// Call this once after the user grants initial access.
static func migrateCodexBarItemsToTrustedAccess() {
let items: [(service: String, account: String)] = [
("com.steipete.CodexBar", "codex-cookie"),
("com.steipete.CodexBar", "claude-cookie"),
("com.steipete.CodexBar", "cursor-cookie"),
("com.steipete.CodexBar", "factory-cookie"),
("com.steipete.CodexBar", "minimax-cookie"),
("com.steipete.CodexBar", "minimax-api-token"),
("com.steipete.CodexBar", "augment-cookie"),
("com.steipete.CodexBar", "amp-cookie"),
("com.steipete.CodexBar", "copilot-api-token"),
("com.steipete.CodexBar", "zai-api-token"),
("com.steipete.CodexBar", "synthetic-api-key"),
("com.steipete.CodexBar", "kimi-auth-token"),
("com.steipete.CodexBar", "kimi-k2-api-key"),
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Account name mismatch: The account name here is "kimi-k2-api-key" but KimiK2TokenStore.swift uses "kimi-k2-api-token" (line 28). This mismatch means the migration function would fail to find and migrate existing Kimi K2 tokens. Update this to "kimi-k2-api-token" to match the actual account name used in the token store.

Suggested change
("com.steipete.CodexBar", "kimi-k2-api-key"),
("com.steipete.CodexBar", "kimi-k2-api-token"),

Copilot uses AI. Check for mistakes.
]
Comment on lines +165 to +179
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Missing "opencode-cookie" account: The SettingsStore initializer (SettingsStore.swift line 94-96) includes an opencodeCookieStore with account "opencode-cookie", and KeychainMigration.swift also includes it (line 31 based on the pattern). However, this migration list is missing that account. This means opencode cookies won't be migrated to trusted access and will continue prompting users. Add the entry: ("com.steipete.CodexBar", "opencode-cookie") to the items list.

Copilot uses AI. Check for mistakes.

var migratedCount = 0
var errorCount = 0

for item in items {
do {
try self.addCodexBarToTrustedApps(service: item.service, account: item.account)
migratedCount += 1
} catch {
// Item might not exist, which is fine
if case ACLError.accessCopyFailed(let status) = error,
status == errSecItemNotFound {
continue
}
errorCount += 1
self.log.warning("Failed to migrate \(item.service):\(item.account): \(error)")
}
}

if migratedCount > 0 {
self.log.info("Migrated \(migratedCount) keychain items to trusted access")
}
if errorCount > 0 {
self.log.warning("\(errorCount) items failed to migrate")
}
}
Comment on lines +161 to +205
Copy link

Copilot AI Jan 31, 2026

Choose a reason for hiding this comment

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

Unused migration function: The migrateCodexBarItemsToTrustedAccess function is never called anywhere in the codebase. According to the PR description, "Existing keychain items will be replaced with trusted versions on next token refresh", which suggests the migration happens automatically through the normal store/update flow. However, if there's a need for a one-time bulk migration (e.g., for users upgrading from an older version), this function should either be called from an appropriate location (like app startup or a settings button), or removed if the incremental migration approach is sufficient. Consider adding documentation clarifying the migration strategy.

Suggested change
/// Migrate all CodexBar-owned keychain items to use trusted access.
/// Call this once after the user grants initial access.
static func migrateCodexBarItemsToTrustedAccess() {
let items: [(service: String, account: String)] = [
("com.steipete.CodexBar", "codex-cookie"),
("com.steipete.CodexBar", "claude-cookie"),
("com.steipete.CodexBar", "cursor-cookie"),
("com.steipete.CodexBar", "factory-cookie"),
("com.steipete.CodexBar", "minimax-cookie"),
("com.steipete.CodexBar", "minimax-api-token"),
("com.steipete.CodexBar", "augment-cookie"),
("com.steipete.CodexBar", "amp-cookie"),
("com.steipete.CodexBar", "copilot-api-token"),
("com.steipete.CodexBar", "zai-api-token"),
("com.steipete.CodexBar", "synthetic-api-key"),
("com.steipete.CodexBar", "kimi-auth-token"),
("com.steipete.CodexBar", "kimi-k2-api-key"),
]
var migratedCount = 0
var errorCount = 0
for item in items {
do {
try self.addCodexBarToTrustedApps(service: item.service, account: item.account)
migratedCount += 1
} catch {
// Item might not exist, which is fine
if case ACLError.accessCopyFailed(let status) = error,
status == errSecItemNotFound {
continue
}
errorCount += 1
self.log.warning("Failed to migrate \(item.service):\(item.account): \(error)")
}
}
if migratedCount > 0 {
self.log.info("Migrated \(migratedCount) keychain items to trusted access")
}
if errorCount > 0 {
self.log.warning("\(errorCount) items failed to migrate")
}
}

Copilot uses AI. Check for mistakes.
}

// MARK: - Log Category
extension LogCategories {
static let keychainACL = "keychain-acl"
}
Loading
Loading