mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
fix(dashboard): MiniMax 'Login' button launched Claude OAuth (#22832)
Fixes #22832. ## Root cause `hermes_cli/web_server.py:start_oauth_login` dispatched OAuth flows by the catalog's `flow` field rather than provider id: if catalog_entry["flow"] == "pkce": return _start_anthropic_pkce() The catalog had two `flow: "pkce"` entries — `anthropic` and `minimax-oauth` — so clicking "Login" on MiniMax in the dashboard's Keys tab unconditionally launched the Anthropic/Claude PKCE flow. ## Fix Three changes in `hermes_cli/web_server.py`: 1. Catalog entry for `minimax-oauth` changed from `flow: "pkce"` to `flow: "device_code"`. From a UX perspective MiniMax is a verification-URI + user-code flow (open URL, enter code, backend polls) — same shape as Nous's device-code flow. The PKCE bit (verifier + challenge from `_minimax_pkce_pair`) is a security extension that doesn't change the operator experience; the existing dashboard modal already renders `device_code` correctly for this UX. 2. New MiniMax branch in `_start_device_code_flow`, mirroring the existing Nous branch but calling MiniMax-specific helpers (`_minimax_request_user_code`, `_minimax_pkce_pair`). Stashes verifier + state in the session for the poller to consume. Handles the overloaded `expired_in` field (could be unix-ms timestamp OR seconds-from-now duration) the same way `_minimax_poll_token` does. 3. New `_minimax_poller` background thread mirroring `_nous_poller`. Calls `_minimax_poll_token` → on success builds the same `auth_state` dict the CLI flow (`_minimax_oauth_login`) builds, and persists via `_minimax_save_auth_state` so the dashboard path leaves the system in the same state as `hermes auth add minimax-oauth`. Plus a dispatcher tightening to prevent regression: the `pkce` branch now requires `provider_id == "anthropic"`, so any future PKCE provider added without a proper start function gets a clean `400 Unsupported flow` rather than silently launching Anthropic OAuth. ## Test New `tests/hermes_cli/test_web_oauth_dispatch.py`: - Regression test asserting MiniMax start does NOT return claude.ai - Sanity test that Anthropic PKCE still works after the dispatcher tightening - Forward-looking test: a hypothetical pkce-flagged provider without an explicit branch is rejected cleanly rather than misrouted ## Limitations - The dashboard MiniMax path defaults to `region="global"`. CN-region operators can still use the CLI flow which supports `--region cn`. Adding a region toggle to the dashboard UI is a follow-up.
This commit is contained in:
parent
ea1d0462cf
commit
05bad7b1e7
2 changed files with 299 additions and 3 deletions
129
tests/hermes_cli/test_web_oauth_dispatch.py
Normal file
129
tests/hermes_cli/test_web_oauth_dispatch.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Regression tests for the OAuth dispatcher in hermes_cli.web_server.
|
||||
|
||||
Bug history (2026-05-09): the `_OAUTH_PROVIDER_CATALOG` had two entries
|
||||
flagged ``flow: "pkce"`` — anthropic and minimax-oauth — and the
|
||||
dispatcher ``start_oauth_login`` hardcoded ``_start_anthropic_pkce()``
|
||||
for any pkce-flagged provider. So clicking "Login" next to MiniMax in
|
||||
the dashboard's Keys tab silently launched the Anthropic/Claude OAuth
|
||||
flow.
|
||||
|
||||
The fix:
|
||||
1. Catalog entry for minimax-oauth changed from ``flow: "pkce"`` to
|
||||
``flow: "device_code"`` (the actual UX is verification URI + user
|
||||
code + background poll, with PKCE as a security extension).
|
||||
2. New MiniMax branch added to ``_start_device_code_flow``.
|
||||
3. Dispatcher tightened: pkce branch now requires
|
||||
``provider_id == "anthropic"``, so any future PKCE provider added
|
||||
without an explicit branch gets a clean ``400 Unsupported flow``
|
||||
instead of silently launching Anthropic OAuth.
|
||||
|
||||
These tests pin the corrected behavior.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli.web_server import _SESSION_TOKEN, app
|
||||
|
||||
client = TestClient(app)
|
||||
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
|
||||
|
||||
|
||||
def test_minimax_login_does_not_launch_anthropic_flow():
|
||||
"""Click 'Login' on MiniMax → MUST NOT return claude.ai auth_url."""
|
||||
fake_user_code_resp = {
|
||||
"user_code": "ABCD-1234",
|
||||
"verification_uri": "https://api.minimax.io/oauth/verify",
|
||||
# `expired_in` < 1e12 so the heuristic treats it as seconds.
|
||||
"expired_in": 600,
|
||||
"interval": 2000,
|
||||
"state": "stub-state",
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.auth._minimax_request_user_code",
|
||||
return_value=fake_user_code_resp,
|
||||
), patch(
|
||||
"hermes_cli.auth._minimax_pkce_pair",
|
||||
return_value=("verifier-stub", "challenge-stub", "stub-state"),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/providers/oauth/minimax-oauth/start",
|
||||
headers=HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
|
||||
# The bug used to return Anthropic's auth_url — make sure the response
|
||||
# references neither the auth_url field nor anything Claude-related.
|
||||
assert "auth_url" not in body
|
||||
assert "claude.ai" not in str(body).lower()
|
||||
|
||||
# And the response IS the device-code shape pointing at MiniMax.
|
||||
assert body["flow"] == "device_code"
|
||||
assert "minimax" in body["verification_url"].lower()
|
||||
assert body["user_code"] == "ABCD-1234"
|
||||
assert body["expires_in"] == 600
|
||||
|
||||
|
||||
def test_anthropic_pkce_branch_still_works():
|
||||
"""Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
|
||||
fake_anthropic_response = {
|
||||
"session_id": "stub-session",
|
||||
"flow": "pkce",
|
||||
"auth_url": "https://claude.ai/oauth/authorize?code=true&...",
|
||||
"expires_in": 600,
|
||||
}
|
||||
with patch(
|
||||
"hermes_cli.web_server._start_anthropic_pkce",
|
||||
return_value=fake_anthropic_response,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/providers/oauth/anthropic/start",
|
||||
headers=HEADERS,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["flow"] == "pkce"
|
||||
assert "claude.ai" in body["auth_url"]
|
||||
|
||||
|
||||
def test_unknown_pkce_provider_rejected_cleanly():
|
||||
"""A future PKCE provider without an explicit branch must NOT silently route to Anthropic.
|
||||
|
||||
Simulates a hypothetical catalog entry with ``flow: "pkce"`` and an
|
||||
id other than "anthropic". The dispatcher should fall through past
|
||||
the pkce branch (now gated on provider_id) and the device_code
|
||||
branch, then hit "Unsupported flow" — proving the bug class is
|
||||
structurally prevented.
|
||||
"""
|
||||
from hermes_cli import web_server as ws
|
||||
|
||||
# Inject a hypothetical catalog entry that's pkce-flagged but isn't
|
||||
# anthropic. This shape mirrors what would happen if a developer
|
||||
# added a new provider entry without remembering to wire up its
|
||||
# start function.
|
||||
fake_entry = {
|
||||
"id": "hypothetical-pkce-provider",
|
||||
"name": "Hypothetical PKCE Provider",
|
||||
"flow": "pkce",
|
||||
"cli_command": "hermes auth add hypothetical-pkce-provider",
|
||||
"docs_url": "https://example.com",
|
||||
"status_fn": None,
|
||||
}
|
||||
original_catalog = ws._OAUTH_PROVIDER_CATALOG
|
||||
try:
|
||||
ws._OAUTH_PROVIDER_CATALOG = original_catalog + (fake_entry,)
|
||||
resp = client.post(
|
||||
"/api/providers/oauth/hypothetical-pkce-provider/start",
|
||||
headers=HEADERS,
|
||||
)
|
||||
finally:
|
||||
ws._OAUTH_PROVIDER_CATALOG = original_catalog
|
||||
|
||||
# Either 400 "Unsupported flow" (the explicit fall-through) or any
|
||||
# 4xx — what we MUST NOT see is a 200 with claude.ai in the body.
|
||||
assert resp.status_code >= 400, resp.text
|
||||
assert "claude.ai" not in resp.text.lower()
|
||||
Loading…
Add table
Add a link
Reference in a new issue