# Keychain Accessibility Migration — Silencing Rebuild Prompts

> On every development rebuild macOS pops a keychain Allow/Deny dialog for each stored credential, breaking the edit-run loop. CodexBar silently migrates all legacy keychain items from their default accessibility level to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly on first launch. The migration is one-shot (guarded by UserDefaults key "KeychainMigrationV1Completed"), uses a delete-then-add dance because SecItemUpdate cannot change kSecAttrAccessible, and is skipped entirely when KeychainAccessGate.isDisabled is set (CI / sandboxed environments). The list of migrated accounts is hard-coded in KeychainMigration.itemsToMigrate and includes every cookie and token the app manages.

- 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/CodexBar/KeychainMigration.swift`
- `Sources/CodexBarCore/KeychainAccessGate.swift`
- `Sources/CodexBarCore/KeychainNoUIQuery.swift`
- `Sources/CodexBarCore/KeychainAccessPreflight.swift`
- `Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift`

---

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

- [Sources/CodexBar/KeychainMigration.swift](Sources/CodexBar/KeychainMigration.swift)
- [Sources/CodexBar/HiddenWindowView.swift](Sources/CodexBar/HiddenWindowView.swift)
- [Sources/CodexBarCore/KeychainAccessGate.swift](Sources/CodexBarCore/KeychainAccessGate.swift)
- [Sources/CodexBarCore/KeychainNoUIQuery.swift](Sources/CodexBarCore/KeychainNoUIQuery.swift)
- [Sources/CodexBarCore/KeychainAccessPreflight.swift](Sources/CodexBarCore/KeychainAccessPreflight.swift)
- [Tests/CodexBarTests/KeychainMigrationTests.swift](Tests/CodexBarTests/KeychainMigrationTests.swift)
- [Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift](Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift)
- [docs/KEYCHAIN_FIX.md](docs/KEYCHAIN_FIX.md)
</details>

# Keychain Accessibility Migration — Silencing Rebuild Prompts

During development, macOS prompts the user with an Allow/Deny dialog for every keychain item an app touches whenever the app's code signature changes — which happens on every `⌘R` rebuild. For a menu-bar app like CodexBar that stores a dozen provider cookies and tokens, this produces a blocking dialog storm before the app even renders its menu.

CodexBar solves this with a one-shot migration that runs on first launch after installation. It upgrades every legacy keychain item from the default accessibility level to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. Items using this level are bound to the device and readable without user interaction after the Mac has been unlocked once — and crucially, macOS does not re-prompt on subsequent code-signature changes for items at this level. The migration is idempotent, gated by a `UserDefaults` flag, skipped entirely in test and CI environments, and completed silently in a background task.

---

## Why the Default Accessibility Level Triggers Prompts

macOS Keychain uses access control lists (ACLs) tied to the app's code signature. Items stored with the default accessibility (`kSecAttrAccessibleWhenUnlocked`, no `ThisDeviceOnly` suffix) carry ACL entries that macOS re-evaluates on every signature change. Switching to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` replaces that per-signature ACL with a device-scoped, session-open check — which the Security framework can satisfy without surfacing any UI.

The comment in the source makes this explicit:

```swift
// Sources/CodexBar/KeychainMigration.swift:5-6
/// Migrates keychain items to use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
/// to prevent permission prompts on every rebuild during development.
```

---

## The Migration Entry Point

The migration is triggered from `HiddenWindowView`, a zero-size (`1×1 pt`) SwiftUI view used solely as a lifecycle anchor for the macOS app. Its `.task` modifier spawns a detached background task so the migration never blocks the main actor:

```swift
// Sources/CodexBar/HiddenWindowView.swift:14-18
.task {
    // Migrate keychain items to reduce permission prompts during development (runs off main thread)
    await Task.detached(priority: .userInitiated) {
        KeychainMigration.migrateIfNeeded()
    }.value
}
```

The `docs/KEYCHAIN_FIX.md` notes that an earlier revision ran this in `CodexBarApp.init()` — synchronously on the main thread. The current placement in a `.task`-launched detached task is deliberate: `SecItemCopyMatching`, `SecItemDelete`, and `SecItemAdd` can block on slow keychains and must not run on the main actor.

Sources: [Sources/CodexBar/HiddenWindowView.swift:14-18]()

---

## The Migration Algorithm

`KeychainMigration.migrateIfNeeded()` is a `static func` on an `enum` (used as a namespace). Its logic is:

```
migrateIfNeeded()
  ├─ KeychainAccessGate.isDisabled? → return (CI / test / manual disable)
  ├─ UserDefaults "KeychainMigrationV1Completed" == true? → return (already done)
  └─ for each item in itemsToMigrate:
       migrateItem(item)
         ├─ SecItemCopyMatching (read existing item + its data)
         │   ├─ errSecItemNotFound → return false (nothing to do)
         │   └─ errSecSuccess → extract data + current kSecAttrAccessible value
         ├─ already kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly? → return false
         ├─ SecItemDelete (remove old item)
         └─ SecItemAdd (re-add with new accessibility + same data)
     UserDefaults.set(true, "KeychainMigrationV1Completed")
```

The delete-then-add pattern is a hard requirement of the Security framework: `SecItemUpdate` cannot change `kSecAttrAccessible` on an existing item. Any attempt to update that attribute via `SecItemUpdate` returns `errSecParam`. The only way to change accessibility is to delete and recreate the item.

Sources: [Sources/CodexBar/KeychainMigration.swift:71-141]()

### Error Handling

Each item's migration is wrapped in a `do/catch` that increments `errorCount` but does not abort the loop. This means a single locked or corrupted item does not prevent the rest from migrating. The migration flag `KeychainMigrationV1Completed` is still set to `true` even when `errorCount > 0` — a deliberate trade-off: re-running a partial migration would risk double-deleting items written between runs.

```swift
// Sources/CodexBar/KeychainMigration.swift:48-55
do {
    if try self.migrateItem(item) {
        migratedCount += 1
    }
} catch {
    errorCount += 1
    self.log.error("Failed to migrate \(item.label): \(String(describing: error))")
}
```

Sources: [Sources/CodexBar/KeychainMigration.swift:44-59]()

---

## The Hard-Coded Item List

`KeychainMigration.itemsToMigrate` is a static `[MigrationItem]` array. Every entry specifies a `service` and an optional `account`. All current entries share the service `com.steipete.CodexBar`:

| Account | Kind |
|---|---|
| `codex-cookie` | Codex session cookie |
| `claude-cookie` | Claude web session cookie |
| `cursor-cookie` | Cursor session cookie |
| `factory-cookie` | Factory session cookie |
| `minimax-cookie` | MiniMax session cookie |
| `minimax-api-token` | MiniMax API token |
| `augment-cookie` | Augment session cookie |
| `copilot-api-token` | GitHub Copilot API token |
| `zai-api-token` | Zai API token |
| `synthetic-api-key` | Synthetic API key |

A test in `KeychainMigrationTests` enforces this list as a contract — any missing entry causes a test failure:

```swift
// Tests/CodexBarTests/KeychainMigrationTests.swift:6-23
@Test
func `migration list covers known keychain items`() {
    let items = Set(KeychainMigration.itemsToMigrate.map(\.label))
    let expected: Set = [
        "com.steipete.CodexBar:codex-cookie",
        // ...
    ]
    let missing = expected.subtracting(items)
    #expect(missing.isEmpty, "Missing migration entries: \(missing.sorted())")
}
```

**Why hard-coded?** The migration is a one-time repair for items already present on disk. A dynamic discovery approach would risk migrating unrelated items from the same service namespace; hard-coding makes the scope explicit and auditable.

Note from `docs/KEYCHAIN_FIX.md`: the migration covers only legacy `com.steipete.CodexBar` items. The Claude OAuth cache (service `com.steipete.codexbar.cache`) is written fresh with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` from the start and is not included in this migration list.

Sources: [Sources/CodexBar/KeychainMigration.swift:21-32](), [Tests/CodexBarTests/KeychainMigrationTests.swift:6-23]()

---

## The CI / Test Gate

`KeychainAccessGate.isDisabled` is checked as the very first guard in `migrateIfNeeded()`:

```swift
// Sources/CodexBar/KeychainMigration.swift:36-39
guard !KeychainAccessGate.isDisabled else {
    self.log.info("Keychain access disabled; skipping migration")
    return
}
```

`KeychainAccessGate` has several disable paths:

| Path | Mechanism |
|---|---|
| Running under Swift test runner (DEBUG only) | Process name `swiftpm-testing-helper` or `XCTestConfigurationFilePath` env var |
| `CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS=1` env var | Opt-in override that re-enables the real keychain in tests |
| `UserDefaults "debugDisableKeychainAccess"` | Set by Preferences → Advanced → Disable Keychain access |
| App Group shared defaults with same key | For multi-process setups |
| `overrideValue` static property | Programmatic override (used in tests) |
| `@TaskLocal taskOverrideValue` | Per-task override for test isolation |

The `@TaskLocal` override (`withTaskOverrideForTesting`) is used extensively in `ClaudeOAuthCredentialsStoreTests` to isolate keychain behaviour per test without affecting global state:

```swift
// Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift:30-32
try KeychainAccessGate.withTaskOverrideForTesting(false) {
    // keychain reads are live in this scope
```

Sources: [Sources/CodexBarCore/KeychainAccessGate.swift:11-46](), [Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift:30-32]()

---

## Non-Interactive Query Infrastructure

The migration reads existing items without a `kSecUseAuthenticationContext` guard (it intentionally needs to read whatever is there, including items that may still prompt). But the rest of the keychain stack uses `KeychainNoUIQuery` to prevent any UI during normal reads:

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

    // Keep explicit UI-fail policy for legacy keychain behavior on macOS where
    // `interactionNotAllowed` alone can still surface Allow/Deny prompts.
    query[kSecUseAuthenticationUI as String] = self.uiFailPolicy as CFString
}
```

The `uiFailPolicy` constant (`kSecUseAuthenticationUIFail`) is resolved at runtime via `dlopen`/`dlsym` to avoid a deprecation warning at compile time — a localized workaround for the fact that Apple deprecated the constant in later SDKs while the underlying behavior is still necessary:

```swift
// Sources/CodexBarCore/KeychainNoUIQuery.swift:25-39
private static func resolveUIFailPolicy() -> String {
    let securityPath = "/System/Library/Frameworks/Security.framework/Security"
    guard let handle = dlopen(securityPath, RTLD_NOW) else {
        return "u_AuthUIF"  // hard-coded fallback value
    }
    // ...
    guard let symbol = dlsym(handle, "kSecUseAuthenticationUIFail") else {
        return "u_AuthUIF"
    }
```

The fallback string `"u_AuthUIF"` is the raw CFString value of `kSecUseAuthenticationUIFail`, used if the dynamic lookup fails. This ensures the no-UI behaviour is preserved even on future OS versions that might remove the symbol from the framework.

Sources: [Sources/CodexBarCore/KeychainNoUIQuery.swift:11-39]()

---

## Preflight Probing

`KeychainAccessPreflight.checkGenericPassword` is a companion mechanism used for post-migration reads. It probes whether an item is accessible without UI before committing to a full interactive load. It applies `KeychainNoUIQuery` and requests only attributes (never `kSecReturnData`), because requesting the secret payload was observed to trigger legacy prompts on some macOS configurations:

```swift
// Sources/CodexBarCore/KeychainAccessPreflight.swift:167-182
static func makeGenericPasswordPreflightQuery(service: String, account: String?) -> [String: Any] {
    var query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: service,
        kSecMatchLimit as String: kSecMatchLimitOne,
        // Preflight should never trigger UI. Avoid requesting the secret payload (`kSecReturnData`) because
        // some macOS configurations have been observed to show the legacy keychain prompt unless the query
        // is strictly non-interactive.
        kSecReturnAttributes as String: true,
    ]
    KeychainNoUIQuery.apply(to: &query)
```

Possible outcomes are `.allowed`, `.interactionRequired`, `.notFound`, and `.failure(Int)`. When the preflight returns `.interactionRequired`, the app can choose to show a pre-alert explaining why the keychain prompt is about to appear, rather than letting macOS show a bare Allow/Deny dialog with no context.

Sources: [Sources/CodexBarCore/KeychainAccessPreflight.swift:126-184]()

---

## Verifying and Resetting the Migration

### Check whether migration has run

```bash
defaults read com.steipete.codexbar KeychainMigrationV1Completed
```

Returns `1` if completed, error if not yet set.

### Reset for local testing

```bash
defaults delete com.steipete.codexbar KeychainMigrationV1Completed
./Scripts/compile_and_run.sh
```

`KeychainMigration.resetMigrationFlag()` (line 144) exists for test-only resets and calls `UserDefaults.standard.removeObject(forKey: migrationKey)`.

### Inspect migration logs

```bash
log show --predicate 'subsystem == "com.steipete.codexbar" && category == "keychain-migration"' --last 10m
```

---

## Flow Summary

```mermaid
flowchart TD
    A[HiddenWindowView .task] -->|detached Task| B[KeychainMigration.migrateIfNeeded]
    B --> C{KeychainAccessGate.isDisabled?}
    C -->|yes| D[return — CI / test / disabled]
    C -->|no| E{UserDefaults\nKeychainMigrationV1Completed?}
    E -->|true| F[return — already done]
    E -->|false| G[Loop over itemsToMigrate]
    G --> H[SecItemCopyMatching\nread item + accessibility]
    H -->|not found| I[skip item]
    H -->|already correct| I
    H -->|needs migration| J[SecItemDelete]
    J --> K[SecItemAdd with\nAfterFirstUnlockThisDeviceOnly]
    K --> G
    G -->|loop done| L[UserDefaults set\nKeychainMigrationV1Completed = true]
```

The migration is the first line of defense against keychain dialogs; `KeychainNoUIQuery` and `KeychainAccessPreflight` form the second line that suppresses prompts on every subsequent read. Together they eliminate the edit-run dialog storm entirely for legacy items, while the Claude OAuth path retains a configurable prompt policy for the separate `Claude Code-credentials` keychain service it does not own. Sources: [docs/KEYCHAIN_FIX.md:32-38]()
