diff --git a/apps/frontend/src/main/agent/agent-process.ts b/apps/frontend/src/main/agent/agent-process.ts index 58f9731674..2acac57b43 100644 --- a/apps/frontend/src/main/agent/agent-process.ts +++ b/apps/frontend/src/main/agent/agent-process.ts @@ -206,6 +206,17 @@ export class AgentProcessManager { // are available even when app is launched from Finder/Dock const augmentedEnv = getAugmentedEnv(); + // Debug: Log OAuth token sources (when DEBUG=true or in development mode) + const DEBUG = process.env.DEBUG === 'true' || process.env.NODE_ENV === 'development'; + if (DEBUG) { + const maskToken = (t: string | undefined) => t ? `${t.substring(0, 15)}...` : 'none'; + console.log('[AgentProcess] Token sources:', { + processEnv: maskToken(process.env.CLAUDE_CODE_OAUTH_TOKEN), + extraEnv: maskToken(extraEnv.CLAUDE_CODE_OAUTH_TOKEN), + profileEnv: maskToken(profileEnv.CLAUDE_CODE_OAUTH_TOKEN), + }); + } + // On Windows, detect and pass git-bash path for Claude Code CLI // Electron can detect git via where.exe, but Python subprocess may not have the same PATH const gitBashEnv: Record = {}; @@ -229,9 +240,13 @@ export class AgentProcessManager { const ghCliEnv = this.detectAndSetCliPath('gh'); const glabCliEnv = this.detectAndSetCliPath('glab'); +<<<<<<< HEAD // 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 = { +======= + const finalEnv = { +>>>>>>> refs/remotes/upstream/pr/1326 ...augmentedEnv, ...gitBashEnv, ...claudeCliEnv, @@ -244,6 +259,7 @@ export class AgentProcessManager { PYTHONUTF8: '1' } as NodeJS.ProcessEnv; +<<<<<<< HEAD // 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. @@ -266,6 +282,15 @@ export class AgentProcessManager { }); return mergedEnv; +======= + // Debug: Log final token being passed (only when DEBUG=true) + if (DEBUG) { + const maskToken = (t: string | undefined) => t ? `${t.substring(0, 15)}...` : 'none'; + console.log('[AgentProcess] Final CLAUDE_CODE_OAUTH_TOKEN:', maskToken(finalEnv.CLAUDE_CODE_OAUTH_TOKEN)); + } + + return finalEnv; +>>>>>>> refs/remotes/upstream/pr/1326 } private handleProcessFailure( diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index e91117e8cb..01415a6b00 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -53,8 +53,13 @@ import { createProfileDirectory as createProfileDirectoryImpl, isProfileAuthenticated as isProfileAuthenticatedImpl, hasValidToken, +<<<<<<< HEAD expandHomePath, getEmailFromConfigDir +======= + isValidTokenFormat, + expandHomePath +>>>>>>> refs/remotes/upstream/pr/1326 } from './claude-profile/profile-utils'; import { debugLog } from '../shared/utils/debug-logger'; @@ -490,6 +495,7 @@ export class ClaudeProfileManager { /** * Set the OAuth token for a profile (encrypted storage). * Used when capturing token from `claude setup-token` output. + * Returns false if token format is invalid or profile not found. */ setProfileToken(profileId: string, token: string, email?: string): boolean { const profile = this.getProfile(profileId); @@ -497,6 +503,12 @@ export class ClaudeProfileManager { return false; } + // Validate token format before storing + if (!isValidTokenFormat(token)) { + console.error('[ProfileManager] Invalid token format. Token must start with sk-ant-oat01-'); + return false; + } + // Encrypt the token before storing profile.oauthToken = encryptToken(token); profile.tokenCreatedAt = new Date(); @@ -542,6 +554,7 @@ export class ClaudeProfileManager { const profile = this.getActiveProfile(); const env: Record = {}; +<<<<<<< HEAD // All profiles now use explicit CLAUDE_CONFIG_DIR for isolation // This prevents interference with external Claude Code CLI usage if (profile?.configDir) { @@ -577,6 +590,24 @@ export class ClaudeProfileManager { credentials.error ? `(error: ${credentials.error})` : '' ); } +======= + if (profile?.oauthToken) { + // Decrypt the token before putting in environment + const decryptedToken = decryptToken(profile.oauthToken); + if (decryptedToken) { + // Validate token format after decryption + if (isValidTokenFormat(decryptedToken)) { + env.CLAUDE_CODE_OAUTH_TOKEN = decryptedToken; + } else { + console.warn('[ProfileManager] Decrypted token has invalid format, falling back to configDir'); + } + } + } + + // Fallback to configDir for backward compatibility (if no valid token and not default profile) + if (!env.CLAUDE_CODE_OAUTH_TOKEN && profile?.configDir && !profile.isDefault) { + env.CLAUDE_CONFIG_DIR = profile.configDir; +>>>>>>> refs/remotes/upstream/pr/1326 } return env; diff --git a/apps/frontend/src/main/claude-profile/profile-utils.ts b/apps/frontend/src/main/claude-profile/profile-utils.ts index c6799a6e0c..f0e97b2600 100644 --- a/apps/frontend/src/main/claude-profile/profile-utils.ts +++ b/apps/frontend/src/main/claude-profile/profile-utils.ts @@ -9,6 +9,20 @@ import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs'; import type { ClaudeProfile, APIProfile } from '../../shared/types'; import { getCredentialsFromKeychain } from './credential-utils'; +/** + * OAuth token format pattern (sk-ant-oat01-...) + * This is the expected format for Claude Code OAuth tokens. + */ +const OAUTH_TOKEN_PATTERN = /^sk-ant-oat01-[A-Za-z0-9_-]+$/; + +/** + * Validate OAuth token format. + * Returns true if token matches expected format (sk-ant-oat01-...). + */ +export function isValidTokenFormat(token: string | undefined): boolean { + return !!token && OAUTH_TOKEN_PATTERN.test(token); +} + /** * Default Claude config directory */ @@ -176,6 +190,7 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean { } /** +<<<<<<< HEAD * Check if a profile has a valid OAuth token stored in the profile. * * DEPRECATED: This function checks for CACHED OAuth tokens which we no longer store. @@ -190,12 +205,17 @@ export function isProfileAuthenticated(profile: ClaudeProfile): boolean { * Use isProfileAuthenticated() to check for configDir-based credentials instead. * * See: docs/LONG_LIVED_AUTH_PLAN.md for full context. +======= + * Check if a profile has a valid OAuth token. + * Token is valid for 1 year from creation and must match expected format. +>>>>>>> refs/remotes/upstream/pr/1326 */ export function hasValidToken(profile: ClaudeProfile): boolean { if (!profile?.oauthToken) { return false; } +<<<<<<< HEAD // For legacy profiles with stored oauthToken, return true. // The actual token validity is determined by the Keychain (via CLAUDE_CONFIG_DIR). // We keep this for backwards compat to avoid breaking existing profiles during migration. @@ -214,6 +234,23 @@ export function isAPIProfileAuthenticated(profile: APIProfile): boolean { // Check for presence of required fields if (!profile?.apiKey || !profile?.baseUrl) { return false; +======= + // Validate token format (must start with sk-ant-oat01-) + // Note: For encrypted tokens (enc:...), we can't validate format before decryption, + // but the token-encryption module will return undefined if decryption fails + if (!profile.oauthToken.startsWith('enc:') && !isValidTokenFormat(profile.oauthToken)) { + console.warn('[profile-utils] Token has invalid format (expected sk-ant-oat01-...)'); + return false; + } + + // Check if token is expired (1 year validity) + if (profile.tokenCreatedAt) { + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + if (new Date(profile.tokenCreatedAt) < oneYearAgo) { + return false; + } +>>>>>>> refs/remotes/upstream/pr/1326 } // Validate that the fields are non-empty strings (after trimming whitespace) diff --git a/apps/frontend/src/main/claude-profile/token-encryption.ts b/apps/frontend/src/main/claude-profile/token-encryption.ts index ad8ab2f142..cfc7516d67 100644 --- a/apps/frontend/src/main/claude-profile/token-encryption.ts +++ b/apps/frontend/src/main/claude-profile/token-encryption.ts @@ -24,8 +24,9 @@ export function encryptToken(token: string): string { /** * Decrypt a token. Handles both encrypted (enc:...) and legacy plain tokens. + * Returns undefined if decryption fails, allowing callers to handle the failure appropriately. */ -export function decryptToken(storedToken: string): string { +export function decryptToken(storedToken: string): string | undefined { try { if (storedToken.startsWith('enc:') && safeStorage.isEncryptionAvailable()) { const encryptedData = Buffer.from(storedToken.slice(4), 'base64'); @@ -33,7 +34,7 @@ export function decryptToken(storedToken: string): string { } } catch (error) { console.error('[TokenEncryption] Failed to decrypt token:', error); - return ''; // Return empty string on decryption failure + return undefined; // Return undefined on decryption failure } // Return as-is for legacy unencrypted tokens return storedToken; diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index f5d3f47f14..e8298a6e8e 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -4,8 +4,12 @@ */ import { getClaudeProfileManager } from './claude-profile-manager'; +<<<<<<< HEAD import { getUsageMonitor } from './claude-profile/usage-monitor'; import { debugLog } from '../shared/utils/debug-logger'; +======= +import { isValidTokenFormat } from './claude-profile/profile-utils'; +>>>>>>> refs/remotes/upstream/pr/1326 /** * Regex pattern to detect Claude Code rate limit messages @@ -388,6 +392,7 @@ export function detectBillingFailure( const effectiveProfileId = profileId || profileManager.getActiveProfile().id; const failureType = classifyBillingFailureType(output); +<<<<<<< HEAD return { isBillingFailure: true, profileId: effectiveProfileId, @@ -395,6 +400,22 @@ export function detectBillingFailure( message: getBillingFailureMessage(failureType), originalError: sanitizeErrorOutput(output) }; +======= + if (decryptedToken) { + // Validate token format after decryption + if (!isValidTokenFormat(decryptedToken)) { + console.warn('[getProfileEnv] Token has invalid format for profile:', profile.name); + console.warn('[getProfileEnv] Token should start with sk-ant-oat01-. Please re-authenticate.'); + // Don't use invalid token - fall through to other auth methods + } else { + console.warn('[getProfileEnv] Using OAuth token for profile:', profile.name); + return { + CLAUDE_CODE_OAUTH_TOKEN: decryptedToken + }; + } + } else { + console.warn('[getProfileEnv] Failed to decrypt token for profile:', profile.name); +>>>>>>> refs/remotes/upstream/pr/1326 } } diff --git a/apps/frontend/src/main/title-generator.ts b/apps/frontend/src/main/title-generator.ts index 1f19a8109d..bc9fd4c35d 100644 --- a/apps/frontend/src/main/title-generator.ts +++ b/apps/frontend/src/main/title-generator.ts @@ -151,6 +151,7 @@ export class TitleGenerator extends EventEmitter { debug('Generating title for description:', description.substring(0, 100) + '...'); const autoBuildEnv = this.loadAutoBuildEnv(); +<<<<<<< HEAD debug('Environment loaded', { hasOAuthToken: !!autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN }); @@ -212,6 +213,17 @@ export class TitleGenerator extends EventEmitter { }); return null; } +======= + const profileEnv = getProfileEnv(); +>>>>>>> refs/remotes/upstream/pr/1326 + + // Log token sources for debugging (masked for security) + const maskToken = (t: string | undefined) => t ? `${t.substring(0, 15)}...` : 'none'; + debug('Token sources:', { + autoBuildEnv: maskToken(autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN), + profileEnv: maskToken(profileEnv.CLAUDE_CODE_OAUTH_TOKEN), + effectiveSource: profileEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'profile' : (autoBuildEnv.CLAUDE_CODE_OAUTH_TOKEN ? 'autoBuildEnv' : 'none') + }); return new Promise((resolve) => { // Parse Python command to handle space-separated commands like "py -3"