diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 5daec12be75..b97cbf32d30 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5579,15 +5579,24 @@ def resolve_nous_runtime_credentials( portal_url = env_portal_override.rstrip("/") else: parsed_portal_url = urlparse(portal_url) + portal_host = parsed_portal_url.hostname + loopback_http = ( + parsed_portal_url.scheme == "http" + and portal_host in {"localhost", "127.0.0.1"} + ) + trusted_scheme = ( + parsed_portal_url.scheme == "https" or loopback_http + ) if ( - parsed_portal_url.hostname - and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS + not portal_host + or portal_host not in _NOUS_PORTAL_ALLOWED_HOSTS + or not trusted_scheme ): logger.warning( "auth: ignoring invalid portal_base_url %r " - "(host %r not in allowlist), using default", + "(host %r or scheme not allowed), using default", portal_url, - parsed_portal_url.hostname, + portal_host, ) portal_url = DEFAULT_NOUS_PORTAL_URL @@ -5769,6 +5778,11 @@ def resolve_nous_runtime_credentials( inference_base_url = ( _nous_inference_env_override() or stored_inference_base_url ) + # Persist network-derived routing with rotated tokens so + # a later JWT validation failure cannot leave the profile + # and shared stores on stale metadata. Never persist the + # operator-only env overlay. + state["inference_base_url"] = stored_inference_base_url state["obtained_at"] = now.isoformat() state["expires_in"] = access_ttl state["expires_at"] = datetime.fromtimestamp( diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index ab665d3e3f8..cd2ee635f2f 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -2067,6 +2067,58 @@ def test_runtime_shared_recovery_recomputes_routing_before_force_refresh( assert profile_state["refresh_token"] == "rotated-shared-refresh" +def test_runtime_unusable_local_token_recomputes_shared_routing( + tmp_path, monkeypatch, shared_store_env, +): + """The unusable-token merge branch must also adopt shared routing.""" + from hermes_cli import auth as auth_mod + + profile_b = tmp_path / "profile_b" + _setup_nous_auth( + profile_b, + access_token="local-not-an-invoke-jwt", + refresh_token="local-stale-refresh", + expires_at="2000-01-01T00:00:00+00:00", + expires_in=0, + ) + auth_path = profile_b / "auth.json" + auth_payload = json.loads(auth_path.read_text()) + local_state = auth_payload["providers"]["nous"] + local_state["portal_base_url"] = "http://127.0.0.1:8001" + local_state["client_id"] = "local-client" + auth_path.write_text(json.dumps(auth_payload, indent=2)) + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + + shared_state = _full_state_fixture() + shared_state["access_token"] = "shared-not-an-invoke-jwt" + shared_state["refresh_token"] = "shared-refresh" + shared_state["expires_at"] = _future_iso(3600) + shared_state["portal_base_url"] = "http://localhost:8002" + shared_state["client_id"] = "shared-client" + auth_mod._write_shared_nous_state(shared_state) + + captured = {} + refreshed_token = _invoke_jwt(seconds=7200) + + def _refresh(**kwargs): + captured.update(kwargs) + return { + "access_token": refreshed_token, + "refresh_token": "rotated-refresh", + "expires_in": 7200, + "scope": auth_mod.DEFAULT_NOUS_SCOPE, + } + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh) + + creds = auth_mod.resolve_nous_runtime_credentials() + + assert captured["portal_base_url"] == "http://localhost:8002" + assert captured["client_id"] == "shared-client" + assert captured["refresh_token"] == "shared-refresh" + assert creds["api_key"] == refreshed_token + + def test_runtime_shared_recovery_honors_inference_env_override( tmp_path, monkeypatch, shared_store_env, ): @@ -2325,3 +2377,46 @@ class TestStalePortalBaseUrlMigration: auth_mod.resolve_nous_runtime_credentials() assert len(refresh_calls) == 1 assert refresh_calls[0] == auth_mod.DEFAULT_NOUS_PORTAL_URL + + def test_runtime_credentials_rejects_http_for_production_portal( + self, tmp_path, monkeypatch, + ): + """An allowlisted production host is still unsafe over plain HTTP.""" + from hermes_cli import auth as auth_mod + + hermes_home = tmp_path / "hermes" + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + _setup_nous_auth( + hermes_home, + access_token=_invoke_jwt(seconds=-60), + refresh_token="valid-refresh", + expires_at=_future_iso(-60), + expires_in=0, + ) + auth_file = hermes_home / "auth.json" + store = json.loads(auth_file.read_text()) + store["providers"]["nous"]["portal_base_url"] = ( + "http://portal.nousresearch.com" + ) + auth_file.write_text(json.dumps(store, indent=2)) + + refresh_calls = [] + + def _fake_refresh_access_token( + *, client, portal_base_url, client_id, refresh_token, + ): + del client, client_id, refresh_token + refresh_calls.append(portal_base_url) + return { + "access_token": _invoke_jwt(seconds=3600), + "refresh_token": "new-refresh", + "expires_in": 3600, + "scope": "inference:invoke", + } + + monkeypatch.setattr( + auth_mod, "_refresh_access_token", _fake_refresh_access_token + ) + + auth_mod.resolve_nous_runtime_credentials() + assert refresh_calls == [auth_mod.DEFAULT_NOUS_PORTAL_URL]