/ utils / secureStorage / macOsKeychainHelpers.ts
macOsKeychainHelpers.ts
  1  /**
  2   * Lightweight helpers shared between keychainPrefetch.ts and
  3   * macOsKeychainStorage.ts.
  4   *
  5   * This module MUST NOT import execa, execFileNoThrow, or
  6   * execFileNoThrowPortable. keychainPrefetch.ts fires at the very top of
  7   * main.tsx (before the ~65ms of module evaluation it parallelizes), and Bun's
  8   * __esm wrapper evaluates the ENTIRE module when any symbol is accessed —
  9   * so a heavy transitive import here defeats the prefetch. The execa →
 10   * human-signals → cross-spawn chain alone is ~58ms of synchronous init.
 11   *
 12   * The imports below (envUtils, oauth constants, crypto, os) are already
 13   * evaluated by startupProfiler.ts at main.tsx:5, so they add no module-init
 14   * cost when keychainPrefetch.ts pulls this file in.
 15   */
 16  
 17  import { createHash } from 'crypto'
 18  import { userInfo } from 'os'
 19  import { getOauthConfig } from 'src/constants/oauth.js'
 20  import { getClaudeConfigHomeDir } from '../envUtils.js'
 21  import type { SecureStorageData } from './types.js'
 22  
 23  // Suffix distinguishing the OAuth credentials keychain entry from the legacy
 24  // API key entry (which uses no suffix). Both share the service name base.
 25  // DO NOT change this value — it's part of the keychain lookup key and would
 26  // orphan existing stored credentials.
 27  export const CREDENTIALS_SERVICE_SUFFIX = '-credentials'
 28  
 29  export function getMacOsKeychainStorageServiceName(
 30    serviceSuffix: string = '',
 31  ): string {
 32    const configDir = getClaudeConfigHomeDir()
 33    const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR
 34  
 35    // Use a hash of the config dir path to create a unique but stable suffix
 36    // Only add suffix for non-default directories to maintain backwards compatibility
 37    const dirHash = isDefaultDir
 38      ? ''
 39      : `-${createHash('sha256').update(configDir).digest('hex').substring(0, 8)}`
 40    return `Claude Code${getOauthConfig().OAUTH_FILE_SUFFIX}${serviceSuffix}${dirHash}`
 41  }
 42  
 43  export function getUsername(): string {
 44    try {
 45      return process.env.USER || userInfo().username
 46    } catch {
 47      return 'claude-code-user'
 48    }
 49  }
 50  
 51  // --
 52  
 53  // Cache for keychain reads to avoid repeated expensive security CLI calls.
 54  // TTL bounds staleness for cross-process scenarios (another CC instance
 55  // refreshing/invalidating tokens) without forcing a blocking spawnSync on
 56  // every read. In-process writes invalidate via clearKeychainCache() directly.
 57  //
 58  // The sync read() path takes ~500ms per `security` spawn. With 50+ claude.ai
 59  // MCP connectors authenticating at startup, a short TTL expires mid-storm and
 60  // triggers repeat sync reads — observed as a 5.5s event-loop stall
 61  // (go/ccshare/adamj-20260326-212235). 30s of cross-process staleness is fine:
 62  // OAuth tokens expire in hours, and the only cross-process writer is another
 63  // CC instance's /login or refresh.
 64  //
 65  // Lives here (not in macOsKeychainStorage.ts) so keychainPrefetch.ts can
 66  // prime it without pulling in execa. Wrapped in an object because ES module
 67  // `let` bindings aren't writable across module boundaries — both this file
 68  // and macOsKeychainStorage.ts need to mutate all three fields.
 69  export const KEYCHAIN_CACHE_TTL_MS = 30_000
 70  
 71  export const keychainCacheState: {
 72    cache: { data: SecureStorageData | null; cachedAt: number } // cachedAt 0 = invalid
 73    // Incremented on every cache invalidation. readAsync() captures this before
 74    // spawning and skips its cache write if a newer generation exists, preventing
 75    // a stale subprocess result from overwriting fresh data written by update().
 76    generation: number
 77    // Deduplicates concurrent readAsync() calls so TTL expiry under load spawns
 78    // one subprocess, not N. Cleared on invalidation so fresh reads don't join
 79    // a stale in-flight promise.
 80    readInFlight: Promise<SecureStorageData | null> | null
 81  } = {
 82    cache: { data: null, cachedAt: 0 },
 83    generation: 0,
 84    readInFlight: null,
 85  }
 86  
 87  export function clearKeychainCache(): void {
 88    keychainCacheState.cache = { data: null, cachedAt: 0 }
 89    keychainCacheState.generation++
 90    keychainCacheState.readInFlight = null
 91  }
 92  
 93  /**
 94   * Prime the keychain cache from a prefetch result (keychainPrefetch.ts).
 95   * Only writes if the cache hasn't been touched yet — if sync read() or
 96   * update() already ran, their result is authoritative and we discard this.
 97   */
 98  export function primeKeychainCacheFromPrefetch(stdout: string | null): void {
 99    if (keychainCacheState.cache.cachedAt !== 0) return
100    let data: SecureStorageData | null = null
101    if (stdout) {
102      try {
103        // eslint-disable-next-line custom-rules/no-direct-json-operations -- jsonParse() pulls slowOperations (lodash-es/cloneDeep) into the early-startup import chain; see file header
104        data = JSON.parse(stdout)
105      } catch {
106        // malformed prefetch result — let sync read() re-fetch
107        return
108      }
109    }
110    keychainCacheState.cache = { data, cachedAt: Date.now() }
111  }