mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(relay): terminal 4401 (opt-out) → clean "Relay disabled" state
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
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
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.
This commit is contained in:
parent
3c75e11571
commit
c93b9f9057
9 changed files with 367 additions and 8 deletions
|
|
@ -140,3 +140,61 @@ async def test_send_preserves_explicit_guild_id():
|
|||
a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
|
||||
await a.send("chan-1", "hi", metadata={"guild_id": "explicit-1"})
|
||||
assert t.sent["metadata"]["guild_id"] == "explicit-1"
|
||||
|
||||
|
||||
# ── Phase 7 Unit 7d-B: terminal auth revocation → clean "relay disabled" ─────
|
||||
|
||||
|
||||
class _RevokedTransport:
|
||||
"""Transport stand-in that reports a terminal auth revocation (the
|
||||
production WebSocketRelayTransport latches this after a 4401 close that
|
||||
follows a successful handshake)."""
|
||||
|
||||
def __init__(self):
|
||||
self.auth_revoked = True
|
||||
|
||||
def set_inbound_handler(self, h): # noqa: D401
|
||||
self._h = h
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revocation_marks_relay_disabled_non_retryable():
|
||||
"""When the transport reports auth_revoked, the adapter surfaces a clean,
|
||||
NON-retryable `relay_disabled` fatal and fires the fatal-error handler."""
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=_RevokedTransport())
|
||||
notified = []
|
||||
a.set_fatal_error_handler(lambda adapter: notified.append(adapter))
|
||||
|
||||
# Drive the monitor body directly (poll loop breaks immediately on the
|
||||
# already-revoked transport).
|
||||
await a._watch_for_revocation(poll_interval_s=0.01)
|
||||
|
||||
assert a.has_fatal_error is True
|
||||
assert a.fatal_error_code == "relay_disabled"
|
||||
assert a.fatal_error_retryable is False
|
||||
assert "disabled" in (a.fatal_error_message or "").lower()
|
||||
assert notified == [a]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_revocation_no_fatal():
|
||||
"""A transport that has NOT been revoked never trips the disabled fatal."""
|
||||
|
||||
class _LiveTransport:
|
||||
auth_revoked = False
|
||||
|
||||
def set_inbound_handler(self, h): # noqa: D401
|
||||
self._h = h
|
||||
|
||||
a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=_LiveTransport())
|
||||
# Run the monitor with a tiny window then cancel — it should never fire.
|
||||
import asyncio
|
||||
|
||||
task = asyncio.create_task(a._watch_for_revocation(poll_interval_s=0.01))
|
||||
await asyncio.sleep(0.05)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert a.has_fatal_error is False
|
||||
|
|
|
|||
|
|
@ -199,3 +199,100 @@ def test_ws_dial_url_idempotent_with_scheme_and_path():
|
|||
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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue