From 442a9203c012989824f9f1698abad92b2ec38034 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Wed, 27 May 2026 11:40:24 +0800 Subject: [PATCH] Fix xAI OAuth timeout manual fallback --- hermes_cli/auth.py | 34 ++++- tests/hermes_cli/test_auth_manual_paste.py | 157 +++++++++++++++++++++ 2 files changed, 185 insertions(+), 6 deletions(-) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index d1be1d889..51c179b40 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -6826,6 +6826,12 @@ def _xai_oauth_loopback_login( remote VM). The same PKCE verifier, ``state``, and ``nonce`` are used for both paths so the upstream-side OAuth flow is identical. """ + def _stdin_supports_manual_paste() -> bool: + try: + return bool(getattr(sys.stdin, "isatty", lambda: False)()) + except Exception: + return False + discovery = _xai_oauth_discovery(timeout_seconds) authorization_endpoint = discovery["authorization_endpoint"] token_endpoint = discovery["token_endpoint"] @@ -6889,12 +6895,28 @@ def _xai_oauth_loopback_login( else: print("Could not open the browser automatically; use the URL above.") - callback = _xai_wait_for_callback( - server, - thread, - callback_result, - timeout_seconds=max(30.0, timeout_seconds * 9), - ) + try: + callback = _xai_wait_for_callback( + server, + thread, + callback_result, + timeout_seconds=max(30.0, timeout_seconds * 9), + ) + except AuthError as exc: + if ( + getattr(exc, "code", "") != "xai_callback_timeout" + or not _stdin_supports_manual_paste() + ): + raise + print() + print("xAI loopback callback timed out.") + print("If your browser reached a failed 127.0.0.1 callback page,") + print("paste that FULL callback URL below to continue this login.") + print("You can also re-run with `--manual-paste` to skip the") + print("loopback listener from the start.") + callback = _prompt_manual_callback_paste(redirect_uri) + if callback.get("code") is None and callback.get("error") is None: + raise exc except Exception: try: server.shutdown() diff --git a/tests/hermes_cli/test_auth_manual_paste.py b/tests/hermes_cli/test_auth_manual_paste.py index 3f0fa2a59..7230b2a36 100644 --- a/tests/hermes_cli/test_auth_manual_paste.py +++ b/tests/hermes_cli/test_auth_manual_paste.py @@ -363,6 +363,163 @@ def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch): assert exc.value.code == "xai_code_missing" +def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch): + """Loopback timeout should offer the existing manual-paste path.""" + monkeypatch.setattr( + auth_mod, "_xai_oauth_discovery", + lambda *_a, **_k: { + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://auth.x.ai/oauth2/token", + }, + ) + + class _StubServer: + def shutdown(self): + return None + + def server_close(self): + return None + + class _StubThread: + def join(self, timeout=None): + return None + + monkeypatch.setattr( + auth_mod, + "_xai_start_callback_server", + lambda: ( + _StubServer(), + _StubThread(), + { + "code": None, + "state": None, + "error": None, + "error_description": None, + }, + "http://127.0.0.1:56121/callback", + ), + ) + + captured: dict = {"state": None, "prompt_calls": 0} + original_build = auth_mod._xai_oauth_build_authorize_url + + def _capture(**kwargs): + captured["state"] = kwargs["state"] + return original_build(**kwargs) + + monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture) + + def _raise_timeout(*_a, **_k): + raise auth_mod.AuthError( + "xAI authorization timed out waiting for the local callback.", + provider="xai-oauth", + code="xai_callback_timeout", + ) + + monkeypatch.setattr(auth_mod, "_xai_wait_for_callback", _raise_timeout) + + def _fake_prompt(_redirect_uri): + captured["prompt_calls"] += 1 + return { + "code": "manual-auth-code", + "state": captured["state"], + "error": None, + "error_description": None, + } + + monkeypatch.setattr(auth_mod, "_prompt_manual_callback_paste", _fake_prompt) + monkeypatch.setattr( + auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: True})() + ) + monkeypatch.setattr( + auth_mod.httpx, + "post", + lambda *_a, **_k: _StubTokenResponse( + { + "access_token": "at-timeout", + "refresh_token": "rt-timeout", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + } + ), + ) + + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + creds = auth_mod._xai_oauth_loopback_login(manual_paste=False) + + rendered = buf.getvalue() + assert "xAI loopback callback timed out." in rendered + assert "--manual-paste" in rendered + assert captured["prompt_calls"] == 1 + assert creds["tokens"]["access_token"] == "at-timeout" + assert creds["tokens"]["refresh_token"] == "rt-timeout" + + +def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch): + """Non-interactive stdin must keep the original timeout error.""" + monkeypatch.setattr( + auth_mod, "_xai_oauth_discovery", + lambda *_a, **_k: { + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://auth.x.ai/oauth2/token", + }, + ) + + class _StubServer: + def shutdown(self): + return None + + def server_close(self): + return None + + class _StubThread: + def join(self, timeout=None): + return None + + monkeypatch.setattr( + auth_mod, + "_xai_start_callback_server", + lambda: ( + _StubServer(), + _StubThread(), + { + "code": None, + "state": None, + "error": None, + "error_description": None, + }, + "http://127.0.0.1:56121/callback", + ), + ) + + monkeypatch.setattr( + auth_mod, + "_xai_wait_for_callback", + lambda *_a, **_k: (_ for _ in ()).throw( + auth_mod.AuthError( + "xAI authorization timed out waiting for the local callback.", + provider="xai-oauth", + code="xai_callback_timeout", + ) + ), + ) + monkeypatch.setattr( + auth_mod.sys, "stdin", type("StubStdin", (), {"isatty": lambda self: False})() + ) + monkeypatch.setattr( + auth_mod, + "_prompt_manual_callback_paste", + lambda *_a, **_k: pytest.fail("manual-paste fallback should not run"), + ) + + with contextlib.redirect_stdout(io.StringIO()): + with pytest.raises(auth_mod.AuthError) as exc: + auth_mod._xai_oauth_loopback_login(manual_paste=False) + assert exc.value.code == "xai_callback_timeout" + + # --------------------------------------------------------------------------- # _print_loopback_ssh_hint — now also mentions --manual-paste # ---------------------------------------------------------------------------