diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 7c1a0d269cf..93290fbf164 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1087,6 +1087,8 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]: or isinstance(raw.get("credential_pool"), dict) ): raw.setdefault("providers", {}) + if isinstance(raw.get("providers"), dict): + _migrate_stale_nous_portal_url(raw["providers"]) return raw # Migrate from PR's "systems" format if present @@ -1782,6 +1784,35 @@ def _optional_base_url(value: Any) -> Optional[str]: return cleaned if cleaned else None +_NOUS_STALE_PORTAL_HOSTS: FrozenSet[str] = frozenset({ + "api.nousresearch.com", +}) + +# Allowlist of valid Nous Portal hosts. A portal_base_url outside this +# set is treated as a misconfiguration and falls back to the default. +# "localhost" / "127.0.0.1" are valid for local development and testing. +_NOUS_PORTAL_ALLOWED_HOSTS: FrozenSet[str] = frozenset({ + "portal.nousresearch.com", + "localhost", + "127.0.0.1", +}) + + +def _migrate_stale_nous_portal_url(providers: Dict[str, Any]) -> None: + nous = providers.get("nous") + if not isinstance(nous, dict): + return + stored = (nous.get("portal_base_url") or "").strip() + if stored: + parsed = urlparse(stored) + if parsed.hostname in _NOUS_STALE_PORTAL_HOSTS: + logger.warning( + "auth: migrating stale nous portal_base_url %s -> %s", + stored, DEFAULT_NOUS_PORTAL_URL, + ) + nous["portal_base_url"] = DEFAULT_NOUS_PORTAL_URL + +# Allowlist of hosts the Nous Portal proxy is willing to forward inference # Allowlist of hosts the Nous Portal proxy is willing to forward inference # JWTs to. Sending a bearer anywhere else would leak it. # @@ -5321,6 +5352,15 @@ def resolve_nous_access_token( or os.getenv("NOUS_PORTAL_BASE_URL") or DEFAULT_NOUS_PORTAL_URL ).rstrip("/") + + parsed_portal_url = urlparse(portal_base_url) + if parsed_portal_url.hostname and parsed_portal_url.hostname not in _NOUS_PORTAL_ALLOWED_HOSTS: + logger.warning( + "auth: ignoring invalid portal_base_url %r (host %r not in allowlist), using default", + portal_base_url, parsed_portal_url.hostname, + ) + portal_base_url = DEFAULT_NOUS_PORTAL_URL + client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index d769d68ebff..f5f4806e23e 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1984,3 +1984,142 @@ def test_managed_gateway_access_token_uses_newer_shared_token( profile_state = auth_mod.get_provider_auth_state("nous") assert profile_state is not None assert profile_state["refresh_token"] == "shared-fresh-refresh" + +class TestStalePortalBaseUrlMigration: + """_migrate_stale_nous_portal_url auto-corrects stale portal_base_url on load.""" + + def test_migrates_stale_portal_url_on_load(self, tmp_path, monkeypatch): + from hermes_cli.auth import _load_auth_store, DEFAULT_NOUS_PORTAL_URL + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + auth_file = tmp_path / "auth.json" + auth_file.write_text(json.dumps({ + "version": 1, + "active_provider": "nous", + "providers": { + "nous": { + "portal_base_url": "https://api.nousresearch.com", + "access_token": "test-token", + "refresh_token": "test-refresh", + } + }, + })) + + store = _load_auth_store(auth_file) + nous = store["providers"]["nous"] + assert nous["portal_base_url"] == DEFAULT_NOUS_PORTAL_URL + + def test_preserves_correct_portal_url(self, tmp_path, monkeypatch): + from hermes_cli.auth import _load_auth_store, DEFAULT_NOUS_PORTAL_URL + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + auth_file = tmp_path / "auth.json" + auth_file.write_text(json.dumps({ + "version": 1, + "active_provider": "nous", + "providers": { + "nous": { + "portal_base_url": DEFAULT_NOUS_PORTAL_URL, + "access_token": "test-token", + "refresh_token": "test-refresh", + } + }, + })) + + store = _load_auth_store(auth_file) + nous = store["providers"]["nous"] + assert nous["portal_base_url"] == DEFAULT_NOUS_PORTAL_URL + + def test_ignores_other_providers(self, tmp_path, monkeypatch): + from hermes_cli.auth import _load_auth_store, DEFAULT_NOUS_PORTAL_URL + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + auth_file = tmp_path / "auth.json" + auth_file.write_text(json.dumps({ + "version": 1, + "active_provider": "openai-codex", + "providers": {}, + })) + + store = _load_auth_store(auth_file) + assert "nous" not in store.get("providers", {}) + + def test_noop_when_nous_state_not_dict(self, tmp_path, monkeypatch): + from hermes_cli.auth import _load_auth_store + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + auth_file = tmp_path / "auth.json" + auth_file.write_text(json.dumps({ + "version": 1, + "active_provider": "nous", + "providers": {"nous": None}, + })) + + store = _load_auth_store(auth_file) + assert store["providers"]["nous"] is None + + def test_runtime_fallback_for_invalid_portal_url(self, tmp_path, monkeypatch): + from hermes_cli import auth as auth_mod + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _setup_nous_auth( + tmp_path, + access_token="expired-access", + refresh_token="valid-refresh", + expires_at="2025-01-01T00:00:00+00:00", + ) + auth_file = tmp_path / "auth.json" + store = json.loads(auth_file.read_text()) + store["providers"]["nous"]["portal_base_url"] = "https://api.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": "refreshed-access", + "refresh_token": "new-refresh", + "expires_in": 3600, + } + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) + + token = auth_mod.resolve_nous_access_token() + assert token == "refreshed-access" + assert len(refresh_calls) == 1 + assert refresh_calls[0] == auth_mod.DEFAULT_NOUS_PORTAL_URL + + def test_runtime_accepts_localhost(self, tmp_path, monkeypatch): + from hermes_cli import auth as auth_mod + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _setup_nous_auth( + tmp_path, + access_token="expired-access", + refresh_token="valid-refresh", + expires_at="2025-01-01T00:00:00+00:00", + ) + auth_file = tmp_path / "auth.json" + store = json.loads(auth_file.read_text()) + store["providers"]["nous"]["portal_base_url"] = "http://localhost:8080/" + 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": "refreshed-access", + "refresh_token": "new-refresh", + "expires_in": 3600, + } + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) + + token = auth_mod.resolve_nous_access_token() + assert token == "refreshed-access" + assert len(refresh_calls) == 1 + assert "localhost" in refresh_calls[0]