mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
feat(auth): make xAI Grok OAuth device-code-only, drop loopback login
Replace the loopback/PKCE-callback server and manual-paste fallback with the RFC 8628 device-code flow as the only xAI Grok OAuth login path. The flow works in headless/SSH/container sessions with no 127.0.0.1 listener, shrinking the local attack surface. - Poll the token endpoint with server-provided interval, honoring slow_down and expires_in; store tokens with auth_mode oauth_device_code. - Adaptive proactive refresh skew for short-lived device-code JWTs; rotated tokens sync back to auth.json, the global root store, and the credential pool (no refresh-token replay). - Clear source suppression on successful re-login (CLI + dashboard) and drop the duplicate dashboard pool entry so exactly one seeded device_code entry exists. - Use the shared device_code source name for consistency with the nous/codex device-code providers. - Desktop: remove the loopback OAuth flow states and dead type variants; pkce providers' sign-in URL selection is unchanged. - Docs (EN + zh-Hans) rewritten for device-code login; drop the deleted --manual-paste flag from documented commands.
This commit is contained in:
parent
472d75193f
commit
5ef0b8acb0
38 changed files with 733 additions and 2904 deletions
|
|
@ -1,148 +0,0 @@
|
|||
"""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 socket
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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() == "<user>@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@<this-host>"
|
||||
|
||||
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@<this-host>"
|
||||
Loading…
Add table
Add a link
Reference in a new issue