diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 78c21252b3c..a839083701e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2895,6 +2895,21 @@ def _is_remote_session() -> bool: return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) +def _ssh_user_at_host() -> str: + """Return best-effort 'user@hostname' for the SSH tunnel hint command. + + Falls back to placeholder tokens when the values cannot be determined so + the hint is always syntactically valid even if not copy-pasteable. + """ + try: + import socket as _socket + hostname = _socket.gethostname() or "" + except OSError: + hostname = "" + user = os.getenv("USER") or os.getenv("LOGNAME") or "" + return f"{user}@{hostname}" + + 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 @@ -2918,19 +2933,22 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) port = parsed.port if host not in {"127.0.0.1", "::1", "localhost"} or not port: return + divider = "-" * 60 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(divider) + print("Remote session detected — SSH tunnel required") + print(divider) + print(f"Hermes is waiting for the OAuth callback on {redirect_uri}") + print("but your browser is on a different machine. Run this command") + print("in a NEW terminal on your local machine BEFORE opening the URL:") print() - print(f" ssh -N -L {port}:127.0.0.1:{port} @") + print(f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_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(divider) print() diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py index fb88a6bf4ce..87dcd526467 100644 --- a/tests/hermes_cli/test_auth_loopback_ssh_hint.py +++ b/tests/hermes_cli/test_auth_loopback_ssh_hint.py @@ -9,6 +9,7 @@ from __future__ import annotations import io import contextlib +import socket import pytest @@ -93,3 +94,56 @@ def test_loopback_ssh_hint_accepts_localhost_hostname(monkeypatch): "http://localhost:56121/callback" )) assert "ssh -N -L 56121:127.0.0.1:56121" in out + + +def test_loopback_ssh_hint_includes_user_at_host(monkeypatch): + """The SSH command should include a detected user@host so the user can + copy-paste it without manually substituting placeholders.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + monkeypatch.setattr(auth_mod, "_ssh_user_at_host", lambda: "alice@myserver.lan") + out = _cap(lambda: auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback" + )) + assert "ssh -N -L 56121:127.0.0.1:56121 alice@myserver.lan" in out + + +def test_loopback_ssh_hint_has_visual_header(monkeypatch): + """The hint should print a divider and header so it stands out in noisy output.""" + 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" + )) + assert "Remote session detected" in out + assert "---" in out # divider is present + + +class TestSshUserAtHost: + def test_resolves_user_and_hostname(self, monkeypatch): + monkeypatch.setenv("USER", "alice") + monkeypatch.delenv("LOGNAME", raising=False) + monkeypatch.setattr(socket, "gethostname", lambda: "myserver") + assert auth_mod._ssh_user_at_host() == "alice@myserver" + + def test_falls_back_to_logname(self, monkeypatch): + monkeypatch.delenv("USER", raising=False) + monkeypatch.setenv("LOGNAME", "bob") + monkeypatch.setattr(socket, "gethostname", lambda: "host1") + assert auth_mod._ssh_user_at_host() == "bob@host1" + + def test_placeholder_when_no_env_vars(self, monkeypatch): + monkeypatch.delenv("USER", raising=False) + monkeypatch.delenv("LOGNAME", raising=False) + monkeypatch.setattr(socket, "gethostname", lambda: "host1") + assert auth_mod._ssh_user_at_host() == "@host1" + + def test_placeholder_when_socket_raises(self, monkeypatch): + monkeypatch.setenv("USER", "charlie") + def _raise(): + raise OSError("no network") + monkeypatch.setattr(socket, "gethostname", _raise) + assert auth_mod._ssh_user_at_host() == "charlie@" + + def test_placeholder_when_empty_hostname(self, monkeypatch): + monkeypatch.setenv("USER", "dave") + monkeypatch.setattr(socket, "gethostname", lambda: "") + assert auth_mod._ssh_user_at_host() == "dave@"