mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-22 10:32:00 +00:00
self_provision_if_managed() gated on is_managed(), but is_managed() means
"NixOS/package-manager-managed" (it keys on HERMES_MANAGED or a ~/.hermes/.managed
marker) — NOT "NAS-hosted". A NAS-provisioned Fly agent sets NEITHER, so the gate
was always False and relay self-provision SILENTLY no-oped on exactly the hosted
agents it was built for. Caught live: a staging agent with GATEWAY_RELAY_URL
correctly stamped logged "No messaging platforms enabled" and never dialed the
connector; HERMES_MANAGED was unset on the machine. The unit tests had mocked
is_managed()->True, so they passed while the real trigger never fired (mocked-
trigger blind spot).
Fix: drop the is_managed() gate and rename self_provision_if_managed ->
self_provision_relay. The real trigger is now "relay_url() set + no pinned secret
+ a resolvable NAS token", which is both NAS-independent and self-guarding:
- NAS-hosted agent: GATEWAY_RELAY_URL + no pinned secret + bootstrapped NAS
token -> self-provisions.
- Self-hosted + `hermes gateway enroll`: pinned GATEWAY_RELAY_SECRET -> skipped
(existing secret-present guard).
- Self-hosted, unenrolled, no NAS identity: resolve_nous_access_token() fails
-> graceful no-op (existing fail-soft path).
Security: unchanged trust model. The connector still derives tenant from the
validated NAS token; this only broadens WHEN the provision attempt fires, and
every broadened case is still guarded by token-resolution + pinned-secret-skip.
Tests: replaced the (wrong) "skips when not managed" test with a regression test
proving a NAS host where is_managed()==False STILL provisions; renamed all call
sites; added a "no NAS token -> non-fatal skip" test for the self-hosted branch.
88 relay tests pass.
Relay-adapter lane. EXPERIMENTAL.
189 lines
7.6 KiB
Python
189 lines
7.6 KiB
Python
"""Unit tests for boot-time relay self-provisioning.
|
|
|
|
Covers gateway.relay.self_provision_relay() + the relay_endpoint() /
|
|
relay_route_keys() config readers. The connector HTTP POST is monkeypatched
|
|
(the cross-repo E2E exercises the real /relay/provision); these prove the
|
|
TRIGGER logic, in-process env wiring, and fail-soft boot behaviour.
|
|
|
|
The trigger is deliberately NOT is_managed() (that means NixOS/package-manager-
|
|
managed, which is False on a NAS-hosted Fly agent). The real gate is
|
|
"relay_url set + no pinned secret + a resolvable NAS token".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import gateway.relay as relay
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clean_env(monkeypatch):
|
|
for k in (
|
|
"GATEWAY_RELAY_URL",
|
|
"GATEWAY_RELAY_ID",
|
|
"GATEWAY_RELAY_SECRET",
|
|
"GATEWAY_RELAY_DELIVERY_KEY",
|
|
"GATEWAY_RELAY_ENDPOINT",
|
|
"GATEWAY_RELAY_ROUTE_KEYS",
|
|
"GATEWAY_RELAY_PLATFORM",
|
|
"GATEWAY_RELAY_BOT_ID",
|
|
):
|
|
monkeypatch.delenv(k, raising=False)
|
|
# Never read config.yaml off disk in these tests.
|
|
monkeypatch.setattr("gateway.run._load_gateway_config", lambda: {}, raising=False)
|
|
|
|
|
|
def _stub_post(captured: dict):
|
|
"""A fake _post_provision that records its kwargs and returns creds."""
|
|
|
|
def _fake(**kwargs):
|
|
captured.update(kwargs)
|
|
return {
|
|
"secret": "a" * 64,
|
|
"deliveryKey": "b" * 64,
|
|
"tenant": "org-tenant-x",
|
|
"gatewayId": kwargs["gateway_id"],
|
|
"routeKeys": kwargs["route_keys"],
|
|
}
|
|
|
|
return _fake
|
|
|
|
|
|
def _arm(monkeypatch, *, url="wss://connector.example/relay", token="nas-token"):
|
|
"""Arm the real trigger: a relay URL + a resolvable NAS token.
|
|
|
|
Note there is intentionally no `managed` knob — self-provision no longer
|
|
consults is_managed(). A test that wants the "no NAS identity" branch
|
|
monkeypatches resolve_nous_access_token to raise instead.
|
|
"""
|
|
monkeypatch.setattr(relay, "relay_url", lambda: url)
|
|
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", lambda: token)
|
|
|
|
|
|
# ─────────────────────────── config readers ───────────────────────────
|
|
|
|
def test_relay_endpoint_from_env(monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_RELAY_ENDPOINT", "https://gw.example.com/inbound/")
|
|
assert relay.relay_endpoint() == "https://gw.example.com/inbound"
|
|
|
|
|
|
def test_relay_endpoint_absent_is_none():
|
|
assert relay.relay_endpoint() is None
|
|
|
|
|
|
def test_relay_route_keys_csv(monkeypatch):
|
|
monkeypatch.setenv("GATEWAY_RELAY_ROUTE_KEYS", "guild-1, guild-2 ,, guild-3")
|
|
assert relay.relay_route_keys() == ["guild-1", "guild-2", "guild-3"]
|
|
|
|
|
|
def test_relay_route_keys_empty():
|
|
assert relay.relay_route_keys() == []
|
|
|
|
|
|
def test_provision_url_maps_ws_to_http():
|
|
assert relay._provision_url("wss://c.example/relay") == "https://c.example/relay/provision"
|
|
assert relay._provision_url("ws://c.example/relay") == "http://c.example/relay/provision"
|
|
assert relay._provision_url("https://c.example") == "https://c.example/relay/provision"
|
|
|
|
|
|
# ─────────────────────────── trigger logic ───────────────────────────
|
|
|
|
def test_provisions_on_nas_host_that_is_NOT_is_managed(monkeypatch):
|
|
"""Regression: a NAS-hosted Fly agent sets neither HERMES_MANAGED nor a
|
|
.managed marker, so is_managed() is False. Self-provision must STILL fire —
|
|
the old is_managed() gate silently no-oped exactly this case in staging.
|
|
"""
|
|
# Force is_managed() False to model a real hosted agent; it must be irrelevant.
|
|
monkeypatch.setattr("hermes_cli.config.is_managed", lambda: False)
|
|
_arm(monkeypatch)
|
|
captured: dict = {}
|
|
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
|
|
|
|
assert relay.self_provision_relay() is True
|
|
assert relay.relay_connection_auth()[1] == "a" * 64
|
|
|
|
|
|
def test_skips_when_relay_not_configured(monkeypatch):
|
|
_arm(monkeypatch, url=None)
|
|
called = {"n": 0}
|
|
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
|
|
assert relay.self_provision_relay() is False
|
|
assert called["n"] == 0
|
|
|
|
|
|
def test_skips_when_secret_already_pinned(monkeypatch):
|
|
"""A self-hosted, enrolled gateway has a pinned secret -> never self-provisions."""
|
|
_arm(monkeypatch)
|
|
monkeypatch.setenv("GATEWAY_RELAY_ID", "gw-pinned")
|
|
monkeypatch.setenv("GATEWAY_RELAY_SECRET", "deadbeef")
|
|
called = {"n": 0}
|
|
monkeypatch.setattr(relay, "_post_provision", lambda **k: called.__setitem__("n", called["n"] + 1) or {})
|
|
assert relay.self_provision_relay() is False
|
|
assert called["n"] == 0
|
|
# The pinned secret is untouched.
|
|
assert relay.relay_connection_auth() == ("gw-pinned", "deadbeef")
|
|
|
|
|
|
# ─────────────────────────── happy path ───────────────────────────
|
|
|
|
def test_provisions_and_sets_env_in_process(monkeypatch):
|
|
_arm(monkeypatch)
|
|
monkeypatch.setenv("GATEWAY_RELAY_ENDPOINT", "https://gw.example.com/inbound")
|
|
monkeypatch.setenv("GATEWAY_RELAY_ROUTE_KEYS", "guild-1,guild-2")
|
|
captured: dict = {}
|
|
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
|
|
|
|
assert relay.self_provision_relay() is True
|
|
# The connector POST carried the gateway-asserted endpoint + route keys.
|
|
assert captured["provision_url"] == "https://connector.example/relay/provision"
|
|
assert captured["access_token"] == "nas-token"
|
|
assert captured["gateway_endpoint"] == "https://gw.example.com/inbound"
|
|
assert captured["route_keys"] == ["guild-1", "guild-2"]
|
|
# Creds landed in os.environ (in-process), so register_relay_adapter() reads them.
|
|
gid, secret = relay.relay_connection_auth()
|
|
assert gid and secret == "a" * 64
|
|
# The delivery key is persisted in-process too (issued by the connector,
|
|
# kept for forward-compat; inbound rides the WS so it isn't consumed).
|
|
assert os.environ["GATEWAY_RELAY_DELIVERY_KEY"] == "b" * 64
|
|
|
|
|
|
def test_outbound_only_when_no_endpoint(monkeypatch):
|
|
_arm(monkeypatch)
|
|
captured: dict = {}
|
|
monkeypatch.setattr(relay, "_post_provision", _stub_post(captured))
|
|
|
|
assert relay.self_provision_relay() is True
|
|
assert captured["gateway_endpoint"] is None
|
|
assert captured["route_keys"] == []
|
|
assert relay.relay_connection_auth()[1] == "a" * 64
|
|
|
|
|
|
# ─────────────────────────── fail-soft ───────────────────────────
|
|
|
|
def test_no_nas_token_is_non_fatal(monkeypatch):
|
|
"""A self-hosted box with a relay URL but no resolvable NAS identity skips
|
|
quietly (this is the branch that replaces the old is_managed() gate for the
|
|
non-NAS case)."""
|
|
monkeypatch.setattr(relay, "relay_url", lambda: "wss://connector.example/relay")
|
|
|
|
def _boom():
|
|
raise RuntimeError("no token")
|
|
|
|
monkeypatch.setattr("hermes_cli.auth.resolve_nous_access_token", _boom)
|
|
# Must not raise; returns False; no creds set.
|
|
assert relay.self_provision_relay() is False
|
|
assert relay.relay_connection_auth() == (None, None)
|
|
|
|
|
|
def test_connector_failure_is_non_fatal(monkeypatch):
|
|
_arm(monkeypatch)
|
|
|
|
def _boom(**kwargs):
|
|
raise RuntimeError("connector returned HTTP 503")
|
|
|
|
monkeypatch.setattr(relay, "_post_provision", _boom)
|
|
assert relay.self_provision_relay() is False
|
|
assert relay.relay_connection_auth() == (None, None)
|