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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user