diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 619a0b57ad7..5daec12be75 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5561,64 +5561,63 @@ def resolve_nous_runtime_credentials( persisted_state = dict(state) state_persisted = False - portal_base_url = ( - _optional_base_url(state.get("portal_base_url")) - or os.getenv("HERMES_PORTAL_BASE_URL") - or os.getenv("NOUS_PORTAL_BASE_URL") - or DEFAULT_NOUS_PORTAL_URL - ).rstrip("/") + def _resolve_effective_routing_metadata() -> tuple[str, str, str, str]: + """Resolve every routing value that shared OAuth state can replace.""" + portal_url = ( + _optional_base_url(state.get("portal_base_url")) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or DEFAULT_NOUS_PORTAL_URL + ).rstrip("/") - # A persisted/stale portal_base_url is where the refresh token gets - # POSTed on refresh — reject any host outside the allowlist so a - # poisoned value can't exfiltrate the bearer, healing to the default. - # The trusted operator/deployment env override (HERMES_PORTAL_BASE_URL / - # NOUS_PORTAL_BASE_URL) bypasses this gate entirely — mirrors - # NOUS_INFERENCE_BASE_URL's treatment below; the allowlist exists to - # reject an untrusted NETWORK-provided value, not one the operator - # explicitly configured. - env_portal_override = _nous_portal_env_override() - if env_portal_override: - portal_base_url = env_portal_override.rstrip("/") - else: - 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 + # A persisted/stale portal_base_url is where the refresh token gets + # POSTed on refresh — reject any host outside the allowlist so a + # poisoned value can't exfiltrate the bearer, healing to the default. + # Trusted operator env overrides bypass this network-value gate. + env_portal_override = _nous_portal_env_override() + if env_portal_override: + portal_url = env_portal_override.rstrip("/") + else: + parsed_portal_url = urlparse(portal_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_url, + parsed_portal_url.hostname, + ) + portal_url = DEFAULT_NOUS_PORTAL_URL - # Persisted value: validated network-provenance only. The stored - # inference_base_url is re-validated on read so a poisoned/stale - # staging host (persisted before the allowlist existed) heals to the - # production default on the no-refresh read path — this is what gets - # written back to auth.json. The env override is deliberately NOT - # folded in here: it must never be persisted (it's a runtime overlay). - stored_inference_base_url = ( - _validate_nous_inference_url_from_network( - _optional_base_url(state.get("inference_base_url")) - ) - or DEFAULT_NOUS_INFERENCE_URL - ) - # Effective value used to build the client / returned to callers: - # the NOUS_INFERENCE_BASE_URL env override wins (documented dev/staging - # escape hatch), else the validated stored value. - inference_base_url = ( - _nous_inference_env_override() or stored_inference_base_url - ) - client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) - - def _refresh_effective_inference_base_url() -> None: - nonlocal stored_inference_base_url, inference_base_url - stored_inference_base_url = ( + # Re-validate persisted network-provenance on every shared merge. + # The env override is runtime-only and must never be persisted. + stored_inference_url = ( _validate_nous_inference_url_from_network( _optional_base_url(state.get("inference_base_url")) ) or DEFAULT_NOUS_INFERENCE_URL ) - inference_base_url = ( - _nous_inference_env_override() or stored_inference_base_url + effective_inference_url = ( + _nous_inference_env_override() or stored_inference_url ) + effective_client_id = str( + state.get("client_id") or DEFAULT_NOUS_CLIENT_ID + ) + return ( + portal_url, + stored_inference_url, + effective_inference_url, + effective_client_id, + ) + + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() def _persist_state(reason: str) -> None: nonlocal persisted_state, state_persisted @@ -5678,7 +5677,12 @@ def resolve_nous_runtime_credentials( if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") - _refresh_effective_inference_base_url() + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() _persist_state("runtime_shared_merge_missing_access_token") if not isinstance(access_token, str) or not access_token: @@ -5695,6 +5699,12 @@ def resolve_nous_runtime_credentials( if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") + ( + portal_base_url, + stored_inference_base_url, + inference_base_url, + client_id, + ) = _resolve_effective_routing_metadata() invoke_jwt_status = _nous_invoke_jwt_status( access_token, scope=state.get("scope"), diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index f367a7589ec..ab665d3e3f8 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -2002,6 +2002,114 @@ def test_runtime_credentials_merges_shared_token_before_empty_local_access_token assert profile_state is not None assert profile_state["access_token"] == shared_token assert profile_state["refresh_token"] == "shared-fresh-refresh" + assert profile_state["agent_key"] == shared_token + + +def test_runtime_shared_recovery_recomputes_routing_before_force_refresh( + tmp_path, monkeypatch, shared_store_env, +): + """A shared refresh token must use its own routing metadata.""" + from hermes_cli import auth as auth_mod + + profile_b = tmp_path / "profile_b" + _setup_nous_auth( + profile_b, + access_token="local-placeholder", + 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["access_token"] = None + 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"] = _invoke_jwt(seconds=3600) + shared_state["refresh_token"] = "shared-refresh" + shared_state["expires_at"] = _future_iso(3600) + shared_state["scope"] = auth_mod.DEFAULT_NOUS_SCOPE + shared_state["portal_base_url"] = "http://localhost:8002" + shared_state["client_id"] = "shared-client" + shared_state["inference_base_url"] = auth_mod.DEFAULT_NOUS_INFERENCE_URL + 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-shared-refresh", + "expires_in": 7200, + "scope": auth_mod.DEFAULT_NOUS_SCOPE, + "inference_base_url": auth_mod.DEFAULT_NOUS_INFERENCE_URL, + } + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh) + + creds = auth_mod.resolve_nous_runtime_credentials(force_refresh=True) + + 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 + + profile_state = auth_mod.get_provider_auth_state("nous") + assert profile_state is not None + assert profile_state["portal_base_url"] == "http://localhost:8002" + assert profile_state["client_id"] == "shared-client" + assert profile_state["refresh_token"] == "rotated-shared-refresh" + + +def test_runtime_shared_recovery_honors_inference_env_override( + tmp_path, monkeypatch, shared_store_env, +): + """Shared state is persisted, but the operator inference override wins.""" + from hermes_cli import auth as auth_mod + + profile_b = tmp_path / "profile_b" + _setup_nous_auth( + profile_b, + access_token="local-placeholder", + 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()) + auth_payload["providers"]["nous"]["access_token"] = None + auth_path.write_text(json.dumps(auth_payload, indent=2)) + monkeypatch.setenv("HERMES_HOME", str(profile_b)) + override_url = "https://operator.example/v1" + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", override_url) + + shared_state = _full_state_fixture() + shared_token = _invoke_jwt(seconds=3600) + shared_state["access_token"] = shared_token + shared_state["refresh_token"] = "shared-refresh" + shared_state["expires_at"] = _future_iso(3600) + shared_state["scope"] = auth_mod.DEFAULT_NOUS_SCOPE + shared_state["inference_base_url"] = auth_mod.DEFAULT_NOUS_INFERENCE_URL + auth_mod._write_shared_nous_state(shared_state) + + monkeypatch.setattr( + auth_mod, + "_refresh_access_token", + lambda **_kwargs: (_ for _ in ()).throw(AssertionError("unexpected refresh")), + ) + + creds = auth_mod.resolve_nous_runtime_credentials() + + assert creds["base_url"] == override_url + profile_state = auth_mod.get_provider_auth_state("nous") + assert profile_state is not None + assert profile_state["inference_base_url"] == auth_mod.DEFAULT_NOUS_INFERENCE_URL def test_managed_gateway_access_token_uses_newer_shared_token(