feat(auth): replace setup-token with embedded /login terminal flow (#1321)

* feat(auth): replace setup-token with embedded /login terminal flow

Migrate OAuth authentication from external `claude setup-token` command
to an embedded terminal experience using `claude /login`:

- Add AuthTerminal component for in-app authentication
- Add session migration between profiles on profile switch
- Add keychain utilities for macOS credential detection
- Add profile change hook for terminal refresh after switch
- Update error messages to reference /login instead of setup-token
- Add i18n translations for all auth UI strings (EN + FR)
- Fix Windows path handling in session utils
- Harden Python temp file security (0o700 permissions, try-finally cleanup)

Cross-platform support:
- macOS: Full keychain integration
- Windows: Credential files + .claude.json verification
- Linux: Secret Service + .claude.json verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(auth): enhance OAuth flow with onboarding support

- Update OAuth token handling to prioritize configDir over stored tokens, allowing full Keychain credential access including subscription type and rate limit tier.
- Introduce `needsOnboarding` flag in OAuthTokenEvent to indicate when users must complete setup in the terminal.
- Modify AuthTerminal component to reflect onboarding status and provide user guidance.
- Update i18n strings for improved clarity on authentication steps and onboarding messages.
- Fix tests

This change improves the user experience by ensuring users are aware of necessary onboarding steps after receiving their OAuth token.

* feat(auth): add onboarding complete detection and auto-close

Detect when Claude Code shows the welcome/ready screen after OAuth login
and automatically close the auth terminal. This improves the login UX by:

- Adding handleOnboardingComplete() to detect ready state patterns
- Auto-closing auth terminal after successful onboarding
- Supporting re-authentication flow (logout first, then login)
- Extracting email from welcome screen to update profiles
- Adding TERMINAL_ONBOARDING_COMPLETE IPC channel

* fix(auth): add backwards compatibility for re-authenticating old setup-token profiles

When re-authenticating a profile that was set up with the old setup-token
system, the browser wasn't opening because Claude CLI detected existing
credentials in .claude.json and skipped the OAuth flow.

Now when authenticateClaudeProfile is called:
- Check if .claude.json exists with oauthAccount credentials
- Back up existing credentials to .claude.json.bak
- Allow /login to start fresh and open the browser for OAuth

This ensures smooth transition from the old setup-token system to the
new /login flow for existing profiles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: remove unused isReauth prop from auth terminal components

Clean up the isReauth approach that was replaced by the backend fix
(backing up .claude.json before re-authentication).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): remove obsolete CLAUDE_PROFILE_INITIALIZE tests and fix extractEmail expectation

- Remove CLAUDE_PROFILE_INITIALIZE handler tests since the handler was
  deprecated as part of the migration to the new /login OAuth flow
- Fix extractEmail test to expect correct behavior (email extraction
  now works for "Authenticated as user@example.com" format)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(auth): use platform detection functions instead of undefined platform module

Replace platform.system() calls with is_macos() and is_windows() functions
that are already imported from core.platform.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review findings (8 issues)

HIGH priority fixes:
- session-utils: Strip Windows drive letters to avoid invalid colons in paths
- AuthTerminal: Use Math.max(0, ...) to prevent RangeError on long translations

MEDIUM priority fixes:
- session-utils: Clean up orphaned session file on partial migration failure
- AuthTerminal: Add authCompletedRef to prevent race condition double-callback
- AuthTerminal: Add successTimeoutRef for proper cleanup on unmount
- claude-code-handlers: Add escapeBashCommand() for Linux terminal defense-in-depth

LOW priority fixes:
- keychain-utils: Use isMacOS() from platform module instead of direct check
- claude-integration-handler: Centralize AUTH_TERMINAL_ID_PATTERN constant

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: complete remaining PR review issues (#7, #9)

Issue #7 (MEDIUM): Code duplication in invokeClaude/invokeClaudeAsync
- Add comprehensive unit tests for both functions (265 lines)
- Extract shared logic into executeProfileCommand() and executeProfileCommandAsync()
- Reduce ~60 lines of duplication while maintaining clarity
- All 75 tests passing

Issue #9 (LOW): Keychain error handling indistinguishable
- Add optional error field to KeychainCredentials interface
- Distinguish "not found" (exit 44) from actual failures
- Update call site to log appropriate error instead of "will retry"
- Backward compatible change

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: enable CodeQL scanning on all PRs

Remove the if condition that was skipping CodeQL on pull requests.
CodeQL will now run on all PRs, pushes to main, and weekly schedule.

Note: This adds 40-60 min to PR checks but provides full security scanning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address follow-up PR review findings (6 issues)

MEDIUM priority fixes:
- NEW-006: Add backup restoration when auth fails (.claude.json.bak)
- NEW-002: Add configDir path validation to prevent arbitrary file reads
  - New utility: config-path-validator.ts
  - Validates paths in checkProfileAuthentication, CLAUDE_PROFILE_AUTHENTICATE,
    and CLAUDE_PROFILE_SAVE handlers

LOW priority fixes:
- NEW-003: Remove token length from warning logs (security hardening)
- NEW-004: Eliminate TOCTOU race conditions in session migration
- NEW-007: Remove sensitive buffer contents from debug logs
- NEW-008: Use private temp directory for expect script (auth.py)

FALSE POSITIVE (no fix needed):
- NEW-005: Keychain cache keys are already unique per profile (hash-based)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: path validator test mock and boundary check

- Mock isValidConfigDir in tests to allow temp directory paths
- Add path separator boundary check to prevent path traversal
  (e.g., /home/alice-malicious bypassing /home/alice validation)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address all PR review findings with quality improvements

- LOGIC-001: Error results now cache for 10s (vs 5min) for quick recovery
- SEC-001: Email logging changed to boolean hasEmail flag for privacy
- LOGIC-004: Fixed misleading 'will retry' log message
- TEST-001: Added 35 comprehensive unit tests for path validator
- LOGIC-002: Replaced string matching with error.code checks
- QUAL-002: Moved dynamic require to top-level import
- QUAL-003: Refactored nested try-finally to TemporaryDirectory

Additional quality improvements:
- Extract isNodeError type guard to shared utils/type-guards.ts
- Fix logging convention (console.log for success, warn for errors)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address cursor bot and additional review findings

Fixes:
- Linux command escaping: Remove escapeBashCommand for trusted install commands
  that use semicolons as statement separators
- Session path format: Keep leading dash to match Claude CLI format
  (-Users-foo-bar instead of Users-foo-bar)
- Platform test coverage: Run Unix path tests on all platforms, document
  Node.js path.resolve() platform-specific behavior
- Security executable: Use path resolution instead of hardcoded /usr/bin/security
- PII logging: Add maskEmail() helper and redact emails in console.warn calls

Additional quality improvements (NEW-003 through NEW-006):
- Token validation: Use 'sk-ant-' prefix for future compatibility
- Logging level: Use console.debug for successful credential retrieval
- Stale backup cleanup: Remove old .claude.json.bak on auth start
- Temp directory: Remove predictable prefix for defense-in-depth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove premature backup deletion that could lose valid credentials

NEW-005-REVIEW: The stale backup cleanup at AUTHENTICATE handler was flawed.
It assumed "both .claude.json and .bak exist = previous auth succeeded" but
this is wrong - the app could have crashed after /login wrote an incomplete
.claude.json but before VERIFY_AUTH confirmed valid credentials.

Removed the premature cleanup. Backup deletion now only happens:
1. In VERIFY_AUTH after confirming valid credentials (safe)
2. When creating a new backup (removes old backup first)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: AUTH_TERMINAL_ID_PATTERN regex to match actual profile IDs

The old regex only matched 'default' or 'profile-\d+' but actual profile
IDs are sanitized names like 'work', 'my-profile' generated by
generateProfileId(). This caused OAuth token capture to silently fail
for all non-default profiles.

Updated regex to match the actual profile ID format: lowercase letters,
numbers, and hyphens with a 13+ digit timestamp suffix.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Test User <test@example.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-19 20:04:47 +01:00
committed by GitHub
parent 8a2f3acd4a
commit 11f8d572ff
52 changed files with 3877 additions and 815 deletions
+3 -7
View File
@@ -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:
+4 -3
View File
@@ -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
+6 -4
View File
@@ -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
+4 -4
View File
@@ -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)
""",
)
+193 -10
View File
@@ -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"
)
+6 -2
View File
@@ -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);
});
};
@@ -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
}
});
}
@@ -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
});
});
@@ -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);
});
});
});
@@ -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<string, unknown>,
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,
@@ -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<string, string> {
const profile = this.getActiveProfile();
const env: Record<string, string> = {};
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;
@@ -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<string, KeychainCacheEntry>();
// 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<string, unknown>;
// 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<string, unknown>;
// 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();
}
}
@@ -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);
}
@@ -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<void> {
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<void> {
// 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<void> {
`;
}
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<void> {
// '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<void> => {
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<void> {
// 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<void> {
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<void> {
// 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<void> {
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<void> {
}
}
/**
* 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<IPCResult<ClaudeCodeVersionInfo>> => {
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<IPCResult<{ terminalId: string; configDir: string }>> => {
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<IPCResult<{ authenticated: boolean; email?: string }>> => {
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');
}
@@ -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<IPCResult> => {
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<void>[] = [];
// 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<IPCResult> => {
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,
@@ -293,8 +293,7 @@ export function getProfileEnv(profileId?: string): Record<string, string> {
// 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
};
@@ -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<TerminalProcess> = {}): 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);
});
});
});
/**
@@ -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');
});
});
});
@@ -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<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);
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) {
@@ -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)
@@ -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) {
@@ -43,9 +43,9 @@ export async function createTerminal(
getWindow: WindowGetter,
dataHandler: DataHandlerFn
): Promise<TerminalOperationResult> {
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);
@@ -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
*/
+26
View File
@@ -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;
}
@@ -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;
}
@@ -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;
}
+66 -1
View File
@@ -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<IPCResult>;
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<IPCResult<ClaudeProfileSettings>>;
@@ -97,6 +106,8 @@ export interface TerminalAPI {
switchClaudeProfile: (terminalId: string, profileId: string) => Promise<IPCResult>;
initializeClaudeProfile: (profileId: string) => Promise<IPCResult>;
setClaudeProfileToken: (profileId: string, token: string, email?: string) => Promise<IPCResult>;
authenticateClaudeProfile: (profileId: string) => Promise<IPCResult<{ terminalId: string; configDir: string }>>;
verifyClaudeProfileAuth: (profileId: string) => Promise<IPCResult<{ authenticated: boolean; email?: string }>>;
getAutoSwitchSettings: () => Promise<IPCResult<import('../../shared/types').ClaudeAutoSwitchSettings>>;
updateAutoSwitchSettings: (settings: Partial<import('../../shared/types').ClaudeAutoSwitchSettings>) => Promise<IPCResult>;
fetchClaudeUsage: (terminalId: string) => Promise<IPCResult>;
@@ -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<IPCResult> =>
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<IPCResult<ClaudeProfileSettings>> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILES_GET),
@@ -393,6 +452,12 @@ export const createTerminalAPI = (): TerminalAPI => ({
setClaudeProfileToken: (profileId: string, token: string, email?: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_SET_TOKEN, profileId, token, email),
authenticateClaudeProfile: (profileId: string): Promise<IPCResult<{ terminalId: string; configDir: string }>> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTHENTICATE, profileId),
verifyClaudeProfileAuth: (profileId: string): Promise<IPCResult<{ authenticated: boolean; email?: string }>> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_VERIFY_AUTH, profileId),
getAutoSwitchSettings: (): Promise<IPCResult<import('../../shared/types').ClaudeAutoSwitchSettings>> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS),
+23 -18
View File
@@ -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 */}
<Dialog open={showInitDialog} onOpenChange={(open) => {
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) {
@@ -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' })
];
@@ -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);
@@ -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);
@@ -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<string | null>(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) {
</div>
)}
{/* Embedded Auth Terminal */}
{authTerminal && (
<div className="mb-4">
<div className="rounded-lg border border-primary/30 overflow-hidden" style={{ height: '320px' }}>
<AuthTerminal
terminalId={authTerminal.terminalId}
configDir={authTerminal.configDir}
profileName={authTerminal.profileName}
onClose={handleAuthTerminalClose}
onAuthSuccess={handleAuthTerminalSuccess}
onAuthError={handleAuthTerminalError}
/>
</div>
</div>
)}
{/* Add new account input */}
<div className="flex items-center gap-2">
<Input
@@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useRef } from 'react';
import {
Key,
Loader2,
CheckCircle2,
AlertCircle,
Info,
Sparkles
Sparkles,
RefreshCw
} from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent } from '../ui/card';
@@ -17,58 +18,21 @@ interface ClaudeOAuthFlowProps {
/**
* Claude OAuth flow component for setup wizard
* Guides users through authenticating with Claude using claude setup-token
* Guides users through authenticating with Claude by opening a visible terminal
* where they type /login to authenticate. Uses manual verification instead of
* auto-polling to avoid race conditions with keychain auto-reconnect.
*/
export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
const [status, setStatus] = useState<'ready' | 'authenticating' | 'success' | 'error'>('ready');
const [status, setStatus] = useState<'ready' | 'authenticating' | 'verifying' | 'success' | 'error'>('ready');
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState<string | undefined>();
const [authenticatingProfileId, setAuthenticatingProfileId] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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.
</p>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
@@ -149,19 +148,19 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
</div>
)}
{/* Authenticating */}
{/* Authenticating - waiting for user to complete /login */}
{status === 'authenticating' && (
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<Key className="h-6 w-6 text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Authenticating...
Complete Authentication
</h3>
<p className="text-sm text-muted-foreground mt-1">
A terminal window has opened. Please complete the authentication in your browser.
A terminal window has opened with Claude CLI.
</p>
</div>
</div>
@@ -170,17 +169,48 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">What's happening:</p>
<p className="font-medium">Complete these steps in the terminal:</p>
<ol className="list-decimal list-inside space-y-1 ml-2">
<li>A terminal opened and ran <code className="px-1 bg-muted rounded">claude setup-token</code></li>
<li>Your browser should open to authenticate with Claude</li>
<li>Type <code className="px-1 bg-muted rounded">/login</code> and press Enter</li>
<li>Your browser will open for Claude authentication</li>
<li>Complete the OAuth flow in your browser</li>
<li>The terminal will display your token (starts with sk-ant-oat01-...)</li>
<li>Auto Claude will automatically detect and save it</li>
<li>Return here and click <strong>Verify Authentication</strong></li>
</ol>
</div>
</div>
</div>
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div className="flex justify-center gap-3">
<Button onClick={handleVerifyAuth} className="gap-2">
<CheckCircle2 className="h-4 w-4" />
Verify Authentication
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Verifying */}
{status === 'verifying' && (
<Card className="border border-info/30 bg-info/10">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<Loader2 className="h-6 w-6 animate-spin text-info shrink-0" />
<div className="flex-1">
<h3 className="text-lg font-medium text-foreground">
Verifying Authentication...
</h3>
<p className="text-sm text-muted-foreground mt-1">
Checking your Claude credentials.
</p>
</div>
</div>
</CardContent>
</Card>
@@ -227,7 +257,8 @@ export function ClaudeOAuthFlow({ onSuccess, onCancel }: ClaudeOAuthFlowProps) {
</Card>
<div className="flex justify-center gap-3">
<Button onClick={handleRetry} variant="outline">
<Button onClick={handleRetry} variant="outline" className="gap-2">
<RefreshCw className="h-4 w-4" />
Retry
</Button>
{onCancel && (
@@ -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<HTMLDivElement>(null);
const xtermRef = useRef<XTerminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const isCreatedRef = useRef(false);
const cleanupFnsRef = useRef<(() => void)[]>([]);
const loginSentRef = useRef(false); // Track if /login was already sent
const loginTimeoutRef = useRef<NodeJS.Timeout | null>(null); // Track setTimeout for cleanup
const successTimeoutRef = useRef<NodeJS.Timeout | null>(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<string | undefined>();
const [errorMessage, setErrorMessage] = useState<string | undefined>();
// 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 (
<div className="flex flex-col h-full border border-border rounded-lg overflow-hidden bg-card">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
{status === 'connecting' && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{status === 'ready' && (
<div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
)}
{status === 'onboarding' && (
<div className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />
)}
{status === 'success' && (
<CheckCircle2 className="h-4 w-4 text-success" />
)}
{status === 'error' && (
<AlertCircle className="h-4 w-4 text-destructive" />
)}
<span className="text-sm font-medium">
{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')}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-6 w-6"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Terminal area */}
<div
ref={terminalRef}
className={cn(
"flex-1 min-h-[200px]",
status === 'success' && "opacity-50"
)}
style={{ padding: '8px' }}
/>
{/* Status bar */}
{status === 'onboarding' && (
<div className="px-3 py-2 border-t border-border bg-blue-500/10">
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('authTerminal.onboardingMessage')}
</p>
</div>
)}
{status === 'success' && (
<div className="px-3 py-2 border-t border-border bg-success/10">
<p className="text-sm text-success">
{t('authTerminal.successMessage')}
</p>
</div>
)}
{status === 'error' && errorMessage && (
<div className="px-3 py-2 border-t border-border bg-destructive/10">
<p className="text-sm text-destructive">{errorMessage}</p>
</div>
)}
</div>
);
}
@@ -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<ClaudeAutoSwitchSettings | null>(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')}
</span>
)}
{(profile.oauthToken || (profile.isDefault && profile.configDir)) ? (
{profile.isAuthenticated ? (
<span className="text-xs bg-success/20 text-success px-1.5 py-0.5 rounded flex items-center gap-1">
<Check className="h-3 w-3" />
{t('integrations.authenticated')}
@@ -553,8 +516,7 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
{editingProfileId !== profile.id && (
<div className="flex items-center gap-1">
{/* 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 ? (
<Button
variant="outline"
size="sm"
@@ -563,11 +525,16 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
className="gap-1 h-7 text-xs"
>
{authenticatingProfileId === profile.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
<>
<Loader2 className="h-3 w-3 animate-spin" />
{t('integrations.authenticating')}
</>
) : (
<LogIn className="h-3 w-3" />
<>
<LogIn className="h-3 w-3" />
{t('integrations.authenticate')}
</>
)}
{t('integrations.authenticate')}
</Button>
) : (
/* Re-authenticate button for already authenticated profiles */
@@ -733,6 +700,22 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
</div>
)}
{/* Embedded Auth Terminal */}
{authTerminal && (
<div className="mb-4">
<div className="rounded-lg border border-primary/30 overflow-hidden" style={{ height: '320px' }}>
<AuthTerminal
terminalId={authTerminal.terminalId}
configDir={authTerminal.configDir}
profileName={authTerminal.profileName}
onClose={handleAuthTerminalClose}
onAuthSuccess={handleAuthTerminalSuccess}
onAuthError={handleAuthTerminalError}
/>
</div>
</div>
)}
{/* Add new account */}
<div className="flex items-center gap-2">
<Input
@@ -740,6 +723,7 @@ export function IntegrationSettings({ settings, onSettingsChange, isOpen }: Inte
value={newProfileName}
onChange={(e) => 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
/>
<Button
onClick={handleAddProfile}
disabled={!newProfileName.trim() || isAddingProfile}
disabled={!newProfileName.trim() || isAddingProfile || !!authTerminal}
size="sm"
className="gap-1 shrink-0"
>
+1 -1
View File
@@ -1,5 +1,4 @@
// Export all custom hooks
export { useClaudeLoginTerminal } from './useClaudeLoginTerminal';
export { useIpcListeners } from './useIpc';
export {
useResolvedAgentSettings,
@@ -8,3 +7,4 @@ export {
type AgentSettingsSource,
} from './useResolvedAgentSettings';
export { useVirtualizedTree } from './useVirtualizedTree';
export { useTerminalProfileChange } from './useTerminalProfileChange';
@@ -1,37 +0,0 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useTerminalStore } from '../stores/terminal-store';
import { toast } from './use-toast';
/**
* Custom hook to handle Claude profile login terminal visibility.
* Listens for onTerminalAuthCreated events and adds the terminal
* to the store so users can see the OAuth flow output.
*/
export function useClaudeLoginTerminal() {
const { t } = useTranslation('terminal');
const addExternalTerminal = useTerminalStore((state) => state.addExternalTerminal);
useEffect(() => {
const unsubscribe = window.electronAPI.onTerminalAuthCreated((info) => {
// Add the terminal to the store so it becomes visible in the UI
// This allows users to see the 'claude setup-token' output and complete the OAuth flow
// cwd is optional and defaults to HOME or '~' in addExternalTerminal
const terminal = addExternalTerminal(
info.terminalId,
t('auth.terminalTitle', { profileName: info.profileName })
);
// If terminal creation failed (max terminals reached), show a notification
// The terminal was created in main process but we can't show it in UI
if (!terminal) {
toast({
title: t('auth.maxTerminalsReached'),
variant: 'destructive',
});
}
});
return unsubscribe;
}, [addExternalTerminal, t]);
}
@@ -0,0 +1,146 @@
import { useEffect, useCallback, useRef } from 'react';
import { useTerminalStore } from '../stores/terminal-store';
import { terminalBufferManager } from '../lib/terminal-buffer-manager';
import type { TerminalProfileChangedEvent } from '../../shared/types';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
/**
* Hook to handle terminal profile change events.
* When a Claude profile switches, all terminals need to be recreated with the new profile's
* environment variables. Terminals with active Claude sessions will have their sessions
* migrated and can be resumed with --resume {sessionId}.
*/
export function useTerminalProfileChange(): void {
// Track terminals being recreated to prevent duplicate processing
const recreatingTerminals = useRef<Set<string>>(new Set());
const recreateTerminal = useCallback(async (
terminalId: string,
sessionId?: string,
sessionMigrated?: boolean
) => {
// Prevent duplicate recreation
if (recreatingTerminals.current.has(terminalId)) {
debugLog('[useTerminalProfileChange] Terminal already being recreated:', terminalId);
return;
}
recreatingTerminals.current.add(terminalId);
try {
const store = useTerminalStore.getState();
const terminal = store.getTerminal(terminalId);
if (!terminal) {
debugLog('[useTerminalProfileChange] Terminal not found in store:', terminalId);
return;
}
debugLog('[useTerminalProfileChange] Recreating terminal:', {
terminalId,
sessionId,
sessionMigrated,
cwd: terminal.cwd,
projectPath: terminal.projectPath
});
// Save terminal state before destroying
const terminalState = {
cwd: terminal.cwd,
projectPath: terminal.projectPath,
title: terminal.title,
worktreeConfig: terminal.worktreeConfig,
associatedTaskId: terminal.associatedTaskId
};
// Clear the output buffer for this terminal
terminalBufferManager.clear(terminalId);
// Destroy the existing terminal (PTY process)
await window.electronAPI.destroyTerminal(terminalId);
// Remove from store
store.removeTerminal(terminalId);
// Create a new terminal with the same settings
// The new terminal will be created with the new profile's env vars
const newTerminal = store.addTerminal(terminalState.cwd, terminalState.projectPath);
if (!newTerminal) {
debugError('[useTerminalProfileChange] Failed to create new terminal');
return;
}
// Restore terminal state
store.updateTerminal(newTerminal.id, {
title: terminalState.title,
worktreeConfig: terminalState.worktreeConfig,
associatedTaskId: terminalState.associatedTaskId
});
// Create the new PTY process
const createResult = await window.electronAPI.createTerminal({
id: newTerminal.id,
cwd: terminalState.cwd,
projectPath: terminalState.projectPath
});
// Set worktree config after terminal creation if it existed
if (terminalState.worktreeConfig) {
window.electronAPI.setTerminalWorktreeConfig(newTerminal.id, terminalState.worktreeConfig);
}
if (!createResult.success) {
debugError('[useTerminalProfileChange] Failed to create PTY:', createResult.error);
store.removeTerminal(newTerminal.id);
return;
}
debugLog('[useTerminalProfileChange] Terminal recreated:', {
oldId: terminalId,
newId: newTerminal.id
});
// If there was an active Claude session that was migrated, show a message
// and set up for potential resume
if (sessionId && sessionMigrated) {
debugLog('[useTerminalProfileChange] Session migrated, ready for resume:', sessionId);
// Store the session ID so the user can resume if desired
store.setClaudeSessionId(newTerminal.id, sessionId);
// Set pending resume flag - user can trigger resume from terminal tab
store.setPendingClaudeResume(newTerminal.id, true);
// Send a message to the terminal about the session
window.electronAPI.sendTerminalInput(
newTerminal.id,
`# Profile switched. Previous Claude session available.\n# Run: claude --resume ${sessionId}\n`
);
}
} finally {
recreatingTerminals.current.delete(terminalId);
}
}, []);
useEffect(() => {
const cleanup = window.electronAPI.onTerminalProfileChanged(async (event: TerminalProfileChangedEvent) => {
debugLog('[useTerminalProfileChange] Profile changed event received:', {
previousProfileId: event.previousProfileId,
newProfileId: event.newProfileId,
terminalsCount: event.terminals.length
});
// Recreate all terminals sequentially to avoid race conditions
for (const terminalInfo of event.terminals) {
await recreateTerminal(
terminalInfo.id,
terminalInfo.sessionId,
terminalInfo.sessionMigrated
);
}
debugLog('[useTerminalProfileChange] All terminals recreated');
});
return cleanup;
}, [recreateTerminal]);
}
@@ -68,5 +68,16 @@ export const claudeProfileMock = {
onUsageUpdated: () => () => {},
onProactiveSwapNotification: () => () => {}
onProactiveSwapNotification: () => () => {},
// Returns terminal config for embedded authentication
authenticateClaudeProfile: async (profileId: string) => ({
success: true,
data: { terminalId: `claude-login-${profileId}-${Date.now()}`, configDir: '/mock/config' }
}),
verifyClaudeProfileAuth: async (_profileId: string) => ({
success: true,
data: { authenticated: false, email: undefined }
})
};
@@ -103,5 +103,13 @@ export const terminalMock = {
onTerminalAuthCreated: () => () => {},
onTerminalClaudeBusy: () => () => {},
onTerminalClaudeExit: () => () => {},
onTerminalPendingResume: () => () => {}
onTerminalOnboardingComplete: () => () => {},
onTerminalPendingResume: () => () => {},
onTerminalProfileChanged: () => () => {},
onTerminalOAuthCodeNeeded: () => () => {},
// OAuth code submission
submitOAuthCode: async () => ({
success: true
})
};
@@ -96,8 +96,12 @@ export const IPC_CHANNELS = {
TERMINAL_RATE_LIMIT: 'terminal:rateLimit', // Claude Code rate limit detected
TERMINAL_OAUTH_TOKEN: 'terminal:oauthToken', // OAuth token captured from setup-token output
TERMINAL_AUTH_CREATED: 'terminal:authCreated', // Auth terminal created for OAuth flow
TERMINAL_OAUTH_CODE_NEEDED: 'terminal:oauthCodeNeeded', // Request user to paste OAuth code from browser
TERMINAL_OAUTH_CODE_SUBMIT: 'terminal:oauthCodeSubmit', // User submitted OAuth code to send to terminal
TERMINAL_CLAUDE_BUSY: 'terminal:claudeBusy', // Claude Code busy state (for visual indicator)
TERMINAL_CLAUDE_EXIT: 'terminal:claudeExit', // Claude Code exited (returned to shell)
TERMINAL_ONBOARDING_COMPLETE: 'terminal:onboardingComplete', // Claude onboarding complete (ready for input after login)
TERMINAL_PROFILE_CHANGED: 'terminal:profileChanged', // Profile changed, terminals need refresh (main -> renderer)
// Claude profile management (multi-account support)
CLAUDE_PROFILES_GET: 'claude:profilesGet',
@@ -108,6 +112,8 @@ export const IPC_CHANNELS = {
CLAUDE_PROFILE_SWITCH: 'claude:profileSwitch',
CLAUDE_PROFILE_INITIALIZE: 'claude:profileInitialize',
CLAUDE_PROFILE_SET_TOKEN: 'claude:profileSetToken', // Set OAuth token for a profile
CLAUDE_PROFILE_AUTHENTICATE: 'claude:profileAuthenticate', // Open visible terminal for OAuth login
CLAUDE_PROFILE_VERIFY_AUTH: 'claude:profileVerifyAuth', // Check if profile has been authenticated
CLAUDE_PROFILE_AUTO_SWITCH_SETTINGS: 'claude:autoSwitchSettings',
CLAUDE_PROFILE_UPDATE_AUTO_SWITCH: 'claude:updateAutoSwitch',
CLAUDE_PROFILE_FETCH_USAGE: 'claude:fetchUsage',
@@ -407,5 +407,47 @@
"loadingMore": "Loading more...",
"scrollForMore": "Scroll for more",
"allLoaded": "All issues loaded"
},
"oauth": {
"enterCode": "Manual Code Entry (Fallback)",
"enterCodeDescription": "This dialog is only needed if the browser didn't redirect automatically. If authentication already completed in your browser, you can close this dialog.",
"fallbackNote": "Only use this if you see a \"Paste this into Claude Code\" page in your browser.",
"step1": "Complete the authorization in your browser",
"step2": "If you see a code page, copy the code shown",
"step3": "Paste the code below and click Submit",
"codeLabel": "Authorization Code",
"codePlaceholder": "Paste your code here (only if needed)...",
"codeHint": "The code is a long string shown only if automatic redirect failed",
"submit": "Submit",
"submitting": "Submitting...",
"codeSubmitted": "Code Submitted",
"codeSubmittedDescription": "Authentication should complete shortly. Check the terminal for confirmation.",
"codeSubmitFailed": "Failed to Submit Code",
"codeSubmitFailedDescription": "Please try again or copy the code manually to the terminal."
},
"authTerminal": {
"failedToCreate": "Failed to create terminal",
"unknownError": "Unknown error",
"instructionTitle": "Claude Authentication",
"step1": "Press Enter to start authentication",
"step2": "Complete authentication in your browser",
"step3": "Return here - auth will be detected automatically",
"authFailed": "Authentication failed",
"connecting": "Connecting...",
"authenticate": "Authenticate: {{profileName}}",
"completeSetup": "Complete setup: {{profileName}}",
"authenticatedAs": "Authenticated as {{email}}",
"authenticated": "Authenticated!",
"authError": "Authentication Error",
"onboardingMessage": "Token received! Complete the Claude Code setup below - this window will close automatically when done.",
"successMessage": "Authentication successful! Closing..."
},
"profileCreated": {
"title": "Profile \"{{profileName}}\" has been created.",
"instructions": "To authenticate this profile:",
"step1": "Go to Settings > Integrations",
"step2": "Find the profile in the Claude Accounts section",
"step3": "Click \"Authenticate\" to complete login",
"footer": "The account will be available once you complete authentication."
}
}
@@ -74,6 +74,11 @@
"tokenSavedDescription": "Your token has been saved successfully.",
"tokenSaveFailed": "Failed to save token",
"tryAgain": "Please try again."
},
"alerts": {
"profileCreatedAuthFailed": "Profile created but failed to prepare authentication: {{error}}",
"authPrepareFailed": "Failed to prepare authentication: {{error}}",
"authStartFailedMessage": "Failed to start authentication. Please try again."
}
},
"memory": {
@@ -414,9 +414,10 @@
"authenticated": "Authenticated",
"needsAuth": "Needs Auth",
"authenticate": "Authenticate",
"authenticating": "Authenticating...",
"setActive": "Set Active",
"manualTokenEntry": "Manual Token Entry",
"runSetupToken": "Run claude setup-token to get your token",
"runSetupToken": "Run claude and type /login to authenticate",
"tokenPlaceholder": "sk-ant-oat01-...",
"emailPlaceholder": "Email (optional, for display)",
"saveToken": "Save Token",
@@ -467,6 +468,11 @@
"terminalErrorDescription": "Failed to create terminal: {{error}}",
"authProcessFailed": "Authentication process failed to start",
"authProcessFailedDescription": "The authentication terminal could not be created. Please try again or check the logs for more details."
},
"alerts": {
"profileCreatedAuthFailed": "Profile created but failed to prepare authentication: {{error}}",
"authPrepareFailed": "Failed to prepare authentication: {{error}}",
"authStartFailedMessage": "Failed to start authentication. Please try again."
}
},
"debug": {
@@ -407,5 +407,47 @@
"loadingMore": "Chargement...",
"scrollForMore": "Défiler pour plus",
"allLoaded": "Toutes les issues chargées"
},
"oauth": {
"enterCode": "Saisie manuelle du code (secours)",
"enterCodeDescription": "Ce dialogue n'est nécessaire que si le navigateur n'a pas redirigé automatiquement. Si l'authentification est déjà terminée dans votre navigateur, vous pouvez fermer ce dialogue.",
"fallbackNote": "N'utilisez ceci que si vous voyez une page « Collez ceci dans Claude Code » dans votre navigateur.",
"step1": "Complétez l'autorisation dans votre navigateur",
"step2": "Si vous voyez une page de code, copiez le code affiché",
"step3": "Collez le code ci-dessous et cliquez sur Soumettre",
"codeLabel": "Code d'autorisation",
"codePlaceholder": "Collez votre code ici (seulement si nécessaire)...",
"codeHint": "Le code est une longue chaîne affichée uniquement si la redirection automatique a échoué",
"submit": "Soumettre",
"submitting": "Soumission...",
"codeSubmitted": "Code soumis",
"codeSubmittedDescription": "L'authentification devrait se terminer bientôt. Vérifiez le terminal pour confirmation.",
"codeSubmitFailed": "Échec de la soumission du code",
"codeSubmitFailedDescription": "Veuillez réessayer ou copier le code manuellement dans le terminal."
},
"authTerminal": {
"failedToCreate": "Échec de la création du terminal",
"unknownError": "Erreur inconnue",
"instructionTitle": "Authentification Claude",
"step1": "Appuyez sur Entrée pour démarrer l'authentification",
"step2": "Complétez l'authentification dans votre navigateur",
"step3": "Revenez ici - l'authentification sera détectée automatiquement",
"authFailed": "Échec de l'authentification",
"connecting": "Connexion...",
"authenticate": "Authentifier : {{profileName}}",
"completeSetup": "Terminer la configuration : {{profileName}}",
"authenticatedAs": "Authentifié en tant que {{email}}",
"authenticated": "Authentifié !",
"authError": "Erreur d'authentification",
"onboardingMessage": "Jeton reçu ! Terminez la configuration de Claude Code ci-dessous - cette fenêtre se fermera automatiquement une fois terminé.",
"successMessage": "Authentification réussie ! Fermeture..."
},
"profileCreated": {
"title": "Le profil « {{profileName}} » a été créé.",
"instructions": "Pour authentifier ce profil :",
"step1": "Allez dans Paramètres > Intégrations",
"step2": "Trouvez le profil dans la section Comptes Claude",
"step3": "Cliquez sur « Authentifier » pour terminer la connexion",
"footer": "Le compte sera disponible une fois l'authentification terminée."
}
}
@@ -74,6 +74,11 @@
"tokenSavedDescription": "Votre jeton a été enregistré avec succès.",
"tokenSaveFailed": "Échec de l'enregistrement du jeton",
"tryAgain": "Veuillez réessayer."
},
"alerts": {
"profileCreatedAuthFailed": "Profil créé mais échec de la préparation de l'authentification : {{error}}",
"authPrepareFailed": "Échec de la préparation de l'authentification : {{error}}",
"authStartFailedMessage": "Échec du démarrage de l'authentification. Veuillez réessayer."
}
},
"memory": {
@@ -414,9 +414,10 @@
"authenticated": "Authentifié",
"needsAuth": "Auth requise",
"authenticate": "Authentifier",
"authenticating": "Authentification...",
"setActive": "Définir actif",
"manualTokenEntry": "Saisie manuelle du token",
"runSetupToken": "Exécutez claude setup-token pour obtenir votre token",
"runSetupToken": "Exécutez claude et tapez /login pour vous authentifier",
"tokenPlaceholder": "sk-ant-oat01-...",
"emailPlaceholder": "Email (optionnel, pour l'affichage)",
"saveToken": "Enregistrer le token",
@@ -467,6 +468,11 @@
"terminalErrorDescription": "Échec de la création du terminal : {{error}}",
"authProcessFailed": "Le processus d'authentification n'a pas pu démarrer",
"authProcessFailedDescription": "Le terminal d'authentification n'a pas pu être créé. Veuillez réessayer ou consulter les logs pour plus de détails."
},
"alerts": {
"profileCreatedAuthFailed": "Profil créé mais échec de la préparation de l'authentification : {{error}}",
"authPrepareFailed": "Échec de la préparation de l'authentification : {{error}}",
"authStartFailedMessage": "Échec du démarrage de l'authentification. Veuillez réessayer."
}
},
"debug": {
+22
View File
@@ -95,6 +95,12 @@ export interface ClaudeProfile {
usage?: ClaudeUsageData;
/** Recent rate limit events for this profile */
rateLimitEvents?: ClaudeRateLimitEvent[];
/**
* Whether this profile has valid authentication.
* Computed server-side by checking configDir for credential files.
* This is NOT persisted, it's computed dynamically on each getSettings() call.
*/
isAuthenticated?: boolean;
}
/**
@@ -138,3 +144,19 @@ export interface ClaudeAuthResult {
authenticated: boolean;
error?: string;
}
/**
* Payload for TERMINAL_PROFILE_CHANGED event.
* Sent when profile switches and terminals need to be refreshed.
*/
export interface TerminalProfileChangedEvent {
previousProfileId: string;
newProfileId: string;
terminals: Array<{
id: string;
/** Session ID if terminal had an active Claude session */
sessionId?: string;
/** Whether the session was successfully migrated to new profile */
sessionMigrated?: boolean;
}>;
}
+26 -3
View File
@@ -64,7 +64,8 @@ import type {
ClaudeProfile,
ClaudeAutoSwitchSettings,
ClaudeAuthResult,
ClaudeUsageSnapshot
ClaudeUsageSnapshot,
TerminalProfileChangedEvent
} from './agent';
import type { AppSettings, SourceEnvConfig, SourceEnvCheckResult } from './settings';
import type { AppUpdateInfo, AppUpdateProgress, AppUpdateAvailableEvent, AppUpdateDownloadedEvent } from './app-update';
@@ -237,7 +238,9 @@ export interface ElectronAPI {
email?: string;
success: boolean;
message?: string;
detectedAt: string
detectedAt: string;
/** If true, user should complete onboarding in terminal before closing */
needsOnboarding?: boolean;
}) => void) => () => void;
/** Listen for auth terminal creation - allows UI to display the OAuth terminal */
onTerminalAuthCreated: (callback: (info: {
@@ -249,8 +252,24 @@ export interface ElectronAPI {
onTerminalClaudeBusy: (callback: (id: string, isBusy: boolean) => void) => () => void;
/** Listen for Claude exit (user closed Claude within terminal, returned to shell) */
onTerminalClaudeExit: (callback: (id: string) => void) => () => void;
/** Listen for onboarding complete (Claude shows ready state after login/onboarding) */
onTerminalOnboardingComplete: (callback: (info: {
terminalId: string;
profileId?: string;
detectedAt: string;
}) => void) => () => void;
/** Listen for pending Claude resume notifications (for deferred resume on tab activation) */
onTerminalPendingResume: (callback: (id: string, sessionId?: string) => void) => () => void;
/** Listen for profile change events - terminals need to be recreated with new profile env vars */
onTerminalProfileChanged: (callback: (event: TerminalProfileChangedEvent) => void) => () => void;
/** Listen for OAuth code input requests (manual OAuth flow) */
onTerminalOAuthCodeNeeded: (callback: (info: {
terminalId: string;
profileId: string;
profileName: string
}) => void) => () => void;
/** Submit OAuth code from user (for manual OAuth flow) */
submitOAuthCode: (terminalId: string, code: string) => Promise<IPCResult>;
// Claude profile management (multi-account support)
getClaudeProfiles: () => Promise<IPCResult<ClaudeProfileSettings>>;
@@ -260,10 +279,14 @@ export interface ElectronAPI {
setActiveClaudeProfile: (profileId: string) => Promise<IPCResult>;
/** Switch terminal to use a different Claude profile (restarts Claude with new config) */
switchClaudeProfile: (terminalId: string, profileId: string) => Promise<IPCResult>;
/** Initialize authentication for a Claude profile */
/** Initialize authentication for a Claude profile (legacy - uses hidden terminal) */
initializeClaudeProfile: (profileId: string) => Promise<IPCResult>;
/** Set OAuth token for a profile (used when capturing from terminal) */
setClaudeProfileToken: (profileId: string, token: string, email?: string) => Promise<IPCResult>;
/** Prepare authentication for a Claude profile - returns terminal config for embedded terminal */
authenticateClaudeProfile: (profileId: string) => Promise<IPCResult<{ terminalId: string; configDir: string }>>;
/** Check if a profile has been authenticated (by checking .claude.json) */
verifyClaudeProfileAuth: (profileId: string) => Promise<IPCResult<{ authenticated: boolean; email?: string }>>;
/** Get auto-switch settings */
getAutoSwitchSettings: () => Promise<IPCResult<ClaudeAutoSwitchSettings>>;
/** Update auto-switch settings */
@@ -16,6 +16,10 @@ export interface TerminalCreateOptions {
cols?: number;
rows?: number;
projectPath?: string;
/** Skip injecting OAuth token into terminal environment (used for auth terminals) */
skipOAuthToken?: boolean;
/** Custom environment variables to add to the terminal (merged with defaults) */
env?: Record<string, string>;
}
export interface TerminalResizeOptions {
@@ -18,7 +18,7 @@
* - [A-Za-z] - Final byte (command)
* - m - Specifically for SGR (Select Graphic Rendition) color codes
*/
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape codes require control characters
const ANSI_CSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
/**
@@ -27,9 +27,9 @@ const ANSI_CSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
* - BEL (bell): \x1b]...\x07 - Single character terminator
* - ST (string terminator): \x1b]...\x1b\\ - Two character terminator (ESC + backslash)
*/
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI OSC sequences use BEL terminator
const ANSI_OSC_BEL_PATTERN = /\x1b\][^\x07]*\x07/g;
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI OSC sequences use ST terminator
const ANSI_OSC_ST_PATTERN = /\x1b\][^\x1b]*\x1b\\/g;
/**
+5 -5
View File
@@ -402,7 +402,7 @@ class TestRequireAuthToken:
error_msg = str(exc_info.value)
assert "macOS Keychain" in error_msg
assert "claude setup-token" in error_msg
assert "/login" in error_msg
def test_error_message_includes_windows_instructions(self, monkeypatch):
"""Error message includes Windows setup instructions."""
@@ -413,7 +413,7 @@ class TestRequireAuthToken:
error_msg = str(exc_info.value)
assert "Windows Credential Manager" in error_msg
assert "claude setup-token" in error_msg
assert "/login" in error_msg
def test_error_message_includes_linux_instructions(self, monkeypatch):
"""Error message includes Linux setup instructions."""
@@ -423,9 +423,9 @@ class TestRequireAuthToken:
require_auth_token()
error_msg = str(exc_info.value)
assert "secret service" in error_msg.lower()
assert "gnome-keyring" in error_msg.lower()
assert "claude setup-token" in error_msg
# Linux error message uses /login and mentions .env file as alternative
assert "/login" in error_msg
assert "CLAUDE_CODE_OAUTH_TOKEN" in error_msg
class TestEnsureClaudeCodeOAuthToken: