diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 7dc83bac..10f7f20e 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -95,10 +95,6 @@ export class ClaudeProfileManager { // This repairs emails that were truncated due to ANSI escape codes in terminal output this.migrateCorruptedEmails(); - // Populate missing subscription metadata for existing profiles - // This reads subscriptionType and rateLimitTier from Keychain credentials - this.populateSubscriptionMetadata(); - this.initialized = true; } @@ -117,6 +113,8 @@ export class ClaudeProfileManager { continue; } + // Import dynamically to avoid circular dependency issues + const { getEmailFromConfigDir } = require('./claude-profile/profile-utils'); const configEmail = getEmailFromConfigDir(profile.configDir); if (configEmail && profile.email !== configEmail) { @@ -136,58 +134,6 @@ export class ClaudeProfileManager { } } - /** - * Populate missing subscription metadata (subscriptionType, rateLimitTier) for existing profiles. - * - * This reads from Keychain credentials and updates profiles that don't have this metadata. - * Runs on initialization to ensure existing profiles get the subscription info for UI display. - */ - private populateSubscriptionMetadata(): void { - let needsSave = false; - - for (const profile of this.data.profiles) { - if (!profile.configDir) { - continue; - } - - // Skip if profile already has subscription metadata - if (profile.subscriptionType && profile.rateLimitTier) { - continue; - } - - // Expand ~ to home directory - const expandedConfigDir = normalizeWindowsPath( - profile.configDir.startsWith('~') - ? profile.configDir.replace(/^~/, homedir()) - : profile.configDir - ); - - // Use helper with onlyIfMissing option to preserve existing values - const result = updateProfileSubscriptionMetadata(profile, expandedConfigDir, { onlyIfMissing: true }); - - if (result.subscriptionTypeUpdated) { - needsSave = true; - console.warn('[ClaudeProfileManager] Populated subscriptionType for profile:', { - profileId: profile.id, - subscriptionType: result.subscriptionType - }); - } - - if (result.rateLimitTierUpdated) { - needsSave = true; - console.warn('[ClaudeProfileManager] Populated rateLimitTier for profile:', { - profileId: profile.id, - rateLimitTier: result.rateLimitTier - }); - } - } - - if (needsSave) { - this.save(); - console.warn('[ClaudeProfileManager] Subscription metadata population complete'); - } - } - /** * Check if the profile manager has been initialized */ @@ -195,6 +141,31 @@ export class ClaudeProfileManager { return this.initialized; } + /** + * Load profiles from disk + */ + private load(): ProfileStoreData { + const loadedData = loadProfileStore(this.storePath); + if (loadedData) { + if (process.env.DEBUG === 'true') { + console.warn('[ClaudeProfileManager] Loaded profiles:', { + count: loadedData.profiles.length, + activeProfileId: loadedData.activeProfileId, + profiles: loadedData.profiles.map(p => ({ + id: p.id, + name: p.name, + email: p.email, + isDefault: p.isDefault + })) + }); + } + return loadedData; + } + + // Return default with a single "Default" profile + return this.createDefaultData(); + } + /** * Create default profile data * @@ -524,8 +495,14 @@ export class ClaudeProfileManager { const profile = this.getActiveProfile(); const env: Record = {}; - // All profiles now use explicit CLAUDE_CONFIG_DIR for isolation - // This prevents interference with external Claude Code CLI usage + // Default profile: Claude CLI uses ~/.claude implicitly (no env var needed) + if (profile?.isDefault) { + console.warn('[ClaudeProfileManager] Using default profile (Claude CLI uses ~/.claude)'); + return env; + } + + // Non-default profiles: set CLAUDE_CONFIG_DIR to point Claude CLI to profile's config + // Claude CLI will read fresh tokens from Keychain, benefiting from auto-refresh if (profile?.configDir) { // Expand ~ to home directory for the environment variable const expandedConfigDir = normalizeWindowsPath( @@ -535,9 +512,7 @@ export class ClaudeProfileManager { ); env.CLAUDE_CONFIG_DIR = expandedConfigDir; - if (process.env.DEBUG === 'true') { - console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); - } + console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); } else { console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name); } diff --git a/apps/frontend/src/main/claude-profile/credential-utils.test.ts b/apps/frontend/src/main/claude-profile/credential-utils.test.ts index 3089d244..0143e5bb 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.test.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.test.ts @@ -34,7 +34,6 @@ import { getKeychainServiceName, getWindowsCredentialTarget, getCredentialsFromKeychain, - getFullCredentialsFromKeychain, getCredentials, clearKeychainCache, clearCredentialCache, @@ -211,37 +210,8 @@ describe('credential-utils', () => { vi.mocked(homedir).mockReturnValue('/home/testuser'); }); - // Helper to mock Secret Service not available (secret-tool not found) - const mockSecretServiceUnavailable = () => { - vi.mocked(existsSync).mockImplementation((path) => { - const pathStr = String(path); - // secret-tool not found - if (pathStr.includes('secret-tool')) return false; - // credentials file exists - if (pathStr.includes('.credentials.json')) return true; - return false; - }); - }; - - it('should return credentials from Secret Service when available', () => { - // secret-tool exists and returns credentials + it('should return credentials from .credentials.json', () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-secret-service-token', - email: 'secretservice@example.com', - }, - })); - - const result = getCredentialsFromKeychain(); - - expect(result.token).toBe('sk-ant-secret-service-token'); - expect(result.email).toBe('secretservice@example.com'); - expect(result.error).toBeUndefined(); - }); - - it('should fall back to .credentials.json when Secret Service unavailable', () => { - mockSecretServiceUnavailable(); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-linux-token-456', @@ -256,7 +226,7 @@ describe('credential-utils', () => { expect(result.error).toBeUndefined(); }); - it('should return null when credentials file not found and Secret Service unavailable', () => { + it('should return null when credentials file not found', () => { vi.mocked(existsSync).mockReturnValue(false); const result = getCredentialsFromKeychain(); @@ -267,12 +237,7 @@ describe('credential-utils', () => { it('should use custom configDir for credentials path', () => { const customConfigDir = '/home/user/.claude-profiles/work'; - mockSecretServiceUnavailable(); - vi.mocked(existsSync).mockImplementation((path) => { - const pathStr = String(path); - if (pathStr.includes('secret-tool')) return false; - return true; // credentials file exists - }); + vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-custom-token', @@ -287,7 +252,7 @@ describe('credential-utils', () => { }); it('should handle emailAddress field (alternative email location)', () => { - mockSecretServiceUnavailable(); + vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-test-token', @@ -301,7 +266,7 @@ describe('credential-utils', () => { }); it('should handle top-level email field', () => { - mockSecretServiceUnavailable(); + vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-test-token', @@ -315,7 +280,7 @@ describe('credential-utils', () => { }); it('should handle file read permission errors', () => { - mockSecretServiceUnavailable(); + vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockImplementation(() => { throw new Error('EACCES: permission denied'); }); @@ -325,30 +290,6 @@ describe('credential-utils', () => { expect(result.token).toBeNull(); expect(result.email).toBeNull(); }); - - it('should fall back to file when Secret Service lookup fails', () => { - // secret-tool exists but lookup fails - vi.mocked(existsSync).mockImplementation((path) => { - const pathStr = String(path); - if (pathStr.includes('secret-tool')) return true; - if (pathStr.includes('.credentials.json')) return true; - return false; - }); - vi.mocked(execFileSync).mockImplementation(() => { - throw new Error('secret-tool lookup failed'); - }); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-fallback-token', - email: 'fallback@example.com', - }, - })); - - const result = getCredentialsFromKeychain(); - - expect(result.token).toBe('sk-ant-fallback-token'); - expect(result.email).toBe('fallback@example.com'); - }); }); describe('getCredentialsFromKeychain (Windows)', () => { @@ -359,24 +300,19 @@ describe('credential-utils', () => { vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser'); }); - it('should return null when PowerShell not found and no credentials file exists', () => { - // Neither PowerShell nor credentials file exists + it('should return null when PowerShell not found', () => { vi.mocked(existsSync).mockReturnValue(false); const result = getCredentialsFromKeychain(); expect(result.token).toBeNull(); expect(result.email).toBeNull(); - // No error because file fallback returns null gracefully when file doesn't exist + expect(result.error).toBe('PowerShell not found'); }); - it('should return credentials from Windows Credential Manager when file is empty', () => { - // Mock PowerShell path found, but credentials file doesn't exist - vi.mocked(existsSync).mockImplementation((path: unknown) => { - const pathStr = String(path); - // PowerShell exists, but credentials file doesn't - return pathStr.includes('PowerShell') || pathStr.includes('powershell'); - }); + it('should return credentials from Windows Credential Manager', () => { + // Mock PowerShell path found + vi.mocked(existsSync).mockReturnValue(true); vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ claudeAiOauth: { accessToken: 'sk-ant-windows-token-789', @@ -390,33 +326,9 @@ describe('credential-utils', () => { expect(result.email).toBe('windows@example.com'); }); - it('should fall back to file when Credential Manager returns empty', () => { - // Mock PowerShell exists but returns empty (no credential in Credential Manager) - // Mock file exists with valid credentials + it('should return null when credential not found', () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-file-fallback-token', - email: 'file@example.com', - }, - })); - - const result = getCredentialsFromKeychain(); - - expect(result.token).toBe('sk-ant-file-fallback-token'); - expect(result.email).toBe('file@example.com'); - }); - - it('should return null when both Credential Manager and file have no credentials', () => { - // Mock PowerShell exists but returns empty - // Mock credentials file doesn't exist - vi.mocked(existsSync).mockImplementation((path: unknown) => { - const pathStr = String(path); - // PowerShell exists, but credentials file doesn't - return pathStr.includes('PowerShell') || pathStr.includes('powershell'); - }); - vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty + vi.mocked(execFileSync).mockReturnValue(''); const result = getCredentialsFromKeychain(); @@ -424,137 +336,14 @@ describe('credential-utils', () => { expect(result.email).toBeNull(); }); - it('should handle invalid JSON from Credential Manager by falling back to file', () => { + it('should handle invalid JSON from Credential Manager', () => { vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(execFileSync).mockReturnValue('invalid json'); // Invalid JSON from Credential Manager - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-file-token-after-cm-failure', - email: 'fallback@example.com', - }, - })); + vi.mocked(execFileSync).mockReturnValue('invalid json'); const result = getCredentialsFromKeychain(); - // Should fall back to file and get valid credentials - expect(result.token).toBe('sk-ant-file-token-after-cm-failure'); - expect(result.email).toBe('fallback@example.com'); - }); - - it('should prefer file credentials when both sources have tokens', () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-windows-file-token', - email: 'windowsfile@example.com', - }, - })); - vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-credman-token', - email: 'credman@example.com', - }, - })); - - const result = getCredentialsFromKeychain(); - - // Should prefer file since Claude CLI writes there after login - expect(result.token).toBe('sk-ant-windows-file-token'); - expect(result.email).toBe('windowsfile@example.com'); - }); - }); - - describe('getFullCredentialsFromKeychain (Windows)', () => { - beforeEach(() => { - vi.mocked(isMacOS).mockReturnValue(false); - vi.mocked(isWindows).mockReturnValue(true); - vi.mocked(isLinux).mockReturnValue(false); - vi.mocked(homedir).mockReturnValue('C:\\Users\\TestUser'); - clearCredentialCache(); - }); - - it('should return full credentials from file when available', () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-full-creds-token', - refreshToken: 'refresh-token-123', - expiresAt: 1700000000000, - email: 'full@example.com', - scopes: ['user:read', 'user:write'], - }, - })); - vi.mocked(execFileSync).mockReturnValue(''); // Credential Manager empty - - const result = getFullCredentialsFromKeychain(); - - expect(result.token).toBe('sk-ant-full-creds-token'); - expect(result.refreshToken).toBe('refresh-token-123'); - expect(result.expiresAt).toBe(1700000000000); - expect(result.email).toBe('full@example.com'); - expect(result.scopes).toEqual(['user:read', 'user:write']); - }); - - it('should return credentials from Credential Manager when file is empty', () => { - vi.mocked(existsSync).mockImplementation((path: unknown) => { - const pathStr = String(path); - return pathStr.includes('PowerShell') || pathStr.includes('powershell'); - }); - vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-credman-full-token', - refreshToken: 'credman-refresh', - expiresAt: 1700000000000, - email: 'credman@example.com', - }, - })); - - const result = getFullCredentialsFromKeychain(); - - expect(result.token).toBe('sk-ant-credman-full-token'); - expect(result.refreshToken).toBe('credman-refresh'); - expect(result.email).toBe('credman@example.com'); - }); - - it('should prefer file credentials when both sources have tokens (consistent with basic API)', () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-file-full-token', - refreshToken: 'file-refresh', - expiresAt: 1700000000000, - email: 'file@example.com', - }, - })); - vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ - claudeAiOauth: { - accessToken: 'sk-ant-credman-full-token', - refreshToken: 'credman-refresh', - expiresAt: 1800000000000, // Later expiry - email: 'credman@example.com', - }, - })); - - const result = getFullCredentialsFromKeychain(); - - // Should prefer file since Claude CLI writes there after login - // This is consistent with getCredentialsFromKeychain behavior - expect(result.token).toBe('sk-ant-file-full-token'); - expect(result.refreshToken).toBe('file-refresh'); - expect(result.email).toBe('file@example.com'); - }); - - it('should return null when both sources have no credentials', () => { - vi.mocked(existsSync).mockImplementation((path: unknown) => { - const pathStr = String(path); - return pathStr.includes('PowerShell') || pathStr.includes('powershell'); - }); - vi.mocked(execFileSync).mockReturnValue(''); - - const result = getFullCredentialsFromKeychain(); - expect(result.token).toBeNull(); - expect(result.refreshToken).toBeNull(); + expect(result.email).toBeNull(); }); }); diff --git a/apps/frontend/src/main/claude-profile/credential-utils.ts b/apps/frontend/src/main/claude-profile/credential-utils.ts index 38f53aec..32659755 100644 --- a/apps/frontend/src/main/claude-profile/credential-utils.ts +++ b/apps/frontend/src/main/claude-profile/credential-utils.ts @@ -4,7 +4,7 @@ * Provides functions to retrieve Claude Code OAuth tokens and email from * platform-specific secure storage: * - macOS: Keychain (via `security` command) - * - Linux: Secret Service API (via `secret-tool` command), with fallback to .credentials.json file + * - Linux: .credentials.json file in config directory * - Windows: Windows Credential Manager (via PowerShell) * * Supports both: @@ -16,10 +16,10 @@ */ import { execFileSync } from 'child_process'; -import { createHash, randomBytes } from 'crypto'; -import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs'; -import { homedir, userInfo } from 'os'; -import { dirname, join } from 'path'; +import { createHash } from 'crypto'; +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; import { isMacOS, isWindows, isLinux } from '../platform'; /** @@ -36,31 +36,6 @@ function getTokenFingerprint(token: string | null | undefined): string { return token.slice(0, 8) + '...' + token.slice(-4); } -/** - * Escape a string for safe interpolation into PowerShell double-quoted strings. - * Escapes all PowerShell special characters to prevent injection attacks. - * - * @param str - The string to escape - * @returns The escaped string safe for PowerShell interpolation - */ -function escapePowerShellString(str: string): string { - return str - .replace(/`/g, '``') // Backtick is PowerShell's escape character - must be escaped first - .replace(/\$/g, '`$') // Dollar sign triggers variable expansion - .replace(/"/g, '`"'); // Double quotes end the string -} - -/** - * Encode a string to base64 for safe passing to PowerShell. - * This is the most secure way to pass arbitrary data to PowerShell scripts. - * - * @param str - The string to encode - * @returns Base64-encoded string - */ -function encodeBase64ForPowerShell(str: string): string { - return Buffer.from(str, 'utf-8').toString('base64'); -} - /** * Credentials retrieved from platform-specific secure storage */ @@ -73,26 +48,6 @@ export interface PlatformCredentials { // Legacy alias for backwards compatibility export type KeychainCredentials = PlatformCredentials; -/** - * Full OAuth credentials including refresh token and expiry info - * Used for token refresh operations - */ -export interface FullOAuthCredentials extends PlatformCredentials { - refreshToken: string | null; - expiresAt: number | null; // Unix timestamp in ms when access token expires - scopes: string[] | null; - subscriptionType: string | null; // e.g., "max" for Claude Max subscription - rateLimitTier: string | null; // e.g., "default_claude_max_20x" -} - -/** - * Result of updating credentials in the keychain/credential store - */ -export interface UpdateCredentialsResult { - success: boolean; - error?: string; -} - /** * Cache for credentials to avoid repeated blocking calls * Map key is the cache key (e.g., "macos:Claude Code-credentials" or "linux:/home/user/.claude") @@ -157,56 +112,17 @@ export function calculateConfigDirHash(configDir: string): string { return createHash('sha256').update(configDir).digest('hex').slice(0, 8); } -/** - * Normalize Windows path separators for hash consistency with Claude CLI. - * - * Claude CLI on Windows uses backslashes, so we must too for hash consistency. - * Mixed slashes (C:\Users\bill/.claude-profiles) produce different hashes than - * consistent slashes (C:\Users\bill\.claude-profiles). - * - * Supports: - * - Drive letter paths: C:\Users\... - * - UNC paths with backslashes: \\server\share - * - UNC paths with forward slashes: //server/share (normalized to \\server\share) - * - * @param path - The path to normalize - * @returns The path with forward slashes replaced by backslashes on Windows - */ -export function normalizeWindowsPath(path: string): string { - if (!isWindows()) return path; - // Match: drive letter (C:), UNC with backslashes (\\), or UNC with forward slashes (//) - if (!/^[A-Za-z]:|^[\\/]{2}/.test(path)) return path; - return path.replace(/\//g, '\\'); -} - /** * Get the Keychain service name for a config directory (macOS). * - * All profiles use hash-based keychain entries for isolation. - * This prevents interference with external Claude Code CLI which uses - * "Claude Code-credentials" (no hash) for ~/.claude. - * - * @param configDir - CLAUDE_CONFIG_DIR path. Required for isolation. + * @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name. * @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506") */ export function getKeychainServiceName(configDir?: string): string { - // No configDir provided - this should not happen with isolated profiles - // Fall back to unhashed name for backwards compatibility during migration if (!configDir) { - console.warn('[CredentialUtils] getKeychainServiceName called without configDir - using legacy fallback'); return 'Claude Code-credentials'; } - - // Normalize the configDir: expand ~ and resolve to absolute path - const normalizedConfigDir = normalizeWindowsPath( - configDir.startsWith('~') - ? join(homedir(), configDir.slice(1)) - : configDir - ); - - // ALL profiles now use hash-based keychain entries for isolation - // This prevents interference with external Claude Code CLI - const hash = calculateConfigDirHash(normalizedConfigDir); + const hash = calculateConfigDirHash(configDir); return `Claude Code-credentials-${hash}`; } @@ -273,52 +189,6 @@ function extractCredentials(data: { claudeAiOauth?: { accessToken?: string; emai return { token, email }; } -/** - * Extract full credentials including refresh token and expiry from validated credential data - */ -function extractFullCredentials(data: { - claudeAiOauth?: { - accessToken?: string; - email?: string; - emailAddress?: string; - refreshToken?: string; - expiresAt?: number; - scopes?: string[]; - subscriptionType?: string; - rateLimitTier?: string; - }; - email?: string -}): { - token: string | null; - email: string | null; - refreshToken: string | null; - expiresAt: number | null; - scopes: string[] | null; - subscriptionType: string | null; - rateLimitTier: string | null; -} { - // Extract OAuth token from nested structure - const token = data?.claudeAiOauth?.accessToken || null; - - // Extract email (might be in different locations depending on Claude Code version) - const email = data?.claudeAiOauth?.email || data?.claudeAiOauth?.emailAddress || data?.email || null; - - // Extract refresh token - const refreshToken = data?.claudeAiOauth?.refreshToken || null; - - // Extract expiry timestamp (Unix timestamp in ms) - const expiresAt = data?.claudeAiOauth?.expiresAt || null; - - // Extract scopes (array of strings) - const scopes = data?.claudeAiOauth?.scopes || null; - - // Extract subscription info (determines "Max" vs "API" display in Claude Code) - const subscriptionType = data?.claudeAiOauth?.subscriptionType || null; - const rateLimitTier = data?.claudeAiOauth?.rateLimitTier || null; - - return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; -} - /** * Validate token format * Use 'sk-ant-' prefix to support future token format versions (oat02, oat03, etc.) @@ -327,277 +197,6 @@ function isValidTokenFormat(token: string): boolean { return token.startsWith('sk-ant-'); } -// ============================================================================= -// Platform-Specific Credential Reading Helpers (Shared Implementation) -// ============================================================================= - -/** - * Execute a credential read operation with platform-specific executable. - * Shared helper to reduce code duplication across macOS, Linux, and Windows. - * - * @param executablePath - Path to the security/secret-tool/powershell executable - * @param args - Arguments to pass to the executable - * @param timeout - Timeout in milliseconds - * @param identifier - Identifier for logging (e.g., "macOS:serviceName", "Linux:attribute") - * @returns The raw output string or null if not found - */ -function executeCredentialRead( - executablePath: string, - args: string[], - timeout: number, - _identifier: string -): string | null { - try { - const result = execFileSync(executablePath, args, { - encoding: 'utf-8', - timeout, - windowsHide: true, - }); - return result.trim(); - } catch (error) { - // Handle expected "not found" errors (macOS exit code 44, Linux/Windows non-zero exit) - if (error && typeof error === 'object' && 'status' in error) { - const status = (error as { status: number }).status; - if (status === 44) { - // macOS: errSecItemNotFound - return null; - } - } - // Check for "not found" in error message (Linux/Windows) - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes('not found') || errorMessage.includes('exit code')) { - return null; - } - // Re-throw unexpected errors - throw error; - } -} - -/** - * Parse and validate credential JSON from platform storage. - * Shared helper to reduce code duplication across platforms. - * - * @param credentialsJson - Raw JSON string from credential store - * @param identifier - Identifier for logging (e.g., "macOS:serviceName") - * @param extractFn - Function to extract credentials (basic or full) - * @returns Extracted credentials or null values if invalid - */ -function parseCredentialJson( - credentialsJson: string | null, - identifier: string, - extractFn: (data: any) => T -): T { - if (!credentialsJson) { - return extractFn({}) as T; - } - - // Parse JSON - let data: unknown; - try { - data = JSON.parse(credentialsJson); - } catch { - console.warn(`[CredentialUtils] Failed to parse credential JSON for ${identifier}`); - return extractFn({}) as T; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn(`[CredentialUtils] Invalid credential data structure for ${identifier}`); - return extractFn({}) as T; - } - - return extractFn(data); -} - -// ============================================================================= -// File-Based Credential Helpers (Shared for Linux and Windows) -// ============================================================================= - -/** - * Shared implementation for reading credentials from a JSON file. - * Used by both Linux and Windows file-based credential storage. - * - * @param credentialsPath - Path to the credentials file - * @param cacheKey - Cache key for storing results - * @param logPrefix - Prefix for log messages (e.g., "Linux", "Windows:File") - * @param forceRefresh - Whether to bypass cache - * @returns Platform credentials with token and email - */ -function getCredentialsFromFile( - credentialsPath: string, - cacheKey: string, - logPrefix: string, - forceRefresh = false -): PlatformCredentials { - const isDebug = process.env.DEBUG === 'true'; - const now = Date.now(); - - // Return cached credentials if available and fresh - const cached = credentialCache.get(cacheKey); - if (!forceRefresh && cached) { - const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; - if ((now - cached.timestamp) < ttl) { - if (isDebug) { - const cacheAge = now - cached.timestamp; - console.warn(`[CredentialUtils:${logPrefix}:CACHE] Returning cached credentials:`, { - credentialsPath, - hasToken: !!cached.credentials.token, - tokenFingerprint: getTokenFingerprint(cached.credentials.token), - cacheAge: Math.round(cacheAge / 1000) + 's' - }); - } - return cached.credentials; - } - } - - // Defense-in-depth: Validate credentials path is within expected boundaries - if (!isValidCredentialsPath(credentialsPath)) { - if (isDebug) { - console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath }); - } - const invalidResult = { token: null, email: null, error: 'Invalid credentials path' }; - credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - // Check if credentials file exists - if (!existsSync(credentialsPath)) { - if (isDebug) { - console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath); - } - const notFoundResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); - return notFoundResult; - } - - try { - const content = readFileSync(credentialsPath, 'utf-8'); - - // Parse JSON - let data: unknown; - try { - data = JSON.parse(content); - } catch { - console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath); - const errorResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); - return errorResult; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath); - const invalidResult = { token: null, email: null }; - credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); - return invalidResult; - } - - const { token, email } = extractCredentials(data); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath); - const result = { token: null, email }; - credentialCache.set(cacheKey, { credentials: result, timestamp: now }); - return result; - } - - const credentials = { token, email }; - credentialCache.set(cacheKey, { credentials, timestamp: now }); - - if (isDebug) { - console.warn(`[CredentialUtils:${logPrefix}] Retrieved credentials from file:`, credentialsPath, { - hasToken: !!token, - hasEmail: !!email, - tokenFingerprint: getTokenFingerprint(token), - forceRefresh - }); - } - return credentials; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage); - const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` }; - credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); - return errorResult; - } -} - -/** - * Shared implementation for reading full credentials from a JSON file. - * Used by both Linux and Windows file-based credential storage. - * - * @param credentialsPath - Path to the credentials file - * @param logPrefix - Prefix for log messages (e.g., "Linux:Full", "Windows:File:Full") - * @returns Full OAuth credentials including refresh token - */ -function getFullCredentialsFromFile( - credentialsPath: string, - logPrefix: string -): FullOAuthCredentials { - const isDebug = process.env.DEBUG === 'true'; - - // Defense-in-depth: Validate credentials path is within expected boundaries - if (!isValidCredentialsPath(credentialsPath)) { - if (isDebug) { - console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials path rejected:`, { credentialsPath }); - } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credentials path' }; - } - - // Check if credentials file exists - if (!existsSync(credentialsPath)) { - if (isDebug) { - console.warn(`[CredentialUtils:${logPrefix}] Credentials file not found:`, credentialsPath); - } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; - } - - try { - const content = readFileSync(credentialsPath, 'utf-8'); - - // Parse JSON - let data: unknown; - try { - data = JSON.parse(content); - } catch { - console.warn(`[CredentialUtils:${logPrefix}] Failed to parse credentials JSON:`, credentialsPath); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; - } - - // Validate JSON structure - if (!validateCredentialData(data)) { - console.warn(`[CredentialUtils:${logPrefix}] Invalid credentials data structure:`, credentialsPath); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null }; - } - - const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = extractFullCredentials(data); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn(`[CredentialUtils:${logPrefix}] Invalid token format in:`, credentialsPath); - return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } - - if (isDebug) { - console.warn(`[CredentialUtils:${logPrefix}] Retrieved full credentials from file:`, credentialsPath, { - hasToken: !!token, - hasEmail: !!email, - hasRefreshToken: !!refreshToken, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token), - subscriptionType, - rateLimitTier - }); - } - return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn(`[CredentialUtils:${logPrefix}] Failed to read credentials file:`, credentialsPath, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Failed to read credentials: ${errorMessage}` }; - } -} - // ============================================================================= // macOS Keychain Implementation // ============================================================================= @@ -647,20 +246,44 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals } try { - // Query macOS Keychain for Claude Code credentials using shared helper - const credentialsJson = executeCredentialRead( + // Query macOS Keychain for Claude Code credentials + const result = execFileSync( securityPath, ['find-generic-password', '-s', serviceName, '-w'], - MACOS_KEYCHAIN_TIMEOUT_MS, - `macOS:${serviceName}` + { + encoding: 'utf-8', + timeout: MACOS_KEYCHAIN_TIMEOUT_MS, + windowsHide: true, + } ); - // Parse and validate using shared helper - const { token, email } = parseCredentialJson( - credentialsJson, - `macOS:${serviceName}`, - extractCredentials - ); + const credentialsJson = result.trim(); + if (!credentialsJson) { + const emptyResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: emptyResult, timestamp: now }); + return emptyResult; + } + + // Parse JSON response + let data: unknown; + try { + data = JSON.parse(credentialsJson); + } catch { + console.warn('[CredentialUtils:macOS] Failed to parse Keychain JSON for service:', serviceName); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:macOS] Invalid Keychain data structure for service:', serviceName); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); // Validate token format if present if (token && !isValidTokenFormat(token)) { @@ -683,7 +306,13 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals } return credentials; } catch (error) { - // Unexpected error (executeCredentialRead already handles "not found" cases) + // Check for exit code 44 (errSecItemNotFound) which indicates item not found + if (error && typeof error === 'object' && 'status' in error && error.status === 44) { + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + const errorMessage = error instanceof Error ? error.message : String(error); console.warn('[CredentialUtils:macOS] Keychain access failed for service:', serviceName, errorMessage); const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` }; @@ -694,162 +323,7 @@ function getCredentialsFromMacOSKeychain(configDir?: string, forceRefresh = fals } // ============================================================================= -// Linux Secret Service Implementation -// ============================================================================= - -/** - * Timeout for secret-tool commands (5 seconds) - */ -const LINUX_SECRET_TOOL_TIMEOUT_MS = 5000; - -/** - * Find secret-tool executable path on Linux - * secret-tool is part of libsecret-tools package - */ -function findSecretToolPath(): string | null { - const candidatePaths = [ - '/usr/bin/secret-tool', - '/bin/secret-tool', - '/usr/local/bin/secret-tool', - ]; - - for (const candidate of candidatePaths) { - if (existsSync(candidate)) { - return candidate; - } - } - return null; -} - -/** - * Get the Secret Service attribute value for a config directory. - * For default profile, uses "claude-code". - * For custom profiles, uses "claude-code-{hash}" where hash is first 8 chars of SHA256. - */ -function getSecretServiceAttribute(configDir?: string): string { - if (!configDir) { - return 'claude-code'; - } - // For custom config dirs, create a hashed attribute to avoid conflicts - const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8); - return `claude-code-${hash}`; -} - -/** - * Retrieve credentials from Linux Secret Service using secret-tool CLI. - * - * Claude Code stores credentials in Secret Service with: - * - Label: "Claude Code-credentials" - * - Attributes: {application: "claude-code"} - * - Secret: JSON string with claudeAiOauth.accessToken - */ -function getCredentialsFromLinuxSecretService(configDir?: string, forceRefresh = false): PlatformCredentials { - const attribute = getSecretServiceAttribute(configDir); - const cacheKey = `linux-secret:${attribute}`; - const isDebug = process.env.DEBUG === 'true'; - const now = Date.now(); - - // Return cached credentials if available and fresh - const cached = credentialCache.get(cacheKey); - if (!forceRefresh && cached) { - const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; - if ((now - cached.timestamp) < ttl) { - if (isDebug) { - const cacheAge = now - cached.timestamp; - console.warn('[CredentialUtils:Linux:SecretService:CACHE] Returning cached credentials:', { - attribute, - hasToken: !!cached.credentials.token, - tokenFingerprint: getTokenFingerprint(cached.credentials.token), - cacheAge: Math.round(cacheAge / 1000) + 's' - }); - } - return cached.credentials; - } - } - - // Find secret-tool executable - const secretToolPath = findSecretToolPath(); - if (!secretToolPath) { - if (isDebug) { - console.warn('[CredentialUtils:Linux:SecretService] secret-tool not found, falling back to file storage'); - } - // Return a special result indicating Secret Service is unavailable - return { token: null, email: null, error: 'secret-tool not found' }; - } - - try { - // Query Secret Service for credentials using shared helper - const credentialsJson = executeCredentialRead( - secretToolPath, - ['lookup', 'application', attribute], - LINUX_SECRET_TOOL_TIMEOUT_MS, - `Linux:SecretService:${attribute}` - ); - - // Parse and validate using shared helper - const { token, email } = parseCredentialJson( - credentialsJson, - `Linux:SecretService:${attribute}`, - extractCredentials - ); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn('[CredentialUtils:Linux:SecretService] Invalid token format for attribute:', attribute); - const result = { token: null, email }; - credentialCache.set(cacheKey, { credentials: result, timestamp: now }); - return result; - } - - const credentials = { token, email }; - credentialCache.set(cacheKey, { credentials, timestamp: now }); - - if (isDebug) { - console.warn('[CredentialUtils:Linux:SecretService] Retrieved credentials from Secret Service:', { - attribute, - hasToken: !!token, - hasEmail: !!email, - tokenFingerprint: getTokenFingerprint(token), - forceRefresh - }); - } - return credentials; - } catch (error) { - // Unexpected error (executeCredentialRead already handles "not found" cases) - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:Linux:SecretService] Secret Service access failed:', errorMessage); - // Return error to trigger fallback to file storage - return { token: null, email: null, error: `Secret Service access failed: ${errorMessage}` }; - } -} - -/** - * Retrieve credentials from Linux - tries Secret Service first, falls back to file - */ -function getCredentialsFromLinux(configDir?: string, forceRefresh = false): PlatformCredentials { - const isDebug = process.env.DEBUG === 'true'; - - // Try Secret Service first (preferred secure storage) - const secretServiceResult = getCredentialsFromLinuxSecretService(configDir, forceRefresh); - - // If we got a token from Secret Service, use it - if (secretServiceResult.token) { - return secretServiceResult; - } - - // If Secret Service had an error (not just "not found"), log it and try file fallback - if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) { - if (isDebug) { - console.warn('[CredentialUtils:Linux] Secret Service unavailable, trying file fallback:', secretServiceResult.error); - } - } - - // Fall back to file-based storage - return getCredentialsFromLinuxFile(configDir, forceRefresh); -} - -// ============================================================================= -// Linux Credentials File Implementation (Fallback) +// Linux Credentials File Implementation // ============================================================================= /** @@ -861,12 +335,103 @@ function getLinuxCredentialsPath(configDir?: string): string { } /** - * Retrieve credentials from Linux .credentials.json file (fallback when Secret Service unavailable) + * Retrieve credentials from Linux .credentials.json file */ function getCredentialsFromLinuxFile(configDir?: string, forceRefresh = false): PlatformCredentials { const credentialsPath = getLinuxCredentialsPath(configDir); const cacheKey = `linux:${credentialsPath}`; - return getCredentialsFromFile(credentialsPath, cacheKey, 'Linux', forceRefresh); + const isDebug = process.env.DEBUG === 'true'; + const now = Date.now(); + + // Return cached credentials if available and fresh + const cached = credentialCache.get(cacheKey); + if (!forceRefresh && cached) { + const ttl = cached.credentials.error ? ERROR_CACHE_TTL_MS : CACHE_TTL_MS; + if ((now - cached.timestamp) < ttl) { + if (isDebug) { + const cacheAge = now - cached.timestamp; + console.warn('[CredentialUtils:Linux:CACHE] Returning cached credentials:', { + credentialsPath, + hasToken: !!cached.credentials.token, + tokenFingerprint: getTokenFingerprint(cached.credentials.token), + cacheAge: Math.round(cacheAge / 1000) + 's' + }); + } + return cached.credentials; + } + } + + // Defense-in-depth: Validate credentials path is within expected boundaries + if (!isValidCredentialsPath(credentialsPath)) { + if (isDebug) { + console.warn('[CredentialUtils:Linux] Invalid credentials path rejected:', { credentialsPath }); + } + const invalidResult = { token: null, email: null, error: 'Invalid credentials path' }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + // Check if credentials file exists + if (!existsSync(credentialsPath)) { + if (isDebug) { + console.warn('[CredentialUtils:Linux] Credentials file not found:', credentialsPath); + } + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + try { + const content = readFileSync(credentialsPath, 'utf-8'); + + // Parse JSON + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.warn('[CredentialUtils:Linux] Failed to parse credentials JSON:', credentialsPath); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:Linux] Invalid credentials data structure:', credentialsPath); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); + + // Validate token format if present + if (token && !isValidTokenFormat(token)) { + console.warn('[CredentialUtils:Linux] Invalid token format in:', credentialsPath); + const result = { token: null, email }; + credentialCache.set(cacheKey, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token, email }; + credentialCache.set(cacheKey, { credentials, timestamp: now }); + + if (isDebug) { + console.warn('[CredentialUtils:Linux] Retrieved credentials from file:', credentialsPath, { + hasToken: !!token, + hasEmail: !!email, + tokenFingerprint: getTokenFingerprint(token), + forceRefresh + }); + } + return credentials; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[CredentialUtils:Linux] Failed to read credentials file:', credentialsPath, errorMessage); + const errorResult = { token: null, email: null, error: `Failed to read credentials: ${errorMessage}` }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } } // ============================================================================= @@ -926,60 +491,29 @@ function getCredentialsFromWindowsCredentialManager(configDir?: string, forceRef try { // PowerShell script to read from Credential Manager // Uses the Windows Credential Manager API via .NET - // NOTE: The CREDENTIAL struct must use IntPtr for string fields (blittable requirement) - // and strings must be manually marshaled after PtrToStructure - // - // NOTE: This CREDENTIAL struct uses IntPtr for string fields (TargetName, Comment, etc.) - // because CredRead returns a pointer to Windows-allocated memory. We must use a "blittable" - // struct layout where strings are IntPtr, then manually marshal strings via PtrToStringUni. - // This differs from the CredWrite struct (see updateWindowsCredentialManagerCredentials) - // which uses string types because the .NET marshaler can automatically convert strings - // to pointers when CALLING Windows APIs (but not when RECEIVING data from them). const psScript = ` $ErrorActionPreference = 'Stop' + Add-Type -AssemblyName System.Runtime.WindowsRuntime - # Define the CREDENTIAL struct with IntPtr for string fields (required for CredRead marshaling) - # See comment above for why this differs from the CredWrite struct definition. - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; + # Use CredRead from advapi32.dll to read generic credentials + $sig = @' + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr); -[StructLayout(LayoutKind.Sequential)] -public struct CREDENTIAL { - public uint Flags; - public uint Type; - public IntPtr TargetName; - public IntPtr Comment; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; - public uint CredentialBlobSize; - public IntPtr CredentialBlob; - public uint Persist; - public uint AttributeCount; - public IntPtr Attributes; - public IntPtr TargetAlias; - public IntPtr UserName; -} + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CredFree(IntPtr cred); '@ - - # Import CredRead and CredFree from advapi32.dll - Add-Type -MemberDefinition @' -[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] -public static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr); - -[DllImport("advapi32.dll", SetLastError = true)] -public static extern bool CredFree(IntPtr cred); -'@ -Namespace Win32 -Name CredApi + Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential $credPtr = [IntPtr]::Zero # CRED_TYPE_GENERIC = 1 - $success = [Win32.CredApi]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) + $success = [Win32.Credential]::CredRead("${targetName.replace(/"/g, '`"')}", 1, 0, [ref]$credPtr) if ($success) { try { - # Marshal the pointer to our CREDENTIAL struct - $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][CREDENTIAL]) + $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][System.Management.Automation.PSCredential].Assembly.GetType('Microsoft.PowerShell.Commands.CREDENTIAL')) - # Read the credential blob (password field) - contains the JSON + # Read the credential blob (password field) $blobSize = $cred.CredentialBlobSize if ($blobSize -gt 0) { $blob = [byte[]]::new($blobSize) @@ -988,7 +522,7 @@ public static extern bool CredFree(IntPtr cred); Write-Output $password } } finally { - [Win32.CredApi]::CredFree($credPtr) | Out-Null + [Win32.Credential]::CredFree($credPtr) | Out-Null } } else { # Credential not found - this is expected if user hasn't authenticated @@ -1006,14 +540,36 @@ public static extern bool CredFree(IntPtr cred); } ); - const credentialsJson = result.trim() || null; + const credentialsJson = result.trim(); + if (!credentialsJson) { + if (isDebug) { + console.warn('[CredentialUtils:Windows] Credential not found for target:', targetName); + } + const notFoundResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } - // Parse and validate using shared helper - const { token, email } = parseCredentialJson( - credentialsJson, - `Windows:${targetName}`, - extractCredentials - ); + // Parse JSON response + let data: unknown; + try { + data = JSON.parse(credentialsJson); + } catch { + console.warn('[CredentialUtils:Windows] Failed to parse credential JSON for target:', targetName); + const errorResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateCredentialData(data)) { + console.warn('[CredentialUtils:Windows] Invalid credential data structure for target:', targetName); + const invalidResult = { token: null, email: null }; + credentialCache.set(cacheKey, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + const { token, email } = extractCredentials(data); // Validate token format if present if (token && !isValidTokenFormat(token)) { @@ -1064,67 +620,6 @@ function findPowerShellPath(): string | null { return null; } -// ============================================================================= -// Windows Credentials File Implementation (Fallback) -// ============================================================================= - -/** - * Get the credentials file path for Windows - * Claude CLI on Windows stores credentials in .credentials.json files, not Windows Credential Manager - */ -function getWindowsCredentialsPath(configDir?: string): string { - const baseDir = configDir || join(homedir(), '.claude'); - return join(baseDir, '.credentials.json'); -} - -/** - * Retrieve credentials from Windows .credentials.json file - * This is the primary storage mechanism used by Claude CLI on Windows - */ -function getCredentialsFromWindowsFile(configDir?: string, forceRefresh = false): PlatformCredentials { - const credentialsPath = getWindowsCredentialsPath(configDir); - const cacheKey = `windows-file:${credentialsPath}`; - return getCredentialsFromFile(credentialsPath, cacheKey, 'Windows:File', forceRefresh); -} - -/** - * Retrieve credentials from Windows - checks both file and Credential Manager, uses the most recent valid token. - * Claude CLI on Windows can store credentials in either location, and they may get out of sync. - * We compare both sources and return the one with the most recent/valid token. - */ -function getCredentialsFromWindows(configDir?: string, forceRefresh = false): PlatformCredentials { - const isDebug = process.env.DEBUG === 'true'; - - // Get credentials from both sources - const fileResult = getCredentialsFromWindowsFile(configDir, forceRefresh); - const credManagerResult = getCredentialsFromWindowsCredentialManager(configDir, forceRefresh); - - // If only one has a token, use that one - if (fileResult.token && !credManagerResult.token) { - if (isDebug) { - console.warn('[CredentialUtils:Windows] Using file credentials (Credential Manager empty)'); - } - return fileResult; - } - if (credManagerResult.token && !fileResult.token) { - if (isDebug) { - console.warn('[CredentialUtils:Windows] Using Credential Manager credentials (file empty)'); - } - return credManagerResult; - } - - // If neither has a token, return file result (which has the appropriate error) - if (!fileResult.token && !credManagerResult.token) { - return fileResult; - } - - // Both have tokens - prefer file since Claude CLI writes there after login - if (isDebug) { - console.warn('[CredentialUtils:Windows] Both sources have tokens, preferring file (Claude CLI primary storage)'); - } - return fileResult; -} - // ============================================================================= // Cross-Platform Public API // ============================================================================= @@ -1134,8 +629,8 @@ function getCredentialsFromWindows(configDir?: string, forceRefresh = false): Pl * secure storage. * * - macOS: Reads from Keychain - * - Linux: Tries Secret Service (via secret-tool), falls back to .credentials.json - * - Windows: Checks both .credentials.json and Credential Manager, prefers file + * - Linux: Reads from .credentials.json file + * - Windows: Reads from Windows Credential Manager * * For default profile: reads from "Claude Code-credentials" or default config dir * For custom profiles: uses SHA256(configDir).slice(0,8) hash suffix @@ -1152,11 +647,11 @@ export function getCredentialsFromKeychain(configDir?: string, forceRefresh = fa } if (isLinux()) { - return getCredentialsFromLinux(configDir, forceRefresh); + return getCredentialsFromLinuxFile(configDir, forceRefresh); } if (isWindows()) { - return getCredentialsFromWindows(configDir, forceRefresh); + return getCredentialsFromWindowsCredentialManager(configDir, forceRefresh); } // Unknown platform - return empty @@ -1178,16 +673,12 @@ export function clearKeychainCache(configDir?: string): void { if (configDir) { // Clear cache for this specific configDir on all platforms const macOSKey = `macos:${getKeychainServiceName(configDir)}`; - const linuxSecretKey = `linux-secret:${getSecretServiceAttribute(configDir)}`; - const linuxFileKey = `linux:${getLinuxCredentialsPath(configDir)}`; + const linuxKey = `linux:${getLinuxCredentialsPath(configDir)}`; const windowsKey = `windows:${getWindowsCredentialTarget(configDir)}`; - const windowsFileKey = `windows-file:${getWindowsCredentialsPath(configDir)}`; credentialCache.delete(macOSKey); - credentialCache.delete(linuxSecretKey); - credentialCache.delete(linuxFileKey); + credentialCache.delete(linuxKey); credentialCache.delete(windowsKey); - credentialCache.delete(windowsFileKey); } else { credentialCache.clear(); } @@ -1197,1123 +688,3 @@ export function clearKeychainCache(configDir?: string): void { * Alias for clearKeychainCache for semantic clarity */ export const clearCredentialCache = clearKeychainCache; - -// ============================================================================= -// Extended Credential Operations (Token Refresh Support) -// ============================================================================= - -/** - * Retrieve full credentials (including refresh token) from macOS Keychain - */ -function getFullCredentialsFromMacOSKeychain(configDir?: string): FullOAuthCredentials { - const serviceName = getKeychainServiceName(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Locate the security executable - let securityPath: string | null = null; - const candidatePaths = ['/usr/bin/security', '/bin/security']; - - for (const candidate of candidatePaths) { - if (existsSync(candidate)) { - securityPath = candidate; - break; - } - } - - if (!securityPath) { - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'macOS security command not found' }; - } - - try { - // Query macOS Keychain for Claude Code credentials using shared helper - const credentialsJson = executeCredentialRead( - securityPath, - ['find-generic-password', '-s', serviceName, '-w'], - MACOS_KEYCHAIN_TIMEOUT_MS, - `macOS:Full:${serviceName}` - ); - - // Parse and validate using shared helper - const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = parseCredentialJson( - credentialsJson, - `macOS:Full:${serviceName}`, - extractFullCredentials - ); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn('[CredentialUtils:macOS:Full] Invalid token format for service:', serviceName); - return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } - - if (isDebug) { - console.warn('[CredentialUtils:macOS:Full] Retrieved full credentials from Keychain for service:', serviceName, { - hasToken: !!token, - hasEmail: !!email, - hasRefreshToken: !!refreshToken, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token), - subscriptionType, - rateLimitTier - }); - } - return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } catch (error) { - // Unexpected error (executeCredentialRead already handles "not found" cases) - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:macOS:Full] Keychain access failed for service:', serviceName, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Keychain access failed: ${errorMessage}` }; - } -} - -/** - * Retrieve full credentials (including refresh token) from Linux Secret Service - */ -function getFullCredentialsFromLinuxSecretService(configDir?: string): FullOAuthCredentials { - const attribute = getSecretServiceAttribute(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Find secret-tool executable - const secretToolPath = findSecretToolPath(); - if (!secretToolPath) { - if (isDebug) { - console.warn('[CredentialUtils:Linux:SecretService:Full] secret-tool not found'); - } - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'secret-tool not found' }; - } - - try { - // Query Secret Service for credentials using shared helper - const credentialsJson = executeCredentialRead( - secretToolPath, - ['lookup', 'application', attribute], - LINUX_SECRET_TOOL_TIMEOUT_MS, - `Linux:SecretService:Full:${attribute}` - ); - - // Parse and validate using shared helper - const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = parseCredentialJson( - credentialsJson, - `Linux:SecretService:Full:${attribute}`, - extractFullCredentials - ); - - if (token && !isValidTokenFormat(token)) { - console.warn('[CredentialUtils:Linux:SecretService:Full] Invalid token format for attribute:', attribute); - return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } - - if (isDebug) { - console.warn('[CredentialUtils:Linux:SecretService:Full] Retrieved full credentials from Secret Service:', { - attribute, - hasToken: !!token, - hasEmail: !!email, - hasRefreshToken: !!refreshToken, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token), - subscriptionType, - rateLimitTier - }); - } - return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } catch (error) { - // Unexpected error (executeCredentialRead already handles "not found" cases) - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:Linux:SecretService:Full] Secret Service access failed:', errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Secret Service access failed: ${errorMessage}` }; - } -} - -/** - * Retrieve full credentials from Linux - tries Secret Service first, falls back to file - */ -function getFullCredentialsFromLinux(configDir?: string): FullOAuthCredentials { - const isDebug = process.env.DEBUG === 'true'; - - // Try Secret Service first - const secretServiceResult = getFullCredentialsFromLinuxSecretService(configDir); - - if (secretServiceResult.token) { - return secretServiceResult; - } - - if (secretServiceResult.error && !secretServiceResult.error.includes('not found')) { - if (isDebug) { - console.warn('[CredentialUtils:Linux:Full] Secret Service unavailable, trying file fallback:', secretServiceResult.error); - } - } - - // Fall back to file-based storage - return getFullCredentialsFromLinuxFile(configDir); -} - -/** - * Retrieve full credentials (including refresh token) from Linux .credentials.json file (fallback) - */ -function getFullCredentialsFromLinuxFile(configDir?: string): FullOAuthCredentials { - const credentialsPath = getLinuxCredentialsPath(configDir); - return getFullCredentialsFromFile(credentialsPath, 'Linux:Full'); -} - -/** - * Retrieve full credentials (including refresh token) from Windows Credential Manager - */ -function getFullCredentialsFromWindowsCredentialManager(configDir?: string): FullOAuthCredentials { - const targetName = getWindowsCredentialTarget(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Defense-in-depth: Validate target name format before using in PowerShell - if (!isValidTargetName(targetName)) { - const invalidResult = { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'Invalid credential target name format' }; - if (isDebug) { - console.warn('[CredentialUtils:Windows:Full] Invalid target name rejected:', { targetName }); - } - return invalidResult; - } - - // Find PowerShell executable - const psPath = findPowerShellPath(); - if (!psPath) { - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: 'PowerShell not found' }; - } - - try { - // PowerShell script to read from Credential Manager (same as basic credentials) - // NOTE: The CREDENTIAL struct must use IntPtr for string fields (blittable requirement) - const psScript = ` - $ErrorActionPreference = 'Stop' - - # Define the CREDENTIAL struct with IntPtr for string fields (required for marshaling) - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; - -[StructLayout(LayoutKind.Sequential)] -public struct CREDENTIAL { - public uint Flags; - public uint Type; - public IntPtr TargetName; - public IntPtr Comment; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; - public uint CredentialBlobSize; - public IntPtr CredentialBlob; - public uint Persist; - public uint AttributeCount; - public IntPtr Attributes; - public IntPtr TargetAlias; - public IntPtr UserName; -} -'@ - - # Import CredRead and CredFree from advapi32.dll - Add-Type -MemberDefinition @' -[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] -public static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr); - -[DllImport("advapi32.dll", SetLastError = true)] -public static extern bool CredFree(IntPtr cred); -'@ -Namespace Win32 -Name CredApi - - $credPtr = [IntPtr]::Zero - # CRED_TYPE_GENERIC = 1 - $success = [Win32.CredApi]::CredRead("${escapePowerShellString(targetName)}", 1, 0, [ref]$credPtr) - - if ($success) { - try { - # Marshal the pointer to our CREDENTIAL struct - $cred = [Runtime.InteropServices.Marshal]::PtrToStructure($credPtr, [Type][CREDENTIAL]) - - # Read the credential blob (password field) - contains the JSON - $blobSize = $cred.CredentialBlobSize - if ($blobSize -gt 0) { - $blob = [byte[]]::new($blobSize) - [Runtime.InteropServices.Marshal]::Copy($cred.CredentialBlob, $blob, 0, $blobSize) - $password = [System.Text.Encoding]::Unicode.GetString($blob) - Write-Output $password - } - } finally { - [Win32.CredApi]::CredFree($credPtr) | Out-Null - } - } else { - # Credential not found - this is expected if user hasn't authenticated - Write-Output "" - } - `; - - const result = execFileSync( - psPath, - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript], - { - encoding: 'utf-8', - timeout: WINDOWS_CREDMAN_TIMEOUT_MS, - windowsHide: true, - } - ); - - const credentialsJson = result.trim() || null; - - // Parse and validate using shared helper - const { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier } = parseCredentialJson( - credentialsJson, - `Windows:Full:${targetName}`, - extractFullCredentials - ); - - // Validate token format if present - if (token && !isValidTokenFormat(token)) { - console.warn('[CredentialUtils:Windows:Full] Invalid token format for target:', targetName); - return { token: null, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } - - if (isDebug) { - console.warn('[CredentialUtils:Windows:Full] Retrieved full credentials from Credential Manager for target:', targetName, { - hasToken: !!token, - hasEmail: !!email, - hasRefreshToken: !!refreshToken, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - tokenFingerprint: getTokenFingerprint(token), - subscriptionType, - rateLimitTier - }); - } - return { token, email, refreshToken, expiresAt, scopes, subscriptionType, rateLimitTier }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:Windows:Full] Credential Manager access failed for target:', targetName, errorMessage); - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Credential Manager access failed: ${errorMessage}` }; - } -} - -/** - * Retrieve full credentials (including refresh token) from Windows .credentials.json file - * This is the primary storage mechanism used by Claude CLI on Windows - */ -function getFullCredentialsFromWindowsFile(configDir?: string): FullOAuthCredentials { - const credentialsPath = getWindowsCredentialsPath(configDir); - return getFullCredentialsFromFile(credentialsPath, 'Windows:File:Full'); -} - -/** - * Retrieve full credentials from Windows - checks both file and Credential Manager, uses the most recent valid token. - * Claude CLI on Windows can store credentials in either location, and they may get out of sync. - * We compare both sources and return the one with the later expiry time (most recently refreshed). - */ -function getFullCredentialsFromWindows(configDir?: string): FullOAuthCredentials { - const isDebug = process.env.DEBUG === 'true'; - - // Get credentials from both sources - const fileResult = getFullCredentialsFromWindowsFile(configDir); - const credManagerResult = getFullCredentialsFromWindowsCredentialManager(configDir); - - // If only one has a token, use that one - if (fileResult.token && !credManagerResult.token) { - if (isDebug) { - console.warn('[CredentialUtils:Windows:Full] Using file credentials (Credential Manager empty)'); - } - return fileResult; - } - if (credManagerResult.token && !fileResult.token) { - if (isDebug) { - console.warn('[CredentialUtils:Windows:Full] Using Credential Manager credentials (file empty)'); - } - return credManagerResult; - } - - // If neither has a token, return file result (which has the appropriate error) - if (!fileResult.token && !credManagerResult.token) { - return fileResult; - } - - // Both have tokens - prefer file since Claude CLI writes there after login - // This is consistent with getCredentialsFromWindows() which also prefers file. - // Using file as primary ensures consistency: the same token is returned whether - // calling getCredentialsFromKeychain() or getFullCredentialsFromKeychain(). - if (isDebug) { - console.warn('[CredentialUtils:Windows:Full] Both sources have tokens, preferring file (Claude CLI primary storage)'); - } - return fileResult; -} - -/** - * Get full credentials including refresh token and expiry from platform-specific secure storage. - * This is an extended version of getCredentialsFromKeychain that returns all credential data - * needed for token refresh operations. - * - * @param configDir - Optional config directory for profile-specific credentials - * @returns Full credentials including refresh token and expiry information - */ -export function getFullCredentialsFromKeychain(configDir?: string): FullOAuthCredentials { - if (isMacOS()) { - return getFullCredentialsFromMacOSKeychain(configDir); - } - - if (isLinux()) { - return getFullCredentialsFromLinux(configDir); - } - - if (isWindows()) { - return getFullCredentialsFromWindows(configDir); - } - - // Unknown platform - return empty - return { token: null, email: null, refreshToken: null, expiresAt: null, scopes: null, subscriptionType: null, rateLimitTier: null, error: `Unsupported platform: ${process.platform}` }; -} - -/** - * Update credentials in macOS Keychain with new tokens - */ -function updateMacOSKeychainCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const serviceName = getKeychainServiceName(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Locate the security executable - let securityPath: string | null = null; - const candidatePaths = ['/usr/bin/security', '/bin/security']; - - for (const candidate of candidatePaths) { - if (existsSync(candidate)) { - securityPath = candidate; - break; - } - } - - if (!securityPath) { - return { success: false, error: 'macOS security command not found' }; - } - - try { - // Read existing credentials to preserve email, subscriptionType, and rateLimitTier - const existing = getFullCredentialsFromMacOSKeychain(configDir); - - // Build new credential JSON with all fields - // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials - // These fields determine "Max" vs "API" display in Claude Code and are NOT returned - // by the OAuth token refresh endpoint - they must be preserved from the original auth. - const newCredentialData = { - claudeAiOauth: { - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - expiresAt: credentials.expiresAt, - scopes: credentials.scopes || existing.scopes || [], - email: existing.email || undefined, - emailAddress: existing.email || undefined, - subscriptionType: existing.subscriptionType || undefined, - rateLimitTier: existing.rateLimitTier || undefined - }, - email: existing.email || undefined - }; - - const credentialsJson = JSON.stringify(newCredentialData); - - // CRITICAL FIX: The -U flag only updates if the account name matches exactly. - // Claude Code CLI stores credentials with the system username as the account, - // but we were using 'claude-ai-oauth'. This mismatch caused updates to create - // a NEW entry instead of updating the existing one, leading to stale tokens. - // - // Solution: Delete any existing entry first, then add fresh. - // This ensures we don't end up with multiple entries with different account names. - - // Step 1: Delete existing entry (ignore errors if not found) - try { - execFileSync( - securityPath, - ['delete-generic-password', '-s', serviceName], - { - encoding: 'utf-8', - timeout: MACOS_KEYCHAIN_TIMEOUT_MS, - windowsHide: true, - } - ); - if (isDebug) { - console.warn('[CredentialUtils:macOS:Update] Deleted existing Keychain entry for service:', serviceName); - } - } catch { - // Entry didn't exist - that's fine, we'll create it - if (isDebug) { - console.warn('[CredentialUtils:macOS:Update] No existing entry to delete for service:', serviceName); - } - } - - // Step 2: Add new entry with system username as account name - // Claude Code CLI uses the system username, so we must match that for compatibility - const accountName = userInfo().username; - execFileSync( - securityPath, - ['add-generic-password', '-s', serviceName, '-a', accountName, '-w', credentialsJson], - { - encoding: 'utf-8', - timeout: MACOS_KEYCHAIN_TIMEOUT_MS, - windowsHide: true, - } - ); - - if (isDebug) { - console.warn('[CredentialUtils:macOS:Update] Successfully updated Keychain credentials for service:', serviceName); - } - - // Clear cached credentials to ensure fresh values are read - clearCredentialCache(configDir); - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[CredentialUtils:macOS:Update] Failed to update Keychain credentials:', errorMessage); - return { success: false, error: `Keychain update failed: ${errorMessage}` }; - } -} - -/** - * Update credentials in Linux Secret Service with new tokens - */ -function updateLinuxSecretServiceCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const attribute = getSecretServiceAttribute(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Find secret-tool executable - const secretToolPath = findSecretToolPath(); - if (!secretToolPath) { - if (isDebug) { - console.warn('[CredentialUtils:Linux:SecretService:Update] secret-tool not found'); - } - return { success: false, error: 'secret-tool not found' }; - } - - try { - // Read existing credentials to preserve email, subscriptionType, and rateLimitTier - const existing = getFullCredentialsFromLinuxSecretService(configDir); - - // Build new credential JSON with all fields - // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials - const newCredentialData = { - claudeAiOauth: { - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - expiresAt: credentials.expiresAt, - scopes: credentials.scopes || existing.scopes || [], - email: existing.email || undefined, - emailAddress: existing.email || undefined, - subscriptionType: existing.subscriptionType || undefined, - rateLimitTier: existing.rateLimitTier || undefined - }, - email: existing.email || undefined - }; - - const credentialsJson = JSON.stringify(newCredentialData); - - // Use secret-tool store to update credentials - // secret-tool store --label="Claude Code-credentials" application claude-code - execFileSync( - secretToolPath, - ['store', '--label=Claude Code-credentials', 'application', attribute], - { - encoding: 'utf-8', - timeout: LINUX_SECRET_TOOL_TIMEOUT_MS, - input: credentialsJson, - windowsHide: true, - } - ); - - if (isDebug) { - console.warn('[CredentialUtils:Linux:SecretService:Update] Successfully updated Secret Service credentials for attribute:', attribute); - } - - // Clear cached credentials to ensure fresh values are read - clearCredentialCache(configDir); - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[CredentialUtils:Linux:SecretService:Update] Failed to update Secret Service credentials:', errorMessage); - return { success: false, error: `Secret Service update failed: ${errorMessage}` }; - } -} - -/** - * Update credentials in Linux - tries Secret Service first, falls back to file - */ -function updateLinuxCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const isDebug = process.env.DEBUG === 'true'; - - // Try Secret Service first - const secretToolPath = findSecretToolPath(); - if (secretToolPath) { - const secretServiceResult = updateLinuxSecretServiceCredentials(configDir, credentials); - if (secretServiceResult.success) { - return secretServiceResult; - } - if (isDebug) { - console.warn('[CredentialUtils:Linux:Update] Secret Service update failed, trying file fallback:', secretServiceResult.error); - } - } - - // Fall back to file-based storage - return updateLinuxFileCredentials(configDir, credentials); -} - -/** - * Update credentials in Linux .credentials.json file with new tokens (fallback) - */ -function updateLinuxFileCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const credentialsPath = getLinuxCredentialsPath(configDir); - const isDebug = process.env.DEBUG === 'true'; - - - // Defense-in-depth: Validate credentials path - if (!isValidCredentialsPath(credentialsPath)) { - return { success: false, error: 'Invalid credentials path' }; - } - - try { - // Read existing credentials to preserve email, subscriptionType, and rateLimitTier - const existing = getFullCredentialsFromLinuxFile(configDir); - - // Build new credential JSON with all fields - // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials - const newCredentialData = { - claudeAiOauth: { - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - expiresAt: credentials.expiresAt, - scopes: credentials.scopes || existing.scopes || [], - email: existing.email || undefined, - emailAddress: existing.email || undefined, - subscriptionType: existing.subscriptionType || undefined, - rateLimitTier: existing.rateLimitTier || undefined - }, - email: existing.email || undefined - }; - - const credentialsJson = JSON.stringify(newCredentialData, null, 2); - - // Ensure directory exists (matching Windows behavior) - const dirPath = dirname(credentialsPath); - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - } - - // Final validation before write (defense-in-depth) - // Ensure credentialsPath hasn't been modified and is still valid - if (!isValidCredentialsPath(credentialsPath)) { - return { success: false, error: 'Credentials path validation failed before write' }; - } - - // Write to file with secure permissions (0600) - writeFileSync(credentialsPath, credentialsJson, { mode: 0o600, encoding: 'utf-8' }); - - if (isDebug) { - console.warn('[CredentialUtils:Linux:Update] Successfully updated credentials file:', credentialsPath); - } - - // Clear cached credentials to ensure fresh values are read - clearCredentialCache(configDir); - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[CredentialUtils:Linux:Update] Failed to update credentials file:', errorMessage); - return { success: false, error: `File update failed: ${errorMessage}` }; - } -} - -/** - * Update credentials in Windows Credential Manager with new tokens - */ -function updateWindowsCredentialManagerCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const targetName = getWindowsCredentialTarget(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Defense-in-depth: Validate target name format - if (!isValidTargetName(targetName)) { - return { success: false, error: 'Invalid credential target name format' }; - } - - // Find PowerShell executable - const psPath = findPowerShellPath(); - if (!psPath) { - return { success: false, error: 'PowerShell not found' }; - } - - try { - // Read existing credentials to preserve email, subscriptionType, and rateLimitTier - const existing = getFullCredentialsFromWindowsCredentialManager(configDir); - - // Build new credential JSON with all fields - // IMPORTANT: Preserve subscriptionType and rateLimitTier from existing credentials - const newCredentialData = { - claudeAiOauth: { - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - expiresAt: credentials.expiresAt, - scopes: credentials.scopes || existing.scopes || [], - email: existing.email || undefined, - emailAddress: existing.email || undefined, - subscriptionType: existing.subscriptionType || undefined, - rateLimitTier: existing.rateLimitTier || undefined - }, - email: existing.email || undefined - }; - - const credentialsJson = JSON.stringify(newCredentialData); - // Use base64 encoding for maximum security - prevents all injection attacks - const base64Json = encodeBase64ForPowerShell(credentialsJson); - - // PowerShell script to write to Credential Manager - // - // NOTE: This CREDENTIAL struct uses string types for TargetName, Comment, etc. - // because CredWrite accepts data FROM us, and the .NET marshaler can automatically - // convert string fields to the appropriate Unicode pointers when CALLING Windows APIs. - // This differs from the CredRead struct (see getCredentialsFromWindowsCredentialManager) - // which must use IntPtr because we're RECEIVING data from Windows and need to manually - // marshal the strings from Windows-allocated memory. - const psScript = ` - $ErrorActionPreference = 'Stop' - - # Use CredWrite from advapi32.dll to write generic credentials - # This struct uses string types (auto-marshaled) unlike CredRead which needs IntPtr. - $sig = @' - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - public struct CREDENTIAL { - public int Flags; - public int Type; - public string TargetName; - public string Comment; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; - public int CredentialBlobSize; - public IntPtr CredentialBlob; - public int Persist; - public int AttributeCount; - public IntPtr Attributes; - public string TargetAlias; - public string UserName; - } - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool CredWrite(ref CREDENTIAL credential, int flags); -'@ - Add-Type -MemberDefinition $sig -Namespace Win32 -Name Credential - - # Decode base64 JSON (more secure than string escaping) - $json = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${base64Json}')) - $jsonBytes = [System.Text.Encoding]::Unicode.GetBytes($json) - $jsonPtr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($jsonBytes.Length) - [System.Runtime.InteropServices.Marshal]::Copy($jsonBytes, 0, $jsonPtr, $jsonBytes.Length) - - try { - $cred = New-Object Win32.Credential+CREDENTIAL - $cred.Type = 1 # CRED_TYPE_GENERIC - $cred.TargetName = "${escapePowerShellString(targetName)}" - $cred.CredentialBlob = $jsonPtr - $cred.CredentialBlobSize = $jsonBytes.Length - $cred.Persist = 2 # CRED_PERSIST_LOCAL_MACHINE - $cred.UserName = "claude-ai-oauth" - - $success = [Win32.Credential]::CredWrite([ref]$cred, 0) - if (-not $success) { - throw "CredWrite failed" - } - Write-Output "SUCCESS" - } finally { - [System.Runtime.InteropServices.Marshal]::FreeHGlobal($jsonPtr) - } - `; - - const result = execFileSync( - psPath, - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', psScript], - { - encoding: 'utf-8', - timeout: WINDOWS_CREDMAN_TIMEOUT_MS, - windowsHide: true, - } - ); - - if (result.trim() !== 'SUCCESS') { - return { success: false, error: 'Credential Manager update failed' }; - } - - if (isDebug) { - console.warn('[CredentialUtils:Windows:Update] Successfully updated Credential Manager for target:', targetName); - } - - // Clear cached credentials to ensure fresh values are read - clearCredentialCache(configDir); - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[CredentialUtils:Windows:Update] Failed to update Credential Manager:', errorMessage); - return { success: false, error: `Credential Manager update failed: ${errorMessage}` }; - } -} - -/** - * Restrict Windows file permissions to current user only using icacls. - * This is a best-effort operation - if it fails, we log a warning but don't fail the overall operation. - * - * @param filePath - Path to the file to secure - */ -function restrictWindowsFilePermissions(filePath: string): void { - const isDebug = process.env.DEBUG === 'true'; - - try { - // Use icacls to: - // 1. Disable inheritance and remove all inherited permissions (/inheritance:r) - // 2. Grant full control to the current user only (/grant:r %USERNAME%:F) - // This mimics Unix 0600 permissions (owner read/write only) - const username = userInfo().username; - - // First, disable inheritance and remove inherited permissions - execFileSync('icacls', [filePath, '/inheritance:r'], { - windowsHide: true, - timeout: 5000, - }); - - // Then grant full control to current user only - execFileSync('icacls', [filePath, '/grant:r', `${username}:F`], { - windowsHide: true, - timeout: 5000, - }); - - if (isDebug) { - console.warn('[CredentialUtils:Windows] Set restrictive permissions on:', filePath); - } - } catch (error) { - // Non-fatal: log warning but don't fail the operation - // The file is still protected by the user's home directory permissions - const errorMessage = error instanceof Error ? error.message : String(error); - console.warn('[CredentialUtils:Windows] Could not set restrictive file permissions:', errorMessage); - } -} - -/** - * Update credentials in Windows .credentials.json file with new tokens (fallback). - * - * This is the fallback method for Windows when Credential Manager is unavailable. - * Claude CLI on Windows primarily uses file-based storage (.credentials.json), - * so this fallback ensures credentials are persisted even if Credential Manager fails. - * - * Security: We use icacls to restrict file permissions to the current user only, - * mimicking Unix 0600 permissions. This prevents other users on multi-user systems - * from reading the OAuth tokens. - * - * @param configDir - Config directory for the profile (undefined for default profile) - * @param credentials - New credentials to store - * @returns Result indicating success or failure - */ -function updateWindowsFileCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const credentialsPath = getWindowsCredentialsPath(configDir); - const isDebug = process.env.DEBUG === 'true'; - - // Defense-in-depth: Validate credentials path - if (!isValidCredentialsPath(credentialsPath)) { - return { success: false, error: 'Invalid credentials path' }; - } - - try { - // Read existing credentials to preserve email and other fields - const existing = getFullCredentialsFromWindowsFile(configDir); - - // Build new credential JSON with all fields - const newCredentialData = { - claudeAiOauth: { - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - expiresAt: credentials.expiresAt, - scopes: credentials.scopes || existing.scopes || [], - email: existing.email || undefined, - emailAddress: existing.email || undefined, - subscriptionType: existing.subscriptionType || undefined, - rateLimitTier: existing.rateLimitTier || undefined - }, - email: existing.email || undefined - }; - - const credentialsJson = JSON.stringify(newCredentialData, null, 2); - - // Ensure directory exists with secure permissions - const dirPath = dirname(credentialsPath); - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }); - // Restrict directory permissions to current user only (mimics Unix 0700) - restrictWindowsFilePermissions(dirPath); - } - - // Atomic file write: write to temp file, set permissions, then rename. - // This prevents a race condition where the file briefly exists with default permissions. - // Use cryptographically random suffix for secure temp file creation - const tempSuffix = `${randomBytes(16).toString('hex')}.tmp`; - const tempPath = `${credentialsPath}.${tempSuffix}`; - - // Validate temp path is safe (same directory as credentials path) - if (!isValidCredentialsPath(tempPath)) { - return { success: false, error: 'Invalid temp file path generated' }; - } - - try { - // Write to temp file - writeFileSync(tempPath, credentialsJson, { encoding: 'utf-8' }); - - // Restrict temp file permissions to current user only (mimics Unix 0600) - restrictWindowsFilePermissions(tempPath); - - // Atomic rename (on same filesystem, this is atomic on Windows) - renameSync(tempPath, credentialsPath); - } catch (writeError) { - // Clean up temp file on error - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - } catch { - // Ignore cleanup errors - } - throw writeError; - } - - if (isDebug) { - console.warn('[CredentialUtils:Windows:Update] Successfully updated credentials file:', credentialsPath); - } - - // Clear cached credentials to ensure fresh values are read - clearCredentialCache(configDir); - - return { success: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - console.error('[CredentialUtils:Windows:Update] Failed to update credentials file:', errorMessage); - return { success: false, error: `File update failed: ${errorMessage}` }; - } -} - -/** - * Update credentials in Windows - writes to file FIRST (primary storage), then Credential Manager. - * - * Claude CLI on Windows primarily uses file-based storage (.credentials.json). - * We write to file first to ensure Claude CLI always has the latest tokens, - * then update Credential Manager for forward compatibility. - * - * IMPORTANT: The write order matters! If we wrote to Credential Manager first and file - * write failed, Claude CLI would read stale tokens from the file while Credential Manager - * has the new tokens - an inconsistent state. By writing to file first, we ensure the - * primary storage is always up-to-date. - * - * @param configDir - Config directory for the profile (undefined for default profile) - * @param credentials - New credentials to store - * @returns Result indicating success or failure - */ -function updateWindowsCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - const isDebug = process.env.DEBUG === 'true'; - - // Write to file FIRST - this is what Claude CLI reads on Windows - const fileResult = updateWindowsFileCredentials(configDir, credentials); - if (!fileResult.success) { - // File write failed - don't proceed with Credential Manager to avoid inconsistent state - console.error('[CredentialUtils:Windows:Update] File update failed:', fileResult.error); - return fileResult; - } - - // File write succeeded - now update Credential Manager for forward compatibility - const psPath = findPowerShellPath(); - if (psPath) { - const credManagerResult = updateWindowsCredentialManagerCredentials(configDir, credentials); - if (!credManagerResult.success) { - // Credential Manager failed but file succeeded - this is acceptable - // Claude CLI will use the file, which has the latest tokens - if (isDebug) { - console.warn('[CredentialUtils:Windows:Update] Credential Manager update failed (file update succeeded):', credManagerResult.error); - } - } - } - - // Return success since file (primary storage) was updated successfully - return { success: true }; -} - -/** - * Update credentials in the platform-specific secure storage with new tokens. - * Called after a successful OAuth token refresh to persist the new tokens. - * - * CRITICAL: This must be called immediately after token refresh because the old tokens - * are revoked by Anthropic as soon as new tokens are issued. - * - * @param configDir - Config directory for the profile (undefined for default profile) - * @param credentials - New credentials to store - * @returns Result indicating success or failure - */ -export function updateKeychainCredentials( - configDir: string | undefined, - credentials: { - accessToken: string; - refreshToken: string; - expiresAt: number; - scopes?: string[]; - } -): UpdateCredentialsResult { - if (isMacOS()) { - return updateMacOSKeychainCredentials(configDir, credentials); - } - - if (isLinux()) { - return updateLinuxCredentials(configDir, credentials); - } - - if (isWindows()) { - return updateWindowsCredentials(configDir, credentials); - } - - return { success: false, error: `Unsupported platform: ${process.platform}` }; -} - -// ============================================================================= -// Profile Subscription Metadata Helper -// ============================================================================= - -/** - * Result of updating profile subscription metadata - */ -export interface UpdateSubscriptionMetadataResult { - /** Whether subscriptionType was updated */ - subscriptionTypeUpdated: boolean; - /** Whether rateLimitTier was updated */ - rateLimitTierUpdated: boolean; - /** The subscriptionType value (if found) */ - subscriptionType?: string | null; - /** The rateLimitTier value (if found) */ - rateLimitTier?: string | null; -} - -/** - * Options for updateProfileSubscriptionMetadata - */ -export interface UpdateSubscriptionMetadataOptions { - /** - * If true, only update fields that are currently missing (undefined/null/empty). - * This is useful for migration/initialization code that should not overwrite existing values. - * Default: false (always update if credentials have values) - */ - onlyIfMissing?: boolean; -} - -/** - * Update a profile's subscription metadata (subscriptionType, rateLimitTier) from Keychain credentials. - * - * This helper centralizes the common pattern of reading subscription info from Keychain - * and updating a profile object. It's used after OAuth login, onboarding completion, - * and profile authentication verification. - * - * NOTE: This function mutates the profile object directly. The caller is responsible - * for saving the profile after calling this function. - * - * @param profile - The profile object to update (must have subscriptionType and rateLimitTier properties) - * @param configDirOrCredentials - Either a config directory path to read credentials from, - * or pre-fetched FullOAuthCredentials to avoid redundant reads - * @param options - Optional settings like onlyIfMissing - * @returns Information about what was updated - * - * @example - * ```typescript - * // Option 1: Pass configDir - helper fetches credentials - * const result = updateProfileSubscriptionMetadata(profile, profile.configDir); - * - * // Option 2: Pass pre-fetched credentials (more efficient when already fetched) - * const fullCreds = getFullCredentialsFromKeychain(profile.configDir); - * const result = updateProfileSubscriptionMetadata(profile, fullCreds); - * - * // Option 3: Only populate if missing (for migration/initialization) - * const result = updateProfileSubscriptionMetadata(profile, profile.configDir, { onlyIfMissing: true }); - * - * if (result.subscriptionTypeUpdated || result.rateLimitTierUpdated) { - * profileManager.saveProfile(profile); - * } - * ``` - */ -export function updateProfileSubscriptionMetadata( - profile: { subscriptionType?: string | null; rateLimitTier?: string | null }, - configDirOrCredentials: string | undefined | FullOAuthCredentials, - options?: UpdateSubscriptionMetadataOptions -): UpdateSubscriptionMetadataResult { - const result: UpdateSubscriptionMetadataResult = { - subscriptionTypeUpdated: false, - rateLimitTierUpdated: false, - }; - - const onlyIfMissing = options?.onlyIfMissing ?? false; - - // Determine if we received pre-fetched credentials or a configDir - const fullCreds: FullOAuthCredentials = - typeof configDirOrCredentials === 'object' && configDirOrCredentials !== null - ? configDirOrCredentials - : getFullCredentialsFromKeychain(configDirOrCredentials); - - // Update subscriptionType if credentials have it and (not onlyIfMissing OR profile doesn't have it) - if (fullCreds.subscriptionType && (!onlyIfMissing || !profile.subscriptionType)) { - profile.subscriptionType = fullCreds.subscriptionType; - result.subscriptionTypeUpdated = true; - result.subscriptionType = fullCreds.subscriptionType; - } - - // Update rateLimitTier if credentials have it and (not onlyIfMissing OR profile doesn't have it) - if (fullCreds.rateLimitTier && (!onlyIfMissing || !profile.rateLimitTier)) { - profile.rateLimitTier = fullCreds.rateLimitTier; - result.rateLimitTierUpdated = true; - result.rateLimitTier = fullCreds.rateLimitTier; - } - - return result; -} diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index ed9d7988..c6779f78 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -40,94 +40,6 @@ export interface ProfileStoreData { autoSwitch?: ClaudeAutoSwitchSettings; /** Unified priority order for both OAuth and API profiles */ accountPriorityOrder?: string[]; - /** - * Profile IDs that were migrated from shared ~/.claude to isolated directories. - * These profiles need re-authentication since their credentials are in the old location. - * Cleared after successful re-authentication. - */ - migratedProfileIds?: string[]; -} - -/** - * Check if a profile uses the legacy shared ~/.claude directory - */ -function usesLegacySharedDirectory(profile: ClaudeProfile): boolean { - if (!profile.configDir) return false; - - // Normalize paths for comparison - const normalizedConfigDir = profile.configDir.startsWith('~') - ? join(homedir(), profile.configDir.slice(1)) - : profile.configDir; - - return normalizedConfigDir === DEFAULT_CLAUDE_CONFIG_DIR; -} - -/** - * Migrate a profile from shared ~/.claude to isolated ~/.claude-profiles/{name} - * Returns the new configDir path - * - * Handles directory collisions by appending a counter (e.g., 'work-account-2') - * when two profile names sanitize to the same value. - */ -function migrateProfileToIsolatedDirectory(profile: ClaudeProfile): string { - // Generate isolated directory name from profile name - const baseName = profile.name.toLowerCase().replace(/[^a-z0-9]+/g, '-') || 'primary'; - - // Ensure the profiles directory exists - if (!existsSync(CLAUDE_PROFILES_DIR)) { - mkdirSync(CLAUDE_PROFILES_DIR, { recursive: true }); - } - - // Check for directory collision and append counter if needed - let sanitizedName = baseName; - let counter = 1; - let isolatedDir = join(CLAUDE_PROFILES_DIR, sanitizedName); - - // Keep incrementing counter until we find an available directory name - // Use profile.id as a marker file to detect if the directory belongs to this profile - // NOTE: There's a TOCTOU race window between existsSync and readFileSync, but this is - // acceptable because profile directory creation is infrequent and concurrent creation - // is unlikely. The worst case is we increment the counter unnecessarily. - while (existsSync(isolatedDir)) { - const markerFile = join(isolatedDir, '.profile-id'); - if (existsSync(markerFile)) { - try { - const existingId = readFileSync(markerFile, 'utf-8').trim(); - if (existingId === profile.id) { - // This directory belongs to us, use it - break; - } - } catch { - // Ignore read errors, treat as collision - } - } - // Directory exists but belongs to different profile, try next counter - counter++; - sanitizedName = `${baseName}-${counter}`; - isolatedDir = join(CLAUDE_PROFILES_DIR, sanitizedName); - } - - // Create the profile directory if it doesn't exist - if (!existsSync(isolatedDir)) { - mkdirSync(isolatedDir, { recursive: true }); - } - - // Write a marker file with our profile ID for collision detection - // Use 'wx' flag to atomically create file only if it doesn't exist (avoids TOCTOU race) - const markerFile = join(isolatedDir, '.profile-id'); - try { - writeFileSync(markerFile, profile.id, { encoding: 'utf-8', flag: 'wx' }); - } catch (err) { - // EEXIST means file already exists, which is fine - we already own this directory - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { - console.warn('[ProfileStorage] Failed to write marker file:', err); - } - } - - console.warn(`[ProfileStorage] Migrated profile "${profile.name}" from ~/.claude to ${isolatedDir}`); - console.warn('[ProfileStorage] NOTE: Credentials remain at ~/.claude - user should re-authenticate in Settings > Accounts'); - - return isolatedDir; } /** @@ -144,9 +56,6 @@ function parseAndMigrateProfileData(data: Record): ProfileStore } if (data.version === STORE_VERSION) { - // Track profiles that were migrated in this session - const newlyMigratedProfileIds: string[] = []; - // Parse dates and migrate profile data const profiles = data.profiles as ClaudeProfile[]; data.profiles = profiles.map((p: ClaudeProfile) => { @@ -161,23 +70,8 @@ function parseAndMigrateProfileData(data: Record): ProfileStore // eslint-disable-next-line @typescript-eslint/no-unused-vars const { oauthToken: _, tokenCreatedAt: __, ...profileWithoutToken } = p; - // MIGRATION: Move profiles from shared ~/.claude to isolated directories - // This prevents interference with external Claude Code CLI usage - let configDir = profileWithoutToken.configDir; - if (usesLegacySharedDirectory(p)) { - configDir = migrateProfileToIsolatedDirectory(p); - // Track this profile as newly migrated (needs re-authentication) - newlyMigratedProfileIds.push(p.id); - console.warn('[ProfileStorage] Profile isolation migration:', { - profileName: p.name, - oldConfigDir: p.configDir, - newConfigDir: configDir - }); - } - return { ...profileWithoutToken, - configDir, // Use migrated configDir createdAt: new Date(p.createdAt), lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, usage: p.usage ? { @@ -191,14 +85,6 @@ function parseAndMigrateProfileData(data: Record): ProfileStore })) }; }); - - // Merge newly migrated profiles with any existing migratedProfileIds - const existingMigrated = (data.migratedProfileIds as string[] | undefined) || []; - const allMigratedIds = [...new Set([...existingMigrated, ...newlyMigratedProfileIds])]; - if (allMigratedIds.length > 0) { - data.migratedProfileIds = allMigratedIds; - } - return data as unknown as ProfileStoreData; } diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index c6799a6e..7d6f7c83 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -203,26 +203,6 @@ export function hasValidToken(profile: ClaudeProfile): boolean { return true; } -/** - * Check if an API profile has valid authentication credentials. - * Validates that both apiKey and baseUrl are present and non-empty. - * - * @param profile - The API profile to check - * @returns true if the profile has both apiKey and baseUrl, false otherwise - */ -export function isAPIProfileAuthenticated(profile: APIProfile): boolean { - // Check for presence of required fields - if (!profile?.apiKey || !profile?.baseUrl) { - return false; - } - - // Validate that the fields are non-empty strings (after trimming whitespace) - const hasValidApiKey = typeof profile.apiKey === 'string' && profile.apiKey.trim().length > 0; - const hasValidBaseUrl = typeof profile.baseUrl === 'string' && profile.baseUrl.trim().length > 0; - - return hasValidApiKey && hasValidBaseUrl; -} - /** * Expand ~ in path to home directory */ diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts index 17c0f7b3..0928ce68 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.test.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.test.ts @@ -56,6 +56,15 @@ vi.mock('../services/profile/profile-manager', () => ({ loadProfilesFile: () => mockLoadProfilesFile() })); +// Mock credential-utils to return mock token instead of reading real credentials +vi.mock('./credential-utils', () => ({ + getCredentialsFromKeychain: vi.fn(() => ({ + token: 'mock-decrypted-token', + email: 'test@example.com' + })), + clearKeychainCache: vi.fn() +})); + // Mock global fetch global.fetch = vi.fn(() => Promise.resolve({ @@ -720,7 +729,7 @@ describe('usage-monitor', () => { // 401 errors should throw await expect( - monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile') + monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined) ).rejects.toThrow('API Auth Failure: 401'); expect(consoleSpy).toHaveBeenCalled(); @@ -751,7 +760,7 @@ describe('usage-monitor', () => { // 403 errors should throw await expect( - monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile') + monitor['fetchUsageViaAPI']('expired-token', 'test-profile-1', 'Test Profile', undefined) ).rejects.toThrow('API Auth Failure: 403'); expect(consoleSpy).toHaveBeenCalled(); @@ -771,7 +780,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -786,7 +795,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -808,7 +817,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile'); + const usage = await monitor['fetchUsageViaAPI']('valid-token', 'test-profile-1', 'Test Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -831,7 +840,7 @@ describe('usage-monitor', () => { // 401 errors should throw with proper message await expect( - monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile') + monitor['fetchUsageViaAPI']('invalid-token', 'test-profile-1', 'Test Profile', undefined) ).rejects.toThrow('API Auth Failure: 401'); expect(consoleSpy).toHaveBeenCalled(); @@ -945,7 +954,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile'); + const usage = await monitor['fetchUsageViaAPI']('zai-api-key', 'zai-profile-1', 'z.ai Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -977,7 +986,7 @@ describe('usage-monitor', () => { const monitor = getUsageMonitor(); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile'); + const usage = await monitor['fetchUsageViaAPI']('zhipu-api-key', 'zhipu-profile-1', 'ZHIPU Profile', undefined); expect(usage).toBeNull(); expect(consoleSpy).toHaveBeenCalled(); @@ -1013,6 +1022,7 @@ describe('usage-monitor', () => { 'unknown-api-key', 'unknown-profile-1', 'Unknown Profile', + undefined, unknownProviderProfile ); @@ -1442,7 +1452,7 @@ describe('usage-monitor', () => { const profileId = 'test-profile-cooldown'; // Call fetchUsageViaAPI which should fail and record timestamp - await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile'); + await monitor['fetchUsageViaAPI']('valid-token', profileId, 'Test Profile', undefined); // Verify failure timestamp was recorded const failureTimestamp = monitor['apiFailureTimestamps'].get(profileId); @@ -1569,6 +1579,7 @@ describe('usage-monitor', () => { 'sk-ant-api-key', 'api-profile-1', 'API Profile', + undefined, predeterminedProfile ); @@ -1615,6 +1626,7 @@ describe('usage-monitor', () => { 'sk-ant-api-key', 'api-profile-1', 'API Profile', + undefined, // No email undefined // No activeProfile passed ); @@ -1655,6 +1667,7 @@ describe('usage-monitor', () => { 'oauth-token', 'oauth-profile', 'OAuth Profile', + undefined, oauthProfile ); diff --git a/apps/frontend/src/main/claude-profile/usage-monitor.ts b/apps/frontend/src/main/claude-profile/usage-monitor.ts index 06ef579b..b53c2fa1 100644 --- a/apps/frontend/src/main/claude-profile/usage-monitor.ts +++ b/apps/frontend/src/main/claude-profile/usage-monitor.ts @@ -12,14 +12,40 @@ import { EventEmitter } from 'events'; import { homedir } from 'os'; import { getClaudeProfileManager } from '../claude-profile-manager'; -import { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import { ClaudeUsageSnapshot, ProfileUsageSummary, AllProfilesUsage } from '../../shared/types/agent'; import { loadProfilesFile } from '../services/profile/profile-manager'; import type { APIProfile } from '../../shared/types/profile'; import { detectProvider as sharedDetectProvider, type ApiProvider } from '../../shared/utils/provider-detection'; +import { getCredentialsFromKeychain, clearKeychainCache } from './credential-utils'; +import { isProfileRateLimited } from './rate-limit-manager'; // Re-export for backward compatibility export type { ApiProvider }; +/** + * Create a safe fingerprint of a credential for debug logging. + * Shows first 8 and last 4 characters, hiding the sensitive middle portion. + * This is NOT for authentication - only for human-readable debug identification. + * + * @param credential - The credential (token or API key) to create a fingerprint for + * @returns A safe fingerprint like "sk-ant-oa...xyz9" or "null" if no credential + */ +function getCredentialFingerprint(credential: string | null | undefined): string { + if (!credential) return 'null'; + if (credential.length <= 16) return credential.slice(0, 4) + '...' + credential.slice(-2); + return credential.slice(0, 8) + '...' + credential.slice(-4); +} + +/** + * Allowed domains for usage API requests. + * Only these domains are permitted for outbound usage monitoring requests. + */ +const ALLOWED_USAGE_API_DOMAINS = new Set([ + 'api.anthropic.com', + 'api.z.ai', + 'open.bigmodel.cn', +]); + /** * Provider usage endpoint configuration * Maps each provider to its usage monitoring endpoint path @@ -156,11 +182,21 @@ export function detectProvider(baseUrl: string): ApiProvider { interface ActiveProfileResult { profileId: string; profileName: string; + profileEmail?: string; isAPIProfile: boolean; baseUrl: string; credential?: string; } +/** + * Type guard to check if an error has an HTTP status code + * @param error - The error to check + * @returns true if the error has a statusCode property + */ +function isHttpError(error: unknown): error is Error & { statusCode?: number } { + return error instanceof Error && 'statusCode' in error; +} + export class UsageMonitor extends EventEmitter { private static instance: UsageMonitor; private intervalId: NodeJS.Timeout | null = null; @@ -177,6 +213,11 @@ export class UsageMonitor extends EventEmitter { private authFailedProfiles: Map = new Map(); // profileId -> timestamp private static AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes cooldown + // Cache for all profiles' usage data + // Map + private allProfilesUsageCache: Map = new Map(); + private static PROFILE_USAGE_CACHE_TTL_MS = 60 * 1000; // 1 minute cache for inactive profiles + // Debug flag for verbose logging private readonly isDebug = process.env.DEBUG === 'true'; @@ -252,13 +293,330 @@ export class UsageMonitor extends EventEmitter { return this.currentUsage; } + /** + * Clear the usage cache for a specific profile. + * Called after re-authentication to ensure fresh usage data is fetched. + * + * @param profileId - Profile identifier to clear cache for + */ + clearProfileUsageCache(profileId: string): void { + const deleted = this.allProfilesUsageCache.delete(profileId); + if (this.isDebug) { + console.warn('[UsageMonitor] Cleared usage cache for profile:', { + profileId, + wasInCache: deleted + }); + } + } + + /** + * Get all profiles usage data (for multi-profile display in UI) + * Returns cached data if fresh, otherwise fetches for all profiles + * + * Uses parallel fetching for inactive profiles to minimize blocking delays. + */ + async getAllProfilesUsage(): Promise { + const profileManager = getClaudeProfileManager(); + const settings = profileManager.getSettings(); + const activeProfileId = settings.activeProfileId; + + if (!this.currentUsage) { + return null; + } + + const now = Date.now(); + const allProfiles: ProfileUsageSummary[] = []; + + // First pass: identify profiles that need fresh data vs cached + type ProfileToFetch = { profile: typeof settings.profiles[0]; index: number }; + const profilesToFetch: ProfileToFetch[] = []; + const profileResults: (ProfileUsageSummary | null)[] = new Array(settings.profiles.length).fill(null); + + for (let i = 0; i < settings.profiles.length; i++) { + const profile = settings.profiles[i]; + const cached = this.allProfilesUsageCache.get(profile.id); + + // Use cached data if fresh (within TTL) + if (cached && (now - cached.fetchedAt) < UsageMonitor.PROFILE_USAGE_CACHE_TTL_MS) { + profileResults[i] = { + ...cached.usage, + isActive: profile.id === activeProfileId + }; + continue; + } + + // For active profile, use the current detailed usage + if (profile.id === activeProfileId && this.currentUsage) { + const summary = this.buildProfileUsageSummary(profile, this.currentUsage); + profileResults[i] = summary; + this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now }); + continue; + } + + // Mark for parallel fetch + profilesToFetch.push({ profile, index: i }); + } + + // Parallel fetch for all inactive profiles that need fresh data + if (profilesToFetch.length > 0) { + // Collect usage updates for batch save (avoids race condition with concurrent saves) + const usageUpdates: Array<{ profileId: string; sessionPercent: number; weeklyPercent: number }> = []; + + const fetchPromises = profilesToFetch.map(async ({ profile, index }) => { + const inactiveUsage = await this.fetchUsageForInactiveProfile(profile); + const rateLimitStatus = isProfileRateLimited(profile); + + let sessionPercent = 0; + let weeklyPercent = 0; + + if (inactiveUsage) { + sessionPercent = inactiveUsage.sessionPercent; + weeklyPercent = inactiveUsage.weeklyPercent; + // Collect update for batch save (don't save here to avoid race condition) + return { + index, + update: { profileId: profile.id, sessionPercent, weeklyPercent }, + profile, + inactiveUsage, + rateLimitStatus + }; + } else { + // Fallback to cached profile data if API fetch failed + sessionPercent = profile.usage?.sessionUsagePercent ?? 0; + weeklyPercent = profile.usage?.weeklyUsagePercent ?? 0; + return { + index, + update: null, // No update needed for fallback + profile, + inactiveUsage, + rateLimitStatus, + sessionPercent, + weeklyPercent + }; + } + }); + + // Wait for all fetches to complete in parallel + const fetchResults = await Promise.all(fetchPromises); + + // Collect all updates and build summaries + for (const result of fetchResults) { + const { index, update, profile, inactiveUsage, rateLimitStatus } = result; + + // Get percentages from either the update or the fallback values + const sessionPercent = update?.sessionPercent ?? result.sessionPercent ?? 0; + const weeklyPercent = update?.weeklyPercent ?? result.weeklyPercent ?? 0; + + if (update) { + usageUpdates.push(update); + } + + const summary: ProfileUsageSummary = { + profileId: profile.id, + profileName: profile.name, + profileEmail: profile.email, + sessionPercent, + weeklyPercent, + isAuthenticated: profile.isAuthenticated ?? false, + isRateLimited: rateLimitStatus.limited, + rateLimitType: rateLimitStatus.type, + availabilityScore: this.calculateAvailabilityScore( + sessionPercent, + weeklyPercent, + rateLimitStatus.limited, + rateLimitStatus.type, + profile.isAuthenticated ?? false + ), + isActive: profile.id === activeProfileId, + lastFetchedAt: inactiveUsage?.fetchedAt?.toISOString() ?? profile.usage?.lastUpdated?.toISOString() + }; + + this.allProfilesUsageCache.set(profile.id, { usage: summary, fetchedAt: now }); + profileResults[index] = summary; + } + + // Batch save all usage updates at once (single disk write, no race condition) + if (usageUpdates.length > 0) { + profileManager.batchUpdateProfileUsageFromAPI(usageUpdates); + } + } + + // Collect non-null results + for (const result of profileResults) { + if (result) { + allProfiles.push(result); + } + } + + // Sort by availability score (highest first = most available) + allProfiles.sort((a, b) => b.availabilityScore - a.availabilityScore); + + return { + activeProfile: this.currentUsage, + allProfiles, + fetchedAt: new Date() + }; + } + + /** + * Fetch usage for an inactive profile using its own credentials + * This allows showing real usage data for non-active profiles + */ + private async fetchUsageForInactiveProfile( + profile: { id: string; name: string; email?: string; configDir?: string; isAuthenticated?: boolean } + ): Promise { + // Only fetch for authenticated profiles with a configDir + if (!profile.isAuthenticated || !profile.configDir) { + if (this.isDebug) { + console.warn('[UsageMonitor] Skipping inactive profile fetch - not authenticated or no configDir:', { + profileId: profile.id, + profileName: profile.name, + isAuthenticated: profile.isAuthenticated, + hasConfigDir: !!profile.configDir + }); + } + return null; + } + + try { + // Get credentials from keychain for this profile's configDir + const expandedConfigDir = profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, homedir()) + : profile.configDir; + + const keychainCreds = getCredentialsFromKeychain(expandedConfigDir); + + if (!keychainCreds.token) { + if (this.isDebug) { + console.warn('[UsageMonitor] No keychain credentials for inactive profile:', profile.name); + } + return null; + } + + if (this.isDebug) { + console.warn('[UsageMonitor] Fetching usage for inactive profile:', { + profileId: profile.id, + profileName: profile.name, + tokenFingerprint: getCredentialFingerprint(keychainCreds.token) + }); + } + + // Fetch usage via API - OAuth profiles always use Anthropic + const usage = await this.fetchUsageViaAPI( + keychainCreds.token, + profile.id, + profile.name, + keychainCreds.email ?? profile.email, + { + profileId: profile.id, + profileName: profile.name, + profileEmail: keychainCreds.email ?? profile.email, + isAPIProfile: false, + baseUrl: 'https://api.anthropic.com' + } + ); + + if (this.isDebug && usage) { + console.warn('[UsageMonitor] Successfully fetched inactive profile usage:', { + profileName: profile.name, + sessionPercent: usage.sessionPercent, + weeklyPercent: usage.weeklyPercent + }); + } + + return usage; + } catch (error) { + console.warn('[UsageMonitor] Failed to fetch inactive profile usage:', profile.name, error); + return null; + } + } + + /** + * Build a ProfileUsageSummary from a ClaudeUsageSnapshot + */ + private buildProfileUsageSummary( + profile: { id: string; name: string; email?: string; isAuthenticated?: boolean }, + usage: ClaudeUsageSnapshot + ): ProfileUsageSummary { + const profileManager = getClaudeProfileManager(); + const fullProfile = profileManager.getProfile(profile.id); + const rateLimitStatus = fullProfile ? isProfileRateLimited(fullProfile) : { limited: false }; + + return { + profileId: profile.id, + profileName: profile.name, + profileEmail: usage.profileEmail || profile.email, + sessionPercent: usage.sessionPercent, + weeklyPercent: usage.weeklyPercent, + sessionResetTimestamp: usage.sessionResetTimestamp, + weeklyResetTimestamp: usage.weeklyResetTimestamp, + isAuthenticated: profile.isAuthenticated ?? true, + isRateLimited: rateLimitStatus.limited, + rateLimitType: rateLimitStatus.type, + availabilityScore: this.calculateAvailabilityScore( + usage.sessionPercent, + usage.weeklyPercent, + rateLimitStatus.limited, + rateLimitStatus.type, + profile.isAuthenticated ?? true + ), + isActive: usage.profileId === profileManager.getActiveProfile()?.id, + lastFetchedAt: usage.fetchedAt?.toISOString() + }; + } + + /** + * Calculate availability score for a profile (higher = more available) + * + * Scoring algorithm: + * - Base score: 100 + * - Rate limited: -500 (session) or -1000 (weekly) + * - Unauthenticated: -500 + * - Weekly usage penalty: -(weeklyPercent * 0.5) + * - Session usage penalty: -(sessionPercent * 0.2) + */ + private calculateAvailabilityScore( + sessionPercent: number, + weeklyPercent: number, + isRateLimited: boolean, + rateLimitType?: 'session' | 'weekly', + isAuthenticated: boolean = true + ): number { + let score = 100; + + // Penalize rate-limited profiles heavily + if (isRateLimited) { + if (rateLimitType === 'weekly') { + score -= 1000; // Weekly limit is worse (takes longer to reset) + } else { + score -= 500; // Session limit resets sooner + } + } + + // Penalize unauthenticated profiles + if (!isAuthenticated) { + score -= 500; + } + + // Penalize based on current usage (weekly more important) + score -= weeklyPercent * 0.5; + score -= sessionPercent * 0.2; + + return Math.round(score * 100) / 100; // Round to 2 decimal places + } + /** * Get credential for usage monitoring (OAuth token or API key) * Detects profile type and returns appropriate credential * * Priority: * 1. API Profile (if active) - returns apiKey directly - * 2. OAuth Profile - returns decrypted oauthToken + * 2. OAuth Profile - reads FRESH token from Keychain (not cached oauthToken) + * + * IMPORTANT: For OAuth profiles, we read from Keychain instead of cached profile.oauthToken. + * OAuth tokens expire in 8-12 hours, but Claude CLI auto-refreshes and stores fresh tokens + * in Keychain. Using cached tokens causes 401 errors after a few hours. + * See: docs/LONG_LIVED_AUTH_PLAN.md * * @returns The credential string or undefined if none available */ @@ -284,15 +642,32 @@ export class UsageMonitor extends EventEmitter { } } - // Fall back to OAuth profile + // Fall back to OAuth profile - read FRESH token from Keychain const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); - if (activeProfile?.oauthToken) { - const decryptedToken = profileManager.getProfileToken(activeProfile.id); - if (this.isDebug && decryptedToken) { - console.warn('[UsageMonitor:TRACE] Using OAuth profile credential:', activeProfile.name); + if (activeProfile) { + // Read fresh token from Keychain using configDir (same as CLAUDE_CONFIG_DIR) + // This ensures we get the auto-refreshed token, not a stale cached copy + // IMPORTANT: Always pass configDir, even for default profiles - the keychain + // service name is based on the expanded path (e.g., /Users/xxx/.claude), not undefined + const keychainCreds = getCredentialsFromKeychain(activeProfile.configDir); + + if (keychainCreds.token) { + if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] Using OAuth token from Keychain for profile:', activeProfile.name, { + tokenFingerprint: getCredentialFingerprint(keychainCreds.token) + }); + } + return keychainCreds.token; + } + + // Keychain read failed - log warning (don't fall back to cached token) + if (keychainCreds.error) { + console.warn('[UsageMonitor] Keychain access failed:', keychainCreds.error); + } else if (this.isDebug) { + console.warn('[UsageMonitor:TRACE] No token in Keychain for profile:', activeProfile.name, + '- user may need to re-authenticate with claude /login'); } - return decryptedToken; } // No credential available @@ -340,9 +715,19 @@ export class UsageMonitor extends EventEmitter { this.currentUsage = usage; this.currentUsageProfileId = profileId; // Track which profile this usage belongs to + // Step 2.5: Persist usage to profile for caching (so other profiles can display cached usage) + const profileManager = getClaudeProfileManager(); + profileManager.updateProfileUsageFromAPI(profileId, usage.sessionPercent, usage.weeklyPercent); + // Step 3: Emit usage update for UI (always emit, regardless of proactive swap settings) this.emit('usage-updated', usage); + // Step 3.5: Emit all profiles usage for multi-profile display + const allProfilesUsage = await this.getAllProfilesUsage(); + if (allProfilesUsage) { + this.emit('all-profiles-usage-updated', allProfilesUsage); + } + // Step 4: Check thresholds and perform proactive swap (OAuth profiles only) if (!isAPIProfile) { const profileManager = getClaudeProfileManager(); @@ -396,7 +781,7 @@ export class UsageMonitor extends EventEmitter { } } catch (error) { // Step 5: Handle auth failures - if ((error as any).statusCode === 401 || (error as any).statusCode === 403) { + if (isHttpError(error) && (error.statusCode === 401 || error.statusCode === 403)) { if (profileId) { await this.handleAuthFailure(profileId, isAPIProfile); return; // handleAuthFailure manages its own logging @@ -485,19 +870,32 @@ export class UsageMonitor extends EventEmitter { return null; } + // Get email from profile or try keychain + let profileEmail = activeOAuthProfile.email; + if (!profileEmail) { + // Try to get email from keychain + // IMPORTANT: Always pass configDir - service name is based on expanded path (e.g., /Users/xxx/.claude) + const keychainCreds = getCredentialsFromKeychain(activeOAuthProfile.configDir); + profileEmail = keychainCreds.email ?? undefined; + } + if (this.isDebug) { console.warn('[UsageMonitor:TRACE] Active auth type: OAuth Profile', { profileId: activeOAuthProfile.id, - profileName: activeOAuthProfile.name + profileName: activeOAuthProfile.name, + profileEmail }); } - return { + const result = { profileId: activeOAuthProfile.id, profileName: activeOAuthProfile.name, + profileEmail, isAPIProfile: false, baseUrl: 'https://api.anthropic.com' }; + + return result; } /** @@ -529,6 +927,17 @@ export class UsageMonitor extends EventEmitter { */ private async handleAuthFailure(profileId: string, isAPIProfile: boolean): Promise { const profileManager = getClaudeProfileManager(); + + // Clear keychain cache for this profile so next attempt gets fresh credentials + // This handles cases where the token was refreshed by Claude CLI but our cache is stale + if (!isAPIProfile) { + const profile = profileManager.getProfile(profileId); + if (profile?.configDir) { + console.warn('[UsageMonitor] Auth failure - clearing keychain cache for profile:', profileId); + clearKeychainCache(profile.configDir); + } + } + const settings = profileManager.getAutoSwitchSettings(); // Proactive swap is only supported for OAuth profiles, not API profiles @@ -578,27 +987,45 @@ export class UsageMonitor extends EventEmitter { credential?: string, activeProfile?: ActiveProfileResult ): Promise { - // Get profile name - check both API profiles and OAuth profiles + // Get profile name and email - prefer activeProfile since it's already determined let profileName: string | undefined; + let profileEmail: string | undefined; - // First, check if it's an API profile - try { - const profilesFile = await loadProfilesFile(); - const apiProfile = profilesFile.profiles.find(p => p.id === profileId); - if (apiProfile) { - profileName = apiProfile.name; - if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Found API profile:', { - profileId, - profileName, - baseUrl: apiProfile.baseUrl - }); - } - } - } catch (error) { - // Failed to load API profiles, continue to OAuth check + // Use activeProfile data if available (already fetched and validated) + // This fixes the bug where API profile names were incorrectly shown for OAuth profiles + if (activeProfile?.profileName) { + profileName = activeProfile.profileName; + profileEmail = activeProfile.profileEmail; if (this.isDebug) { - console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error); + console.warn('[UsageMonitor:FETCH] Using activeProfile data:', { + profileId, + profileName, + profileEmail, + isAPIProfile: activeProfile.isAPIProfile + }); + } + } + + // Only search API profiles if not already set from activeProfile + if (!profileName) { + try { + const profilesFile = await loadProfilesFile(); + const apiProfile = profilesFile.profiles.find(p => p.id === profileId); + if (apiProfile) { + profileName = apiProfile.name; + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Found API profile:', { + profileId, + profileName, + baseUrl: apiProfile.baseUrl + }); + } + } + } catch (error) { + // Failed to load API profiles, continue to OAuth check + if (this.isDebug) { + console.warn('[UsageMonitor:FETCH] Failed to load API profiles:', error); + } } } @@ -608,10 +1035,15 @@ export class UsageMonitor extends EventEmitter { const oauthProfile = profileManager.getProfile(profileId); if (oauthProfile) { profileName = oauthProfile.name; + // Get email from OAuth profile if not already set + if (!profileEmail) { + profileEmail = oauthProfile.email; + } if (this.isDebug) { console.warn('[UsageMonitor:FETCH] Found OAuth profile:', { profileId, - profileName + profileName, + profileEmail }); } } @@ -638,7 +1070,7 @@ export class UsageMonitor extends EventEmitter { if (this.isDebug) { console.warn('[UsageMonitor:FETCH] Attempting API fetch method'); } - const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, activeProfile); + const apiUsage = await this.fetchUsageViaAPI(credential, profileId, profileName, profileEmail, activeProfile); if (apiUsage) { console.warn('[UsageMonitor] Successfully fetched via API'); if (this.isDebug) { @@ -683,6 +1115,7 @@ export class UsageMonitor extends EventEmitter { * @param credential - OAuth token or API key * @param profileId - Profile identifier * @param profileName - Profile display name + * @param profileEmail - Optional email associated with the profile * @param activeProfile - Optional pre-determined active profile info to avoid race conditions * @returns Normalized usage snapshot or null on failure */ @@ -690,6 +1123,7 @@ export class UsageMonitor extends EventEmitter { credential: string, profileId: string, profileName: string, + profileEmail?: string, activeProfile?: ActiveProfileResult ): Promise { if (this.isDebug) { @@ -754,6 +1188,14 @@ export class UsageMonitor extends EventEmitter { return null; } + if (this.isDebug) { + console.warn('[UsageMonitor:API_FETCH] API request:', { + endpoint: usageEndpoint, + profileId, + credentialFingerprint: getCredentialFingerprint(credential) + }); + } + if (this.isDebug) { console.warn('[UsageMonitor:API_FETCH] Fetching from endpoint:', { provider, @@ -762,25 +1204,45 @@ export class UsageMonitor extends EventEmitter { }); } - // Step 4: Fetch usage from provider endpoint + // Step 4: Validate endpoint domain before making request + // Security: Only allow requests to known provider domains + let endpointHostname: string; + try { + const endpointUrl = new URL(usageEndpoint); + endpointHostname = endpointUrl.hostname; + } catch { + console.error('[UsageMonitor] Invalid usage endpoint URL:', usageEndpoint); + return null; + } + + if (!ALLOWED_USAGE_API_DOMAINS.has(endpointHostname)) { + console.error('[UsageMonitor] Blocked request to unauthorized domain:', endpointHostname, { + allowedDomains: Array.from(ALLOWED_USAGE_API_DOMAINS) + }); + return null; + } + + // Step 5: Fetch usage from provider endpoint // All providers use Bearer token authentication (RFC 6750) const authHeader = `Bearer ${credential}`; - const response = await fetch(usageEndpoint, { - method: 'GET', - headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/json', - ...(provider === 'anthropic' && { 'anthropic-version': '2023-06-01' }) - } + // Build headers based on provider + // Anthropic OAuth requires the 'anthropic-beta: oauth-2025-04-20' header + // See: https://codelynx.dev/posts/claude-code-usage-limits-statusline + const headers: Record = { + 'Authorization': authHeader, + 'Content-Type': 'application/json', + }; + + if (provider === 'anthropic') { + // OAuth authentication requires the beta header + headers['anthropic-beta'] = 'oauth-2025-04-20'; + headers['anthropic-version'] = '2023-06-01'; } - const isAPIProfile = !!apiProfile; - this.debugLog('[UsageMonitor:TRACE] Fetching usage', { - provider, - baseUrl, - isAPIProfile, - profileId + const response = await fetch(usageEndpoint, { + method: 'GET', + headers }); if (!response.ok) { @@ -900,13 +1362,13 @@ export class UsageMonitor extends EventEmitter { switch (provider) { case 'anthropic': - normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName); + normalizedUsage = this.normalizeAnthropicResponse(rawData, profileId, profileName, profileEmail); break; case 'zai': - normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName); + normalizedUsage = this.normalizeZAIResponse(responseData, profileId, profileName, profileEmail); break; case 'zhipu': - normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName); + normalizedUsage = this.normalizeZhipuResponse(responseData, profileId, profileName, profileEmail); break; default: console.warn('[UsageMonitor] Unsupported provider for usage normalization:', provider); @@ -921,7 +1383,10 @@ export class UsageMonitor extends EventEmitter { } if (this.isDebug) { - console.warn('[UsageMonitor:PROVIDER] Normalized usage:', { + console.warn('[UsageMonitor:API_FETCH] Fetch completed - usage:', { + profileId, + profileName, + email: normalizedUsage.profileEmail, provider, sessionPercent: normalizedUsage.sessionPercent, weeklyPercent: normalizedUsage.weeklyPercent, @@ -948,32 +1413,63 @@ export class UsageMonitor extends EventEmitter { /** * Normalize Anthropic API response to ClaudeUsageSnapshot * - * Expected Anthropic response format: + * Actual Anthropic OAuth usage API response format: * { - * "five_hour_utilization": 0.72, // 0.0-1.0 - * "seven_day_utilization": 0.45, // 0.0-1.0 - * "five_hour_reset_at": "2025-01-17T15:00:00Z", - * "seven_day_reset_at": "2025-01-20T12:00:00Z" + * "five_hour": { + * "utilization": 19, // integer 0-100 + * "resets_at": "2025-01-17T15:00:00Z" + * }, + * "seven_day": { + * "utilization": 45, // integer 0-100 + * "resets_at": "2025-01-20T12:00:00Z" + * } * } */ private normalizeAnthropicResponse( data: any, profileId: string, - profileName: string + profileName: string, + profileEmail?: string ): ClaudeUsageSnapshot { - const fiveHourUtil = data.five_hour_utilization ?? 0; - const sevenDayUtil = data.seven_day_utilization ?? 0; + // Support both new nested format and legacy flat format for backward compatibility + // + // NEW format (current API): { five_hour: { utilization: 72, resets_at: "..." } } + // OLD format (legacy): { five_hour_utilization: 0.72, five_hour_reset_at: "..." } + + let fiveHourUtil: number; + let sevenDayUtil: number; + let sessionResetTimestamp: string | undefined; + let weeklyResetTimestamp: string | undefined; + + // Check for new nested format first + if (data.five_hour !== undefined || data.seven_day !== undefined) { + // New nested format - utilization is already 0-100 integer + fiveHourUtil = data.five_hour?.utilization ?? 0; + sevenDayUtil = data.seven_day?.utilization ?? 0; + sessionResetTimestamp = data.five_hour?.resets_at; + weeklyResetTimestamp = data.seven_day?.resets_at; + } else { + // Legacy flat format - utilization is 0-1 float, needs *100 + const rawFiveHour = data.five_hour_utilization ?? 0; + const rawSevenDay = data.seven_day_utilization ?? 0; + // Convert 0-1 float to 0-100 integer + fiveHourUtil = Math.round(rawFiveHour * 100); + sevenDayUtil = Math.round(rawSevenDay * 100); + sessionResetTimestamp = data.five_hour_reset_at; + weeklyResetTimestamp = data.seven_day_reset_at; + } return { - sessionPercent: Math.round(fiveHourUtil * 100), - weeklyPercent: Math.round(sevenDayUtil * 100), + sessionPercent: fiveHourUtil, + weeklyPercent: sevenDayUtil, // Omit sessionResetTime/weeklyResetTime - renderer uses timestamps with formatTimeRemaining sessionResetTime: undefined, weeklyResetTime: undefined, - sessionResetTimestamp: data.five_hour_reset_at, - weeklyResetTimestamp: data.seven_day_reset_at, + sessionResetTimestamp, + weeklyResetTimestamp, profileId, profileName, + profileEmail, fetchedAt: new Date(), limitType: sevenDayUtil > fiveHourUtil ? 'weekly' : 'session', usageWindows: { @@ -992,6 +1488,7 @@ export class UsageMonitor extends EventEmitter { * @param data - Raw response data with limits array * @param profileId - Profile identifier * @param profileName - Profile display name + * @param profileEmail - Optional email associated with the profile * @param providerName - Provider name for logging ('zai' or 'zhipu') * @returns Normalized usage snapshot or null on parse failure */ @@ -999,6 +1496,7 @@ export class UsageMonitor extends EventEmitter { data: any, profileId: string, profileName: string, + profileEmail: string | undefined, providerName: 'zai' | 'zhipu' ): ClaudeUsageSnapshot | null { const logPrefix = providerName.toUpperCase(); @@ -1098,6 +1596,7 @@ export class UsageMonitor extends EventEmitter { weeklyResetTimestamp, profileId, profileName, + profileEmail, fetchedAt: new Date(), limitType: weeklyPercent > sessionPercent ? 'weekly' : 'session', usageWindows: { @@ -1146,10 +1645,11 @@ export class UsageMonitor extends EventEmitter { private normalizeZAIResponse( data: any, profileId: string, - profileName: string + profileName: string, + profileEmail?: string ): ClaudeUsageSnapshot | null { // Delegate to shared quota/limit response normalization - return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zai'); + return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zai'); } /** @@ -1163,10 +1663,11 @@ export class UsageMonitor extends EventEmitter { private normalizeZhipuResponse( data: any, profileId: string, - profileName: string + profileName: string, + profileEmail?: string ): ClaudeUsageSnapshot | null { // Delegate to shared quota/limit response normalization - return this.normalizeQuotaLimitResponse(data, profileId, profileName, 'zhipu'); + return this.normalizeQuotaLimitResponse(data, profileId, profileName, profileEmail, 'zhipu'); } /** @@ -1198,13 +1699,61 @@ export class UsageMonitor extends EventEmitter { additionalExclusions: string[] = [] ): Promise { const profileManager = getClaudeProfileManager(); - - // Get all profiles to swap to, excluding current and any additional exclusions - const allProfiles = profileManager.getProfilesSortedByAvailability(); const excludeIds = new Set([currentProfileId, ...additionalExclusions]); - const eligibleProfiles = allProfiles.filter(p => !excludeIds.has(p.id)); - if (eligibleProfiles.length === 0) { + // Get priority order for unified account system + const priorityOrder = profileManager.getAccountPriorityOrder(); + + // Build unified list of available accounts + type UnifiedSwapTarget = { + id: string; + unifiedId: string; // oauth-{id} or api-{id} + name: string; + type: 'oauth' | 'api'; + priorityIndex: number; + }; + + const unifiedAccounts: UnifiedSwapTarget[] = []; + + // Add OAuth profiles (sorted by availability) + const oauthProfiles = profileManager.getProfilesSortedByAvailability(); + for (const profile of oauthProfiles) { + if (!excludeIds.has(profile.id)) { + const unifiedId = `oauth-${profile.id}`; + const priorityIndex = priorityOrder.indexOf(unifiedId); + unifiedAccounts.push({ + id: profile.id, + unifiedId, + name: profile.name, + type: 'oauth', + priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex + }); + } + } + + // Add API profiles (always considered available since they have unlimited usage) + try { + const profilesFile = await loadProfilesFile(); + for (const apiProfile of profilesFile.profiles) { + if (!excludeIds.has(apiProfile.id) && apiProfile.apiKey) { + const unifiedId = `api-${apiProfile.id}`; + const priorityIndex = priorityOrder.indexOf(unifiedId); + unifiedAccounts.push({ + id: apiProfile.id, + unifiedId, + name: apiProfile.name, + type: 'api', + priorityIndex: priorityIndex === -1 ? Infinity : priorityIndex + }); + } + } + } catch (error) { + if (this.isDebug) { + console.warn('[UsageMonitor] Failed to load API profiles for swap:', error); + } + } + + if (unifiedAccounts.length === 0) { console.warn('[UsageMonitor] No alternative profile for proactive swap (excluded:', Array.from(excludeIds), ')'); this.emit('proactive-swap-failed', { reason: additionalExclusions.length > 0 ? 'all_alternatives_failed_auth' : 'no_alternative', @@ -1214,8 +1763,22 @@ export class UsageMonitor extends EventEmitter { return; } - // Use the best available from eligible profiles - const bestProfile = eligibleProfiles[0]; + // Sort by priority order (lower index = higher priority) + // If no priority order is set, OAuth profiles come first (they were already sorted by availability) + unifiedAccounts.sort((a, b) => { + // If both have priority indices, use them + if (a.priorityIndex !== Infinity || b.priorityIndex !== Infinity) { + return a.priorityIndex - b.priorityIndex; + } + // Otherwise, prefer OAuth profiles (which are sorted by availability) + if (a.type !== b.type) { + return a.type === 'oauth' ? -1 : 1; + } + return 0; + }); + + // Use the best available from unified accounts + const bestAccount = unifiedAccounts[0]; // Sort by priority order (lower index = higher priority) // If no priority order is set, OAuth profiles come first (they were already sorted by availability) @@ -1241,10 +1804,6 @@ export class UsageMonitor extends EventEmitter { reason: limitType }); - // Clear cache for the profile that's becoming inactive - // This ensures the next fetch gets fresh data instead of stale cached values - this.clearProfileUsageCache(currentProfileId); - // Switch to the new profile if (bestAccount.type === 'oauth') { // Switch OAuth profile via profile manager diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index e26f7005..e1470ab9 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -23,7 +23,7 @@ import { isSecurePath } from '../utils/windows-paths'; import { isWindows, isMacOS, isLinux } from '../platform'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { isValidConfigDir } from '../utils/config-path-validator'; -import { clearKeychainCache, getCredentialsFromKeychain, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils'; +import { clearKeychainCache } from '../claude-profile/credential-utils'; import { getUsageMonitor } from '../claude-profile/usage-monitor'; import semver from 'semver'; @@ -1370,7 +1370,7 @@ export function registerClaudeCodeHandlers(): void { } } - // If authenticated, update the profile with metadata from credentials + // If authenticated, update the profile with the email // NOTE: We intentionally do NOT store the OAuth token in the profile. // Storing the token causes AutoClaude to use a stale cached token instead of // letting Claude CLI read fresh tokens from Keychain (which auto-refreshes). @@ -1384,11 +1384,7 @@ export function registerClaudeCodeHandlers(): void { profile.email = result.email; } - // Update subscription metadata from Keychain credentials - // These are needed to display "Max" vs "Pro" in the UI - updateProfileSubscriptionMetadata(profile, expandedConfigDir); - - // Save profile metadata (email, isAuthenticated, subscriptionType, rateLimitTier) but NOT the OAuth token + // Save profile metadata (email, isAuthenticated) but NOT the OAuth token profileManager.saveProfile(profile); // CRITICAL: Clear keychain cache for this profile's configDir diff --git a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts index 94c6e051..46603d2c 100644 --- a/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/task/worktree-handlers.ts @@ -4,8 +4,8 @@ import { IPC_CHANNELS, AUTO_BUILD_PATHS, DEFAULT_APP_SETTINGS, DEFAULT_FEATURE_M import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, WorktreeMergeResult, WorktreeDiscardResult, WorktreeListResult, WorktreeListItem, WorktreeCreatePROptions, WorktreeCreatePRResult, SupportedIDE, SupportedTerminal, AppSettings } from '../../../shared/types'; import path from 'path'; import { minimatch } from 'minimatch'; -import { existsSync, readdirSync, statSync, readFileSync, promises as fsPromises } from 'fs'; -import { execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; +import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; +import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process'; import { homedir } from 'os'; import { projectStore } from '../../project-store'; import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager'; diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 0bfef379..68baaebe 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -10,7 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator'; import { readSettingsFileAsync } from '../settings-utils'; import { debugLog, } from '../../shared/utils/debug-logger'; import { migrateSession } from '../claude-profile/session-utils'; -import { createProfileDirectory } from '../claude-profile/profile-utils'; +import { DEFAULT_CLAUDE_CONFIG_DIR, createProfileDirectory } from '../claude-profile/profile-utils'; import { isValidConfigDir } from '../utils/config-path-validator'; @@ -539,13 +539,12 @@ export function registerTerminalHandlers( ); // Request all profiles usage immediately (for startup/refresh) - // Optional forceRefresh parameter bypasses cache to get fresh data ipcMain.handle( IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, - async (_event: IpcMainInvokeEvent, forceRefresh: boolean = false): Promise> => { + async (): Promise> => { try { const monitor = getUsageMonitor(); - const allProfilesUsage = await monitor.getAllProfilesUsage(forceRefresh); + const allProfilesUsage = await monitor.getAllProfilesUsage(); return { success: true, data: allProfilesUsage }; } catch (error) { return { diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index 43a74a78..f5b72364 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -365,48 +365,6 @@ export function isAuthFailureError(output: string): boolean { return detectAuthFailure(output).isAuthFailure; } -/** - * Detect billing failure from output (stdout + stderr combined) - */ -export function detectBillingFailure( - output: string, - profileId?: string -): BillingFailureDetectionResult { - // First, make sure this isn't a rate limit or auth error (those should be handled separately) - if (detectRateLimit(output).isRateLimited) { - return { isBillingFailure: false }; - } - if (detectAuthFailure(output).isAuthFailure) { - return { isBillingFailure: false }; - } - - // Check for billing failure patterns - for (const pattern of BILLING_FAILURE_PATTERNS) { - if (pattern.test(output)) { - const profileManager = getClaudeProfileManager(); - const effectiveProfileId = profileId || profileManager.getActiveProfile().id; - const failureType = classifyBillingFailureType(output); - - return { - isBillingFailure: true, - profileId: effectiveProfileId, - failureType, - message: getBillingFailureMessage(failureType), - originalError: sanitizeErrorOutput(output) - }; - } - } - - return { isBillingFailure: false }; -} - -/** - * Check if output contains billing failure error - */ -export function isBillingFailureError(output: string): boolean { - return detectBillingFailure(output).isBillingFailure; -} - /** * Get environment variables for a specific Claude profile. * @@ -434,188 +392,6 @@ export function getProfileEnv(profileId?: string): Record { return profileManager.getActiveProfileEnv(); } -/** - * Result of getting the best available profile environment - */ -export interface BestProfileEnvResult { - /** Environment variables for the selected profile */ - env: Record; - /** The profile ID that was selected */ - profileId: string; - /** The profile name for logging/display */ - profileName: string; - /** Whether a swap was performed (true if different from active profile) */ - wasSwapped: boolean; - /** Reason for the swap if one occurred */ - swapReason?: 'rate_limited' | 'at_capacity' | 'proactive'; - /** The original active profile if a swap occurred */ - originalProfile?: { - id: string; - name: string; - }; -} - -/** - * Get environment variables for the BEST available Claude profile and persist the profile swap. - * - * IMPORTANT: This function has the side effect of calling profileManager.setActiveProfile() - * when a better profile is found. This modifies global state and persists the profile swap. - * - * This is the preferred function for SDK operations that need profile environment. - * It automatically handles: - * 1. Checking if the active profile is explicitly rate-limited (received 429/rate limit error) - * 2. Checking if the active profile is at capacity (100% weekly usage) - * 3. Finding a better alternative profile if available - * 4. PERSISTING the swap by updating the active profile - * - * Use this instead of getProfileEnv() for any operation that will make Claude API calls. - * - * @returns Object containing env vars and metadata about which profile was selected - */ -export function getBestAvailableProfileEnv(): BestProfileEnvResult { - const profileManager = getClaudeProfileManager(); - const activeProfile = profileManager.getActiveProfile(); - - // Check for explicit rate limit (from previous API errors) - const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id); - - // Check for capacity limit (100% weekly usage - will be rate limited on next request) - const isAtCapacity = activeProfile.usage?.weeklyUsagePercent !== undefined && - activeProfile.usage.weeklyUsagePercent >= 100; - - // Determine if we need to find an alternative - const needsSwap = rateLimitStatus.limited || isAtCapacity; - const swapReason: BestProfileEnvResult['swapReason'] = rateLimitStatus.limited - ? 'rate_limited' - : isAtCapacity - ? 'at_capacity' - : undefined; - - if (needsSwap) { - if (process.env.DEBUG === 'true') { - console.warn('[RateLimitDetector] Active profile needs swap:', { - activeProfile: activeProfile.name, - isRateLimited: rateLimitStatus.limited, - isAtCapacity, - weeklyUsage: activeProfile.usage?.weeklyUsagePercent, - limitType: rateLimitStatus.type, - resetAt: rateLimitStatus.resetAt - }); - } - - // Try to find a better profile - const bestProfile = profileManager.getBestAvailableProfile(activeProfile.id); - - if (bestProfile) { - if (process.env.DEBUG === 'true') { - console.warn('[RateLimitDetector] Using alternative profile:', { - originalProfile: activeProfile.name, - alternativeProfile: bestProfile.name, - reason: swapReason - }); - } - - // Persist the swap by updating the active profile - // This ensures the UI reflects which account is actually being used - profileManager.setActiveProfile(bestProfile.id); - console.warn('[RateLimitDetector] Switched active profile:', { - from: activeProfile.name, - to: bestProfile.name, - reason: swapReason - }); - - // Trigger a usage refresh so the UI shows the new active profile - // This updates the UsageIndicator in the header - // We use fire-and-forget pattern to avoid making this function async - try { - const usageMonitor = getUsageMonitor(); - // Force refresh all profiles usage data, which will emit 'all-profiles-usage-updated' event - // The UI components listen for this and will update automatically - usageMonitor.getAllProfilesUsage(true).then((allProfilesUsage) => { - if (allProfilesUsage) { - // Find the new active profile in allProfiles and emit its usage - // This ensures UsageIndicator.usage state also updates to show the new active account - const newActiveProfile = allProfilesUsage.allProfiles.find(p => p.isActive); - if (newActiveProfile) { - // Construct a ClaudeUsageSnapshot for the new active profile - const newActiveUsage = { - profileId: newActiveProfile.profileId, - profileName: newActiveProfile.profileName, - profileEmail: newActiveProfile.profileEmail, - sessionPercent: newActiveProfile.sessionPercent, - weeklyPercent: newActiveProfile.weeklyPercent, - sessionResetTimestamp: newActiveProfile.sessionResetTimestamp, - weeklyResetTimestamp: newActiveProfile.weeklyResetTimestamp, - fetchedAt: allProfilesUsage.fetchedAt, - needsReauthentication: newActiveProfile.needsReauthentication, - }; - usageMonitor.emit('usage-updated', newActiveUsage); - } - // Also emit all-profiles-usage-updated for the other profiles list - usageMonitor.emit('all-profiles-usage-updated', allProfilesUsage); - } - }).catch((err) => { - console.warn('[RateLimitDetector] Failed to refresh usage after swap:', err); - }); - } catch (err) { - // Usage monitor may not be initialized yet, that's OK - console.warn('[RateLimitDetector] Could not trigger usage refresh:', err); - } - - const profileEnv = profileManager.getProfileEnv(bestProfile.id); - - return { - env: ensureCleanProfileEnv(profileEnv), - profileId: bestProfile.id, - profileName: bestProfile.name, - wasSwapped: true, - swapReason, - originalProfile: { - id: activeProfile.id, - name: activeProfile.name - } - }; - } else { - if (process.env.DEBUG === 'true') { - console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile'); - } - } - } - - // Use active profile (either it's fine, or no better alternative exists) - const activeEnv = profileManager.getActiveProfileEnv(); - return { - env: ensureCleanProfileEnv(activeEnv), - profileId: activeProfile.id, - profileName: activeProfile.name, - wasSwapped: false - }; -} - -/** - * Ensure the profile environment is clean for subprocess invocation. - * - * When CLAUDE_CONFIG_DIR is set, we MUST clear CLAUDE_CODE_OAUTH_TOKEN to prevent - * the Claude Agent SDK from using a hardcoded/cached token (e.g., from .env file) - * instead of reading fresh credentials from the specified config directory. - * - * This is critical for multi-account switching: when switching from a rate-limited - * account to an available one, the subprocess must use the new account's credentials. - * - * @param env - Profile environment from getProfileEnv() or getActiveProfileEnv() - * @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set - */ -function ensureCleanProfileEnv(env: Record): Record { - if (env.CLAUDE_CONFIG_DIR) { - // Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR - return { - ...env, - CLAUDE_CODE_OAUTH_TOKEN: '' - }; - } - return env; -} - /** * Get the active Claude profile ID */ diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 5269fa22..c7a75a81 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -10,8 +10,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; -import { getFullCredentialsFromKeychain, clearKeychainCache, updateProfileSubscriptionMetadata } from '../claude-profile/credential-utils'; -import { getUsageMonitor } from '../claude-profile/usage-monitor'; +import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/credential-utils'; import { getEmailFromConfigDir } from '../claude-profile/profile-utils'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; @@ -525,8 +524,6 @@ export function handleOAuthToken( if (email) { profile.email = email; } - // Update subscription metadata from Keychain credentials - updateProfileSubscriptionMetadata(profile, keychainCreds); profile.isAuthenticated = true; profileManager.saveProfile(profile); @@ -613,8 +610,6 @@ export function handleOAuthToken( if (email) { profile.email = email; } - // Update subscription metadata from Keychain credentials - updateProfileSubscriptionMetadata(profile, profile.configDir); profile.isAuthenticated = true; profileManager.saveProfile(profile); @@ -677,8 +672,6 @@ export function handleOAuthToken( if (email) { activeProfile.email = email; } - // Update subscription metadata from Keychain credentials - updateProfileSubscriptionMetadata(activeProfile, activeProfile.configDir); activeProfile.isAuthenticated = true; profileManager.saveProfile(activeProfile); @@ -772,13 +765,11 @@ export function handleOnboardingComplete( bufferLength: terminal.outputBuffer.length }); - // Update profile with email and subscription metadata if found and profile exists + // Update profile with email if found and profile exists // Always update - the newly extracted email from re-authentication should overwrite any stale/truncated email if (profileId && email && profile) { const previousEmail = profile.email; profile.email = email; - // Also update subscription metadata from Keychain credentials - updateProfileSubscriptionMetadata(profile, profile.configDir); profileManager.saveProfile(profile); if (previousEmail !== email) { console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email), '(was:', maskEmail(previousEmail), ')'); diff --git a/apps/frontend/src/main/terminal/output-parser.ts b/apps/frontend/src/main/terminal/output-parser.ts index 10b0049b..764d4cd9 100644 --- a/apps/frontend/src/main/terminal/output-parser.ts +++ b/apps/frontend/src/main/terminal/output-parser.ts @@ -159,7 +159,7 @@ export function extractEmail(data: string): string | null { for (const pattern of EMAIL_PATTERNS) { const match = cleanData.match(pattern); - if (match?.[1]) { + if (match && match[1]) { return match[1]; } } diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 67171091..2703be33 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -120,7 +120,7 @@ export interface TerminalAPI { // Usage Monitoring (Proactive Account Switching) requestUsageUpdate: () => Promise>; - requestAllProfilesUsage: (forceRefresh?: boolean) => Promise>; + requestAllProfilesUsage: () => Promise>; onUsageUpdated: (callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void) => () => void; onAllProfilesUsageUpdated: (callback: (allProfilesUsage: import('../../shared/types').AllProfilesUsage) => void) => () => void; onProactiveSwapNotification: (callback: (notification: ProactiveSwapNotification) => void) => () => void; @@ -518,8 +518,8 @@ export const createTerminalAPI = (): TerminalAPI => ({ requestUsageUpdate: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.USAGE_REQUEST), - requestAllProfilesUsage: (forceRefresh?: boolean): Promise> => - ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST, forceRefresh ?? false), + requestAllProfilesUsage: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.ALL_PROFILES_USAGE_REQUEST), onUsageUpdated: ( callback: (usage: import('../../shared/types').ClaudeUsageSnapshot) => void diff --git a/apps/frontend/src/renderer/components/UsageIndicator.tsx b/apps/frontend/src/renderer/components/UsageIndicator.tsx index eae99739..eb64fe49 100644 --- a/apps/frontend/src/renderer/components/UsageIndicator.tsx +++ b/apps/frontend/src/renderer/components/UsageIndicator.tsx @@ -6,8 +6,13 @@ * - Click to pin popup open (stays until clicking outside) */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Activity, TrendingUp, AlertCircle, Clock, User, ChevronRight, Info } from 'lucide-react'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from './ui/popover'; import { Tooltip, TooltipContent, @@ -16,33 +21,87 @@ import { } from './ui/tooltip'; import { useTranslation } from 'react-i18next'; import { formatTimeRemaining, localizeUsageWindowLabel, hasHardcodedText } from '../../shared/utils/format-time'; -import type { ClaudeUsageSnapshot } from '../../shared/types/agent'; +import type { ClaudeUsageSnapshot, ProfileUsageSummary } from '../../shared/types/agent'; +import type { AppSection } from './settings/AppSettings'; + +/** + * Usage threshold constants for color coding + */ +const THRESHOLD_CRITICAL = 95; // Red: At or near limit +const THRESHOLD_WARNING = 91; // Orange: Very high usage +const THRESHOLD_ELEVATED = 71; // Yellow: Moderate usage +// Below 71 is considered normal (green) + +/** + * Get color class based on usage percentage + */ +const getColorClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'text-red-500'; + if (percent >= THRESHOLD_WARNING) return 'text-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500'; + return 'text-green-500'; +}; + +/** + * Get background/border color classes for badges based on usage percentage + */ +const getBadgeColorClasses = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'text-red-500 bg-red-500/10 border-red-500/20'; + if (percent >= THRESHOLD_WARNING) return 'text-orange-500 bg-orange-500/10 border-orange-500/20'; + if (percent >= THRESHOLD_ELEVATED) return 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20'; + return 'text-green-500 bg-green-500/10 border-green-500/20'; +}; + +/** + * Get gradient background class based on usage percentage + */ +const getGradientClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'bg-gradient-to-r from-red-600 to-red-500'; + if (percent >= THRESHOLD_WARNING) return 'bg-gradient-to-r from-orange-600 to-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'bg-gradient-to-r from-yellow-600 to-yellow-500'; + return 'bg-gradient-to-r from-green-600 to-green-500'; +}; + +/** + * Get background class for small usage bars based on usage percentage + */ +const getBarColorClass = (percent: number): string => { + if (percent >= THRESHOLD_CRITICAL) return 'bg-red-500'; + if (percent >= THRESHOLD_WARNING) return 'bg-orange-500'; + if (percent >= THRESHOLD_ELEVATED) return 'bg-yellow-500'; + return 'bg-green-500'; +}; export function UsageIndicator() { const { t, i18n } = useTranslation(['common']); const [usage, setUsage] = useState(null); + const [otherProfiles, setOtherProfiles] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isAvailable, setIsAvailable] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const [isPinned, setIsPinned] = useState(false); + const hoverTimeoutRef = useRef(null); + + /** + * Helper function to get initials from a profile name + */ + const getInitials = (name: string): string => { + if (!name || name.trim().length === 0) { + return 'UN'; // Unknown + } + const words = name.trim().split(/\s+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; /** * Helper function to format large numbers with locale-aware compact notation - * - * Returns undefined for null/undefined values. The caller (JSX conditional guards) - * is responsible for checking values before calling this function. - * - * @param value - The number to format (undefined, null, or number) - * @returns Formatted compact number string (e.g., "1.2K", "3.4M"), or undefined if input is null/undefined - * - * @example - * formatUsageValue(1234) // "1.2K" (en-US) - * formatUsageValue(null) // undefined - * formatUsageValue(undefined) // undefined */ const formatUsageValue = (value?: number | null): string | undefined => { if (value == null) return undefined; - // Use Intl.NumberFormat for locale-aware compact number formatting - // Fallback to toString() if Intl is not available if (typeof Intl !== 'undefined' && Intl.NumberFormat) { try { return new Intl.NumberFormat(i18n.language, { @@ -57,8 +116,172 @@ export function UsageIndicator() { return value.toString(); }; + /** + * Navigate to settings accounts tab + */ + const handleOpenAccounts = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Close the popover first + setIsOpen(false); + setIsPinned(false); + // Dispatch custom event to open settings with accounts section + // Small delay to allow popover to close first + setTimeout(() => { + const event = new CustomEvent('open-app-settings', { + detail: 'accounts' + }); + window.dispatchEvent(event); + }, 100); + }, []); + + /** + * Handle swapping to a different profile + * Uses optimistic UI update for immediate feedback, then fetches fresh data + */ + const handleSwapProfile = useCallback(async (e: React.MouseEvent, profileId: string) => { + e.preventDefault(); + e.stopPropagation(); + + // Capture previous state for revert (before any changes) + const previousUsage = usage; + const previousOtherProfiles = otherProfiles; + + // Find the profile we're swapping to + const targetProfile = otherProfiles.find(p => p.profileId === profileId); + if (!targetProfile) { + console.error('[UsageIndicator] Target profile not found:', profileId); + return; + } + + // Optimistic update: immediately swap profiles in the UI + // 1. Convert current active profile to a ProfileUsageSummary for the "other" list + const currentActiveAsSummary: ProfileUsageSummary = { + profileId: usage?.profileId || '', + profileName: usage?.profileName || '', + profileEmail: usage?.profileEmail, + sessionPercent: usage?.sessionPercent || 0, + weeklyPercent: usage?.weeklyPercent || 0, + sessionResetTimestamp: usage?.sessionResetTimestamp, + weeklyResetTimestamp: usage?.weeklyResetTimestamp, + isAuthenticated: true, + isRateLimited: false, + availabilityScore: 100 - Math.max(usage?.sessionPercent || 0, usage?.weeklyPercent || 0), + isActive: false, // It's no longer active + }; + + // 2. Convert target profile to a ClaudeUsageSnapshot for the active display + const newActiveUsage: ClaudeUsageSnapshot = { + profileId: targetProfile.profileId, + profileName: targetProfile.profileName, + profileEmail: targetProfile.profileEmail, + sessionPercent: targetProfile.sessionPercent, + weeklyPercent: targetProfile.weeklyPercent, + sessionResetTimestamp: targetProfile.sessionResetTimestamp, + weeklyResetTimestamp: targetProfile.weeklyResetTimestamp, + fetchedAt: new Date(), + }; + + // 3. Update the other profiles list: remove target, add current active + const newOtherProfiles = otherProfiles + .filter(p => p.profileId !== profileId) + .concat(usage ? [currentActiveAsSummary] : []) + .sort((a, b) => b.availabilityScore - a.availabilityScore); + + // Apply optimistic update immediately + setUsage(newActiveUsage); + setOtherProfiles(newOtherProfiles); + + try { + // Actually switch the profile on the backend + const result = await window.electronAPI.setActiveClaudeProfile(profileId); + if (result.success) { + // Fetch fresh data in the background (will update via event listeners) + window.electronAPI.requestUsageUpdate(); + window.electronAPI.requestAllProfilesUsage?.(); + } else { + // Revert to captured previous state + console.error('[UsageIndicator] Failed to swap profile, reverting'); + if (previousUsage) setUsage(previousUsage); + setOtherProfiles(previousOtherProfiles); + } + } catch (error) { + console.error('[UsageIndicator] Failed to swap profile:', error); + // Revert to captured previous state + if (previousUsage) setUsage(previousUsage); + setOtherProfiles(previousOtherProfiles); + } + }, [usage, otherProfiles]); + + /** + * Handle mouse enter - show popup after short delay (unless pinned) + */ + const handleMouseEnter = useCallback(() => { + if (isPinned) return; + // Clear any pending close timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + // Open after short delay for smoother UX + hoverTimeoutRef.current = setTimeout(() => { + setIsOpen(true); + }, 150); + }, [isPinned]); + + /** + * Handle mouse leave - close popup after delay (unless pinned) + */ + const handleMouseLeave = useCallback(() => { + if (isPinned) return; + // Clear any pending open timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + // Close after delay to allow moving to popup content + hoverTimeoutRef.current = setTimeout(() => { + setIsOpen(false); + }, 300); + }, [isPinned]); + + /** + * Handle click on trigger - toggle pinned state + */ + const handleTriggerClick = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + if (isPinned) { + // Clicking when pinned unpins and closes + setIsPinned(false); + setIsOpen(false); + } else { + // Clicking when not pinned pins it open + setIsPinned(true); + setIsOpen(true); + } + }, [isPinned]); + + /** + * Handle popover open change (e.g., clicking outside) + */ + const handleOpenChange = useCallback((open: boolean) => { + if (!open) { + // Closing from outside click + setIsOpen(false); + setIsPinned(false); + } + }, []); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + // Get formatted reset times (calculated dynamically from timestamps) - // Only fall back to sessionResetTime/weeklyResetTime if they don't contain placeholder/hardcoded text const sessionResetTime = usage?.sessionResetTimestamp ? (formatTimeRemaining(usage.sessionResetTimestamp, t) ?? (hasHardcodedText(usage?.sessionResetTime) ? undefined : usage?.sessionResetTime)) @@ -76,6 +299,13 @@ export function UsageIndicator() { setIsLoading(false); }); + // Listen for all profiles usage updates (for multi-profile display) + const unsubscribeAllProfiles = window.electronAPI.onAllProfilesUsageUpdated?.((allProfilesUsage) => { + // Filter out the active profile - we only want to show "other" profiles + const nonActiveProfiles = allProfilesUsage.allProfiles.filter(p => !p.isActive); + setOtherProfiles(nonActiveProfiles); + }); + // Request initial usage on mount window.electronAPI.requestUsageUpdate().then((result) => { setIsLoading(false); @@ -83,24 +313,31 @@ export function UsageIndicator() { setUsage(result.data); setIsAvailable(true); } else { - // No usage data available (endpoint not supported or error) setIsAvailable(false); } }).catch((error) => { - // Handle errors (IPC failure, network issues, etc.) console.warn('[UsageIndicator] Failed to fetch initial usage:', error); setIsLoading(false); setIsAvailable(false); }); + // Request all profiles usage immediately on mount (so other accounts show right away) + window.electronAPI.requestAllProfilesUsage?.().then((result) => { + if (result.success && result.data) { + const nonActiveProfiles = result.data.allProfiles.filter(p => !p.isActive); + setOtherProfiles(nonActiveProfiles); + } + }).catch((error) => { + console.warn('[UsageIndicator] Failed to fetch all profiles usage:', error); + }); + return () => { unsubscribe(); unsubscribeAllProfiles?.(); }; }, []); - // Always show the badge, but display different states - // Show loading state initially + // Show loading state if (isLoading) { return (
@@ -110,7 +347,7 @@ export function UsageIndicator() { ); } - // Show unavailable state when endpoint doesn't return data + // Show unavailable state if (!isAvailable || !usage) { return ( @@ -134,17 +371,18 @@ export function UsageIndicator() { ); } - // Determine color based on session usage (5-hour window) - // This is what should be shown on the badge per QA feedback - const badgeUsage = usage.sessionPercent; - const badgeColorClasses = - badgeUsage >= 95 ? 'text-red-500 bg-red-500/10 border-red-500/20' : - badgeUsage >= 91 ? 'text-orange-500 bg-orange-500/10 border-orange-500/20' : - badgeUsage >= 71 ? 'text-yellow-500 bg-yellow-500/10 border-yellow-500/20' : - 'text-green-500 bg-green-500/10 border-green-500/20'; + // Determine colors and labels based on the LIMITING factor (higher of session/weekly) + const sessionPercent = usage.sessionPercent; + const weeklyPercent = usage.weeklyPercent; + const limitingPercent = Math.max(sessionPercent, weeklyPercent); + + // Badge color based on the limiting (higher) percentage + const badgeColorClasses = getBadgeColorClasses(limitingPercent); + + // Individual colors for session and weekly in the badge + const sessionColorClass = getColorClass(sessionPercent); + const weeklyColorClass = getColorClass(weeklyPercent); - // Get window labels for display - // Map backend-provided labels to localized versions with appropriate defaults const sessionLabel = localizeUsageWindowLabel( usage?.usageWindows?.sessionWindowLabel, t, @@ -156,143 +394,33 @@ export function UsageIndicator() { 'common:usage.weeklyDefault' ); - // For icon, use the highest of the two windows const maxUsage = Math.max(usage.sessionPercent, usage.weeklyPercent); const Icon = - maxUsage >= 91 ? AlertCircle : - maxUsage >= 71 ? TrendingUp : + maxUsage >= THRESHOLD_WARNING ? AlertCircle : + maxUsage >= THRESHOLD_ELEVATED ? TrendingUp : Activity; return ( - - - - - - -
- {/* Header with overall status */} -
- - {t('common:usage.usageBreakdown')} -
- - {/* Session/5-hour usage */} -
-
- - - {sessionLabel} - - = 95 ? 'text-red-500' : - usage.sessionPercent >= 91 ? 'text-orange-500' : - usage.sessionPercent >= 71 ? 'text-yellow-600' : - 'text-green-600' - }`}> - {Math.round(usage.sessionPercent)}% - -
- {sessionResetTime && ( -
- - {sessionResetTime} -
- )} - {/* Enhanced progress bar with gradient */} -
-
= 95 ? 'bg-gradient-to-r from-red-600 to-red-500' : - usage.sessionPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' : - usage.sessionPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' : - 'bg-gradient-to-r from-green-600 to-green-500' - }`} - style={{ width: `${Math.min(usage.sessionPercent, 100)}%` }} - > - {/* Subtle shine effect */} -
-
-
- {/* Raw usage value with better styling */} - {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} - -
- )} -
- - {/* Weekly/Monthly usage */} -
-
- - - {weeklyLabel} - - = 99 ? 'text-red-500' : - usage.weeklyPercent >= 91 ? 'text-orange-500' : - usage.weeklyPercent >= 71 ? 'text-yellow-600' : - 'text-green-600' - }`}> - {Math.round(usage.weeklyPercent)}% - -
- {weeklyResetTime && ( -
- - {weeklyResetTime} -
- )} - {/* Enhanced progress bar with gradient */} -
-
= 99 ? 'bg-gradient-to-r from-red-600 to-red-500' : - usage.weeklyPercent >= 91 ? 'bg-gradient-to-r from-orange-600 to-orange-500' : - usage.weeklyPercent >= 71 ? 'bg-gradient-to-r from-yellow-600 to-yellow-500' : - 'bg-gradient-to-r from-green-600 to-green-500' - }`} - style={{ width: `${Math.min(usage.weeklyPercent, 100)}%` }} - > - {/* Subtle shine effect */} -
-
-
- {/* Raw usage value with better styling */} - {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} - -
- )} -
- - {/* Active account footer */} -
-
- - {t('common:usage.activeAccount')} -
-
- {usage.profileName} - -
-
- )} + + + {Math.round(weeklyPercent)} + +
{t('common:usage.usageBreakdown')}
- {/* Re-auth required prompt - shown when active profile needs re-authentication */} - {usage.needsReauthentication ? ( -
-
- -
-

- {t('common:usage.reauthRequired')} -

-

- {t('common:usage.reauthRequiredDescription')} -

-
-
- + {/* Session/5-hour usage */} +
+
+ + + {sessionLabel} + + + {Math.round(usage.sessionPercent)}% +
- ) : ( - <> - {/* Session/5-hour usage */} -
-
- - - {sessionLabel} - - - {Math.round(usage.sessionPercent)}% - -
- {sessionResetTime && ( -
- - {sessionResetTime} -
- )} -
-
-
-
-
- {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} - -
- )} + {sessionResetTime && ( +
+ + {sessionResetTime}
+ )} +
+
+
+
+
+ {usage.sessionUsageValue != null && usage.sessionUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.sessionUsageValue)} / {formatUsageValue(usage.sessionUsageLimit)} + +
+ )} +
- {/* Weekly/Monthly usage */} -
-
- - - {weeklyLabel} - - - {Math.round(usage.weeklyPercent)}% - -
- {weeklyResetTime && ( -
- - {weeklyResetTime} -
- )} -
-
-
-
-
- {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( -
- {t('common:usage.used')} - - {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} - -
- )} + {/* Weekly/Monthly usage */} +
+
+ + + {weeklyLabel} + + + {Math.round(usage.weeklyPercent)}% + +
+ {weeklyResetTime && ( +
+ + {weeklyResetTime}
- - )} + )} +
+
+
+
+
+ {usage.weeklyUsageValue != null && usage.weeklyUsageLimit != null && ( +
+ {t('common:usage.used')} + + {formatUsageValue(usage.weeklyUsageValue)} / {formatUsageValue(usage.weeklyUsageLimit)} + +
+ )} +
{/* Active account footer - clickable to go to settings */}
{/* Account Priority Order */} diff --git a/apps/frontend/src/renderer/components/settings/AppSettings.tsx b/apps/frontend/src/renderer/components/settings/AppSettings.tsx index 5d1a4b92..1a738c9c 100644 --- a/apps/frontend/src/renderer/components/settings/AppSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/AppSettings.tsx @@ -18,7 +18,6 @@ import { Globe, Code, Bug, - Terminal, Users } from 'lucide-react'; @@ -51,7 +50,6 @@ import { GeneralSettings } from './GeneralSettings'; import { AdvancedSettings } from './AdvancedSettings'; import { DevToolsSettings } from './DevToolsSettings'; import { DebugSettings } from './DebugSettings'; -import { TerminalFontSettings } from './terminal-font-settings/TerminalFontSettings'; import { AccountSettings } from './AccountSettings'; import { ProjectSelector } from './ProjectSelector'; import { ProjectSettingsContent, ProjectSettingsSection } from './ProjectSettingsContent'; @@ -67,7 +65,7 @@ interface AppSettingsDialogProps { } // App-level settings sections -export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'terminal-fonts' | 'agent' | 'paths' | 'integrations' | 'accounts' | 'api-profiles' | 'updates' | 'notifications' | 'debug'; +export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'accounts' | 'updates' | 'notifications' | 'debug'; interface NavItemConfig { id: T; diff --git a/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts b/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts index d2a937e4..865dfe5c 100644 --- a/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts @@ -76,7 +76,7 @@ export const claudeProfileMock = { data: null }), - requestAllProfilesUsage: async (_forceRefresh?: boolean) => ({ + requestAllProfilesUsage: async () => ({ success: true, data: null }), diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index f6129fdf..885fb2bc 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -106,8 +106,7 @@ "optional": "Optional", "required": "Required", "dismiss": "Dismiss", - "important": "Important", - "orphaned": "(orphaned)" + "important": "Important" }, "selection": { "select": "Select", @@ -475,7 +474,15 @@ "window5Hour": "5-hour window", "window7Day": "7-day window", "window5HoursQuota": "5 Hours Quota", - "windowMonthlyToolsQuota": "Monthly Tools Quota" + "windowMonthlyToolsQuota": "Monthly Tools Quota", + "otherAccounts": "Other Accounts", + "next": "Next", + "weeklyLimitReached": "Weekly limit reached", + "sessionLimitReached": "Session limit reached", + "notAuthenticated": "Not authenticated", + "sessionShort": "5-hour session usage", + "weeklyShort": "7-day weekly usage", + "swap": "Swap" }, "oauth": { "enterCode": "Manual Code Entry (Fallback)", diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index 6a410007..b9ce3a84 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -577,9 +577,7 @@ "weeklyThreshold": "Weekly usage threshold", "weeklyThresholdDescription": "Switch when weekly usage reaches this level (recommended: 99%)", "reactiveRecovery": "Reactive Recovery", - "reactiveDescription": "Auto-swap when unexpected rate limit is hit", - "autoSwitchOnAuthFailure": "Auto-switch on auth failure", - "autoSwitchOnAuthFailureDescription": "Automatically switch to another authenticated account when authentication fails" + "reactiveDescription": "Auto-swap when unexpected rate limit is hit" }, "priority": { "title": "Account Priority Order", @@ -597,8 +595,6 @@ "needsAuth": "Not authenticated", "duplicateUsage": "Duplicate usage detected", "duplicateUsageHint": "This profile has identical usage to another profile, suggesting they may be authenticated to the same Anthropic account. Re-authenticate with a different account to fix.", - "needsReauth": "Needs re-auth", - "needsReauthHint": "This profile's refresh token is invalid. Click to re-authenticate.", "sessionUsage": "Session usage (5-hour window)", "weeklyUsage": "Weekly usage (7-day window)", "oauthSection": "Claude Accounts (cycle through first)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index cb5189c6..fbe09d2f 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -106,8 +106,7 @@ "optional": "Optionnel", "required": "Requis", "dismiss": "Ignorer", - "important": "Important", - "orphaned": "(orphelin)" + "important": "Important" }, "selection": { "select": "Sélectionner", @@ -475,7 +474,15 @@ "window5Hour": "Fenêtre de 5 heures", "window7Day": "Fenêtre de 7 jours", "window5HoursQuota": "Quota de 5 heures", - "windowMonthlyToolsQuota": "Quota mensuel d'outils" + "windowMonthlyToolsQuota": "Quota mensuel d'outils", + "otherAccounts": "Autres comptes", + "next": "Suivant", + "weeklyLimitReached": "Limite hebdomadaire atteinte", + "sessionLimitReached": "Limite de session atteinte", + "notAuthenticated": "Non authentifié", + "sessionShort": "Utilisation session 5 heures", + "weeklyShort": "Utilisation hebdomadaire 7 jours", + "swap": "Changer" }, "oauth": { "enterCode": "Saisie manuelle du code (secours)", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index b1e44556..9c806028 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -577,9 +577,7 @@ "weeklyThreshold": "Seuil d'utilisation hebdomadaire", "weeklyThresholdDescription": "Basculer lorsque l'utilisation hebdomadaire atteint ce niveau (recommandé: 99%)", "reactiveRecovery": "Récupération réactive", - "reactiveDescription": "Auto-basculement en cas de limite de taux inattendue", - "autoSwitchOnAuthFailure": "Changement auto en cas d'échec d'auth", - "autoSwitchOnAuthFailureDescription": "Basculer automatiquement vers un autre compte authentifié en cas d'échec d'authentification" + "reactiveDescription": "Auto-basculement en cas de limite de taux inattendue" }, "priority": { "title": "Ordre de priorité des comptes", @@ -597,8 +595,6 @@ "needsAuth": "Non authentifié", "duplicateUsage": "Doublon détecté", "duplicateUsageHint": "Ce profil a une utilisation identique à un autre profil, suggérant qu'ils sont peut-être authentifiés sur le même compte Anthropic. Réauthentifiez-vous avec un autre compte pour corriger.", - "needsReauth": "Réauth requise", - "needsReauthHint": "Le token de rafraîchissement de ce profil est invalide. Cliquez pour vous réauthentifier.", "sessionUsage": "Utilisation de session (fenêtre de 5 heures)", "weeklyUsage": "Utilisation hebdomadaire (fenêtre de 7 jours)", "oauthSection": "Comptes Claude (utilisés en premier)", diff --git a/apps/frontend/src/shared/types/agent.ts b/apps/frontend/src/shared/types/agent.ts index 4327c78e..9084727b 100644 --- a/apps/frontend/src/shared/types/agent.ts +++ b/apps/frontend/src/shared/types/agent.ts @@ -79,6 +79,54 @@ export interface ClaudeUsageSnapshot { weeklyUsageLimit?: number; } +/** + * Profile usage summary for multi-profile display + * Contains the essential data needed to rank and display profiles in the usage indicator + */ +export interface ProfileUsageSummary { + /** Profile ID */ + profileId: string; + /** Profile name for display */ + profileName: string; + /** Email address (from Keychain or profile) */ + profileEmail?: string; + /** Session usage percentage (0-100) */ + sessionPercent: number; + /** Weekly usage percentage (0-100) */ + weeklyPercent: number; + /** ISO timestamp of when the session limit resets */ + sessionResetTimestamp?: string; + /** ISO timestamp of when the weekly limit resets */ + weeklyResetTimestamp?: string; + /** Whether this profile is authenticated */ + isAuthenticated: boolean; + /** Whether this profile is currently rate limited */ + isRateLimited: boolean; + /** Type of rate limit if limited */ + rateLimitType?: 'session' | 'weekly'; + /** Availability score (higher = more available, used for sorting) */ + availabilityScore: number; + /** Whether this is the currently active profile */ + isActive: boolean; + /** When this data was last fetched (ISO timestamp) */ + lastFetchedAt?: string; + /** Error message if usage fetch failed */ + fetchError?: string; +} + +/** + * All profiles usage data for the usage indicator + * Emitted alongside the active profile's detailed snapshot + */ +export interface AllProfilesUsage { + /** Detailed snapshot for the active profile */ + activeProfile: ClaudeUsageSnapshot; + /** Summary usage data for all profiles (sorted by availability, best first) */ + allProfiles: ProfileUsageSummary[]; + /** When this data was collected */ + fetchedAt: Date; +} + /** * Rate limit event recorded for a profile */ diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 28a8f2f2..db68607c 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -356,10 +356,8 @@ export interface ElectronAPI { // Usage Monitoring (Proactive Account Switching) /** Request current usage snapshot */ requestUsageUpdate: () => Promise>; - /** Request all profiles usage immediately (for startup/refresh) - * @param forceRefresh - If true, bypasses cache to get fresh data for all profiles - */ - requestAllProfilesUsage: (forceRefresh?: boolean) => Promise>; + /** Request all profiles usage immediately (for startup/refresh) */ + requestAllProfilesUsage: () => Promise>; /** Listen for usage data updates */ onUsageUpdated: (callback: (usage: ClaudeUsageSnapshot) => void) => () => void; /** Listen for proactive swap notifications */