"""Discover a cooperative local runtime without taking its session lease. The owner's handshake supplies the existing authenticated WebSocket URL. A registry entry is discovery information, not authority to mint a credential. """ from __future__ import annotations import ipaddress import json from pathlib import Path from urllib.parse import urlencode, urlsplit import httpx from hermes_constants import get_hermes_home from hermes_cli.active_sessions import active_session_registry_snapshot, session_owner_details def _local_origin(url: str, scheme: str) -> tuple[str, int]: parts = urlsplit(url) host = parts.hostname or "" try: local = ipaddress.ip_address(host).is_loopback except ValueError: local = False if (parts.scheme != scheme or not local or parts.username is not None or parts.password is not None or parts.fragment or not parts.port): raise ValueError("Shared runtime endpoint must be an explicit loopback address and port.") return host, parts.port def discover_attach_url(session_id: str, *, registry_home: str | Path | None = None) -> str | None: """Return a fenced authenticated URL, None for no owner, or refuse safely. This deliberately does not scan ports or read another profile. The runtime must advertise ``metadata.shared_runtime_url`` and implement the local ``/api/session-attach`` handshake. Unsupported owners keep their lease. """ home = Path(registry_home if registry_home is not None else get_hermes_home()).resolve() owners = [entry for entry in active_session_registry_snapshot(home, strict=True) if entry.get("session_id") == session_id] if not owners: return None if len(owners) != 1: raise ValueError("Session owner identity is ambiguous; no attachment was attempted.") owner = owners[0] endpoint = (owner.get("metadata") or {}).get("shared_runtime_url") if not isinstance(endpoint, str) or not endpoint: raise ValueError("This chat is open in another Hermes window/terminal, and attaching " "this terminal to it is not available in this build. Close the chat " "there and run hermes --resume " + session_id + " here to take it over.\n" + session_owner_details(session_id, owner)) origin = _local_origin(endpoint, "http") parts = urlsplit(endpoint) if parts.path not in ("", "/") or parts.query: raise ValueError("Shared runtime endpoint must be an origin without a path or query.") query = urlencode({"session_id": session_id, "lease_id": owner["lease_id"], "profile_home": str(home)}) try: # Ignore proxy env and redirects: local discovery must stay on the # advertised endpoint, including on machines with corporate proxies. with httpx.Client(trust_env=False, follow_redirects=False, timeout=3.0) as client: with client.stream("GET", endpoint.rstrip("/") + "/api/session-attach?" + query) as response: response.raise_for_status() body = bytearray() for chunk in response.iter_bytes(): body.extend(chunk) if len(body) > 65536: raise ValueError("Shared runtime handshake response is too large.") reply = json.loads(body) except (httpx.HTTPError, json.JSONDecodeError) as exc: # Never include a remote body or authenticated URL in diagnostics. raise ValueError("This chat is open in another Hermes window/terminal, and attaching " "this terminal to it just failed. Use the chat where it is open, or " "close it there and run hermes --resume " + session_id + " here.\n" + session_owner_details(session_id, owner)) from exc if not isinstance(reply, dict) or any(reply.get(key) != value for key, value in { "session_id": session_id, "lease_id": owner["lease_id"], "profile_home": str(home), }.items()): raise ValueError("Shared runtime handshake identity does not match the requested owner.") websocket_url = reply.get("websocket_url") if (not isinstance(websocket_url, str) or _local_origin(websocket_url, "ws") != origin or urlsplit(websocket_url).path != "/api/ws"): raise ValueError("Shared runtime handshake returned a different endpoint.") return websocket_url def configure_tui_attachment(env: dict[str, str], session_id: str | None, *, registry_home: str | Path | None = None) -> None: """Retain an explicit transport, otherwise attach a resumed owner's runtime.""" if not session_id or env.get("HERMES_TUI_GATEWAY_URL", "").strip(): return url = discover_attach_url(session_id, registry_home=registry_home) if url is not None: env["HERMES_TUI_GATEWAY_URL"] = url