diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index 4d45abbc..78faac55 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -694,10 +694,25 @@ def get_auth_token(config_dir: str | None = None) -> str | None: Returns: Token string if found, None otherwise """ + _debug = os.environ.get("DEBUG", "").lower() in ("true", "1") + + if _debug: + # Log which auth env vars are set (presence only, never values) + set_vars = [v for v in AUTH_TOKEN_ENV_VARS if os.environ.get(v)] + logger.info( + "[Auth] get_auth_token() called — config_dir param=%s, " + "env vars present: %s, CLAUDE_CONFIG_DIR env=%s", + repr(config_dir), + set_vars or "(none)", + "set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset", + ) + # First check environment variables (highest priority) for var in AUTH_TOKEN_ENV_VARS: token = os.environ.get(var) if token: + if _debug: + logger.info("[Auth] Token resolved from env var: %s", var) return _try_decrypt_token(token) # Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory) @@ -705,12 +720,13 @@ def get_auth_token(config_dir: str | None = None) -> str | None: effective_config_dir = config_dir or env_config_dir # Debug: Log which config_dir is being used for credential resolution - debug = os.environ.get("DEBUG", "").lower() in ("true", "1") - if debug and effective_config_dir: + if _debug and effective_config_dir: service_name = _get_keychain_service_name(effective_config_dir) logger.info( - f"[Auth] Resolving credentials for profile config_dir: {effective_config_dir} " - f"(Keychain service: {service_name})" + "[Auth] Resolving credentials for profile config_dir: %s " + "(Keychain service: %s)", + effective_config_dir, + service_name, ) # If a custom config directory is specified, read from there first @@ -718,24 +734,37 @@ def get_auth_token(config_dir: str | None = None) -> str | None: # Try reading from .credentials.json file in the config directory token = _get_token_from_config_dir(effective_config_dir) if token: + if _debug: + logger.info( + "[Auth] Token resolved from config dir file: %s", + effective_config_dir, + ) return _try_decrypt_token(token) # Also try the system credential store with hash-based service name # This is needed because macOS stores credentials in Keychain, not files token = get_token_from_keychain(effective_config_dir) if token: + if _debug: + logger.info("[Auth] Token resolved from Keychain (profile-specific)") return _try_decrypt_token(token) # If config_dir was explicitly provided, DON'T fall back to default keychain # - that would return the wrong profile's token logger.debug( - f"No credentials found for config_dir '{effective_config_dir}' " - "in file or keychain" + "No credentials found for config_dir '%s' in file or keychain", + effective_config_dir, ) return None # No config_dir specified - use default system credential store - return _try_decrypt_token(get_token_from_keychain()) + keychain_token = get_token_from_keychain() + if _debug: + logger.info( + "[Auth] Token resolved from default Keychain: %s", + "found" if keychain_token else "not found", + ) + return _try_decrypt_token(keychain_token) def get_auth_token_source(config_dir: str | None = None) -> str | None: @@ -970,8 +999,18 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None: - API profile mode: requires ANTHROPIC_AUTH_TOKEN - OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env) """ + _debug = os.environ.get("DEBUG", "").lower() in ("true", "1") api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip()) + if _debug: + logger.info( + "[Auth] configure_sdk_authentication() — mode=%s, config_dir=%s, " + "CLAUDE_CONFIG_DIR env=%s", + "api_profile" if api_profile_mode else "oauth", + repr(config_dir), + "set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset", + ) + if api_profile_mode: # API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present if not os.environ.get("ANTHROPIC_AUTH_TOKEN"): @@ -999,6 +1038,14 @@ def configure_sdk_authentication(config_dir: str | None = None) -> None: os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token logger.info("Using OAuth authentication") + if _debug: + logger.info( + "[Auth] SDK env check — CLAUDE_CONFIG_DIR=%s, " + "CLAUDE_CODE_OAUTH_TOKEN=%s", + "set" if os.environ.get("CLAUDE_CONFIG_DIR") else "unset", + "set" if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") else "unset", + ) + def ensure_claude_code_oauth_token() -> None: """ diff --git a/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts b/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts index 6622a583..d51bed03 100644 --- a/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts +++ b/apps/frontend/src/main/__tests__/rate-limit-detector.test.ts @@ -1012,3 +1012,115 @@ Please add credits to continue.`; }); }); }); + +describe('ensureCleanProfileEnv', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('with CLAUDE_CONFIG_DIR set', () => { + it('should preserve CLAUDE_CONFIG_DIR while clearing CLAUDE_CODE_OAUTH_TOKEN', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const env = { + CLAUDE_CONFIG_DIR: '/tmp/profile-1', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123', + ANTHROPIC_API_KEY: 'sk-ant-key-456' + }; + const result = ensureCleanProfileEnv(env); + + expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1'); + expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe(''); + expect(result.ANTHROPIC_API_KEY).toBe(''); + }); + + it('should preserve other environment variables', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const env = { + CLAUDE_CONFIG_DIR: '/tmp/profile-1', + CLAUDE_CODE_OAUTH_TOKEN: 'token', + ANTHROPIC_API_KEY: 'key', + SOME_OTHER_VAR: 'value' + }; + const result = ensureCleanProfileEnv(env); + + expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1'); + expect(result.SOME_OTHER_VAR).toBe('value'); + expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe(''); + expect(result.ANTHROPIC_API_KEY).toBe(''); + }); + + it('should clear tokens even if they are not present in input', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const env = { + CLAUDE_CONFIG_DIR: '/tmp/profile-1' + }; + const result = ensureCleanProfileEnv(env); + + expect(result.CLAUDE_CONFIG_DIR).toBe('/tmp/profile-1'); + expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe(''); + expect(result.ANTHROPIC_API_KEY).toBe(''); + }); + }); + + describe('without CLAUDE_CONFIG_DIR', () => { + it('should return env unchanged when CLAUDE_CONFIG_DIR is not set', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const env = { + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123', + ANTHROPIC_API_KEY: 'sk-ant-key-456' + }; + const result = ensureCleanProfileEnv(env); + + expect(result).toEqual(env); + expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-123'); + expect(result.ANTHROPIC_API_KEY).toBe('sk-ant-key-456'); + }); + }); + + describe('edge cases', () => { + it('should handle empty profile env', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const result = ensureCleanProfileEnv({}); + + // Empty env has no CLAUDE_CONFIG_DIR, so should return as-is + expect(result).toEqual({}); + }); + + it('should handle env with empty string CLAUDE_CONFIG_DIR', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const env = { + CLAUDE_CONFIG_DIR: '', + CLAUDE_CODE_OAUTH_TOKEN: 'token' + }; + const result = ensureCleanProfileEnv(env); + + // Empty string is falsy, so should not trigger clearing + expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('token'); + }); + + it('should return a new object when clearing (not mutate input)', async () => { + const { ensureCleanProfileEnv } = await import('../rate-limit-detector'); + + const env = { + CLAUDE_CONFIG_DIR: '/tmp/profile-1', + CLAUDE_CODE_OAUTH_TOKEN: 'token' + }; + const result = ensureCleanProfileEnv(env); + + // Original should not be mutated + expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('token'); + expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe(''); + expect(result).not.toBe(env); + }); + }); +}); diff --git a/apps/frontend/src/main/agent/agent-process.test.ts b/apps/frontend/src/main/agent/agent-process.test.ts index e092d34e..e2102d00 100644 --- a/apps/frontend/src/main/agent/agent-process.test.ts +++ b/apps/frontend/src/main/agent/agent-process.test.ts @@ -799,4 +799,127 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => { expect(envArg.GITHUB_CLI_PATH).toBe('/opt/homebrew/bin/gh'); }); }); + + describe('CLAUDE_CONFIG_DIR Propagation', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.CLAUDE_CONFIG_DIR; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should propagate CLAUDE_CONFIG_DIR from profile env in OAuth mode', async () => { + // OAuth mode - no active API profile + vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({}); + + // Profile provides CLAUDE_CONFIG_DIR (OAuth subscription profile) + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: { + CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-1', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-abc' + }, + profileId: 'profile-1', + profileName: 'Profile 1', + wasSwapped: false + }); + + await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); + + expect(spawnCalls).toHaveLength(1); + const envArg = spawnCalls[0].options.env as Record; + + // CLAUDE_CONFIG_DIR should be present in spawn env + expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-1'); + }); + + it('should clear ANTHROPIC_API_KEY in OAuth mode with CLAUDE_CONFIG_DIR', async () => { + // Simulate stale ANTHROPIC_API_KEY in process.env + process.env.ANTHROPIC_API_KEY = 'sk-stale-key'; + + // OAuth mode - no active API profile + vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({}); + + // Profile provides CLAUDE_CONFIG_DIR + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: { + CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-2', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-def' + }, + profileId: 'profile-2', + profileName: 'Profile 2', + wasSwapped: false + }); + + await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); + + expect(spawnCalls).toHaveLength(1); + const envArg = spawnCalls[0].options.env as Record; + + // ANTHROPIC_API_KEY should be cleared (empty string) in OAuth mode + expect(envArg.ANTHROPIC_API_KEY).toBe(''); + // CLAUDE_CONFIG_DIR should still be set + expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-2'); + }); + + it('should pass ANTHROPIC_* vars without CLAUDE_CONFIG_DIR interference in API profile mode', async () => { + // API Profile mode - active profile with custom endpoint + const mockApiProfileEnv = { + ANTHROPIC_AUTH_TOKEN: 'sk-api-profile-key', + ANTHROPIC_BASE_URL: 'https://custom-api.example.com', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929' + }; + vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv); + + // Profile env without CLAUDE_CONFIG_DIR (API profile mode) + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: {}, + profileId: 'api-profile-1', + profileName: 'Custom API', + wasSwapped: false + }); + + await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); + + expect(spawnCalls).toHaveLength(1); + const envArg = spawnCalls[0].options.env as Record; + + // ANTHROPIC_* vars from API profile should be passed through + expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-api-profile-key'); + expect(envArg.ANTHROPIC_BASE_URL).toBe('https://custom-api.example.com'); + expect(envArg.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929'); + + // CLAUDE_CONFIG_DIR should NOT be present since profile didn't provide it + expect(envArg.CLAUDE_CONFIG_DIR).toBeUndefined(); + }); + + it('should clear CLAUDE_CODE_OAUTH_TOKEN when CLAUDE_CONFIG_DIR is provided by profile', async () => { + // OAuth mode + vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({}); + + // Profile provides CLAUDE_CONFIG_DIR - agent should use config dir for auth + vi.mocked(rateLimitDetector.getBestAvailableProfileEnv).mockReturnValue({ + env: { + CLAUDE_CONFIG_DIR: '/home/user/.config/claude-profile-3', + CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-ghi' + }, + profileId: 'profile-3', + profileName: 'Profile 3', + wasSwapped: false + }); + + await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution'); + + expect(spawnCalls).toHaveLength(1); + const envArg = spawnCalls[0].options.env as Record; + + // When CLAUDE_CONFIG_DIR is present, CLAUDE_CODE_OAUTH_TOKEN should be cleared + // because Claude Code resolves auth from the config dir instead + expect(envArg.CLAUDE_CONFIG_DIR).toBe('/home/user/.config/claude-profile-3'); + expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBeFalsy(); + }); + }); }); diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 2c6605c7..58f97316 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -26,6 +26,7 @@ import { getOAuthModeClearVars } from './env-utils'; import { getAugmentedEnv } from '../env-utils'; import { getToolInfo, getClaudeCliPathForSdk } from '../cli-tool-manager'; import { killProcessGracefully, isWindows } from '../platform'; +import { debugLog } from '../../shared/utils/debug-logger'; /** * Type for supported CLI tools @@ -178,6 +179,29 @@ export class AgentProcessManager { // Get best available Claude profile environment (automatically handles rate limits) const profileResult = getBestAvailableProfileEnv(); const profileEnv = profileResult.env; + + debugLog('[AgentProcess:setupEnv] Profile result:', { + profileId: profileResult.profileId, + hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN, + hasApiKey: !!profileEnv.ANTHROPIC_API_KEY, + hasConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR, + configDir: profileEnv.CLAUDE_CONFIG_DIR || '(not set)', + oauthTokenPrefix: profileEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 8) || '(not set)', + apiKeyPrefix: profileEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)', + }); + + // Warn if profile lacks CLAUDE_CONFIG_DIR - this means the profile has no configDir + // and subscription metadata may not propagate correctly to the agent subprocess + if (!profileEnv.CLAUDE_CONFIG_DIR) { + console.warn('[AgentProcess:setupEnv] WARNING: Profile env lacks CLAUDE_CONFIG_DIR - profile may not have a configDir set. Subscription metadata may not reach agent subprocess.'); + } + + debugLog('[AgentProcess:setupEnv] extraEnv auth keys:', { + hasOAuthToken: !!extraEnv.CLAUDE_CODE_OAUTH_TOKEN, + hasApiKey: !!extraEnv.ANTHROPIC_API_KEY, + hasConfigDir: !!extraEnv.CLAUDE_CONFIG_DIR, + }); + // Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.) // are available even when app is launched from Finder/Dock const augmentedEnv = getAugmentedEnv(); @@ -205,7 +229,9 @@ export class AgentProcessManager { const ghCliEnv = this.detectAndSetCliPath('gh'); const glabCliEnv = this.detectAndSetCliPath('glab'); - return { + // Profile env is spread last to ensure CLAUDE_CONFIG_DIR and auth vars + // from the active profile always win over extraEnv or augmentedEnv. + const mergedEnv = { ...augmentedEnv, ...gitBashEnv, ...claudeCliEnv, @@ -217,6 +243,29 @@ export class AgentProcessManager { PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' } as NodeJS.ProcessEnv; + + // When the active profile provides CLAUDE_CONFIG_DIR, clear CLAUDE_CODE_OAUTH_TOKEN + // from the spawn environment. CLAUDE_CONFIG_DIR lets Claude Code resolve its own + // OAuth tokens from the config directory, making an explicit token unnecessary. + // This matches the terminal pattern in claude-integration-handler.ts where + // configDir is preferred over direct token injection. + // We check profileEnv specifically (not mergedEnv) to avoid clearing the token + // when CLAUDE_CONFIG_DIR comes from the shell environment rather than the profile. + if (profileEnv.CLAUDE_CONFIG_DIR) { + mergedEnv.CLAUDE_CODE_OAUTH_TOKEN = ''; + debugLog('[AgentProcess:setupEnv] Profile provides CLAUDE_CONFIG_DIR, cleared CLAUDE_CODE_OAUTH_TOKEN from spawn env'); + } + + debugLog('[AgentProcess:setupEnv] Final merged env auth state:', { + hasOAuthToken: !!mergedEnv.CLAUDE_CODE_OAUTH_TOKEN, + hasApiKey: !!mergedEnv.ANTHROPIC_API_KEY, + hasConfigDir: !!mergedEnv.CLAUDE_CONFIG_DIR, + configDir: mergedEnv.CLAUDE_CONFIG_DIR || '(not set)', + oauthTokenPrefix: mergedEnv.CLAUDE_CODE_OAUTH_TOKEN?.substring(0, 8) || '(not set)', + apiKeyPrefix: mergedEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)', + }); + + return mergedEnv; } private handleProcessFailure( @@ -615,6 +664,21 @@ export class AgentProcessManager { // Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode) const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv); + debugLog('[AgentProcess:spawnProcess] Environment merge chain for task:', taskId, { + baseEnv: { + hasOAuthToken: !!env.CLAUDE_CODE_OAUTH_TOKEN, + hasApiKey: !!env.ANTHROPIC_API_KEY, + hasConfigDir: !!env.CLAUDE_CONFIG_DIR, + configDir: env.CLAUDE_CONFIG_DIR || '(not set)', + }, + oauthModeClearVars: Object.keys(oauthModeClearVars), + apiProfileEnv: { + hasApiKey: !!apiProfileEnv.ANTHROPIC_API_KEY, + hasBaseUrl: !!apiProfileEnv.ANTHROPIC_BASE_URL, + apiKeyPrefix: apiProfileEnv.ANTHROPIC_API_KEY?.substring(0, 8) || '(not set)', + }, + }); + // Parse Python commandto handle space-separated commands like "py -3" const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath()); let childProcess; diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 4d9cca42..e91117e8 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -56,6 +56,7 @@ import { expandHomePath, getEmailFromConfigDir } from './claude-profile/profile-utils'; +import { debugLog } from '../shared/utils/debug-logger'; /** * Manages Claude Code profiles for multi-account support. @@ -86,6 +87,8 @@ export class ClaudeProfileManager { return; } + console.log('[ClaudeProfileManager] Starting initialization...'); + // Ensure directory exists (async) - mkdir with recursive:true is idempotent await mkdir(this.configDir, { recursive: true }); @@ -93,6 +96,9 @@ export class ClaudeProfileManager { const loadedData = await loadProfileStoreAsync(this.storePath); if (loadedData) { this.data = loadedData; + debugLog('[ClaudeProfileManager] Loaded profile store with', this.data.profiles.length, 'profiles'); + } else { + debugLog('[ClaudeProfileManager] No existing profile store found, using defaults'); } // Run one-time migration to fix corrupted emails @@ -104,6 +110,7 @@ export class ClaudeProfileManager { this.populateSubscriptionMetadata(); this.initialized = true; + console.log('[ClaudeProfileManager] Initialization complete'); } /** @@ -149,13 +156,20 @@ export class ClaudeProfileManager { private populateSubscriptionMetadata(): void { let needsSave = false; + debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: checking', this.data.profiles.length, 'profiles'); + for (const profile of this.data.profiles) { if (!profile.configDir) { + debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: skipping profile', profile.id, '(no configDir)'); continue; } // Skip if profile already has subscription metadata if (profile.subscriptionType && profile.rateLimitTier) { + debugLog('[ClaudeProfileManager] populateSubscriptionMetadata: profile', profile.id, 'already has metadata:', { + subscriptionType: profile.subscriptionType, + rateLimitTier: profile.rateLimitTier + }); continue; } @@ -542,8 +556,27 @@ export class ClaudeProfileManager { if (process.env.DEBUG === 'true') { console.warn('[ClaudeProfileManager] Using CLAUDE_CONFIG_DIR for profile:', profile.name, expandedConfigDir); } - } else { - console.warn('[ClaudeProfileManager] Profile has no configDir configured:', profile?.name); + } else if (profile) { + // Fallback: retrieve OAuth token directly from Keychain when configDir is missing. + // Without configDir, Claude CLI cannot resolve credentials automatically, + // so we inject CLAUDE_CODE_OAUTH_TOKEN as a direct override. + debugLog( + '[ClaudeProfileManager] Profile has no configDir configured:', + profile.name, + '- falling back to Keychain token lookup. Subscription display may be degraded.' + ); + + const credentials = getCredentialsFromKeychain(undefined, true); + if (credentials.token) { + env.CLAUDE_CODE_OAUTH_TOKEN = credentials.token; + debugLog('[ClaudeProfileManager] Injected CLAUDE_CODE_OAUTH_TOKEN from Keychain for profile:', profile.name); + } else { + debugLog( + '[ClaudeProfileManager] No token found in Keychain for profile without configDir:', + profile.name, + credentials.error ? `(error: ${credentials.error})` : '' + ); + } } return env; @@ -801,8 +834,26 @@ export class ClaudeProfileManager { return {}; } - // If no configDir is defined, fall back to default if (!profile.configDir) { + // Fallback: retrieve OAuth token directly from Keychain when configDir is missing. + // Without configDir, Claude CLI cannot resolve credentials automatically, + // so we inject CLAUDE_CODE_OAUTH_TOKEN as a direct override. + // This mirrors the fallback in getActiveProfileEnv(). + debugLog( + '[ClaudeProfileManager] getProfileEnv: profile has no configDir:', + profile.name, + '- falling back to Keychain token lookup.' + ); + + const credentials = getCredentialsFromKeychain(undefined, true); + if (credentials.token) { + debugLog('[ClaudeProfileManager] getProfileEnv: injected CLAUDE_CODE_OAUTH_TOKEN from Keychain for profile:', profile.name); + return { CLAUDE_CODE_OAUTH_TOKEN: credentials.token }; + } + debugLog( + '[ClaudeProfileManager] getProfileEnv: no token found in Keychain for profile without configDir:', + profile.name + ); return {}; } diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index 43a74a78..f5d3f47f 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -5,6 +5,7 @@ import { getClaudeProfileManager } from './claude-profile-manager'; import { getUsageMonitor } from './claude-profile/usage-monitor'; +import { debugLog } from '../shared/utils/debug-logger'; /** * Regex pattern to detect Claude Code rate limit messages @@ -476,6 +477,14 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult { const profileManager = getClaudeProfileManager(); const activeProfile = profileManager.getActiveProfile(); + debugLog('[RateLimitDetector] getBestAvailableProfileEnv() called:', { + activeProfileId: activeProfile.id, + activeProfileName: activeProfile.name, + hasConfigDir: !!activeProfile.configDir, + configDir: activeProfile.configDir, + weeklyUsagePercent: activeProfile.usage?.weeklyUsagePercent, + }); + // Check for explicit rate limit (from previous API errors) const rateLimitStatus = profileManager.isProfileRateLimited(activeProfile.id); @@ -492,28 +501,24 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult { : 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 - }); - } + debugLog('[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 - }); - } + debugLog('[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 @@ -564,6 +569,14 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult { const profileEnv = profileManager.getProfileEnv(bestProfile.id); + debugLog('[RateLimitDetector] Profile env for swapped profile:', { + profileId: bestProfile.id, + hasClaudeConfigDir: !!profileEnv.CLAUDE_CONFIG_DIR, + claudeConfigDir: profileEnv.CLAUDE_CONFIG_DIR, + hasOAuthToken: !!profileEnv.CLAUDE_CODE_OAUTH_TOKEN, + envKeys: Object.keys(profileEnv), + }); + return { env: ensureCleanProfileEnv(profileEnv), profileId: bestProfile.id, @@ -576,14 +589,21 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult { } }; } else { - if (process.env.DEBUG === 'true') { - console.warn('[RateLimitDetector] No alternative profile available, using rate-limited/at-capacity profile'); - } + debugLog('[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(); + + debugLog('[RateLimitDetector] Using active profile env (no swap):', { + profileId: activeProfile.id, + hasClaudeConfigDir: !!activeEnv.CLAUDE_CONFIG_DIR, + claudeConfigDir: activeEnv.CLAUDE_CONFIG_DIR, + hasOAuthToken: !!activeEnv.CLAUDE_CODE_OAUTH_TOKEN, + envKeys: Object.keys(activeEnv), + }); + return { env: ensureCleanProfileEnv(activeEnv), profileId: activeProfile.id, @@ -595,23 +615,55 @@ export function getBestAvailableProfileEnv(): BestProfileEnvResult { /** * 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. + * When CLAUDE_CONFIG_DIR is set, we MUST clear both CLAUDE_CODE_OAUTH_TOKEN and + * ANTHROPIC_API_KEY to prevent the Claude Agent SDK from using hardcoded/cached + * tokens or API keys (e.g., from .env file or shell environment) instead of reading + * fresh credentials from the specified config directory. + * + * ANTHROPIC_API_KEY is cleared to prevent Claude Code from using API keys present + * in the shell environment, which would cause it to show "Claude API" instead of + * "Claude Max" and bypass the intended config dir credentials. * * 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. * + * Also warns if the profile env is empty, which indicates a misconfigured profile. + * * @param env - Profile environment from getProfileEnv() or getActiveProfileEnv() - * @returns Environment with CLAUDE_CODE_OAUTH_TOKEN cleared if CLAUDE_CONFIG_DIR is set + * @returns Environment with CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY cleared if CLAUDE_CONFIG_DIR is set */ -function ensureCleanProfileEnv(env: Record): Record { +export function ensureCleanProfileEnv(env: Record): Record { + debugLog('[RateLimitDetector] ensureCleanProfileEnv() input:', { + hasClaudeConfigDir: !!env.CLAUDE_CONFIG_DIR, + claudeConfigDir: env.CLAUDE_CONFIG_DIR, + hasOAuthToken: !!env.CLAUDE_CODE_OAUTH_TOKEN, + willClearOAuthToken: !!env.CLAUDE_CONFIG_DIR, + willClearApiKey: !!env.CLAUDE_CONFIG_DIR, + }); + + // Warn if the profile environment is empty — this likely indicates a misconfigured profile + if (Object.keys(env).length === 0) { + console.warn('[RateLimitDetector] ensureCleanProfileEnv() received empty profile env — profile may be misconfigured'); + } + if (env.CLAUDE_CONFIG_DIR) { - // Clear CLAUDE_CODE_OAUTH_TOKEN to ensure SDK uses credentials from CLAUDE_CONFIG_DIR - return { + // Clear CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY to ensure SDK uses credentials from CLAUDE_CONFIG_DIR + // ANTHROPIC_API_KEY must also be cleared to prevent Claude Code from using + // API keys that may be present in the shell environment instead of the config dir credentials. + const cleanedEnv = { ...env, - CLAUDE_CODE_OAUTH_TOKEN: '' + CLAUDE_CODE_OAUTH_TOKEN: '', + ANTHROPIC_API_KEY: '' }; + + debugLog('[RateLimitDetector] ensureCleanProfileEnv() output:', { + claudeConfigDirPreserved: 'CLAUDE_CONFIG_DIR' in cleanedEnv, + claudeConfigDir: (cleanedEnv as Record).CLAUDE_CONFIG_DIR, + oauthTokenCleared: cleanedEnv.CLAUDE_CODE_OAUTH_TOKEN === '', + envKeys: Object.keys(cleanedEnv), + }); + + return cleanedEnv; } return env; }