Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/frontend/src/main/agent/agent-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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(
Expand Down
31 changes: 31 additions & 0 deletions apps/frontend/src/main/claude-profile-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -490,13 +495,20 @@ 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);
if (!profile) {
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();
Expand Down Expand Up @@ -542,6 +554,7 @@ export class ClaudeProfileManager {
const profile = this.getActiveProfile();
const env: Record<string, string> = {};

<<<<<<< HEAD
// All profiles now use explicit CLAUDE_CONFIG_DIR for isolation
// This prevents interference with external Claude Code CLI usage
if (profile?.configDir) {
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions apps/frontend/src/main/claude-profile/profile-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/src/main/claude-profile/token-encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,17 @@ 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');
return safeStorage.decryptString(encryptedData);
}
} 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;
Expand Down
21 changes: 21 additions & 0 deletions apps/frontend/src/main/rate-limit-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -388,13 +392,30 @@ export function detectBillingFailure(
const effectiveProfileId = profileId || profileManager.getActiveProfile().id;
const failureType = classifyBillingFailureType(output);

<<<<<<< HEAD
return {
isBillingFailure: true,
profileId: effectiveProfileId,
failureType,
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
}
}

Expand Down
12 changes: 12 additions & 0 deletions apps/frontend/src/main/title-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
Expand Down Expand Up @@ -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"
Expand Down