diff --git a/.github/workflows/quality-security.yml b/.github/workflows/quality-security.yml index 59f63bba..21bf2949 100644 --- a/.github/workflows/quality-security.yml +++ b/.github/workflows/quality-security.yml @@ -1,9 +1,8 @@ name: Quality Security -# CodeQL is slow (20-30 min per language), so: -# - Run on push to main only (not PRs or develop) -# - Run weekly scheduled scan -# Bandit is fast (5-10 min), so keep it on all PRs +# CodeQL runs on all PRs, pushes to main, and weekly schedule +# Note: CodeQL takes 20-30 min per language (40-60 min total) +# Bandit is fast (5-10 min) on: push: @@ -35,13 +34,10 @@ permissions: actions: read jobs: - # CodeQL only on push to main or scheduled (NOT on PRs - saves 40-60 min per PR) codeql: name: CodeQL (${{ matrix.language }}) runs-on: ubuntu-latest timeout-minutes: 30 - # Only run on push to main or scheduled - skip PRs for speed - if: github.event_name == 'push' || github.event_name == 'schedule' strategy: fail-fast: false matrix: diff --git a/CLAUDE.md b/CLAUDE.md index 45327eba..4b931c10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,10 @@ cd apps/backend && uv venv && uv pip install -r requirements.txt # Frontend (from apps/frontend/) cd apps/frontend && npm install -# Set up OAuth token -claude setup-token -# Add to apps/backend/.env: CLAUDE_CODE_OAUTH_TOKEN=your-token +# Authenticate (token auto-saved to Keychain) +claude +# Then type: /login +# Press Enter to open browser and complete OAuth ``` ### Creating and Running Specs diff --git a/apps/backend/README.md b/apps/backend/README.md index 30640f61..d1d23569 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -17,12 +17,14 @@ python -m pip install -r requirements.txt cp .env.example .env ``` -Set your Claude API token in `.env`: -``` -CLAUDE_CODE_OAUTH_TOKEN=your-token-here +Authenticate with Claude Code (token auto-saved to Keychain): +```bash +claude +# Type: /login +# Press Enter to open browser ``` -Get your token by running: `claude setup-token` +Token is auto-detected from macOS Keychain / Windows Credential Manager. ### 3. Run diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index 0b4db55c..dc1f6a9c 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -74,12 +74,12 @@ Examples: python auto-claude/run.py --spec 001 --qa-status # Check QA validation status Prerequisites: - 1. Create a spec first: claude /spec - 2. Run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN + 1. Authenticate: Run 'claude' and type '/login' + 2. Create a spec first: claude /spec Environment Variables: - CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (required) - Get it by running: claude setup-token + CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (auto-detected from Keychain) + Or authenticate via: claude → /login AUTO_BUILD_MODEL Override default model (optional) """, ) diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index e1bf1f74..95729303 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -559,25 +559,30 @@ def require_auth_token() -> str: if is_macos(): error_msg += ( "To authenticate:\n" - " 1. Run: claude setup-token\n" - " 2. The token will be saved to macOS Keychain automatically\n\n" - "Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file." + " 1. Run: claude\n" + " 2. Type: /login\n" + " 3. Press Enter to open browser\n" + " 4. Complete OAuth login in browser\n\n" + "The token will be saved to macOS Keychain automatically." ) elif is_windows(): error_msg += ( "To authenticate:\n" - " 1. Run: claude setup-token\n" - " 2. The token should be saved to Windows Credential Manager\n\n" - "If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n" - "Check: %LOCALAPPDATA%\\Claude\\credentials.json" + " 1. Run: claude\n" + " 2. Type: /login\n" + " 3. Press Enter to open browser\n" + " 4. Complete OAuth login in browser\n\n" + "The token will be saved to Windows Credential Manager." ) else: # Linux error_msg += ( "To authenticate:\n" - " 1. Run: claude setup-token\n" - " 2. The token will be saved to the system secret service (gnome-keyring/kwallet)\n\n" - "If secret-service is not available, set CLAUDE_CODE_OAUTH_TOKEN in your .env file." + " 1. Run: claude\n" + " 2. Type: /login\n" + " 3. Press Enter to open browser\n" + " 4. Complete OAuth login in browser\n\n" + "Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file." ) raise ValueError(error_msg) return token @@ -713,3 +718,181 @@ def ensure_claude_code_oauth_token() -> None: token = get_auth_token() if token: os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token + + +def trigger_login() -> bool: + """ + Trigger Claude Code OAuth login flow. + + Opens the Claude Code CLI and sends /login command to initiate + browser-based OAuth authentication. The token is automatically + saved to the system credential store (macOS Keychain, Windows + Credential Manager). + + Returns: + True if login was successful, False otherwise + """ + if is_macos(): + return _trigger_login_macos() + elif is_windows(): + return _trigger_login_windows() + else: + # Linux: fall back to manual instructions + print("\nTo authenticate, run 'claude' and type '/login'") + return False + + +def _trigger_login_macos() -> bool: + """Trigger login on macOS using expect.""" + import shutil + import tempfile + + # Check if expect is available + if not shutil.which("expect"): + print("\nTo authenticate, run 'claude' and type '/login'") + return False + + # Create expect script + expect_script = """#!/usr/bin/expect -f +set timeout 120 +spawn claude +expect { + -re ".*" { + send "/login\\r" + expect { + "Press Enter" { + send "\\r" + } + -re ".*login.*" { + send "\\r" + } + timeout { + send "\\r" + } + } + } +} +# Keep running until user completes login or exits +interact +""" + + # Use TemporaryDirectory context manager for automatic cleanup + # This prevents information leakage about authentication activity + # Directory created with mode 0o700 (owner read/write/execute only) + try: + with tempfile.TemporaryDirectory() as temp_dir: + # Ensure directory has owner-only permissions + os.chmod(temp_dir, 0o700) + + # Write expect script to temp file in our private directory + script_path = os.path.join(temp_dir, "login.exp") + with open(script_path, "w", encoding="utf-8") as f: + f.write(expect_script) + + # Set script permissions to owner-only (0o700) + os.chmod(script_path, 0o700) + + print("\n" + "=" * 60) + print("CLAUDE CODE LOGIN") + print("=" * 60) + print("\nOpening Claude Code for authentication...") + print("A browser window will open for OAuth login.") + print("After completing login in the browser, press Ctrl+C to exit.\n") + + # Run expect script + result = subprocess.run( + ["expect", script_path], + timeout=300, # 5 minute timeout + ) + + # Verify token was saved + token = get_token_from_keychain() + if token: + print("\n✓ Login successful! Token saved to macOS Keychain.") + return True + else: + print( + "\n✗ Login may not have completed. Try running 'claude' and type '/login'" + ) + return False + + except subprocess.TimeoutExpired: + print("\nLogin timed out. Try running 'claude' manually and type '/login'") + return False + except KeyboardInterrupt: + # User pressed Ctrl+C - check if login completed + token = get_token_from_keychain() + if token: + print("\n✓ Login successful! Token saved to macOS Keychain.") + return True + return False + except Exception as e: + print(f"\nLogin failed: {e}") + print("Try running 'claude' manually and type '/login'") + return False + + +def _trigger_login_windows() -> bool: + """Trigger login on Windows.""" + # Windows doesn't have expect by default, so we use a simpler approach + # that just launches claude and tells the user what to type + print("\n" + "=" * 60) + print("CLAUDE CODE LOGIN") + print("=" * 60) + print("\nLaunching Claude Code...") + print("Please type '/login' and press Enter.") + print("A browser window will open for OAuth login.\n") + + try: + # Launch claude interactively + subprocess.run(["claude"], timeout=300) + + # Verify token was saved + token = _get_token_from_windows_credential_files() + if token: + print("\n✓ Login successful!") + return True + else: + print("\n✗ Login may not have completed.") + return False + + except Exception as e: + print(f"\nLogin failed: {e}") + return False + + +def ensure_authenticated() -> str: + """ + Ensure the user is authenticated, prompting for login if needed. + + Checks for existing token and triggers login flow if not found. + + Returns: + The authentication token + + Raises: + ValueError: If authentication fails after login attempt + """ + # First check if already authenticated + token = get_auth_token() + if token: + return token + + # No token found - trigger login + print("\nNo OAuth token found. Starting login flow...") + + if trigger_login(): + # Re-check for token after login + token = get_auth_token() + if token: + return token + + # Login failed or was cancelled + raise ValueError( + "Authentication required.\n\n" + "To authenticate:\n" + " 1. Run: claude\n" + " 2. Type: /login\n" + " 3. Press Enter to open browser\n" + " 4. Complete OAuth login in browser" + ) diff --git a/apps/frontend/scripts/download-prebuilds.cjs b/apps/frontend/scripts/download-prebuilds.cjs index 072afcd7..87df6478 100644 --- a/apps/frontend/scripts/download-prebuilds.cjs +++ b/apps/frontend/scripts/download-prebuilds.cjs @@ -68,7 +68,9 @@ function getLatestRelease() { https .get(options, (res) => { let data = ''; - res.on('data', (chunk) => (data += chunk)); + res.on('data', (chunk) => { + data += chunk; + }); res.on('end', () => { if (res.statusCode === 200) { resolve(JSON.parse(data)); @@ -121,7 +123,9 @@ function downloadFile(url, destPath) { }); }) .on('error', (err) => { - fs.unlink(destPath, () => {}); // Delete partial file + fs.unlink(destPath, () => { + // Intentionally ignoring unlink errors for partial file cleanup + }); reject(err); }); }; diff --git a/apps/frontend/src/__mocks__/sentry-electron-shared.ts b/apps/frontend/src/__mocks__/sentry-electron-shared.ts index e2c97e98..3ab8f201 100644 --- a/apps/frontend/src/__mocks__/sentry-electron-shared.ts +++ b/apps/frontend/src/__mocks__/sentry-electron-shared.ts @@ -15,12 +15,18 @@ export type SentryInitOptions = { enabled?: boolean; }; -export function init(_options: SentryInitOptions): void {} +export function init(_options: SentryInitOptions): void { + // Mock: no-op for tests +} -export function captureException(_error: Error): void {} +export function captureException(_error: Error): void { + // Mock: no-op for tests +} export function withScope(callback: (scope: SentryScope) => void): void { callback({ - setContext: () => {} + setContext: () => { + // Mock: no-op for tests + } }); } diff --git a/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts b/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts index 8fb06aa9..5c01c7c4 100644 --- a/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts +++ b/apps/frontend/src/__tests__/integration/claude-profile-ipc.test.ts @@ -35,6 +35,11 @@ vi.mock('electron', () => ({ BrowserWindow: vi.fn() })); +// Mock config path validator to allow test temp directories +vi.mock('../../main/utils/config-path-validator', () => ({ + isValidConfigDir: vi.fn().mockReturnValue(true), +})); + // Mock ClaudeProfileManager const mockProfileManager = { generateProfileId: vi.fn((name: string) => `profile-${name.toLowerCase().replace(/\s+/g, '-')}`), @@ -236,176 +241,18 @@ describe('Claude Profile IPC Integration', () => { }); }); - describe('CLAUDE_PROFILE_INITIALIZE', () => { - beforeEach(() => { - // Reset terminal manager mock - mockTerminalManager.create.mockResolvedValue({ success: true }); - mockTerminalManager.write.mockReturnValue(undefined); - }); - - it('should create terminal and run claude setup-token for non-default profile', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - const profile = createTestProfile({ - id: 'test-profile', - name: 'Test Profile', - isDefault: false, - configDir: path.join(TEST_DIR, 'test-config') - }); - - mockProfileManager.getProfile.mockReturnValue(profile); - - const result = await handleProfileInit!(null, 'test-profile') as IPCResult; - - expect(result.success).toBe(true); - expect(mockProfileManager.getProfile).toHaveBeenCalledWith('test-profile'); - expect(mockTerminalManager.create).toHaveBeenCalled(); - - const createCall = mockTerminalManager.create.mock.calls[0][0] as TerminalCreateOptions; - expect(createCall.id).toMatch(/^claude-login-test-profile-/); - }); - - it('should write claude setup-token command with CLAUDE_CONFIG_DIR for non-default profile', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - const profile = createTestProfile({ - id: 'test-profile', - name: 'Test Profile', - isDefault: false, - configDir: path.join(TEST_DIR, 'test-config') - }); - - mockProfileManager.getProfile.mockReturnValue(profile); - - await handleProfileInit!(null, 'test-profile'); - - expect(mockTerminalManager.write).toHaveBeenCalled(); - - const writeCall = mockTerminalManager.write.mock.calls[0]; - const command = writeCall[1] as string; - - expect(command).toContain('CLAUDE_CONFIG_DIR'); - expect(command).toContain('setup-token'); - }); - - it('should write simple claude setup-token command for default profile', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - const profile = createTestProfile({ - id: 'default', - name: 'Default', - isDefault: true - }); - - mockProfileManager.getProfile.mockReturnValue(profile); - - await handleProfileInit!(null, 'default'); - - expect(mockTerminalManager.write).toHaveBeenCalled(); - - const writeCall = mockTerminalManager.write.mock.calls[0]; - const command = writeCall[1] as string; - - expect(command).not.toContain('CLAUDE_CONFIG_DIR'); - expect(command).toContain('setup-token'); - }); - - it('should send TERMINAL_AUTH_CREATED event after creating terminal', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - const profile = createTestProfile({ - id: 'test-profile', - name: 'Test Profile' - }); - - mockProfileManager.getProfile.mockReturnValue(profile); - - await handleProfileInit!(null, 'test-profile'); - - expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith( - 'terminal:authCreated', - expect.objectContaining({ - profileId: 'test-profile', - profileName: 'Test Profile', - terminalId: expect.stringMatching(/^claude-login-test-profile-/) - }) - ); - }); - - it('should return error if profile not found', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - mockProfileManager.getProfile.mockReturnValue(null); - - const result = await handleProfileInit!(null, 'nonexistent') as IPCResult; - - expect(result.success).toBe(false); - expect(result.error).toContain('Profile not found'); - expect(mockTerminalManager.create).not.toHaveBeenCalled(); - }); - - it('should return error if terminal creation fails', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - const profile = createTestProfile(); - mockProfileManager.getProfile.mockReturnValue(profile); - - mockTerminalManager.create.mockResolvedValueOnce({ - success: false, - error: 'Max terminals reached' - }); - - const result = await handleProfileInit!(null, 'test-profile') as IPCResult; - - expect(result.success).toBe(false); - expect(result.error).toContain('Max terminals reached'); - expect(mockTerminalManager.write).not.toHaveBeenCalled(); - }); - - it('should create config directory for non-default profile before terminal creation', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - const profile = createTestProfile({ - isDefault: false, - configDir: path.join(TEST_DIR, 'init-config') - }); - - mockProfileManager.getProfile.mockReturnValue(profile); - - await handleProfileInit!(null, 'test-profile'); - - expect(existsSync(profile.configDir!)).toBe(true); - }); - - it('should handle initialization errors gracefully', async () => { - const handleProfileInit = handlers.get('claude:profileInitialize'); - expect(handleProfileInit).toBeDefined(); - - mockProfileManager.getProfile.mockImplementationOnce(() => { - throw new Error('Internal error'); - }); - - const result = await handleProfileInit!(null, 'test-profile') as IPCResult; - - expect(result.success).toBe(false); - expect(result.error).toContain('Internal error'); - }); - }); + // Note: CLAUDE_PROFILE_INITIALIZE tests were removed. + // The handler was deprecated as part of the migration from setup-token to the + // new /login OAuth flow. Profile initialization now happens automatically + // during the /login flow in claude-code-handlers.ts. describe('IPC handler registration', () => { it('should register CLAUDE_PROFILE_SAVE handler', () => { expect(handlers.has('claude:profileSave')).toBe(true); }); - it('should register CLAUDE_PROFILE_INITIALIZE handler', () => { - expect(handlers.has('claude:profileInitialize')).toBe(true); - }); + // Note: CLAUDE_PROFILE_INITIALIZE handler was removed as part of the + // OAuth /login flow migration. Profile initialization now happens + // automatically during the /login flow in claude-code-handlers.ts }); }); diff --git a/apps/frontend/src/main/__tests__/config-path-validator.test.ts b/apps/frontend/src/main/__tests__/config-path-validator.test.ts new file mode 100644 index 00000000..215e783a --- /dev/null +++ b/apps/frontend/src/main/__tests__/config-path-validator.test.ts @@ -0,0 +1,452 @@ +/** + * Unit tests for config-path-validator.ts + * + * SECURITY-CRITICAL: These tests validate the isValidConfigDir() function + * which prevents path traversal attacks and unauthorized filesystem access. + * + * Security Model: + * ---------------- + * The validator allows ANY path within the user's home directory, including: + * - Direct home directory paths (~/ or $HOME) + * - Any subdirectory within home (~/Documents, ~/.local, etc.) + * - The .claude and .claude-profiles directories + * + * The validator rejects: + * - Paths outside home directory (/etc, /var, C:\Windows, etc.) + * - Path traversal that escapes home (~/.., ~/../../etc/passwd) + * - Paths in other users' home directories (/home/other, C:\Users\Other) + * - Attempts to access similar-named paths outside home (/home/alice-malicious when home is /home/alice) + * + * Implementation Details: + * ----------------------- + * 1. All paths are normalized using path.resolve() to handle . and .. components + * 2. Tilde (~) is expanded to the actual home directory path + * 3. The normalized path must start with one of the allowed prefixes + path separator + * 4. Boundary checks prevent attacks like /home/alice-malicious bypassing /home/alice validation + * + * Cross-Platform Testing Strategy: + * --------------------------------- + * IMPORTANT: Node.js path.resolve() is platform-aware and behaves differently on each OS: + * + * - Unix systems: Paths like "C:\Windows" are treated as RELATIVE paths because backslash + * is a valid filename character. They resolve to something like "/home/user/project/C:\Windows" + * + * - Windows systems: Paths like "C:\Windows" are recognized as ABSOLUTE paths with drive letters + * + * This means we CANNOT simply mock process.platform to test all path types on all platforms. + * The underlying path.resolve() behavior is baked into Node.js's platform-specific implementation. + * + * Our approach: + * 1. Platform-agnostic tests (Unix absolute paths starting with /) run on ALL platforms + * 2. Platform-specific tests (Windows paths with drive letters) run ONLY on their native OS + * 3. CI tests on Windows, macOS, AND Linux ensure comprehensive coverage across actual platforms + * 4. Each platform's CI run validates the security model works correctly for that OS + * + * This ensures: + * - Unix builds verify Unix paths are rejected correctly + * - Windows builds verify Windows paths are rejected correctly + * - All builds verify cross-platform logic (tilde expansion, boundary checks, etc.) + * + * Testing Considerations: + * ----------------------- + * - Relative paths (., .., ./config) resolve based on process.cwd() + * - If tests run from within home directory, relative paths may be valid + * - Empty string resolves to cwd, which may be within home + * - Platform-specific paths (Windows C:\, Unix /etc) are tested conditionally + */ + +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { isValidConfigDir } from '../utils/config-path-validator'; + +describe('isValidConfigDir - Security Validation', () => { + let originalHomedir: string; + let consoleWarnSpy: any; + + beforeEach(() => { + // Store original homedir for restoration + originalHomedir = os.homedir(); + + // Spy on console.warn to suppress warning output during tests + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restore console.warn + consoleWarnSpy.mockRestore(); + }); + + describe('Valid paths - Should ACCEPT', () => { + test('accepts paths within home directory', () => { + const homeDir = os.homedir(); + + expect(isValidConfigDir(homeDir)).toBe(true); + expect(isValidConfigDir(path.join(homeDir, 'Documents'))).toBe(true); + expect(isValidConfigDir(path.join(homeDir, 'Documents', 'configs'))).toBe(true); + expect(isValidConfigDir(path.join(homeDir, 'any', 'nested', 'path'))).toBe(true); + }); + + test('accepts tilde paths within home directory', () => { + expect(isValidConfigDir('~')).toBe(true); + expect(isValidConfigDir('~/')).toBe(true); + expect(isValidConfigDir('~/Documents')).toBe(true); + expect(isValidConfigDir('~/Documents/configs')).toBe(true); + expect(isValidConfigDir('~/any/nested/path')).toBe(true); + }); + + test('accepts ~/.claude directory', () => { + const homeDir = os.homedir(); + + expect(isValidConfigDir(path.join(homeDir, '.claude'))).toBe(true); + expect(isValidConfigDir('~/.claude')).toBe(true); + }); + + test('accepts paths within ~/.claude', () => { + const homeDir = os.homedir(); + + expect(isValidConfigDir(path.join(homeDir, '.claude', 'config'))).toBe(true); + expect(isValidConfigDir(path.join(homeDir, '.claude', 'deep', 'nested', 'path'))).toBe(true); + expect(isValidConfigDir('~/.claude/config')).toBe(true); + expect(isValidConfigDir('~/.claude/deep/nested/path')).toBe(true); + }); + + test('accepts ~/.claude-profiles directory', () => { + const homeDir = os.homedir(); + + expect(isValidConfigDir(path.join(homeDir, '.claude-profiles'))).toBe(true); + expect(isValidConfigDir('~/.claude-profiles')).toBe(true); + }); + + test('accepts paths within ~/.claude-profiles', () => { + const homeDir = os.homedir(); + + expect(isValidConfigDir(path.join(homeDir, '.claude-profiles', 'profile1'))).toBe(true); + expect(isValidConfigDir(path.join(homeDir, '.claude-profiles', 'profile2', 'config'))).toBe(true); + expect(isValidConfigDir('~/.claude-profiles/profile1')).toBe(true); + expect(isValidConfigDir('~/.claude-profiles/profile2/config')).toBe(true); + }); + + test('accepts paths with . and .. that resolve within boundaries', () => { + const homeDir = os.homedir(); + + // These paths use .. but still resolve within home directory + expect(isValidConfigDir(path.join(homeDir, '.claude', 'foo', '..', 'bar'))).toBe(true); + expect(isValidConfigDir('~/.claude/foo/../bar')).toBe(true); + + // Path that navigates but stays within bounds + expect(isValidConfigDir(path.join(homeDir, 'Documents', '..', 'Downloads'))).toBe(true); + }); + }); + + describe('Path traversal attacks - Should REJECT', () => { + test('rejects path traversal to parent of home directory', () => { + const homeDir = os.homedir(); + const parentDir = path.dirname(homeDir); + + expect(isValidConfigDir(path.join(homeDir, '..'))).toBe(false); + expect(isValidConfigDir('~/..')).toBe(false); + expect(isValidConfigDir(parentDir)).toBe(false); + }); + + test('rejects multiple parent directory traversal attempts', () => { + expect(isValidConfigDir('~/../..')).toBe(false); + expect(isValidConfigDir('~/../../..')).toBe(false); + expect(isValidConfigDir('~/.claude/../..')).toBe(false); + expect(isValidConfigDir('~/.claude-profiles/../..')).toBe(false); + }); + + test('rejects classic path traversal attack patterns', () => { + // Note: Relative paths like '../../etc/passwd' will resolve based on cwd. + // If cwd is within home, they might be valid. Test with absolute paths instead. + + // These definitely escape home directory + expect(isValidConfigDir('~/../../etc/passwd')).toBe(false); + expect(isValidConfigDir('~/.claude/../../etc/passwd')).toBe(false); + expect(isValidConfigDir('~/.claude/../../../etc/passwd')).toBe(false); + }); + + test('rejects paths that traverse beyond home directory boundaries', () => { + const homeDir = os.homedir(); + const parentOfHome = path.dirname(homeDir); + + // Try to escape using nested paths + expect(isValidConfigDir(path.join(homeDir, 'Documents', '..', '..', 'etc'))).toBe(false); + expect(isValidConfigDir(path.join(homeDir, '.claude', '..', '..', 'usr'))).toBe(false); + + // Direct parent paths + expect(isValidConfigDir(path.join(parentOfHome, 'etc'))).toBe(false); + expect(isValidConfigDir(path.join(parentOfHome, 'var'))).toBe(false); + }); + }); + + describe('Absolute paths outside home - Should REJECT', () => { + test('rejects common system directories on Unix-like systems', () => { + // These absolute Unix paths work correctly on all platforms + // because they start with / and are universally recognized as absolute + expect(isValidConfigDir('/etc')).toBe(false); + expect(isValidConfigDir('/etc/passwd')).toBe(false); + expect(isValidConfigDir('/var')).toBe(false); + expect(isValidConfigDir('/var/log')).toBe(false); + expect(isValidConfigDir('/usr')).toBe(false); + expect(isValidConfigDir('/usr/local')).toBe(false); + expect(isValidConfigDir('/tmp')).toBe(false); + expect(isValidConfigDir('/root')).toBe(false); + expect(isValidConfigDir('/opt')).toBe(false); + expect(isValidConfigDir('/bin')).toBe(false); + expect(isValidConfigDir('/sbin')).toBe(false); + }); + + test('rejects common system directories on Windows', () => { + // NOTE: Windows-style paths only work correctly when running on Windows + // On Unix, backslashes are valid filename characters, so these become + // relative paths like ./C:\Windows (which may be within home if cwd is in home) + if (process.platform === 'win32') { + expect(isValidConfigDir('C:\\Windows')).toBe(false); + expect(isValidConfigDir('C:\\Windows\\System32')).toBe(false); + expect(isValidConfigDir('C:\\Program Files')).toBe(false); + expect(isValidConfigDir('C:\\Program Files (x86)')).toBe(false); + expect(isValidConfigDir('C:\\ProgramData')).toBe(false); + expect(isValidConfigDir('D:\\Windows')).toBe(false); + } + }); + + test('rejects paths in other users home directories on Unix', () => { + // These absolute Unix paths work correctly on all platforms + expect(isValidConfigDir('/home/otheruser')).toBe(false); + expect(isValidConfigDir('/home/otheruser/.claude')).toBe(false); + expect(isValidConfigDir('/root/.claude')).toBe(false); + }); + + test('rejects paths in other users home directories on Windows', () => { + // NOTE: Windows-style paths only work correctly when running on Windows + if (process.platform === 'win32') { + expect(isValidConfigDir('C:\\Users\\OtherUser')).toBe(false); + expect(isValidConfigDir('C:\\Users\\OtherUser\\.claude')).toBe(false); + } + }); + }); + + describe('Boundary attack vectors - Should REJECT', () => { + test('rejects paths with similar prefix but wrong boundary', () => { + const homeDir = os.homedir(); + + // If homeDir is /home/alice, reject /home/alice-malicious + const similarPath = homeDir + '-malicious'; + expect(isValidConfigDir(similarPath)).toBe(false); + + // Try with subdirectory + expect(isValidConfigDir(path.join(similarPath, 'configs'))).toBe(false); + }); + + test('accepts directories with .claude prefix but validates boundaries', () => { + const homeDir = os.homedir(); + + // Note: .claude-malicious is still within home directory, so it's accepted. + // The validator allows ANY path within home, not just .claude and .claude-profiles. + // The important check is that paths like /home/alice-malicious are rejected. + const claudeLikePath = path.join(homeDir, '.claude-malicious'); + expect(isValidConfigDir(claudeLikePath)).toBe(true); + + // But paths that try to escape home boundaries are rejected + const homeDirMaliciousSuffix = homeDir + '-malicious'; + expect(isValidConfigDir(homeDirMaliciousSuffix)).toBe(false); + }); + + test('enforces path separator boundary checks', () => { + const homeDir = os.homedir(); + + // These paths have correct prefix but no separator + // The validator should only allow exact match or prefix + separator + const exactMatch = homeDir; + expect(isValidConfigDir(exactMatch)).toBe(true); + + const withSeparator = path.join(homeDir, 'subdir'); + expect(isValidConfigDir(withSeparator)).toBe(true); + + // Path that looks like home but isn't (if such path could exist) + // Example: if home is /home/user, test /home/username + const homeDirParent = path.dirname(homeDir); + const homeBasename = path.basename(homeDir); + const similarName = path.join(homeDirParent, homeBasename + 'name'); + + // Only reject if this isn't actually within our home (which it shouldn't be) + if (!similarName.startsWith(homeDir + path.sep) && similarName !== homeDir) { + expect(isValidConfigDir(similarName)).toBe(false); + } + }); + }); + + describe('Edge cases and special inputs', () => { + test('handles empty string based on cwd resolution', () => { + // Empty string resolves to cwd via path.resolve() + // If cwd is within home, it will be accepted + const result = isValidConfigDir(''); + const resolvedPath = path.resolve(''); + const homeDir = os.homedir(); + const shouldBeValid = resolvedPath === homeDir || resolvedPath.startsWith(homeDir + path.sep); + + expect(result).toBe(shouldBeValid); + }); + + test('handles paths with null bytes based on path normalization', () => { + // Node.js path module handles null bytes - test actual behavior + // These typically get stripped or cause the path to resolve to cwd + + const result1 = isValidConfigDir('~/.claude\0/../../etc/passwd'); + const result2 = isValidConfigDir('\0/etc/passwd'); + + // Just verify function doesn't crash - acceptance depends on path.resolve behavior + expect(typeof result1).toBe('boolean'); + expect(typeof result2).toBe('boolean'); + }); + + test('handles relative paths based on cwd resolution', () => { + // Relative paths resolve based on cwd + // If cwd is within home, they will be accepted + const homeDir = os.homedir(); + const cwd = process.cwd(); + const cwdInHome = cwd === homeDir || cwd.startsWith(homeDir + path.sep); + + if (cwdInHome) { + // If running from within home, these resolve to valid paths + expect(isValidConfigDir('.')).toBe(true); + expect(isValidConfigDir('./config')).toBe(true); + + // .. might escape home depending on cwd depth + const parentDir = path.resolve('..'); + const parentShouldBeValid = parentDir === homeDir || parentDir.startsWith(homeDir + path.sep); + expect(isValidConfigDir('..')).toBe(parentShouldBeValid); + } else { + // If running from outside home, these should be rejected + expect(isValidConfigDir('.')).toBe(false); + expect(isValidConfigDir('..')).toBe(false); + expect(isValidConfigDir('./config')).toBe(false); + } + }); + + test('rejects paths with excessive slashes', () => { + expect(isValidConfigDir('////etc/passwd')).toBe(false); + expect(isValidConfigDir('~/////..//..//etc')).toBe(false); + }); + + test('rejects UNC paths on Windows', () => { + // NOTE: UNC paths (\\server\share) only work correctly on Windows + // On Unix, backslashes are filename characters, making these relative paths + if (process.platform === 'win32') { + expect(isValidConfigDir('\\\\server\\share')).toBe(false); + expect(isValidConfigDir('\\\\server\\share\\config')).toBe(false); + } + }); + + test('rejects paths with mixed separators on Windows', () => { + // NOTE: Mixed separator detection only works correctly on Windows + if (process.platform === 'win32') { + expect(isValidConfigDir('C:/Windows\\System32')).toBe(false); + expect(isValidConfigDir('~\\..\\/etc')).toBe(false); + } + }); + }); + + describe('Console warning output', () => { + test('logs warning for rejected paths', () => { + isValidConfigDir('/etc/passwd'); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[Config Path Validator] Rejected unsafe configDir path:', + '/etc/passwd', + '(normalized:', + expect.any(String), + ')' + ); + }); + + test('does not log warning for accepted paths', () => { + consoleWarnSpy.mockClear(); + + isValidConfigDir('~/.claude'); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Cross-platform compatibility', () => { + test('handles platform-specific path separators correctly', () => { + const homeDir = os.homedir(); + + // Use platform-appropriate path construction + const validPath = path.join(homeDir, '.claude', 'config'); + expect(isValidConfigDir(validPath)).toBe(true); + + // Tilde expansion should work on all platforms + expect(isValidConfigDir('~/.claude/config')).toBe(true); + }); + + test('normalizes paths consistently across platforms', () => { + const homeDir = os.homedir(); + + // Test that normalization works correctly + const pathWithDots = path.join(homeDir, '.claude', 'foo', '.', 'bar'); + const normalizedPath = path.join(homeDir, '.claude', 'foo', 'bar'); + + // Both should be valid if they resolve within boundaries + expect(isValidConfigDir(pathWithDots)).toBe(true); + expect(isValidConfigDir(normalizedPath)).toBe(true); + }); + }); + + describe('Real-world attack scenarios', () => { + test('prevents symbolic link style attacks via path traversal', () => { + // Attacker tries to use .. to reach /etc after appearing to be in home + expect(isValidConfigDir('~/.claude/../../../../../etc/passwd')).toBe(false); + }); + + test('prevents encoded path traversal attempts', () => { + // Some systems might decode %2e%2e to .. + // The validator should work with the already-decoded path + expect(isValidConfigDir('~/../etc/passwd')).toBe(false); + }); + + test('prevents Windows drive letter hopping', () => { + // NOTE: Windows drive letters only work correctly on Windows + if (process.platform === 'win32') { + expect(isValidConfigDir('D:\\sensitive-data')).toBe(false); + expect(isValidConfigDir('E:\\other-drive')).toBe(false); + } + }); + + test('prevents access to sensitive config directories', () => { + // Unix absolute paths work correctly on all platforms + expect(isValidConfigDir('/etc/ssh')).toBe(false); + expect(isValidConfigDir('/etc/ssl')).toBe(false); + expect(isValidConfigDir('/etc/security')).toBe(false); + + // Windows paths only work correctly on Windows + if (process.platform === 'win32') { + expect(isValidConfigDir('C:\\Windows\\System32\\config')).toBe(false); + } + }); + }); + + describe('Tilde expansion behavior', () => { + test('expands tilde to home directory before validation', () => { + const homeDir = os.homedir(); + + // These should be equivalent + expect(isValidConfigDir('~/.claude')).toBe(isValidConfigDir(path.join(homeDir, '.claude'))); + expect(isValidConfigDir('~/Documents')).toBe(isValidConfigDir(path.join(homeDir, 'Documents'))); + }); + + test('handles tilde at start of path only', () => { + // Tilde in middle should not expand + const weirdPath = '/some/path/~/config'; + expect(isValidConfigDir(weirdPath)).toBe(false); + }); + + test('handles tilde with following slash correctly', () => { + expect(isValidConfigDir('~/')).toBe(true); + expect(isValidConfigDir('~/.')).toBe(true); + expect(isValidConfigDir('~/.claude')).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts index 3f310d5d..0454c4e1 100644 --- a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts +++ b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts @@ -42,21 +42,28 @@ vi.mock('../project-store', () => ({ })); vi.mock('child_process', () => { - const mockExecFile = vi.fn((cmd: any, args: any, options: any, callback: any) => { - // Return a minimal ChildProcess-like object - const childProcess = { - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn() - }; + const mockExecFile = vi.fn( + ( + _cmd: string, + _args: string[], + _options: Record, + callback?: (error: Error | null, stdout: string, stderr: string) => void + ) => { + // Return a minimal ChildProcess-like object + const childProcess = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn() + }; - // If callback is provided, call it asynchronously - if (typeof callback === 'function') { - setImmediate(() => callback(null, '', '')); + // If callback is provided, call it asynchronously + if (typeof callback === 'function') { + setImmediate(() => callback(null, '', '')); + } + + return childProcess as unknown; } - - return childProcess as any; - }); + ); return { spawn: spawnMock, diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 452c0a2c..5ef0083e 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -142,10 +142,17 @@ export class ClaudeProfileManager { /** * Get all profiles and settings + * Computes isAuthenticated for each profile by checking configDir credentials */ getSettings(): ClaudeProfileSettings { + // Compute isAuthenticated for each profile + const profilesWithAuth = this.data.profiles.map(profile => ({ + ...profile, + isAuthenticated: this.isProfileAuthenticated(profile) || hasValidToken(profile) + })); + return { - profiles: this.data.profiles, + profiles: profilesWithAuth, activeProfileId: this.data.activeProfileId, autoSwitch: this.data.autoSwitch || DEFAULT_AUTO_SWITCH_SETTINGS }; @@ -355,21 +362,39 @@ export class ClaudeProfileManager { /** * Get environment variables for spawning processes with the active profile. - * Returns { CLAUDE_CODE_OAUTH_TOKEN: token } if token is available (decrypted). + * Sets CLAUDE_CONFIG_DIR to point Claude CLI to the profile's config directory. + * Claude CLI handles token storage in the system Keychain. + * + * IMPORTANT: When CLAUDE_CONFIG_DIR is set, we do NOT set CLAUDE_CODE_OAUTH_TOKEN + * because Claude Code prioritizes CLAUDE_CODE_OAUTH_TOKEN over Keychain lookup. + * The OAuth token alone doesn't contain subscription tier info (like "max"), + * causing Claude Code to show "Claude API" instead of "Claude Max". + * By only setting CLAUDE_CONFIG_DIR, Claude Code reads from the Keychain which + * has the full credential object including subscriptionType and rateLimitTier. */ getActiveProfileEnv(): Record { const profile = this.getActiveProfile(); const env: Record = {}; - if (profile?.oauthToken) { - // Decrypt the token before putting in environment + // For non-default profiles, set CLAUDE_CONFIG_DIR + // Claude CLI will use credentials stored in that directory's Keychain + if (profile?.configDir && !profile.isDefault) { + // Expand ~ to home directory for the environment variable + const expandedConfigDir = profile.configDir.startsWith('~') + ? profile.configDir.replace(/^~/, require('os').homedir()) + : profile.configDir; + env.CLAUDE_CONFIG_DIR = expandedConfigDir; + console.warn('[ClaudeProfileManager] Using configDir for profile:', profile.name, expandedConfigDir); + // DO NOT set CLAUDE_CODE_OAUTH_TOKEN here - let Claude Code use Keychain + // credentials which include subscription tier info. See comment above. + } else if (profile?.oauthToken) { + // Only use stored OAuth token for default profile (no configDir) + // This is a legacy path for backward compatibility const decryptedToken = decryptToken(profile.oauthToken); if (decryptedToken) { env.CLAUDE_CODE_OAUTH_TOKEN = decryptedToken; + console.warn('[ClaudeProfileManager] Using stored OAuth token for profile:', profile.name); } - } else if (profile?.configDir && !profile.isDefault) { - // Fallback to configDir for backward compatibility - env.CLAUDE_CONFIG_DIR = profile.configDir; } return env; diff --git a/apps/frontend/src/main/claude-profile/keychain-utils.ts b/apps/frontend/src/main/claude-profile/keychain-utils.ts new file mode 100644 index 00000000..d4d70fc6 --- /dev/null +++ b/apps/frontend/src/main/claude-profile/keychain-utils.ts @@ -0,0 +1,251 @@ +/** + * macOS Keychain Utilities + * + * Provides functions to retrieve Claude Code OAuth tokens and email from macOS Keychain. + * Supports both: + * - Default profile: "Claude Code-credentials" service + * - Custom profiles: "Claude Code-credentials-{sha256-8-hash}" where hash is first 8 chars + * of SHA256 hash of the CLAUDE_CONFIG_DIR path + * + * Mirrors the functionality of apps/backend/core/auth.py get_token_from_keychain() + */ + +import { execFileSync } from 'child_process'; +import { createHash } from 'crypto'; +import { existsSync } from 'fs'; +import { isMacOS } from '../platform'; + +/** + * Credentials retrieved from macOS Keychain + */ +export interface KeychainCredentials { + token: string | null; + email: string | null; + error?: string; // Set when keychain access fails (locked, permission denied, etc.) +} + +/** + * Cache for keychain credentials to avoid repeated blocking calls + * Map key is the service name (e.g., "Claude Code-credentials" or "Claude Code-credentials-d74c9506") + */ +interface KeychainCacheEntry { + credentials: KeychainCredentials; + timestamp: number; +} + +const keychainCache = new Map(); +// Cache for 5 minutes (300,000 ms) for successful results +const CACHE_TTL_MS = 5 * 60 * 1000; +// Cache for 10 seconds for error results (allows quick retry after keychain unlock) +const ERROR_CACHE_TTL_MS = 10 * 1000; + +/** + * Calculate the Keychain service name suffix for a config directory. + * Claude Code uses SHA256 hash of the config dir path, taking first 8 hex chars. + * + * @param configDir - The CLAUDE_CONFIG_DIR path + * @returns The 8-character hex hash suffix + */ +export function calculateConfigDirHash(configDir: string): string { + return createHash('sha256').update(configDir).digest('hex').slice(0, 8); +} + +/** + * Get the Keychain service name for a config directory. + * + * @param configDir - Optional CLAUDE_CONFIG_DIR path. If not provided, returns default service name. + * @returns The Keychain service name (e.g., "Claude Code-credentials-d74c9506") + */ +export function getKeychainServiceName(configDir?: string): string { + if (!configDir) { + return 'Claude Code-credentials'; + } + const hash = calculateConfigDirHash(configDir); + return `Claude Code-credentials-${hash}`; +} + +/** + * Validate the structure of parsed Keychain JSON data + * @param data - Parsed JSON data from Keychain + * @returns true if data structure is valid, false otherwise + */ +function validateKeychainData(data: unknown): data is { claudeAiOauth?: { accessToken?: string; email?: string }; email?: string } { + if (!data || typeof data !== 'object') { + return false; + } + + const obj = data as Record; + + // Check if claudeAiOauth exists and is an object + if (obj.claudeAiOauth !== undefined) { + if (typeof obj.claudeAiOauth !== 'object' || obj.claudeAiOauth === null) { + return false; + } + const oauth = obj.claudeAiOauth as Record; + // Validate accessToken if present + if (oauth.accessToken !== undefined && typeof oauth.accessToken !== 'string') { + return false; + } + // Validate email if present + if (oauth.email !== undefined && typeof oauth.email !== 'string') { + return false; + } + } + + // Validate top-level email if present + if (obj.email !== undefined && typeof obj.email !== 'string') { + return false; + } + + return true; +} + +/** + * Retrieve Claude Code OAuth credentials (token and email) from macOS Keychain. + * + * For default profile: reads from "Claude Code-credentials" + * For custom profiles: reads from "Claude Code-credentials-{hash}" where hash is + * SHA256(configDir).slice(0,8) + * + * Uses caching (5-minute TTL) to avoid repeated blocking calls. + * Only works on macOS (Darwin platform). + * + * @param configDir - Optional CLAUDE_CONFIG_DIR path for custom profiles + * @param forceRefresh - Set to true to bypass cache and fetch fresh credentials + * @returns Object with token and email (both may be null if not found or invalid) + */ +export function getCredentialsFromKeychain(configDir?: string, forceRefresh = false): KeychainCredentials { + // Only attempt on macOS + if (!isMacOS()) { + return { token: null, email: null }; + } + + const serviceName = getKeychainServiceName(configDir); + + // Return cached credentials if available and fresh + const now = Date.now(); + const cached = keychainCache.get(serviceName); + if (!forceRefresh && cached && (now - cached.timestamp) < CACHE_TTL_MS) { + return cached.credentials; + } + + // Locate the security executable using platform abstraction + let securityPath: string | null = null; + try { + // The 'security' command is macOS-specific and typically in /usr/bin + // Try common macOS locations instead of hardcoding + const candidatePaths = ['/usr/bin/security', '/bin/security']; + + for (const candidate of candidatePaths) { + if (existsSync(candidate)) { + securityPath = candidate; + break; + } + } + + if (!securityPath) { + // Security command not found - this is expected on non-macOS or if security is missing + const notFoundResult = { token: null, email: null, error: 'macOS security command not found' }; + keychainCache.set(serviceName, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[KeychainUtils] Failed to locate security executable:', errorMessage); + const errorResult = { token: null, email: null, error: `Failed to locate security executable: ${errorMessage}` }; + keychainCache.set(serviceName, { credentials: errorResult, timestamp: now - (CACHE_TTL_MS - ERROR_CACHE_TTL_MS) }); + return errorResult; + } + + try { + // Query macOS Keychain for Claude Code credentials + // Use execFileSync with argument array to prevent command injection + const result = execFileSync( + securityPath, + ['find-generic-password', '-s', serviceName, '-w'], + { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + } + ); + + const credentialsJson = result.trim(); + if (!credentialsJson) { + const emptyResult = { token: null, email: null }; + keychainCache.set(serviceName, { credentials: emptyResult, timestamp: now }); + return emptyResult; + } + + // Parse JSON response + let data: unknown; + try { + data = JSON.parse(credentialsJson); + } catch { + console.warn('[KeychainUtils] Failed to parse Keychain JSON for service:', serviceName); + const errorResult = { token: null, email: null }; + keychainCache.set(serviceName, { credentials: errorResult, timestamp: now }); + return errorResult; + } + + // Validate JSON structure + if (!validateKeychainData(data)) { + console.warn('[KeychainUtils] Invalid Keychain data structure for service:', serviceName); + const invalidResult = { token: null, email: null }; + keychainCache.set(serviceName, { credentials: invalidResult, timestamp: now }); + return invalidResult; + } + + // Extract OAuth token from nested structure + const token = data?.claudeAiOauth?.accessToken; + + // Extract email (might be in different locations depending on Claude Code version) + const email = data?.claudeAiOauth?.email || data?.email || null; + + // Validate token format if present + // Use 'sk-ant-' prefix instead of 'sk-ant-oat01-' to support future token format versions + // (e.g., oat02, oat03, etc.) without breaking validation + if (token && !token.startsWith('sk-ant-')) { + console.warn('[KeychainUtils] Invalid token format for service:', serviceName); + const result = { token: null, email }; + keychainCache.set(serviceName, { credentials: result, timestamp: now }); + return result; + } + + const credentials = { token: token || null, email }; + keychainCache.set(serviceName, { credentials, timestamp: now }); + console.debug('[KeychainUtils] Retrieved credentials from Keychain for service:', serviceName, { hasToken: !!token, hasEmail: !!email }); + return credentials; + } catch (error) { + // Check for exit code 44 (errSecItemNotFound) which indicates item not found + if (error && typeof error === 'object' && 'status' in error && error.status === 44) { + // Item not found - this is expected if user hasn't authenticated yet + const notFoundResult = { token: null, email: null }; + keychainCache.set(serviceName, { credentials: notFoundResult, timestamp: now }); + return notFoundResult; + } + + // Other errors (keychain locked, access denied, etc.) - return error details + const errorMessage = error instanceof Error ? error.message : String(error); + console.warn('[KeychainUtils] Keychain access failed for service:', serviceName, errorMessage); + const errorResult = { token: null, email: null, error: `Keychain access failed: ${errorMessage}` }; + // Use shorter TTL for errors so users who unlock keychain see quick recovery + keychainCache.set(serviceName, { credentials: errorResult, timestamp: now - (CACHE_TTL_MS - ERROR_CACHE_TTL_MS) }); + return errorResult; + } +} + +/** + * Clear the keychain credentials cache for a specific service or all services. + * Useful when you know the credentials have changed (e.g., after running claude /login) + * + * @param configDir - Optional config dir to clear cache for specific profile. If not provided, clears all. + */ +export function clearKeychainCache(configDir?: string): void { + if (configDir) { + const serviceName = getKeychainServiceName(configDir); + keychainCache.delete(serviceName); + } else { + keychainCache.clear(); + } +} diff --git a/apps/frontend/src/main/claude-profile/session-utils.ts b/apps/frontend/src/main/claude-profile/session-utils.ts new file mode 100644 index 00000000..fae29e2d --- /dev/null +++ b/apps/frontend/src/main/claude-profile/session-utils.ts @@ -0,0 +1,210 @@ +/** + * Session Utilities + * + * Handles Claude Code session migration between profiles. + * Sessions are stored in CLAUDE_CONFIG_DIR/projects/{cwd-path-hash}/{session-id}.jsonl + * and can be copied between profiles to enable session continuity after profile switches. + */ + +import { existsSync, mkdirSync, copyFileSync, cpSync, unlinkSync } from 'fs'; +import { join, dirname } from 'path'; +import { homedir } from 'os'; +import { isNodeError } from '../utils/type-guards'; + +/** + * Convert a working directory path to the Claude projects path format. + * Claude uses a sanitized path format: /Users/foo/bar -> -Users-foo-bar + * + * LIMITATION: This function has a known collision risk where paths containing dashes + * can collide with paths using directory separators. For example: + * - '/foo/bar-baz' -> 'foo-bar-baz' + * - '/foo/bar/baz' -> 'foo-bar-baz' + * + * This behavior matches Claude CLI's existing convention for compatibility and is + * accepted as a low-probability edge case in typical project directory structures. + * + * @param cwd - The working directory path to convert + * @returns The sanitized path format used by Claude for project identification + */ +export function cwdToProjectPath(cwd: string): string { + // Normalize to forward slashes first (cross-platform: Windows C:\foo\bar -> C:/foo/bar) + const normalized = cwd.replace(/\\/g, '/'); + // Remove Windows drive letter (C:, D:, etc.) to avoid colons in directory names + // Then replace all path separators with dashes (keeping leading dash for Unix paths) + return normalized.replace(/^[a-zA-Z]:/, '').replace(/\//g, '-'); +} + +/** + * Get the full path to a session file for a given profile config directory. + * + * @param configDir - The profile's CLAUDE_CONFIG_DIR path + * @param cwd - The working directory where the session was created + * @param sessionId - The session UUID + * @returns Full path to the session .jsonl file + */ +export function getSessionFilePath(configDir: string, cwd: string, sessionId: string): string { + const expandedConfigDir = configDir.startsWith('~') + ? configDir.replace(/^~/, homedir()) + : configDir; + + const projectPath = cwdToProjectPath(cwd); + return join(expandedConfigDir, 'projects', projectPath, `${sessionId}.jsonl`); +} + +/** + * Get the full path to a session's tool-results directory. + * + * @param configDir - The profile's CLAUDE_CONFIG_DIR path + * @param cwd - The working directory where the session was created + * @param sessionId - The session UUID + * @returns Full path to the session directory (contains tool-results/) + */ +export function getSessionDirPath(configDir: string, cwd: string, sessionId: string): string { + const expandedConfigDir = configDir.startsWith('~') + ? configDir.replace(/^~/, homedir()) + : configDir; + + const projectPath = cwdToProjectPath(cwd); + return join(expandedConfigDir, 'projects', projectPath, sessionId); +} + +/** + * Result of a session migration operation + */ +export interface SessionMigrationResult { + success: boolean; + sessionId: string; + sourceProfile: string; + targetProfile: string; + filesCopied: number; + error?: string; +} + +/** + * Migrate a Claude Code session from one profile to another. + * + * This copies the session .jsonl file and any associated tool-results directory + * from the source profile's config directory to the target profile's config directory. + * + * After migration, the session can be resumed with the target profile's credentials + * using `claude --resume {sessionId}`. + * + * @param sourceConfigDir - Source profile's CLAUDE_CONFIG_DIR + * @param targetConfigDir - Target profile's CLAUDE_CONFIG_DIR + * @param cwd - Working directory where the session was created + * @param sessionId - The session UUID to migrate + * @returns Migration result with success status and details + */ +export function migrateSession( + sourceConfigDir: string, + targetConfigDir: string, + cwd: string, + sessionId: string +): SessionMigrationResult { + const result: SessionMigrationResult = { + success: false, + sessionId, + sourceProfile: sourceConfigDir, + targetProfile: targetConfigDir, + filesCopied: 0 + }; + + // Get source and target paths (declared outside try block for error cleanup) + const sourceFile = getSessionFilePath(sourceConfigDir, cwd, sessionId); + const targetFile = getSessionFilePath(targetConfigDir, cwd, sessionId); + const sourceDir = getSessionDirPath(sourceConfigDir, cwd, sessionId); + const targetDir = getSessionDirPath(targetConfigDir, cwd, sessionId); + + try { + // Ensure target directory exists (do this first, before any file operations) + const targetParentDir = dirname(targetFile); + mkdirSync(targetParentDir, { recursive: true }); + console.warn('[SessionUtils] Ensured target directory exists:', targetParentDir); + + // Attempt to copy the session .jsonl file + // This will throw if source doesn't exist or target cannot be written + try { + copyFileSync(sourceFile, targetFile); + result.filesCopied++; + console.warn('[SessionUtils] Copied session file:', sourceFile, '->', targetFile); + } catch (copyError) { + // Check common error cases for better error messages + if (isNodeError(copyError)) { + if (copyError.code === 'ENOENT') { + result.error = `Source session file not found: ${sourceFile}`; + } else if (copyError.code === 'EEXIST') { + // Target already exists - this is OK, treat as successful skip + console.warn('[SessionUtils] Session already exists in target profile, skipping copy'); + result.success = true; + result.filesCopied = 0; + return result; + } else { + result.error = `Failed to copy session file: ${copyError.message}`; + } + } else if (copyError instanceof Error) { + result.error = `Failed to copy session file: ${copyError.message}`; + } else { + result.error = 'Unknown error copying session file'; + } + console.warn('[SessionUtils] Migration failed:', result.error); + return result; + } + + // Attempt to copy the session directory (tool-results) if it exists + // Use try-catch instead of existsSync to avoid TOCTOU race + try { + cpSync(sourceDir, targetDir, { recursive: true }); + result.filesCopied++; + console.warn('[SessionUtils] Copied session directory:', sourceDir, '->', targetDir); + } catch (dirCopyError) { + // If source directory doesn't exist, that's fine - not all sessions have tool-results + if (isNodeError(dirCopyError) && dirCopyError.code === 'ENOENT') { + console.warn('[SessionUtils] No session directory to copy (this is normal):', sourceDir); + } else { + // Other errors are real problems, but we already copied the main file + // Log the error but continue (partial success) + console.warn('[SessionUtils] Warning: Failed to copy session directory:', + dirCopyError instanceof Error ? dirCopyError.message : 'Unknown error'); + } + } + + result.success = true; + console.warn('[SessionUtils] Session migration successful:', { + sessionId, + filesCopied: result.filesCopied + }); + + return result; + } catch (error) { + result.error = error instanceof Error ? error.message : 'Unknown error during migration'; + console.error('[SessionUtils] Migration error:', result.error); + + // Clean up partially migrated session file to enable retry + // Use try-catch instead of existsSync to avoid TOCTOU race + try { + unlinkSync(targetFile); + console.warn('[SessionUtils] Cleaned up partial migration file:', targetFile); + } catch (cleanupError) { + // If file doesn't exist during cleanup, that's fine + if (!(isNodeError(cleanupError) && cleanupError.code === 'ENOENT')) { + console.error('[SessionUtils] Failed to cleanup partial migration:', + cleanupError instanceof Error ? cleanupError.message : 'Unknown cleanup error'); + } + } + + return result; + } +} + +/** + * Check if a session exists in a profile's config directory. + * + * @param configDir - The profile's CLAUDE_CONFIG_DIR path + * @param cwd - The working directory where the session was created + * @param sessionId - The session UUID to check + * @returns true if the session file exists + */ +export function sessionExists(configDir: string, cwd: string, sessionId: string): boolean { + const sessionFile = getSessionFilePath(configDir, cwd, sessionId); + return existsSync(sessionFile); +} diff --git a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts index 3116b644..ec050bfa 100644 --- a/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts @@ -9,7 +9,8 @@ import { ipcMain } from 'electron'; import { exec, execFileSync, spawn, execFile } from 'child_process'; -import { existsSync, promises as fsPromises } from 'fs'; +import { existsSync, readFileSync, promises as fsPromises } from 'fs'; +import { mkdir, rename, unlink } from 'fs/promises'; import path from 'path'; import os from 'os'; import { promisify } from 'util'; @@ -19,6 +20,8 @@ import type { ClaudeCodeVersionInfo, ClaudeInstallationList, ClaudeInstallationI import { getToolInfo, configureTools, sortNvmVersionDirs, getClaudeDetectionPaths, type ExecFileAsyncOptionsWithVerbatim } from '../cli-tool-manager'; import { readSettingsFile, writeSettingsFile } from '../settings-utils'; import { isSecurePath } from '../utils/windows-paths'; +import { getClaudeProfileManager } from '../claude-profile-manager'; +import { isValidConfigDir } from '../utils/config-path-validator'; import semver from 'semver'; const execFileAsync = promisify(execFile); @@ -340,10 +343,16 @@ function getInstallCommand(isUpdate: boolean): string { } /** - * Escape single quotes in a string for use in AppleScript + * Escape a string for use inside AppleScript double-quoted strings. + * In AppleScript: + * - Backslashes must be escaped: \ → \\ + * - Double quotes must be escaped: " → \" + * - Single quotes do NOT need escaping inside double-quoted strings */ export function escapeAppleScriptString(str: string): string { - return str.replace(/'/g, "'\\''"); + return str + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/"/g, '\\"'); // Escape double quotes } /** @@ -381,6 +390,18 @@ export function escapeGitBashCommand(str: string): string { .replace(/!/g, '\\!'); // Escape exclamation marks (history expansion) } +/** + * Escape a string for safe use in bash -c context (Linux terminals). + * Uses the same escaping rules as escapeGitBashCommand for consistency. + * Defense-in-depth: Currently all commands come from trusted sources (getInstallCommand, + * getInstallVersionCommand), but this prevents potential command injection if future + * code adds new call sites with less controlled input. + */ +export function escapeBashCommand(str: string): string { + // Reuse the same escaping logic as Git Bash + return escapeGitBashCommand(str); +} + /** * Open a terminal with the given command * Uses the user's preferred terminal from settings @@ -391,8 +412,8 @@ export async function openTerminalWithCommand(command: string): Promise { const settings = readSettingsFile(); const preferredTerminal = settings?.preferredTerminal as string | undefined; - console.log('[Claude Code] Platform:', platform); - console.log('[Claude Code] Preferred terminal:', preferredTerminal); + console.warn('[Claude Code] Platform:', platform); + console.warn('[Claude Code] Preferred terminal:', preferredTerminal); if (platform === 'darwin') { // macOS: Use AppleScript to open terminal with command @@ -403,18 +424,30 @@ export async function openTerminalWithCommand(command: string): Promise { // Values come from settings.preferredTerminal (SupportedTerminal type) const terminalId = preferredTerminal?.toLowerCase() || 'terminal'; - console.log('[Claude Code] Using terminal:', terminalId); + console.warn('[Claude Code] Using terminal:', terminalId); if (terminalId === 'iterm2') { - // iTerm2 + // iTerm2 - handle both running and not-running cases to prevent double windows script = ` - tell application "iTerm" - activate - create window with default profile - tell current session of current window - write text "${escapedCommand}" + if application "iTerm" is running then + tell application "iTerm" + create window with default profile + tell current session of current window + write text "${escapedCommand}" + end tell + activate end tell - end tell + else + tell application "iTerm" + activate + end tell + delay 0.5 + tell application "iTerm" + tell current session of current window + write text "${escapedCommand}" + end tell + end tell + end if `; } else if (terminalId === 'warp') { // Warp - open and send command @@ -488,7 +521,7 @@ export async function openTerminalWithCommand(command: string): Promise { `; } - console.log('[Claude Code] Running AppleScript...'); + console.warn('[Claude Code] Running AppleScript...'); execFileSync('osascript', ['-e', script], { stdio: 'pipe' }); } else if (platform === 'win32') { @@ -497,14 +530,14 @@ export async function openTerminalWithCommand(command: string): Promise { // 'gitbash', 'alacritty', 'wezterm', 'hyper', 'tabby', 'cygwin', 'msys2' const terminalId = preferredTerminal?.toLowerCase() || 'powershell'; - console.log('[Claude Code] Using terminal:', terminalId); - console.log('[Claude Code] Command to run:', command); + console.warn('[Claude Code] Using terminal:', terminalId); + console.warn('[Claude Code] Command to run:', command); // For Windows, use exec with a properly formed command string // This is more reliable than spawn for complex PowerShell commands with pipes const runWindowsCommand = (cmdString: string): Promise => { return new Promise((resolve) => { - console.log(`[Claude Code] Executing: ${cmdString}`); + console.warn(`[Claude Code] Executing: ${cmdString}`); // Fire and forget - don't wait for the terminal to close // The -NoExit flag keeps the terminal open, so we can't wait for exec to complete const child = exec(cmdString, { windowsHide: false }); @@ -592,7 +625,7 @@ export async function openTerminalWithCommand(command: string): Promise { // Launch Hyper and it will pick up the shell; send command via PowerShell since Hyper // doesn't have a built-in way to run commands on startup await runWindowsCommand(`start "" "${hyperPath}"`); - console.log('[Claude Code] Hyper opened - command must be pasted manually'); + console.warn('[Claude Code] Hyper opened - command must be pasted manually'); } else { console.warn('[Claude Code] Hyper not found, falling back to PowerShell'); await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); @@ -607,7 +640,7 @@ export async function openTerminalWithCommand(command: string): Promise { if (tabbyPath) { // Tabby opens with default shell; similar to Hyper, no command line arg for running commands await runWindowsCommand(`start "" "${tabbyPath}"`); - console.log('[Claude Code] Tabby opened - command must be pasted manually'); + console.warn('[Claude Code] Tabby opened - command must be pasted manually'); } else { console.warn('[Claude Code] Tabby not found, falling back to PowerShell'); await runWindowsCommand(`start powershell -NoExit -Command "${escapedCommand}"`); @@ -663,9 +696,13 @@ export async function openTerminalWithCommand(command: string): Promise { // Values match SupportedTerminal type: 'gnometerminal', 'konsole', 'xfce4terminal', 'tilix', etc. const terminalId = preferredTerminal?.toLowerCase() || ''; - console.log('[Claude Code] Using terminal:', terminalId || 'auto-detect'); + console.warn('[Claude Code] Using terminal:', terminalId || 'auto-detect'); // Command to run (keep terminal open after execution) + // Note: Currently all commands come from trusted sources (getInstallCommand, getInstallVersionCommand), + // which return multi-statement commands with semicolons as separators. + // We do NOT escape these commands to preserve the semicolon command separators. + // If future code needs to pass user input here, that input must be pre-sanitized. const bashCommand = `${command}; exec bash`; // Try to use preferred terminal if specified @@ -742,7 +779,7 @@ export async function openTerminalWithCommand(command: string): Promise { try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); opened = true; - console.log('[Claude Code] Opened terminal:', cmd); + console.warn('[Claude Code] Opened terminal:', cmd); break; } catch { } @@ -754,6 +791,107 @@ export async function openTerminalWithCommand(command: string): Promise { } } +/** + * Result of authentication check + */ +interface AuthCheckResult { + authenticated: boolean; + email?: string; + /** The full oauthAccount data from .claude.json (if available) */ + oauthAccount?: { + emailAddress?: string; + accessToken?: string; + refreshToken?: string; + expiresAt?: string; + [key: string]: unknown; + }; +} + +/** + * Check if a profile's config directory has authentication. + * Checks multiple locations based on platform: + * - macOS: .claude.json with oauthAccount containing emailAddress + * - Linux: .credentials.json OR .claude.json (Claude uses different storage on Linux) + * - Windows: .claude.json with oauthAccount containing emailAddress + * + * Also returns the full oauthAccount data so we can update the profile token. + */ +function checkProfileAuthentication(configDir: string): AuthCheckResult { + // Validate path to prevent reading arbitrary files + if (!isValidConfigDir(configDir)) { + console.error('[Claude Code] Security: Rejected authentication check for invalid configDir:', configDir); + return { authenticated: false }; + } + + // Expand ~ to home directory + const expandedConfigDir = configDir.startsWith('~') + ? path.join(os.homedir(), configDir.slice(1)) + : configDir; + + const claudeJsonPath = path.join(expandedConfigDir, '.claude.json'); + const credentialsJsonPath = path.join(expandedConfigDir, '.credentials.json'); + + try { + // First check .claude.json (primary on macOS/Windows, also used on some Linux setups) + if (existsSync(claudeJsonPath)) { + const content = readFileSync(claudeJsonPath, 'utf-8'); + const data = JSON.parse(content); + + // Check for oauthAccount with emailAddress + if (data.oauthAccount && data.oauthAccount.emailAddress) { + return { + authenticated: true, + email: data.oauthAccount.emailAddress, + oauthAccount: data.oauthAccount + }; + } + } + + // On Linux, also check .credentials.json (Claude CLI may store tokens here) + if (process.platform === 'linux' && existsSync(credentialsJsonPath)) { + const content = readFileSync(credentialsJsonPath, 'utf-8'); + const data = JSON.parse(content); + + // .credentials.json may have different structure + // Check for claudeAiOauth or oauthAccount + if (data.claudeAiOauth) { + // Extract email from claudeAiOauth if available + const email = data.claudeAiOauth.email || data.claudeAiOauth.emailAddress; + return { + authenticated: true, + email: email, + oauthAccount: data.claudeAiOauth + }; + } + + if (data.oauthAccount && data.oauthAccount.emailAddress) { + return { + authenticated: true, + email: data.oauthAccount.emailAddress, + oauthAccount: data.oauthAccount + }; + } + + // If .credentials.json exists with any oauth-related content, consider it authenticated + if (data.accessToken || data.refreshToken || data.token) { + return { + authenticated: true, + email: undefined, // Email might not be available in this format + oauthAccount: { + accessToken: data.accessToken || data.token, + refreshToken: data.refreshToken + } + }; + } + } + + return { authenticated: false }; + } catch (error) { + console.error('[Claude Code] Error checking authentication:', error); + return { authenticated: false }; + } +} + /** * Register Claude Code IPC handlers */ @@ -763,27 +901,27 @@ export function registerClaudeCodeHandlers(): void { IPC_CHANNELS.CLAUDE_CODE_CHECK_VERSION, async (): Promise> => { try { - console.log('[Claude Code] Checking version...'); + console.warn('[Claude Code] Checking version...'); // Get installed version via cli-tool-manager let detectionResult; try { detectionResult = getToolInfo('claude'); - console.log('[Claude Code] Detection result:', JSON.stringify(detectionResult, null, 2)); + console.warn('[Claude Code] Detection result:', JSON.stringify(detectionResult, null, 2)); } catch (detectionError) { console.error('[Claude Code] Detection error:', detectionError); throw new Error(`Detection failed: ${detectionError instanceof Error ? detectionError.message : 'Unknown error'}`); } const installed = detectionResult.found ? detectionResult.version || null : null; - console.log('[Claude Code] Installed version:', installed); + console.warn('[Claude Code] Installed version:', installed); // Fetch latest version from npm let latest: string; try { - console.log('[Claude Code] Fetching latest version from npm...'); + console.warn('[Claude Code] Fetching latest version from npm...'); latest = await fetchLatestVersion(); - console.log('[Claude Code] Latest version:', latest); + console.warn('[Claude Code] Latest version:', latest); } catch (error) { console.warn('[Claude Code] Failed to fetch latest version, continuing with unknown:', error); // If we can't fetch latest, still return installed info @@ -813,7 +951,7 @@ export function registerClaudeCodeHandlers(): void { } } - console.log('[Claude Code] Check complete:', { installed, latest, isOutdated }); + console.warn('[Claude Code] Check complete:', { installed, latest, isOutdated }); return { success: true, data: { @@ -845,17 +983,17 @@ export function registerClaudeCodeHandlers(): void { try { const detectionResult = getToolInfo('claude'); isUpdate = detectionResult.found && !!detectionResult.version; - console.log('[Claude Code] Is update:', isUpdate, 'detected version:', detectionResult.version); + console.warn('[Claude Code] Is update:', isUpdate, 'detected version:', detectionResult.version); } catch { // Detection failed, assume fresh install isUpdate = false; } const command = getInstallCommand(isUpdate); - console.log('[Claude Code] Install command:', command); - console.log('[Claude Code] Opening terminal...'); + console.warn('[Claude Code] Install command:', command); + console.warn('[Claude Code] Opening terminal...'); await openTerminalWithCommand(command); - console.log('[Claude Code] Terminal opened successfully'); + console.warn('[Claude Code] Terminal opened successfully'); return { success: true, @@ -1018,5 +1156,207 @@ export function registerClaudeCodeHandlers(): void { } ); + // Authenticate Claude profile - returns terminal config for embedded terminal + // The frontend creates an embedded terminal with CLAUDE_CONFIG_DIR set, + // and the terminal ID pattern enables automatic token capture on /login + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_AUTHENTICATE, + async (_event, profileId: string): Promise> => { + try { + console.warn('[Claude Code] Authenticating profile:', profileId); + + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + + if (!profile) { + return { + success: false, + error: `Profile not found: ${profileId}` + }; + } + + // For default profile, use the default Claude config dir + const configDir = profile.configDir || '~/.claude'; + + // Validate path to prevent operations on arbitrary directories + if (!isValidConfigDir(configDir)) { + return { + success: false, + error: `Invalid config directory path: ${configDir}. Config directories must be within the user's home directory.` + }; + } + + // Ensure the config directory exists + const expandedConfigDir = configDir.startsWith('~') + ? path.join(os.homedir(), configDir.slice(1)) + : configDir; + + // Create directory if it doesn't exist + await mkdir(expandedConfigDir, { recursive: true }); + + console.warn('[Claude Code] Config directory:', expandedConfigDir); + + // Backwards compatibility: If re-authenticating an existing profile that was + // set up with the old setup-token system, we need to clear the existing + // credentials so that /login opens the browser for fresh OAuth. + // We back up the existing .claude.json to .claude.json.bak + const claudeJsonPath = path.join(expandedConfigDir, '.claude.json'); + const claudeJsonBakPath = path.join(expandedConfigDir, '.claude.json.bak'); + + // NOTE: We intentionally do NOT clean up .claude.json.bak here. + // If both files exist, we cannot assume the previous auth succeeded - the app + // may have crashed after /login wrote an incomplete .claude.json but before + // VERIFY_AUTH ran. The backup may contain valid credentials needed for rollback. + // + // Backup cleanup happens safely in two places: + // 1. VERIFY_AUTH handler (lines ~1339-1347): After confirming valid credentials + // 2. Below (lines ~1229-1231): When creating a new backup (removes old backup first) + + if (existsSync(claudeJsonPath)) { + try { + const content = readFileSync(claudeJsonPath, 'utf-8'); + const data = JSON.parse(content); + + // Check if this has OAuth credentials (old setup-token or previous /login) + if (data.oauthAccount) { + console.warn('[Claude Code] Found existing OAuth credentials, backing up for re-authentication'); + + // Remove old backup if exists + if (existsSync(claudeJsonBakPath)) { + await unlink(claudeJsonBakPath); + } + + // Backup current credentials + await rename(claudeJsonPath, claudeJsonBakPath); + console.warn('[Claude Code] Backed up .claude.json to .claude.json.bak'); + } + } catch (backupError) { + // Non-fatal: if backup fails, /login might still work or show "already logged in" + console.warn('[Claude Code] Could not backup existing credentials:', backupError); + } + } + + // Generate terminal ID with pattern: claude-login-{profileId}-{timestamp} + // This pattern is used by claude-integration-handler.ts to identify + // which profile to save captured OAuth tokens to + const terminalId = `claude-login-${profileId}-${Date.now()}`; + console.warn('[Claude Code] Generated terminal ID:', terminalId); + + return { + success: true, + data: { + terminalId, + configDir: expandedConfigDir + } + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Authentication failed:', errorMsg, error); + return { + success: false, + error: `Failed to prepare authentication: ${errorMsg}` + }; + } + } + ); + + // Verify if a profile has been authenticated + ipcMain.handle( + IPC_CHANNELS.CLAUDE_PROFILE_VERIFY_AUTH, + async (_event, profileId: string): Promise> => { + try { + console.warn('[Claude Code] Verifying auth for profile:', profileId); + + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + + if (!profile) { + return { + success: false, + error: `Profile not found: ${profileId}` + }; + } + + const configDir = profile.configDir || '~/.claude'; + const result = checkProfileAuthentication(configDir); + + console.warn('[Claude Code] Auth verification result:', result); + + // Expand configDir for backup restoration check + const expandedConfigDir = configDir.startsWith('~') + ? path.join(os.homedir(), configDir.slice(1)) + : configDir; + + const claudeJsonPath = path.join(expandedConfigDir, '.claude.json'); + const claudeJsonBakPath = path.join(expandedConfigDir, '.claude.json.bak'); + + // If NOT authenticated AND backup exists, restore the backup + // This handles cases where authentication was cancelled or failed + if (!result.authenticated && existsSync(claudeJsonBakPath)) { + try { + console.warn('[Claude Code] Authentication failed and backup exists, restoring .claude.json.bak'); + + // Remove incomplete .claude.json if it exists + if (existsSync(claudeJsonPath)) { + await unlink(claudeJsonPath); + } + + // Restore the backup + await rename(claudeJsonBakPath, claudeJsonPath); + console.warn('[Claude Code] Restored .claude.json from backup'); + } catch (restoreError) { + console.warn('[Claude Code] Failed to restore backup:', restoreError); + // Non-fatal: user can manually restore from .claude.json.bak + } + } + + // If authenticated, update the profile with the email and OAuth token + if (result.authenticated) { + profile.isAuthenticated = true; + + if (result.email) { + profile.email = result.email; + } + + // Save the OAuth token if available (critical for re-authentication) + if (result.oauthAccount?.accessToken) { + console.warn('[Claude Code] Saving OAuth token for profile:', profileId); + profileManager.setProfileToken( + profileId, + result.oauthAccount.accessToken, + result.email + ); + } else { + // No OAuth token, just save the email update + profileManager.saveProfile(profile); + } + + // Clean up backup file after successful authentication + if (existsSync(claudeJsonBakPath)) { + try { + await unlink(claudeJsonBakPath); + console.warn('[Claude Code] Cleaned up .claude.json.bak after successful auth'); + } catch (cleanupError) { + console.warn('[Claude Code] Failed to clean up backup:', cleanupError); + // Non-fatal: backup file can remain for safety + } + } + } + + return { + success: true, + data: result + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + console.error('[Claude Code] Auth verification failed:', errorMsg, error); + return { + success: false, + error: `Failed to verify authentication: ${errorMsg}` + }; + } + } + ); + console.warn('[IPC] Claude Code handlers registered'); } diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index 52ebcbf5..f1abfbc8 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -7,9 +7,11 @@ import { getUsageMonitor } from '../claude-profile/usage-monitor'; import { TerminalManager } from '../terminal-manager'; import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; -import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape'; -import { getClaudeCliInvocationAsync } from '../claude-cli-utils'; import { readSettingsFileAsync } from '../settings-utils'; +import { debugLog, debugError } from '../../shared/utils/debug-logger'; +import { migrateSession } from '../claude-profile/session-utils'; +import { DEFAULT_CLAUDE_CONFIG_DIR } from '../claude-profile/profile-utils'; +import { isValidConfigDir } from '../utils/config-path-validator'; /** @@ -140,8 +142,17 @@ export function registerTerminalHandlers( profile.id = profileManager.generateProfileId(profile.name); } - // Ensure config directory exists for non-default profiles + // Security: Validate configDir path to prevent path traversal attacks + // Only validate non-default profiles with custom configDir if (!profile.isDefault && profile.configDir) { + if (!isValidConfigDir(profile.configDir)) { + return { + success: false, + error: `Invalid config directory path: ${profile.configDir}. Config directories must be within the user's home directory.` + }; + } + + // Ensure config directory exists for non-default profiles const { mkdirSync, existsSync } = await import('fs'); if (!existsSync(profile.configDir)) { mkdirSync(profile.configDir, { recursive: true }); @@ -202,7 +213,8 @@ export function registerTerminalHandlers( async (_, profileId: string): Promise => { try { const profileManager = getClaudeProfileManager(); - const previousProfileId = profileManager.getActiveProfile().id; + const previousProfile = profileManager.getActiveProfile(); + const previousProfileId = previousProfile.id; const success = profileManager.setActiveProfile(profileId); @@ -210,27 +222,82 @@ export function registerTerminalHandlers( return { success: false, error: 'Profile not found' }; } + const newProfile = profileManager.getProfile(profileId); + // If the profile actually changed, restart Claude in active terminals // This ensures existing Claude sessions use the new profile's OAuth token const profileChanged = previousProfileId !== profileId; if (profileChanged) { - const activeTerminalIds = terminalManager.getActiveTerminalIds(); - const switchPromises: Promise[] = []; + // Get all terminal info for profile change + const terminals = terminalManager.getTerminalsForProfileChange(); + debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminals for profile change:', terminals.length); - for (const terminalId of activeTerminalIds) { - if (terminalManager.isClaudeMode(terminalId)) { - switchPromises.push( - terminalManager.switchClaudeProfile(terminalId, profileId) - .then(() => undefined) - .catch(() => undefined) + // Determine config directories for session migration + const sourceConfigDir = previousProfile.isDefault + ? DEFAULT_CLAUDE_CONFIG_DIR + : previousProfile.configDir; + const targetConfigDir = newProfile?.isDefault + ? DEFAULT_CLAUDE_CONFIG_DIR + : newProfile?.configDir; + + // Build terminal refresh info for frontend + const terminalsNeedingRefresh: Array<{ + id: string; + sessionId?: string; + sessionMigrated?: boolean; + }> = []; + + // Process each terminal + for (const terminal of terminals) { + debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Processing terminal:', { + id: terminal.id, + isClaudeMode: terminal.isClaudeMode, + claudeSessionId: terminal.claudeSessionId, + cwd: terminal.cwd + }); + + let sessionMigrated = false; + + // If terminal has an active Claude session, migrate it to new profile + if (terminal.claudeSessionId && sourceConfigDir && targetConfigDir) { + debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Migrating session:', { + sessionId: terminal.claudeSessionId, + from: sourceConfigDir, + to: targetConfigDir + }); + + const migrationResult = migrateSession( + sourceConfigDir, + targetConfigDir, + terminal.cwd, + terminal.claudeSessionId ); + + sessionMigrated = migrationResult.success; + debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Session migration result:', migrationResult); } + + // All terminals need refresh (PTY env vars can't be updated) + terminalsNeedingRefresh.push({ + id: terminal.id, + sessionId: terminal.claudeSessionId, + sessionMigrated + }); } - // Wait for all switches to complete (but don't fail the main operation if some fail) - if (switchPromises.length > 0) { - await Promise.allSettled(switchPromises); + debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Terminals needing refresh:', terminalsNeedingRefresh); + + // Notify frontend that terminals need to be refreshed + // Frontend will destroy and recreate terminals with new profile env vars + const mainWindow = getMainWindow(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.TERMINAL_PROFILE_CHANGED, { + previousProfileId, + newProfileId: profileId, + terminals: terminalsNeedingRefresh + }); + debugLog('[terminal-handlers:CLAUDE_PROFILE_SET_ACTIVE] Sent TERMINAL_PROFILE_CHANGED event to frontend'); } } @@ -259,100 +326,10 @@ export function registerTerminalHandlers( } ); - ipcMain.handle( - IPC_CHANNELS.CLAUDE_PROFILE_INITIALIZE, - async (_, profileId: string): Promise => { - try { - const profileManager = getClaudeProfileManager(); - - const profile = profileManager.getProfile(profileId); - if (!profile) { - return { success: false, error: 'Profile not found' }; - } - - // Ensure the config directory exists for non-default profiles - if (!profile.isDefault && profile.configDir) { - const { mkdirSync, existsSync } = await import('fs'); - if (!existsSync(profile.configDir)) { - mkdirSync(profile.configDir, { recursive: true }); - } - } - - // Create a terminal and run claude setup-token there - // This is needed because claude setup-token requires TTY/raw mode - const terminalId = `claude-login-${profileId}-${Date.now()}`; - const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp'; - - // Create a new terminal for the login process - const createResult = await terminalManager.create({ id: terminalId, cwd: homeDir }); - - // If terminal creation failed, return the error - if (!createResult.success) { - return { - success: false, - error: createResult.error || 'Failed to create terminal for authentication' - }; - } - - // Wait a moment for the terminal to initialize - await new Promise(resolve => setTimeout(resolve, 500)); - - // Build the login command with the profile's config dir - // Use full path to claude CLI - no need to modify PATH since we have the absolute path - let loginCommand: string; - const { command: claudeCmd } = await getClaudeCliInvocationAsync(); - - // Use the full path directly - escaping only needed for paths with spaces - const shellClaudeCmd = process.platform === 'win32' - ? `"${escapeShellArgWindows(claudeCmd)}"` - : escapeShellArg(claudeCmd); - - if (!profile.isDefault && profile.configDir) { - if (process.platform === 'win32') { - // SECURITY: Use Windows-specific escaping for cmd.exe - const escapedConfigDir = escapeShellArgWindows(profile.configDir); - // Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion - loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && ${shellClaudeCmd} setup-token`; - } else { - // SECURITY: Use POSIX escaping for bash/zsh - const escapedConfigDir = escapeShellArg(profile.configDir); - // Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion - loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && ${shellClaudeCmd} setup-token`; - } - } else { - // Simple command for default profile - just run setup-token - loginCommand = `${shellClaudeCmd} setup-token`; - } - - // Write the login command to the terminal - terminalManager.write(terminalId, `${loginCommand}\r`); - - // Notify the renderer that an auth terminal was created - // This allows the UI to display the terminal so users can see the OAuth flow - const mainWindow = getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send(IPC_CHANNELS.TERMINAL_AUTH_CREATED, { - terminalId, - profileId, - profileName: profile.name - }); - } - - return { - success: true, - data: { - terminalId, - message: `A terminal has been opened to authenticate "${profile.name}". Complete the OAuth flow in your browser, then copy the token shown in the terminal.` - } - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to initialize Claude profile' - }; - } - } - ); + // CLAUDE_PROFILE_INITIALIZE handler has been removed. + // Use CLAUDE_PROFILE_AUTHENTICATE (in claude-code-handlers.ts) instead, + // which opens a visible terminal for the user to run /login manually. + // Authentication status is checked via CLAUDE_PROFILE_VERIFY_AUTH with polling. // Set OAuth token for a profile (used when capturing from terminal or manual input) ipcMain.handle( @@ -374,6 +351,10 @@ export function registerTerminalHandlers( } ); + // TERMINAL_OAUTH_CODE_SUBMIT handler has been removed. + // The new authentication flow (CLAUDE_PROFILE_AUTHENTICATE) doesn't require + // manual code submission - the user completes OAuth directly in the browser. + // Get auto-switch settings ipcMain.handle( IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS, diff --git a/apps/frontend/src/main/rate-limit-detector.ts b/apps/frontend/src/main/rate-limit-detector.ts index e93278c9..89650766 100644 --- a/apps/frontend/src/main/rate-limit-detector.ts +++ b/apps/frontend/src/main/rate-limit-detector.ts @@ -293,8 +293,7 @@ export function getProfileEnv(profileId?: string): Record { // Fallback: Use configDir for profiles without OAuth token (legacy) if (profile.configDir) { - console.warn('[getProfileEnv] Using configDir fallback for profile:', profile.name); - console.warn('[getProfileEnv] WARNING: Profile has no OAuth token. Run "claude setup-token" and save the token to enable instant switching.'); + console.warn('[getProfileEnv] Using configDir for profile:', profile.name); return { CLAUDE_CONFIG_DIR: profile.configDir }; diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index b83012b1..5126fd60 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -21,7 +21,9 @@ import { isWindows } from '../../platform'; const escapeForRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const mockGetClaudeCliInvocation = vi.fn(); +const mockGetClaudeCliInvocationAsync = vi.fn(); const mockGetClaudeProfileManager = vi.fn(); +const mockInitializeClaudeProfileManager = vi.fn(); const mockPersistSession = vi.fn(); const mockReleaseSessionId = vi.fn(); @@ -58,10 +60,12 @@ const createMockTerminal = (overrides: Partial = {}): TerminalP vi.mock('../../claude-cli-utils', () => ({ getClaudeCliInvocation: mockGetClaudeCliInvocation, + getClaudeCliInvocationAsync: mockGetClaudeCliInvocationAsync, })); vi.mock('../../claude-profile-manager', () => ({ getClaudeProfileManager: mockGetClaudeProfileManager, + initializeClaudeProfileManager: mockInitializeClaudeProfileManager, })); vi.mock('fs', async (importOriginal) => { @@ -69,6 +73,9 @@ vi.mock('fs', async (importOriginal) => { return { ...actual, writeFileSync: vi.fn(), + promises: { + writeFile: vi.fn(), + }, }; }); @@ -291,7 +298,10 @@ describe('claude-integration-handler', () => { nowSpy.mockRestore(); }); - it('prefers the temp token flow when profile has both oauth token and config dir', async () => { + it('prefers the config dir flow when profile has both oauth token and config dir', async () => { + // The configDir method is preferred over temp-file because CLAUDE_CONFIG_DIR lets + // Claude Code read full Keychain credentials including subscriptionType ("max") and + // rateLimitTier. Using CLAUDE_CODE_OAUTH_TOKEN alone lacks tier info. const command = '/opt/claude/bin/claude'; const profileManager = { getActiveProfile: vi.fn(), @@ -311,30 +321,27 @@ describe('claude-integration-handler', () => { env: { PATH: '/opt/claude/bin:/usr/bin' }, }); mockGetClaudeProfileManager.mockReturnValue(profileManager); - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(5678); const terminal = createMockTerminal({ id: 'term-both' }); const { invokeClaude } = await import('../claude-integration-handler'); invokeClaude(terminal, '/tmp/project', 'prof-both', () => null, vi.fn()); - const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; - const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; - const tokenPrefix = path.join(tmpdir(), '.claude-token-5678-'); - const tokenExt = getTempFileExtension(platform); - expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}${escapeForRegex(tokenExt)}$`)); - expect(tokenContents).toBe(getTokenFileContent(platform, 'token-value')); + // Should NOT write a temp file - configDir is used instead + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); const written = mockWriteToPty.mock.calls[0][1] as string; - expect(written).toContain(getTempFileInvocation(platform, tokenPath)); - expect(written).toContain(getTempFileCleanup(platform, tokenPath)); + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const configDir = getConfigDirCommand(platform, '/tmp/claude-config'); + + expect(written).toContain(histPrefix); + expect(written).toContain(configDir); + expect(written).toContain(clearCmd); expect(written).toContain(getQuotedCommand(platform, command)); - expect(written).not.toContain('CLAUDE_CONFIG_DIR='); expect(profileManager.getProfile).toHaveBeenCalledWith('prof-both'); expect(mockPersistSession).toHaveBeenCalledWith(terminal); expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-both'); - - nowSpy.mockRestore(); }); it('handles missing profiles by falling back to the default command', async () => { @@ -537,6 +544,264 @@ describe('claude-integration-handler', () => { expect(() => invokeClaude(terminal, '/tmp/project', 'prof-err', () => null, vi.fn())).toThrow('disk full'); expect(mockWriteToPty).not.toHaveBeenCalled(); }); + + it('includes YOLO mode flag when dangerouslySkipPermissions is true', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn(), true); + + const written = mockWriteToPty.mock.calls[0][1] as string; + expect(written).toContain('--dangerously-skip-permissions'); + expect(terminal.dangerouslySkipPermissions).toBe(true); + }); + + it('does not include YOLO mode flag when dangerouslySkipPermissions is false', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn(), false); + + const written = mockWriteToPty.mock.calls[0][1] as string; + expect(written).not.toContain('--dangerously-skip-permissions'); + expect(terminal.dangerouslySkipPermissions).toBe(false); + }); + + it('resets terminal state on error', async () => { + mockGetClaudeCliInvocation.mockImplementation(() => { + throw new Error('CLI error'); + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ + isClaudeMode: false, + claudeProfileId: 'old-profile', + }); + + const { invokeClaude } = await import('../claude-integration-handler'); + expect(() => invokeClaude(terminal, '/tmp/project', 'new-profile', () => null, vi.fn())).toThrow('CLI error'); + + // Terminal state should be rolled back + expect(terminal.isClaudeMode).toBe(false); + expect(terminal.claudeProfileId).toBe('old-profile'); + expect(terminal.claudeSessionId).toBeUndefined(); + }); +}); + +/** + * Tests for invokeClaudeAsync() - async version with timeout protection + */ +describe('invokeClaudeAsync', () => { + beforeEach(() => { + mockGetClaudeCliInvocationAsync.mockClear(); + mockInitializeClaudeProfileManager.mockClear(); + mockPersistSession.mockClear(); + mockReleaseSessionId.mockClear(); + mockWriteToPty.mockClear(); + vi.mocked(writeFileSync).mockClear(); + }); + + describe.each(['win32', 'darwin', 'linux'] as const)('on %s', (platform) => { + beforeEach(() => { + mockPlatform(platform); + }); + + it('should invoke Claude asynchronously with default profile', async () => { + mockGetClaudeCliInvocationAsync.mockResolvedValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + await invokeClaudeAsync(terminal, '/tmp/project', undefined, () => null, vi.fn()); + + const written = mockWriteToPty.mock.calls[0][1] as string; + expect(written).toContain(buildCdCommand('/tmp/project')); + expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default'); + }); + + it('should handle profile with configDir', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-config', + name: 'Work', + isDefault: false, + configDir: '/tmp/claude-config', + })), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocationAsync.mockResolvedValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + await invokeClaudeAsync(terminal, '/tmp/project', 'prof-config', () => null, vi.fn()); + + const written = mockWriteToPty.mock.calls[0][1] as string; + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const configDir = getConfigDirCommand(platform, '/tmp/claude-config'); + + expect(written).toContain(histPrefix); + expect(written).toContain(configDir); + expect(written).toContain(clearCmd); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-config'); + }); + + it('should timeout after 10 seconds if CLI invocation hangs', async () => { + mockGetClaudeCliInvocationAsync.mockImplementation(() => + new Promise(resolve => setTimeout(() => resolve({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }), 15000)) + ); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + + await expect(invokeClaudeAsync(terminal, '/tmp/project', undefined, () => null, vi.fn())) + .rejects.toThrow('CLI invocation timeout after 10s'); + + // Terminal state should be rolled back + expect(terminal.isClaudeMode).toBe(false); + }, 12000); // Allow 12 seconds for test (10s timeout + 2s buffer) + + it('should reset terminal state on async error', async () => { + mockGetClaudeCliInvocationAsync.mockRejectedValue(new Error('Async CLI error')); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + + const terminal = createMockTerminal({ + isClaudeMode: false, + claudeProfileId: 'old-profile', + }); + + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + await expect(invokeClaudeAsync(terminal, '/tmp/project', 'new-profile', () => null, vi.fn())) + .rejects.toThrow('Async CLI error'); + + // Terminal state should be rolled back + expect(terminal.isClaudeMode).toBe(false); + expect(terminal.claudeProfileId).toBe('old-profile'); + expect(terminal.claudeSessionId).toBeUndefined(); + }); + + it('should include YOLO mode flag when dangerouslySkipPermissions is true', async () => { + mockGetClaudeCliInvocationAsync.mockResolvedValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + + const terminal = createMockTerminal(); + + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + await invokeClaudeAsync(terminal, '/tmp/project', undefined, () => null, vi.fn(), true); + + const written = mockWriteToPty.mock.calls[0][1] as string; + expect(written).toContain('--dangerously-skip-permissions'); + expect(terminal.dangerouslySkipPermissions).toBe(true); + }); + + it('should call onSessionCapture callback with correct parameters', async () => { + mockGetClaudeCliInvocationAsync.mockResolvedValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + const profileManager = { + getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), + getProfile: vi.fn(), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + mockInitializeClaudeProfileManager.mockResolvedValue(profileManager); + + const terminal = createMockTerminal(); + const mockOnSessionCapture = vi.fn(); + const startTime = Date.now(); + + const { invokeClaudeAsync } = await import('../claude-integration-handler'); + await invokeClaudeAsync(terminal, '/tmp/project', undefined, () => null, mockOnSessionCapture); + + expect(mockOnSessionCapture).toHaveBeenCalledWith( + terminal.id, + '/tmp/project', + expect.any(Number) + ); + + const capturedTime = mockOnSessionCapture.mock.calls[0][2]; + expect(capturedTime).toBeGreaterThanOrEqual(startTime); + }); + }); }); /** diff --git a/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts b/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts index 417affd2..4d2a2c92 100644 --- a/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/output-parser.test.ts @@ -249,10 +249,9 @@ describe('output-parser', () => { expect(extractEmail('No email here')).toBe(null); }); - it('returns null for "Authenticated as" with space before email', () => { - // Note: The current pattern expects email immediately after "as" - // This documents the actual behavior of the implementation - expect(extractEmail('Authenticated as user@example.com')).toBe(null); + it('extracts email from "Authenticated as" with space before email', () => { + // The pattern includes space after "as" to match Claude CLI output + expect(extractEmail('Authenticated as user@example.com')).toBe('user@example.com'); }); }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ccf6a647..84846fc6 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; +import { getCredentialsFromKeychain, clearKeychainCache } from '../claude-profile/keychain-utils'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import * as PtyManager from './pty-manager'; @@ -21,9 +22,94 @@ import type { TerminalProcess, WindowGetter, RateLimitEvent, - OAuthTokenEvent + OAuthTokenEvent, + OnboardingCompleteEvent } from './types'; +// ============================================================================ +// AUTH TERMINAL ID PATTERN CONSTANTS +// ============================================================================ + +/** + * Regular expression pattern for matching auth terminal IDs. + * Auth terminals follow the format: claude-login-{profileId}-{timestamp} + * + * Profile IDs are generated by generateProfileId() in profile-utils.ts: + * - 'default' for the default profile + * - Sanitized profile names: name.toLowerCase().replace(/[^a-z0-9]+/g, '-') + * Examples: "Work" -> "work", "My Profile" -> "my-profile" + * + * The pattern matches: + * - 'claude-login-' prefix + * - Profile ID: lowercase letters, numbers, and hyphens (non-greedy to stop at timestamp) + * - '-' separator before timestamp + * - Timestamp: 13+ digit Unix timestamp + * + * @see claude-code-handlers.ts where the ID format is generated + * @see profile-utils.ts generateProfileId() for profile ID format + */ +const AUTH_TERMINAL_ID_PATTERN = /^claude-login-([a-z0-9-]+)-(\d{13,})$/; + +/** + * Extract profile ID from an auth terminal ID. + * + * @param terminalId - Terminal ID to parse (e.g., 'claude-login-work-1737298800000') + * @returns The profile ID (e.g., 'work', 'my-profile', 'default'), or null if not an auth terminal + * + * @example + * extractProfileIdFromAuthTerminalId('claude-login-default-1737298800000') // 'default' + * extractProfileIdFromAuthTerminalId('claude-login-work-1737298800000') // 'work' + * extractProfileIdFromAuthTerminalId('claude-login-my-profile-1737298800000') // 'my-profile' + * extractProfileIdFromAuthTerminalId('regular-terminal-1') // null + */ +function extractProfileIdFromAuthTerminalId(terminalId: string): string | null { + const match = terminalId.match(AUTH_TERMINAL_ID_PATTERN); + return match ? match[1] : null; +} + +/** + * Mask email address for logging to prevent PII exposure. + * + * @param email - Email address to mask + * @returns Masked email (e.g., 'user@example.com' -> 'u***@e***.com') + * + * @example + * maskEmail('john.doe@example.com') // 'j***@e***.com' + * maskEmail('a@b.co') // 'a***@b***.co' + * maskEmail('') // '' + */ +function maskEmail(email: string | null | undefined): string { + if (!email || typeof email !== 'string') { + return ''; + } + + const atIndex = email.indexOf('@'); + if (atIndex === -1) { + // Not a valid email format, mask most of it + return email.charAt(0) + '***'; + } + + const localPart = email.substring(0, atIndex); + const domainPart = email.substring(atIndex + 1); + + // Mask local part (keep first char) + const maskedLocal = localPart.charAt(0) + '***'; + + // Mask domain part (keep first char and TLD) + const domainDotIndex = domainPart.indexOf('.'); + if (domainDotIndex === -1) { + // No TLD, just mask after first char + const maskedDomain = domainPart.charAt(0) + '***'; + return `${maskedLocal}@${maskedDomain}`; + } + + const domainName = domainPart.substring(0, domainDotIndex); + const tld = domainPart.substring(domainDotIndex); // includes the dot + const maskedDomain = domainName.charAt(0) + '***' + tld; + + return `${maskedLocal}@${maskedDomain}`; +} + function normalizePathForBash(envPath: string): string { return isWindows() ? envPath.replace(/;/g, ':') : envPath; } @@ -371,30 +457,123 @@ export function handleRateLimit( /** * Handle OAuth token detection and auto-save + * Also handles "Login successful" detection for claude /login flow */ export function handleOAuthToken( terminal: TerminalProcess, data: string, getWindow: WindowGetter ): void { + // Extract profile ID from auth terminal ID pattern (if this is an auth terminal) + const profileId = extractProfileIdFromAuthTerminalId(terminal.id); + + // First check for "Login successful" message (claude /login flow) + // This is the primary detection method since tokens aren't displayed in output + if (OutputParser.hasLoginSuccess(data) && profileId) { + console.warn('[ClaudeIntegration] Login success detected for profile:', profileId); + + const emailFromOutput = OutputParser.extractEmail(terminal.outputBuffer); + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + + if (!profile) { + console.error('[ClaudeIntegration] Profile not found for login success:', profileId); + return; + } + + // Clear Keychain cache to get fresh credentials + clearKeychainCache(profile.configDir); + + // Extract token from Keychain using the profile's configDir + const keychainCreds = getCredentialsFromKeychain(profile.configDir, true); + + // Check if there was a keychain access error (not just "not found") + if (keychainCreds.error) { + console.error('[ClaudeIntegration] Keychain access error:', keychainCreds.error); + // Don't retry on keychain failures - they won't resolve with retries + return; + } + + if (keychainCreds.token) { + // Store the token in our profile store + const success = profileManager.setProfileToken( + profileId, + keychainCreds.token, + emailFromOutput || keychainCreds.email || undefined + ); + + if (success) { + console.warn('[ClaudeIntegration] OAuth token extracted from Keychain and saved to profile:', profileId); + + // Set flag to watch for Claude's ready state (onboarding complete) + terminal.awaitingOnboardingComplete = true; + + const win = getWindow(); + if (win) { + // needsOnboarding: true tells the UI to show "complete setup" message + // instead of "success" - user should finish Claude's onboarding before closing + win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email: emailFromOutput || keychainCreds.email || profile?.email, + success: true, + needsOnboarding: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); + } + } else { + console.error('[ClaudeIntegration] Failed to save Keychain token to profile:', profileId); + } + } else { + // Token not in Keychain yet, but profile may still be authenticated via configDir + // Check if profile has valid auth (credentials exist in configDir) + const hasCredentials = profileManager.hasValidAuth(profileId); + + if (hasCredentials) { + console.warn('[ClaudeIntegration] Profile credentials verified (no Keychain token):', profileId); + + // Set flag to watch for Claude's ready state (onboarding complete) + terminal.awaitingOnboardingComplete = true; + + const win = getWindow(); + if (win) { + // needsOnboarding: true tells the UI to show "complete setup" message + // instead of "success" - user should finish Claude's onboarding before closing + win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email: emailFromOutput || profile?.email, + success: true, + needsOnboarding: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); + } + } else { + console.warn('[ClaudeIntegration] Login successful but Keychain token not found and no credentials in configDir - user may need to complete authentication manually'); + } + } + return; + } + + // Fallback: Check for raw OAuth token in output (legacy method) const token = OutputParser.extractOAuthToken(data); if (!token) { return; } - console.warn('[ClaudeIntegration] OAuth token detected, length:', token.length); + console.warn('[ClaudeIntegration] OAuth token detected in output'); const email = OutputParser.extractEmail(terminal.outputBuffer); - // Match both custom profiles (profile-123456) and the default profile - const profileIdMatch = terminal.id.match(/claude-login-(profile-\d+|default)-/); - if (profileIdMatch) { + if (profileId) { // Save to specific profile (profile login terminal) - const profileId = profileIdMatch[1]; const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); const success = profileManager.setProfileToken(profileId, token, email || undefined); if (success) { + // Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token + clearKeychainCache(profile?.configDir); console.warn('[ClaudeIntegration] OAuth token auto-saved to profile:', profileId); const win = getWindow(); @@ -436,6 +615,8 @@ export function handleOAuthToken( const success = profileManager.setProfileToken(activeProfile.id, token, email || undefined); if (success) { + // Clear keychain cache so next getCredentialsFromKeychain() fetches fresh token + clearKeychainCache(activeProfile.configDir); console.warn('[ClaudeIntegration] OAuth token auto-saved to active profile:', activeProfile.name); const win = getWindow(); @@ -465,6 +646,103 @@ export function handleOAuthToken( } } +/** + * Handle onboarding complete detection + * Called when terminal output indicates Claude Code is ready after login/onboarding + * + * This detects the Claude Code welcome screen that appears after successful login, + * which includes patterns like "Welcome back", "Claude Code v2.x", or subscription + * tier info like "Claude Max". When detected, it notifies the frontend to auto-close + * the auth terminal. + */ +export function handleOnboardingComplete( + terminal: TerminalProcess, + data: string, + getWindow: WindowGetter +): void { + // Only check if we're waiting for onboarding to complete + if (!terminal.awaitingOnboardingComplete) { + return; + } + + // Check if output shows Claude Code welcome screen (onboarding complete indicators) + if (!OutputParser.isOnboardingCompleteOutput(data)) { + return; + } + + console.warn('[ClaudeIntegration] Onboarding complete detected for terminal:', terminal.id); + + // Clear the flag + terminal.awaitingOnboardingComplete = false; + + // Extract profile ID from terminal ID pattern (claude-login-{profileId}-*) + const profileId = extractProfileIdFromAuthTerminalId(terminal.id) || undefined; + + // Try to extract email from the welcome screen (e.g., "user@example.com's Organization") + // Strip ANSI escape codes first since terminal output contains formatting + // eslint-disable-next-line no-control-regex + const stripAnsi = (str: string) => str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + + const cleanData = stripAnsi(data); + const cleanBuffer = stripAnsi(terminal.outputBuffer); + + let email = OutputParser.extractEmail(cleanData); + if (!email) { + email = OutputParser.extractEmail(cleanBuffer); + } + + // Debug: Test each pattern individually to see what matches + const emailPatternTests = [ + { name: 'Authenticated/Logged in', pattern: /(?:Authenticated as |Logged in as |email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i }, + { name: "email's Organization", pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})[''\u2019]s\s*Organization/i }, + { name: 'Claude Max · email', pattern: /Claude\s+(?:Max|Pro|Team|Enterprise)\s*[·•]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i }, + { name: "email's (broad)", pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})['''\u2019]s/i }, + { name: 'any email', pattern: /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i }, + ]; + + const patternResults = emailPatternTests.map(({ name, pattern }) => { + const match = cleanBuffer.match(pattern); + return { name, matched: !!match, email: match?.[1] || null }; + }); + + // Redact PII from pattern results for logging + const redactedPatternResults = patternResults.map(({ name, matched, email: foundEmail }) => ({ + name, + matched, + email: maskEmail(foundEmail) + })); + + console.warn('[ClaudeIntegration] Email extraction attempt:', { + profileId, + foundEmail: maskEmail(email), + dataLength: data.length, + bufferLength: terminal.outputBuffer.length, + cleanBufferLength: cleanBuffer.length, + patternResults: redactedPatternResults + }); + + // Update profile with email if found and profile exists + if (profileId && email) { + const profileManager = getClaudeProfileManager(); + const profile = profileManager.getProfile(profileId); + if (profile && !profile.email) { + // Update the profile with the email + profile.email = email; + profileManager.saveProfile(profile); + console.warn('[ClaudeIntegration] Updated profile email from welcome screen:', profileId, maskEmail(email)); + } + } + + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, { + terminalId: terminal.id, + profileId, + detectedAt: new Date().toISOString() + } as OnboardingCompleteEvent); + } +} + /** * Handle Claude session ID capture */ @@ -520,6 +798,178 @@ export function handleClaudeExit( } } +/** + * Shared command execution logic for profile-based invocation + * Returns true if command was executed via configDir or temp-file method + */ +interface ExecuteProfileCommandOptions { + needsEnvOverride: boolean; + activeProfile: any; + cwdCommand: string; + pathPrefix: string; + escapedClaudeCmd: string; + extraFlags: string | undefined; + terminal: TerminalProcess; + profileManager: any; + projectPath: string | undefined; + startTime: number; + getWindow: WindowGetter; + onSessionCapture: SessionCaptureCallback; + logPrefix: string; +} + +function executeProfileCommand(options: ExecuteProfileCommandOptions): boolean { + const { + needsEnvOverride, + activeProfile, + cwdCommand, + pathPrefix, + escapedClaudeCmd, + extraFlags, + terminal, + profileManager, + projectPath, + startTime, + getWindow, + onSessionCapture, + logPrefix, + } = options; + + if (!needsEnvOverride || !activeProfile || activeProfile.isDefault) { + return false; // Use default method + } + + // Prefer configDir over token because CLAUDE_CONFIG_DIR lets Claude Code + // read full Keychain credentials including subscriptionType ("max") and rateLimitTier. + // Using CLAUDE_CODE_OAUTH_TOKEN alone lacks tier info, causing "Claude API" display. + if (activeProfile.configDir) { + const command = buildClaudeShellCommand( + cwdCommand, + pathPrefix, + escapedClaudeCmd, + { method: 'config-dir', configDir: activeProfile.configDir }, + extraFlags + ); + debugLog(`${logPrefix} Executing command (configDir method, history-safe)`); + PtyManager.writeToPty(terminal, command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog(`${logPrefix} ========== INVOKE CLAUDE COMPLETE (configDir) ==========`); + return true; + } + + // Legacy fallback: use temp-file method if only token is available + const token = profileManager.getProfileToken(activeProfile.id); + debugLog(`${logPrefix} Token retrieval:`, { + hasToken: !!token + }); + + if (token) { + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join( + os.tmpdir(), + `.claude-token-${Date.now()}-${nonce}${getTempFileExtension()}` + ); + debugLog(`${logPrefix} Writing token to temp file:`, tempFile); + fs.writeFileSync(tempFile, generateTokenTempFileContent(token), { mode: 0o600 }); + + const command = buildClaudeShellCommand( + cwdCommand, + pathPrefix, + escapedClaudeCmd, + { method: 'temp-file', tempFile }, + extraFlags + ); + debugLog(`${logPrefix} Executing command (temp file method, history-safe)`); + PtyManager.writeToPty(terminal, command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog(`${logPrefix} ========== INVOKE CLAUDE COMPLETE (temp file) ==========`); + return true; + } + + debugLog(`${logPrefix} WARNING: No token or configDir available for non-default profile`); + return false; +} + +/** + * Async version of executeProfileCommand for non-blocking file operations + * Returns true if command was executed via configDir or temp-file method + */ +async function executeProfileCommandAsync(options: ExecuteProfileCommandOptions): Promise { + const { + needsEnvOverride, + activeProfile, + cwdCommand, + pathPrefix, + escapedClaudeCmd, + extraFlags, + terminal, + profileManager, + projectPath, + startTime, + getWindow, + onSessionCapture, + logPrefix, + } = options; + + if (!needsEnvOverride || !activeProfile || activeProfile.isDefault) { + return false; // Use default method + } + + // Prefer configDir over token because CLAUDE_CONFIG_DIR lets Claude Code + // read full Keychain credentials including subscriptionType ("max") and rateLimitTier. + // Using CLAUDE_CODE_OAUTH_TOKEN alone lacks tier info, causing "Claude API" display. + if (activeProfile.configDir) { + const command = buildClaudeShellCommand( + cwdCommand, + pathPrefix, + escapedClaudeCmd, + { method: 'config-dir', configDir: activeProfile.configDir }, + extraFlags + ); + debugLog(`${logPrefix} Executing command (configDir method, history-safe)`); + PtyManager.writeToPty(terminal, command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog(`${logPrefix} ========== INVOKE CLAUDE COMPLETE (configDir) ==========`); + return true; + } + + // Legacy fallback: use temp-file method if only token is available + const token = profileManager.getProfileToken(activeProfile.id); + debugLog(`${logPrefix} Token retrieval:`, { + hasToken: !!token + }); + + if (token) { + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join( + os.tmpdir(), + `.claude-token-${Date.now()}-${nonce}${getTempFileExtension()}` + ); + debugLog(`${logPrefix} Writing token to temp file:`, tempFile); + await fsPromises.writeFile(tempFile, generateTokenTempFileContent(token), { mode: 0o600 }); + + const command = buildClaudeShellCommand( + cwdCommand, + pathPrefix, + escapedClaudeCmd, + { method: 'temp-file', tempFile }, + extraFlags + ); + debugLog(`${logPrefix} Executing command (temp file method, history-safe)`); + PtyManager.writeToPty(terminal, command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog(`${logPrefix} ========== INVOKE CLAUDE COMPLETE (temp file) ==========`); + return true; + } + + debugLog(`${logPrefix} WARNING: No token or configDir available for non-default profile`); + return false; +} + /** * Invoke Claude with optional profile override */ @@ -573,7 +1023,7 @@ export function invokeClaude( const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); const escapedClaudeCmd = escapeShellCommand(claudeCmd); const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); - const needsEnvOverride = profileId && profileId !== previousProfileId; + const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId); debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', { profileIdProvided: !!profileId, @@ -581,52 +1031,34 @@ export function invokeClaude( needsEnvOverride }); - if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { - const token = profileManager.getProfileToken(activeProfile.id); - debugLog('[ClaudeIntegration:invokeClaude] Token retrieval:', { - hasToken: !!token, - tokenLength: token?.length - }); + // Try to execute using profile-specific method (configDir or temp-file) + const executed = executeProfileCommand({ + needsEnvOverride, + activeProfile, + cwdCommand, + pathPrefix, + escapedClaudeCmd, + extraFlags, + terminal, + profileManager, + projectPath, + startTime, + getWindow, + onSessionCapture, + logPrefix: '[ClaudeIntegration:invokeClaude]', + }); - if (token) { - const nonce = crypto.randomBytes(8).toString('hex'); - const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}${getTempFileExtension()}`); - debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile); - fs.writeFileSync( - tempFile, - generateTokenTempFileContent(token), - { mode: 0o600 } - ); - - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', tempFile }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); - // Use PtyManager.writeToPty for safer write with error handling - PtyManager.writeToPty(terminal, command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); - return; - } else if (activeProfile.configDir) { - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', configDir: activeProfile.configDir }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); - // Use PtyManager.writeToPty for safer write with error handling - PtyManager.writeToPty(terminal, command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); - return; - } else { - debugLog('[ClaudeIntegration:invokeClaude] WARNING: No token or configDir available for non-default profile'); - } + if (executed) { + return; // Command already executed via configDir or temp-file method } + // Fall back to default method if (activeProfile && !activeProfile.isDefault) { debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); } const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); - // Use PtyManager.writeToPty for safer write with error handling PtyManager.writeToPty(terminal, command); if (activeProfile) { @@ -795,7 +1227,7 @@ export async function invokeClaudeAsync( const escapedClaudeCmd = escapeShellCommand(claudeCmd); const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); - const needsEnvOverride = profileId && profileId !== previousProfileId; + const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId); debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { profileIdProvided: !!profileId, @@ -803,52 +1235,34 @@ export async function invokeClaudeAsync( needsEnvOverride }); - if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { - const token = profileManager.getProfileToken(activeProfile.id); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Token retrieval:', { - hasToken: !!token, - tokenLength: token?.length - }); + // Try to execute using profile-specific method (configDir or temp-file) with async file operations + const executed = await executeProfileCommandAsync({ + needsEnvOverride, + activeProfile, + cwdCommand, + pathPrefix, + escapedClaudeCmd, + extraFlags, + terminal, + profileManager, + projectPath, + startTime, + getWindow, + onSessionCapture, + logPrefix: '[ClaudeIntegration:invokeClaudeAsync]', + }); - if (token) { - const nonce = crypto.randomBytes(8).toString('hex'); - const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}${getTempFileExtension()}`); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile); - await fsPromises.writeFile( - tempFile, - generateTokenTempFileContent(token), - { mode: 0o600 } - ); - - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', tempFile }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); - // Use PtyManager.writeToPty for safer write with error handling - PtyManager.writeToPty(terminal, command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); - return; - } else if (activeProfile.configDir) { - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', configDir: activeProfile.configDir }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); - // Use PtyManager.writeToPty for safer write with error handling - PtyManager.writeToPty(terminal, command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); - return; - } else { - debugLog('[ClaudeIntegration:invokeClaudeAsync] WARNING: No token or configDir available for non-default profile'); - } + if (executed) { + return; // Command already executed via configDir or temp-file method } + // Fall back to default method if (activeProfile && !activeProfile.isDefault) { debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); } const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); - // Use PtyManager.writeToPty for safer write with error handling PtyManager.writeToPty(terminal, command); if (activeProfile) { diff --git a/apps/frontend/src/main/terminal/output-parser.ts b/apps/frontend/src/main/terminal/output-parser.ts index 4f577c7d..20f9139c 100644 --- a/apps/frontend/src/main/terminal/output-parser.ts +++ b/apps/frontend/src/main/terminal/output-parser.ts @@ -20,14 +20,39 @@ const CLAUDE_SESSION_PATTERNS = [ const RATE_LIMIT_PATTERN = /Limit reached\s*[·•]\s*resets\s+(.+?)$/m; /** - * Regex pattern to capture OAuth token from `claude setup-token` output + * Regex pattern to capture OAuth token from Claude CLI output + * Token is displayed when authentication completes via /login or setup-token */ const OAUTH_TOKEN_PATTERN = /(sk-ant-oat01-[A-Za-z0-9_-]+)/; /** - * Pattern to detect email in Claude output + * Regex pattern to capture OAuth authorization URL from Claude CLI /login output + * The URL is displayed when /login is run and needs to be opened in browser + * Uses \x1b to exclude ANSI escape sequences from URL matching */ -const EMAIL_PATTERN = /(?:Authenticated as|Logged in as|email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i; +// eslint-disable-next-line no-control-regex -- Intentionally matches ANSI escape sequences to exclude them from URLs +const OAUTH_URL_PATTERN = /https:\/\/claude\.ai\/oauth\/authorize\?[^\s\x1b\]]+/; + +/** + * Patterns to detect email in Claude output + * Multiple patterns to handle different output formats: + * - "Authenticated as user@example.com" or "Logged in as user@example.com" + * - "email: user@example.com" + * - "user@example.com's Organization" (Claude Code welcome screen) + * - Fallback: any email-like pattern in the context of Claude Max/Pro/Team + */ +const EMAIL_PATTERNS = [ + /(?:Authenticated as |Logged in as |email[:\s]+)([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i, // Note: space after "as" + /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})[''\u2019]s\s*Organization/i, // "user@example.com's Organization" (various apostrophes) + /Claude\s+(?:Max|Pro|Team|Enterprise)\s*[·•]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i, // "Claude Max · user@example.com" + /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})['''\u2019]s/i, // Just "user@example.com's" (broader match) +]; + +/** + * Pattern to detect successful login in Claude CLI output + * Matches: "Login successful" or "Logged in as X" + */ +const LOGIN_SUCCESS_PATTERN = /(?:Login successful|Successfully logged in|Logged in as\s+\S+@\S+)/i; /** * Extract Claude session ID from output @@ -58,12 +83,34 @@ export function extractOAuthToken(data: string): string | null { return match ? match[1] : null; } +/** + * Extract OAuth authorization URL from output + * Returns the URL that needs to be opened in browser for /login flow + */ +export function extractOAuthUrl(data: string): string | null { + const match = data.match(OAUTH_URL_PATTERN); + return match ? match[0] : null; +} + +/** + * Check if output contains an OAuth authorization URL + */ +export function hasOAuthUrl(data: string): boolean { + return OAUTH_URL_PATTERN.test(data); +} + /** * Extract email from output + * Tries multiple patterns to handle different output formats */ export function extractEmail(data: string): string | null { - const match = data.match(EMAIL_PATTERN); - return match ? match[1] : null; + for (const pattern of EMAIL_PATTERNS) { + const match = data.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + return null; } /** @@ -80,6 +127,14 @@ export function hasOAuthToken(data: string): boolean { return OAUTH_TOKEN_PATTERN.test(data); } +/** + * Check if output indicates successful login + * This catches the localhost callback flow where no token is displayed + */ +export function hasLoginSuccess(data: string): boolean { + return LOGIN_SUCCESS_PATTERN.test(data); +} + /** * Patterns indicating Claude Code is busy/processing * These appear when Claude is actively thinking or working @@ -127,6 +182,16 @@ const CLAUDE_IDLE_PATTERNS = [ /^\s*>\s+$/m, // "> " with possible whitespace ]; +/** + * Patterns indicating Claude Code onboarding/login is complete + * These patterns detect the welcome screen that appears after successful login + */ +const ONBOARDING_COMPLETE_PATTERNS = [ + /Welcome back\s+\w+/i, // "Welcome back André!" or similar + /Claude Code v\d+\.\d+/i, // "Claude Code v2.1.12" version header + /Claude\s+(Max|Pro|Team|Enterprise)/i, // Subscription tier indicator +]; + /** * Check if output indicates Claude is busy (processing) */ @@ -141,6 +206,14 @@ export function isClaudeIdleOutput(data: string): boolean { return CLAUDE_IDLE_PATTERNS.some(pattern => pattern.test(data)); } +/** + * Check if output indicates Claude Code onboarding is complete + * This detects the welcome screen that appears after successful login/onboarding + */ +export function isOnboardingCompleteOutput(data: string): boolean { + return ONBOARDING_COMPLETE_PATTERNS.some(pattern => pattern.test(data)); +} + /** * Determine Claude busy state from output * Returns: 'busy' | 'idle' | null (no change detected) diff --git a/apps/frontend/src/main/terminal/terminal-event-handler.ts b/apps/frontend/src/main/terminal/terminal-event-handler.ts index dddb90af..d6d3ca2f 100644 --- a/apps/frontend/src/main/terminal/terminal-event-handler.ts +++ b/apps/frontend/src/main/terminal/terminal-event-handler.ts @@ -15,6 +15,7 @@ export interface EventHandlerCallbacks { onClaudeSessionId: (terminal: TerminalProcess, sessionId: string) => void; onRateLimit: (terminal: TerminalProcess, data: string) => void; onOAuthToken: (terminal: TerminalProcess, data: string) => void; + onOnboardingComplete: (terminal: TerminalProcess, data: string) => void; onClaudeBusyChange: (terminal: TerminalProcess, isBusy: boolean) => void; onClaudeExit: (terminal: TerminalProcess) => void; } @@ -46,6 +47,9 @@ export function handleTerminalData( // Check for OAuth token callbacks.onOAuthToken(terminal, data); + // Check for onboarding complete (after login, Claude shows ready state) + callbacks.onOnboardingComplete(terminal, data); + // Detect Claude busy state changes (only when in Claude mode) if (terminal.isClaudeMode) { const busyState = OutputParser.detectClaudeBusyState(data); @@ -101,6 +105,9 @@ export function createEventCallbacks( onOAuthToken: (terminal, data) => { ClaudeIntegration.handleOAuthToken(terminal, data, getWindow); }, + onOnboardingComplete: (terminal, data) => { + ClaudeIntegration.handleOnboardingComplete(terminal, data, getWindow); + }, onClaudeBusyChange: (terminal, isBusy) => { const win = getWindow(); if (win) { diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 142e2005..423071a4 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -43,9 +43,9 @@ export async function createTerminal( getWindow: WindowGetter, dataHandler: DataHandlerFn ): Promise { - const { id, cwd, cols = 80, rows = 24, projectPath } = options; + const { id, cwd, cols = 80, rows = 24, projectPath, skipOAuthToken, env: customEnv } = options; - debugLog('[TerminalLifecycle] Creating terminal:', { id, cwd, cols, rows, projectPath }); + debugLog('[TerminalLifecycle] Creating terminal:', { id, cwd, cols, rows, projectPath, skipOAuthToken, hasCustomEnv: !!customEnv }); if (terminals.has(id)) { debugLog('[TerminalLifecycle] Terminal already exists, returning success:', id); @@ -53,10 +53,19 @@ export async function createTerminal( } try { - const profileEnv = PtyManager.getActiveProfileEnv(); + // For auth terminals, don't inject existing OAuth token - we want a fresh login + const profileEnv = skipOAuthToken ? {} : PtyManager.getActiveProfileEnv(); - if (profileEnv.CLAUDE_CODE_OAUTH_TOKEN) { + // Merge custom environment variables (e.g., CLAUDE_CONFIG_DIR for auth terminals) + const mergedEnv = customEnv ? { ...profileEnv, ...customEnv } : profileEnv; + + if (mergedEnv.CLAUDE_CODE_OAUTH_TOKEN) { debugLog('[TerminalLifecycle] Injecting OAuth token from active profile'); + } else if (skipOAuthToken) { + debugLog('[TerminalLifecycle] Skipping OAuth token injection (auth terminal)'); + } + if (mergedEnv.CLAUDE_CONFIG_DIR) { + debugLog('[TerminalLifecycle] Setting CLAUDE_CONFIG_DIR:', mergedEnv.CLAUDE_CONFIG_DIR); } // Validate cwd exists - if the directory doesn't exist (e.g., worktree removed), @@ -71,7 +80,7 @@ export async function createTerminal( effectiveCwd || os.homedir(), cols, rows, - profileEnv + mergedEnv ); debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid, 'shellType:', shellType); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index b05568b8..69118a9d 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -10,7 +10,8 @@ import type { TerminalSession } from '../terminal-session-store'; import type { TerminalProcess, WindowGetter, - TerminalOperationResult + TerminalOperationResult, + TerminalProfileChangeInfo } from './types'; import * as PtyManager from './pty-manager'; import * as SessionHandler from './session-handler'; @@ -347,6 +348,13 @@ export class TerminalManager { return Array.from(this.terminals.keys()); } + /** + * Get a terminal by ID (for debugging/inspection) + */ + getTerminal(id: string): TerminalProcess | undefined { + return this.terminals.get(id); + } + /** * Check if a terminal is in Claude mode */ @@ -363,6 +371,27 @@ export class TerminalManager { return terminal?.claudeSessionId; } + /** + * Get info about all terminals for profile change operations. + * Returns info needed to migrate sessions and notify frontend. + */ + getTerminalsForProfileChange(): TerminalProfileChangeInfo[] { + const result: TerminalProfileChangeInfo[] = []; + + for (const [id, terminal] of this.terminals) { + result.push({ + id, + cwd: terminal.cwd, + projectPath: terminal.projectPath, + claudeSessionId: terminal.claudeSessionId, + claudeProfileId: terminal.claudeProfileId, + isClaudeMode: terminal.isClaudeMode + }); + } + + return result; + } + /** * Update terminal title */ diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index 6585c8aa..d68ca2c1 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -26,6 +26,8 @@ export interface TerminalProcess { dangerouslySkipPermissions?: boolean; /** Shell type for Windows (affects command chaining syntax) */ shellType?: WindowsShellType; + /** Whether this terminal is waiting for Claude onboarding to complete (login flow) */ + awaitingOnboardingComplete?: boolean; } /** @@ -51,6 +53,18 @@ export interface OAuthTokenEvent { success: boolean; message?: string; detectedAt: string; + /** If true, user should complete onboarding in terminal before closing */ + needsOnboarding?: boolean; +} + +/** + * Onboarding complete event data + * Sent when Claude Code shows its ready state after login/onboarding + */ +export interface OnboardingCompleteEvent { + terminalId: string; + profileId?: string; + detectedAt: string; } /** @@ -74,3 +88,15 @@ export interface TerminalOperationResult { * Window getter function type */ export type WindowGetter = () => BrowserWindow | null; + +/** + * Terminal info for profile change operations + */ +export interface TerminalProfileChangeInfo { + id: string; + cwd: string; + projectPath?: string; + claudeSessionId?: string; + claudeProfileId?: string; + isClaudeMode: boolean; +} diff --git a/apps/frontend/src/main/utils/config-path-validator.ts b/apps/frontend/src/main/utils/config-path-validator.ts new file mode 100644 index 00000000..907f1477 --- /dev/null +++ b/apps/frontend/src/main/utils/config-path-validator.ts @@ -0,0 +1,54 @@ +/** + * Config Path Validator + * + * Security utility to validate Claude profile config directory paths. + * Prevents path traversal attacks where malicious code could specify + * arbitrary paths like /etc or C:\Windows\System32\config. + */ + +import path from 'path'; +import os from 'os'; + +/** + * Validate that a config directory path is safe and within expected boundaries. + * This prevents path traversal attacks where a malicious renderer could + * specify arbitrary paths like /etc or C:\Windows\System32\config. + * + * @param configDir - The config directory path to validate (may contain ~) + * @returns true if the path is safe, false otherwise + */ +export function isValidConfigDir(configDir: string): boolean { + // Expand ~ to home directory for validation + const expandedPath = configDir.startsWith('~') + ? path.join(os.homedir(), configDir.slice(1)) + : configDir; + + // Normalize to resolve any .. or . components + const normalizedPath = path.resolve(expandedPath); + const homeDir = os.homedir(); + + // Allow paths within: + // 1. User's home directory (~/) + // 2. ~/.claude (default config directory) + // 3. ~/.claude-profiles/* (profile config directories) + // 4. User's app data directory (for custom profiles) + const allowedPrefixes = [ + homeDir, + path.join(homeDir, '.claude'), + path.join(homeDir, '.claude-profiles'), + ]; + + // Check if normalized path starts with any allowed prefix + // IMPORTANT: Use path separator boundary to prevent attacks like + // /home/alice-malicious passing validation for /home/alice + for (const prefix of allowedPrefixes) { + const resolvedPrefix = path.resolve(prefix); + // Check for exact match OR starts with prefix + separator + if (normalizedPath === resolvedPrefix || normalizedPath.startsWith(resolvedPrefix + path.sep)) { + return true; + } + } + + console.warn('[Config Path Validator] Rejected unsafe configDir path:', configDir, '(normalized:', normalizedPath, ')'); + return false; +} diff --git a/apps/frontend/src/main/utils/type-guards.ts b/apps/frontend/src/main/utils/type-guards.ts new file mode 100644 index 00000000..056dc6a4 --- /dev/null +++ b/apps/frontend/src/main/utils/type-guards.ts @@ -0,0 +1,13 @@ +/** + * Type Guards Utility + * + * Shared type guard functions for common type checking patterns. + */ + +/** + * Type guard to check if an error is a NodeJS.ErrnoException with a code property. + * Useful for checking error codes like 'ENOENT', 'EEXIST', etc. + */ +export function isNodeError(err: unknown): err is NodeJS.ErrnoException { + return err instanceof Error && 'code' in err; +} diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 660eba6b..7efd414a 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -17,6 +17,7 @@ import type { TerminalWorktreeConfig, TerminalWorktreeResult, OtherWorktreeInfo, + TerminalProfileChangedEvent, } from '../../shared/types'; /** Type for proactive swap notification events */ @@ -79,14 +80,22 @@ export interface TerminalAPI { onTerminalClaudeSession: (callback: (id: string, sessionId: string) => void) => () => void; onTerminalRateLimit: (callback: (info: RateLimitInfo) => void) => () => void; onTerminalOAuthToken: ( - callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string }) => void + callback: (info: { terminalId: string; profileId?: string; email?: string; success: boolean; message?: string; detectedAt: string; needsOnboarding?: boolean }) => void ) => () => void; onTerminalAuthCreated: ( callback: (info: { terminalId: string; profileId: string; profileName: string }) => void ) => () => void; + onTerminalOAuthCodeNeeded: ( + callback: (info: { terminalId: string; profileId: string; profileName: string }) => void + ) => () => void; + submitOAuthCode: (terminalId: string, code: string) => Promise; onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void; onTerminalClaudeExit: (callback: (id: string) => void) => () => void; + onTerminalOnboardingComplete: ( + callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void + ) => () => void; onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void; + onTerminalProfileChanged: (callback: (event: TerminalProfileChangedEvent) => void) => () => void; // Claude Profile Management getClaudeProfiles: () => Promise>; @@ -97,6 +106,8 @@ export interface TerminalAPI { switchClaudeProfile: (terminalId: string, profileId: string) => Promise; initializeClaudeProfile: (profileId: string) => Promise; setClaudeProfileToken: (profileId: string, token: string, email?: string) => Promise; + authenticateClaudeProfile: (profileId: string) => Promise>; + verifyClaudeProfileAuth: (profileId: string) => Promise>; getAutoSwitchSettings: () => Promise>; updateAutoSwitchSettings: (settings: Partial) => Promise; fetchClaudeUsage: (terminalId: string) => Promise; @@ -321,6 +332,24 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onTerminalOAuthCodeNeeded: ( + callback: (info: { terminalId: string; profileId: string; profileName: string }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + info: { terminalId: string; profileId: string; profileName: string } + ): void => { + callback(info); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_OAUTH_CODE_NEEDED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_OAUTH_CODE_NEEDED, handler); + }; + }, + + submitOAuthCode: (terminalId: string, code: string): Promise => + ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_OAUTH_CODE_SUBMIT, terminalId, code), + onTerminalClaudeBusy: ( callback: (id: string, isBusy: boolean) => void ): (() => void) => { @@ -352,6 +381,21 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onTerminalOnboardingComplete: ( + callback: (info: { terminalId: string; profileId?: string; detectedAt: string }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + info: { terminalId: string; profileId?: string; detectedAt: string } + ): void => { + callback(info); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, handler); + }; + }, + onTerminalPendingResume: ( callback: (id: string, sessionId?: string) => void ): (() => void) => { @@ -368,6 +412,21 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onTerminalProfileChanged: ( + callback: (event: TerminalProfileChangedEvent) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + data: TerminalProfileChangedEvent + ): void => { + callback(data); + }; + ipcRenderer.on(IPC_CHANNELS.TERMINAL_PROFILE_CHANGED, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.TERMINAL_PROFILE_CHANGED, handler); + }; + }, + // Claude Profile Management getClaudeProfiles: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILES_GET), @@ -393,6 +452,12 @@ export const createTerminalAPI = (): TerminalAPI => ({ setClaudeProfileToken: (profileId: string, token: string, email?: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, profileId, token, email), + authenticateClaudeProfile: (profileId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTHENTICATE, profileId), + + verifyClaudeProfileAuth: (profileId: string): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_VERIFY_AUTH, profileId), + getAutoSwitchSettings: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS), diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index d5f4ed76..4698ef8e 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -9,6 +9,7 @@ import { PointerSensor, useSensor, useSensors, + type DragStartEvent, type DragEndEvent } from '@dnd-kit/core'; import { @@ -61,6 +62,7 @@ import { initDownloadProgressListener } from './stores/download-store'; import { GlobalDownloadIndicator } from './components/GlobalDownloadIndicator'; import { useIpcListeners } from './hooks/useIpc'; import { useGlobalTerminalListeners } from './hooks/useGlobalTerminalListeners'; +import { useTerminalProfileChange } from './hooks/useTerminalProfileChange'; import { COLOR_THEMES, UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT } from '../shared/constants'; import type { Task, Project, ColorTheme } from '../shared/types'; import { ProjectTabBar } from './components/ProjectTabBar'; @@ -105,6 +107,9 @@ export function App() { // This ensures terminal output is captured even when the terminal component is not rendered useGlobalTerminalListeners(); + // Handle terminal profile change events (recreate terminals on profile switch) + useTerminalProfileChange(); + // Stores const projects = useProjectStore((state) => state.projects); const selectedProjectId = useProjectStore((state) => state.selectedProjectId); @@ -120,7 +125,6 @@ export function App() { // API Profile state const profiles = useSettingsStore((state) => state.profiles); - const activeProfileId = useSettingsStore((state) => state.activeProfileId); // Claude Profile state (OAuth) const claudeProfiles = useClaudeProfileStore((state) => state.profiles); @@ -186,7 +190,7 @@ export function App() { // Restore tab state and open tabs for loaded projects useEffect(() => { - console.log('[App] Tab restore useEffect triggered:', { + console.warn('[App] Tab restore useEffect triggered:', { projectsCount: projects.length, openProjectIds, activeProjectId, @@ -201,36 +205,37 @@ export function App() { if (openProjectIds.length === 0) { // No tabs persisted at all, open the first available project const projectToOpen = activeProjectId || selectedProjectId || projects[0].id; - console.log('[App] No tabs persisted, opening project:', projectToOpen); + console.warn('[App] No tabs persisted, opening project:', projectToOpen); // Verify the project exists before opening if (projects.some(p => p.id === projectToOpen)) { openProjectTab(projectToOpen); setActiveProject(projectToOpen); } else { // Fallback to first project if stored IDs are invalid - console.log('[App] Project not found, falling back to first project:', projects[0].id); + console.warn('[App] Project not found, falling back to first project:', projects[0].id); openProjectTab(projects[0].id); setActiveProject(projects[0].id); } return; } - console.log('[App] Tabs already persisted, checking active project'); + console.warn('[App] Tabs already persisted, checking active project'); // If there's an active project but no tabs open for it, open a tab // Note: Use openProjectIds instead of projectTabs to avoid re-render loop // (projectTabs creates a new array on every render) if (activeProjectId && !openProjectIds.includes(activeProjectId)) { - console.log('[App] Active project has no tab, opening:', activeProjectId); + console.warn('[App] Active project has no tab, opening:', activeProjectId); openProjectTab(activeProjectId); } // If there's a selected project but no active project, make it active else if (selectedProjectId && !activeProjectId) { - console.log('[App] No active project, using selected:', selectedProjectId); + console.warn('[App] No active project, using selected:', selectedProjectId); setActiveProject(selectedProjectId); openProjectTab(selectedProjectId); } else { - console.log('[App] Tab state is valid, no action needed'); + console.warn('[App] Tab state is valid, no action needed'); } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- projectTabs is intentionally omitted to avoid infinite re-render (computed array creates new reference each render) }, [projects, activeProjectId, selectedProjectId, openProjectIds, openProjectTab, setActiveProject]); // Track if settings have been loaded at least once @@ -549,7 +554,7 @@ export function App() { const handleOpenInbuiltTerminal = (_id: string, cwd: string) => { // Note: _id parameter is intentionally unused - terminal ID is auto-generated by addTerminal() // Parameter kept for callback signature consistency with callers - console.log('[App] Opening inbuilt terminal:', { cwd }); + console.warn('[App] Opening inbuilt terminal:', { cwd }); // Switch to terminals view setActiveView('terminals'); @@ -565,7 +570,7 @@ export function App() { if (!terminal) { console.error('[App] Failed to add terminal to store (max terminals reached?)'); } else { - console.log('[App] Terminal added to store:', terminal.id); + console.warn('[App] Terminal added to store:', terminal.id); } }; @@ -624,7 +629,7 @@ export function App() { }; // Handle drag start - set the active dragged project - const handleDragStart = (event: any) => { + const handleDragStart = (event: DragStartEvent) => { const { active } = event; const draggedProject = projectTabs.find(p => p.id === active.id); if (draggedProject) { @@ -651,19 +656,19 @@ export function App() { if (!pendingProject) return; const projectId = pendingProject.id; - console.log('[InitDialog] Starting initialization for project:', projectId); + console.warn('[InitDialog] Starting initialization for project:', projectId); setIsInitializing(true); setInitSuccess(false); setInitError(null); // Clear any previous errors try { const result = await initializeProject(projectId); - console.log('[InitDialog] Initialization result:', result); + console.warn('[InitDialog] Initialization result:', result); if (result?.success) { - console.log('[InitDialog] Initialization successful, closing dialog'); + console.warn('[InitDialog] Initialization successful, closing dialog'); // Get the updated project from store const updatedProject = useProjectStore.getState().projects.find(p => p.id === projectId); - console.log('[InitDialog] Updated project:', updatedProject); + console.warn('[InitDialog] Updated project:', updatedProject); // Mark as successful to prevent onOpenChange from treating this as a skip setInitSuccess(true); @@ -680,7 +685,7 @@ export function App() { } } else { // Initialization failed - show error but keep dialog open - console.log('[InitDialog] Initialization failed, showing error'); + console.warn('[InitDialog] Initialization failed, showing error'); const errorMessage = result?.error || 'Failed to initialize Auto Claude. Please try again.'; setInitError(errorMessage); setIsInitializing(false); @@ -738,7 +743,7 @@ export function App() { }; const handleSkipInit = () => { - console.log('[InitDialog] User skipped initialization'); + console.warn('[InitDialog] User skipped initialization'); if (pendingProject) { setSkippedInitProjectId(pendingProject.id); } @@ -948,7 +953,7 @@ export function App() { {/* Initialize Auto Claude Dialog */} { - console.log('[InitDialog] onOpenChange called', { open, pendingProject: !!pendingProject, isInitializing, initSuccess }); + console.warn('[InitDialog] onOpenChange called', { open, pendingProject: !!pendingProject, isInitializing, initSuccess }); // Only trigger skip if user manually closed the dialog // Don't trigger if: successful init, no pending project, or currently initializing if (!open && pendingProject && !isInitializing && !initSuccess) { diff --git a/apps/frontend/src/renderer/__tests__/OAuthStep.test.tsx b/apps/frontend/src/renderer/__tests__/OAuthStep.test.tsx index 91244c80..2de6563f 100644 --- a/apps/frontend/src/renderer/__tests__/OAuthStep.test.tsx +++ b/apps/frontend/src/renderer/__tests__/OAuthStep.test.tsx @@ -5,7 +5,7 @@ * @vitest-environment jsdom */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import type { ClaudeProfile, ClaudeProfileSettings, ElectronAPI } from '../../shared/types'; +import type { ClaudeProfile } from '../../shared/types'; // Import browser mock to get full ElectronAPI structure import '../lib/browser-mock'; @@ -426,7 +426,7 @@ describe('OAuthStep Profile Management Logic', () => { }); it('should show "Active" badge for active profile', () => { - const profiles: ClaudeProfile[] = [ + const _profiles: ClaudeProfile[] = [ createTestProfile({ id: 'p1' }), createTestProfile({ id: 'p2' }) ]; diff --git a/apps/frontend/src/renderer/components/RateLimitModal.tsx b/apps/frontend/src/renderer/components/RateLimitModal.tsx index 9d0e2e32..477a4177 100644 --- a/apps/frontend/src/renderer/components/RateLimitModal.tsx +++ b/apps/frontend/src/renderer/components/RateLimitModal.tsx @@ -109,28 +109,21 @@ export function RateLimitModal() { }); if (result.success && result.data) { - // Initialize the profile (creates terminal and runs claude setup-token) - const initResult = await window.electronAPI.initializeClaudeProfile(result.data.id); + // Reload profiles + loadClaudeProfiles(); + setNewProfileName(''); + // Close the modal + hideRateLimitModal(); - if (initResult.success) { - // Reload profiles - loadClaudeProfiles(); - setNewProfileName(''); - // Close the modal so user can see the terminal - hideRateLimitModal(); - - // Notify the user about the terminal (non-blocking) - toast({ - title: t('rateLimit.toast.authenticating', { profileName }), - description: t('rateLimit.toast.checkTerminal'), - }); - } else { - toast({ - variant: 'destructive', - title: t('rateLimit.toast.authStartFailed'), - description: initResult.error || t('rateLimit.toast.tryAgain'), - }); - } + // Direct user to Settings to complete authentication + alert( + `${t('profileCreated.title', { profileName })}\n\n` + + `${t('profileCreated.instructions')}\n` + + `1. ${t('profileCreated.step1')}\n` + + `2. ${t('profileCreated.step2')}\n` + + `3. ${t('profileCreated.step3')}\n\n` + + `${t('profileCreated.footer')}` + ); } } catch (err) { debugError('[RateLimitModal] Failed to add profile:', err); diff --git a/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx b/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx index ae98edea..e5cc0ca1 100644 --- a/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx +++ b/apps/frontend/src/renderer/components/SDKRateLimitModal.tsx @@ -155,28 +155,21 @@ export function SDKRateLimitModal() { }); if (result.success && result.data) { - // Initialize the profile (creates terminal and runs claude setup-token) - const initResult = await window.electronAPI.initializeClaudeProfile(result.data.id); + // Reload profiles + loadClaudeProfiles(); + setNewProfileName(''); + // Close the modal + hideSDKRateLimitModal(); - if (initResult.success) { - // Reload profiles - loadClaudeProfiles(); - setNewProfileName(''); - // Close the modal so user can see the terminal - hideSDKRateLimitModal(); - - // Notify the user about the terminal (non-blocking) - toast({ - title: t('rateLimit.toast.authenticating', { profileName }), - description: t('rateLimit.toast.checkTerminal'), - }); - } else { - toast({ - variant: 'destructive', - title: t('rateLimit.toast.authStartFailed'), - description: initResult.error || t('rateLimit.toast.tryAgain'), - }); - } + // Direct user to Settings to complete authentication + alert( + `${t('profileCreated.title', { profileName })}\n\n` + + `${t('profileCreated.instructions')}\n` + + `1. ${t('profileCreated.step1')}\n` + + `2. ${t('profileCreated.step2')}\n` + + `3. ${t('profileCreated.step3')}\n\n` + + `${t('profileCreated.footer')}` + ); } } catch (err) { debugError('[SDKRateLimitModal] Failed to add profile:', err); diff --git a/apps/frontend/src/renderer/components/onboarding/OAuthStep.tsx b/apps/frontend/src/renderer/components/onboarding/OAuthStep.tsx index e175c0df..78683d7a 100644 --- a/apps/frontend/src/renderer/components/onboarding/OAuthStep.tsx +++ b/apps/frontend/src/renderer/components/onboarding/OAuthStep.tsx @@ -1,7 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { - Key, Eye, EyeOff, Info, @@ -25,8 +24,8 @@ import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Card, CardContent } from '../ui/card'; import { cn } from '../../lib/utils'; +import { AuthTerminal } from '../settings/AuthTerminal'; import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store'; -import { useClaudeLoginTerminal } from '../../hooks/useClaudeLoginTerminal'; import { useToast } from '../../hooks/use-toast'; import type { ClaudeProfile } from '../../../shared/types'; @@ -66,6 +65,14 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { // Error state const [error, setError] = useState(null); + // Auth terminal state - for embedded authentication + const [authTerminal, setAuthTerminal] = useState<{ + terminalId: string; + configDir: string; + profileId: string; + profileName: string; + } | null>(null); + // Derived state: check if at least one profile is authenticated const hasAuthenticatedProfile = claudeProfiles.some( (profile) => profile.oauthToken || (profile.isDefault && profile.configDir) @@ -95,26 +102,6 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { loadClaudeProfiles(); }, []); - // Listen for login terminal creation - makes the terminal visible so user can see OAuth flow - useClaudeLoginTerminal(); - - // Listen for OAuth authentication completion - useEffect(() => { - const unsubscribe = window.electronAPI.onTerminalOAuthToken(async (info) => { - if (info.success && info.profileId) { - // Reload profiles to show updated state - await loadClaudeProfiles(); - // Show simple success notification (non-blocking) - toast({ - title: t('oauth.toast.authSuccess'), - description: info.email ? t('oauth.toast.authSuccessWithEmail', { email: info.email }) : t('oauth.toast.authSuccessGeneric'), - }); - } - }); - - return unsubscribe; - }, [t, toast]); - // Profile management handlers - following patterns from IntegrationSettings.tsx const handleAddProfile = async () => { if (!newProfileName.trim()) return; @@ -146,22 +133,27 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { }); if (result.success && result.data) { - // Initialize the profile (starts OAuth flow) - const initResult = await window.electronAPI.initializeClaudeProfile(result.data.id); + await loadClaudeProfiles(); + const savedProfileName = newProfileName.trim(); + setNewProfileName(''); - if (initResult.success) { - await loadClaudeProfiles(); - setNewProfileName(''); + // Get terminal config for authentication + const authResult = await window.electronAPI.authenticateClaudeProfile(result.data.id); - // Note: The terminal is now visible in the UI via the onTerminalAuthCreated event - // Users can see the 'claude setup-token' output directly - } else { - await loadClaudeProfiles(); - toast({ - variant: 'destructive', - title: t('oauth.toast.authStartFailed'), - description: initResult.error || t('oauth.toast.tryAgain'), + if (authResult.success && authResult.data) { + setAuthenticatingProfileId(result.data.id); + + // Set up embedded auth terminal + setAuthTerminal({ + terminalId: authResult.data.terminalId, + configDir: authResult.data.configDir, + profileId: result.data.id, + profileName: savedProfileName, }); + + console.warn('[OAuthStep] New profile auth terminal ready:', authResult.data); + } else { + alert(t('oauth.alerts.profileCreatedAuthFailed', { error: authResult.error || t('oauth.toast.tryAgain') })); } } } catch (err) { @@ -231,28 +223,59 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { } }; + // Handle auth terminal close + const handleAuthTerminalClose = useCallback(() => { + setAuthTerminal(null); + setAuthenticatingProfileId(null); + }, []); + + // Handle auth terminal success + const handleAuthTerminalSuccess = useCallback(async (email?: string) => { + console.warn('[OAuthStep] Auth success:', email); + + // Close terminal immediately + setAuthTerminal(null); + setAuthenticatingProfileId(null); + + // Reload profiles to get updated auth state + await loadClaudeProfiles(); + }, []); + + // Handle auth terminal error + const handleAuthTerminalError = useCallback((error: string) => { + console.error('[OAuthStep] Auth error:', error); + // Don't auto-close on error - let user see the error and close manually + }, []); + const handleAuthenticateProfile = async (profileId: string) => { + // Find the profile name for display + const profile = claudeProfiles.find(p => p.id === profileId); + const profileName = profile?.name || 'Profile'; + setAuthenticatingProfileId(profileId); setError(null); try { - const initResult = await window.electronAPI.initializeClaudeProfile(profileId); - if (!initResult.success) { - toast({ - variant: 'destructive', - title: t('oauth.toast.authStartFailed'), - description: initResult.error || t('oauth.toast.tryAgain'), - }); + // Get terminal config from backend (terminalId and configDir) + const result = await window.electronAPI.authenticateClaudeProfile(profileId); + + if (!result.success || !result.data) { + alert(t('oauth.alerts.authPrepareFailed', { error: result.error || t('oauth.toast.tryAgain') })); + setAuthenticatingProfileId(null); + return; } - // Note: If successful, the terminal is now visible in the UI via the onTerminalAuthCreated event - // Users can see the 'claude setup-token' output and complete OAuth flow directly + + // Set up embedded auth terminal + setAuthTerminal({ + terminalId: result.data.terminalId, + configDir: result.data.configDir, + profileId, + profileName, + }); + + console.warn('[OAuthStep] Auth terminal ready:', result.data); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to authenticate profile'); - toast({ - variant: 'destructive', - title: t('oauth.toast.authStartFailed'), - description: t('oauth.toast.tryAgain'), - }); - } finally { + alert(t('oauth.alerts.authStartFailedMessage')); setAuthenticatingProfileId(null); } }; @@ -624,6 +647,22 @@ export function OAuthStep({ onNext, onBack, onSkip }: OAuthStepProps) { )} + {/* Embedded Auth Terminal */} + {authTerminal && ( +
+
+ +
+
+ )} + {/* Add new account input */}
('ready'); + const [status, setStatus] = useState<'ready' | 'authenticating' | 'verifying' | 'success' | 'error'>('ready'); const [error, setError] = useState(null); const [email, setEmail] = useState(); + const [authenticatingProfileId, setAuthenticatingProfileId] = useState(null); // Track if we've already started auth to prevent double-execution const hasStartedRef = useRef(false); // Track the auto-advance timeout so we can cancel it on unmount/re-render const successTimeoutRef = useRef | null>(null); - // Listen for OAuth token detection - useEffect(() => { - // Clear any pending timeout from previous effect run - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - successTimeoutRef.current = null; - } - - const unsubscribe = window.electronAPI.onTerminalOAuthToken((info) => { - console.warn('[ClaudeOAuth] Token event received:', { - success: info.success, - hasEmail: !!info.email, - profileId: info.profileId - }); - - if (info.success) { - setEmail(info.email); - setStatus('success'); - // Auto-advance after a short delay to show success message - // Store the timeout ID so cleanup can cancel it if needed - successTimeoutRef.current = setTimeout(() => { - successTimeoutRef.current = null; // Clear ref since timeout fired - onSuccess(); - }, 1500); - } else { - setError(info.message || 'Failed to save OAuth token'); - setStatus('error'); - } - }); - - return () => { - unsubscribe?.(); - // Clear timeout on cleanup to prevent calling onSuccess after unmount - if (successTimeoutRef.current) { - clearTimeout(successTimeoutRef.current); - successTimeoutRef.current = null; - } - }; - }, [onSuccess]); - const handleStartAuth = async () => { if (hasStartedRef.current) { console.warn('[ClaudeOAuth] Auth already started, ignoring duplicate call'); @@ -89,17 +53,17 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) { } const activeProfileId = profilesResult.data.activeProfileId; - console.warn('[ClaudeOAuth] Initializing profile:', activeProfileId); + console.warn('[ClaudeOAuth] Authenticating profile:', activeProfileId); - // Initialize the profile - this opens a terminal and runs 'claude setup-token' - const result = await window.electronAPI.initializeClaudeProfile(activeProfileId); + // Open visible terminal for authentication + const result = await window.electronAPI.authenticateClaudeProfile(activeProfileId); if (!result.success) { - throw new Error(result.error || 'Failed to start authentication'); + throw new Error(result.error || 'Failed to open terminal for authentication'); } - console.warn('[ClaudeOAuth] Authentication started, waiting for token...'); - // Status will be updated by the event listener when token is detected + setAuthenticatingProfileId(activeProfileId); + console.warn('[ClaudeOAuth] Terminal opened, waiting for user to complete /login...'); } catch (err) { console.error('[ClaudeOAuth] Authentication failed:', err); setError(err instanceof Error ? err.message : 'Authentication failed'); @@ -108,10 +72,45 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) { } }; + const handleVerifyAuth = async () => { + if (!authenticatingProfileId) { + setError('No profile selected for verification'); + return; + } + + setStatus('verifying'); + setError(null); + + try { + const result = await window.electronAPI.verifyClaudeProfileAuth(authenticatingProfileId); + console.warn('[ClaudeOAuth] Verification result:', result); + + if (result.success && result.data?.authenticated) { + console.warn('[ClaudeOAuth] Auth verified! Email:', result.data.email); + setEmail(result.data.email); + setStatus('success'); + + // Auto-advance after a short delay to show success message + successTimeoutRef.current = setTimeout(() => { + successTimeoutRef.current = null; + onSuccess(); + }, 1500); + } else { + setError('Authentication not detected. Please complete /login in the terminal first.'); + setStatus('authenticating'); + } + } catch (err) { + console.error('[ClaudeOAuth] Verification failed:', err); + setError(err instanceof Error ? err.message : 'Verification failed'); + setStatus('authenticating'); + } + }; + const handleRetry = () => { hasStartedRef.current = false; setStatus('ready'); setError(null); + setAuthenticatingProfileId(null); }; return ( @@ -132,7 +131,7 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) { Roadmap generation, Task automation, and Ideation.

- This will open a browser window to authenticate with your Claude account. + This will open a terminal with Claude CLI where you can authenticate. Your credentials are stored securely and are valid for 1 year.

@@ -149,19 +148,19 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) { )} - {/* Authenticating */} + {/* Authenticating - waiting for user to complete /login */} {status === 'authenticating' && (
- +

- Authenticating... + Complete Authentication

- A terminal window has opened. Please complete the authentication in your browser. + A terminal window has opened with Claude CLI.

@@ -170,17 +169,48 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
-

What's happening:

+

Complete these steps in the terminal:

    -
  1. A terminal opened and ran claude setup-token
  2. -
  3. Your browser should open to authenticate with Claude
  4. +
  5. Type /login and press Enter
  6. +
  7. Your browser will open for Claude authentication
  8. Complete the OAuth flow in your browser
  9. -
  10. The terminal will display your token (starts with sk-ant-oat01-...)
  11. -
  12. Auto Claude will automatically detect and save it
  13. +
  14. Return here and click Verify Authentication
+ + {error && ( +
+

{error}

+
+ )} + +
+ +
+ +
+
+ )} + + {/* Verifying */} + {status === 'verifying' && ( + + +
+ +
+

+ Verifying Authentication... +

+

+ Checking your Claude credentials. +

+
@@ -227,7 +257,8 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
- {onCancel && ( diff --git a/apps/frontend/src/renderer/components/settings/AuthTerminal.tsx b/apps/frontend/src/renderer/components/settings/AuthTerminal.tsx new file mode 100644 index 00000000..c51604c2 --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/AuthTerminal.tsx @@ -0,0 +1,470 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { Terminal as XTerminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import { WebLinksAddon } from '@xterm/addon-web-links'; +import '@xterm/xterm/css/xterm.css'; +import { X, Loader2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../ui/button'; +import { cn } from '../../lib/utils'; + +// Debug logging - only active when DEBUG=true (npm run dev:debug) +const DEBUG = typeof process !== 'undefined' && process.env?.DEBUG === 'true'; +const debugLog = (...args: unknown[]) => { + if (DEBUG) console.warn('[AuthTerminal:DEBUG]', ...args); +}; + +interface AuthTerminalProps { + /** Terminal ID for this auth session */ + terminalId: string; + /** Claude config directory for this profile (CLAUDE_CONFIG_DIR) */ + configDir: string; + /** Profile name being authenticated */ + profileName: string; + /** Callback when terminal is closed */ + onClose: () => void; + /** Callback when authentication succeeds */ + onAuthSuccess?: (email?: string) => void; + /** Callback when authentication fails */ + onAuthError?: (error: string) => void; +} + +/** + * Embedded terminal component for Claude profile authentication. + * Shows a minimal terminal where users can run /login to authenticate. + * Automatically detects OAuth token capture via TERMINAL_OAUTH_TOKEN event. + */ +export function AuthTerminal({ + terminalId, + configDir, + profileName, + onClose, + onAuthSuccess, + onAuthError, +}: AuthTerminalProps) { + const { t } = useTranslation('common'); + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef(null); + const isCreatedRef = useRef(false); + const cleanupFnsRef = useRef<(() => void)[]>([]); + const loginSentRef = useRef(false); // Track if /login was already sent + const loginTimeoutRef = useRef(null); // Track setTimeout for cleanup + const successTimeoutRef = useRef(null); // Track success auto-close timeout for cleanup + const authCompletedRef = useRef(false); // Track if auth has already completed to prevent race conditions + + const [status, setStatus] = useState<'connecting' | 'ready' | 'onboarding' | 'success' | 'error'>('connecting'); + const [authEmail, setAuthEmail] = useState(); + const [errorMessage, setErrorMessage] = useState(); + + // Refs to track current status/email for exit handler closure + const statusRef = useRef(status); + const authEmailRef = useRef(authEmail); + statusRef.current = status; + authEmailRef.current = authEmail; + + debugLog('Component render', { terminalId, status, isCreated: isCreatedRef.current, loginSent: loginSentRef.current }); + + // Initialize xterm + useEffect(() => { + if (!terminalRef.current || xtermRef.current) return; + + const xterm = new XTerminal({ + cursorBlink: true, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + theme: { + background: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + cursor: 'hsl(var(--primary))', + selectionBackground: 'hsl(var(--accent))', + }, + allowProposedApi: true, + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + + xterm.loadAddon(fitAddon); + xterm.loadAddon(webLinksAddon); + xterm.open(terminalRef.current); + + // Initial fit + setTimeout(() => { + try { + fitAddon.fit(); + } catch { + // Ignore fit errors + } + }, 100); + + xtermRef.current = xterm; + fitAddonRef.current = fitAddon; + + return () => { + xterm.dispose(); + xtermRef.current = null; + fitAddonRef.current = null; + }; + }, []); + + // Create the PTY terminal + useEffect(() => { + if (!xtermRef.current || isCreatedRef.current) return; + + const createTerminal = async () => { + const xterm = xtermRef.current; + const fitAddon = fitAddonRef.current; + if (!xterm || !fitAddon) return; + + try { + // Fit to get proper dimensions + fitAddon.fit(); + const cols = xterm.cols; + const rows = xterm.rows; + + console.warn('[AuthTerminal] Creating terminal:', terminalId, { cols, rows, configDir }); + + // Create terminal with CLAUDE_CONFIG_DIR set for this profile + // The terminal ID pattern (claude-login-{profileId}-*) tells the + // integration handler which profile to save captured tokens to + const result = await window.electronAPI.createTerminal({ + id: terminalId, + cols, + rows, + skipOAuthToken: true, // Don't inject existing token for auth terminals + env: { + CLAUDE_CONFIG_DIR: configDir, + }, + }); + + if (!result.success) { + console.error('[AuthTerminal] Failed to create terminal:', result.error); + setStatus('error'); + const errorMsg = result.error || t('authTerminal.failedToCreate'); + setErrorMessage(errorMsg); + onAuthError?.(errorMsg); + return; + } + + isCreatedRef.current = true; + setStatus('ready'); + + // Show instructions + const titleText = t('authTerminal.instructionTitle'); + const step1Text = t('authTerminal.step1'); + const step2Text = t('authTerminal.step2'); + const step3Text = t('authTerminal.step3'); + + xterm.writeln('\x1b[1;36m╔════════════════════════════════════════════════════════════╗\x1b[0m'); + xterm.writeln(`\x1b[1;36m║\x1b[0m \x1b[1m${titleText}\x1b[0m${' '.repeat(Math.max(0, 60 - titleText.length - 3))}\x1b[1;36m║\x1b[0m`); + xterm.writeln('\x1b[1;36m╠════════════════════════════════════════════════════════════╣\x1b[0m'); + xterm.writeln('\x1b[1;36m║\x1b[0m \x1b[1;36m║\x1b[0m'); + xterm.writeln(`\x1b[1;36m║\x1b[0m \x1b[33m1.\x1b[0m ${step1Text}${' '.repeat(Math.max(0, 60 - step1Text.length - 6))}\x1b[1;36m║\x1b[0m`); + xterm.writeln(`\x1b[1;36m║\x1b[0m \x1b[33m2.\x1b[0m ${step2Text}${' '.repeat(Math.max(0, 60 - step2Text.length - 6))}\x1b[1;36m║\x1b[0m`); + xterm.writeln(`\x1b[1;36m║\x1b[0m \x1b[33m3.\x1b[0m ${step3Text}${' '.repeat(Math.max(0, 60 - step3Text.length - 6))}\x1b[1;36m║\x1b[0m`); + xterm.writeln('\x1b[1;36m║\x1b[0m \x1b[1;36m║\x1b[0m'); + xterm.writeln('\x1b[1;36m╚════════════════════════════════════════════════════════════╝\x1b[0m'); + xterm.writeln(''); + + // Pre-fill the terminal with 'claude /login' command + // Wait a moment for the shell prompt to be ready, then send the command + // (without carriage return so user must press Enter) + // Guard: only send once per component lifecycle + if (!loginSentRef.current) { + debugLog('Scheduling /login pre-fill', { terminalId, delay: 500 }); + loginTimeoutRef.current = setTimeout(() => { + // Double-check guard in case of race conditions + if (!loginSentRef.current) { + loginSentRef.current = true; + debugLog('Sending /login pre-fill NOW', { terminalId }); + window.electronAPI.sendTerminalInput(terminalId, 'claude /login'); + } else { + debugLog('SKIPPED /login pre-fill (already sent)', { terminalId }); + } + }, 500); + } else { + debugLog('SKIPPED scheduling /login pre-fill (already sent)', { terminalId }); + } + + console.warn('[AuthTerminal] Terminal created successfully'); + } catch (error) { + console.error('[AuthTerminal] Error creating terminal:', error); + setStatus('error'); + const errorMsg = error instanceof Error ? error.message : t('authTerminal.unknownError'); + setErrorMessage(errorMsg); + onAuthError?.(errorMsg); + } + }; + + createTerminal(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- configDir is stable for auth terminal lifecycle + }, [terminalId, onAuthError]); + + // Setup terminal event listeners + useEffect(() => { + debugLog('Setting up event listeners effect', { terminalId, hasXterm: !!xtermRef.current }); + if (!xtermRef.current) return; + + const xterm = xtermRef.current; + + // Handle terminal output + const unsubOutput = window.electronAPI.onTerminalOutput((id, data) => { + if (id === terminalId && xterm) { + xterm.write(data); + } + }); + cleanupFnsRef.current.push(unsubOutput); + + // Handle terminal input - log user keystrokes for debugging + const inputDisposable = xterm.onData((data) => { + // Log Enter key presses and significant input + if (data === '\r' || data === '\n') { + debugLog('User pressed ENTER', { terminalId, status: statusRef.current }); + } else if (data.length > 1) { + debugLog('User input (paste or special)', { terminalId, dataLength: data.length }); + } + window.electronAPI.sendTerminalInput(terminalId, data); + }); + + // Handle OAuth token capture + const unsubOAuth = window.electronAPI.onTerminalOAuthToken((info) => { + console.warn('[AuthTerminal] OAuth token event:', info); + debugLog('OAuth token event received', { + terminalId: info.terminalId, + thisTerminalId: terminalId, + isMatch: info.terminalId === terminalId, + success: info.success, + needsOnboarding: info.needsOnboarding, + email: info.email, + currentStatus: statusRef.current, + loginSent: loginSentRef.current + }); + if (info.terminalId === terminalId) { + if (info.success) { + setAuthEmail(info.email); + // If needsOnboarding is true, user should complete setup in terminal + // Otherwise, authentication is fully complete + if (info.needsOnboarding) { + debugLog('Setting status to onboarding', { terminalId }); + setStatus('onboarding'); + } else { + debugLog('Setting status to success (no onboarding needed)', { terminalId }); + setStatus('success'); + onAuthSuccess?.(info.email); + } + } else { + debugLog('OAuth failed', { terminalId, message: info.message }); + setStatus('error'); + const errorMsg = info.message || t('authTerminal.authFailed'); + setErrorMessage(errorMsg); + onAuthError?.(errorMsg); + } + } + }); + cleanupFnsRef.current.push(unsubOAuth); + + // Handle terminal exit + const unsubExit = window.electronAPI.onTerminalExit((id, exitCode) => { + if (id === terminalId) { + console.warn('[AuthTerminal] Terminal exited:', exitCode, 'status:', statusRef.current); + debugLog('Terminal exit event', { + terminalId, + exitCode, + currentStatus: statusRef.current, + loginSent: loginSentRef.current, + willTransitionToSuccess: statusRef.current === 'onboarding' && exitCode === 0 + }); + // If we were in onboarding status and terminal exits with code 0, + // that means the user completed the onboarding successfully + if (statusRef.current === 'onboarding' && exitCode === 0) { + // Prevent race condition with onboarding-complete handler + if (authCompletedRef.current) { + debugLog('SKIPPED exit handler - auth already completed', { terminalId }); + return; + } + authCompletedRef.current = true; + debugLog('Transitioning from onboarding to success', { terminalId }); + setStatus('success'); + onAuthSuccess?.(authEmailRef.current); + } + // Don't close automatically - let user see any error messages + } + }); + cleanupFnsRef.current.push(unsubExit); + + // Handle onboarding complete (Claude shows ready state after login) + const unsubOnboardingComplete = window.electronAPI.onTerminalOnboardingComplete((info) => { + if (info.terminalId === terminalId) { + console.warn('[AuthTerminal] Onboarding complete:', info); + debugLog('Onboarding complete event', { + terminalId: info.terminalId, + profileId: info.profileId, + currentStatus: statusRef.current + }); + // Only process if we're in onboarding status + if (statusRef.current === 'onboarding') { + // Prevent race condition with terminal exit handler + if (authCompletedRef.current) { + debugLog('SKIPPED onboarding-complete handler - auth already completed', { terminalId }); + return; + } + authCompletedRef.current = true; + debugLog('Auto-closing terminal after onboarding complete', { terminalId }); + setStatus('success'); + onAuthSuccess?.(authEmailRef.current); + // Auto-close after a brief delay to show success UI + successTimeoutRef.current = setTimeout(() => { + if (isCreatedRef.current) { + window.electronAPI.destroyTerminal(terminalId).catch(console.error); + isCreatedRef.current = false; + } + onClose(); + }, 1500); + } + } + }); + cleanupFnsRef.current.push(unsubOnboardingComplete); + + return () => { + debugLog('Cleaning up event listeners', { terminalId }); + inputDisposable.dispose(); + cleanupFnsRef.current.forEach(fn => fn()); + cleanupFnsRef.current = []; + }; + }, [terminalId, onAuthSuccess, onAuthError]); + + // Handle resize + useEffect(() => { + const handleResize = () => { + if (fitAddonRef.current && xtermRef.current) { + try { + fitAddonRef.current.fit(); + const cols = xtermRef.current.cols; + const rows = xtermRef.current.rows; + window.electronAPI.resizeTerminal(terminalId, cols, rows); + } catch { + // Ignore resize errors + } + } + }; + + window.addEventListener('resize', handleResize); + + // Initial resize after a brief delay + const timer = setTimeout(handleResize, 200); + + return () => { + window.removeEventListener('resize', handleResize); + clearTimeout(timer); + }; + }, [terminalId]); + + // Cleanup terminal on unmount + useEffect(() => { + return () => { + debugLog('Component unmounting', { + terminalId, + isCreated: isCreatedRef.current, + loginSent: loginSentRef.current, + hasLoginTimeout: !!loginTimeoutRef.current, + hasSuccessTimeout: !!successTimeoutRef.current + }); + // Clear pending login timeout if component unmounts before it fires + if (loginTimeoutRef.current) { + debugLog('Clearing pending login timeout', { terminalId }); + clearTimeout(loginTimeoutRef.current); + loginTimeoutRef.current = null; + } + // Clear pending success timeout if component unmounts before it fires + if (successTimeoutRef.current) { + debugLog('Clearing pending success timeout', { terminalId }); + clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + if (isCreatedRef.current) { + debugLog('Destroying terminal', { terminalId }); + window.electronAPI.destroyTerminal(terminalId).catch(console.error); + } + }; + }, [terminalId]); + + const handleClose = useCallback(() => { + if (isCreatedRef.current) { + window.electronAPI.destroyTerminal(terminalId).catch(console.error); + isCreatedRef.current = false; + } + onClose(); + }, [terminalId, onClose]); + + return ( +
+ {/* Header */} +
+
+ {status === 'connecting' && ( + + )} + {status === 'ready' && ( +
+ )} + {status === 'onboarding' && ( +
+ )} + {status === 'success' && ( + + )} + {status === 'error' && ( + + )} + + {status === 'connecting' && t('authTerminal.connecting')} + {status === 'ready' && t('authTerminal.authenticate', { profileName })} + {status === 'onboarding' && t('authTerminal.completeSetup', { profileName })} + {status === 'success' && (authEmail ? t('authTerminal.authenticatedAs', { email: authEmail }) : t('authTerminal.authenticated'))} + {status === 'error' && t('authTerminal.authError')} + +
+ +
+ + {/* Terminal area */} +
+ + {/* Status bar */} + {status === 'onboarding' && ( +
+

+ {t('authTerminal.onboardingMessage')} +

+
+ )} + {status === 'success' && ( +
+

+ {t('authTerminal.successMessage')} +

+
+ )} + {status === 'error' && errorMessage && ( +
+

{errorMessage}

+
+ )} +
+ ); +} diff --git a/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx b/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx index df318f7a..3498eaf5 100644 --- a/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/IntegrationSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Key, @@ -27,8 +27,8 @@ import { Switch } from '../ui/switch'; import { cn } from '../../lib/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; import { SettingsSection } from './SettingsSection'; +import { AuthTerminal } from './AuthTerminal'; import { loadClaudeProfiles as loadGlobalClaudeProfiles } from '../../stores/claude-profile-store'; -import { useClaudeLoginTerminal } from '../../hooks/useClaudeLoginTerminal'; import { useToast } from '../../hooks/use-toast'; import type { AppSettings, ClaudeProfile, ClaudeAutoSwitchSettings } from '../../../shared/types'; @@ -68,6 +68,14 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte const [autoSwitchSettings, setAutoSwitchSettings] = useState(null); const [isLoadingAutoSwitch, setIsLoadingAutoSwitch] = useState(false); + // Auth terminal state - for embedded authentication + const [authTerminal, setAuthTerminal] = useState<{ + terminalId: string; + configDir: string; + profileId: string; + profileName: string; + } | null>(null); + // Load Claude profiles and auto-swap settings when section is shown useEffect(() => { if (isOpen) { @@ -76,51 +84,6 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte } }, [isOpen]); - // Listen for login terminal creation - makes the terminal visible so user can see OAuth flow - useClaudeLoginTerminal(); - - // Listen for OAuth authentication completion - useEffect(() => { - const unsubscribe = window.electronAPI.onTerminalOAuthToken(async (info) => { - if (info.success && info.profileId) { - // Reload profiles to show updated state - await loadClaudeProfiles(); - // Show simple success notification (non-blocking) - toast({ - title: t('integrations.toast.authSuccess'), - description: info.email ? t('integrations.toast.authSuccessWithEmail', { email: info.email }) : t('integrations.toast.authSuccessGeneric'), - }); - } else if (!info.success) { - // Handle authentication failure - await loadClaudeProfiles(); - - const errorMessage = info.message || ''; - let title = t('integrations.toast.authStartFailed'); - let description = t('integrations.toast.tryAgain'); - - // Provide specific error messages based on error type - if (errorMessage.toLowerCase().includes('cancelled') || errorMessage.toLowerCase().includes('timeout')) { - title = t('integrations.toast.authProcessFailed'); - description = errorMessage || t('integrations.toast.authProcessFailedDescription'); - } else if (errorMessage.toLowerCase().includes('invalid') || errorMessage.toLowerCase().includes('token')) { - title = t('integrations.toast.tokenSaveFailed'); - description = errorMessage || t('integrations.toast.tryAgain'); - } else if (errorMessage) { - title = t('integrations.toast.authProcessFailed'); - description = errorMessage; - } - - toast({ - variant: 'destructive', - title, - description, - }); - } - }); - - return unsubscribe; - }, [t, toast]); - const loadClaudeProfiles = async () => { setIsLoadingProfiles(true); try { @@ -159,6 +122,7 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte const profileName = newProfileName.trim(); const profileSlug = profileName.toLowerCase().replace(/\s+/g, '-'); + // Create the profile first const result = await window.electronAPI.saveClaudeProfile({ id: `profile-${Date.now()}`, name: profileName, @@ -168,38 +132,26 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte }); if (result.success && result.data) { - // Initialize the profile - const initResult = await window.electronAPI.initializeClaudeProfile(result.data.id); + await loadClaudeProfiles(); + setNewProfileName(''); - if (initResult.success) { - await loadClaudeProfiles(); - setNewProfileName(''); - // Note: The terminal is now visible in the UI via the onTerminalAuthCreated event - // Users can see the 'claude setup-token' output directly - } else { - await loadClaudeProfiles(); - setNewProfileName(''); + // Get terminal config for authentication + const authResult = await window.electronAPI.authenticateClaudeProfile(result.data.id); - const errorMessage = initResult.error || ''; - let title = t('integrations.toast.profileCreatedAuthFailed'); - let description = t('integrations.toast.profileCreatedAuthFailedDescription'); + if (authResult.success && authResult.data) { + setAuthenticatingProfileId(result.data.id); - if (errorMessage.toLowerCase().includes('max terminals')) { - title = t('integrations.toast.maxTerminalsReached'); - description = t('integrations.toast.maxTerminalsReachedDescription'); - } else if (errorMessage.toLowerCase().includes('terminal creation')) { - title = t('integrations.toast.terminalCreationFailed'); - description = t('integrations.toast.terminalCreationFailedDescription', { error: errorMessage }); - } else if (errorMessage.toLowerCase().includes('terminal')) { - title = t('integrations.toast.terminalError'); - description = t('integrations.toast.terminalErrorDescription', { error: errorMessage }); - } - - toast({ - variant: 'destructive', - title, - description, + // Set up embedded auth terminal + setAuthTerminal({ + terminalId: authResult.data.terminalId, + configDir: authResult.data.configDir, + profileId: result.data.id, + profileName, }); + + console.warn('[IntegrationSettings] New profile auth terminal ready:', authResult.data); + } else { + alert(t('integrations.alerts.profileCreatedAuthFailed', { error: authResult.error || t('integrations.toast.tryAgain') })); } } } catch (err) { @@ -299,50 +251,61 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte }; const handleAuthenticateProfile = async (profileId: string) => { + // Find the profile name for display + const profile = claudeProfiles.find(p => p.id === profileId); + const profileName = profile?.name || 'Profile'; + setAuthenticatingProfileId(profileId); try { - const initResult = await window.electronAPI.initializeClaudeProfile(profileId); - if (!initResult.success) { - const errorMessage = initResult.error || ''; - let title: string; - let description: string; + // Get terminal config from backend (terminalId and configDir) + const result = await window.electronAPI.authenticateClaudeProfile(profileId); - if (errorMessage.toLowerCase().includes('max terminals')) { - title = t('integrations.toast.maxTerminalsReached'); - description = t('integrations.toast.maxTerminalsReachedDescription'); - } else if (errorMessage.toLowerCase().includes('terminal creation')) { - title = t('integrations.toast.terminalCreationFailed'); - description = t('integrations.toast.terminalCreationFailedDescription', { error: errorMessage }); - } else if (errorMessage.toLowerCase().includes('terminal')) { - title = t('integrations.toast.terminalError'); - description = t('integrations.toast.terminalErrorDescription', { error: errorMessage }); - } else if (errorMessage) { - title = t('integrations.toast.authProcessFailed'); - description = errorMessage; - } else { - title = t('integrations.toast.authProcessFailed'); - description = t('integrations.toast.authProcessFailedDescription'); - } - - toast({ - variant: 'destructive', - title, - description, - }); + if (!result.success || !result.data) { + alert(t('integrations.alerts.authPrepareFailed', { error: result.error || t('integrations.toast.tryAgain') })); + setAuthenticatingProfileId(null); + return; } - // Note: If successful, the terminal is now visible in the UI via the onTerminalAuthCreated event - // Users can see the 'claude setup-token' output and complete OAuth flow directly - } catch (err) { - toast({ - variant: 'destructive', - title: t('integrations.toast.authStartFailed'), - description: t('integrations.toast.tryAgain'), + + // Set up embedded auth terminal + setAuthTerminal({ + terminalId: result.data.terminalId, + configDir: result.data.configDir, + profileId, + profileName, }); - } finally { + + console.warn('[IntegrationSettings] Auth terminal ready:', result.data); + } catch (err) { + console.error('Failed to authenticate profile:', err); + alert(t('integrations.alerts.authStartFailedMessage')); setAuthenticatingProfileId(null); } }; + // Handle auth terminal close + const handleAuthTerminalClose = useCallback(() => { + setAuthTerminal(null); + setAuthenticatingProfileId(null); + }, []); + + // Handle auth terminal success + const handleAuthTerminalSuccess = useCallback(async (email?: string) => { + console.warn('[IntegrationSettings] Auth success:', email); + + // Close terminal immediately + setAuthTerminal(null); + setAuthenticatingProfileId(null); + + // Reload profiles to get updated auth state + await loadClaudeProfiles(); + }, []); + + // Handle auth terminal error + const handleAuthTerminalError = useCallback((error: string) => { + console.error('[IntegrationSettings] Auth error:', error); + // Don't auto-close on error - let user see the error and close manually + }, []); + const toggleTokenEntry = (profileId: string) => { if (expandedTokenProfileId === profileId) { setExpandedTokenProfileId(null); @@ -532,7 +495,7 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte {t('integrations.active')} )} - {(profile.oauthToken || (profile.isDefault && profile.configDir)) ? ( + {profile.isAuthenticated ? ( {t('integrations.authenticated')} @@ -553,8 +516,7 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte {editingProfileId !== profile.id && (
{/* Authenticate button - show only if NOT authenticated */} - {/* A profile is authenticated if: has OAuth token OR (is default AND has configDir) */} - {!(profile.oauthToken || (profile.isDefault && profile.configDir)) ? ( + {!profile.isAuthenticated ? ( ) : ( /* Re-authenticate button for already authenticated profiles */ @@ -733,6 +700,22 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
)} + {/* Embedded Auth Terminal */} + {authTerminal && ( +
+
+ +
+
+ )} + {/* Add new account */}
setNewProfileName(e.target.value)} className="flex-1 h-8 text-sm" + disabled={!!authTerminal} onKeyDown={(e) => { if (e.key === 'Enter' && newProfileName.trim()) { handleAddProfile(); @@ -748,7 +732,7 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte />