# NousResearch/hermes-agent · OAuth manual paste fallback for remote console environments

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #28358. Verify it addresses your case.
- **Recommended:** read PR #28358 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-33880`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.29`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #26923: OAuth loopback login broken for remote/browser-based consoles (GCP, Codespaces, etc.)
- #28971: Google Gemini CLI OAuth fails on remote servers — redirect to 127.0.0.1 unreachable

## Prior & competing PRs

- **#28358: feat(oauth): add --manual-paste fallback for browser-only remote consoles (closes #26923)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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`
- **#33880: fix(xai-oauth): accept bare-code manual paste (state=None) (#26923)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/auth.py`, `tests/hermes_cli/test_auth_manual_paste.py`
  - fix hunks:
  - `hermes_cli/auth.py`

    ```diff
    @@ -3181,6 +3181,9 @@ def _prompt_manual_callback_paste(redirect_uri: str) -> dict:
         print("not on your laptop) — that is expected.  Copy the FULL URL")
         print("from your browser's address bar of that failed page and paste")
         print("it below.  A bare '?code=...&state=...' fragment also works.")
    +    print("If the consent page shows the authorization code in-page")
    +    print("(xAI's current behavior) rather than redirecting, paste the")
    +    print("bare code value on its own.")
         print("───────────────────────────────────────────────────────────────")
         try:
             raw = input("Callback URL: ")
    ```

    ```diff
    @@ -6965,7 +6968,21 @@ def _stdin_supports_manual_paste() -> bool:
                 provider="xai-oauth",
                 code="xai_authorization_failed",
             )
    -    if callback.get("state") != state:
    +    callback_state = callback.get("state")
    +    # Manual-paste bare-code path: when a user pastes only the opaque
    +    # authorization code (no ``code=``/``state=`` query parameters),
    +    # ``_parse_pasted_callback`` returns ``state=None``.  xAI's consent
    +    # page renders the code in-page rather than redirecting through the
    +    # 127.0.0.1 callback, so on many remote setups (Cloud Shell, headless
    +    # VPS, container consoles) the bare code is the only thing the user
    +    # can obtain.  PKCE (code_verifier) still binds the exchange to this
    +    # client, so the local state-equality check is redundant on the
    +    # bare-code path — we substitute the locally generated state to keep
    +    # the rest of the validation chain (and the token exchange) unchanged.
    +    # See #26923 (AccursedGalaxy comment, 2026-05-20).
    +    if callback_state is None and manual_paste:
    +        callback_state = state
    +    if callback_state != state:
             raise AuthError(
                 "xAI authorization failed: state mismatch.",
                 provider="xai-oauth",
    ```
  - `tests/hermes_cli/test_auth_manual_paste.py`

    ```diff
    @@ -330,6 +330,107 @@ def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch):
         assert exc.value.code == "xai_state_mismatch"
     
     
    +def test_xai_loopback_login_manual_paste_bare_code_succeeds(monkeypatch):
    +    """Bare-code paste (state=None) must complete login under manual_paste.
    +
    +    xAI's consent page renders the authorization code in-page rather than
    +    redirecting through 127.0.0.1, so on remote/headless setups the only
    +    value the user can obtain is the opaque code with no ``state=``
    +    parameter. ``_parse_pasted_callback`` correctly returns
    +    ``state=None`` for that input. The login flow must accept this case
    +    (PKCE still protects the exchange); historically it raised
    +    ``xai_state_mismatch``. Regression for the bare-code branch of #26923.
    +    """
    +    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",
    +        },
    +    )
    +    monkeypatch.setattr(
    +        auth_mod, "_prompt_manual_callback_paste",
    +        lambda _ru: {
    +            "code": "bare-opaque-code",
    +            "state": None,
    +            "error": None,
    +            "error_description": None,
    +        },
    +    )
    +
    +    def _fake_token_post(*_a, **_k):
    +        return _StubTokenResponse(
    +            {
    +                "access_token": "at",
    +                "refresh_token": "rt",
    +                "id_token": "",
    +                "expires_in": 3600,
    +                "token_type": "Bearer",
    +            }
    +        )
    +
    +    monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post)
    +
    +    with contextlib.redirect_stdout(io.StringIO()):
    +        creds = auth_mod._xai_oauth_loopback_login(manual_paste=True)
    +
    +    assert creds["tokens"]["access_token"] == "at"
    +    assert creds["tokens"]["refresh_token"] == "rt"
    +
    +
    +def test_xai_loopback_login_loopback_path_rejects_missing_state(monkeypatch):
    +    """Loopback (manual_paste=False) must NOT accept ``state=None``.
    +
    +    The bare-code relaxation only applies to the manual-paste path,
    +    where the user demonstrably has no way to supply ``state``. The
    +    HTTP-server path always sees ``state`` populated from the real
    +    callback query string, so missing state there means something is
    +    wrong (a malformed callback, an attacker-supplied request) and
    +    must still raise ``xai_state_mismatch``.
    +    """
    +    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
    +
    +    monkeypatch.setattr(
    +        auth_mod, "_xai_start_callback_server",
    +        lambda *_a, **_k: (
    +            _StubServer(),
    +            None,
    +            {"code": "fake", "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: {
    +            "code": "fake",
    +            "state": None,
    +            "error": None,
    +            "error_description": None,
    +        },
    +    )
    +    monkeypatch.setattr(auth_mod, "_xai_validate_loopback_redirect_uri", lambda _u: None)
    +    monkeypatch.setattr(auth_mod, "_print_loopback_ssh_hint", lambda *_a, **_k: None)
    +
    +    with contextlib.redirect_stdout(io.StringIO()):
    +        with pytest.raises(auth_mod.AuthError) as exc:
    +            auth_mod._xai_oauth_loopback_login(manual_paste=False, open_browser=False)
    +    assert exc.value.code == "xai_state_mismatch"
    +
    +
     def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
         """Empty paste must surface as ``xai_code_missing``, not crash."""
         monkeypatch.setattr(
    ```
- **#26929: feat(oauth): add --manual-paste fallback for browser-only remote consoles (closes #26923)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#28358`
- **#33297: fix: improve xAI Grok OAuth manual paste flow (#26923)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `442a9203c`, `#33880`, `#26923`, `#33880`, `#26923`, `#33880`

## Fix status

- Merged to main in PR #28358 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-28`.
- Subsystem: `hermes_cli`
