mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
394 lines
16 KiB
Python
394 lines
16 KiB
Python
import asyncio
|
|
import os
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from importlib.util import module_from_spec, spec_from_file_location
|
|
from pathlib import Path
|
|
import sys
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
|
|
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
|
|
assert MODULE_SPEC and MODULE_SPEC.loader
|
|
managed_tool_gateway = module_from_spec(MODULE_SPEC)
|
|
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
|
|
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
|
|
is_managed_tool_gateway_ready = managed_tool_gateway.is_managed_tool_gateway_ready
|
|
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
|
},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
result = resolve_managed_tool_gateway(
|
|
"firecrawl",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com"
|
|
assert result.nous_user_token == "nous-token"
|
|
assert result.managed_mode is True
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
|
|
},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
result = resolve_managed_tool_gateway(
|
|
"browser-use",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.gateway_origin == "http://browser-use-gateway.localhost:3009"
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
|
},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
result = resolve_managed_tool_gateway(
|
|
"firecrawl",
|
|
token_reader=lambda: None,
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
|
|
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \
|
|
patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
|
|
result = resolve_managed_tool_gateway(
|
|
"firecrawl",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
|
|
(tmp_path / "auth.json").write_text(json.dumps({
|
|
"providers": {
|
|
"nous": {
|
|
"access_token": "stale-token",
|
|
"refresh_token": "refresh-token",
|
|
"expires_at": expires_at,
|
|
}
|
|
}
|
|
}))
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.resolve_nous_access_token",
|
|
lambda refresh_skew_seconds=120: "fresh-token",
|
|
)
|
|
|
|
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
|
|
|
|
|
|
def test_managed_vendor_endpoints_pin_the_deployed_gateway_url():
|
|
"""The exact URL an agent may connect to is a code fact, not a lookup.
|
|
|
|
Exercises the real ``build_vendor_gateway_url`` (which once resolved a
|
|
typo'd pseudo-vendor to a non-existent host while every other test stubbed
|
|
it): default builder, real deployed host, pinned vendor path.
|
|
"""
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
|
|
patch.dict(
|
|
os.environ,
|
|
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
|
|
clear=False,
|
|
):
|
|
os.environ.pop("TOOL_GATEWAY_URL", None)
|
|
endpoints = managed_tool_gateway.managed_vendor_endpoints("bfl")
|
|
|
|
assert endpoints == {
|
|
"origin": "https://tool-gateway.nousresearch.com",
|
|
"base_url": "https://tool-gateway.nousresearch.com/api/bfl",
|
|
"upload_path": "/api/uploads/bfl",
|
|
}
|
|
|
|
|
|
def test_managed_vendor_endpoints_unreachable_when_managed_tools_disabled():
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
|
|
assert managed_tool_gateway.managed_vendor_endpoints("bfl") is None
|
|
|
|
|
|
def test_managed_gateway_auth_headers_carry_the_bearer():
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
headers = managed_tool_gateway.managed_gateway_auth_headers(
|
|
"https://tool-gateway.example.com/api/bfl/generations",
|
|
gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com",
|
|
token_reader=lambda: "nous-token",
|
|
)
|
|
|
|
assert headers == {"Authorization": "Bearer nous-token"}
|
|
|
|
|
|
def test_managed_gateway_auth_headers_reflect_a_rotated_token():
|
|
# Read fresh on every call: a Nous access token expires within the hour,
|
|
# and a long session must not keep presenting a dead bearer.
|
|
tokens = iter(["first-token", "second-token"])
|
|
builder = lambda vendor: f"https://{vendor}-gateway.example.com"
|
|
url = "https://tool-gateway.example.com/api/bfl/generations"
|
|
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
first = managed_tool_gateway.managed_gateway_auth_headers(url, builder, lambda: next(tokens))
|
|
second = managed_tool_gateway.managed_gateway_auth_headers(url, builder, lambda: next(tokens))
|
|
|
|
assert first["Authorization"] == "Bearer first-token"
|
|
assert second["Authorization"] == "Bearer second-token"
|
|
|
|
|
|
def test_managed_gateway_auth_headers_refuse_a_url_off_the_gateway_origin():
|
|
# Gated on the URL, never a name: our bearer must never be handed to a
|
|
# host that merely looks managed.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
assert managed_tool_gateway.managed_gateway_auth_headers(
|
|
"https://attacker.example/api/bfl/generations",
|
|
gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com",
|
|
token_reader=lambda: "nous-token",
|
|
) == {}
|
|
|
|
|
|
def test_managed_gateway_auth_headers_empty_without_a_token():
|
|
# Empty rather than raising, so a caller can say "sign in" instead of
|
|
# sending an unauthenticated request.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
assert managed_tool_gateway.managed_gateway_auth_headers(
|
|
"https://tool-gateway.example.com/api/bfl/generations",
|
|
gateway_builder=lambda vendor: f"https://{vendor}-gateway.example.com",
|
|
token_reader=lambda: None,
|
|
) == {}
|
|
|
|
|
|
class TestManagedMediaUploader:
|
|
"""The presign -> PUT -> ``nous-upload:<token>`` protocol.
|
|
|
|
This is the only way a local image or video reaches a managed vendor, and
|
|
the pieces it gets right are not incidental: the presigned URL signs the
|
|
content type and byte length, so a PUT that disagrees with the presign is
|
|
rejected by storage rather than by us.
|
|
"""
|
|
|
|
GATEWAY = "https://tool-gateway.example.com"
|
|
BASE_URL = f"{GATEWAY}/api/bfl"
|
|
UPLOAD_PATH = "/api/uploads/bfl"
|
|
|
|
@staticmethod
|
|
def _builder(vendor):
|
|
return f"https://{vendor}-gateway.example.com"
|
|
|
|
def _uploader(self, **kwargs):
|
|
return managed_tool_gateway.build_managed_media_uploader(
|
|
kwargs.pop("server_url", self.BASE_URL),
|
|
kwargs.pop("upload_path", self.UPLOAD_PATH),
|
|
gateway_builder=lambda vendor: self.GATEWAY,
|
|
token_reader=kwargs.pop("token_reader", lambda: "nous-token"),
|
|
)
|
|
|
|
@staticmethod
|
|
def _response(status_code=200, payload=None):
|
|
class _R:
|
|
def __init__(self):
|
|
self.status_code = status_code
|
|
|
|
def json(self):
|
|
if payload is None:
|
|
raise ValueError("no json")
|
|
return payload
|
|
|
|
return _R()
|
|
|
|
def _run(self, uploader, data=b"bytes", mime="image/png", presign=None, put=None):
|
|
"""Drive one upload with both HTTP legs stubbed; returns the calls made."""
|
|
import httpx
|
|
|
|
from tools import url_safety
|
|
|
|
calls = {"presign": [], "put": []}
|
|
presign = presign if presign is not None else self._response(
|
|
200, {"uploadUrl": "https://storage.example/put?sig=abc", "token": "tok-1"}
|
|
)
|
|
put = put if put is not None else self._response(200)
|
|
|
|
class _PresignClient:
|
|
def __init__(self, **_kw):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_exc):
|
|
return False
|
|
|
|
async def post(self, url, headers=None, json=None):
|
|
calls["presign"].append({"url": url, "headers": headers, "json": json})
|
|
return presign
|
|
|
|
class _PutClient:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_exc):
|
|
return False
|
|
|
|
async def put(self, url, content=None, headers=None):
|
|
calls["put"].append({"url": url, "content": content, "headers": headers})
|
|
return put
|
|
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
|
|
patch.object(httpx, "AsyncClient", _PresignClient), \
|
|
patch.object(url_safety, "create_ssrf_safe_async_client", lambda **_kw: _PutClient()):
|
|
calls["result"] = asyncio.run(uploader(data, mime))
|
|
return calls
|
|
|
|
def test_presign_declares_the_exact_type_and_length_the_put_then_sends(self):
|
|
# Storage validates the PUT against what was signed, so a mismatch
|
|
# between these two is a rejection with no useful error.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
data = b"\x89PNG\r\n\x1a\n" + b"payload" * 100
|
|
|
|
calls = self._run(uploader, data=data, mime="image/png")
|
|
|
|
assert calls["presign"][0]["url"] == f"{self.GATEWAY}{self.UPLOAD_PATH}"
|
|
assert calls["presign"][0]["json"] == {
|
|
"contentType": "image/png",
|
|
"contentLength": len(data),
|
|
}
|
|
assert calls["presign"][0]["headers"]["Authorization"] == "Bearer nous-token"
|
|
assert calls["put"][0]["url"] == "https://storage.example/put?sig=abc"
|
|
assert calls["put"][0]["content"] == data
|
|
assert calls["put"][0]["headers"] == {"Content-Type": "image/png"}
|
|
assert calls["result"] == "nous-upload:tok-1"
|
|
|
|
def test_the_bytes_go_to_storage_and_never_through_the_gateway(self):
|
|
# The whole point of presigning is that the gateway's request-size
|
|
# ceiling does not apply to a 50MB clip.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
|
|
calls = self._run(uploader, data=b"v" * 4096, mime="video/mp4")
|
|
|
|
assert len(calls["presign"]) == 1 and len(calls["put"]) == 1
|
|
assert self.GATEWAY not in calls["put"][0]["url"]
|
|
assert calls["presign"][0]["json"]["contentType"] == "video/mp4"
|
|
|
|
def test_no_uploader_when_the_url_is_not_a_managed_gateway(self):
|
|
# Refusing to build is what makes the caller say "pass a URL instead"
|
|
# rather than forwarding a raw local path to a third party.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
assert self._uploader(server_url="https://attacker.example/api/bfl") is None
|
|
|
|
@pytest.mark.parametrize("upload_path", [None, "", "api/uploads/bfl", 42])
|
|
def test_no_uploader_without_a_rooted_upload_path(self, upload_path):
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
assert self._uploader(upload_path=upload_path) is None
|
|
|
|
def test_a_missing_credential_fails_before_any_request(self):
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
|
|
patch.object(managed_tool_gateway, "managed_gateway_auth_headers", return_value={}):
|
|
with pytest.raises(RuntimeError, match="no Nous credential"):
|
|
asyncio.run(uploader(b"x", "image/png"))
|
|
|
|
def test_a_gateway_refusal_surfaces_its_own_message(self):
|
|
# Quota and size refusals carry guidance written for the model; a bare
|
|
# status code would throw that away.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
refusal = self._response(
|
|
413, {"error": {"message": "That file is 82MB; the limit for video is 50MB."}}
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="the limit for video is 50MB"):
|
|
self._run(uploader, presign=refusal)
|
|
|
|
def test_an_unreadable_refusal_still_reports_the_status(self):
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
|
|
with pytest.raises(RuntimeError, match="HTTP 502"):
|
|
self._run(uploader, presign=self._response(502, None))
|
|
|
|
@pytest.mark.parametrize(
|
|
"payload",
|
|
[
|
|
{},
|
|
{"uploadUrl": "https://storage.example/put"},
|
|
{"token": "tok-1"},
|
|
{"uploadUrl": "", "token": "tok-1"},
|
|
{"uploadUrl": "https://storage.example/put", "token": ""},
|
|
],
|
|
)
|
|
def test_a_malformed_presign_response_is_refused_rather_than_guessed(self, payload):
|
|
# Half a presign must not become a PUT to nowhere or an empty token
|
|
# that later reads as a valid reference.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
|
|
with pytest.raises(RuntimeError, match="malformed"):
|
|
self._run(uploader, presign=self._response(200, payload))
|
|
|
|
def test_a_storage_rejection_is_not_reported_as_a_successful_upload(self):
|
|
# A signature mismatch answers non-200 with an XML body; returning a
|
|
# token here would hand the vendor a reference to nothing.
|
|
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
uploader = self._uploader()
|
|
|
|
with pytest.raises(RuntimeError, match="storage refused the upload"):
|
|
self._run(uploader, put=self._response(403))
|
|
|
|
|
|
def test_is_managed_tool_gateway_ready_skips_refresh_for_expired_cached_token(tmp_path, monkeypatch):
|
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
expired_at = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
|
|
(tmp_path / "auth.json").write_text(json.dumps({
|
|
"providers": {
|
|
"nous": {
|
|
"access_token": "expired-token",
|
|
"refresh_token": "refresh-token",
|
|
"expires_at": expired_at,
|
|
}
|
|
}
|
|
}))
|
|
refresh_calls = []
|
|
|
|
def _record_refresh(*, refresh_skew_seconds=120, **_kwargs):
|
|
refresh_calls.append(refresh_skew_seconds)
|
|
return "fresh-token"
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.auth.resolve_nous_access_token",
|
|
_record_refresh,
|
|
)
|
|
|
|
with patch.dict(
|
|
os.environ,
|
|
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com"},
|
|
clear=False,
|
|
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
|
|
assert is_managed_tool_gateway_ready("modal") is True
|
|
|
|
assert refresh_calls == []
|