diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 376f50bdad..8d91230270 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -108,36 +108,23 @@ _ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ # Free-tier (anonymous) host: serves the single ``nous/welcome`` model. "welcome-api.nousresearch.com"}) -# Every Nous inference gateway, production or not, lives under this domain. Consulted only when -# the operator has pointed the process at a non-production Portal (see below). -_NOUS_INFERENCE_HOST_SUFFIX = ".nousresearch.com" - - -def _operator_selected_non_production_portal() -> bool: - """True when the trusted ``HERMES_PORTAL_BASE_URL`` override names a Portal outside the - production allowlist — the operator has deliberately put this profile on another environment. - - A token minted by that Portal is meant to be spent at that environment's own inference - gateway, and the Portal's refresh response names it. Keyed on the operator override, never on - the stored ``portal_base_url``, so a poisoned auth.json cannot widen the allowlist and a - production-Portal session that finds a foreign inference URL in its state is still refused. - """ - override = _nous_portal_env_override() - if not override: - return False - from hermes_cli.auth import _NOUS_PORTAL_ALLOWED_HOSTS - host = urlparse(override).hostname - return bool(host) and host not in _NOUS_PORTAL_ALLOWED_HOSTS # unparseable override: fail closed - - def _nous_inference_host_allowed(hostname: Optional[str]) -> bool: - """Production hosts always; any Nous-domain host when the operator selected another Portal.""" + """Production hosts always; otherwise only the host the operator named in + ``NOUS_INFERENCE_BASE_URL``. + + A non-production Portal's refresh response names that environment's inference gateway. The + Portal-returned value is network provenance, so it does not get bearer-receive authority on + its own — not even for a Nous-owned host: the operator's explicit override is the authority, + and the network value is accepted exactly when it agrees with it. Then the persisted endpoint, + the pricing scope and the proxy all follow the environment the operator chose, and the + per-turn "refusing inference URL host" warning stops. + """ if hostname in _ALLOWED_NOUS_INFERENCE_HOSTS: return True - if not hostname or not hostname.endswith(_NOUS_INFERENCE_HOST_SUFFIX): + if not hostname: return False - labels = hostname.removesuffix(_NOUS_INFERENCE_HOST_SUFFIX).split(".") - return all(labels) and _operator_selected_non_production_portal() + override = _nous_inference_env_override() + return override is not None and urlparse(override).hostname == hostname def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: diff --git a/tests/hermes_cli/test_nous_nonproduction_inference_host.py b/tests/hermes_cli/test_nous_nonproduction_inference_host.py index 8add6ae871..8578468cef 100644 --- a/tests/hermes_cli/test_nous_nonproduction_inference_host.py +++ b/tests/hermes_cli/test_nous_nonproduction_inference_host.py @@ -50,3 +50,63 @@ def test_routing_overrides_follow_the_profile_scope_and_fail_closed_without_one( assert helper() is None, "a call that lost its profile scope has no authority over the launch value" finally: ss.set_multiplex_active(False) + + +@pytest.mark.parametrize( + "inference_override, url, expected", + [ + # The operator named the environment's gateway; the Portal's matching value survives. + (ENV_INFERENCE, ENV_INFERENCE, ENV_INFERENCE), + # No operator authority: a non-production host is refused however it arrived. + (None, ENV_INFERENCE, None), + # Another Nous-owned host is not the named recipient — refused, DNS suffix or not. + (ENV_INFERENCE, OTHER_NOUS_HOST, None), + # Production is always valid; the rule widens, never narrows. + (None, PROD_INFERENCE, PROD_INFERENCE), + (ENV_INFERENCE, PROD_INFERENCE, PROD_INFERENCE), + ], +) +def test_network_inference_host_needs_the_operator_named_recipient(monkeypatch, inference_override, url, expected): + if inference_override is None: + monkeypatch.delenv("NOUS_INFERENCE_BASE_URL", raising=False) + else: + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", inference_override) + assert auth_nous._validate_nous_inference_url_from_network(url) == expected + + +def test_portal_override_alone_grants_nothing(monkeypatch): + """A non-production Portal override, with or without a matching stored Portal, does not make + the Portal's returned host a bearer recipient; the healed value stays production. Only the + inference override does, and scheme checks still apply to it.""" + monkeypatch.setenv("HERMES_PORTAL_BASE_URL", NONPROD_PORTAL) + monkeypatch.delenv("NOUS_INFERENCE_BASE_URL", raising=False) + state = {"portal_base_url": NONPROD_PORTAL, "inference_base_url": ENV_INFERENCE, "client_id": "cid"} + portal, stored_inference, _effective, _ = auth_nous._nous_effective_routing(state) + assert portal == NONPROD_PORTAL and stored_inference == auth_nous.DEFAULT_NOUS_INFERENCE_URL + assert auth_nous._healed_nous_inference_url({"inference_base_url": ENV_INFERENCE}) == ( + auth_nous.DEFAULT_NOUS_INFERENCE_URL) + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", ENV_INFERENCE) + assert auth_nous._healed_nous_inference_url({"inference_base_url": ENV_INFERENCE}) == ENV_INFERENCE + assert auth_nous._validate_nous_inference_url_from_network(ENV_INFERENCE.replace("https", "http", 1)) is None + + +def test_recipient_match_follows_the_profile_scope_under_multiplex(monkeypatch): + """The recipient is the routed profile's own override: a secondary without it refuses the + host even when the launch profile's process env names it.""" + from agent import secret_scope as ss + + monkeypatch.setenv("NOUS_INFERENCE_BASE_URL", ENV_INFERENCE) + ss.set_multiplex_active(True) + try: + token = ss.set_secret_scope({}) + try: + assert auth_nous._validate_nous_inference_url_from_network(ENV_INFERENCE) is None + finally: + ss.reset_secret_scope(token) + token = ss.set_secret_scope({"NOUS_INFERENCE_BASE_URL": ENV_INFERENCE}) + try: + assert auth_nous._validate_nous_inference_url_from_network(ENV_INFERENCE) == ENV_INFERENCE + finally: + ss.reset_secret_scope(token) + finally: + ss.set_multiplex_active(False) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index af7ba2089c..17282aa408 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -138,8 +138,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing). When it points at a non-production Portal, that Portal's own `*.nousresearch.com` inference host is accepted, so `NOUS_INFERENCE_BASE_URL` is not also required. Per-profile under multiplexing: set it in the served profile's `.env`. | -| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | +| `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing). Per-profile under multiplexing: set it in the served profile's `.env`. | +| `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL. Also the only non-production host a Portal response may name: when the Portal's returned inference URL matches this override it is accepted and persisted instead of being healed to production. Per-profile under multiplexing. | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | | `HERMES_NOUS_TIMEOUT_SECONDS` | HTTP timeout for Nous credential / token flows | | `HERMES_DUMP_REQUESTS` | Dump API request payloads to log files (`true`/`false`) |