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)