fix(auth): don't launch a text-mode browser inside the terminal for OAuth (#34479)

OAuth auto-open only checked _is_remote_session() (SSH + cloud-shell env
vars). On a headless/CLI-only Linux box with no GUI browser, none of those
trip, so webbrowser.open() resolved to a console browser (w3m/lynx/links)
and launched it INSIDE the terminal — hijacking the user's TTY with the
xAI 'Account Management' login page instead of letting them copy the URL.

Add _can_open_graphical_browser(): returns False when webbrowser would
resolve to a known console browser, when $BROWSER names one, when there's
no display server on Linux, or when no browser resolves at all. Gate all 5
OAuth auto-open callsites (xAI loopback, Spotify loopback, MiniMax device
code, Anthropic, Google) on it in addition to the existing remote check.
Headless boxes now print the URL / fall through to manual-paste instead.
This commit is contained in:
Teknium
2026-05-29 01:23:06 -07:00
committed by GitHub
parent f247686c42
commit c01a2df0a3
5 changed files with 201 additions and 7 deletions

View File

@ -1256,10 +1256,16 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
print() print()
try: try:
webbrowser.open(auth_url) from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui
print(" (Browser opened automatically)")
except Exception: 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()
print("After authorizing, you'll see a code. Paste it below.") print("After authorizing, you'll see a code. Paste it below.")

View File

@ -899,7 +899,15 @@ def start_oauth_flow(
try: try:
import webbrowser 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: except Exception as exc:
logger.debug("webbrowser.open failed: %s", exc) logger.debug("webbrowser.open failed: %s", exc)

View File

@ -3033,7 +3033,7 @@ def login_spotify_command(args) -> None:
_print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) _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: try:
opened = webbrowser.open(authorize_url) opened = webbrowser.open(authorize_url)
except Exception: except Exception:
@ -3114,6 +3114,83 @@ def _is_remote_session() -> bool:
return False 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: def _parse_pasted_callback(raw: str) -> dict:
"""Parse a pasted callback URL / query string into the loopback shape. """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) _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: try:
opened = webbrowser.open(authorize_url) opened = webbrowser.open(authorize_url)
except Exception: except Exception:
@ -7358,7 +7435,7 @@ def _minimax_oauth_login(
print("To continue:") print("To continue:")
print(f" 1. Open: {verification_url}") print(f" 1. Open: {verification_url}")
print(f" 2. If prompted, enter code: {user_code}") 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): if webbrowser.open(verification_url):
print(" (Opened browser for verification)") print(" (Opened browser for verification)")
else: else:

View File

@ -52,6 +52,13 @@ def _patch_oauth_flow(
return True return True
monkeypatch.setattr("webbrowser.open", fake_open) 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) monkeypatch.setattr("builtins.input", lambda *_a, **_kw: callback_code)
class _FakeResponse: class _FakeResponse:

View File

@ -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