Files
orcs-code/src/utils/secureStorage/macOsKeychainHelpers.ts
gnanam1990 e43ba9da69 feat(config): add OPENCLAUDE_CONFIG_DIR env var as preferred alias for CLAUDE_CONFIG_DIR (#454)
The legacy CLAUDE_CONFIG_DIR name was the only way to point openclaude
at a non-default config home, which leaked Anthropic branding for a
fork that has otherwise rebranded to OpenClaude. Add OPENCLAUDE_CONFIG_DIR
as the preferred name. CLAUDE_CONFIG_DIR continues to work for
backward compatibility; when both are set with different values,
OPENCLAUDE_CONFIG_DIR wins and a one-time warning is logged.

- src/utils/envUtils.ts: introduce resolveConfigDirEnv() that picks
  OPENCLAUDE_CONFIG_DIR over CLAUDE_CONFIG_DIR and emits a conflict
  warning. Memoize cache key now tracks both env vars so changing
  either invalidates the cached result.
- src/utils/env.ts: getGlobalClaudeFile() previously read
  CLAUDE_CONFIG_DIR directly, missing the new alias. Route through
  resolveConfigDirEnv() so the global config file path follows the
  same precedence.
- src/utils/secureStorage/macOsKeychainHelpers.ts: the "is default
  dir" check used by keychain service-name scoping now considers
  both env vars.
- src/utils/swarm/spawnUtils.ts: forward OPENCLAUDE_CONFIG_DIR to
  teammate processes alongside the legacy var.
- src/utils/openclaudePaths.test.ts: +6 unit tests covering the new
  alias, fallthrough, conflict warning, and resolveConfigDirEnv()
  in isolation.
- .env.example: document both env vars and the precedence rule.

Verified locally on Linux: with only OPENCLAUDE_CONFIG_DIR set, with
only CLAUDE_CONFIG_DIR set (legacy still works), with both set
matching (silent), with both set conflicting (warn once + OPENCLAUDE
wins), with neither set (default ~/.openclaude). Memo cache
invalidates across 4 sequential env transitions. Built dist/cli.mjs
honors the new var and emits the conflict warning to the user.
2026-04-28 11:50:34 +05:30

123 lines
5.0 KiB
TypeScript

/**
* 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 './index.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'
/**
* Get the service/resource name for secure storage, scoped by CLAUDE_CONFIG_DIR
* if it's set to a non-default location.
*/
export function getSecureStorageServiceName(
serviceSuffix: string = '',
): string {
const configDir = getClaudeConfigHomeDir()
const isDefaultDir =
!process.env.OPENCLAUDE_CONFIG_DIR && !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 getMacOsKeychainStorageServiceName(
serviceSuffix: string = '',
): string {
return getSecureStorageServiceName(serviceSuffix)
}
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() }
}