{"repo":"NousResearch/hermes-agent","public_id":"26923","display_label":"OAuth manual paste fallback for remote console environments","summary":"These issues and PRs address OAuth loopback redirect failures in remote or browser-based environments (e.g., GCP, Codespaces) where 127.0.0.1 is unreachable. They propose and implement a manual paste fallback for xAI and Google Gemini CLI OAuth flows.","subsystem":"hermes_cli","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":28358},"resolution":{"state":"partially_resolved","by":"pr-33880"},"freshness":{"state":"verified","last_verified_at":"2026-05-28T12:47:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"fix_edges":[{"issue_number":26923,"pr_number":28358,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":26923,"pr_number":33880,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":26923,"title":"OAuth loopback login broken for remote/browser-based consoles (GCP, Codespaces, etc.)","url":null,"state":"closed"},{"kind":"issue","number":28971,"title":"Google Gemini CLI OAuth fails on remote servers — redirect to 127.0.0.1 unreachable","url":null,"state":"closed"},{"kind":"pr","number":26929,"title":"feat(oauth): add --manual-paste fallback for browser-only remote consoles (closes #26923)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28358","number":28358}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":28358,"title":"feat(oauth): add --manual-paste fallback for browser-only remote consoles (closes #26923)","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","hermes_cli/auth_commands.py","hermes_cli/main.py","tests/hermes_cli/test_auth_manual_paste.py","website/docs/guides/oauth-over-ssh.md","website/docs/guides/xai-grok-oauth.md"]},{"kind":"pr","number":33297,"title":"fix: improve xAI Grok OAuth manual paste flow (#26923)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"442a9203c","number":0},{"kind":"pr","value":"#33880","number":33880},{"kind":"pr","value":"#26923","number":26923},{"kind":"pr","value":"#33880","number":33880},{"kind":"pr","value":"#26923","number":26923},{"kind":"pr","value":"#33880","number":33880}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":33880,"title":"fix(xai-oauth): accept bare-code manual paste (state=None) (#26923)","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"],"fix_hunks":[{"file":"hermes_cli/auth.py","hunks":["@@ -3181,6 +3181,9 @@ def _prompt_manual_callback_paste(redirect_uri: str) -> dict:\n     print(\"not on your laptop) — that is expected.  Copy the FULL URL\")\n     print(\"from your browser's address bar of that failed page and paste\")\n     print(\"it below.  A bare '?code=...&state=...' fragment also works.\")\n+    print(\"If the consent page shows the authorization code in-page\")\n+    print(\"(xAI's current behavior) rather than redirecting, paste the\")\n+    print(\"bare code value on its own.\")\n     print(\"───────────────────────────────────────────────────────────────\")\n     try:\n         raw = input(\"Callback URL: \")","@@ -6965,7 +6968,21 @@ def _stdin_supports_manual_paste() -> bool:\n             provider=\"xai-oauth\",\n             code=\"xai_authorization_failed\",\n         )\n-    if callback.get(\"state\") != state:\n+    callback_state = callback.get(\"state\")\n+    # Manual-paste bare-code path: when a user pastes only the opaque\n+    # authorization code (no ``code=``/``state=`` query parameters),\n+    # ``_parse_pasted_callback`` returns ``state=None``.  xAI's consent\n+    # page renders the code in-page rather than redirecting through the\n+    # 127.0.0.1 callback, so on many remote setups (Cloud Shell, headless\n+    # VPS, container consoles) the bare code is the only thing the user\n+    # can obtain.  PKCE (code_verifier) still binds the exchange to this\n+    # client, so the local state-equality check is redundant on the\n+    # bare-code path — we substitute the locally generated state to keep\n+    # the rest of the validation chain (and the token exchange) unchanged.\n+    # See #26923 (AccursedGalaxy comment, 2026-05-20).\n+    if callback_state is None and manual_paste:\n+        callback_state = state\n+    if callback_state != state:\n         raise AuthError(\n             \"xAI authorization failed: state mismatch.\",\n             provider=\"xai-oauth\","]},{"file":"tests/hermes_cli/test_auth_manual_paste.py","hunks":["@@ -330,6 +330,107 @@ def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):\n     assert exc.value.code == \"xai_state_mismatch\"\n \n \n+def test_xai_loopback_login_manual_paste_bare_code_succeeds(monkeypatch):\n+    \"\"\"Bare-code paste (state=None) must complete login under manual_paste.\n+\n+    xAI's consent page renders the authorization code in-page rather than\n+    redirecting through 127.0.0.1, so on remote/headless setups the only\n+    value the user can obtain is the opaque code with no ``state=``\n+    parameter. ``_parse_pasted_callback`` correctly returns\n+    ``state=None`` for that input. The login flow must accept this case\n+    (PKCE still protects the exchange); historically it raised\n+    ``xai_state_mismatch``. Regression for the bare-code branch of #26923.\n+    \"\"\"\n+    monkeypatch.setattr(\n+        auth_mod, \"_xai_oauth_discovery\",\n+        lambda *_a, **_k: {\n+            \"authorization_endpoint\": \"https://auth.x.ai/oauth2/authorize\",\n+            \"token_endpoint\": \"https://auth.x.ai/oauth2/token\",\n+        },\n+    )\n+    monkeypatch.setattr(\n+        auth_mod, \"_prompt_manual_callback_paste\",\n+        lambda _ru: {\n+            \"code\": \"bare-opaque-code\",\n+            \"state\": None,\n+            \"error\": None,\n+            \"error_description\": None,\n+        },\n+    )\n+\n+    def _fake_token_post(*_a, **_k):\n+        return _StubTokenResponse(\n+            {\n+                \"access_token\": \"at\",\n+                \"refresh_token\": \"rt\",\n+                \"id_token\": \"\",\n+                \"expires_in\": 3600,\n+                \"token_type\": \"Bearer\",\n+            }\n+        )\n+\n+    monkeypatch.setattr(auth_mod.httpx, \"post\", _fake_token_post)\n+\n+    with contextlib.redirect_stdout(io.StringIO()):\n+        creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)\n+\n+    assert creds[\"tokens\"][\"access_token\"] == \"at\"\n+    assert creds[\"tokens\"][\"refresh_token\"] == \"rt\"\n+\n+\n+def test_xai_loopback_login_loopback_path_rejects_missing_state(monkeypatch):\n+    \"\"\"Loopback (manual_paste=False) must NOT accept ``state=None``.\n+\n+    The bare-code relaxation only applies to the manual-paste path,\n+    where the user demonstrably has no way to supply ``state``. The\n+    HTTP-server path always sees ``state`` populated from the real\n+    callback query string, so missing state there means something is\n+    wrong (a malformed callback, an attacker-supplied request) and\n+    must still raise ``xai_state_mismatch``.\n+    \"\"\"\n+    monkeypatch.setattr(\n+        auth_mod, \"_xai_oauth_discovery\",\n+        lambda *_a, **_k: {\n+            \"authorization_endpoint\": \"https://auth.x.ai/oauth2/authorize\",\n+            \"token_endpoint\": \"https://auth.x.ai/oauth2/token\",\n+        },\n+    )\n+\n+    class _StubServer:\n+        def shutdown(self):\n+            return None\n+\n+        def server_close(self):\n+            return None\n+\n+    monkeypatch.setattr(\n+        auth_mod, \"_xai_start_callback_server\",\n+        lambda *_a, **_k: (\n+            _StubServer(),\n+            None,\n+            {\"code\": \"fake\", \"state\": None, \"error\": None,\n+             \"error_description\": None},\n+            \"http://127.0.0.1:56121/callback\",\n+        ),\n+    )\n+    monkeypatch.setattr(\n+        auth_mod, \"_xai_wait_for_callback\",\n+        lambda *_a, **_k: {\n+            \"code\": \"fake\",\n+            \"state\": None,\n+            \"error\": None,\n+            \"error_description\": None,\n+        },\n+    )\n+    monkeypatch.setattr(auth_mod, \"_xai_validate_loopback_redirect_uri\", lambda _u: None)\n+    monkeypatch.setattr(auth_mod, \"_print_loopback_ssh_hint\", lambda *_a, **_k: None)\n+\n+    with contextlib.redirect_stdout(io.StringIO()):\n+        with pytest.raises(auth_mod.AuthError) as exc:\n+            auth_mod._xai_oauth_loopback_login(manual_paste=False, open_browser=False)\n+    assert exc.value.code == \"xai_state_mismatch\"\n+\n+\n def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):\n     \"\"\"Empty paste must surface as ``xai_code_missing``, not crash.\"\"\"\n     monkeypatch.setattr(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}