Files
hermes-agent/tests/hermes_cli/test_display_ws_ticket.py
teknium1 22bb5d8054 fix(bot-screen): ticket and desktop-gone refusals reach the viewer as close frames
/api/display/ws closed with 4401 (bad/used ticket) and 4001 (desktop not
running / socket unreachable) BEFORE ws.accept(). A close before accept is an
HTTP 403 handshake failure in the browser: no close code, so the renderer's
raw-close listener (which reads 4000 control-taken today) never saw WHY and
could not choose between re-observing for a fresh ticket and showing "screen
is gone". The route now accepts right after the Host/Origin/client policy
check, then refuses with code + reason inside a real close frame.

The policy reject (4403) stays before accept, like every other dashboard
websocket route: a cross-origin page must never complete the handshake.

Test (Starlette TestClient): a consumed ticket yields a completed handshake and
a WebSocketDisconnect with code 4401 on the first read, not a handshake error.
2026-09-13 05:51:47 -07:00

63 lines
2.9 KiB
Python

"""The display bridge admits only a display ticket minted for THIS profile's socket: a gateway ticket,
an expired ticket, or one for another provider is refused before any socket is dialled."""
from __future__ import annotations
from hermes_cli.dashboard_auth import ws_tickets
from hermes_cli.web_routers import display
class _Ws:
def __init__(self, **params):
self.query_params = params
def test_display_ticket_must_be_a_bot_desktop_ticket_pinned_to_a_profile_home(monkeypatch):
ws_tickets._reset_for_tests()
gateway_ticket = ws_tickets.mint_ticket(user_id="u", provider="google")
assert display._consume_display_ticket(_Ws(display_ticket=gateway_ticket)) is None
unpinned = ws_tickets.mint_ticket(user_id="display:v", provider="bot-desktop")
assert display._consume_display_ticket(_Ws(display_ticket=unpinned)) is None
good = ws_tickets.mint_ticket(user_id="display:v", provider="bot-desktop",
extra={"hermes_home": "/srv/hermes/bot-a", "viewer_id": "v"})
info = display._consume_display_ticket(_Ws(display_ticket=good))
assert info and info["hermes_home"] == "/srv/hermes/bot-a" and info["viewer_id"] == "v"
assert display._consume_display_ticket(_Ws(display_ticket=good)) is None, "single use"
def test_a_bad_ticket_is_refused_with_a_close_frame_the_renderer_can_read(monkeypatch):
"""Closing BEFORE accept surfaces to a browser as an HTTP 403 handshake failure with no close
code; the renderer's close listener never learns WHY (4401 = ticket, 4001 = desktop gone) and
cannot pick between re-observing and showing "not running". The handshake must complete and
the reason travel in the close frame."""
import pytest
from starlette.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from hermes_cli import web_server
ws_tickets._reset_for_tests()
prev = {k: getattr(web_server.app.state, k, None) for k in ("auth_required", "bound_host")}
web_server.app.state.auth_required = False
web_server.app.state.bound_host = None
client = TestClient(web_server.app)
try:
used = ws_tickets.mint_ticket(user_id="display:v", provider="bot-desktop",
extra={"hermes_home": "/srv/hermes/bot-a", "viewer_id": "v"})
ws_tickets.consume_ticket(used)
with client.websocket_connect(f"/api/display/ws?display_ticket={used}") as conn: # handshake completes
with pytest.raises(WebSocketDisconnect) as exc:
conn.receive_bytes()
assert exc.value.code == 4401
finally:
client.close()
ws_tickets._reset_for_tests()
for k, v in prev.items():
if v is None:
if hasattr(web_server.app.state, k):
delattr(web_server.app.state, k)
else:
setattr(web_server.app.state, k, v)