diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index 53f2f7b0..cbd46efa 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -10,6 +10,17 @@ import json import os import platform import subprocess +from typing import TYPE_CHECKING + +# Optional import for Linux secret-service support +# secretstorage provides access to the Freedesktop.org Secret Service API via DBus +if TYPE_CHECKING: + import secretstorage +else: + try: + import secretstorage # type: ignore[import-untyped] + except ImportError: + secretstorage = None # type: ignore[assignment] # Priority order for auth token resolution # NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY. @@ -48,7 +59,7 @@ def get_token_from_keychain() -> str | None: Reads Claude Code credentials from: - macOS: Keychain - Windows: Credential Manager - - Linux: Not yet supported (use env var) + - Linux: Secret Service API (via dbus/secretstorage) Returns: Token string if found, None otherwise @@ -60,8 +71,8 @@ def get_token_from_keychain() -> str | None: elif system == "Windows": return _get_token_from_windows_credential_files() else: - # Linux: secret-service not yet implemented - return None + # Linux: use secret-service API via DBus + return _get_token_from_linux_secret_service() def _get_token_from_macos_keychain() -> str | None: @@ -131,6 +142,82 @@ def _get_token_from_windows_credential_files() -> str | None: return None +def _get_token_from_linux_secret_service() -> str | None: + """Get token from Linux Secret Service API via DBus. + + Claude Code on Linux stores credentials in the Secret Service API + using the 'org.freedesktop.secrets' collection. This implementation + uses the secretstorage library which communicates via DBus. + + The credential is stored with: + - Label: "Claude Code-credentials" + - Attributes: {application: "claude-code"} + + Returns: + Token string if found, None otherwise + """ + if secretstorage is None: + # secretstorage not installed, fall back to env var + return None + + try: + # Get the default collection (typically "login" keyring) + # secretstorage handles DBus communication internally + try: + collection = secretstorage.get_default_collection(None) + except ( + AttributeError, + secretstorage.exceptions.SecretServiceNotAvailableException, + ): + # DBus not available or secret-service not running + return None + + if collection.is_locked(): + # Try to unlock the collection (may prompt user for password) + try: + collection.unlock() + except secretstorage.exceptions.SecretStorageException: + # User cancelled or unlock failed + return None + + # Search for items with our application attribute + items = collection.search_items({"application": "claude-code"}) + + for item in items: + # Check if this is the Claude Code credentials item + label = item.get_label() + # Use exact match for "Claude Code-credentials" to avoid false positives + if label == "Claude Code-credentials": + # Get the secret (stored as JSON string) + secret = item.get_secret() + if not secret: + continue + + try: + # Explicitly decode bytes to string if needed + if isinstance(secret, bytes): + secret = secret.decode("utf-8") + data = json.loads(secret) + token = data.get("claudeAiOauth", {}).get("accessToken") + + if token and token.startswith("sk-ant-oat01-"): + return token + except json.JSONDecodeError: + continue + + return None + + except ( + secretstorage.exceptions.SecretStorageException, + json.JSONDecodeError, + KeyError, + AttributeError, + TypeError, + ): + # Any error with secret-service, fall back to env var + return None + + def get_auth_token() -> str | None: """ Get authentication token from environment variables or system credential store. @@ -138,7 +225,7 @@ def get_auth_token() -> str | None: Checks multiple sources in priority order: 1. CLAUDE_CODE_OAUTH_TOKEN (env var) 2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups) - 3. System credential store (macOS Keychain, Windows Credential Manager) + 3. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service) NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent silent billing to user's API credits when OAuth is misconfigured. @@ -171,7 +258,7 @@ def get_auth_token_source() -> str | None: elif system == "Windows": return "Windows Credential Files" else: - return "System Credential Store" + return "Linux Secret Service" return None @@ -208,10 +295,12 @@ def require_auth_token() -> str: "Check: %LOCALAPPDATA%\\Claude\\credentials.json" ) else: + # Linux error_msg += ( "To authenticate:\n" " 1. Run: claude setup-token\n" - " 2. Set CLAUDE_CODE_OAUTH_TOKEN in your .env file" + " 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." ) raise ValueError(error_msg) return token diff --git a/apps/backend/requirements.txt b/apps/backend/requirements.txt index aa5e2713..78331e01 100644 --- a/apps/backend/requirements.txt +++ b/apps/backend/requirements.txt @@ -5,6 +5,11 @@ python-dotenv>=1.0.0 # TOML parsing fallback for Python < 3.11 tomli>=2.0.0; python_version < "3.11" +# Linux Secret Service support for credential storage +# Provides access to the Freedesktop.org Secret Service API via DBus +# Used on Linux to store OAuth tokens in gnome-keyring/kwallet +secretstorage>=3.3.3; sys_platform == "linux" + # Memory Integration - LadybugDB (embedded graph database) # Requires Python 3.12+ (no Docker required) real_ladybug>=0.13.0; python_version >= "3.12" diff --git a/tests/test_auth.py b/tests/test_auth.py index d7c5b80b..d5a41288 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,129 +1,608 @@ #!/usr/bin/env python3 """ -Tests for core/auth module. +Tests for Authentication System +================================ -Tests authentication token management and SDK environment variable handling, -including the PYTHONPATH isolation fix for ACS-251. +Tests the auth.py module functionality including: +- Environment variable token resolution +- System credential store integration (macOS, Windows, Linux) +- Token source detection +- Token validation and format checking """ +import json +import os import platform -from unittest.mock import patch +from unittest.mock import MagicMock, Mock -from core.auth import get_sdk_env_vars +import pytest +from core.auth import ( + AUTH_TOKEN_ENV_VARS, + ensure_claude_code_oauth_token, + get_auth_token, + get_auth_token_source, + get_sdk_env_vars, + get_token_from_keychain, + require_auth_token, +) -class TestGetSdkEnvVars: - """Tests for get_sdk_env_vars() function.""" +class TestEnvVarTokenResolution: + """Tests for environment variable token resolution.""" - def test_pythonpath_is_always_set_in_result(self, monkeypatch): - """ - PYTHONPATH should always be present in result, even when empty. + @pytest.fixture(autouse=True) + def clear_env(self): + """Clear auth environment variables before each test.""" + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + yield + # Cleanup after test + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) - When no SDK env vars are set, PYTHONPATH is still explicitly set to - empty string to override any inherited value from the parent process. - """ - # Clear all SDK env vars - for var in [ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_MODEL", - "NO_PROXY", - "DISABLE_TELEMETRY", - ]: - monkeypatch.delenv(var, raising=False) + def test_claude_oauth_token_from_env(self): + """Reads CLAUDE_CODE_OAUTH_TOKEN from environment.""" + test_token = "sk-ant-oat01-test-token" + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = test_token - result = get_sdk_env_vars() + token = get_auth_token() + assert token == test_token - # PYTHONPATH should always be present, even if empty - assert "PYTHONPATH" in result - assert result["PYTHONPATH"] == "" + def test_anthropic_auth_token_from_env(self): + """Reads ANTHROPIC_AUTH_TOKEN from environment.""" + test_token = "sk-ant-oat01-test-enterprise-token" + os.environ["ANTHROPIC_AUTH_TOKEN"] = test_token - def test_includes_anthropic_base_url_when_set(self, monkeypatch): - """Should include ANTHROPIC_BASE_URL when set.""" - monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.example.com") + token = get_auth_token() + assert token == test_token - result = get_sdk_env_vars() + def test_claude_oauth_takes_precedence(self): + """CLAUDE_CODE_OAUTH_TOKEN takes precedence over ANTHROPIC_AUTH_TOKEN.""" + claude_token = "sk-ant-oat01-claude-token" + anthropic_token = "sk-ant-oat01-anthropic-token" - assert result.get("ANTHROPIC_BASE_URL") == "https://api.example.com" + os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_token + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = claude_token - def test_includes_anthropic_auth_token_when_set(self, monkeypatch): - """Should include ANTHROPIC_AUTH_TOKEN when set.""" - monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-test-token") + token = get_auth_token() + assert token == claude_token - result = get_sdk_env_vars() + def test_no_token_returns_none(self): + """Returns None when no auth token is configured.""" + token = get_auth_token() + assert token is None - assert result.get("ANTHROPIC_AUTH_TOKEN") == "sk-test-token" + def test_token_source_from_env(self): + """Identifies environment variable as token source.""" + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "sk-ant-oat01-test-token" - def test_pythonpath_isolation_from_parent_process(self, monkeypatch): - """ - Test ACS-251 fix: PYTHONPATH from parent process should be overridden. + source = get_auth_token_source() + assert source == "CLAUDE_CODE_OAUTH_TOKEN" - This ensures that Auto-Claude's PYTHONPATH (which may point to Python 3.12 - packages) doesn't pollute agent subprocess environments, preventing - failures when working on external projects with different Python versions. - """ - # Simulate parent process having a PYTHONPATH set - monkeypatch.setenv( - "PYTHONPATH", - "/path/to/auto-claude/backend:/path/to/python3.12/site-packages", + def test_empty_token_ignored(self): + """Empty string tokens are ignored.""" + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "" + os.environ["ANTHROPIC_AUTH_TOKEN"] = "sk-ant-oat01-test-token" + + token = get_auth_token() + # Should get ANTHROPIC_AUTH_TOKEN since CLAUDE_CODE_OAUTH_TOKEN is empty + assert token == "sk-ant-oat01-test-token" + + +class TestMacOSKeychain: + """Tests for macOS keychain token retrieval.""" + + def test_macos_keychain_success(self, monkeypatch): + """Successfully retrieves token from macOS keychain.""" + test_token = "sk-ant-oat01-macos-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = credentials + + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) + + token = get_token_from_keychain() + assert token == test_token + + def test_macos_keychain_command_failure(self, monkeypatch): + """Returns None when security command fails.""" + mock_result = Mock() + mock_result.returncode = 1 + + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) + + token = get_token_from_keychain() + assert token is None + + def test_macos_keychain_invalid_json(self, monkeypatch): + """Returns None when keychain returns invalid JSON.""" + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = "invalid json" + + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) + + token = get_token_from_keychain() + assert token is None + + def test_macos_keychain_invalid_token_format(self, monkeypatch): + """Returns None when token doesn't start with sk-ant-oat01-.""" + credentials = json.dumps({"claudeAiOauth": {"accessToken": "invalid-token"}}) + + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = credentials + + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) + + token = get_token_from_keychain() + assert token is None + + +class TestWindowsCredentialFiles: + """Tests for Windows credential file token retrieval.""" + + def test_windows_credential_file_success(self, monkeypatch, tmp_path): + """Successfully retrieves token from Windows credential file.""" + test_token = "sk-ant-oat01-windows-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + # Create a temporary credential file + cred_file = tmp_path / ".credentials.json" + cred_file.write_text(credentials) + + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr( + os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/") ) - result = get_sdk_env_vars() + token = get_token_from_keychain() + assert token == test_token - # PYTHONPATH should be explicitly overridden to empty string - # This prevents the SDK from inheriting the parent's PYTHONPATH - assert "PYTHONPATH" in result - assert result["PYTHONPATH"] == "" + def test_windows_credential_file_not_found(self, monkeypatch): + """Returns None when credential file doesn't exist.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr(os.path, "exists", lambda x: False) - def test_skips_empty_env_vars(self, monkeypatch): - """Should not include env vars with empty values.""" - monkeypatch.setenv("ANTHROPIC_BASE_URL", "") - monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "") + token = get_token_from_keychain() + assert token is None - result = get_sdk_env_vars() + def test_windows_credential_file_invalid_json(self, monkeypatch, tmp_path): + """Returns None when credential file contains invalid JSON.""" + cred_file = tmp_path / ".credentials.json" + cred_file.write_text("invalid json") - # Empty vars should not be in result (except PYTHONPATH which is explicitly set) - assert "ANTHROPIC_BASE_URL" not in result - assert "ANTHROPIC_AUTH_TOKEN" not in result - # PYTHONPATH should still be present as explicit override - assert result["PYTHONPATH"] == "" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr( + os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/") + ) + monkeypatch.setattr(os.path, "exists", lambda x: str(x).endswith(".json")) - def test_on_windows_auto_detects_git_bash_path(self, monkeypatch): - """On Windows, should auto-detect git-bash path if not set.""" - # Mock platform.system to simulate Windows for cross-platform testing - with patch.object(platform, "system", return_value="Windows"): - # Mock _find_git_bash_path to return a path - with patch( - "core.auth._find_git_bash_path", - return_value="C:/Program Files/Git/bin/bash.exe", - ): - monkeypatch.delenv("CLAUDE_CODE_GIT_BASH_PATH", raising=False) + token = get_token_from_keychain() + assert token is None - result = get_sdk_env_vars() - assert ( - result.get("CLAUDE_CODE_GIT_BASH_PATH") - == "C:/Program Files/Git/bin/bash.exe" - ) +class TestLinuxSecretService: + """Tests for Linux Secret Service token retrieval.""" - def test_preserves_existing_git_bash_path_on_windows(self, monkeypatch): - """On Windows, should preserve existing CLAUDE_CODE_GIT_BASH_PATH.""" - # Mock platform.system to simulate Windows for cross-platform testing - with patch.object(platform, "system", return_value="Windows"): - monkeypatch.setenv("CLAUDE_CODE_GIT_BASH_PATH", "C:/Custom/bash.exe") + def test_linux_secret_service_not_installed(self, monkeypatch): + """Returns None when secretstorage is not installed.""" + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", None) - result = get_sdk_env_vars() + token = get_token_from_keychain() + assert token is None - assert result.get("CLAUDE_CODE_GIT_BASH_PATH") == "C:/Custom/bash.exe" + def test_linux_secret_service_dbus_not_available(self, monkeypatch): + """Returns None when DBus is not available.""" + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception - def test_on_non_windows_git_bash_not_added(self, monkeypatch): - """On non-Windows platforms, CLAUDE_CODE_GIT_BASH_PATH should not be auto-added.""" - # Mock platform.system to simulate Linux for cross-platform testing - # When platform is not Windows, _find_git_bash_path is never called - with patch.object(platform, "system", return_value="Linux"): - monkeypatch.delenv("CLAUDE_CODE_GIT_BASH_PATH", raising=False) + # Make get_default_collection raise exception + mock_ss.get_default_collection.side_effect = Exception("DBus not available") - result = get_sdk_env_vars() + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) - # Should not have CLAUDE_CODE_GIT_BASH_PATH - assert "CLAUDE_CODE_GIT_BASH_PATH" not in result + token = get_token_from_keychain() + assert token is None + + def test_linux_secret_service_success(self, monkeypatch): + """Successfully retrieves token from Linux secret service.""" + test_token = "sk-ant-oat01-linux-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + # Mock secretstorage + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + # Mock collection + mock_collection = MagicMock() + mock_collection.is_locked.return_value = False + mock_collection.unlock.return_value = None + + # Mock item + mock_item = MagicMock() + mock_item.get_label.return_value = "Claude Code-credentials" + mock_item.get_secret.return_value = credentials + mock_item.is_locked.return_value = False + + mock_collection.search_items.return_value = [mock_item] + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + token = get_token_from_keychain() + assert token == test_token + + def test_linux_secret_service_exact_label_match_only(self, monkeypatch): + """Only matches exact 'Claude Code-credentials' label.""" + test_token = "sk-ant-oat01-linux-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + mock_collection = MagicMock() + mock_collection.is_locked.return_value = False + + # Mock item with similar but not exact label + mock_item = MagicMock() + mock_item.get_label.return_value = ( + "Some-Claude-Code-Thing" # Similar but not exact + ) + mock_item.get_secret.return_value = credentials + + mock_collection.search_items.return_value = [mock_item] + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + token = get_token_from_keychain() + # Should return None because label doesn't match exactly + assert token is None + + def test_linux_secret_service_locked_collection_unlock_fails(self, monkeypatch): + """Returns None when collection is locked and unlock fails.""" + + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + mock_collection = MagicMock() + mock_collection.is_locked.return_value = True + mock_collection.unlock.side_effect = Exception("Unlock failed") + + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + token = get_token_from_keychain() + assert token is None + + def test_linux_secret_service_no_matching_item(self, monkeypatch): + """Returns None when no matching credential found.""" + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + mock_collection = MagicMock() + mock_collection.is_locked.return_value = False + mock_collection.search_items.return_value = [] # No items found + + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + token = get_token_from_keychain() + assert token is None + + def test_linux_secret_service_invalid_json(self, monkeypatch): + """Returns None when stored secret contains invalid JSON.""" + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + mock_collection = MagicMock() + mock_collection.is_locked.return_value = False + + mock_item = MagicMock() + mock_item.get_label.return_value = "Claude Code-credentials" + mock_item.get_secret.return_value = "invalid json" + + mock_collection.search_items.return_value = [mock_item] + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + token = get_token_from_keychain() + assert token is None + + def test_linux_secret_service_invalid_token_format(self, monkeypatch): + """Returns None when token doesn't start with sk-ant-oat01-.""" + credentials = json.dumps({"claudeAiOauth": {"accessToken": "invalid-token"}}) + + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + mock_collection = MagicMock() + mock_collection.is_locked.return_value = False + + mock_item = MagicMock() + mock_item.get_label.return_value = "Claude Code-credentials" + mock_item.get_secret.return_value = credentials + + mock_collection.search_items.return_value = [mock_item] + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + token = get_token_from_keychain() + assert token is None + + +class TestRequireAuthToken: + """Tests for require_auth_token function.""" + + @pytest.fixture(autouse=True) + def clear_env(self): + """Clear auth environment variables before each test.""" + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + yield + # Cleanup after test + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + + def test_require_token_returns_valid_token(self): + """Returns token when valid token exists.""" + test_token = "sk-ant-oat01-test-token" + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = test_token + + token = require_auth_token() + assert token == test_token + + def test_require_token_raises_when_missing(self): + """Raises ValueError when no token is configured.""" + with pytest.raises(ValueError, match="No OAuth token found"): + require_auth_token() + + def test_error_message_includes_macos_instructions(self, monkeypatch): + """Error message includes macOS setup instructions.""" + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + with pytest.raises(ValueError) as exc_info: + require_auth_token() + + error_msg = str(exc_info.value) + assert "macOS Keychain" in error_msg + assert "claude setup-token" in error_msg + + def test_error_message_includes_windows_instructions(self, monkeypatch): + """Error message includes Windows setup instructions.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + + with pytest.raises(ValueError) as exc_info: + require_auth_token() + + error_msg = str(exc_info.value) + assert "Windows Credential Manager" in error_msg + assert "claude setup-token" in error_msg + + def test_error_message_includes_linux_instructions(self, monkeypatch): + """Error message includes Linux setup instructions.""" + monkeypatch.setattr(platform, "system", lambda: "Linux") + + with pytest.raises(ValueError) as exc_info: + 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 + + +class TestEnsureClaudeCodeOAuthToken: + """Tests for ensure_claude_code_oauth_token function.""" + + @pytest.fixture(autouse=True) + def clear_env(self): + """Clear auth environment variables before each test.""" + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + yield + # Cleanup after test + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + + def test_does_nothing_when_already_set(self): + """Doesn't modify env var when CLAUDE_CODE_OAUTH_TOKEN is already set.""" + existing_token = "sk-ant-oat01-existing-token" + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = existing_token + + ensure_claude_code_oauth_token() + + assert os.environ["CLAUDE_CODE_OAUTH_TOKEN"] == existing_token + + def test_copies_from_anthropic_auth_token(self): + """Copies ANTHROPIC_AUTH_TOKEN to CLAUDE_CODE_OAUTH_TOKEN.""" + anthropic_token = "sk-ant-oat01-anthropic-token" + os.environ["ANTHROPIC_AUTH_TOKEN"] = anthropic_token + + ensure_claude_code_oauth_token() + + assert os.environ["CLAUDE_CODE_OAUTH_TOKEN"] == anthropic_token + + def test_does_nothing_when_no_token_available(self, monkeypatch): + """Doesn't set env var when no auth token is available.""" + monkeypatch.setattr(platform, "system", lambda: "Linux") + # Ensure keychain returns None + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + ensure_claude_code_oauth_token() + + assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ + + +class TestTokenSourceDetection: + """Tests for get_auth_token_source function.""" + + @pytest.fixture(autouse=True) + def clear_env(self): + """Clear auth environment variables before each test.""" + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + yield + # Cleanup after test + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + + def test_source_env_var_claude_oauth(self): + """Identifies CLAUDE_CODE_OAUTH_TOKEN as source.""" + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = "sk-ant-oat01-test-token" + + source = get_auth_token_source() + assert source == "CLAUDE_CODE_OAUTH_TOKEN" + + def test_source_env_var_anthropic_auth(self): + """Identifies ANTHROPIC_AUTH_TOKEN as source.""" + os.environ["ANTHROPIC_AUTH_TOKEN"] = "sk-ant-oat01-test-token" + + source = get_auth_token_source() + assert source == "ANTHROPIC_AUTH_TOKEN" + + def test_source_macos_keychain(self, monkeypatch): + """Identifies macOS Keychain as source.""" + test_token = "sk-ant-oat01-macos-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + mock_result = Mock() + mock_result.returncode = 0 + mock_result.stdout = credentials + + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr("subprocess.run", Mock(return_value=mock_result)) + + source = get_auth_token_source() + assert source == "macOS Keychain" + + def test_source_windows_credential_files(self, monkeypatch, tmp_path): + """Identifies Windows Credential Files as source.""" + test_token = "sk-ant-oat01-windows-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + cred_file = tmp_path / ".credentials.json" + cred_file.write_text(credentials) + + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr( + os.path, "expandvars", lambda p: str(cred_file).replace("\\", "/") + ) + + source = get_auth_token_source() + assert source == "Windows Credential Files" + + def test_source_linux_secret_service(self, monkeypatch): + """Identifies Linux Secret Service as source.""" + test_token = "sk-ant-oat01-linux-token" + credentials = json.dumps({"claudeAiOauth": {"accessToken": test_token}}) + + mock_ss = MagicMock() + mock_ss.exceptions = MagicMock() + mock_ss.exceptions.SecretServiceNotAvailableException = Exception + mock_ss.exceptions.SecretStorageException = Exception + + mock_collection = MagicMock() + mock_collection.is_locked.return_value = False + + mock_item = MagicMock() + mock_item.get_label.return_value = "Claude Code-credentials" + mock_item.get_secret.return_value = credentials + + mock_collection.search_items.return_value = [mock_item] + mock_ss.get_default_collection.return_value = mock_collection + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr("core.auth.secretstorage", mock_ss) + + source = get_auth_token_source() + assert source == "Linux Secret Service" + + def test_source_none_when_not_found(self): + """Returns None when no token source is found.""" + source = get_auth_token_source() + assert source is None + + +class TestSdkEnvVars: + """Tests for get_sdk_env_vars function.""" + + def test_returns_non_empty_vars(self, monkeypatch): + """Only returns non-empty environment variables.""" + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + monkeypatch.setenv("ANTHROPIC_MODEL", "") # Empty, should be excluded + monkeypatch.setenv("DISABLE_TELEMETRY", "1") + + env = get_sdk_env_vars() + + assert "ANTHROPIC_BASE_URL" in env + assert env["ANTHROPIC_BASE_URL"] == "https://api.anthropic.com" + assert "ANTHROPIC_MODEL" not in env # Empty value excluded + assert "DISABLE_TELEMETRY" in env + assert env["DISABLE_TELEMETRY"] == "1" + + def test_includes_claude_git_bash_on_windows(self, monkeypatch): + """Auto-detects git-bash path on Windows.""" + monkeypatch.setattr(platform, "system", lambda: "Windows") + monkeypatch.setattr( + "core.auth._find_git_bash_path", + lambda: "C:\\Program Files\\Git\\bin\\bash.exe", + ) + + env = get_sdk_env_vars() + + assert "CLAUDE_CODE_GIT_BASH_PATH" in env + assert "Git\\bin\\bash.exe" in env["CLAUDE_CODE_GIT_BASH_PATH"] + + def test_does_not_include_git_bash_on_non_windows(self, monkeypatch): + """Doesn't include git-bash path on non-Windows platforms.""" + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + env = get_sdk_env_vars() + + assert "CLAUDE_CODE_GIT_BASH_PATH" not in env + + def test_does_not_overwrite_existing_git_bash_path(self, monkeypatch): + """Respects existing CLAUDE_CODE_GIT_BASH_PATH environment variable.""" + existing_path = "/custom/bash.exe" + monkeypatch.setenv("CLAUDE_CODE_GIT_BASH_PATH", existing_path) + + monkeypatch.setattr(platform, "system", lambda: "Windows") + + env = get_sdk_env_vars() + + assert env["CLAUDE_CODE_GIT_BASH_PATH"] == existing_path