diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 647779f6e..83006e0da 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5430,9 +5430,15 @@ def refresh_nous_oauth_pure( state["refresh_token"] = refreshed.get("refresh_token") or refresh_token_value state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" state["scope"] = refreshed.get("scope") or state.get("scope") + # Heal a poisoned stored value: when the Portal-returned URL is + # rejected by the allowlist (returns None), reset to the production + # default instead of leaving a previously-persisted bad host (e.g. a + # stale staging URL) in place. Without this reset, an auth.json that + # was poisoned before the allowlist existed keeps re-validating to + # None on every refresh and silently re-uses the dead endpoint — + # the "falling back to default" warning never actually takes effect. refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) - if refreshed_url: - state["inference_base_url"] = refreshed_url + state["inference_base_url"] = refreshed_url or DEFAULT_NOUS_INFERENCE_URL state["obtained_at"] = now.isoformat() state["expires_in"] = access_ttl state["expires_at"] = datetime.fromtimestamp( @@ -5705,9 +5711,13 @@ def resolve_nous_runtime_credentials( state["refresh_token"] = refreshed.get("refresh_token") or refresh_token state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" state["scope"] = refreshed.get("scope") or state.get("scope") + # Heal a poisoned stored value (see refresh_nous_oauth_pure): + # reject → reset to production default, don't keep a stale + # staging host that re-validates to None every refresh. + # The local inference_base_url is persisted to state below + # (and used for the client), so healing it here suffices. refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) - if refreshed_url: - inference_base_url = refreshed_url + inference_base_url = refreshed_url or DEFAULT_NOUS_INFERENCE_URL state["obtained_at"] = now.isoformat() state["expires_in"] = access_ttl state["expires_at"] = datetime.fromtimestamp( diff --git a/tests/hermes_cli/test_nous_inference_url_validation.py b/tests/hermes_cli/test_nous_inference_url_validation.py index e4c70786b..193a342cf 100644 --- a/tests/hermes_cli/test_nous_inference_url_validation.py +++ b/tests/hermes_cli/test_nous_inference_url_validation.py @@ -211,3 +211,83 @@ class TestEnvOverrideNotGated: "env override path must not gate through the network " "validator — it would break documented dev/staging use." ) + + +class TestHealsPoisonedStoredValue: + """A stored inference_base_url that is NOT in the allowlist (e.g. a + stale ``stg-inference-api.nousresearch.com`` persisted before the + allowlist existed) must be HEALED back to the production default on + the next refresh — not silently retained. + + Before the fix, the refresh sites only assigned the validated URL + ``if refreshed_url:`` and otherwise left the poisoned value in place, + so the "falling back to default" warning was logged but never + actually took effect — every subsequent call kept hitting the dead + staging endpoint (real incident: opus-4.8 routed to nous, nous pinned + to staging, every request + the aux compression call 401'd). + """ + + def test_refresh_resets_rejected_url_to_default(self, monkeypatch): + import hermes_cli.auth as auth + + poisoned = "https://stg-inference-api.nousresearch.com/v1" + state = { + "access_token": "tok", + "refresh_token": "rtok", + "client_id": "hermes-cli", + "portal_base_url": auth.DEFAULT_NOUS_PORTAL_URL, + "inference_base_url": poisoned, + } + + # Force the refresh branch and return another rejected (staging) URL, + # exercising the validator-returns-None heal path. + monkeypatch.setattr(auth, "_nous_invoke_jwt_status", lambda *a, **k: "needs_refresh") + monkeypatch.setattr( + auth, + "_refresh_access_token", + lambda **k: { + "access_token": "newtok", + "refresh_token": "newrtok", + "expires_in": 3600, + "inference_base_url": poisoned, # Portal still hands back staging + }, + ) + # Skip the JWT usability assertions (orthogonal to URL healing). + monkeypatch.setattr(auth, "_assert_nous_inference_jwt_usable", lambda *a, **k: None) + monkeypatch.setattr(auth, "_select_nous_invoke_jwt", lambda *a, **k: None) + + result = auth.refresh_nous_oauth_from_state(state, force_refresh=True) + + assert result["inference_base_url"] == auth.DEFAULT_NOUS_INFERENCE_URL, ( + "rejected Portal URL must heal to the production default, " + f"got {result['inference_base_url']!r}" + ) + + def test_refresh_keeps_valid_url(self, monkeypatch): + """A legitimate allowlisted URL from the Portal is preserved.""" + import hermes_cli.auth as auth + + good = "https://inference-api.nousresearch.com/v1" + state = { + "access_token": "tok", + "refresh_token": "rtok", + "client_id": "hermes-cli", + "portal_base_url": auth.DEFAULT_NOUS_PORTAL_URL, + "inference_base_url": good, + } + monkeypatch.setattr(auth, "_nous_invoke_jwt_status", lambda *a, **k: "needs_refresh") + monkeypatch.setattr( + auth, + "_refresh_access_token", + lambda **k: { + "access_token": "newtok", + "refresh_token": "newrtok", + "expires_in": 3600, + "inference_base_url": good, + }, + ) + monkeypatch.setattr(auth, "_assert_nous_inference_jwt_usable", lambda *a, **k: None) + monkeypatch.setattr(auth, "_select_nous_invoke_jwt", lambda *a, **k: None) + + result = auth.refresh_nous_oauth_from_state(state, force_refresh=True) + assert result["inference_base_url"] == good