test(auth): update nous jwt-only expectations

This commit is contained in:
Robin Fernandes
2026-05-29 08:17:58 +10:00
committed by kshitij
parent 7e958dafc2
commit 4e4984a11a
4 changed files with 33 additions and 16 deletions

View File

@ -155,17 +155,17 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch):
assert not any(item["source"] == "manual:device_code" for item in entries)
entry = device_code_entries[0]
assert entry["source"] == "device_code"
assert entry["agent_key"] == "ak-test"
assert entry["agent_key"] == token
assert entry["portal_base_url"] == "https://portal.example.com"
# `hermes auth add nous` must also populate providers.nous so the
# 401-recovery path (resolve_nous_runtime_credentials) can mint a fresh
# agent_key when the 24h TTL expires. If this mirror is missing, recovery
# 401-recovery path (resolve_nous_runtime_credentials) can refresh an
# invoke JWT when the token expires. If this mirror is missing, recovery
# raises "Hermes is not logged into Nous Portal" and the agent dies.
singleton = payload["providers"]["nous"]
assert singleton["access_token"] == token
assert singleton["refresh_token"] == "refresh-token"
assert singleton["agent_key"] == "ak-test"
assert singleton["agent_key"] == token
assert singleton["portal_base_url"] == "https://portal.example.com"
assert singleton["inference_base_url"] == "https://inference.example.com/v1"

View File

@ -1,8 +1,8 @@
"""Regression tests for Nous Portal inference_base_url host-allowlist validation.
A poisoned ``inference_base_url`` from the Portal refresh / agent-key-mint
response (network MITM, malicious response injection) would otherwise be
persisted to auth.json and forwarded the user's legitimate agent_key
A poisoned ``inference_base_url`` from a Portal refresh response (network
MITM, malicious response injection) would otherwise be persisted to
auth.json and forwarded with the user's legitimate invoke JWT
bearer on every subsequent proxy request, exfiltrating their inference
budget and opening a response-injection channel into the IDE / chat
client. ``_validate_nous_inference_url_from_network()`` blocks any URL
@ -11,7 +11,7 @@ outside the allowlist at the source.
These tests verify:
1. The validator's host + scheme rules.
2. Each of the five NETWORK call sites in ``auth.py`` calls the validator
2. Each of the two NETWORK call sites in ``auth.py`` calls the validator
rather than the unrestricted ``_optional_base_url`` helper.
3. The proxy adapter applies the validator as belt-and-suspenders.
4. The env-var override path (``NOUS_INFERENCE_BASE_URL``) is NOT
@ -124,7 +124,7 @@ class TestValidatorRules:
class TestCallSiteWiring:
"""Verify the validator is actually wired into all 5 NETWORK call sites.
"""Verify the validator is actually wired into all auth.py NETWORK call sites.
These are not behaviour-end-to-end tests (the surrounding code is
several hundred lines per site with extensive HTTP mocking
@ -161,7 +161,7 @@ class TestCallSiteWiring:
)
def test_validator_wired_at_all_known_call_sites(self):
"""All 5 known NETWORK sites use the validator. If this count
"""All 2 known auth.py NETWORK sites use the validator. If this count
drops, someone removed protection; if it grows, audit the new
site to be sure validation is appropriate."""
source = self._read_auth_source()
@ -171,8 +171,8 @@ class TestCallSiteWiring:
mint_count = source.count(
'_validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))'
)
assert refresh_count == 3, f"expected 3 refresh sites, found {refresh_count}"
assert mint_count == 2, f"expected 2 mint sites, found {mint_count}"
assert refresh_count == 2, f"expected 2 refresh sites, found {refresh_count}"
assert mint_count == 0, f"expected 0 mint sites, found {mint_count}"
def test_proxy_adapter_also_validates(self):
"""The Nous proxy adapter applies the validator as defense-in-depth

View File

@ -260,8 +260,8 @@ def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch
assert stored.get("credential_pool", {}).get("nous") == []
def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, monkeypatch):
"""If the refresh helper succeeds but produces no agent_key, we surface a clear error."""
def test_nous_adapter_get_credential_raises_when_no_jwt_returned(tmp_path, monkeypatch):
"""If the refresh helper succeeds but produces no JWT, we surface a clear error."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_write_auth_store(tmp_path, {
"access_token": "access-tok",
@ -273,7 +273,7 @@ def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path,
return_value={"access_token": "a", "refresh_token": "r"},
):
adapter = NousPortalAdapter()
with pytest.raises(RuntimeError, match="did not return a usable agent_key"):
with pytest.raises(RuntimeError, match="did not return a usable inference JWT"):
adapter.get_credential()

View File

@ -4,6 +4,8 @@ and handles responses properly for all supported providers.
Ensures changes to one provider path don't silently break another.
"""
import base64
import json
import sys
import types
from types import SimpleNamespace
@ -35,6 +37,17 @@ def _tool_defs(*names):
]
def _fake_invoke_jwt() -> str:
def _part(payload):
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
return (
f"{_part({'alg': 'none', 'typ': 'JWT'})}."
f"{_part({'scope': 'inference:invoke', 'exp': 4102444800})}.sig"
)
class _FakeOpenAI:
def __init__(self, **kw):
self.api_key = kw.get("api_key", "test")
@ -925,7 +938,11 @@ class TestAuxiliaryClientProviderPriority:
def test_nous_when_no_openrouter(self, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
from agent.auxiliary_client import get_text_auxiliary_client
with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \
nous_auth = {
"access_token": _fake_invoke_jwt(),
"scope": "inference:invoke",
}
with patch("agent.auxiliary_client._read_nous_auth", return_value=nous_auth), \
patch("agent.auxiliary_client.OpenAI") as mock, \
patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None):
client, model = get_text_auxiliary_client()