diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 5cf6d50186a..619a0b57ad7 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -5608,6 +5608,18 @@ def resolve_nous_runtime_credentials( ) 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 = ( + _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 + ) + def _persist_state(reason: str) -> None: nonlocal persisted_state, state_persisted # Skip writes where only derived TTL countdowns changed; this keeps @@ -5659,6 +5671,16 @@ def resolve_nous_runtime_credentials( access_token = state.get("access_token") refresh_token = state.get("refresh_token") + if not isinstance(access_token, str) or not access_token: + with _nous_shared_store_lock( + timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS) + ): + if _merge_shared_nous_oauth_state(state): + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + _refresh_effective_inference_base_url() + _persist_state("runtime_shared_merge_missing_access_token") + if not isinstance(access_token, str) or not access_token: raise AuthError("No access token found for Nous Portal login.", provider="nous", relogin_required=True) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 183cf82fa52..f367a7589ec 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1954,6 +1954,56 @@ def test_runtime_refresh_uses_newer_shared_token_before_local_stale_token( assert profile_state["access_token"] == shared_token +def test_runtime_credentials_merges_shared_token_before_empty_local_access_token( + tmp_path, monkeypatch, shared_store_env, +): + """A profile with access_token=None must recover from the shared store. + + ``resolve_nous_access_token()`` already merges shared OAuth state before + giving up. The runtime path must do the same so sibling profiles that + share a valid Nous login do not dead-end on an empty local auth.json. + """ + 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)) + + shared_state = _full_state_fixture() + shared_token = _invoke_jwt(seconds=3600) + shared_state["access_token"] = shared_token + shared_state["refresh_token"] = "shared-fresh-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) + + def _refresh_should_not_happen(**_kwargs): + raise AssertionError("empty local access token should recover from shared store") + + monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh_should_not_happen) + + creds = auth_mod.resolve_nous_runtime_credentials() + + assert creds["api_key"] == shared_token + assert creds["base_url"] == auth_mod.DEFAULT_NOUS_INFERENCE_URL + + profile_state = auth_mod.get_provider_auth_state("nous") + assert profile_state is not None + assert profile_state["access_token"] == shared_token + assert profile_state["refresh_token"] == "shared-fresh-refresh" + + def test_managed_gateway_access_token_uses_newer_shared_token( tmp_path, monkeypatch, shared_store_env, ):