Files
hermes-agent/hermes_cli/shared_session_attach.py
Konstantin Khlopkov 4b39c5558a fix(cli): name the real state when cooperative attach is unavailable
The attach-refusal message framed an unimplemented capability as a property
of the live owner ("does not advertise cooperative attachment"), sending users
looking for a setting that does not exist. Follow the shared owner-refusal
contract instead: first line states the chat is open elsewhere and names the
working alternative (close it there, hermes --resume <id> here), second line
keeps the Details: owner summary. Same shape when the handshake itself fails.

Fixes #113457

(cherry picked from commit 78966577102f3db1383a162f526394d8af757a38)
2026-09-18 10:45:07 -07:00

96 lines
4.9 KiB
Python

"""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