diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index c6dce709384..6cabb61570d 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -107,6 +107,9 @@ DEFAULT_SPOTIFY_REDIRECT_URI = "http://127.0.0.1:43827/spotify/callback" SPOTIFY_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/spotify" SPOTIFY_DASHBOARD_URL = "https://developer.spotify.com/dashboard" SPOTIFY_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 + +XAI_OAUTH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth" +OAUTH_OVER_SSH_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh" DEFAULT_SPOTIFY_SCOPE = " ".join(( "user-modify-playback-state", "user-read-playback-state", @@ -2528,6 +2531,8 @@ def login_spotify_command(args) -> None: print(f"Full setup guide: {SPOTIFY_DOCS_URL}") print() + _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) + if open_browser and not _is_remote_session(): try: opened = webbrowser.open(authorize_url) @@ -2584,6 +2589,45 @@ def _is_remote_session() -> bool: return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) +def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None: + """Print an SSH tunnel hint when running a loopback-redirect OAuth flow on a + remote host. The auth server (xAI, Spotify, ...) will redirect the user's + browser to ``127.0.0.1:/callback``. If the browser is on a different + machine than the loopback listener (the usual SSH case), the redirect can't + reach the listener without a local port forward. + + The hint is best-effort: silent if we don't think we're remote, or if we + can't parse a host/port out of the redirect URI. + + Pass ``docs_url`` for a provider-specific guide (e.g. the xAI Grok OAuth + page); the generic OAuth-over-SSH guide is always shown after it. + """ + if not _is_remote_session(): + return + try: + parsed = urlparse(redirect_uri) + except Exception: + return + host = parsed.hostname or "" + port = parsed.port + if host not in ("127.0.0.1", "::1", "localhost") or not port: + return + print() + print("Remote session detected. Your browser will redirect to") + print(f" {redirect_uri}") + print("which the loopback listener on THIS machine is waiting on. If your") + print("browser is on a different machine, forward the port first from your") + print("local machine in a separate terminal:") + print() + print(f" ssh -N -L {port}:127.0.0.1:{port} @") + print() + print("Then open the authorize URL above in your local browser.") + if docs_url: + print(f"Provider docs: {docs_url}") + print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}") + print() + + # ============================================================================= # OpenAI Codex auth — tokens stored in ~/.hermes/auth.json (not ~/.codex/) # @@ -5297,6 +5341,8 @@ def _xai_oauth_loopback_login( print() print(f"Waiting for callback on {redirect_uri}") + _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) + if open_browser and not _is_remote_session(): try: opened = webbrowser.open(authorize_url) diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py new file mode 100644 index 00000000000..fb88a6bf4ce --- /dev/null +++ b/tests/hermes_cli/test_auth_loopback_ssh_hint.py @@ -0,0 +1,95 @@ +"""Unit tests for _print_loopback_ssh_hint() in hermes_cli/auth.py. + +The helper exists to warn users that loopback OAuth flows (xAI Grok OAuth, +Spotify) don't work over SSH unless they set up an `ssh -L` port forward +between their laptop's browser and the remote host's loopback listener. +""" + +from __future__ import annotations + +import io +import contextlib + +import pytest + +from hermes_cli import auth as auth_mod + + +def _cap(fn): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + fn() + return buf.getvalue() + + +def test_loopback_ssh_hint_silent_when_not_remote(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: False) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_prints_tunnel_command_on_ssh(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + # Must include the exact ssh -L command with the port from the redirect URI + assert "ssh -N -L 56121:127.0.0.1:56121" in out + # Must include the provider-specific docs URL + assert auth_mod.XAI_OAUTH_DOCS_URL in out + # Must always include the cross-provider SSH guide + assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out + + +def test_loopback_ssh_hint_uses_actual_bound_port(monkeypatch): + """When the preferred port is busy, _xai_start_callback_server falls back to + an OS-assigned port. The hint must echo whichever port actually got bound, + not the hardcoded constant.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:51234/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert "ssh -N -L 51234:127.0.0.1:51234" in out + assert "56121" not in out + + +def test_loopback_ssh_hint_silent_for_non_loopback_uri(monkeypatch): + """Defense in depth: if a future caller passes a non-loopback redirect URI + by mistake, we don't tell the user to forward an external port.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "https://example.com/callback", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_silent_for_malformed_uri(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "not-a-uri", docs_url=auth_mod.XAI_OAUTH_DOCS_URL + )) + assert out == "" + + +def test_loopback_ssh_hint_works_without_provider_docs_url(monkeypatch): + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:43827/spotify/callback" + )) + assert "ssh -N -L 43827:127.0.0.1:43827" in out + # Generic SSH guide is always present even without a provider-specific URL + assert auth_mod.OAUTH_OVER_SSH_DOCS_URL in out + # Should not falsely show "Provider docs:" when no docs_url was passed + assert "Provider docs:" not in out + + +def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch): + """The constant is 127.0.0.1, but parsing tolerates `localhost` too in case + a future caller normalizes the URI differently.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://localhost:56121/callback" + )) + assert "ssh -N -L 56121:127.0.0.1:56121" in out diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md new file mode 100644 index 00000000000..46a818a7934 --- /dev/null +++ b/website/docs/guides/oauth-over-ssh.md @@ -0,0 +1,137 @@ +--- +sidebar_position: 17 +title: "OAuth over SSH / Remote Hosts" +description: "How to complete browser-based OAuth (xAI, Spotify) when Hermes runs on a remote machine, container, or behind a jump box" +--- + +# OAuth over SSH / Remote Hosts + +Some Hermes providers — currently **xAI Grok OAuth** and **Spotify** — use a *loopback redirect* OAuth flow. The auth server (xAI, Spotify) redirects your browser to `http://127.0.0.1:/callback` so a tiny HTTP listener started by the `hermes auth ...` command can grab the authorization code. + +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. + +## TL;DR + +```bash +# On your local machine (laptop), in a separate terminal: +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# In your existing SSH session on the remote machine: +hermes auth add xai-oauth --no-browser +# → Hermes prints an authorize URL. Open it in a browser on your laptop. +# → Your browser redirects to 127.0.0.1:56121/callback, the tunnel forwards +# the request to the remote listener, login completes. +``` + +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. + +## Which Providers Need This + +| Provider | Loopback port | Tunnel needed? | +|----------|---------------|----------------| +| `xai-oauth` (Grok SuperGrok) | `56121` | Yes, when Hermes is remote | +| Spotify | `43827` | Yes, when Hermes is remote | +| `anthropic` (Claude Pro/Max) | n/a | No — paste-the-code flow | +| `openai-codex` (ChatGPT Plus/Pro) | n/a | No — device code flow | +| `minimax`, `nous-portal` | n/a | No — device code flow | + +If your provider isn't in the table, you don't need a tunnel. + +## Why the listener can't just bind 0.0.0.0 + +xAI and Spotify both validate the `redirect_uri` parameter against an allowlist. Both require the loopback form (`http://127.0.0.1:/callback`). Binding the listener to `0.0.0.0` or a different port would cause the auth server to reject the request as a redirect_uri mismatch. The SSH tunnel keeps the loopback URI intact end-to-end. + +## Step-by-step: single SSH hop + +### 1. Start the tunnel from your local machine + +```bash +# xAI Grok OAuth (port 56121) +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# Or for Spotify (port 43827) +ssh -N -L 43827:127.0.0.1:43827 user@remote-host +``` + +`-N` means "don't open a remote shell, just hold the tunnel open." Keep this terminal running for the duration of the login. + +### 2. In a separate SSH session, run the auth command + +```bash +ssh user@remote-host +hermes auth add xai-oauth --no-browser +# or for Spotify: +# hermes auth add spotify --no-browser +``` + +Hermes detects the SSH session, skips the browser auto-open, and prints an authorize URL plus a `Waiting for callback on http://127.0.0.1:/callback` line. + +### 3. Open the URL in your local browser + +Copy the authorize URL from the remote terminal and paste it into the browser on your laptop. Approve the consent screen. The auth server redirects to `http://127.0.0.1:/callback`. Your browser hits the tunnel, the request is forwarded to the remote listener, and Hermes prints `Login successful!`. + +You can tear down the tunnel (Ctrl+C in the first terminal) once you see the success line. + +## Step-by-step: through a jump box + +If you reach Hermes through a bastion / jump host, use SSH's built-in `-J` (ProxyJump): + +```bash +ssh -N -L 56121:127.0.0.1:56121 -J jump-user@jump-host user@final-host +``` + +This chains a SSH connection through the jump host without putting the loopback port on the jump box itself. The local `127.0.0.1:56121` on your laptop tunnels straight through to `127.0.0.1:56121` on the final remote host. + +For older OpenSSH that doesn't support `-J`, the long form is: + +```bash +ssh -N \ + -o "ProxyCommand=ssh -W %h:%p jump-user@jump-host" \ + -L 56121:127.0.0.1:56121 \ + user@final-host +``` + +## Mosh, tmux, ssh ControlMaster + +The tunnel is a property of the underlying SSH connection. If you're running Hermes inside `tmux` over a mosh session, the mosh roaming doesn't carry the `-L` forwarding. Open a *separate* plain SSH session **only** for the `-L` tunnel — that's the connection that has to stay alive during the auth flow. Your interactive mosh/tmux session can keep running Hermes normally. + +If you use `ssh -o ControlMaster=auto`, port forwards on a multiplexed connection share the master's lifetime. Restart the master if the tunnel doesn't come up: + +```bash +ssh -O exit user@remote-host +ssh -N -L 56121:127.0.0.1:56121 user@remote-host +``` + +## Troubleshooting + +### `bind [127.0.0.1]:56121: Address already in use` + +Something on your laptop is already using that port. Either the previous tunnel didn't shut down cleanly, or a local Hermes is also listening on it. Find and kill the offender: + +```bash +# macOS / Linux +lsof -iTCP:56121 -sTCP:LISTEN +kill +``` + +Then retry the `ssh -L` command. + +### "Could not establish connection. We couldn't reach your app." (xAI) + +xAI's authorize page shows this when its redirect to `127.0.0.1:/callback` doesn't reach a listener. Either the tunnel isn't running, the port is wrong, or you're using the port Hermes printed in a previous run (the port can be auto-bumped if the preferred one is busy — always read the latest `Waiting for callback on ...` line). + +### `xAI authorization timed out waiting for the local callback` + +Same root cause as above — the redirect never made it back. Check the tunnel is still alive (`ssh -N` doesn't show output, so look at the terminal you started it from), restart it if needed, and re-run `hermes auth add xai-oauth --no-browser`. + +### Tokens land in the wrong `~/.hermes` + +The tokens are written under the Linux user that ran `hermes auth add ...`. If your gateway / systemd service runs as a different user (e.g. `root` or a dedicated `hermes` user), authenticate as **that** user so the tokens land in their `~/.hermes/auth.json`. `sudo -u hermes -i` or equivalent. + +## See Also + +- [xAI Grok OAuth](./xai-grok-oauth.md) +- [Spotify (`Running over SSH`)](../user-guide/features/spotify.md#running-over-ssh--in-a-headless-environment) +- [SSH `-J` / ProxyJump (man page)](https://man.openbsd.org/ssh#J) diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index 5afccb6d881..95167a2430c 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -59,14 +59,23 @@ hermes auth add xai-oauth ### Remote / headless sessions -On servers, containers, or SSH sessions where no browser is available, Hermes detects the remote environment and prints the authorization URL instead of opening a browser. Open the URL on any device with a browser, complete the consent flow, and Hermes finishes the loopback exchange when the redirect comes back. +On servers, containers, or SSH sessions where no browser is available, Hermes detects the remote environment and prints the authorization URL instead of opening a browser. -If you need to force this behaviour explicitly: +**Important:** the loopback listener still runs on the remote machine at `127.0.0.1:56121`. The xAI redirect needs to reach *that* listener, so opening the URL on your laptop will fail (`Could not establish connection. We couldn't reach your app.`) unless you forward the port: ```bash +# In a separate terminal on your local machine: +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# Then in your SSH session on the remote machine: hermes auth add xai-oauth --no-browser +# Open the printed authorize URL in your local browser. ``` +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. + ## How the Login Works 1. Hermes opens your browser to `accounts.x.ai`. @@ -182,14 +191,18 @@ Hermes detected that the `state` value returned by the authorization server does ### Logging in from a remote server -On SSH or container sessions Hermes prints the authorization URL instead of opening a browser. Open the URL on any device with a browser and complete the consent there — the loopback callback comes back to your remote host. - -You can also force this behaviour: +On SSH or container sessions Hermes prints the authorization URL instead of opening a browser. The loopback callback listener still binds `127.0.0.1:56121` on the remote host — your laptop's browser can't reach it without an SSH local-forward: ```bash +# Local machine, separate terminal: +ssh -N -L 56121:127.0.0.1:56121 user@remote-host + +# Remote machine: hermes auth add xai-oauth --no-browser ``` +Full walkthrough (jump boxes, mosh/tmux, port conflicts): [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md). + ### "No xAI credentials found" error at runtime The auth store has no `xai-oauth` entry and no `XAI_API_KEY` is set. You haven't logged in yet, or the credential file was deleted. diff --git a/website/docs/user-guide/features/spotify.md b/website/docs/user-guide/features/spotify.md index bf9d652b318..5e57688e48f 100644 --- a/website/docs/user-guide/features/spotify.md +++ b/website/docs/user-guide/features/spotify.md @@ -68,7 +68,13 @@ Agree to the terms and click **Save**. On the next page click **Settings** → c ### Running over SSH / in a headless environment -If `SSH_CLIENT` or `SSH_TTY` is set, Hermes skips the automatic browser open during both the wizard and the OAuth step. Copy the dashboard URL and the authorization URL Hermes prints, open them in a browser on your local machine, and proceed normally — the local HTTP listener still runs on the remote host on port 43827. If you need to reach it through an SSH tunnel, forward that port: `ssh -L 43827:127.0.0.1:43827 remote`. +If `SSH_CLIENT` or `SSH_TTY` is set, Hermes skips the automatic browser open during both the wizard and the OAuth step. Copy the dashboard URL and the authorization URL Hermes prints, open them in a browser on your local machine, and proceed normally — the local HTTP listener still runs on the remote host on port `43827`. Your laptop's browser can't reach the remote loopback without an SSH local-forward: + +```bash +ssh -N -L 43827:127.0.0.1:43827 user@remote-host +``` + +For jump-box / bastion setups and other gotchas (mosh, tmux, port conflicts), see [OAuth over SSH / Remote Hosts](../../guides/oauth-over-ssh.md). ## Verify diff --git a/website/sidebars.ts b/website/sidebars.ts index a0fb24b8c50..f0a0658c3bf 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -192,6 +192,7 @@ const sidebars: SidebarsConfig = { 'guides/aws-bedrock', 'guides/azure-foundry', 'guides/xai-grok-oauth', + 'guides/oauth-over-ssh', 'guides/microsoft-graph-app-registration', 'guides/operate-teams-meeting-pipeline', ],