chore: initialize recovered claude workspace

This commit is contained in:
2026-04-02 15:29:01 +08:00
commit a10efa3b4b
1940 changed files with 506426 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import type { SecureStorage, SecureStorageData } from './types.js'
/**
* Creates a fallback storage that tries to use the primary storage first,
* and if that fails, falls back to the secondary storage
*/
export function createFallbackStorage(
primary: SecureStorage,
secondary: SecureStorage,
): SecureStorage {
return {
name: `${primary.name}-with-${secondary.name}-fallback`,
read(): SecureStorageData {
const result = primary.read()
if (result !== null && result !== undefined) {
return result
}
return secondary.read() || {}
},
async readAsync(): Promise<SecureStorageData | null> {
const result = await primary.readAsync()
if (result !== null && result !== undefined) {
return result
}
return (await secondary.readAsync()) || {}
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
// Capture state before update
const primaryDataBefore = primary.read()
const result = primary.update(data)
if (result.success) {
// Delete secondary when migrating to primary for the first time
// This preserves credentials when sharing .claude between host and containers
// See: https://github.com/anthropics/claude-code/issues/1414
if (primaryDataBefore === null) {
secondary.delete()
}
return result
}
const fallbackResult = secondary.update(data)
if (fallbackResult.success) {
// Primary write failed but primary may still hold an *older* valid
// entry. read() prefers primary whenever it returns non-null, so that
// stale entry would shadow the fresh data we just wrote to secondary —
// e.g. a refresh token the server has already rotated away, causing a
// /login loop (#30337). Best-effort delete; if this also fails the
// user's keychain is in a bad state we can't fix from here.
if (primaryDataBefore !== null) {
primary.delete()
}
return {
success: true,
warning: fallbackResult.warning,
}
}
return { success: false }
},
delete(): boolean {
const primarySuccess = primary.delete()
const secondarySuccess = secondary.delete()
return primarySuccess || secondarySuccess
},
}
}

View File

@@ -0,0 +1,17 @@
import { createFallbackStorage } from './fallbackStorage.js'
import { macOsKeychainStorage } from './macOsKeychainStorage.js'
import { plainTextStorage } from './plainTextStorage.js'
import type { SecureStorage } from './types.js'
/**
* Get the appropriate secure storage implementation for the current platform
*/
export function getSecureStorage(): SecureStorage {
if (process.platform === 'darwin') {
return createFallbackStorage(macOsKeychainStorage, plainTextStorage)
}
// TODO: add libsecret support for Linux
return plainTextStorage
}

View File

@@ -0,0 +1,116 @@
/**
* Minimal module for firing macOS keychain reads in parallel with main.tsx
* module evaluation, same pattern as startMdmRawRead() in settings/mdm/rawRead.ts.
*
* isRemoteManagedSettingsEligible() reads two separate keychain entries
* SEQUENTIALLY via sync execSync during applySafeConfigEnvironmentVariables():
* 1. "Claude Code-credentials" (OAuth tokens) — ~32ms
* 2. "Claude Code" (legacy API key) — ~33ms
* Sequential cost: ~65ms on every macOS startup.
*
* Firing both here lets the subprocesses run in parallel with the ~65ms of
* main.tsx imports. ensureKeychainPrefetchCompleted() is awaited alongside
* ensureMdmSettingsLoaded() in main.tsx preAction — nearly free since the
* subprocesses finish during import evaluation. Sync read() and
* getApiKeyFromConfigOrMacOSKeychain() then hit their caches.
*
* Imports stay minimal: child_process + macOsKeychainHelpers.ts (NOT
* macOsKeychainStorage.ts — that pulls in execa → human-signals →
* cross-spawn, ~58ms of synchronous module init). The helpers file's own
* import chain (envUtils, oauth constants, crypto) is already evaluated by
* startupProfiler.ts at main.tsx:5, so no new module-init cost lands here.
*/
import { execFile } from 'child_process'
import { isBareMode } from '../envUtils.js'
import {
CREDENTIALS_SERVICE_SUFFIX,
getMacOsKeychainStorageServiceName,
getUsername,
primeKeychainCacheFromPrefetch,
} from './macOsKeychainHelpers.js'
const KEYCHAIN_PREFETCH_TIMEOUT_MS = 10_000
// Shared with auth.ts getApiKeyFromConfigOrMacOSKeychain() so it can skip its
// sync spawn when the prefetch already landed. Distinguishing "not started" (null)
// from "completed with no key" ({ stdout: null }) lets the sync reader only
// trust a completed prefetch.
let legacyApiKeyPrefetch: { stdout: string | null } | null = null
let prefetchPromise: Promise<void> | null = null
type SpawnResult = { stdout: string | null; timedOut: boolean }
function spawnSecurity(serviceName: string): Promise<SpawnResult> {
return new Promise(resolve => {
execFile(
'security',
['find-generic-password', '-a', getUsername(), '-w', '-s', serviceName],
{ encoding: 'utf-8', timeout: KEYCHAIN_PREFETCH_TIMEOUT_MS },
(err, stdout) => {
// Exit 44 (entry not found) is a valid "no key" result and safe to
// prime as null. But timeout (err.killed) means the keychain MAY have
// a key we couldn't fetch — don't prime, let sync spawn retry.
// biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise
resolve({
stdout: err ? null : stdout?.trim() || null,
timedOut: Boolean(err && 'killed' in err && err.killed),
})
},
)
})
}
/**
* Fire both keychain reads in parallel. Called at main.tsx top-level
* immediately after startMdmRawRead(). Non-darwin is a no-op.
*/
export function startKeychainPrefetch(): void {
if (process.platform !== 'darwin' || prefetchPromise || isBareMode()) return
// Fire both subprocesses immediately (non-blocking). They run in parallel
// with each other AND with main.tsx imports. The await in Promise.all
// happens later via ensureKeychainPrefetchCompleted().
const oauthSpawn = spawnSecurity(
getMacOsKeychainStorageServiceName(CREDENTIALS_SERVICE_SUFFIX),
)
const legacySpawn = spawnSecurity(getMacOsKeychainStorageServiceName())
prefetchPromise = Promise.all([oauthSpawn, legacySpawn]).then(
([oauth, legacy]) => {
// Timed-out prefetch: don't prime. Sync read/spawn will retry with its
// own (longer) timeout. Priming null here would shadow a key that the
// sync path might successfully fetch.
if (!oauth.timedOut) primeKeychainCacheFromPrefetch(oauth.stdout)
if (!legacy.timedOut) legacyApiKeyPrefetch = { stdout: legacy.stdout }
},
)
}
/**
* Await prefetch completion. Called in main.tsx preAction alongside
* ensureMdmSettingsLoaded() — nearly free since subprocesses finish during
* the ~65ms of main.tsx imports. Resolves immediately on non-darwin.
*/
export async function ensureKeychainPrefetchCompleted(): Promise<void> {
if (prefetchPromise) await prefetchPromise
}
/**
* Consumed by getApiKeyFromConfigOrMacOSKeychain() in auth.ts before it
* falls through to sync execSync. Returns null if prefetch hasn't completed.
*/
export function getLegacyApiKeyPrefetchResult(): {
stdout: string | null
} | null {
return legacyApiKeyPrefetch
}
/**
* Clear prefetch result. Called alongside getApiKeyFromConfigOrMacOSKeychain
* cache invalidation so a stale prefetch doesn't shadow a fresh write.
*/
export function clearLegacyApiKeyPrefetch(): void {
legacyApiKeyPrefetch = null
}

View File

@@ -0,0 +1,111 @@
/**
* 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() }
}

View File

@@ -0,0 +1,231 @@
import { execaSync } from 'execa'
import { logForDebugging } from '../debug.js'
import { execFileNoThrow } from '../execFileNoThrow.js'
import { execSyncWithDefaults_DEPRECATED } from '../execFileNoThrowPortable.js'
import { jsonParse, jsonStringify } from '../slowOperations.js'
import {
CREDENTIALS_SERVICE_SUFFIX,
clearKeychainCache,
getMacOsKeychainStorageServiceName,
getUsername,
KEYCHAIN_CACHE_TTL_MS,
keychainCacheState,
} from './macOsKeychainHelpers.js'
import type { SecureStorage, SecureStorageData } from './types.js'
// `security -i` reads stdin with a 4096-byte fgets() buffer (BUFSIZ on darwin).
// A command line longer than this is truncated mid-argument: the first 4096
// bytes are consumed as one command (unterminated quote → fails), the overflow
// is interpreted as a second unknown command. Net: non-zero exit with NO data
// written, but the *previous* keychain entry is left intact — which fallback
// storage then reads as stale. See #30337.
// Headroom of 64B below the limit guards against edge-case line-terminator
// accounting differences.
const SECURITY_STDIN_LINE_LIMIT = 4096 - 64
export const macOsKeychainStorage = {
name: 'keychain',
read(): SecureStorageData | null {
const prev = keychainCacheState.cache
if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
return prev.data
}
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
const result = execSyncWithDefaults_DEPRECATED(
`security find-generic-password -a "${username}" -w -s "${storageServiceName}"`,
)
if (result) {
const data = jsonParse(result)
keychainCacheState.cache = { data, cachedAt: Date.now() }
return data
}
} catch (_e) {
// fall through
}
// Stale-while-error: if we had a value before and the refresh failed,
// keep serving the stale value rather than caching null. Since #23192
// clears the upstream memoize on every API request (macOS path), a
// single transient `security` spawn failure would otherwise poison the
// cache and surface as "Not logged in" across all subsystems until the
// next user interaction. clearKeychainCache() sets data=null, so
// explicit invalidation (logout, delete) still reads through.
if (prev.data !== null) {
logForDebugging('[keychain] read failed; serving stale cache', {
level: 'warn',
})
keychainCacheState.cache = { data: prev.data, cachedAt: Date.now() }
return prev.data
}
keychainCacheState.cache = { data: null, cachedAt: Date.now() }
return null
},
async readAsync(): Promise<SecureStorageData | null> {
const prev = keychainCacheState.cache
if (Date.now() - prev.cachedAt < KEYCHAIN_CACHE_TTL_MS) {
return prev.data
}
if (keychainCacheState.readInFlight) {
return keychainCacheState.readInFlight
}
const gen = keychainCacheState.generation
const promise = doReadAsync().then(data => {
// If the cache was invalidated or updated while we were reading,
// our subprocess result is stale — don't overwrite the newer entry.
if (gen === keychainCacheState.generation) {
// Stale-while-error — mirror read() above.
if (data === null && prev.data !== null) {
logForDebugging('[keychain] readAsync failed; serving stale cache', {
level: 'warn',
})
}
const next = data ?? prev.data
keychainCacheState.cache = { data: next, cachedAt: Date.now() }
keychainCacheState.readInFlight = null
return next
}
return data
})
keychainCacheState.readInFlight = promise
return promise
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
// Invalidate cache before update
clearKeychainCache()
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
const jsonString = jsonStringify(data)
// Convert to hexadecimal to avoid any escaping issues
const hexValue = Buffer.from(jsonString, 'utf-8').toString('hex')
// Prefer stdin (`security -i`) so process monitors (CrowdStrike et al.)
// see only "security -i", not the payload (INC-3028).
// When the payload would overflow the stdin line buffer, fall back to
// argv. Hex in argv is recoverable by a determined observer but defeats
// naive plaintext-grep rules, and the alternative — silent credential
// corruption — is strictly worse. ARG_MAX on darwin is 1MB so argv has
// effectively no size limit for our purposes.
const command = `add-generic-password -U -a "${username}" -s "${storageServiceName}" -X "${hexValue}"\n`
let result
if (command.length <= SECURITY_STDIN_LINE_LIMIT) {
result = execaSync('security', ['-i'], {
input: command,
stdio: ['pipe', 'pipe', 'pipe'],
reject: false,
})
} else {
logForDebugging(
`Keychain payload (${jsonString.length}B JSON) exceeds security -i stdin limit; using argv`,
{ level: 'warn' },
)
result = execaSync(
'security',
[
'add-generic-password',
'-U',
'-a',
username,
'-s',
storageServiceName,
'-X',
hexValue,
],
{ stdio: ['ignore', 'pipe', 'pipe'], reject: false },
)
}
if (result.exitCode !== 0) {
return { success: false }
}
// Update cache with new data on success
keychainCacheState.cache = { data, cachedAt: Date.now() }
return { success: true }
} catch (_e) {
return { success: false }
}
},
delete(): boolean {
// Invalidate cache before delete
clearKeychainCache()
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
execSyncWithDefaults_DEPRECATED(
`security delete-generic-password -a "${username}" -s "${storageServiceName}"`,
)
return true
} catch (_e) {
return false
}
},
} satisfies SecureStorage
async function doReadAsync(): Promise<SecureStorageData | null> {
try {
const storageServiceName = getMacOsKeychainStorageServiceName(
CREDENTIALS_SERVICE_SUFFIX,
)
const username = getUsername()
const { stdout, code } = await execFileNoThrow(
'security',
['find-generic-password', '-a', username, '-w', '-s', storageServiceName],
{ useCwd: false, preserveOutputOnError: false },
)
if (code === 0 && stdout) {
return jsonParse(stdout.trim())
}
} catch (_e) {
// fall through
}
return null
}
let keychainLockedCache: boolean | undefined
/**
* Checks if the macOS keychain is locked.
* Returns true if on macOS and keychain is locked (exit code 36 from security show-keychain-info).
* This commonly happens in SSH sessions where the keychain isn't automatically unlocked.
*
* Cached for process lifetime — execaSync('security', ...) is a ~27ms sync
* subprocess spawn, and this is called from render (AssistantTextMessage).
* During virtual-scroll remounts on sessions with "Not logged in" messages,
* each remount re-spawned security(1), adding 27ms/message to the commit.
* Keychain lock state doesn't change during a CLI session.
*/
export function isMacOsKeychainLocked(): boolean {
if (keychainLockedCache !== undefined) return keychainLockedCache
// Only check on macOS
if (process.platform !== 'darwin') {
keychainLockedCache = false
return false
}
try {
const result = execaSync('security', ['show-keychain-info'], {
reject: false,
stdio: ['ignore', 'pipe', 'pipe'],
})
// Exit code 36 indicates the keychain is locked
keychainLockedCache = result.exitCode === 36
} catch {
// If the command fails for any reason, assume keychain is not locked
keychainLockedCache = false
}
return keychainLockedCache
}

View File

@@ -0,0 +1,84 @@
import { chmodSync } from 'fs'
import { join } from 'path'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
import {
jsonParse,
jsonStringify,
writeFileSync_DEPRECATED,
} from '../slowOperations.js'
import type { SecureStorage, SecureStorageData } from './types.js'
function getStoragePath(): { storageDir: string; storagePath: string } {
const storageDir = getClaudeConfigHomeDir()
const storageFileName = '.credentials.json'
return { storageDir, storagePath: join(storageDir, storageFileName) }
}
export const plainTextStorage = {
name: 'plaintext',
read(): SecureStorageData | null {
// sync IO: called from sync context (SecureStorage interface)
const { storagePath } = getStoragePath()
try {
const data = getFsImplementation().readFileSync(storagePath, {
encoding: 'utf8',
})
return jsonParse(data)
} catch {
return null
}
},
async readAsync(): Promise<SecureStorageData | null> {
const { storagePath } = getStoragePath()
try {
const data = await getFsImplementation().readFile(storagePath, {
encoding: 'utf8',
})
return jsonParse(data)
} catch {
return null
}
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
// sync IO: called from sync context (SecureStorage interface)
try {
const { storageDir, storagePath } = getStoragePath()
try {
getFsImplementation().mkdirSync(storageDir)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code !== 'EEXIST') {
throw e
}
}
writeFileSync_DEPRECATED(storagePath, jsonStringify(data), {
encoding: 'utf8',
flush: false,
})
chmodSync(storagePath, 0o600)
return {
success: true,
warning: 'Warning: Storing credentials in plaintext.',
}
} catch {
return { success: false }
}
},
delete(): boolean {
// sync IO: called from sync context (SecureStorage interface)
const { storagePath } = getStoragePath()
try {
getFsImplementation().unlinkSync(storagePath)
return true
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code === 'ENOENT') {
return true
}
return false
}
},
} satisfies SecureStorage