/ src / utils / secureStorage / keychainPrefetch.ts
keychainPrefetch.ts
  1  /**
  2   * Minimal module for firing macOS keychain reads in parallel with main.tsx
  3   * module evaluation, same pattern as startMdmRawRead() in settings/mdm/rawRead.ts.
  4   *
  5   * isRemoteManagedSettingsEligible() reads two separate keychain entries
  6   * SEQUENTIALLY via sync execSync during applySafeConfigEnvironmentVariables():
  7   *   1. "Claude Code-credentials" (OAuth tokens)  — ~32ms
  8   *   2. "Claude Code" (legacy API key)            — ~33ms
  9   * Sequential cost: ~65ms on every macOS startup.
 10   *
 11   * Firing both here lets the subprocesses run in parallel with the ~65ms of
 12   * main.tsx imports. ensureKeychainPrefetchCompleted() is awaited alongside
 13   * ensureMdmSettingsLoaded() in main.tsx preAction — nearly free since the
 14   * subprocesses finish during import evaluation. Sync read() and
 15   * getApiKeyFromConfigOrMacOSKeychain() then hit their caches.
 16   *
 17   * Imports stay minimal: child_process + macOsKeychainHelpers.ts (NOT
 18   * macOsKeychainStorage.ts — that pulls in execa → human-signals →
 19   * cross-spawn, ~58ms of synchronous module init). The helpers file's own
 20   * import chain (envUtils, oauth constants, crypto) is already evaluated by
 21   * startupProfiler.ts at main.tsx:5, so no new module-init cost lands here.
 22   */
 23  
 24  import { execFile } from 'child_process'
 25  import { isBareMode } from '../envUtils.js'
 26  import {
 27    CREDENTIALS_SERVICE_SUFFIX,
 28    getMacOsKeychainStorageServiceName,
 29    getUsername,
 30    primeKeychainCacheFromPrefetch,
 31  } from './macOsKeychainHelpers.js'
 32  
 33  const KEYCHAIN_PREFETCH_TIMEOUT_MS = 10_000
 34  
 35  // Shared with auth.ts getApiKeyFromConfigOrMacOSKeychain() so it can skip its
 36  // sync spawn when the prefetch already landed. Distinguishing "not started" (null)
 37  // from "completed with no key" ({ stdout: null }) lets the sync reader only
 38  // trust a completed prefetch.
 39  let legacyApiKeyPrefetch: { stdout: string | null } | null = null
 40  
 41  let prefetchPromise: Promise<void> | null = null
 42  
 43  type SpawnResult = { stdout: string | null; timedOut: boolean }
 44  
 45  function spawnSecurity(serviceName: string): Promise<SpawnResult> {
 46    return new Promise(resolve => {
 47      execFile(
 48        'security',
 49        ['find-generic-password', '-a', getUsername(), '-w', '-s', serviceName],
 50        { encoding: 'utf-8', timeout: KEYCHAIN_PREFETCH_TIMEOUT_MS },
 51        (err, stdout) => {
 52          // Exit 44 (entry not found) is a valid "no key" result and safe to
 53          // prime as null. But timeout (err.killed) means the keychain MAY have
 54          // a key we couldn't fetch — don't prime, let sync spawn retry.
 55          // biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise
 56          resolve({
 57            stdout: err ? null : stdout?.trim() || null,
 58            timedOut: Boolean(err && 'killed' in err && err.killed),
 59          })
 60        },
 61      )
 62    })
 63  }
 64  
 65  /**
 66   * Fire both keychain reads in parallel. Called at main.tsx top-level
 67   * immediately after startMdmRawRead(). Non-darwin is a no-op.
 68   */
 69  export function startKeychainPrefetch(): void {
 70    if (process.platform !== 'darwin' || prefetchPromise || isBareMode()) return
 71  
 72    // Fire both subprocesses immediately (non-blocking). They run in parallel
 73    // with each other AND with main.tsx imports. The await in Promise.all
 74    // happens later via ensureKeychainPrefetchCompleted().
 75    const oauthSpawn = spawnSecurity(
 76      getMacOsKeychainStorageServiceName(CREDENTIALS_SERVICE_SUFFIX),
 77    )
 78    const legacySpawn = spawnSecurity(getMacOsKeychainStorageServiceName())
 79  
 80    prefetchPromise = Promise.all([oauthSpawn, legacySpawn]).then(
 81      ([oauth, legacy]) => {
 82        // Timed-out prefetch: don't prime. Sync read/spawn will retry with its
 83        // own (longer) timeout. Priming null here would shadow a key that the
 84        // sync path might successfully fetch.
 85        if (!oauth.timedOut) primeKeychainCacheFromPrefetch(oauth.stdout)
 86        if (!legacy.timedOut) legacyApiKeyPrefetch = { stdout: legacy.stdout }
 87      },
 88    )
 89  }
 90  
 91  /**
 92   * Await prefetch completion. Called in main.tsx preAction alongside
 93   * ensureMdmSettingsLoaded() — nearly free since subprocesses finish during
 94   * the ~65ms of main.tsx imports. Resolves immediately on non-darwin.
 95   */
 96  export async function ensureKeychainPrefetchCompleted(): Promise<void> {
 97    if (prefetchPromise) await prefetchPromise
 98  }
 99  
100  /**
101   * Consumed by getApiKeyFromConfigOrMacOSKeychain() in auth.ts before it
102   * falls through to sync execSync. Returns null if prefetch hasn't completed.
103   */
104  export function getLegacyApiKeyPrefetchResult(): {
105    stdout: string | null
106  } | null {
107    return legacyApiKeyPrefetch
108  }
109  
110  /**
111   * Clear prefetch result. Called alongside getApiKeyFromConfigOrMacOSKeychain
112   * cache invalidation so a stale prefetch doesn't shadow a fresh write.
113   */
114  export function clearLegacyApiKeyPrefetch(): void {
115    legacyApiKeyPrefetch = null
116  }