fix(auth): point SSH OAuth users at the tunnel they actually need (#26592)

Two loopback-redirect OAuth flows (xAI Grok, Spotify) silently fail when
Hermes runs on a remote host: the auth server redirects to
127.0.0.1:<port> on the user's laptop, not on the remote box. The
--no-browser flag only suppresses webbrowser.open() — it doesn't change
the bind address. Symptom xAI surfaces is 'Could not establish
connection. We couldn't reach your app.', followed by a 'xAI
authorization timed out waiting for the local callback' on the CLI side.

Changes
- hermes_cli/auth.py: new _print_loopback_ssh_hint() helper, called from
  _xai_oauth_loopback_login() and _spotify_login() right after they
  print the redirect URI. Silent off SSH; on SSH prints the exact
  'ssh -N -L <port>:127.0.0.1:<port>' command using the actually-bound
  port (not the hardcoded constant — the listener auto-bumps when the
  preferred port is busy), a provider-specific docs URL, and a link to
  the new shared guide.
- website/docs/guides/oauth-over-ssh.md (new): single source of truth
  for the tunnel pattern — TL;DR command, jump-box / ProxyJump variant,
  mosh+tmux+ControlMaster gotchas, troubleshooting.
- website/docs/guides/xai-grok-oauth.md: fix the two sections that
  claimed --no-browser alone was enough; link to the shared guide.
- website/docs/user-guide/features/spotify.md: expand the existing
  one-liner; link to the shared guide.
- website/sidebars.ts: register the new page.
- tests/hermes_cli/test_auth_loopback_ssh_hint.py: 7 unit tests
  covering SSH-vs-not, loopback-vs-not, malformed URIs, port echo,
  with and without provider docs URL.
This commit is contained in:
Teknium 2026-05-15 14:27:50 -07:00 committed by GitHub
parent 9e67c8e8be
commit 3b9368a0c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 304 additions and 6 deletions

View file

@ -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:<port>/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} <user>@<this-host>")
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)

View file

@ -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

View file

@ -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:<port>/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:<exact-port>/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:<port>/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:<port>/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 <PID>
```
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:<port>/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)

View file

@ -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.

View file

@ -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

View file

@ -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',
],