chore: initialize recovered claude workspace
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user