hermes-agent/tests/gateway/relay/test_ws_transport.py
Ben c93b9f9057
Some checks are pending
CI / detect (push) Waiting to run
CI / tests (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / typecheck (push) Blocked by required conditions
CI / docs-site (push) Blocked by required conditions
CI / history-check (push) Blocked by required conditions
CI / contributor-check (push) Blocked by required conditions
CI / uv-lockfile (push) Blocked by required conditions
CI / docker-lint (push) Blocked by required conditions
CI / supply-chain (push) Blocked by required conditions
CI / osv-scanner (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
feat(relay): terminal 4401 (opt-out) → clean "Relay disabled" state
Phase 7 Unit 7d-B. When an operator opts an instance OUT of the Team Gateway
relay (Unit 7b deprovision), the connector revokes the per-gateway secret and
closes the gateway's WS with 4401. The reconnect supervisor previously treated
EVERY close as retryable, so the live process spun "retrying 4401" forever and
the dashboard showed a red error — opt-out looked like a failure.

Now a 4401 close that arrives AFTER a successful handshake is recognized as a
terminal credential revocation:

- ws_transport.py: track `_handshake_succeeded` (set when a descriptor is
  received); on a 4401 close after a prior success, latch `auth_revoked` and do
  NOT spawn the reconnect supervisor. A 4401 BEFORE any successful handshake
  stays retryable (cold-start / not-yet-provisioned race, not a revocation).
  New `auth_revoked` property + a websockets-version-safe close-code reader
  (prefers `.rcvd`/`.sent` Close frames; `.code` is deprecated in websockets 13+).
- adapter.py: a revocation monitor turns `transport.auth_revoked` into a clean,
  NON-retryable `relay_disabled` fatal and notifies the gateway's fatal-error
  handler (so the adapter is removed and NOT queued for reconnection — the
  credential is dead until the instance is recreated). Monitor is cancelled on
  disconnect; only started when the transport exposes `auth_revoked` (prod WS).
- run.py: `_handle_adapter_fatal_error` maps the `relay_disabled` code to a
  `disabled` platform_state (not `fatal`/`retrying`).
- web: PlatformsCard renders the `disabled` state with a neutral outline badge,
  a PowerOff icon, and muted (not destructive-red) text + message. New optional
  `status.disabled` i18n string ("Disabled").

Also bundles the Phase 7 contract-doc update (this doc is authoritative in
hermes-agent): docs/relay-connector-contract.md gains an "Author-first
resolution + the account-link (DM) path" section documenting the
multi-tenant-guild rule (D-7.2 — route by authenticated author binding, never by
guild; unlinked → fail-closed), the `/link <code>` DM flow, and the
connector-authoritative opt-out + terminal-4401 behavior this PR implements.

Tests: +2 ws_transport (4401-after-handshake terminal / no-reconnect;
4401-before-handshake stays retryable) and +2 adapter (revocation → non-retryable
relay_disabled fatal + handler fired; no-revocation → no fatal). 138 relay tests
pass (incl. the contract-doc conformance test); ruff clean; web tsc clean.

Phase 7 Unit 7d-B (relay-adapter solo lane). Q17 → Option 2; Option 3 (live
de-register, no recreate) + the restart-re-provision hole deferred post-alpha.
2026-06-24 18:43:01 +10:00

298 lines
11 KiB
Python

"""WebSocketRelayTransport against a real in-process WebSocket server.
Exercises the production transport over an actual ``websockets`` server (no
mock socket): handshake (hello -> descriptor), inbound frame -> handler,
outbound request/response correlation, and follow_up routing. Proves the wire
framing (newline-delimited JSON) and the request/response future plumbing work
end to end on a live socket.
Skipped cleanly if the optional ``websockets`` dependency is absent.
"""
from __future__ import annotations
import asyncio
import json
import pytest
import pytest_asyncio
from gateway.relay.ws_transport import WebSocketRelayTransport, WEBSOCKETS_AVAILABLE
pytestmark = pytest.mark.skipif(not WEBSOCKETS_AVAILABLE, reason="websockets not installed")
if WEBSOCKETS_AVAILABLE:
import websockets
DESCRIPTOR = {
"contract_version": 1,
"platform": "discord",
"label": "Discord",
"max_message_length": 2000,
"supports_draft_streaming": False,
"supports_edit": True,
"supports_threads": True,
"markdown_dialect": "discord",
"len_unit": "chars",
}
class _StubConnectorServer:
"""Minimal connector: answers hello with a descriptor, echoes outbound."""
def __init__(self):
self.received: list[dict] = []
self._server = None
self.url = ""
# Push channel: tests set this to a frame dict to deliver inbound.
self._to_push: list[dict] = []
async def start(self):
self._server = await websockets.serve(self._handle, "127.0.0.1", 0)
sock = next(iter(self._server.sockets))
port = sock.getsockname()[1]
self.url = f"ws://127.0.0.1:{port}"
async def stop(self):
if self._server is not None:
self._server.close()
await self._server.wait_closed()
async def _handle(self, ws):
async for raw in ws:
for line in str(raw).split("\n"):
if not line.strip():
continue
frame = json.loads(line)
self.received.append(frame)
await self._on_frame(ws, frame)
async def _on_frame(self, ws, frame):
ftype = frame.get("type")
if ftype == "hello":
await ws.send(json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n")
# Deliver any queued inbound frames right after handshake.
for f in self._to_push:
await ws.send(json.dumps(f) + "\n")
elif ftype == "outbound":
action = frame.get("action", {})
# Echo a successful result correlated by requestId.
result = {"success": True, "message_id": f"srv-{action.get('op')}"}
await ws.send(
json.dumps({"type": "outbound_result", "requestId": frame["requestId"], "result": result})
+ "\n"
)
@pytest_asyncio.fixture
async def server():
srv = _StubConnectorServer()
await srv.start()
yield srv
await srv.stop()
@pytest.mark.asyncio
async def test_handshake_negotiates_descriptor(server):
t = WebSocketRelayTransport(server.url, "discord", "appShared")
await t.connect()
try:
desc = await t.handshake()
assert desc.platform == "discord"
assert desc.max_message_length == 2000
# The hello carried the platform + botId.
hello = next(f for f in server.received if f["type"] == "hello")
assert hello["platform"] == "discord"
assert hello["botId"] == "appShared"
finally:
await t.disconnect()
@pytest.mark.asyncio
async def test_inbound_frame_reaches_handler(server):
server._to_push = [
{
"type": "inbound",
"event": {
"text": "hello from connector",
"message_type": "text",
"source": {"platform": "discord", "chat_id": "chan1", "chat_type": "group", "guild_id": "guildA"},
},
"bufferId": "buf-1",
}
]
received = []
t = WebSocketRelayTransport(server.url, "discord", "appShared")
t.set_inbound_handler(lambda ev: received.append(ev) or asyncio.sleep(0))
await t.connect()
try:
await t.handshake()
# Give the reader a tick to deliver the pushed inbound frame.
await asyncio.sleep(0.05)
assert len(received) == 1
assert received[0].text == "hello from connector"
assert received[0].source.guild_id == "guildA"
finally:
await t.disconnect()
@pytest.mark.asyncio
async def test_outbound_round_trips_with_correlation(server):
t = WebSocketRelayTransport(server.url, "discord", "appShared")
await t.connect()
try:
await t.handshake()
result = await t.send_outbound({"op": "send", "chat_id": "chan1", "content": "hi"})
assert result["success"] is True
assert result["message_id"] == "srv-send"
finally:
await t.disconnect()
@pytest.mark.asyncio
async def test_follow_up_round_trips(server):
t = WebSocketRelayTransport(server.url, "discord", "appShared")
await t.connect()
try:
await t.handshake()
result = await t.send_follow_up(
{"op": "follow_up", "session_key": "s1", "kind": "discord.interaction_token", "content": "fu"}
)
assert result["success"] is True
assert result["message_id"] == "srv-follow_up"
# The follow_up rode an outbound frame the connector saw.
outbound = [f for f in server.received if f["type"] == "outbound"]
assert any(f["action"]["op"] == "follow_up" for f in outbound)
finally:
await t.disconnect()
@pytest.mark.asyncio
async def test_disconnect_fails_pending_waiters_cleanly(server):
t = WebSocketRelayTransport(server.url, "discord", "appShared", outbound_timeout_s=5)
await t.connect()
await t.handshake()
await t.disconnect()
# After disconnect, an outbound returns a structured failure rather than hanging.
result = await t.send_outbound({"op": "send", "chat_id": "c", "content": "x"})
assert result["success"] is False
def test_https_url_normalized_to_wss():
"""The relay URL is configured once as the http(s):// BASE (for the provision
POST), but websockets.connect needs ws(s):// and the connector mounts its WS
server at /relay. The transport must convert scheme AND ensure the /relay
path. Regression for the live staging failures 'scheme isn't ws or wss' then
'server rejected WebSocket connection: HTTP 400' (wrong path)."""
t = WebSocketRelayTransport("https://connector.example", "discord", "b")
assert t._url == "wss://connector.example/relay"
t2 = WebSocketRelayTransport("http://connector.local:8080", "discord", "b")
assert t2._url == "ws://connector.local:8080/relay"
def test_ws_dial_url_idempotent_with_scheme_and_path():
# Already ws(s):// and/or already ending in /relay -> unchanged (no double append).
t = WebSocketRelayTransport("wss://connector.example/relay", "discord", "b")
assert t._url == "wss://connector.example/relay"
t2 = WebSocketRelayTransport("https://connector.example/relay/", "discord", "b")
assert t2._url == "wss://connector.example/relay"
t3 = WebSocketRelayTransport("ws://127.0.0.1:9", "discord", "b")
assert t3._url == "ws://127.0.0.1:9/relay"
# ── Phase 7 Unit 7d-B: terminal 4401 (opt-out revocation) ────────────────────
class _Revoking4401Server:
"""Connector stub that, on hello, optionally sends a descriptor and then
closes the socket with application code 4401 (unauthorized) — the shape of a
connector that has revoked this gateway's per-gateway secret (opt-out)."""
def __init__(self, *, send_descriptor_first: bool):
self._server = None
self.url = ""
self._send_descriptor_first = send_descriptor_first
async def start(self):
self._server = await websockets.serve(self._handle, "127.0.0.1", 0)
port = next(iter(self._server.sockets)).getsockname()[1]
self.url = f"ws://127.0.0.1:{port}"
async def stop(self):
if self._server is not None:
self._server.close()
await self._server.wait_closed()
async def _handle(self, ws):
async for raw in ws:
for line in str(raw).split("\n"):
if not line.strip():
continue
frame = json.loads(line)
if frame.get("type") == "hello":
if self._send_descriptor_first:
await ws.send(
json.dumps({"type": "descriptor", "descriptor": DESCRIPTOR}) + "\n"
)
# Let the descriptor flush + be processed before the close.
await asyncio.sleep(0.05)
# Close with 4401 (the connector's "unauthorized" close).
await ws.close(code=4401, reason="unauthorized")
return
@pytest.mark.asyncio
async def test_4401_after_handshake_is_terminal_no_reconnect():
"""A 4401 close AFTER a successful handshake = a revoked credential (opt-out):
the transport latches auth_revoked and does NOT spin the reconnect supervisor."""
srv = _Revoking4401Server(send_descriptor_first=True)
await srv.start()
try:
t = WebSocketRelayTransport(
srv.url, "discord", "appShared",
gateway_id="gw-x", upgrade_secret="secret-x",
reconnect=True, reconnect_backoff_s=0.05,
)
await t.connect()
await t.handshake() # records _handshake_succeeded
# Wait for the server's 4401 close to propagate through the read loop.
for _ in range(100):
if t.auth_revoked:
break
await asyncio.sleep(0.02)
assert t.auth_revoked is True
# Terminal: no reconnect supervisor was spawned.
assert t._supervisor is None
# Give a reconnect (if it were going to happen) time to NOT happen.
await asyncio.sleep(0.2)
assert t._supervisor is None
finally:
await t.disconnect()
await srv.stop()
@pytest.mark.asyncio
async def test_4401_before_handshake_stays_retryable():
"""A 4401 close BEFORE any successful handshake is a cold-start / not-yet-
provisioned race, NOT a revocation: it stays retryable (reconnect runs)."""
srv = _Revoking4401Server(send_descriptor_first=False)
await srv.start()
try:
t = WebSocketRelayTransport(
srv.url, "discord", "appShared",
gateway_id="gw-x", upgrade_secret="secret-x",
reconnect=True, reconnect_backoff_s=0.05,
)
await t.connect()
# No handshake ever succeeded; the 4401 must NOT latch auth_revoked.
for _ in range(50):
if t._supervisor is not None:
break
await asyncio.sleep(0.02)
assert t.auth_revoked is False
# The reconnect supervisor IS running (retrying), since this is not terminal.
assert t._supervisor is not None
finally:
await t.disconnect()
await srv.stop()