diff --git a/tests/hermes_cli/test_auth_manual_paste.py b/tests/hermes_cli/test_auth_manual_paste.py new file mode 100644 index 00000000000..3f0fa2a59e4 --- /dev/null +++ b/tests/hermes_cli/test_auth_manual_paste.py @@ -0,0 +1,384 @@ +"""Tests for the OAuth manual-paste fallback for browser-only remotes. + +Regression coverage for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923): +GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect and +other browser-only remote consoles can't reach the +``http://127.0.0.1:56121/callback`` loopback listener bound on the +remote VM. The previous SSH-tunnel hint was useless without a real +SSH client, leaving the user with no path forward. This test file +locks in four things: + +* ``_is_remote_session`` recognises the cloud-shell / Codespaces + envvars (so the existing hint at least fires). +* ``_parse_pasted_callback`` accepts every form a user might paste + (full URL, ``?code=...&state=...`` fragment, bare ``code=...``, + bare opaque value) and returns the same shape the loopback HTTP + handler does. +* ``_prompt_manual_callback_paste`` reads stdin and produces that + same shape. +* ``_xai_oauth_loopback_login(manual_paste=True)`` skips the HTTP + server entirely, validates ``state``, and goes straight to the + token exchange — proving the paste path actually wires up. +""" + +from __future__ import annotations + +import builtins +import io +import contextlib + +import pytest + +from hermes_cli import auth as auth_mod + + +# --------------------------------------------------------------------------- +# _is_remote_session — broadened detection (#26923) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "envvar", + [ + "SSH_CLIENT", + "SSH_TTY", + "CLOUD_SHELL", + "CODESPACES", + "CODESPACE_NAME", + "GITPOD_WORKSPACE_ID", + "REPL_ID", + "STACKBLITZ", + ], +) +def test_is_remote_session_detects_known_remote_envvar(monkeypatch, envvar): + """Each documented remote-console env var must trip the check. + + The SSH ones preserve historical behaviour; the cloud-shell ones + are what closes #26923. Without these, the SSH hint never fires + and the user has no signal that ``--manual-paste`` exists. + """ + for name in ( + "SSH_CLIENT", + "SSH_TTY", + "CLOUD_SHELL", + "CODESPACES", + "CODESPACE_NAME", + "GITPOD_WORKSPACE_ID", + "REPL_ID", + "STACKBLITZ", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv(envvar, "1") + assert auth_mod._is_remote_session() is True + + +def test_is_remote_session_false_when_no_remote_envvars(monkeypatch): + for name in ( + "SSH_CLIENT", + "SSH_TTY", + "CLOUD_SHELL", + "CODESPACES", + "CODESPACE_NAME", + "GITPOD_WORKSPACE_ID", + "REPL_ID", + "STACKBLITZ", + ): + monkeypatch.delenv(name, raising=False) + assert auth_mod._is_remote_session() is False + + +# --------------------------------------------------------------------------- +# _parse_pasted_callback — accept every plausible paste form +# --------------------------------------------------------------------------- + + +def test_parse_full_callback_url(): + out = auth_mod._parse_pasted_callback( + "http://127.0.0.1:56121/callback?code=abc123&state=deadbeef" + ) + assert out == { + "code": "abc123", + "state": "deadbeef", + "error": None, + "error_description": None, + } + + +def test_parse_callback_url_https_and_extra_params(): + out = auth_mod._parse_pasted_callback( + "https://127.0.0.1:56121/callback?code=abc&state=xyz&scope=openid" + ) + assert out["code"] == "abc" + assert out["state"] == "xyz" + + +def test_parse_bare_query_string_with_leading_question_mark(): + out = auth_mod._parse_pasted_callback("?code=p1&state=s1") + assert out["code"] == "p1" + assert out["state"] == "s1" + + +def test_parse_bare_query_fragment_no_question_mark(): + out = auth_mod._parse_pasted_callback("code=p2&state=s2") + assert out["code"] == "p2" + assert out["state"] == "s2" + + +def test_parse_bare_opaque_code_value(): + """Some users only copy the ``code`` value itself.""" + out = auth_mod._parse_pasted_callback("ABCDEF-the-code-value") + assert out["code"] == "ABCDEF-the-code-value" + assert out["state"] is None + + +def test_parse_callback_with_error_field(): + out = auth_mod._parse_pasted_callback( + "http://127.0.0.1:56121/callback?error=access_denied" + "&error_description=user+rejected" + ) + assert out["code"] is None + assert out["error"] == "access_denied" + assert out["error_description"] == "user rejected" + + +def test_parse_empty_input_returns_all_none(): + out = auth_mod._parse_pasted_callback("") + assert out == { + "code": None, + "state": None, + "error": None, + "error_description": None, + } + + +def test_parse_whitespace_only_returns_all_none(): + out = auth_mod._parse_pasted_callback(" \n\t ") + assert out["code"] is None + + +def test_parse_malformed_url_does_not_crash(): + out = auth_mod._parse_pasted_callback("http://[not a url") + # Malformed URLs return all-None rather than raising — the caller + # (state check) will reject the empty payload with a clear error. + assert out["code"] is None + + +# --------------------------------------------------------------------------- +# _prompt_manual_callback_paste — stdin handling +# --------------------------------------------------------------------------- + + +def test_prompt_reads_stdin_and_parses(monkeypatch): + monkeypatch.setattr( + builtins, "input", + lambda *_a, **_k: "http://127.0.0.1:56121/callback?code=abc&state=xyz", + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + out = auth_mod._prompt_manual_callback_paste( + "http://127.0.0.1:56121/callback" + ) + rendered = buf.getvalue() + assert "Manual callback paste" in rendered + assert "127.0.0.1:56121" in rendered + assert out["code"] == "abc" + assert out["state"] == "xyz" + + +def test_prompt_eof_returns_all_none(monkeypatch): + def _raise_eof(*_a, **_k): + raise EOFError() + + monkeypatch.setattr(builtins, "input", _raise_eof) + with contextlib.redirect_stdout(io.StringIO()): + out = auth_mod._prompt_manual_callback_paste( + "http://127.0.0.1:56121/callback" + ) + assert out["code"] is None + + +def test_prompt_keyboard_interrupt_returns_all_none(monkeypatch): + def _raise_kbi(*_a, **_k): + raise KeyboardInterrupt() + + monkeypatch.setattr(builtins, "input", _raise_kbi) + with contextlib.redirect_stdout(io.StringIO()): + out = auth_mod._prompt_manual_callback_paste( + "http://127.0.0.1:56121/callback" + ) + assert out["code"] is None + + +# --------------------------------------------------------------------------- +# _xai_oauth_loopback_login(manual_paste=True) — full integration +# --------------------------------------------------------------------------- + + +class _StubTokenResponse: + status_code = 200 + + def __init__(self, payload): + self._payload = payload + self.text = "" + + def json(self): + return self._payload + + +def test_xai_loopback_login_manual_paste_skips_http_server(monkeypatch): + """``manual_paste=True`` must NOT bind a loopback HTTP server. + + Direct end-to-end regression for #26923: the whole point is that + the listener is unreachable on browser-only remotes, so the paste + path must avoid it entirely. We assert this by replacing + ``_xai_start_callback_server`` with a function that fails if + invoked, then driving the full happy path with a stubbed prompt + + stubbed token endpoint. + """ + 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", + }, + ) + + def _server_must_not_be_called(*_a, **_k): + raise AssertionError( + "manual_paste=True must skip the loopback HTTP server " + "(regression for #26923)" + ) + + monkeypatch.setattr( + auth_mod, "_xai_start_callback_server", _server_must_not_be_called + ) + + captured_state: dict = {} + + def _fake_prompt(_redirect_uri): + # Hermes generates state internally; we won't know it ahead of + # time, so capture the state Hermes baked into the authorize + # URL via a sneak peek on ``_xai_oauth_build_authorize_url``. + return { + "code": "fake-auth-code", + "state": captured_state["value"], + "error": None, + "error_description": None, + } + + monkeypatch.setattr( + auth_mod, "_prompt_manual_callback_paste", _fake_prompt + ) + + original_build = auth_mod._xai_oauth_build_authorize_url + + def _capture_state(**kwargs): + captured_state["value"] = kwargs["state"] + return original_build(**kwargs) + + monkeypatch.setattr( + auth_mod, "_xai_oauth_build_authorize_url", _capture_state + ) + + 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" + assert "127.0.0.1:56121" in creds["redirect_uri"] + + +def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch): + """A pasted callback with the wrong state must still be rejected. + + The HTTP-server path uses the same state check; manual-paste + must not be a CSRF bypass. + """ + 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": "fake", + "state": "WRONG-STATE", + "error": None, + "error_description": None, + }, + ) + + with contextlib.redirect_stdout(io.StringIO()): + with pytest.raises(auth_mod.AuthError) as exc: + auth_mod._xai_oauth_loopback_login(manual_paste=True) + 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( + auth_mod, "_xai_oauth_discovery", + lambda *_a, **_k: { + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://auth.x.ai/oauth2/token", + }, + ) + captured: dict = {"state": None} + original_build = auth_mod._xai_oauth_build_authorize_url + + def _capture(**kw): + captured["state"] = kw["state"] + return original_build(**kw) + + monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture) + monkeypatch.setattr( + auth_mod, "_prompt_manual_callback_paste", + lambda _ru: { + "code": None, + "state": captured["state"], + "error": None, + "error_description": None, + }, + ) + + with contextlib.redirect_stdout(io.StringIO()): + with pytest.raises(auth_mod.AuthError) as exc: + auth_mod._xai_oauth_loopback_login(manual_paste=True) + assert exc.value.code == "xai_code_missing" + + +# --------------------------------------------------------------------------- +# _print_loopback_ssh_hint — now also mentions --manual-paste +# --------------------------------------------------------------------------- + + +def test_ssh_hint_mentions_manual_paste_for_non_ssh_remotes(monkeypatch): + """Users on Cloud Shell / Codespaces have no real SSH client; the + hint must point them at the new ``--manual-paste`` flag instead + of leaving them stuck on the ``ssh -L`` recipe.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback", + docs_url=auth_mod.XAI_OAUTH_DOCS_URL, + ) + rendered = buf.getvalue() + assert "--manual-paste" in rendered + assert "Cloud Shell" in rendered or "Codespaces" in rendered diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md index 46a818a7934..085ba8a2924 100644 --- a/website/docs/guides/oauth-over-ssh.md +++ b/website/docs/guides/oauth-over-ssh.md @@ -10,7 +10,7 @@ Some Hermes providers — currently **xAI Grok OAuth** and **Spotify** — use a This works perfectly when Hermes and your browser are on the same machine. It breaks the moment they aren't: your laptop's browser tries to reach `127.0.0.1` on **your laptop**, but the listener is bound to `127.0.0.1` on **the remote server**. -The fix is a one-line SSH local-forward. +The fix is a one-line SSH local-forward — **or**, when you don't have a real SSH client (GCP Cloud Shell, GitHub Codespaces, EC2 Instance Connect, Gitpod, browser-based web IDEs), the new `--manual-paste` flag introduced in [#26923](https://github.com/NousResearch/hermes-agent/issues/26923). ## TL;DR @@ -27,6 +27,23 @@ hermes auth add xai-oauth --no-browser Port `56121` is what xAI OAuth uses. For Spotify, replace it with `43827`. Hermes prints the exact port it bound to on the `Waiting for callback on ...` line — copy it from there. +## Browser-only remote (Cloud Shell / Codespaces / EC2 Instance Connect) + +If you don't have a regular SSH client — for example because you're running Hermes inside GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, or another browser-based console — the SSH tunnel above isn't available. Use `--manual-paste` instead: + +```bash +hermes auth add xai-oauth --manual-paste +# → Hermes prints an authorize URL. Open it in a browser on your laptop. +# → Approve in the browser. The redirect to 127.0.0.1:56121/callback fails +# to load — that's expected. +# → Copy the FULL URL from the failed page's address bar. +# → Paste it back into the terminal at the "Callback URL:" prompt. +``` + +The same flag works on `hermes model --manual-paste` for the integrated model picker. A bare `?code=...&state=...` query fragment is accepted too if you don't want to paste the whole URL. + +Hermes uses the **same PKCE verifier, state and nonce** for both paths, so the upstream OAuth flow is byte-identical — `--manual-paste` is purely a transport change for the callback hop and is not a security downgrade. + ## Which Providers Need This | Provider | Loopback port | Tunnel needed? | diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index 7057595c8d3..ed1de2da90d 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -80,6 +80,18 @@ Through a jump box / bastion: add `-J jump-user@jump-host`. See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) for the full step-by-step, including ProxyJump chains, mosh/tmux, and ControlMaster gotchas. +### Browser-only remotes (Cloud Shell, Codespaces, EC2 Instance Connect) + +If you don't have a regular SSH client (e.g. you're running Hermes inside GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, or another browser-based console), the `ssh -L` recipe above isn't available. Use `--manual-paste` instead — Hermes skips the loopback listener and lets you paste the failed callback URL straight from your browser: + +```bash +hermes auth add xai-oauth --manual-paste +# Or via the model picker: +hermes model --manual-paste +``` + +See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md#browser-only-remote-cloud-shell--codespaces--ec2-instance-connect) for the full walkthrough. Regression fix for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923). + ## How the Login Works 1. Hermes opens your browser to `accounts.x.ai`.