# BrowserCookieAccessGate — 6-Hour Prompt Cooldown

> Reading Chromium browser cookies for providers like Claude-web requires decrypting the "Chrome Safe Storage" keychain entry, which itself triggers a macOS permission prompt. BrowserCookieAccessGate wraps every cookie-fetch call and suppresses attempts for 6 hours after a denial. The gate uses KeychainAccessPreflight.checkGenericPassword to probe each known safe-storage label without triggering the full dialog; if interaction is required it records a "denied until" timestamp in UserDefaults and returns false immediately. The cooldown is intentionally long (6h) to prevent repeated nagging if the user clicks Deny. The gate is a no-op stub on non-macOS platforms so the same CodexBarCore code compiles for Linux tests. Evidence: BrowserCookieAccessGate.swift, BrowserCookieAccessGate.cooldownInterval = 60*60*6.

- Repository: steipete/CodexBar
- GitHub: https://github.com/steipete/CodexBar
- Human wiki: https://grok-wiki.com/public/wiki/steipete-codexbar-3494bea25492
- Complete Markdown: https://grok-wiki.com/public/wiki/steipete-codexbar-3494bea25492/llms-full.txt

## Source Files

- `Sources/CodexBarCore/BrowserCookieAccessGate.swift`
- `Sources/CodexBarCore/BrowserCookieImportOrder.swift`
- `Sources/CodexBarCore/KeychainAccessPreflight.swift`
- `Sources/CodexBarCore/KeychainNoUIQuery.swift`
- `Tests/CodexBarTests/OpenAIWebAccountSwitchTests.swift`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [Sources/CodexBarCore/BrowserCookieAccessGate.swift](Sources/CodexBarCore/BrowserCookieAccessGate.swift)
- [Sources/CodexBarCore/KeychainAccessPreflight.swift](Sources/CodexBarCore/KeychainAccessPreflight.swift)
- [Sources/CodexBarCore/KeychainNoUIQuery.swift](Sources/CodexBarCore/KeychainNoUIQuery.swift)
- [Sources/CodexBarCore/BrowserCookieImportOrder.swift](Sources/CodexBarCore/BrowserCookieImportOrder.swift)
- [Sources/CodexBarCore/KeychainAccessGate.swift](Sources/CodexBarCore/KeychainAccessGate.swift)
- [Tests/CodexBarTests/BrowserDetectionTests.swift](Tests/CodexBarTests/BrowserDetectionTests.swift)
- [Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift](Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift)
</details>

# BrowserCookieAccessGate — 6-Hour Prompt Cooldown

`BrowserCookieAccessGate` is a macOS-only guard that prevents repeated keychain permission prompts when CodexBar attempts to read Chromium-based browser cookies. Chromium stores its cookie encryption key in the macOS keychain under the "Chrome Safe Storage" label (and per-browser equivalents). Accessing that key triggers a native macOS Allow/Deny dialog — a disruptive, blocking UX event. If the user clicks Deny, the same dialog would immediately reappear on the next usage check. The gate solves this by recording a "denied until" timestamp in `UserDefaults` and skipping all cookie-fetch attempts for the next **six hours**.

On non-macOS platforms the entire type is replaced by a stub that unconditionally returns `true` for `shouldAttempt`, making the gate a zero-cost abstraction for Linux CI builds.

---

## Why Chromium Browsers Need Keychain Access

Not all browsers store cookies in the same way. Safari and Firefox do not use the keychain for cookie decryption, so their cookies can be read without any permission dialog. Chromium-based browsers (Chrome, Arc, Brave, Edge, Vivaldi, Dia, Yandex, Comet, and others) encrypt their cookies with a key stored in the macOS keychain.

The `usesKeychainForCookieDecryption` property on `Browser` makes this distinction explicit:

```swift
// Sources/CodexBarCore/BrowserCookieImportOrder.swift:35-55
var usesKeychainForCookieDecryption: Bool {
    switch self {
    case .safari, .firefox, .zen:
        return false
    case .chrome, .chromeBeta, .chromeCanary,
         .arc, .arcBeta, .arcCanary,
         .chatgptAtlas, .chromium,
         .brave, .braveBeta, .braveNightly,
         .edge, .edgeBeta, .edgeCanary,
         .helium, .vivaldi, .dia, .yandex, .comet:
        return true
    @unknown default:
        return true  // conservative default
    }
}
```

The `@unknown default: return true` is a deliberate conservative choice — any future Chromium-derived browser is assumed to need keychain access until proven otherwise. Sources: [Sources/CodexBarCore/BrowserCookieImportOrder.swift:35-55]()

---

## The `shouldAttempt` Decision Flow

The public API is a single static predicate: `BrowserCookieAccessGate.shouldAttempt(_ browser:)`. All cookie-fetch call sites must pass through it before touching the keychain.

```mermaid
flowchart TD
    A[shouldAttempt called] --> B{browser.usesKeychainForCookieDecryption?}
    B -- No --> C[return true]
    B -- Yes --> D{KeychainAccessGate.isDisabled?}
    D -- Yes --> E[return false]
    D -- No --> F{deniedUntil timestamp in state?}
    F -- Yes, still future --> G[return false]
    F -- No or expired --> H[chromiumKeychainRequiresInteraction?]
    H -- No --> I[return true]
    H -- Yes --> J[record deniedUntil = now + 6h, persist, return false]
```

The two-phase structure is subtle: the in-memory/persisted cooldown check comes **before** the live keychain probe. Only if no existing cooldown is active does the gate actually probe the keychain via `KeychainAccessPreflight`. This prevents redundant probes during the cooldown window.

Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:18-51]()

---

## Keychain Preflight: Probing Without Triggering the Dialog

The core of the no-UI probe is `KeychainAccessPreflight.checkGenericPassword(service:account:)`. It issues a `SecItemCopyMatching` query that requests only keychain **attributes** (not the secret data), and applies two complementary no-interaction flags:

1. **`LAContext.interactionNotAllowed = true`** — tells LocalAuthentication to abort rather than prompt.
2. **`kSecUseAuthenticationUI = kSecUseAuthenticationUIFail`** — applies the legacy Security framework flag for older macOS keychain behavior where the `LAContext` flag alone was insufficient to suppress the Allow/Deny prompt.

```swift
// Sources/CodexBarCore/KeychainNoUIQuery.swift:13-18
static func apply(to query: inout [String: Any]) {
    let context = LAContext()
    context.interactionNotAllowed = true
    query[kSecUseAuthenticationContext as String] = context
    query[kSecUseAuthenticationUI as String] = self.uiFailPolicy as CFString
}
```

The `kSecUseAuthenticationUIFail` symbol is deprecated in modern SDKs. `KeychainNoUIQuery` resolves it at runtime via `dlopen`/`dlsym` to avoid a compile-time deprecation warning while still applying the correct constant value:

```swift
// Sources/CodexBarCore/KeychainNoUIQuery.swift:26-38
private static func resolveUIFailPolicy() -> String {
    let securityPath = "/System/Library/Frameworks/Security.framework/Security"
    guard let handle = dlopen(securityPath, RTLD_NOW) else { return "u_AuthUIF" }
    defer { dlclose(handle) }
    guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else { return "u_AuthUIF" }
    let valuePointer = symbol.assumingMemoryBound(to: CFString?.self)
    return (valuePointer.pointee as String?) ?? "u_AuthUIF"
}
```

The fallback literal `"u_AuthUIF"` is the raw string value of the constant, used if dynamic resolution fails. Sources: [Sources/CodexBarCore/KeychainNoUIQuery.swift:9-39]()

### Preflight Outcomes

| Outcome | Meaning | Gate decision |
|---|---|---|
| `.allowed` | Keychain item readable without UI | Return `true` immediately (short-circuit loop) |
| `.interactionRequired` | Would show a dialog | Record cooldown, return `false` |
| `.notFound` | No matching keychain item | Continue to next label |
| `.failure(Int)` | Other Security error | Continue to next label |

Note: `.allowed` returns `false` for `chromiumKeychainRequiresInteraction()` (meaning interaction is *not* required), making the naming convention a double-negative to watch for. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:84-96]()

---

## The Safe-Storage Label List

`BrowserCookieAccessGate` probes every known "Chrome Safe Storage" keychain service name across all supported Chromium browsers. The label list is defined on `Browser` itself (in `SweetCookieKit`) and mirrored into the gate:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:98
private static let safeStorageLabels: [(service: String, account: String)] = Browser.safeStorageLabels
```

Iterating all labels matters because Chrome, Brave, Arc, and others each register distinct keychain service names. If *any* label probes as `.allowed`, the gate concludes that the keychain is accessible without UI and allows the fetch. If *any* probes as `.interactionRequired`, it immediately trips the cooldown. Labels that come back `.notFound` are silently skipped — the browser simply isn't installed. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:84-98]()

---

## State Management and Persistence

The gate uses an `OSAllocatedUnfairLock` to protect a small `State` struct containing the per-browser denial map:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:8-13
private struct State {
    var loaded = false
    var deniedUntilByBrowser: [String: Date] = [:]
}
private static let lock = OSAllocatedUnfairLock<State>(initialState: State())
```

State is lazily loaded from `UserDefaults` on the first access (`loadIfNeeded`). Persistence serializes `Date` values as `Double` (Unix timestamps) under the key `"browserCookieAccessDeniedUntil"`:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:109-112
private static func persist(_ state: State) {
    let raw = state.deniedUntilByBrowser.mapValues { $0.timeIntervalSince1970 }
    UserDefaults.standard.set(raw, forKey: self.defaultsKey)
}
```

This means the cooldown survives application restarts. A user who clicks Deny will not be re-prompted for six hours even after quitting and relaunching CodexBar. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:100-112]()

### Cooldown Expiry

When `shouldAttempt` is called and finds a stored timestamp that is already in the past, it removes the entry and re-runs the live keychain probe:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:23-31
if let blockedUntil = state.deniedUntilByBrowser[browser.rawValue] {
    if blockedUntil > now {
        return false  // still cooling down
    }
    state.deniedUntilByBrowser.removeValue(forKey: browser.rawValue)
    self.persist(state)
}
return true  // proceed to live probe
```

The expired entry is removed and persisted before the live probe runs — a clean state transition. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:23-34]()

---

## Two Entry Points for Recording Denials

Beyond the proactive preflight in `shouldAttempt`, there is a reactive path for recording denials that slip through:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:53-74
public static func recordIfNeeded(_ error: Error, now: Date = Date()) {
    guard let error = error as? BrowserCookieError else { return }
    guard case .accessDenied = error else { return }
    self.recordDenied(for: error.browser, now: now)
}

public static func recordDenied(for browser: Browser, now: Date = Date()) { ... }
```

Call sites that receive a `BrowserCookieError.accessDenied` (i.e., the actual cookie read failed with an access error) can call `recordIfNeeded` to start the cooldown retroactively. This handles the edge case where the preflight passed but the full keychain fetch was denied at a different layer. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:53-74]()

---

## Integration into `BrowserCookieClient`

The gate wraps `SweetCookieKit`'s `BrowserCookieClient` via an extension that adds a `codexBarRecords` method:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:115-124
extension BrowserCookieClient {
    public func codexBarRecords(
        matching query: BrowserCookieQuery,
        in browser: Browser,
        logger: ((String) -> Void)? = nil) throws -> [BrowserCookieStoreRecords]
    {
        guard BrowserCookieAccessGate.shouldAttempt(browser) else { return [] }
        return try self.records(matching: query, in: browser, logger: logger)
    }
}
```

A blocked gate returns an empty array (not an error), so callers treat it as "no cookies found" rather than a failure state. This is intentional: the feature gracefully degrades rather than surfacing errors to the UI. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:115-124]()

The `[Browser].cookieImportCandidates(using:)` function applies the gate at the candidate-selection stage, before any fetch is attempted:

```swift
// Sources/CodexBarCore/BrowserCookieImportOrder.swift:17-25
public func cookieImportCandidates(using detection: BrowserDetection) -> [Browser] {
    let candidates = self.filter { browser in
        if KeychainAccessGate.isDisabled, browser.usesKeychainForCookieDecryption { return false }
        return detection.isCookieSourceAvailable(browser)
    }
    return candidates.filter { BrowserCookieAccessGate.shouldAttempt($0) }
}
```

Sources: [Sources/CodexBarCore/BrowserCookieImportOrder.swift:17-25]()

---

## Testing Infrastructure

### Test-Time Keychain Bypass

`KeychainAccessGate.isDisabled` returns `true` automatically when running under the Swift test runner (process name `swiftpm-testing-helper` or `*PackageTests`, or `XCTestConfigurationFilePath` set), unless the environment variable `CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1` is set. This means tests that use `cookieImportCandidates` automatically drop all Chromium browsers from the candidate list without any explicit mock. Sources: [Sources/CodexBarCore/KeychainAccessGate.swift:34-46]()

This behavior is verified directly in tests:

```swift
// Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift:49-73
@Test
func `default cookie import candidates skip keychain browsers during tests`() throws {
    BrowserCookieAccessGate.resetForTesting()
    // ... creates Chrome profile on disk ...
    let candidates = AlibabaCodingPlanCookieImporter.cookieImportCandidates(browserDetection: detection)
    #expect(candidates.first == .safari)
    #expect(candidates.contains(.chrome) == false)
}
```

Sources: [Tests/CodexBarTests/AlibabaCodingPlanCookieImporterTests.swift:49-73]()

### `resetForTesting()`

`BrowserCookieAccessGate.resetForTesting()` clears the in-memory denial map, marks state as loaded (bypassing the `UserDefaults` load), and removes the `UserDefaults` key. Tests call this at the start of each test case to prevent cooldown state from leaking between tests:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:76-82
public static func resetForTesting() {
    self.lock.withLock { state in
        state.loaded = true
        state.deniedUntilByBrowser.removeAll()
        UserDefaults.standard.removeObject(forKey: self.defaultsKey)
    }
}
```

### Preflight Override for Testing

`KeychainAccessPreflight` has a `@TaskLocal` override mechanism that lets individual tests inject a fake keychain response without touching the real Security framework:

```swift
// Sources/CodexBarCore/KeychainAccessPreflight.swift:103-112
static func withCheckGenericPasswordOverrideForTesting<T>(
    _ override: ((String, String?) -> Outcome)?,
    operation: () throws -> T) rethrows -> T
{
    try self.$taskCheckGenericPasswordOverrideStore.withValue(
        override.map(CheckGenericPasswordOverrideStore.init(check:))) {
        try operation()
    }
}
```

Sources: [Sources/CodexBarCore/KeychainAccessPreflight.swift:88-124]()

---

## Non-macOS Stub

On Linux (and any other non-macOS platform), the entire `BrowserCookieAccessGate` is replaced with a minimal stub that compiles without importing `SweetCookieKit`, `Security`, or `LocalAuthentication`:

```swift
// Sources/CodexBarCore/BrowserCookieAccessGate.swift:126-135
#else
public enum BrowserCookieAccessGate {
    public static func shouldAttempt(_ browser: Browser, now: Date = Date()) -> Bool { true }
    public static func recordIfNeeded(_ error: Error, now: Date = Date()) {}
    public static func recordDenied(for browser: Browser, now: Date = Date()) {}
    public static func resetForTesting() {}
}
#endif
```

This means the same `CodexBarCore` library builds and runs on Linux for CI purposes, where the full keychain stack is unavailable. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:125-135]()

---

## Summary

`BrowserCookieAccessGate` is a narrow, intentionally blunt instrument: once a keychain interaction is detected or a denial is recorded, it backs off for exactly six hours and returns empty results silently, with no retry and no UI feedback to the end user. The preflight mechanism in `KeychainAccessPreflight` uses two overlapping no-interaction flags (one via `LAContext`, one via a runtime-resolved deprecated constant) because `interactionNotAllowed` alone was historically insufficient to suppress the Allow/Deny dialog on some macOS configurations. The gate is the mandatory entry point for all Chromium cookie reads in CodexBar, enforced through both the `BrowserCookieClient` extension and the `cookieImportCandidates` filter. Sources: [Sources/CodexBarCore/BrowserCookieAccessGate.swift:15]()
