chore: initialize recovered claude workspace
This commit is contained in:
70
src/utils/secureStorage/fallbackStorage.ts
Normal file
70
src/utils/secureStorage/fallbackStorage.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
17
src/utils/secureStorage/index.ts
Normal file
17
src/utils/secureStorage/index.ts
Normal 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
|
||||
}
|
||||
116
src/utils/secureStorage/keychainPrefetch.ts
Normal file
116
src/utils/secureStorage/keychainPrefetch.ts
Normal 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
|
||||
}
|
||||
111
src/utils/secureStorage/macOsKeychainHelpers.ts
Normal file
111
src/utils/secureStorage/macOsKeychainHelpers.ts
Normal 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() }
|
||||
}
|
||||
231
src/utils/secureStorage/macOsKeychainStorage.ts
Normal file
231
src/utils/secureStorage/macOsKeychainStorage.ts
Normal 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
|
||||
}
|
||||
84
src/utils/secureStorage/plainTextStorage.ts
Normal file
84
src/utils/secureStorage/plainTextStorage.ts
Normal 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
|
||||
Reference in New Issue
Block a user