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 { 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 { 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 }