diff --git a/agent/anthropic_credentials.py b/agent/anthropic_credentials.py index ed3f57f0ac90d..e994e4d5f822e 100644 --- a/agent/anthropic_credentials.py +++ b/agent/anthropic_credentials.py @@ -506,7 +506,7 @@ def _generate_pkce() -> tuple: return verifier, challenge -def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: +def run_hermes_oauth_login_pure(open_browser: bool = True) -> Optional[Dict[str, Any]]: """Run Hermes-native OAuth PKCE flow and return credential state.""" import webbrowser from urllib.parse import urlencode @@ -529,7 +529,7 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui except Exception: _can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior - if _can_open_gui(): + if open_browser and _can_open_gui(): with contextlib.suppress(Exception): webbrowser.open(auth_url) print(" (Browser opened automatically)") diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 6bc57fde25c9f..ce1fc385312af 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -180,7 +180,9 @@ def _format_exhausted_status(entry) -> str: def _anthropic_oauth_login(args) -> dict: from agent import anthropic_credentials as anthropic_mod - creds = anthropic_mod.run_hermes_oauth_login_pure() + creds = anthropic_mod.run_hermes_oauth_login_pure( + open_browser=not getattr(args, "no_browser", False), + ) if not creds: raise SystemExit("Anthropic OAuth login did not return credentials.") return creds diff --git a/tests/agent/test_anthropic_oauth_pkce.py b/tests/agent/test_anthropic_oauth_pkce.py index 3336317643487..d35d237ab16f7 100644 --- a/tests/agent/test_anthropic_oauth_pkce.py +++ b/tests/agent/test_anthropic_oauth_pkce.py @@ -84,6 +84,20 @@ def fake_urlopen(req, *_a, **_kw): monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen) +def test_no_browser_skips_automatic_browser_open(monkeypatch): + opened_urls = [] + monkeypatch.setattr("webbrowser.open", opened_urls.append) + monkeypatch.setattr( + "hermes_cli.auth._can_open_graphical_browser", lambda: True + ) + monkeypatch.setattr("builtins.input", lambda *_a, **_kw: "") + + from agent.anthropic_credentials import run_hermes_oauth_login_pure + + assert run_hermes_oauth_login_pure(open_browser=False) is None + assert opened_urls == [] + + def test_authorization_url_state_is_not_pkce_verifier(monkeypatch, tmp_path): """The ``state`` parameter in the authorization URL must NOT equal the PKCE ``code_verifier``. diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index a0c5fe8e5a8cd..30a98b4ca3c86 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -116,6 +116,45 @@ class _Args: assert entry["access_token"] == "sk-or-manual" +def test_auth_add_anthropic_oauth_forwards_no_browser(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store(tmp_path, {"version": 1, "providers": {}}) + token = _jwt_with_email("claude@example.com") + oauth_options = {} + + def fake_oauth_login(*, open_browser=True): + oauth_options["open_browser"] = open_browser + return { + "access_token": token, + "refresh_token": "refresh-token", + "expires_at_ms": 1711234567000, + } + + monkeypatch.setattr( + "agent.anthropic_credentials.run_hermes_oauth_login_pure", + fake_oauth_login, + ) + + from hermes_cli.auth_commands import auth_add_command + + class _Args: + provider = "anthropic" + auth_type = "oauth" + api_key = None + label = None + no_browser = True + + auth_add_command(_Args()) + + payload = json.loads( + (tmp_path / "hermes" / "auth.json").read_text(encoding="utf-8") + ) + entries = payload["credential_pool"]["anthropic"] + entry = next(item for item in entries if item["source"] == "manual:hermes_pkce") + assert entry["label"] == "claude@example.com" + assert oauth_options == {"open_browser": False} + + def test_auth_add_configured_provider_uses_canonical_pool_key(tmp_path, monkeypatch): """A keyed providers row must keep its runtime slug in the auth pool.""" hermes_home = tmp_path / "hermes"