mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
test+docs(oauth): pin manual-paste semantics and document browser-only path (#26923)
Tests (``tests/hermes_cli/test_auth_manual_paste.py``): * 9 parametrised + scalar cases for ``_is_remote_session`` covering the new Cloud Shell / Codespaces / Gitpod / Replit / StackBlitz env vars (plus the existing SSH ones). * 9 cases for ``_parse_pasted_callback`` covering every paste form (full URL, https URL with extra params, bare ``?code=...``, bare ``code=...`` fragment, bare opaque value, error+description, empty, whitespace-only, malformed URL). * 3 cases for ``_prompt_manual_callback_paste`` (happy path, EOF, Ctrl-C). * 3 end-to-end ``_xai_oauth_loopback_login(manual_paste=True)`` cases: the HTTP server MUST NOT be started (asserted via a callable that raises if invoked), wrong state still rejected with ``xai_state_mismatch`` (no CSRF bypass), and empty paste surfaces ``xai_code_missing``. * SSH-hint mention test ensures the ``--manual-paste`` instruction is printed in the remote-session hint. Docs: * ``oauth-over-ssh.md`` — new "Browser-only remote (Cloud Shell / Codespaces / EC2 Instance Connect)" section with the ``--manual-paste`` recipe, plus a TL;DR note for the new flag. * ``xai-grok-oauth.md`` — short subsection pointing at the same recipe and the OAuth-over-SSH guide anchor.
This commit is contained in:
parent
cafbc9a734
commit
817e1d6340
3 changed files with 414 additions and 1 deletions
384
tests/hermes_cli/test_auth_manual_paste.py
Normal file
384
tests/hermes_cli/test_auth_manual_paste.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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? |
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue