{"repo":"NousResearch/hermes-agent","public_id":"27385","display_label":"xAI OAuth loopback callback timeout and fallback fixes","summary":"Multiple issues and fixes addressing xAI OAuth loopback server failures on macOS, where the local callback is received but the auth flow times out. Changes include diagnostics, threaded callback handling, 'not received' page display, and manual paste fallback on timeout.","subsystem":"agent","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":3,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28110},"resolution":{"state":"partially_resolved","by":"pr-33231"},"freshness":{"state":"verified","last_verified_at":"2026-05-28T07:24:18+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.22714285714285712},"fix_edges":[{"issue_number":27385,"pr_number":28110,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":27385,"pr_number":28112,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":32984,"pr_number":33231,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":27385,"title":"[Bug]: xAI OAuth loopback on macOS: local callback received, but Hermes times out","url":null,"state":"closed"},{"kind":"issue","number":32984,"title":"[Setup]: Problem connecting to xAI Grok OAuth (Grok Business, includes SuperGrok)","url":null,"state":"closed"},{"kind":"pr","number":27439,"title":"fix(xai-oauth): show \"not received\" page when loopback callback has no code (#27385)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28112","number":28112},{"kind":"pr","value":"#28110","number":28110}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28110,"title":"fix(xai-oauth): make loopback callback server threaded + latch first result","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","scripts/release.py","tests/hermes_cli/test_auth_xai_oauth_provider.py"]},{"kind":"pr","number":28112,"title":"fix(xai-oauth): show 'not received' page when loopback callback has no code","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","tests/hermes_cli/test_auth_xai_oauth_provider.py"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -2405,15 +2405,37 @@ def do_GET(self) -> None:  # noqa: N802\n                 \"error\": params.get(\"error\", [None])[0],\n                 \"error_description\": params.get(\"error_description\", [None])[0],\n             }\n+\n+            # Treat a hit on the callback path with neither `code` nor `error`\n+            # as a missing OAuth callback (e.g. xAI's auth backend failed to\n+            # redirect and the user navigated to the bare loopback URL by hand).\n+            # Show an explicit \"not received\" page rather than the success page —\n+            # otherwise the browser claims authorization succeeded while the CLI\n+            # is still waiting for a real callback and eventually times out.\n+            if incoming[\"code\"] is None and incoming[\"error\"] is None:\n+                self.send_response(400)\n+                self._maybe_write_cors_headers()\n+                self.send_header(\"Content-Type\", \"text/html; charset=utf-8\")\n+                self.end_headers()\n+                body = (\n+                    \"<html><body>\"\n+                    \"<h1>xAI authorization not received.</h1>\"\n+                    \"<p>No authorization code was present in this callback URL. \"\n+                    \"Return to the terminal and re-run \"\n+                    \"<code>hermes auth add xai-oauth</code> to retry.</p>\"\n+                    \"</body></html>\"\n+                )\n+                self.wfile.write(body.encode(\"utf-8\"))\n+                return\n+\n             # ThreadingHTTPServer allows a fallback/manual callback to complete\n             # while a browser connection is stuck.  Once we have a terminal\n             # OAuth result (code or error), keep the first one so a later\n             # concurrent/invalid callback cannot overwrite state before\n             # validation in _xai_oauth_loopback_login().\n-            if incoming[\"code\"] or incoming[\"error\"]:\n-                with result_lock:\n-                    if not (result[\"code\"] or result[\"error\"]):\n-                        result.update(incoming)\n+            with result_lock:\n+                if not (result[\"code\"] or result[\"error\"]):\n+                    result.update(incoming)\n \n             self.send_response(200)\n             self._maybe_write_cors_headers()"]},{"file":"tests/hermes_cli/test_auth_xai_oauth_provider.py","hunks":["@@ -326,6 +326,84 @@ def test_xai_callback_server_latches_first_terminal_callback_result():\n         thread.join(timeout=1.0)\n \n \n+# ---------------------------------------------------------------------------\n+# Loopback callback handler GET responses\n+# ---------------------------------------------------------------------------\n+\n+\n+def _get_callback(redirect_uri: str, query: str = \"\") -> tuple[int, str]:\n+    \"\"\"GET the loopback callback URL with an optional query string.\"\"\"\n+    from urllib.request import Request, urlopen\n+    from urllib.error import HTTPError\n+\n+    target = redirect_uri + ((\"?\" + query) if query else \"\")\n+    req = Request(target, method=\"GET\")\n+    try:\n+        with urlopen(req, timeout=5.0) as resp:\n+            return resp.getcode(), resp.read().decode(\"utf-8\", \"replace\")\n+    except HTTPError as exc:\n+        return exc.code, exc.read().decode(\"utf-8\", \"replace\")\n+\n+\n+def test_xai_callback_handler_returns_400_when_callback_url_lacks_code_and_error():\n+    \"\"\"Bare loopback URL (no code, no error) must not claim authorization received.\n+\n+    Regression for #27385: when xAI's auth backend fails to redirect and the user\n+    manually navigates to http://127.0.0.1:<port>/callback, the handler used to\n+    return 200 \"xAI authorization received\" while the CLI's wait loop still timed\n+    out — leaving the user with a contradictory success page and a CLI error.\n+    \"\"\"\n+    server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)\n+    try:\n+        status, body = _get_callback(redirect_uri)\n+        assert status == 400\n+        assert \"not received\" in body.lower()\n+        assert \"hermes auth add xai-oauth\" in body\n+        # Wait loop must still see no code/error so it raises a real timeout,\n+        # rather than treating this empty hit as a successful callback.\n+        assert result[\"code\"] is None\n+        assert result[\"error\"] is None\n+    finally:\n+        server.shutdown()\n+        server.server_close()\n+        thread.join(timeout=1.0)\n+\n+\n+def test_xai_callback_handler_accepts_callback_with_code():\n+    \"\"\"A real OAuth redirect (code + state) still records both and shows success.\"\"\"\n+    server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)\n+    try:\n+        status, body = _get_callback(redirect_uri, query=\"code=abc&state=xyz\")\n+        assert status == 200\n+        assert \"xAI authorization received\" in body\n+        assert result[\"code\"] == \"abc\"\n+        assert result[\"state\"] == \"xyz\"\n+        assert result[\"error\"] is None\n+    finally:\n+        server.shutdown()\n+        server.server_close()\n+        thread.join(timeout=1.0)\n+\n+\n+def test_xai_callback_handler_records_error_callback():\n+    \"\"\"A redirect carrying an `error` param must surface the failure page and capture detail.\"\"\"\n+    server, thread, result, redirect_uri = _xai_start_callback_server(preferred_port=0)\n+    try:\n+        status, body = _get_callback(\n+            redirect_uri,\n+            query=\"error=access_denied&error_description=user%20cancelled\",\n+        )\n+        assert status == 200\n+        assert \"xAI authorization failed\" in body\n+        assert result[\"error\"] == \"access_denied\"\n+        assert result[\"error_description\"] == \"user cancelled\"\n+        assert result[\"code\"] is None\n+    finally:\n+        server.shutdown()\n+        server.server_close()\n+        thread.join(timeout=1.0)\n+\n+\n # ---------------------------------------------------------------------------\n # Token roundtrip + reads\n # ---------------------------------------------------------------------------\n"]}]},{"kind":"pr","number":33006,"title":"Fix xAI OAuth timeout manual fallback","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"1b9c247b9","number":0},{"kind":"pr","value":"#33231","number":33231},{"kind":"pr","value":"#31894","number":31894},{"kind":"pr","value":"#31894","number":31894}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":33231,"title":"fix(xai-oauth): fall back to manual paste on loopback timeout (#33006)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/auth.py","tests/hermes_cli/test_auth_manual_paste.py"]},{"kind":"pr","number":33289,"title":"fix(xai-oauth): fall back to manual paste on loopback timeout (#33006)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33231","number":33231}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}