mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
feat(desktop): make xAI Grok a first-class OAuth provider in the launcher
xAI Grok was only reachable via the "I have an API key" form. xAI's OAuth (SuperGrok / Premium+) flow already exists in the backend (`hermes auth add xai-oauth`) but was never surfaced in the desktop onboarding launcher. Add a loopback PKCE flow: the local backend binds the 127.0.0.1 callback listener, the client opens the browser, and the redirect lands back automatically — no code to copy/paste. Reuses the existing xAI OAuth helpers (discovery, callback server, token exchange, persist) rather than duplicating them. - web_server: catalog entry (flow: loopback) + status dispatch + _start_xai_loopback_flow + background worker + route branch - desktop: 'loopback' flow type, awaiting_browser status, xAI Grok card (PROVIDER_DISPLAY / FLOW_SUBTITLES / FlowPanel waiting render) - tests: catalog listing, start authorize-url, worker persist, state mismatch rejection
This commit is contained in:
parent
c47b9d126f
commit
dd5e97bd7f
5 changed files with 434 additions and 6 deletions
|
|
@ -327,6 +327,168 @@ def test_anthropic_pkce_branch_still_works():
|
|||
assert "claude.ai" in body["auth_url"]
|
||||
|
||||
|
||||
def test_xai_oauth_listed_as_loopback_flow():
|
||||
"""xAI Grok OAuth must surface in the catalog as a first-class loopback flow."""
|
||||
resp = client.get("/api/providers/oauth", headers=HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
providers = {p["id"]: p for p in resp.json()["providers"]}
|
||||
assert "xai-oauth" in providers
|
||||
assert providers["xai-oauth"]["flow"] == "loopback"
|
||||
assert "grok" in providers["xai-oauth"]["name"].lower()
|
||||
|
||||
|
||||
def test_xai_loopback_start_returns_authorize_url(monkeypatch):
|
||||
"""Start MUST bind the loopback listener and hand back an xAI authorize URL."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
class _FakeServer:
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
def server_close(self):
|
||||
pass
|
||||
|
||||
class _FakeThread:
|
||||
def join(self, timeout=None):
|
||||
pass
|
||||
|
||||
redirect_uri = (
|
||||
f"http://{auth_mod.XAI_OAUTH_REDIRECT_HOST}:{auth_mod.XAI_OAUTH_REDIRECT_PORT}"
|
||||
f"{auth_mod.XAI_OAUTH_REDIRECT_PATH}"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_oauth_discovery",
|
||||
lambda *a, **k: {
|
||||
"authorization_endpoint": "https://auth.x.ai/oauth2/auth",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_start_callback_server",
|
||||
lambda *a, **k: (_FakeServer(), _FakeThread(), {"code": None, "error": None}, redirect_uri),
|
||||
)
|
||||
# Don't let the background worker run a real callback wait/exchange.
|
||||
monkeypatch.setattr(ws, "_xai_loopback_worker", lambda sid: None)
|
||||
|
||||
resp = client.post("/api/providers/oauth/xai-oauth/start", headers=HEADERS)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
try:
|
||||
assert body["flow"] == "loopback"
|
||||
assert "user_code" not in body # loopback has nothing to paste/show
|
||||
assert body["auth_url"].startswith("https://auth.x.ai/oauth2/auth?")
|
||||
assert "code_challenge" in body["auth_url"]
|
||||
sess = ws._oauth_sessions[body["session_id"]]
|
||||
assert sess["provider"] == "xai-oauth"
|
||||
assert sess["flow"] == "loopback"
|
||||
finally:
|
||||
ws._oauth_sessions.pop(body["session_id"], None)
|
||||
|
||||
|
||||
def test_xai_loopback_worker_persists_tokens_on_success(monkeypatch):
|
||||
"""The worker exchanges the callback code and marks the session approved."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
saved = {}
|
||||
session_id = "xai-loopback-success-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "xai-oauth",
|
||||
"flow": "loopback",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"error_message": None,
|
||||
"server": object(),
|
||||
"thread": object(),
|
||||
"callback_result": {"code": "auth-code", "state": "st"},
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"verifier": "verifier",
|
||||
"challenge": "challenge",
|
||||
"state": "st",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"discovery": {"token_endpoint": "https://auth.x.ai/oauth2/token"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_wait_for_callback",
|
||||
lambda *a, **k: {"code": "auth-code", "state": "st"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_oauth_exchange_code_for_tokens",
|
||||
lambda **k: {
|
||||
"access_token": "xai-access",
|
||||
"refresh_token": "xai-refresh",
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_save_xai_oauth_tokens",
|
||||
lambda tokens, **k: saved.update(tokens),
|
||||
)
|
||||
monkeypatch.setattr(ws, "_add_xai_oauth_pool_entry", lambda *a, **k: None)
|
||||
|
||||
try:
|
||||
ws._xai_loopback_worker(session_id)
|
||||
assert ws._oauth_sessions[session_id]["status"] == "approved"
|
||||
assert saved["access_token"] == "xai-access"
|
||||
assert saved["refresh_token"] == "xai-refresh"
|
||||
finally:
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def test_xai_loopback_worker_fails_on_state_mismatch(monkeypatch):
|
||||
"""A mismatched OAuth state must fail the session, not persist tokens."""
|
||||
from hermes_cli import auth as auth_mod
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
session_id = "xai-loopback-state-test"
|
||||
ws._oauth_sessions[session_id] = {
|
||||
"session_id": session_id,
|
||||
"provider": "xai-oauth",
|
||||
"flow": "loopback",
|
||||
"created_at": time.time(),
|
||||
"status": "pending",
|
||||
"error_message": None,
|
||||
"server": object(),
|
||||
"thread": object(),
|
||||
"callback_result": {},
|
||||
"redirect_uri": "http://127.0.0.1:56121/callback",
|
||||
"verifier": "verifier",
|
||||
"challenge": "challenge",
|
||||
"state": "expected-state",
|
||||
"token_endpoint": "https://auth.x.ai/oauth2/token",
|
||||
"discovery": {},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_mod,
|
||||
"_xai_wait_for_callback",
|
||||
lambda *a, **k: {"code": "auth-code", "state": "ATTACKER-state"},
|
||||
)
|
||||
|
||||
def _boom(**kwargs):
|
||||
raise AssertionError("token exchange must not run on state mismatch")
|
||||
|
||||
monkeypatch.setattr(auth_mod, "_xai_oauth_exchange_code_for_tokens", _boom)
|
||||
|
||||
try:
|
||||
ws._xai_loopback_worker(session_id)
|
||||
sess = ws._oauth_sessions[session_id]
|
||||
assert sess["status"] == "error"
|
||||
assert "state mismatch" in sess["error_message"].lower()
|
||||
finally:
|
||||
ws._oauth_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def test_unknown_pkce_provider_rejected_cleanly():
|
||||
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue