diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index 7981e4df..5879b5d1 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -948,6 +948,57 @@ def get_sdk_env_vars() -> dict[str, str]: return env +def configure_sdk_authentication(config_dir: str | None = None) -> None: + """ + Configure SDK authentication based on environment variables. + + Supports two authentication modes: + - API Profile mode (ANTHROPIC_BASE_URL set): uses ANTHROPIC_AUTH_TOKEN + - OAuth mode (default): uses CLAUDE_CODE_OAUTH_TOKEN + + In API profile mode, explicitly removes CLAUDE_CODE_OAUTH_TOKEN from the + environment because the SDK gives OAuth priority over API keys when both + are present. + + Args: + config_dir: Optional profile config directory for per-profile Keychain + lookup. When set, enables multi-profile token storage. + + Raises: + ValueError: If required tokens are missing for the active mode. + - API profile mode: requires ANTHROPIC_AUTH_TOKEN + - OAuth mode: requires CLAUDE_CODE_OAUTH_TOKEN (from Keychain or env) + """ + api_profile_mode = bool(os.environ.get("ANTHROPIC_BASE_URL", "").strip()) + + if api_profile_mode: + # API profile mode: ensure ANTHROPIC_AUTH_TOKEN is present + if not os.environ.get("ANTHROPIC_AUTH_TOKEN"): + raise ValueError( + "API profile mode active (ANTHROPIC_BASE_URL is set) " + "but ANTHROPIC_AUTH_TOKEN is not set" + ) + # Explicitly remove CLAUDE_CODE_OAUTH_TOKEN so SDK uses ANTHROPIC_AUTH_TOKEN + # SDK gives OAuth priority over API keys when both are present + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + logger.info("Using API profile authentication") + else: + # OAuth mode: require and validate OAuth token + # Get OAuth token - uses profile-specific Keychain lookup when config_dir is set + # This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles + oauth_token = require_auth_token(config_dir) + + # Validate token is not encrypted before passing to SDK + # Encrypted tokens (enc:...) should have been decrypted by require_auth_token() + # If we still have an encrypted token here, it means decryption failed or was skipped + validate_token_not_encrypted(oauth_token) + + # Ensure SDK can access it via its expected env var + # This is required because the SDK doesn't know about per-profile Keychain naming + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token + logger.info("Using OAuth authentication") + + def ensure_claude_code_oauth_token() -> None: """ Ensure CLAUDE_CODE_OAUTH_TOKEN is set (for SDK compatibility). diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index a254308d..aaa2c32b 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -139,9 +139,8 @@ from agents.tools_pkg import ( from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from claude_agent_sdk.types import HookMatcher from core.auth import ( + configure_sdk_authentication, get_sdk_env_vars, - require_auth_token, - validate_token_not_encrypted, ) from linear_updater import is_linear_enabled from prompts_pkg.project_context import detect_project_capabilities, load_project_index @@ -497,18 +496,8 @@ def create_client( # CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names config_dir = sdk_env.get("CLAUDE_CONFIG_DIR") - # Get OAuth token - uses profile-specific Keychain lookup when config_dir is set - # This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles - oauth_token = require_auth_token(config_dir) - - # Validate token is not encrypted before passing to SDK - # Encrypted tokens (enc:...) should have been decrypted by require_auth_token() - # If we still have an encrypted token here, it means decryption failed or was skipped - validate_token_not_encrypted(oauth_token) - - # Ensure SDK can access it via its expected env var - # This is required because the SDK doesn't know about per-profile Keychain naming - os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token + # Configure SDK authentication (OAuth or API profile mode) + configure_sdk_authentication(config_dir) if config_dir: logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}") diff --git a/apps/backend/core/simple_client.py b/apps/backend/core/simple_client.py index 29680db0..3e26d8de 100644 --- a/apps/backend/core/simple_client.py +++ b/apps/backend/core/simple_client.py @@ -28,9 +28,8 @@ from pathlib import Path from agents.tools_pkg import get_agent_config, get_default_thinking_level from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient from core.auth import ( + configure_sdk_authentication, get_sdk_env_vars, - require_auth_token, - validate_token_not_encrypted, ) from core.platform import validate_cli_path from phase_config import get_thinking_budget @@ -80,18 +79,8 @@ def create_simple_client( # CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names config_dir = sdk_env.get("CLAUDE_CONFIG_DIR") - # Get OAuth token - uses profile-specific Keychain lookup when config_dir is set - # This correctly reads from "Claude Code-credentials-{hash}" for non-default profiles - oauth_token = require_auth_token(config_dir) - - # Validate token is not encrypted before passing to SDK - # Encrypted tokens (enc:...) should have been decrypted by require_auth_token() - # If we still have an encrypted token here, it means decryption failed or was skipped - validate_token_not_encrypted(oauth_token) - - # Ensure SDK can access it via its expected env var - # This is required because the SDK doesn't know about per-profile Keychain naming - os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token + # Configure SDK authentication (OAuth or API profile mode) + configure_sdk_authentication(config_dir) # Get agent configuration (raises ValueError if unknown type) config = get_agent_config(agent_type) diff --git a/tests/test_auth.py b/tests/test_auth.py index 1f5cee48..fcb4334d 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -67,10 +67,10 @@ class TestEnvVarTokenResolution: token = get_auth_token() assert token == claude_token - def test_no_token_returns_none(self, mocker): + def test_no_token_returns_none(self, monkeypatch): """Returns None when no auth token is configured.""" # Mock keychain to return None (env vars already cleared by fixture) - mocker.patch("core.auth.get_token_from_keychain", return_value=None) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) token = get_auth_token() assert token is None @@ -369,12 +369,12 @@ class TestRequireAuthToken: """Tests for require_auth_token function.""" @pytest.fixture(autouse=True) - def clear_env(self, mocker): + def clear_env(self, monkeypatch): """Clear auth environment variables and mock keychain before each test.""" for var in AUTH_TOKEN_ENV_VARS: os.environ.pop(var, None) # Mock keychain to return None (tests that need a token will set env var) - mocker.patch("core.auth.get_token_from_keychain", return_value=None) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) yield # Cleanup after test for var in AUTH_TOKEN_ENV_VARS: @@ -556,10 +556,10 @@ class TestTokenSourceDetection: source = get_auth_token_source() assert source == "Linux Secret Service" - def test_source_none_when_not_found(self, mocker): + def test_source_none_when_not_found(self, monkeypatch): """Returns None when no token source is found.""" # Mock keychain to return None (env vars already cleared by fixture) - mocker.patch("core.auth.get_token_from_keychain", return_value=None) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) source = get_auth_token_source() assert source is None diff --git a/tests/test_client.py b/tests/test_client.py index b3495548..4f0a73ba 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,20 +18,27 @@ import pytest AUTH_TOKEN_ENV_VARS = [ "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", ] +@pytest.fixture +def clear_auth_env(): + """Clear auth environment variables before and after each test.""" + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + yield + for var in AUTH_TOKEN_ENV_VARS: + os.environ.pop(var, None) + + class TestClientTokenValidation: """Tests for client token validation.""" @pytest.fixture(autouse=True) - def clear_env(self): - """Clear auth environment variables before and after each test.""" - for var in AUTH_TOKEN_ENV_VARS: - os.environ.pop(var, None) - yield - for var in AUTH_TOKEN_ENV_VARS: - os.environ.pop(var, None) + def setup(self, clear_auth_env): + """Use shared clear_auth_env fixture.""" + pass def test_create_client_rejects_encrypted_tokens(self, tmp_path, monkeypatch): """Verify create_client() rejects encrypted tokens.""" @@ -110,7 +117,7 @@ class TestClientTokenValidation: # Mock validate_token_not_encrypted to verify it's called with patch( - "core.client.validate_token_not_encrypted" + "core.auth.validate_token_not_encrypted" ) as mock_validate, patch("core.client.ClaudeSDKClient"): from core.client import create_client @@ -127,7 +134,7 @@ class TestClientTokenValidation: # Mock validate_token_not_encrypted to verify it's called with patch( - "core.simple_client.validate_token_not_encrypted" + "core.auth.validate_token_not_encrypted" ) as mock_validate, patch("core.simple_client.ClaudeSDKClient"): from core.simple_client import create_simple_client @@ -135,3 +142,454 @@ class TestClientTokenValidation: # Verify validation was called with the token mock_validate.assert_called_once_with(valid_token) + + +class TestAPIProfileAuthentication: + """Tests for API Profile authentication mode (e.g., z.ai, custom endpoints).""" + + @pytest.fixture(autouse=True) + def setup(self, clear_auth_env): + """Use shared clear_auth_env fixture.""" + pass + + def test_api_profile_mode_with_valid_token(self, tmp_path, monkeypatch): + """API profile mode succeeds with ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN.""" + api_token = "sk-api-test-token-123456" + api_endpoint = "https://api.z.ai/v1" + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + # Ensure no OAuth token is set + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + # Mock the SDK client to avoid actual initialization + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "glm-4", "coder") + + # Verify SDK client was created + assert client is mock_sdk_client + + # Verify CLAUDE_CODE_OAUTH_TOKEN was NOT set (API profile mode) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ + + # Verify ANTHROPIC_AUTH_TOKEN is still set + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == api_token + assert os.environ.get("ANTHROPIC_BASE_URL") == api_endpoint + + def test_api_profile_mode_missing_token_raises_error(self, tmp_path, monkeypatch): + """API profile mode raises ValueError when ANTHROPIC_AUTH_TOKEN is missing.""" + api_endpoint = "https://api.z.ai/v1" + + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + # Don't set ANTHROPIC_AUTH_TOKEN - this should cause an error + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + from core.client import create_client + + with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"): + create_client(tmp_path, tmp_path, "glm-4", "coder") + + def test_api_profile_mode_empty_token_raises_error(self, tmp_path, monkeypatch): + """API profile mode raises ValueError when ANTHROPIC_AUTH_TOKEN is empty string.""" + api_endpoint = "https://api.z.ai/v1" + + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "") # Empty string + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + from core.client import create_client + + with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"): + create_client(tmp_path, tmp_path, "glm-4", "coder") + + def test_oauth_mode_without_base_url(self, tmp_path, monkeypatch): + """OAuth mode is used when ANTHROPIC_BASE_URL is not set.""" + oauth_token = "sk-ant-oat01-oauth-token" + + # Don't set ANTHROPIC_BASE_URL - this should trigger OAuth mode + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock the SDK client + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + # Verify SDK client was created + assert client is mock_sdk_client + + # Verify CLAUDE_CODE_OAUTH_TOKEN was set (OAuth mode) + assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == oauth_token + + def test_api_profile_takes_precedence_over_oauth(self, tmp_path, monkeypatch): + """ + When both ANTHROPIC_BASE_URL and OAuth token are set, API profile mode wins. + + create_client() explicitly removes CLAUDE_CODE_OAUTH_TOKEN in API profile mode + so the SDK uses ANTHROPIC_AUTH_TOKEN instead (SDK prioritizes OAuth over API keys). + """ + api_token = "sk-api-test-token-123456" + api_endpoint = "https://api.z.ai/v1" + oauth_token = "sk-ant-oat01-oauth-token" + + # Set both API profile and OAuth + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + + # Mock the SDK client and OAuth functions to verify OAuth path is NOT taken + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client), \ + patch("core.auth.require_auth_token") as mock_require, \ + patch("core.auth.validate_token_not_encrypted") as mock_validate: + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "glm-4", "coder") + + # Verify SDK client was created + assert client is mock_sdk_client + + # Verify CLAUDE_CODE_OAUTH_TOKEN was removed (API profile mode) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ + + # Ensure OAuth flow was NOT used (this proves API profile path was taken) + mock_require.assert_not_called() + mock_validate.assert_not_called() + + def test_empty_base_url_triggers_oauth_mode(self, tmp_path, monkeypatch): + """Empty ANTHROPIC_BASE_URL should trigger OAuth mode, not API profile mode.""" + oauth_token = "sk-ant-oat01-oauth-token" + + # Set empty ANTHROPIC_BASE_URL - should be treated as "not set" + monkeypatch.setenv("ANTHROPIC_BASE_URL", "") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock require_auth_token to verify it's called (OAuth mode) + with patch("core.auth.require_auth_token", return_value=oauth_token): + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + # Verify SDK client was created + assert client is mock_sdk_client + + @pytest.mark.parametrize("endpoint", [ + "https://api.z.ai/v1", + "https://api.example.com", + "http://localhost:8080/v1", + "https://custom-gateway.com/anthropic-proxy", + ]) + def test_api_profile_with_various_endpoints(self, tmp_path, monkeypatch, endpoint): + """API profile mode works with various endpoint formats.""" + api_token = "sk-api-test-token-123456" + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", endpoint) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "glm-4", "coder") + + assert client is mock_sdk_client + assert os.environ.get("ANTHROPIC_BASE_URL") == endpoint + + def test_oauth_mode_without_any_token_raises_error(self, tmp_path, monkeypatch): + """OAuth mode raises ValueError when no OAuth token is available.""" + # Don't set any auth tokens + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + + # Mock keychain to return None + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + from core.client import create_client + + with pytest.raises(ValueError, match="No OAuth token found"): + create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + +class TestAPIProfileAuthenticationIntegration: + """Integration tests verifying the complete auth flow behavior.""" + + @pytest.fixture(autouse=True) + def setup(self, clear_auth_env): + """Use shared clear_auth_env fixture.""" + pass + + def test_sdk_env_vars_includes_api_profile_vars(self, monkeypatch): + """Verify get_sdk_env_vars() passes ANTHROPIC_AUTH_TOKEN and ANTHROPIC_BASE_URL.""" + from core.auth import get_sdk_env_vars + + api_token = "sk-api-test-token" + api_endpoint = "https://api.z.ai/v1" + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + + sdk_env = get_sdk_env_vars() + + assert sdk_env.get("ANTHROPIC_AUTH_TOKEN") == api_token + assert sdk_env.get("ANTHROPIC_BASE_URL") == api_endpoint + + def test_sdk_env_vars_excludes_oauth_in_api_profile_mode(self, monkeypatch): + """Verify SDK env vars don't include CLAUDE_CODE_OAUTH_TOKEN in API profile mode.""" + from core.auth import get_sdk_env_vars + + api_token = "sk-api-test-token" + api_endpoint = "https://api.z.ai/v1" + oauth_token = "sk-ant-oat01-oauth-token" + + # Set both API profile and OAuth + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + + sdk_env = get_sdk_env_vars() + + # SDK_ENV_VARS doesn't include CLAUDE_CODE_OAUTH_TOKEN + # (it's set separately in create_client()) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in sdk_env + assert sdk_env.get("ANTHROPIC_AUTH_TOKEN") == api_token + assert sdk_env.get("ANTHROPIC_BASE_URL") == api_endpoint + + def test_api_profile_mode_does_not_validate_oauth_token(self, tmp_path, monkeypatch): + """In API profile mode, OAuth token validation is skipped.""" + api_token = "sk-api-test-token" + api_endpoint = "https://api.z.ai/v1" + encrypted_oauth_token = "enc:encrypted-oauth-token" # Invalid encrypted format + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + # Even with a bogus encrypted OAuth token, API profile mode should work + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token) + + # Mock the SDK client + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + # Should NOT raise ValueError about encrypted token + # because OAuth validation is skipped in API profile mode + client = create_client(tmp_path, tmp_path, "glm-4", "coder") + + assert client is mock_sdk_client + + def test_oauth_mode_validates_token_even_with_api_env_vars_set(self, tmp_path, monkeypatch): + """In OAuth mode (no BASE_URL), token validation happens even if ANTHROPIC_AUTH_TOKEN is set.""" + api_token = "sk-api-test-token" # This exists but should be ignored in OAuth mode + encrypted_oauth_token = "enc:encrypted-oauth-token" # Invalid encrypted format + + # Set ANTHROPIC_AUTH_TOKEN but NOT ANTHROPIC_BASE_URL - this is OAuth mode + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", encrypted_oauth_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + from core.client import create_client + + # Should raise ValueError about encrypted token because we're in OAuth mode + with pytest.raises(ValueError, match="encrypted format"): + create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + +class TestAPIProfileAuthenticationEdgeCases: + """Edge case tests for API profile authentication.""" + + @pytest.fixture(autouse=True) + def setup(self, clear_auth_env): + """Use shared clear_auth_env fixture.""" + pass + + def test_whitespace_base_url_treated_as_empty(self, tmp_path, monkeypatch): + """Whitespace-only ANTHROPIC_BASE_URL is trimmed and treated as empty (OAuth mode).""" + oauth_token = "sk-ant-oat01-oauth-token" + + # Set whitespace-only ANTHROPIC_BASE_URL - should be trimmed to empty string + monkeypatch.setenv("ANTHROPIC_BASE_URL", " ") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + # Mock the SDK client + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + # Should use OAuth mode (whitespace is trimmed) + client = create_client(tmp_path, tmp_path, "claude-sonnet-4", "coder") + + # Verify SDK client was created successfully + assert client is mock_sdk_client + + def test_unicode_base_url(self, tmp_path, monkeypatch): + """API profile mode works with Unicode characters in endpoint URL.""" + api_token = "sk-api-test-token-123456" + # Using an IDN (Internationalized Domain Name) + api_endpoint = "https://münchen.example.com/v1" + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "glm-4", "coder") + + assert client is mock_sdk_client + assert os.environ.get("ANTHROPIC_BASE_URL") == api_endpoint + + def test_api_token_with_special_characters(self, tmp_path, monkeypatch): + """API profile mode works with tokens containing special characters.""" + # Tokens with various formats + test_tokens = [ + "sk-api-simple", + "sk-api-with-dashes-and_underscores", + "sk.api.with.dots", + "sk_api_with_123456_numbers", + ] + + api_endpoint = "https://api.example.com/v1" + + for token in test_tokens: + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + + mock_sdk_client = MagicMock() + with patch("core.client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.client import create_client + + client = create_client(tmp_path, tmp_path, "glm-4", "coder") + + assert client is mock_sdk_client + assert os.environ.get("ANTHROPIC_AUTH_TOKEN") == token + + +class TestSimpleClientAPIProfileAuthentication: + """Tests for API Profile authentication mode in create_simple_client().""" + + @pytest.fixture(autouse=True) + def setup(self, clear_auth_env): + """Use shared clear_auth_env fixture.""" + pass + + def test_simple_client_api_profile_mode_with_valid_token(self, monkeypatch): + """create_simple_client() works with API profile mode.""" + api_token = "sk-api-test-token-123456" + api_endpoint = "https://api.z.ai/v1" + + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + mock_sdk_client = MagicMock() + with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.simple_client import create_simple_client + + client = create_simple_client(agent_type="merge_resolver") + + # Verify SDK client was created + assert client is mock_sdk_client + + # Verify CLAUDE_CODE_OAUTH_TOKEN was NOT set (API profile mode) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ + + def test_simple_client_api_profile_mode_missing_token_raises_error(self, monkeypatch): + """create_simple_client() raises ValueError when API profile mode but no token.""" + api_endpoint = "https://api.z.ai/v1" + + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False) + + from core.simple_client import create_simple_client + + with pytest.raises(ValueError, match=r"API profile mode active.*ANTHROPIC_AUTH_TOKEN is not set"): + create_simple_client(agent_type="merge_resolver") + + def test_simple_client_oauth_mode_without_base_url(self, monkeypatch): + """create_simple_client() uses OAuth mode when ANTHROPIC_BASE_URL is not set.""" + oauth_token = "sk-ant-oat01-oauth-token" + + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + mock_sdk_client = MagicMock() + with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.simple_client import create_simple_client + + client = create_simple_client(agent_type="merge_resolver") + + # Verify SDK client was created + assert client is mock_sdk_client + + # Verify CLAUDE_CODE_OAUTH_TOKEN was set (OAuth mode) + assert os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") == oauth_token + + def test_simple_client_api_profile_takes_precedence_over_oauth(self, monkeypatch): + """ + When both ANTHROPIC_BASE_URL and OAuth token are set, API profile mode wins. + + create_simple_client() explicitly removes CLAUDE_CODE_OAUTH_TOKEN in API profile mode + so the SDK uses ANTHROPIC_AUTH_TOKEN instead (SDK prioritizes OAuth over API keys). + """ + api_token = "sk-api-test-token-123456" + api_endpoint = "https://api.z.ai/v1" + oauth_token = "sk-ant-oat01-oauth-token" + + # Set both API profile and OAuth + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", api_token) + monkeypatch.setenv("ANTHROPIC_BASE_URL", api_endpoint) + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + + # Mock the SDK client and OAuth functions to verify OAuth path is NOT taken + mock_sdk_client = MagicMock() + with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client), \ + patch("core.auth.require_auth_token") as mock_require, \ + patch("core.auth.validate_token_not_encrypted") as mock_validate: + from core.simple_client import create_simple_client + + client = create_simple_client(agent_type="merge_resolver") + + # Verify SDK client was created + assert client is mock_sdk_client + + # Verify CLAUDE_CODE_OAUTH_TOKEN was removed (API profile mode) + assert "CLAUDE_CODE_OAUTH_TOKEN" not in os.environ + + # Ensure OAuth flow was NOT used (this proves API profile path was taken) + mock_require.assert_not_called() + mock_validate.assert_not_called() + + def test_simple_client_whitespace_base_url_triggers_oauth_mode(self, monkeypatch): + """Whitespace-only ANTHROPIC_BASE_URL is trimmed and treated as empty (OAuth mode).""" + oauth_token = "sk-ant-oat01-oauth-token" + + # Set whitespace-only ANTHROPIC_BASE_URL - should be trimmed to empty string + monkeypatch.setenv("ANTHROPIC_BASE_URL", " ") + monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", oauth_token) + monkeypatch.setattr("core.auth.get_token_from_keychain", lambda: None) + + mock_sdk_client = MagicMock() + with patch("core.simple_client.ClaudeSDKClient", return_value=mock_sdk_client): + from core.simple_client import create_simple_client + + # Should use OAuth mode (whitespace is trimmed) + client = create_simple_client(agent_type="merge_resolver") + + # Verify SDK client was created successfully + assert client is mock_sdk_client