diff --git a/EvoScientist/ccproxy_manager.py b/EvoScientist/ccproxy_manager.py index 4223359..8648c34 100644 --- a/EvoScientist/ccproxy_manager.py +++ b/EvoScientist/ccproxy_manager.py @@ -15,15 +15,11 @@ import os import shutil import subprocess import time -from typing import TYPE_CHECKING -if TYPE_CHECKING: - pass +from EvoScientist.config import EvoScientistConfig logger = logging.getLogger(__name__) -_DEFAULT_PORT = 8000 - # ============================================================================= # Availability & auth checks @@ -138,18 +134,18 @@ def check_ccproxy_auth(provider: str = "claude_api") -> tuple[bool, str]: # ============================================================================= -def is_ccproxy_running(port: int = _DEFAULT_PORT) -> bool: +def is_ccproxy_running(port: int) -> bool: """Check if ccproxy is already serving on the given port.""" import httpx try: - resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2.0) - return resp.status_code < 500 + resp = httpx.get(f"http://127.0.0.1:{port}/health/live", timeout=2.0) + return resp.status_code == 200 except (httpx.ConnectError, httpx.TimeoutException, OSError): return False -def start_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen: +def start_ccproxy(port: int) -> subprocess.Popen: """Start ccproxy serve as a background process. Args: @@ -206,7 +202,7 @@ def stop_ccproxy(proc: subprocess.Popen | None) -> None: pass -def ensure_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen | None: +def ensure_ccproxy(port: int) -> subprocess.Popen | None: """Ensure ccproxy is running — reuse existing or start new. Returns: @@ -223,7 +219,7 @@ def ensure_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen | None: # ============================================================================= -def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None: +def setup_ccproxy_env(port: int) -> None: """Set environment variables for Anthropic ccproxy routing. Force-sets ``ANTHROPIC_BASE_URL`` and ``ANTHROPIC_API_KEY`` so that @@ -236,7 +232,7 @@ def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None: os.environ["ANTHROPIC_API_KEY"] = "ccproxy-oauth" -def setup_codex_env(port: int = _DEFAULT_PORT) -> None: +def setup_codex_env(port: int) -> None: """Set environment variables for OpenAI/Codex ccproxy routing. Force-sets ``OPENAI_BASE_URL`` and ``OPENAI_API_KEY`` so that @@ -255,7 +251,86 @@ def setup_codex_env(port: int = _DEFAULT_PORT) -> None: # ============================================================================= -def maybe_start_ccproxy(config: object) -> subprocess.Popen | None: +def _patch_ccproxy_oauth_header() -> None: + """Auto-patch ccproxy's adapter to send the correct OAuth beta header. + + ccproxy 0.2.4 hardcodes ``computer-use-2025-01-24`` as the + ``anthropic-beta`` header, which causes two problems: + - Missing ``oauth-2025-04-20`` → 401 from Anthropic + - ``computer-use-2025-01-24`` incompatible with OAuth auth → 400 + + The ccproxy binary may use a different Python environment than the one + running EvoScientist, so we resolve the adapter path via the ccproxy + binary's shebang line rather than the current Python's import system. + + This patch is idempotent and places the header AFTER cli_headers so it + cannot be overridden. + """ + import pathlib + import re + + try: + ccproxy_bin = _ccproxy_exe() + if not ccproxy_bin: + return + + # Find the Python interpreter used by the ccproxy binary via shebang + shebang = pathlib.Path(ccproxy_bin).read_text().splitlines()[0] + python_exe = shebang.lstrip("#!").strip() + + # Ask that Python where ccproxy's adapter lives + result = subprocess.run( + [ + python_exe, "-c", + "import inspect, ccproxy.plugins.claude_api.adapter as m; print(inspect.getfile(m))", + ], + capture_output=True, text=True, timeout=10, + ) + if result.returncode != 0: + return + src_file = pathlib.Path(result.stdout.strip()) + if not src_file.exists(): + return + + text = src_file.read_text() + + # Check if already correctly patched (oauth header set after cli_headers) + correct = 'filtered_headers["anthropic-beta"] = "oauth-2025-04-20"' + cli_marker = "cli_headers = self._collect_cli_headers()" + if correct in text: + # Verify it's placed after cli_headers + if text.index(correct) > text.index(cli_marker): + return # Already correctly patched + + # Move/replace anthropic-beta assignment to after cli_headers loop, + # and set only oauth-2025-04-20 (computer-use-* is incompatible). + # Step 1: remove any existing filtered_headers["anthropic-beta"] line + patched = re.sub( + r'\s*filtered_headers\["anthropic-beta"\]\s*=\s*"[^"]*"\n', + "\n", + text, + ) + # Step 2: insert correct assignment after the cli_headers block + insert_after = 'filtered_headers[lk] = value\n' + replacement = ( + 'filtered_headers[lk] = value\n\n' + ' # oauth-2025-04-20: required for OAuth Bearer token auth (Anthropic 2026-03)\n' + ' filtered_headers["anthropic-beta"] = "oauth-2025-04-20"\n' + ) + patched = patched.replace(insert_after, replacement, 1) + + if patched == text: + return + + src_file.write_text(patched) + for pyc in src_file.parent.glob("__pycache__/adapter*.pyc"): + pyc.unlink(missing_ok=True) + logger.info("Auto-patched ccproxy adapter: set anthropic-beta=oauth-2025-04-20") + except Exception as exc: + logger.warning("Could not auto-patch ccproxy adapter: %s", exc) + + +def maybe_start_ccproxy(config: EvoScientistConfig) -> subprocess.Popen | None: """High-level: conditionally start ccproxy based on config. Checks ``config.anthropic_auth_mode`` and ``config.openai_auth_mode``: @@ -301,17 +376,24 @@ def maybe_start_ccproxy(config: object) -> subprocess.Popen | None: "Run: ccproxy auth login codex" ) + port = config.ccproxy_port + if not (1 <= port <= 65535): + raise ValueError(f"Invalid ccproxy port: {port}. Must be between 1 and 65535.") + + # Auto-patch ccproxy adapter to fix OAuth header compatibility + _patch_ccproxy_oauth_header() + # Start ccproxy (single process serves both providers) - proc = ensure_ccproxy() + proc = ensure_ccproxy(port) # Set environment for each OAuth provider if anthropic_oauth: - setup_ccproxy_env() + setup_ccproxy_env(port) if openai_oauth: - setup_codex_env() + setup_codex_env(port) if proc: - logger.info("Started ccproxy on port %d", _DEFAULT_PORT) + logger.info("Started ccproxy on port %d", port) else: - logger.info("Reusing existing ccproxy on port %d", _DEFAULT_PORT) + logger.info("Reusing existing ccproxy on port %d", port) return proc diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index fae8f60..e39e52f 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -759,6 +759,63 @@ def _prompt_and_validate_api_key( return new_key if new_key else None +def _prompt_ccproxy_port(config: EvoScientistConfig) -> None: + """Prompt the user for a ccproxy port and save it to config.""" + + def valid_port(value: str) -> bool: + if not value: # empty = keep default + return True + try: + return 0 < int(value) < 2**16 + except (ValueError, TypeError): + return False + + current_port = getattr(config, "ccproxy_port", 8000) + try: + raw = questionary.text( + f"Enter port number for ccproxy to run on (Current: {current_port}, Enter to keep):", + validate=valid_port, + style=WIZARD_STYLE, + qmark=QMARK, + ).ask() + ccproxy_port = int(raw) if raw else current_port + except (ValueError, TypeError): + ccproxy_port = current_port + console.print(f" [dim]Using default port: {ccproxy_port}[/dim]") + + setattr(config, "ccproxy_port", ccproxy_port) + console.print( + f" [green]✓ ccproxy will run on http://127.0.0.1:{ccproxy_port}[/green]" + ) + + +def _run_ccproxy_login(provider: str, label: str) -> None: + """Run ccproxy auth login for the given provider and show status.""" + from ..ccproxy_manager import _ccproxy_exe, check_ccproxy_auth + + console.print(" [dim]Opening browser for authentication...[/dim]") + try: + proc = subprocess.run( + [_ccproxy_exe() or "ccproxy", "auth", "login", provider], + capture_output=True, + text=True, + timeout=120, + ) + for line in proc.stdout.splitlines(): + if line.strip().startswith("https://"): + console.print(f" [dim]Visit: {line.strip()}[/dim]") + break + authed, msg = check_ccproxy_auth(provider) + if authed: + console.print(f" [green]✓ {label}: {msg}[/green]") + else: + console.print(f" [red]Authentication failed: {msg}[/red]") + except subprocess.TimeoutExpired: + console.print(" [red]Login timed out.[/red]") + except Exception as exc: + console.print(f" [red]Login error: {exc}[/red]") + + def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: """Step 2a: Select Anthropic authentication mode (API key vs OAuth). @@ -768,7 +825,7 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: Returns: Selected auth mode: "api_key", "oauth", or "auto". """ - from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth + from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth ccproxy_available = is_ccproxy_available() @@ -825,11 +882,22 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: ) return "api_key" + if auth_mode == "oauth": + _prompt_ccproxy_port(config) + # If OAuth selected, check auth status and offer login if auth_mode in ("oauth", "auto"): authed, msg = check_ccproxy_auth() if authed: console.print(f" [green]✓ OAuth: {msg}[/green]") + relogin = questionary.confirm( + "Re-authenticate to refresh credentials?", + default=False, + style=CONFIRM_STYLE, + qmark=QMARK, + ).ask() + if relogin: + _run_ccproxy_login("claude_api", "OAuth") else: console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]") login = questionary.confirm( @@ -839,28 +907,7 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: qmark=QMARK, ).ask() if login: - console.print(" [dim]Opening browser for authentication...[/dim]") - try: - proc = subprocess.run( - [_ccproxy_exe() or "ccproxy", "auth", "login", "claude_api"], - capture_output=True, - text=True, - timeout=120, - ) - # Show browser URL in case browser didn't open automatically - for line in proc.stdout.splitlines(): - if line.strip().startswith("https://"): - console.print(f" [dim]Visit: {line.strip()}[/dim]") - break - authed, msg = check_ccproxy_auth() - if authed: - console.print(f" [green]✓ OAuth: {msg}[/green]") - else: - console.print(f" [red]Authentication failed: {msg}[/red]") - except subprocess.TimeoutExpired: - console.print(" [red]Login timed out.[/red]") - except Exception as exc: - console.print(f" [red]Login error: {exc}[/red]") + _run_ccproxy_login("claude_api", "OAuth") return auth_mode @@ -874,7 +921,7 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: Returns: Selected auth mode: "api_key" or "oauth". """ - from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth + from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth ccproxy_available = is_ccproxy_available() @@ -931,11 +978,20 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: ) return "api_key" - # If OAuth selected, check auth status and offer login + # If OAuth selected, prompt for port and check auth status if auth_mode == "oauth": + _prompt_ccproxy_port(config) authed, msg = check_ccproxy_auth("codex") if authed: console.print(f" [green]✓ Codex OAuth: {msg}[/green]") + relogin = questionary.confirm( + "Re-authenticate to refresh credentials?", + default=False, + style=CONFIRM_STYLE, + qmark=QMARK, + ).ask() + if relogin: + _run_ccproxy_login("codex", "Codex OAuth") else: console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]") login = questionary.confirm( @@ -945,28 +1001,7 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: qmark=QMARK, ).ask() if login: - console.print(" [dim]Opening browser for authentication...[/dim]") - try: - proc = subprocess.run( - [_ccproxy_exe() or "ccproxy", "auth", "login", "codex"], - capture_output=True, - text=True, - timeout=120, - ) - # Show browser URL in case browser didn't open automatically - for line in proc.stdout.splitlines(): - if line.strip().startswith("https://"): - console.print(f" [dim]Visit: {line.strip()}[/dim]") - break - authed, msg = check_ccproxy_auth("codex") - if authed: - console.print(f" [green]✓ Codex OAuth: {msg}[/green]") - else: - console.print(f" [red]Authentication failed: {msg}[/red]") - except subprocess.TimeoutExpired: - console.print(" [red]Login timed out.[/red]") - except Exception as exc: - console.print(f" [red]Login error: {exc}[/red]") + _run_ccproxy_login("codex", "Codex OAuth") return auth_mode @@ -1782,28 +1817,18 @@ def validate_imessage() -> tuple[bool, str]: def _install_ccproxy() -> bool: """Run pip install for ccproxy (evoscientist[oauth]). + Uses uv pip install when available (uv-managed envs don't ship pip). + Returns: True if installation succeeded and ccproxy is available. """ from ..ccproxy_manager import is_ccproxy_available - try: - proc = subprocess.run( - [sys.executable, "-m", "pip", "install", "evoscientist[oauth]"], - capture_output=True, - text=True, - timeout=120, - ) - if proc.returncode != 0: - console.print(f" [red]✗ Installation failed:[/red]\n{proc.stderr.strip()}") - return False - return is_ccproxy_available() - except subprocess.TimeoutExpired: - console.print(" [red]✗ Installation timed out.[/red]") - return False - except Exception as e: - console.print(f" [red]✗ Installation failed: {e}[/red]") + ok = _install_pip_package("evoscientist[oauth]") + if not ok: + console.print(" [red]✗ Installation failed.[/red]") return False + return is_ccproxy_available() def _install_imsg() -> bool: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 037bc76..5e070c1 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -195,6 +195,9 @@ class EvoScientistConfig: # DM access control policy dm_policy: str = "allowlist" + # ccproxy + ccproxy_port: int = 8000 + # ============================================================================= # Config file operations @@ -357,6 +360,7 @@ _ENV_MAPPINGS = { "default_mode": "EVOSCIENTIST_DEFAULT_MODE", "default_workdir": "EVOSCIENTIST_WORKSPACE_DIR", "ui_backend": "EVOSCIENTIST_UI_BACKEND", + "ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT", } diff --git a/tests/test_ccproxy_manager.py b/tests/test_ccproxy_manager.py index 0e3e9d2..df472de 100644 --- a/tests/test_ccproxy_manager.py +++ b/tests/test_ccproxy_manager.py @@ -91,6 +91,20 @@ class TestIsCcproxyRunning: mock_get.return_value = MagicMock(status_code=200) assert is_ccproxy_running(8000) is True + @patch("httpx.get") + def test_uses_health_live_endpoint(self, mock_get): + mock_get.return_value = MagicMock(status_code=200) + is_ccproxy_running(8000) + url = mock_get.call_args[0][0] + assert url == "http://127.0.0.1:8000/health/live" + + @patch("httpx.get") + def test_uses_custom_port(self, mock_get): + mock_get.return_value = MagicMock(status_code=200) + is_ccproxy_running(7777) + url = mock_get.call_args[0][0] + assert url == "http://127.0.0.1:7777/health/live" + @patch("httpx.get") def test_not_running(self, mock_get): import httpx @@ -98,6 +112,11 @@ class TestIsCcproxyRunning: mock_get.side_effect = httpx.ConnectError("Connection refused") assert is_ccproxy_running(8000) is False + @patch("httpx.get") + def test_non_200_means_not_running(self, mock_get): + mock_get.return_value = MagicMock(status_code=404) + assert is_ccproxy_running(8000) is False + # ============================================================================= # start_ccproxy @@ -248,6 +267,7 @@ class TestMaybeStartCcproxy: config = MagicMock() config.anthropic_auth_mode = "oauth" config.openai_auth_mode = "api_key" + config.ccproxy_port = 8000 result = maybe_start_ccproxy(config) assert result is proc @@ -285,6 +305,7 @@ class TestMaybeStartCcproxy: config = MagicMock() config.anthropic_auth_mode = "api_key" config.openai_auth_mode = "oauth" + config.ccproxy_port = 8000 result = maybe_start_ccproxy(config) assert result is proc @@ -304,6 +325,7 @@ class TestMaybeStartCcproxy: config = MagicMock() config.anthropic_auth_mode = "oauth" config.openai_auth_mode = "oauth" + config.ccproxy_port = 8000 result = maybe_start_ccproxy(config) assert result is proc @@ -331,3 +353,44 @@ class TestMaybeStartCcproxy: config.openai_auth_mode = "oauth" with pytest.raises(RuntimeError, match="Codex OAuth not authenticated"): maybe_start_ccproxy(config) + + @patch("EvoScientist.ccproxy_manager.setup_ccproxy_env") + @patch("EvoScientist.ccproxy_manager.ensure_ccproxy") + @patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK")) + @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True) + def test_uses_config_ccproxy_port( + self, mock_avail, mock_auth, mock_ensure, mock_env + ): + """maybe_start_ccproxy passes config.ccproxy_port to ensure_ccproxy.""" + proc = MagicMock() + mock_ensure.return_value = proc + config = MagicMock() + config.anthropic_auth_mode = "oauth" + config.openai_auth_mode = "api_key" + config.ccproxy_port = 7777 + + maybe_start_ccproxy(config) + mock_ensure.assert_called_once_with(7777) + + @patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK")) + @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True) + def test_invalid_port_raises(self, mock_avail, mock_auth): + """maybe_start_ccproxy raises ValueError for out-of-range port.""" + config = MagicMock() + config.anthropic_auth_mode = "oauth" + config.openai_auth_mode = "api_key" + config.ccproxy_port = 0 + + with pytest.raises(ValueError, match="Invalid ccproxy port"): + maybe_start_ccproxy(config) + + @patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK")) + @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True) + def test_port_too_large_raises(self, mock_avail, mock_auth): + config = MagicMock() + config.anthropic_auth_mode = "oauth" + config.openai_auth_mode = "api_key" + config.ccproxy_port = 99999 + + with pytest.raises(ValueError, match="Invalid ccproxy port"): + maybe_start_ccproxy(config)