diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fbdb265b0..8eeb559ec 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1256,10 +1256,16 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: print() try: - webbrowser.open(auth_url) - print(" (Browser opened automatically)") + from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui except Exception: - pass + _can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior + + if _can_open_gui(): + try: + webbrowser.open(auth_url) + print(" (Browser opened automatically)") + except Exception: + pass print() print("After authorizing, you'll see a code. Paste it below.") diff --git a/agent/google_oauth.py b/agent/google_oauth.py index 97a65349d..9eb55ec19 100644 --- a/agent/google_oauth.py +++ b/agent/google_oauth.py @@ -899,7 +899,15 @@ def start_oauth_flow( try: import webbrowser - webbrowser.open(auth_url, new=1, autoraise=True) + try: + from hermes_cli.auth import ( + _can_open_graphical_browser as _can_open_gui, + ) + except Exception: + _can_open_gui = lambda: True # noqa: E731 + + if _can_open_gui(): + webbrowser.open(auth_url, new=1, autoraise=True) except Exception as exc: logger.debug("webbrowser.open failed: %s", exc) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e085adae6..6bbe1473a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3033,7 +3033,7 @@ def login_spotify_command(args) -> None: _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) - if open_browser and not _is_remote_session(): + if open_browser and not _is_remote_session() and _can_open_graphical_browser(): try: opened = webbrowser.open(authorize_url) except Exception: @@ -3114,6 +3114,83 @@ def _is_remote_session() -> bool: return False +# Console/text-mode browsers that ``webbrowser`` will happily launch INSIDE +# the terminal. Opening one of these is worse than not opening anything — +# it hijacks the user's TTY with an unusable text browser (the xAI OAuth +# "Account Management" page rendered in w3m, reported May 2026) instead of +# letting them copy the URL to a real browser. When the resolved browser is +# one of these we refuse to auto-open and fall back to the print-the-URL / +# manual-paste path, same as a remote session. +_CONSOLE_BROWSER_NAMES: FrozenSet[str] = frozenset( + { + "w3m", + "lynx", + "links", + "links2", + "elinks", + "www-browser", + "browsh", # TUI browser — still hijacks the terminal + } +) + + +def _can_open_graphical_browser() -> bool: + """Return True only when a *graphical* browser is likely to open. + + ``webbrowser.open()`` resolves to whatever the platform offers, and on a + headless / CLI-only Linux box with no GUI browser installed that is often + a text-mode browser (w3m/lynx/links) which launches inside the terminal + and takes over the user's session. This guard distinguishes "a real + windowed browser will pop up" from "a console browser will hijack the + TTY", so callers can fall back to printing the URL instead. + + Heuristics: + * Respect ``$BROWSER`` — if it names a known console browser, refuse. + * On Linux, require a display server (``$DISPLAY`` / ``$WAYLAND_DISPLAY``) + unless ``$BROWSER`` points at something graphical; no display server + almost always means no GUI browser. + * Ask ``webbrowser.get()`` what it resolved to and refuse when the + underlying command is a known console browser. + * macOS and Windows always have a usable default GUI browser. + """ + import webbrowser as _webbrowser + + def _names_console_browser(value: str) -> bool: + token = value.strip().split()[0] if value.strip() else "" + base = os.path.basename(token).lower() + return base in _CONSOLE_BROWSER_NAMES + + browser_env = os.environ.get("BROWSER", "") + if browser_env and _names_console_browser(browser_env): + return False + + if sys.platform.startswith("linux"): + has_display = bool( + os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") + ) + # An explicit graphical $BROWSER can work without $DISPLAY in odd + # setups, but a console $BROWSER already returned False above, so the + # only way to reach here with a $BROWSER set is a graphical one. + if not has_display and not browser_env: + return False + + try: + controller = _webbrowser.get() + except Exception: + # No browser resolvable at all → definitely don't auto-open. + return False + + candidate = ( + getattr(controller, "name", "") + or getattr(controller, "basename", "") + or "" + ) + if candidate and _names_console_browser(candidate): + return False + + return True + + def _parse_pasted_callback(raw: str) -> dict: """Parse a pasted callback URL / query string into the loopback shape. @@ -6916,7 +6993,7 @@ def _xai_oauth_loopback_login( _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) - if open_browser and not _is_remote_session(): + if open_browser and not _is_remote_session() and _can_open_graphical_browser(): try: opened = webbrowser.open(authorize_url) except Exception: @@ -7358,7 +7435,7 @@ def _minimax_oauth_login( print("To continue:") print(f" 1. Open: {verification_url}") print(f" 2. If prompted, enter code: {user_code}") - if open_browser: + if open_browser and _can_open_graphical_browser(): if webbrowser.open(verification_url): print(" (Opened browser for verification)") else: diff --git a/tests/agent/test_anthropic_oauth_pkce.py b/tests/agent/test_anthropic_oauth_pkce.py index 864ee2c70..49045e945 100644 --- a/tests/agent/test_anthropic_oauth_pkce.py +++ b/tests/agent/test_anthropic_oauth_pkce.py @@ -52,6 +52,13 @@ def _patch_oauth_flow( return True monkeypatch.setattr("webbrowser.open", fake_open) + # The flow now gates webbrowser.open() behind a graphical-browser check so + # it never launches a console browser (w3m/lynx) inside the terminal. Tests + # run headless, so force the GUI path to True — the URL capture relies on + # webbrowser.open() being invoked. + monkeypatch.setattr( + "hermes_cli.auth._can_open_graphical_browser", lambda: True + ) monkeypatch.setattr("builtins.input", lambda *_a, **_kw: callback_code) class _FakeResponse: diff --git a/tests/hermes_cli/test_graphical_browser_detection.py b/tests/hermes_cli/test_graphical_browser_detection.py new file mode 100644 index 000000000..31b641818 --- /dev/null +++ b/tests/hermes_cli/test_graphical_browser_detection.py @@ -0,0 +1,96 @@ +"""Tests for `_can_open_graphical_browser()` in hermes_cli.auth. + +Guards the fix for the May 2026 report where `hermes auth add xai-oauth` +launched a text-mode browser (w3m) INSIDE the terminal on a headless Linux +box — `_is_remote_session()` only checked SSH/cloud-shell env vars, so a plain +local box with no GUI browser still called `webbrowser.open()`, which resolved +to a console browser and hijacked the TTY. + +The helper distinguishes "a real windowed browser will pop up" from "a console +browser will hijack the terminal" so OAuth callsites can fall back to printing +the URL / manual paste instead of auto-opening. +""" + +from __future__ import annotations + +import webbrowser + +import pytest + +from hermes_cli.auth import _can_open_graphical_browser + + +class _FakeController: + def __init__(self, name: str) -> None: + self.name = name + + def open(self, *_a, **_kw): # pragma: no cover - never invoked + return True + + +@pytest.fixture(autouse=True) +def _clean_browser_env(monkeypatch): + """Each test controls DISPLAY / WAYLAND_DISPLAY / BROWSER explicitly.""" + for var in ("DISPLAY", "WAYLAND_DISPLAY", "BROWSER"): + monkeypatch.delenv(var, raising=False) + yield + + +def _force_platform_linux(monkeypatch): + monkeypatch.setattr("hermes_cli.auth.sys.platform", "linux") + + +def _force_resolved_browser(monkeypatch, name: str): + monkeypatch.setattr(webbrowser, "get", lambda *_a, **_kw: _FakeController(name)) + + +def test_headless_linux_no_display_refuses(monkeypatch): + """The reported bug: headless Linux, no display server → don't auto-open.""" + _force_platform_linux(monkeypatch) + # Even if a GUI browser somehow resolved, no display means no GUI. + _force_resolved_browser(monkeypatch, "google-chrome") + assert _can_open_graphical_browser() is False + + +def test_browser_env_pointing_at_console_browser_refuses(monkeypatch): + """$BROWSER=w3m must refuse even with a display server present.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setenv("BROWSER", "/usr/bin/w3m") + assert _can_open_graphical_browser() is False + + +@pytest.mark.parametrize("console", ["w3m", "lynx", "links", "elinks", "browsh"]) +def test_resolved_console_browser_refuses(monkeypatch, console): + """When webbrowser resolves to a console browser, refuse to auto-open.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + _force_resolved_browser(monkeypatch, console) + assert _can_open_graphical_browser() is False + + +def test_graphical_browser_with_display_allows(monkeypatch): + """Real GUI browser + display server → auto-open is fine.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + _force_resolved_browser(monkeypatch, "firefox") + assert _can_open_graphical_browser() is True + + +def test_webbrowser_get_raises_refuses(monkeypatch): + """No resolvable browser at all → don't auto-open.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + + def _boom(*_a, **_kw): + raise webbrowser.Error("no browser") + + monkeypatch.setattr(webbrowser, "get", _boom) + assert _can_open_graphical_browser() is False + + +def test_non_linux_with_gui_allows(monkeypatch): + """macOS / Windows always have a usable default GUI browser.""" + monkeypatch.setattr("hermes_cli.auth.sys.platform", "darwin") + _force_resolved_browser(monkeypatch, "MacOSX") + assert _can_open_graphical_browser() is True