/// // src/utils/secrets.ts // Secure storage utilities for tokens using Bun's secrets API with Node.js fallback let secrets: typeof Bun.secrets; let secretsAvailable = false; // Try to import Bun's secrets API, fallback if unavailable try { secrets = require("bun").secrets; secretsAvailable = true; } catch { // Running in Node.js or Bun secrets unavailable secretsAvailable = false; } let SERVICE_NAME = "letta-code"; const API_KEY_NAME = "letta-api-key"; const REFRESH_TOKEN_NAME = "letta-refresh-token"; function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function isDuplicateKeychainItemError(error: unknown): boolean { const message = getErrorMessage(error); return ( message.includes("already exists in the keychain") || message.includes("code: -25299") ); } async function setSecretValue(name: string, value: string): Promise { if (!secretsAvailable) { throw new Error("Secrets API unavailable"); } try { await secrets.set({ service: SERVICE_NAME, name, value, }); return; } catch (error) { if (!isDuplicateKeychainItemError(error)) { throw error; } } // Replace existing keychain item and retry once. try { await secrets.delete({ service: SERVICE_NAME, name, }); } catch { // Ignore delete errors and retry set below. } await secrets.set({ service: SERVICE_NAME, name, value, }); } /** * Override the keychain service name (useful for tests to avoid touching real credentials) */ export function setServiceName(name: string): void { SERVICE_NAME = name; } // Note: When secrets API is unavailable (Node.js), tokens will be managed // by the settings manager which falls back to storing in the settings file // This provides persistence across restarts export interface SecureTokens { apiKey?: string; refreshToken?: string; } /** * Store API key in system secrets */ export async function setApiKey(apiKey: string): Promise { if (!secretsAvailable) { // When secrets unavailable, let the settings manager handle fallback throw new Error("Secrets API unavailable"); } await setSecretValue(API_KEY_NAME, apiKey); } /** * Retrieve API key from system secrets */ export async function getApiKey(): Promise { if (secretsAvailable) { try { return await secrets.get({ service: SERVICE_NAME, name: API_KEY_NAME, }); } catch (error) { console.warn(`Failed to retrieve API key from secrets: ${error}`); } } // When secrets unavailable, return null (settings manager will use fallback) return null; } /** * Store refresh token in system secrets */ export async function setRefreshToken(refreshToken: string): Promise { if (!secretsAvailable) { // When secrets unavailable, let the settings manager handle fallback throw new Error("Secrets API unavailable"); } await setSecretValue(REFRESH_TOKEN_NAME, refreshToken); } /** * Retrieve refresh token from system secrets */ export async function getRefreshToken(): Promise { if (secretsAvailable) { try { return await secrets.get({ service: SERVICE_NAME, name: REFRESH_TOKEN_NAME, }); } catch (error) { console.warn(`Failed to retrieve refresh token from secrets: ${error}`); } } // When secrets unavailable, return null (settings manager will use fallback) return null; } /** * Get both tokens from secrets */ export async function getSecureTokens(): Promise { const [apiKey, refreshToken] = await Promise.allSettled([ getApiKey(), getRefreshToken(), ]); return { apiKey: apiKey.status === "fulfilled" ? apiKey.value || undefined : undefined, refreshToken: refreshToken.status === "fulfilled" ? refreshToken.value || undefined : undefined, }; } /** * Store both tokens in secrets */ export async function setSecureTokens(tokens: SecureTokens): Promise { const promises: Promise[] = []; if (tokens.apiKey) { promises.push(setApiKey(tokens.apiKey)); } if (tokens.refreshToken) { promises.push(setRefreshToken(tokens.refreshToken)); } if (promises.length > 0) { await Promise.all(promises); } } /** * Remove API key from system secrets */ export async function deleteApiKey(): Promise { if (secretsAvailable) { try { await secrets.delete({ service: SERVICE_NAME, name: API_KEY_NAME, }); return; } catch (error) { console.warn(`Failed to delete API key from secrets: ${error}`); } } // When secrets unavailable, deletion is handled by settings manager // No action needed here } /** * Remove refresh token from system secrets */ export async function deleteRefreshToken(): Promise { if (secretsAvailable) { try { await secrets.delete({ service: SERVICE_NAME, name: REFRESH_TOKEN_NAME, }); return; } catch (error) { console.warn(`Failed to delete refresh token from secrets: ${error}`); } } // When secrets unavailable, deletion is handled by settings manager // No action needed here } /** * Remove all tokens from system secrets */ export async function deleteSecureTokens(): Promise { await Promise.allSettled([deleteApiKey(), deleteRefreshToken()]); } /** * Check if secrets API is available * Set LETTA_SKIP_KEYCHAIN_CHECK=1 to skip the check (useful in CI/test environments) */ export async function isKeychainAvailable(): Promise { // Skip keychain check in test/CI environments to avoid error dialogs if (process.env.LETTA_SKIP_KEYCHAIN_CHECK === "1") { return false; } if (!secretsAvailable) { return false; } try { // Non-mutating probe: if this call succeeds (even with null), keychain is usable. await secrets.get({ service: SERVICE_NAME, name: API_KEY_NAME, }); return true; } catch { return false; } } /** Const value of isKeychainAvailable * Precomputed for tests */ export const keychainAvailablePrecompute = await isKeychainAvailable();