From aad3ff2cdf74b7f872dc57eb8aa8117dae3b6952 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:35:03 +0100 Subject: [PATCH 1/2] fix(auth): Improve OAuth credential detection and startup warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced getClaudeAuthIndicators() to return detailed check information including file paths checked and specific error details for debugging - Added debug logging to server startup credential detection for easier troubleshooting in Docker environments - Show paths that were checked in the warning message to help users debug mount issues - Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable - Return authType in verify-claude-auth response to distinguish between OAuth and CLI authentication methods - Updated UI to show specific success messages for Claude Code subscription vs generic CLI auth - Added Docker troubleshooting tips to sandbox risk dialog - Added comprehensive unit tests for OAuth credential detection scenarios Closes #721 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/index.ts | 101 ++- .../routes/setup/routes/verify-claude-auth.ts | 19 + .../dialogs/sandbox-risk-dialog.tsx | 23 + .../setup-view/steps/claude-setup-step.tsx | 25 +- apps/ui/src/lib/electron.ts | 1 + apps/ui/src/lib/http-api-client.ts | 1 + libs/platform/src/index.ts | 2 + libs/platform/src/system-paths.ts | 156 +++- .../tests/oauth-credential-detection.test.ts | 736 ++++++++++++++++++ 9 files changed, 1028 insertions(+), 36 deletions(-) create mode 100644 libs/platform/tests/oauth-credential-detection.test.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4bd496bc8..c10702bb3 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -121,21 +121,89 @@ const BOX_CONTENT_WIDTH = 67; // The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication (async () => { const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY; + const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + logger.debug('[CREDENTIAL_CHECK] Starting credential detection...'); + logger.debug('[CREDENTIAL_CHECK] Environment variables:', { + hasAnthropicKey, + hasEnvOAuthToken, + }); if (hasAnthropicKey) { logger.info('✓ ANTHROPIC_API_KEY detected'); return; } + if (hasEnvOAuthToken) { + logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected'); + return; + } + // Check for Claude Code CLI authentication + // Store indicators outside the try block so we can use them in the warning message + let cliAuthIndicators: Awaited> | null = null; + try { - const indicators = await getClaudeAuthIndicators(); + cliAuthIndicators = await getClaudeAuthIndicators(); + const indicators = cliAuthIndicators; + + // Log detailed credential detection results + logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', { + hasCredentialsFile: indicators.hasCredentialsFile, + hasSettingsFile: indicators.hasSettingsFile, + hasStatsCacheWithActivity: indicators.hasStatsCacheWithActivity, + hasProjectsSessions: indicators.hasProjectsSessions, + credentials: indicators.credentials, + }); + + logger.debug('[CREDENTIAL_CHECK] File check details:', { + settingsFile: { + path: indicators.checks.settingsFile.path, + exists: indicators.checks.settingsFile.exists, + readable: indicators.checks.settingsFile.readable, + error: indicators.checks.settingsFile.error, + }, + statsCache: { + path: indicators.checks.statsCache.path, + exists: indicators.checks.statsCache.exists, + readable: indicators.checks.statsCache.readable, + hasDailyActivity: indicators.checks.statsCache.hasDailyActivity, + error: indicators.checks.statsCache.error, + }, + projectsDir: { + path: indicators.checks.projectsDir.path, + exists: indicators.checks.projectsDir.exists, + readable: indicators.checks.projectsDir.readable, + entryCount: indicators.checks.projectsDir.entryCount, + error: indicators.checks.projectsDir.error, + }, + credentialFiles: indicators.checks.credentialFiles.map((cf) => ({ + path: cf.path, + exists: cf.exists, + readable: cf.readable, + error: cf.error, + })), + }); + const hasCliAuth = indicators.hasStatsCacheWithActivity || (indicators.hasSettingsFile && indicators.hasProjectsSessions) || (indicators.hasCredentialsFile && (indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey)); + logger.debug('[CREDENTIAL_CHECK] Auth determination:', { + hasCliAuth, + reason: hasCliAuth + ? indicators.hasStatsCacheWithActivity + ? 'stats cache with activity' + : indicators.hasSettingsFile && indicators.hasProjectsSessions + ? 'settings file + project sessions' + : indicators.credentials?.hasOAuthToken + ? 'credentials file with OAuth token' + : 'credentials file with API key' + : 'no valid credentials found', + }); + if (hasCliAuth) { logger.info('✓ Claude Code CLI authentication detected'); return; @@ -145,7 +213,7 @@ const BOX_CONTENT_WIDTH = 67; logger.warn('Error checking for Claude Code CLI authentication:', error); } - // No authentication found - show warning + // No authentication found - show warning with paths that were checked const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH); const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH); const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH); @@ -158,6 +226,33 @@ const BOX_CONTENT_WIDTH = 67; BOX_CONTENT_WIDTH ); + // Build paths checked summary from the indicators (if available) + let pathsCheckedInfo = ''; + if (cliAuthIndicators) { + const pathsChecked: string[] = []; + + // Collect paths that were checked + if (cliAuthIndicators.checks.settingsFile.path) { + pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`); + } + if (cliAuthIndicators.checks.statsCache.path) { + pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`); + } + if (cliAuthIndicators.checks.projectsDir.path) { + pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`); + } + for (const credFile of cliAuthIndicators.checks.credentialFiles) { + pathsChecked.push(`Credentials: ${credFile.path}`); + } + + if (pathsChecked.length > 0) { + pathsCheckedInfo = ` +║ ║ +║ ${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}║ +${pathsChecked.map((p) => `║ ${p.substring(0, BOX_CONTENT_WIDTH - 2).padEnd(BOX_CONTENT_WIDTH - 2)} ║`).join('\n')}`; + } + } + logger.warn(` ╔═════════════════════════════════════════════════════════════════════╗ ║ ${wHeader}║ @@ -169,7 +264,7 @@ const BOX_CONTENT_WIDTH = 67; ║ ${w3}║ ║ ${w4}║ ║ ${w5}║ -║ ${w6}║ +║ ${w6}║${pathsCheckedInfo} ║ ║ ╚═════════════════════════════════════════════════════════════════════╝ `); diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index df04d4629..2a8d21b00 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -320,9 +320,28 @@ export function createVerifyClaudeAuthHandler() { authMethod, }); + // Determine specific auth type for success messages + let authType: 'oauth' | 'api_key' | 'cli' | undefined; + if (authenticated) { + if (authMethod === 'api_key') { + authType = 'api_key'; + } else if (authMethod === 'cli') { + // Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI + // OAuth tokens are stored in the credentials file by the Claude CLI + const { getClaudeAuthIndicators } = await import('@automaker/platform'); + const indicators = await getClaudeAuthIndicators(); + if (indicators.credentials?.hasOAuthToken) { + authType = 'oauth'; + } else { + authType = 'cli'; + } + } + } + res.json({ success: true, authenticated, + authType, error: errorMessage || undefined, }); } catch (error) { diff --git a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx index 3a5f6d35b..7b597c8cb 100644 --- a/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx +++ b/apps/ui/src/components/dialogs/sandbox-risk-dialog.tsx @@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog For safer operation, consider running Automaker in Docker. See the README for instructions.

+ +
+

+ Already running in Docker? Try these troubleshooting steps: +

+
    +
  • + Ensure IS_CONTAINERIZED=true is + set in your docker-compose environment +
  • +
  • + Verify the server container has the environment variable:{' '} + + docker exec automaker-server printenv IS_CONTAINERIZED + +
  • +
  • Rebuild and restart containers if you recently changed the configuration
  • +
  • + Check the server logs for startup messages:{' '} + docker-compose logs server +
  • +
+
diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index b864bfdb5..0b4799d66 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps // CLI Verification state const [cliVerificationStatus, setCliVerificationStatus] = useState('idle'); const [cliVerificationError, setCliVerificationError] = useState(null); + const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null); // API Key Verification state const [apiKeyVerificationStatus, setApiKeyVerificationStatus] = @@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps const verifyCliAuth = useCallback(async () => { setCliVerificationStatus('verifying'); setCliVerificationError(null); + setCliAuthType(null); try { const api = getElectronAPI(); @@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps if (result.authenticated && !hasLimitReachedError) { setCliVerificationStatus('verified'); + // Store the auth type for displaying specific success message + const authType = result.authType === 'oauth' ? 'oauth' : 'cli'; + setCliAuthType(authType); setClaudeAuthStatus({ authenticated: true, - method: 'cli_authenticated', + method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated', hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, + oauthTokenValid: authType === 'oauth', }); - toast.success('Claude CLI authentication verified!'); + // Show specific success message based on auth type + if (authType === 'oauth') { + toast.success('Claude Code subscription detected and verified!'); + } else { + toast.success('Claude CLI authentication verified!'); + } } else { setCliVerificationStatus('error'); setCliVerificationError( @@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
-

CLI Authentication verified!

+

+ {cliAuthType === 'oauth' + ? 'Claude Code subscription verified!' + : 'CLI Authentication verified!'} +

- Your Claude CLI is working correctly. + {cliAuthType === 'oauth' + ? 'Your Claude Code subscription is active and ready to use.' + : 'Your Claude CLI is working correctly.'}

diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 89aa07baa..22079822c 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1442,6 +1442,7 @@ interface SetupAPI { verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }>; getGhStatus?: () => Promise<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 1f79ff075..acd75d22a 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI { ): Promise<{ success: boolean; authenticated: boolean; + authType?: 'oauth' | 'api_key' | 'cli'; error?: string; }> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }), diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 5952ba2d8..5c0b80784 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -134,6 +134,8 @@ export { findClaudeCliPath, getClaudeAuthIndicators, type ClaudeAuthIndicators, + type FileCheckResult, + type DirectoryCheckResult, findCodexCliPath, getCodexAuthIndicators, type CodexAuthIndicators, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 0d900dfa4..fb5e6bd36 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -976,6 +976,27 @@ export async function findGitBashPath(): Promise { return findFirstExistingPath(getGitBashPaths()); } +/** + * Details about a file check performed during auth detection + */ +export interface FileCheckResult { + path: string; + exists: boolean; + readable: boolean; + error?: string; +} + +/** + * Details about a directory check performed during auth detection + */ +export interface DirectoryCheckResult { + path: string; + exists: boolean; + readable: boolean; + entryCount: number; + error?: string; +} + /** * Get Claude authentication status by checking various indicators */ @@ -988,67 +1009,144 @@ export interface ClaudeAuthIndicators { hasOAuthToken: boolean; hasApiKey: boolean; } | null; + /** Detailed information about what was checked */ + checks: { + settingsFile: FileCheckResult; + statsCache: FileCheckResult & { hasDailyActivity?: boolean }; + projectsDir: DirectoryCheckResult; + credentialFiles: FileCheckResult[]; + }; } export async function getClaudeAuthIndicators(): Promise { + const settingsPath = getClaudeSettingsPath(); + const statsCachePath = getClaudeStatsCachePath(); + const projectsDir = getClaudeProjectsDir(); + const credentialPaths = getClaudeCredentialPaths(); + + // Initialize checks with paths + const settingsFileCheck: FileCheckResult = { + path: settingsPath, + exists: false, + readable: false, + }; + + const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = { + path: statsCachePath, + exists: false, + readable: false, + }; + + const projectsDirCheck: DirectoryCheckResult = { + path: projectsDir, + exists: false, + readable: false, + entryCount: 0, + }; + + const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({ + path: p, + exists: false, + readable: false, + })); + const result: ClaudeAuthIndicators = { hasCredentialsFile: false, hasSettingsFile: false, hasStatsCacheWithActivity: false, hasProjectsSessions: false, credentials: null, + checks: { + settingsFile: settingsFileCheck, + statsCache: statsCacheCheck, + projectsDir: projectsDirCheck, + credentialFiles: credentialFileChecks, + }, }; // Check settings file try { - if (await systemPathAccess(getClaudeSettingsPath())) { + if (await systemPathAccess(settingsPath)) { + settingsFileCheck.exists = true; + settingsFileCheck.readable = true; result.hasSettingsFile = true; } - } catch { - // Ignore errors + } catch (err) { + settingsFileCheck.error = err instanceof Error ? err.message : String(err); } // Check stats cache for recent activity try { - const statsContent = await systemPathReadFile(getClaudeStatsCachePath()); - const stats = JSON.parse(statsContent); - if (stats.dailyActivity && stats.dailyActivity.length > 0) { - result.hasStatsCacheWithActivity = true; + const statsContent = await systemPathReadFile(statsCachePath); + statsCacheCheck.exists = true; + statsCacheCheck.readable = true; + try { + const stats = JSON.parse(statsContent); + if (stats.dailyActivity && stats.dailyActivity.length > 0) { + statsCacheCheck.hasDailyActivity = true; + result.hasStatsCacheWithActivity = true; + } else { + statsCacheCheck.hasDailyActivity = false; + } + } catch (parseErr) { + statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + statsCacheCheck.exists = false; + } else { + statsCacheCheck.error = err instanceof Error ? err.message : String(err); } - } catch { - // Ignore errors } // Check for sessions in projects directory try { - const sessions = await systemPathReaddir(getClaudeProjectsDir()); + const sessions = await systemPathReaddir(projectsDir); + projectsDirCheck.exists = true; + projectsDirCheck.readable = true; + projectsDirCheck.entryCount = sessions.length; if (sessions.length > 0) { result.hasProjectsSessions = true; } - } catch { - // Ignore errors + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + projectsDirCheck.exists = false; + } else { + projectsDirCheck.error = err instanceof Error ? err.message : String(err); + } } // Check credentials files - const credentialPaths = getClaudeCredentialPaths(); - for (const credPath of credentialPaths) { + for (let i = 0; i < credentialPaths.length; i++) { + const credPath = credentialPaths[i]; + const credCheck = credentialFileChecks[i]; try { const content = await systemPathReadFile(credPath); - const credentials = JSON.parse(content); - result.hasCredentialsFile = true; - // Support multiple credential formats: - // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } - // 2. Legacy format: { oauth_token } or { access_token } - // 3. API key format: { api_key } - const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; - const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; - } catch { - // Continue to next path + credCheck.exists = true; + credCheck.readable = true; + try { + const credentials = JSON.parse(content); + result.hasCredentialsFile = true; + // Support multiple credential formats: + // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } + // 2. Legacy format: { oauth_token } or { access_token } + // 3. API key format: { api_key } + const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; + const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); + result.credentials = { + hasOAuthToken: hasClaudeOauth || hasLegacyOauth, + hasApiKey: !!credentials.api_key, + }; + break; + } catch (parseErr) { + credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + credCheck.exists = false; + } else { + credCheck.error = err instanceof Error ? err.message : String(err); + } } } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts new file mode 100644 index 000000000..cf5a4705a --- /dev/null +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -0,0 +1,736 @@ +/** + * Unit tests for OAuth credential detection scenarios + * + * Tests the various Claude credential detection formats including: + * - Claude Code CLI OAuth format (claudeAiOauth) + * - Legacy OAuth token format (oauth_token, access_token) + * - API key format (api_key) + * - Invalid/malformed credential files + * + * These tests use real temp directories to avoid complex fs mocking issues. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'fs/promises'; +import path from 'path'; +import os from 'os'; + +describe('OAuth Credential Detection', () => { + let tempDir: string; + let originalHomedir: () => string; + let mockClaudeDir: string; + let mockCodexDir: string; + let mockOpenCodeDir: string; + + beforeEach(async () => { + // Reset modules to get fresh state + vi.resetModules(); + + // Create a temporary directory + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-')); + + // Create mock home directory structure + mockClaudeDir = path.join(tempDir, '.claude'); + mockCodexDir = path.join(tempDir, '.codex'); + mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode'); + + await fs.mkdir(mockClaudeDir, { recursive: true }); + await fs.mkdir(mockCodexDir, { recursive: true }); + await fs.mkdir(mockOpenCodeDir, { recursive: true }); + + // Mock os.homedir to return our temp directory + originalHomedir = os.homedir; + vi.spyOn(os, 'homedir').mockReturnValue(tempDir); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + // Clean up temp directory + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('getClaudeAuthIndicators', () => { + it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-access-token-12345', + refreshToken: 'oauth-refresh-token-67890', + expiresAt: Date.now() + 3600000, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy OAuth token format (oauth_token)', async () => { + const credentialsContent = JSON.stringify({ + oauth_token: 'legacy-oauth-token-abcdef', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect legacy access_token format', async () => { + const credentialsContent = JSON.stringify({ + access_token: 'legacy-access-token-xyz', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect API key format', async () => { + const credentialsContent = JSON.stringify({ + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should detect both OAuth and API key when present', async () => { + const credentialsContent = JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + api_key: 'sk-ant-api03-xxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle missing credentials file gracefully', async () => { + // No credentials file created + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0); + expect(indicators.checks.credentialFiles[0].exists).toBe(false); + }); + + it('should handle malformed JSON in credentials file', async () => { + const malformedContent = '{ invalid json }'; + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // File exists but parsing fails + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error'); + }); + + it('should handle empty credentials file', async () => { + const emptyContent = JSON.stringify({}); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials).not.toBeNull(); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials file with null values', async () => { + const nullContent = JSON.stringify({ + claudeAiOauth: null, + api_key: null, + oauth_token: null, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle credentials with empty string values', async () => { + const emptyStrings = JSON.stringify({ + claudeAiOauth: { + accessToken: '', + refreshToken: '', + }, + api_key: '', + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + // Empty strings should not be treated as valid credentials + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should detect settings file presence', async () => { + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.checks.settingsFile.exists).toBe(true); + expect(indicators.checks.settingsFile.readable).toBe(true); + }); + + it('should detect stats cache with activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [ + { date: '2025-01-15', messagesCount: 10 }, + { date: '2025-01-16', messagesCount: 5 }, + ], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(true); + }); + + it('should detect stats cache without activity', async () => { + const statsContent = JSON.stringify({ + dailyActivity: [], + }); + + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.hasDailyActivity).toBe(false); + }); + + it('should detect project sessions', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + await fs.mkdir(path.join(projectsDir, 'session-2')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(2); + }); + + it('should return comprehensive check details', async () => { + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Verify all check detail objects are present + expect(indicators.checks).toBeDefined(); + expect(indicators.checks.settingsFile).toBeDefined(); + expect(indicators.checks.settingsFile.path).toContain('settings.json'); + expect(indicators.checks.statsCache).toBeDefined(); + expect(indicators.checks.statsCache.path).toContain('stats-cache.json'); + expect(indicators.checks.projectsDir).toBeDefined(); + expect(indicators.checks.projectsDir.path).toContain('projects'); + expect(indicators.checks.credentialFiles).toBeDefined(); + expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true); + }); + + it('should try both .credentials.json and credentials.json paths', async () => { + // Write to credentials.json (without leading dot) + const credentialsContent = JSON.stringify({ + api_key: 'sk-test-key', + }); + + await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in the second path + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should prefer first credentials file if both exist', async () => { + // Write OAuth to .credentials.json (first path checked) + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + refreshToken: 'refresh-token', + }, + }) + ); + + // Write API key to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should use first file (.credentials.json) which has OAuth + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + }); + + describe('getCodexAuthIndicators', () => { + it('should detect OAuth token in Codex auth file', async () => { + const authContent = JSON.stringify({ + access_token: 'codex-oauth-token-12345', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect API key in Codex auth file', async () => { + const authContent = JSON.stringify({ + OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect nested tokens in Codex auth file', async () => { + const authContent = JSON.stringify({ + tokens: { + oauth_token: 'nested-oauth-token', + }, + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should handle missing Codex auth file', async () => { + // No auth file created + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect api_key field in Codex auth', async () => { + const authContent = JSON.stringify({ + api_key: 'sk-api-key-value', + }); + + await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent); + + const { getCodexAuthIndicators } = await import('../src/system-paths'); + const indicators = await getCodexAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + }); + + describe('getOpenCodeAuthIndicators', () => { + it('should detect provider-specific OAuth credentials', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'oauth-access-token', + refresh: 'oauth-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should detect GitHub Copilot refresh token as OAuth', async () => { + const authContent = JSON.stringify({ + 'github-copilot': { + type: 'oauth', + access: '', // Empty access token + refresh: 'gh-refresh-token', // But has refresh token + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect provider-specific API key credentials', async () => { + const authContent = JSON.stringify({ + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should detect multiple providers', async () => { + const authContent = JSON.stringify({ + anthropic: { + type: 'oauth', + access: 'anthropic-token', + refresh: 'refresh-token', + }, + openai: { + type: 'api_key', + key: 'sk-xxxxxxxxxxxx', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + expect(indicators.hasApiKey).toBe(true); + }); + + it('should handle missing OpenCode auth file', async () => { + // No auth file created + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(false); + expect(indicators.hasOAuthToken).toBe(false); + expect(indicators.hasApiKey).toBe(false); + }); + + it('should handle legacy top-level OAuth keys', async () => { + const authContent = JSON.stringify({ + access_token: 'legacy-access-token', + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + + it('should detect copilot provider OAuth', async () => { + const authContent = JSON.stringify({ + copilot: { + type: 'oauth', + access: 'copilot-access-token', + refresh: 'copilot-refresh-token', + }, + }); + + await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent); + + const { getOpenCodeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getOpenCodeAuthIndicators(); + + expect(indicators.hasAuthFile).toBe(true); + expect(indicators.hasOAuthToken).toBe(true); + }); + }); + + describe('Credential path helpers', () => { + it('should return correct Claude credential paths', async () => { + const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths'); + + const configDir = getClaudeConfigDir(); + expect(configDir).toContain('.claude'); + + const credPaths = getClaudeCredentialPaths(); + expect(credPaths.length).toBeGreaterThan(0); + expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true); + expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true); + }); + + it('should return correct Codex auth path', async () => { + const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths'); + + const configDir = getCodexConfigDir(); + expect(configDir).toContain('.codex'); + + const authPath = getCodexAuthPath(); + expect(authPath).toContain('.codex'); + expect(authPath).toContain('auth.json'); + }); + + it('should return correct OpenCode auth path', async () => { + const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths'); + + const configDir = getOpenCodeConfigDir(); + expect(configDir).toContain('opencode'); + + const authPath = getOpenCodeAuthPath(); + expect(authPath).toContain('opencode'); + expect(authPath).toContain('auth.json'); + }); + }); + + describe('Edge cases for credential detection', () => { + it('should handle credentials file with unexpected structure', async () => { + const unexpectedContent = JSON.stringify({ + someUnexpectedKey: 'value', + nested: { + deeply: { + unexpected: true, + }, + }, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle array instead of object in credentials', async () => { + const arrayContent = JSON.stringify(['token1', 'token2']); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Array is valid JSON but wrong structure - should handle gracefully + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); + expect(indicators.credentials?.hasApiKey).toBe(false); + }); + + it('should handle numeric values in credential fields', async () => { + const numericContent = JSON.stringify({ + api_key: 12345, + oauth_token: 67890, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness which accepts numbers + // This documents the actual behavior - ideally would validate string type + expect(indicators.hasCredentialsFile).toBe(true); + // The implementation checks truthiness, not strict string type + expect(indicators.credentials?.hasOAuthToken).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); + + it('should handle boolean values in credential fields', async () => { + const booleanContent = JSON.stringify({ + api_key: true, + oauth_token: false, + }); + + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Note: Current implementation uses JavaScript truthiness + // api_key: true is truthy, oauth_token: false is falsy + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy + expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy + }); + + it('should handle malformed stats-cache.json gracefully', async () => { + await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }'); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(false); + expect(indicators.checks.statsCache.exists).toBe(true); + expect(indicators.checks.statsCache.error).toBeDefined(); + }); + + it('should handle empty projects directory', async () => { + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasProjectsSessions).toBe(false); + expect(indicators.checks.projectsDir.exists).toBe(true); + expect(indicators.checks.projectsDir.entryCount).toBe(0); + }); + }); + + describe('Combined authentication scenarios', () => { + it('should detect CLI authenticated state with settings + sessions', async () => { + // Create settings file + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + + // Create projects directory with sessions + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + }); + + it('should detect recent activity indicating working auth', async () => { + // Create stats cache with recent activity + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ + dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }], + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasStatsCacheWithActivity).toBe(true); + }); + + it('should handle complete auth setup', async () => { + // Create all auth indicators + await fs.writeFile( + path.join(mockClaudeDir, '.credentials.json'), + JSON.stringify({ + claudeAiOauth: { + accessToken: 'token', + refreshToken: 'refresh', + }, + }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'settings.json'), + JSON.stringify({ theme: 'dark' }) + ); + await fs.writeFile( + path.join(mockClaudeDir, 'stats-cache.json'), + JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] }) + ); + const projectsDir = path.join(mockClaudeDir, 'projects'); + await fs.mkdir(projectsDir, { recursive: true }); + await fs.mkdir(path.join(projectsDir, 'session-1')); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.hasSettingsFile).toBe(true); + expect(indicators.hasStatsCacheWithActivity).toBe(true); + expect(indicators.hasProjectsSessions).toBe(true); + expect(indicators.credentials?.hasOAuthToken).toBe(true); + }); + }); +}); From 0aef72540e5f9eff7919f5b2157ce7c5f1141204 Mon Sep 17 00:00:00 2001 From: Kacper Date: Mon, 2 Feb 2026 17:54:23 +0100 Subject: [PATCH 2/2] fix(auth): Enhance credential detection logic for OAuth - Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths. - Improved error handling for settings file readability checks, providing clearer feedback on file access issues. - Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid. This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management. --- libs/platform/src/system-paths.ts | 37 ++++++++--- .../tests/oauth-credential-detection.test.ts | 61 +++++++++++++------ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index fb5e6bd36..f17494645 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -1065,11 +1065,20 @@ export async function getClaudeAuthIndicators(): Promise { }; // Check settings file + // First check existence, then try to read to confirm it's actually readable try { if (await systemPathAccess(settingsPath)) { settingsFileCheck.exists = true; - settingsFileCheck.readable = true; - result.hasSettingsFile = true; + // Try to actually read the file to confirm read permissions + try { + await systemPathReadFile(settingsPath); + settingsFileCheck.readable = true; + result.hasSettingsFile = true; + } catch (readErr) { + // File exists but cannot be read (permission denied, etc.) + settingsFileCheck.readable = false; + settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`; + } } } catch (err) { settingsFileCheck.error = err instanceof Error ? err.message : String(err); @@ -1117,6 +1126,9 @@ export async function getClaudeAuthIndicators(): Promise { } // Check credentials files + // We iterate through all credential paths and only stop when we find a file + // that contains actual credentials (OAuth tokens or API keys). An empty or + // token-less file should not prevent checking subsequent credential paths. for (let i = 0; i < credentialPaths.length; i++) { const credPath = credentialPaths[i]; const credCheck = credentialFileChecks[i]; @@ -1126,18 +1138,27 @@ export async function getClaudeAuthIndicators(): Promise { credCheck.readable = true; try { const credentials = JSON.parse(content); - result.hasCredentialsFile = true; // Support multiple credential formats: // 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } } // 2. Legacy format: { oauth_token } or { access_token } // 3. API key format: { api_key } const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken; const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token); - result.credentials = { - hasOAuthToken: hasClaudeOauth || hasLegacyOauth, - hasApiKey: !!credentials.api_key, - }; - break; + const hasOAuthToken = hasClaudeOauth || hasLegacyOauth; + const hasApiKey = !!credentials.api_key; + + // Only consider this a valid credentials file if it actually contains tokens + // An empty JSON file ({}) or file without tokens should not stop us from + // checking subsequent credential paths + if (hasOAuthToken || hasApiKey) { + result.hasCredentialsFile = true; + result.credentials = { + hasOAuthToken, + hasApiKey, + }; + break; // Found valid credentials, stop searching + } + // File exists and is valid JSON but contains no tokens - continue checking other paths } catch (parseErr) { credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`; } diff --git a/libs/platform/tests/oauth-credential-detection.test.ts b/libs/platform/tests/oauth-credential-detection.test.ts index cf5a4705a..6e445b227 100644 --- a/libs/platform/tests/oauth-credential-detection.test.ts +++ b/libs/platform/tests/oauth-credential-detection.test.ts @@ -173,10 +173,14 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials).not.toBeNull(); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Empty credentials file ({}) should NOT be treated as having credentials + // because it contains no actual tokens. This allows the system to continue + // checking subsequent credential paths that might have valid tokens. + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); + // But the file should still show as existing and readable in the checks + expect(indicators.checks.credentialFiles[0].exists).toBe(true); + expect(indicators.checks.credentialFiles[0].readable).toBe(true); }); it('should handle credentials file with null values', async () => { @@ -191,9 +195,10 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // File with all null values should NOT be treated as having credentials + // because null values are not valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle credentials with empty string values', async () => { @@ -210,10 +215,10 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - // Empty strings should not be treated as valid credentials - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Empty strings should NOT be treated as having credentials + // This allows checking subsequent credential paths for valid tokens + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should detect settings file presence', async () => { @@ -337,6 +342,27 @@ describe('OAuth Credential Detection', () => { expect(indicators.credentials?.hasOAuthToken).toBe(true); expect(indicators.credentials?.hasApiKey).toBe(false); }); + + it('should check second credentials file if first file has no tokens', async () => { + // Write empty/token-less content to .credentials.json (first path checked) + // This tests the bug fix: previously, an empty JSON file would stop the search + await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({})); + + // Write actual credentials to credentials.json (second path) + await fs.writeFile( + path.join(mockClaudeDir, 'credentials.json'), + JSON.stringify({ + api_key: 'sk-test-key-from-second-file', + }) + ); + + const { getClaudeAuthIndicators } = await import('../src/system-paths'); + const indicators = await getClaudeAuthIndicators(); + + // Should find credentials in second file since first file has no tokens + expect(indicators.hasCredentialsFile).toBe(true); + expect(indicators.credentials?.hasApiKey).toBe(true); + }); }); describe('getCodexAuthIndicators', () => { @@ -585,9 +611,9 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // File with unexpected structure but no valid tokens should NOT be treated as having credentials + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle array instead of object in credentials', async () => { @@ -598,10 +624,9 @@ describe('OAuth Credential Detection', () => { const { getClaudeAuthIndicators } = await import('../src/system-paths'); const indicators = await getClaudeAuthIndicators(); - // Array is valid JSON but wrong structure - should handle gracefully - expect(indicators.hasCredentialsFile).toBe(true); - expect(indicators.credentials?.hasOAuthToken).toBe(false); - expect(indicators.credentials?.hasApiKey).toBe(false); + // Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file + expect(indicators.hasCredentialsFile).toBe(false); + expect(indicators.credentials).toBeNull(); }); it('should handle numeric values in credential fields', async () => {