NousResearch/hermes-agent

MiniMax OAuth URL staleness fixes and hardening

Cluster 19337 · hermes cli. Data as of

2 open items. Fix merged in #24165 on 12 May 2026. Check it covers your case.

The short answer

1open issue
1open PR
7items grouped
Known?
Yes. 7 related items grouped here, 0 issues already closed.
Fix event
A fix was merged to main in PR #24165. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
1 issue and 1 PR.

These issues and PRs address a stale or incorrect verification_uri returned by MiniMax OAuth, pointing to a deleted or wrong page, along with related fixes for URL rewriting, timestamp normalization, and setup error surfacing.

Open issues1

  • Issue#19337MiniMax OAuth returns stale verification_uri pointing to deleted /oauth-authorize page on www.minimax.io

Open PRs1

  • PR#19461fix(auth): rewrite stale MiniMax OAuth approval URL

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #24165 fix(minimax): harden OAuth dashboard and runtime

    merged to main

    files: hermes_cli/auth.py, hermes_cli/auth_commands.py, hermes_cli/runtime_provider.py, hermes_cli/web_server.py, tests/hermes_cli/test_auth_commands.py, tests/hermes_cli/test_runtime_provider_resolution.py, tests/hermes_cli/test_web_oauth_dispatch.py, tests/test_minimax_oauth.py

    Show the diff
    • hermes_cli/auth.py
      @@ -4046,6 +4046,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
               return get_qwen_auth_status()
           if target == "google-gemini-cli":
               return get_gemini_oauth_auth_status()
      +    if target == "minimax-oauth":
      +        return get_minimax_oauth_auth_status()
           if target == "copilot-acp":
               return get_external_process_provider_status(target)
           # API-key providers
      @@ -4757,6 +4759,20 @@ def _minimax_request_user_code(
           return payload
       
       
      +def _minimax_expired_in_looks_like_unix_ms(expired_in: int, *, now_ms: int) -> bool:
      +    """True if ``expired_in`` is plausibly a unix-ms absolute time (vs TTL seconds)."""
      +    return int(expired_in) > (now_ms // 2)
      +
      +
      +def _minimax_resolve_token_expiry_unix(expired_in: int, *, now: datetime) -> float:
      +    """Return access-token expiry as unix seconds (MiniMax uses ms epoch or TTL seconds)."""
      +    raw = int(expired_in)
      +    now_ms = int(now.timestamp() * 1000)
      +    if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
      +        return raw / 1000.0
      +    return now.timestamp() + max(1, raw)
      +
      +
       def _minimax_poll_token(
           client: httpx.Client, *, portal_base_url: str, client_id: str,
           user_code: str, code_verifier: str, expired_in: int, interval_ms: Optional[int],
      @@ -4765,12 +4781,11 @@ def _minimax_poll_token(
           # Defensive parsing: if it's small enough to be a duration, treat as seconds.
           import time as _time
           now_ms = int(_time.time() * 1000)
      -    if expired_in > now_ms // 2:
      -        # Looks like a unix-ms timestamp.
      -        deadline = expired_in / 1000.0
      +    raw = int(expired_in)
      +    if _minimax_expired_in_looks_like_unix_ms(raw, now_ms=now_ms):
      +        deadline = raw / 1000.0
           else:
      -        # Treat as duration in seconds from now.
      -        deadline = _time.time() + max(1, expired_in)
      +        deadline = _time.time() + max(1, raw)
           interval = max(2.0, (interval_ms or 2000) / 1000.0)
       
           while _time.time() < deadline:
      @@ -4884,8 +4899,10 @@ def _minimax_oauth_login(
               )
       
           now = datetime.now(timezone.utc)
      -    expires_in_s = int(token_data["expired_in"])
      -    expires_at = now.timestamp() + expires_in_s
      +    expires_at_unix = _minimax_resolve_token_expiry_unix(
      +        int(token_data["expired_in"]), now=now,
      +    )
      +    expires_in_s = max(0, int(expires_at_unix - now.timestamp()))
       
           auth_state = {
               "provider": "minimax-oauth",
      @@ -4899,7 +4916,7 @@ def _minimax_oauth_login(
               "refresh_token": [redacted:secret-assignment]"refresh_token"],
               "resource_url": token_data.get("resource_url"),
               "obtained_at": now.isoformat(),
      -        "expires_at": datetime.fromtimestamp(expires_at, tz=timezone.utc).isoformat(),
      +        "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
               "expires_in": expires_in_s,
           }
       
      @@ -4960,14 +4977,16 @@ def _refresh_minimax_oauth_state(
                   relogin_required=True,
               )
           now_dt = datetime.now(timezone.utc)
      -    expires_in_s = int(payload["expired_in"])
      +    expires_at_unix = _minimax_resolve_token_expiry_unix(
      +        int(payload["expired_in"]), now=now_dt,
      +    )
      +    expires_in_s = max(0, int(expires_at_unix - now_dt.timestamp()))
           new_state = dict(state)
           new_state.update({
               "access_token": [redacted:secret-assignment]"access_token"],
               "refresh_token": payload.get("refresh_token", state["refresh_token"]),
               "obtained_at": now_dt.isoformat(),
      -        "expires_at": datetime.fromtimestamp(now_dt.timestamp() + expires_in_s,
      -                                             tz=timezone.utc).isoformat(),
      +        "expires_at": datetime.fromtimestamp(expires_at_unix, tz=timezone.utc).isoformat(),
               "expires_in": expires_in_s,
           })
           _minimax_save_auth_state(new_state)
    • hermes_cli/auth_commands.py
      @@ -375,10 +375,12 @@ def auth_add_command(args) -> None:
               return
       
           if provider == "minimax-oauth":
      -        from hermes_cli.auth import resolve_minimax_oauth_runtime_credentials
      -        creds = resolve_minimax_oauth_runtime_credentials()
      +        creds = auth_mod._minimax_oauth_login(
      +            open_browser=not getattr(args, "no_browser", False),
      +            timeout_seconds=getattr(args, "timeout", None) or 15.0,
      +        )
               label = (getattr(args, "label", None) or "").strip() or label_from_token(
      -            creds["api_key"],
      +            creds["access_token"],
                   _oauth_default_label(provider, len(pool.entries()) + 1),
               )
               entry = PooledCredential(
      @@ -388,8 +390,9 @@ def auth_add_command(args) -> None:
                   auth_type=AUTH_TYPE_OAUTH,
                   priority=0,
                   source=f"{SOURCE_MANUAL}:minimax_oauth",
      -            access_token=[redacted:secret-assignment]"api_key"],
      -            base_url=creds.get("base_url"),
      +            access_token=[redacted:secret-assignment]"access_token"],
      +            refresh_token=creds.get("refresh_token"),
      +            base_url=creds.get("inference_base_url"),
               )
               pool.add_entry(entry)
               print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
    • hermes_cli/runtime_provider.py
      @@ -205,6 +205,14 @@ def _resolve_runtime_from_pool_entry(
           elif provider == "google-gemini-cli":
               api_mode = "chat_completions"
               base_url = base_url or "cloudcode-pa://google"
      +    elif provider == "minimax-oauth":
      +        # MiniMax OAuth tokens are valid only against the Anthropic Messages
      +        # compatible endpoint. Do not honor stale model.api_mode values from a
      +        # prior OpenAI-compatible provider, or the client will hit
      +        # /chat/completions under /anthropic and receive a bare nginx 404.
      +        api_mode = "anthropic_messages"
      +        pconfig = PROVIDER_REGISTRY.get(provider)
      +        base_url = base_url or (pconfig.inference_base_url if pconfig else "")
           elif provider == "anthropic":
               api_mode = "anthropic_messages"
               cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
    • hermes_cli/web_server.py
      @@ -2053,6 +2053,7 @@ def _minimax_poller(session_id: str) -> None:
           """
           from hermes_cli.auth import (
               _minimax_poll_token,
      +        _minimax_resolve_token_expiry_unix,
               _minimax_save_auth_state,
               MINIMAX_OAUTH_GLOBAL_INFERENCE,
               MINIMAX_OAUTH_SCOPE,
      @@ -2090,8 +2091,10 @@ def _minimax_poller(session_id: str) -> None:
               # dashboard path; cn-region operators can still use the CLI
               # flow which supports `--region cn`.
               now = datetime.now(timezone.utc)
      -        expires_in_s = int(token_data["expired_in"])
      -        expires_at_ts = now.timestamp() + expires_in_s
      +        expires_at_ts = _minimax_resolve_token_expiry_unix(
      +            int(token_data["expired_in"]), now=now,
      +        )
      +        expires_in_s = max(0, int(expires_at_ts - now.timestamp()))
               auth_state = {
                   "provider": "minimax-oauth",
                   "region": sess.get("region", "global"),
    • tests/hermes_cli/test_auth_commands.py
      @@ -170,6 +170,50 @@ class _Args:
           assert singleton["inference_base_url"] == "https://inference.example.com/v1"
       
       
      +def test_auth_add_minimax_oauth_starts_login_and_persists_pool_entry(tmp_path, monkeypatch):
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
      +    _write_auth_store(tmp_path, {"version": 1, "providers": {}})
      +    token = _jwt_with_email("[redacted:email]")
      +    monkeypatch.setattr(
      +        "hermes_cli.auth._minimax_oauth_login",
      +        lambda **kwargs: {
      +            "provider": "minimax-oauth",
      +            "region": "global",
      +            "portal_base_url": "https://api.minimax.io",
      +            "inference_base_url": "https://api.minimax.io/anthropic",
      +            "client_id": "client-id",
      +            "scope": "group_id profile model.completion",
      +            "token_type": "[redacted:secret-assignment]",
      +            "access_token": [redacted:secret-assignment]
      +            "refresh_token": "[redacted:secret-assignment]",
      +            "resource_url": None,
      +            "obtained_at": "2026-05-11T10:00:00+00:00",
      +            "expires_at": "2026-05-14T10:00:00+00:00",
      +            "expires_in": 259200,
      +        },
      +    )
      +
      +    from hermes_cli.auth_commands import auth_add_command
      +
      +    class _Args:
      +        provider = "minimax-oauth"
      +        auth_type = "oauth"
      +        api_key = None
      +        label = None
      +        no_browser = True
      +        timeout = None
      +
      +    auth_add_command(_Args())
      +
      +    payload = json.loads((tmp_path / "hermes" / "auth.json").read_text())
      +    entries = payload["credential_pool"]["minimax-oauth"]
      +    entry = next(item for item in entries if item["source"] == "manual:minimax_oauth")
      +    assert entry["label"] == "[redacted:email]"
      +    assert entry["access_token"] == token
      +    assert entry["refresh_token"] == "refresh-token"
      +    assert entry["base_url"] == "https://api.minimax.io/anthropic"
      +
      +
       def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch):
           """`hermes auth add nous --type oauth --label <name>` must preserve the
           custom label end-to-end — it was silently dropped in the first cut of the
    • tests/hermes_cli/test_runtime_provider_resolution.py
      @@ -2285,3 +2285,39 @@ def test_minimax_oauth_runtime_uses_inference_base_url(monkeypatch):
           resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
       
           assert MINIMAX_OAUTH_CN_INFERENCE.rstrip("/") in resolved["base_url"]
      +
      +
      +def test_minimax_oauth_pool_forces_anthropic_messages_despite_stale_config(monkeypatch):
      +    """A pooled MiniMax OAuth token must not inherit stale chat_completions config."""
      +
      +    class _Entry:
      +        access_token = "[redacted:secret-assignment]"
      +        source = "manual:minimax_oauth"
      +        base_url = "https://api.minimax.io/anthropic"
      +
      +    class _Pool:
      +        def has_credentials(self):
      +            return True
      +
      +        def select(self):
      +            return _Entry()
      +
      +    monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "minimax-oauth")
      +    monkeypatch.setattr(
      +        rp,
      +        "_get_model_config",
      +        lambda: {
      +            "provider": "minimax-oauth",
      +            "default": "MiniMax-M2.7",
      +            "api_mode": "chat_completions",
      +        },
      +    )
      +    monkeypatch.setattr(rp, "load_pool", lambda provider: _Pool())
      +    monkeypatch.setattr(rp, "_resolve_named_custom_runtime", lambda **k: None)
      +    monkeypatch.setattr(rp, "_resolve_explicit_runtime", lambda **k: None)
      +
      +    resolved = rp.resolve_runtime_provider(requested="minimax-oauth")
      +
      +    assert resolved["provider"] == "minimax-oauth"
      +    assert resolved["api_mode"] == "anthropic_messages"
      +    assert resolved["base_url"] == "https://api.minimax.io/anthropic"
    • tests/hermes_cli/test_web_oauth_dispatch.py
      @@ -19,6 +19,8 @@
       
       These tests pin the corrected behavior.
       """
      +import time
      +from datetime import datetime, timezone
       from unittest.mock import patch
       
       import pytest
      @@ -67,6 +69,53 @@ def test_minimax_login_does_not_launch_anthropic_flow():
           assert body["expires_in"] == 600
       
       
      +def test_minimax_dashboard_poller_accepts_absolute_ms_expired_in():
      +    """Dashboard MiniMax completion must accept unix-ms token expiry values."""
      +    from hermes_cli import web_server as ws
      +
      +    now = datetime.now(timezone.utc)
      +    abs_ms = int((now.timestamp() + 1800) * 1000)
      +    session_id = "minimax-absolute-ms-test"
      +    ws._oauth_sessions[session_id] = {
      +        "session_id": session_id,
      +        "provider": "minimax-oauth",
      +        "flow": "device_code",
      +        "created_at": time.time(),
      +        "status": "pending",
      +        "error_message": None,
      +        "portal_base_url": "https://api.minimax.io",
      +        "client_id": "client-id",
      +        "user_code": "ABCD-1234",
      +        "code_verifier": "verifier",
      +        "interval_ms": 2000,
      +        "expired_in_raw": abs_ms,
      +        "region": "global",
      +    }
      +    captured_state = {}
      +
      +    try:
      +        with patch(
      +            "hermes_cli.auth._minimax_poll_token",
      +            return_value={
      +                "status": "success",
      +                "access_token": "[redacted:secret-assignment]",
      +                "refresh_token": "[redacted:secret-assignment]",
      +                "expired_in": abs_ms,
      +                "token_type": "[redacted:secret-assignment]",
      +            },
      +        ), patch(
      +            "hermes_cli.auth._minimax_save_auth_state",
      +            side_effect=lambda state: captured_state.update(state),
      +        ):
      +            ws._minimax_poller(session_id)
      +    finally:
      +        ws._oauth_sessions.pop(session_id, None)
      +
      +    assert captured_state["access_token"] == "access"
      +    assert 1790 <= captured_state["expires_in"] <= 1810
      +    assert datetime.fromisoformat(captured_state["expires_at"]).year < 9999
      +
      +
       def test_anthropic_pkce_branch_still_works():
           """Sanity: the dispatcher tightening doesn't break the legitimate Anthropic PKCE path."""
           fake_anthropic_response = {
    • tests/test_minimax_oauth.py
      @@ -32,9 +32,11 @@
           _minimax_pkce_pair,
           _minimax_request_user_code,
           _minimax_poll_token,
      +    _minimax_resolve_token_expiry_unix,
           _refresh_minimax_oauth_state,
           resolve_minimax_oauth_runtime_credentials,
           get_minimax_oauth_auth_status,
      +    get_auth_status,
           get_provider_auth_state,
       )
       
      @@ -67,6 +69,23 @@ def _past_iso(seconds_ago: int = 3600) -> str:
           return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
       
       
      +# ---------------------------------------------------------------------------
      +# 0. test_resolve_token_expiry_unix_ttl_vs_absolute_ms
      +# ---------------------------------------------------------------------------
      +
      +def test_resolve_token_expiry_unix_ttl_seconds():
      +    now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
      +    got = _minimax_resolve_token_expiry_unix(3600, now=now)
      +    assert abs(got - (now.timestamp() + 3600)) < 0.01
      +
      +
      +def test_resolve_token_expiry_unix_absolute_ms():
      +    now = datetime(2025, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
      +    abs_ms = int((now.timestamp() + 7200) * 1000)
      +    got = _minimax_resolve_token_expiry_unix(abs_ms, now=now)
      +    assert abs(got - (now.timestamp() + 7200)) < 0.01
      +
      +
       # ---------------------------------------------------------------------------
       # 1. test_pkce_pair_produces_valid_s256
       # ---------------------------------------------------------------------------
      @@ -362,6 +381,46 @@ def test_refresh_updates_access_token():
           assert result["expires_in"] == 7200
       
       
      +def test_refresh_updates_access_token_absolute_ms_expired_in():
      +    """Refresh payload may use unix-ms absolute ``expired_in`` (same as device-code)."""
      +    now0 = datetime.now(timezone.utc)
      +    abs_ms = int((now0.timestamp() + 1800) * 1000)
      +
      +    state = {
      +        "access_token": "[redacted:secret-assignment]",
      +        "refresh_token": "[redacted:secret-assignment]",
      +        "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE,
      +        "client_id": MINIMAX_OAUTH_CLIENT_ID,
      +        "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE,
      +        "expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1),
      +    }
      +
      +    new_token_body = {
      +        "status": "success",
      +        "access_token": "[redacted:secret-assignment]",
      +        "refresh_token": "[redacted:secret-assignment]",
      +        "expired_in": abs_ms,
      +    }
      +
      +    mock_resp = _make_httpx_response(200, new_token_body)
      +
      +    with patch("httpx.Client") as mock_client_class:
      +        mock_client_instance = MagicMock()
      +        mock_client_instance.__enter__ = MagicMock(return_value=mock_client_instance)
      +        mock_client_instance.__exit__ = MagicMock(return_value=False)
      +        mock_client_instance.post.return_value = mock_resp
      +        mock_client_class.return_value = mock_client_instance
      +
      +        with patch("hermes_cli.auth._minimax_save_auth_state"):
      +            result = _refresh_minimax_oauth_state(state)
      +
      +    assert result["access_token"] == "new-access"
      +    assert 1790 <= result["expires_in"] <= 1810
      +    exp = datetime.fromisoformat(result["expires_at"].replace("Z", "+00:00"))
      +    skew = exp.timestamp() - datetime.now(timezone.utc).timestamp()
      +    assert 1790 <= skew <= 1810
      +
      +
       # ---------------------------------------------------------------------------
       # 10. test_refresh_reuse_triggers_relogin_required
       # ---------------------------------------------------------------------------
      @@ -464,3 +523,18 @@ def test_get_minimax_oauth_auth_status_logged_in():
       
           assert status["logged_in"] is True
           assert status["region"] == "global"
      +
      +
      +def test_generic_auth_status_dispatches_minimax_oauth():
      +    state = {
      +        "access_token": "tok",
      +        "expires_at": _future_iso(3600),
      +        "region": "global",
      +    }
      +
      +    with patch("hermes_cli.auth.get_provider_auth_state", return_value=state):
      +        status = get_auth_status("minimax-oauth")
      +
      +    assert status["logged_in"] is True
      +    assert status["provider"] == "minimax-oauth"
      +    assert status["region"] == "global"

4 PRs closed without merging

From the close comments: superseded by another PR 3 · other 1

  • #19466 fix(cli): rewrite stale MiniMax OAuth verification_uri and surface setup failures · superseded by another PR → #19461
  • #19550 fix(auth): rewrite stale MiniMax OAuth verification_uri host · superseded by another PR → #19466, #19336
  • #20374 fix(minimax-oauth): correct authorize URL domain and handle ms timestamps · superseded by another PR → #24165
  • #124236 fix(minimax-oauth): rewrite stale verification_uri host (#19337) · other → 89a9ef3
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#24165
Closed issues
0
Attempts
4 PRs closed without merging
Fix link checked
12 May 2026

For your agent

The same facts, typed.