πŸ“„ File detail

utils/secureStorage/macOsKeychainHelpers.ts

🧩 .tsπŸ“ 112 linesπŸ’Ύ 4,797 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œutils/”, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, …). On the API surface it exposes CREDENTIALS_SERVICE_SUFFIX, getMacOsKeychainStorageServiceName, getUsername, KEYCHAIN_CACHE_TTL_MS, and keychainCacheState (and more) β€” mainly functions, hooks, or classes. Dependencies touch crypto, Node OS/process metadata, and src. It composes internal code from envUtils and types (relative imports). What the file header says: Lightweight helpers shared between keychainPrefetch.ts and macOsKeychainStorage.ts. This module MUST NOT import execa, execFileNoThrow, or execFileNoThrowPortable. keychainPrefetch.ts fires at the very top of main.tsx (before the ~65ms of module evaluation it parallelizes), and B.

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

Lightweight helpers shared between keychainPrefetch.ts and macOsKeychainStorage.ts. This module MUST NOT import execa, execFileNoThrow, or execFileNoThrowPortable. keychainPrefetch.ts fires at the very top of main.tsx (before the ~65ms of module evaluation it parallelizes), and Bun's __esm wrapper evaluates the ENTIRE module when any symbol is accessed β€” so a heavy transitive import here defeats the prefetch. The execa β†’ human-signals β†’ cross-spawn chain alone is ~58ms of synchronous init. The imports below (envUtils, oauth constants, crypto, os) are already evaluated by startupProfiler.ts at main.tsx:5, so they add no module-init cost when keychainPrefetch.ts pulls this file in.

πŸ“€ Exports (heuristic)

  • CREDENTIALS_SERVICE_SUFFIX
  • getMacOsKeychainStorageServiceName
  • getUsername
  • KEYCHAIN_CACHE_TTL_MS
  • keychainCacheState
  • clearKeychainCache
  • primeKeychainCacheFromPrefetch

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • crypto
  • os
  • src

πŸ–₯️ Source preview

/**
 * Lightweight helpers shared between keychainPrefetch.ts and
 * macOsKeychainStorage.ts.
 *
 * This module MUST NOT import execa, execFileNoThrow, or
 * execFileNoThrowPortable. keychainPrefetch.ts fires at the very top of
 * main.tsx (before the ~65ms of module evaluation it parallelizes), and Bun's
 * __esm wrapper evaluates the ENTIRE module when any symbol is accessed β€”
 * so a heavy transitive import here defeats the prefetch. The execa β†’
 * human-signals β†’ cross-spawn chain alone is ~58ms of synchronous init.
 *
 * The imports below (envUtils, oauth constants, crypto, os) are already
 * evaluated by startupProfiler.ts at main.tsx:5, so they add no module-init
 * cost when keychainPrefetch.ts pulls this file in.
 */

import { createHash } from 'crypto'
import { userInfo } from 'os'
import { getOauthConfig } from 'src/constants/oauth.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import type { SecureStorageData } from './types.js'

// Suffix distinguishing the OAuth credentials keychain entry from the legacy
// API key entry (which uses no suffix). Both share the service name base.
// DO NOT change this value β€” it's part of the keychain lookup key and would
// orphan existing stored credentials.
export const CREDENTIALS_SERVICE_SUFFIX = '-credentials'

export function getMacOsKeychainStorageServiceName(
  serviceSuffix: string = '',
): string {
  const configDir = getClaudeConfigHomeDir()
  const isDefaultDir = !process.env.CLAUDE_CONFIG_DIR

  // Use a hash of the config dir path to create a unique but stable suffix
  // Only add suffix for non-default directories to maintain backwards compatibility
  const dirHash = isDefaultDir
    ? ''
    : `-${createHash('sha256').update(configDir).digest('hex').substring(0, 8)}`
  return `Claude Code${getOauthConfig().OAUTH_FILE_SUFFIX}${serviceSuffix}${dirHash}`
}

export function getUsername(): string {
  try {
    return process.env.USER || userInfo().username
  } catch {
    return 'claude-code-user'
  }
}

// --

// Cache for keychain reads to avoid repeated expensive security CLI calls.
// TTL bounds staleness for cross-process scenarios (another CC instance
// refreshing/invalidating tokens) without forcing a blocking spawnSync on
// every read. In-process writes invalidate via clearKeychainCache() directly.
//
// The sync read() path takes ~500ms per `security` spawn. With 50+ claude.ai
// MCP connectors authenticating at startup, a short TTL expires mid-storm and
// triggers repeat sync reads β€” observed as a 5.5s event-loop stall
// (go/ccshare/adamj-20260326-212235). 30s of cross-process staleness is fine:
// OAuth tokens expire in hours, and the only cross-process writer is another
// CC instance's /login or refresh.
//
// Lives here (not in macOsKeychainStorage.ts) so keychainPrefetch.ts can
// prime it without pulling in execa. Wrapped in an object because ES module
// `let` bindings aren't writable across module boundaries β€” both this file
// and macOsKeychainStorage.ts need to mutate all three fields.
export const KEYCHAIN_CACHE_TTL_MS = 30_000

export const keychainCacheState: {
  cache: { data: SecureStorageData | null; cachedAt: number } // cachedAt 0 = invalid
  // Incremented on every cache invalidation. readAsync() captures this before
  // spawning and skips its cache write if a newer generation exists, preventing
  // a stale subprocess result from overwriting fresh data written by update().
  generation: number
  // Deduplicates concurrent readAsync() calls so TTL expiry under load spawns
  // one subprocess, not N. Cleared on invalidation so fresh reads don't join
  // a stale in-flight promise.
  readInFlight: Promise<SecureStorageData | null> | null
} = {
  cache: { data: null, cachedAt: 0 },
  generation: 0,
  readInFlight: null,
}

export function clearKeychainCache(): void {
  keychainCacheState.cache = { data: null, cachedAt: 0 }
  keychainCacheState.generation++
  keychainCacheState.readInFlight = null
}

/**
 * Prime the keychain cache from a prefetch result (keychainPrefetch.ts).
 * Only writes if the cache hasn't been touched yet β€” if sync read() or
 * update() already ran, their result is authoritative and we discard this.
 */
export function primeKeychainCacheFromPrefetch(stdout: string | null): void {
  if (keychainCacheState.cache.cachedAt !== 0) return
  let data: SecureStorageData | null = null
  if (stdout) {
    try {
      // 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
      data = JSON.parse(stdout)
    } catch {
      // malformed prefetch result β€” let sync read() re-fetch
      return
    }
  }
  keychainCacheState.cache = { data, cachedAt: Date.now() }
}