From 8b2ef359d168835c8ac1ae73d2f4edf771ee3d0e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:21:41 -0700 Subject: [PATCH] feat(tui): discover cooperative local session owners Fence owner discovery by profile, lease and loopback endpoint, and hand the authenticated URL to the existing Ink transport without acquiring a competing lease. Distinguish lease age from turn activity in unsupported-owner recovery. Client slice only: requires the integration runtime to advertise shared_runtime_url and provide the session-attach handshake. Classic CLI attachment remains an integration gap. --- hermes_cli/active_sessions.py | 13 +-- hermes_cli/main_tui_launch.py | 6 ++ hermes_cli/shared_session_attach.py | 91 +++++++++++++++++++ tests/hermes_cli/test_shared_owner_message.py | 13 +++ .../hermes_cli/test_shared_session_attach.py | 87 ++++++++++++++++++ 5 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 hermes_cli/shared_session_attach.py create mode 100644 tests/hermes_cli/test_shared_owner_message.py create mode 100644 tests/hermes_cli/test_shared_session_attach.py diff --git a/hermes_cli/active_sessions.py b/hermes_cli/active_sessions.py index a4ddf414ea..253b633fe8 100644 --- a/hermes_cli/active_sessions.py +++ b/hermes_cli/active_sessions.py @@ -151,11 +151,12 @@ def session_already_owned_message(session_id: str, entry: dict[str, Any]) -> str surface = str(entry.get("surface") or "another surface") pid = entry.get("pid") started = _optional_float(entry.get("started_at")) - age = f", running {format_age(time.time() - started)}" if started else "" + age = f", lease age {format_age(time.time() - started)}" if started else "" return ( f"Session {session_id} already has a live owner ({surface}, pid {pid}{age}). " - "Only one surface at a time may run a session, because a second one would " - "reason from a transcript that does not include the first one's work." + "Its turn activity is unknown; an open lease does not mean a turn is running. " + "Attach through a compatible owner, or close the session in its owning surface " + "before resuming here. Do not delete a live owner's lease to force a takeover." ) @@ -665,13 +666,13 @@ def release_orphaned_leases(live_lease_ids: set[str]) -> int: def active_session_registry_snapshot( - registry_home: str | Path | None = None, + registry_home: str | Path | None = None, *, strict: bool = False, ) -> list[dict[str, Any]]: - """Return the pruned active-session registry for diagnostics/tests.""" + """Return live leases; attachment callers require provable liveness.""" state_path, lock_path = _lease_paths(registry_home=registry_home) with _FileLock(lock_path): raw_entries = _read_entries(state_path, strict=True) - entries = _prune_dead(raw_entries) + entries = _prune_dead(raw_entries, strict=strict) if entries != raw_entries: _write_entries(state_path, entries) return entries diff --git a/hermes_cli/main_tui_launch.py b/hermes_cli/main_tui_launch.py index 2a3d238128..781f161102 100644 --- a/hermes_cli/main_tui_launch.py +++ b/hermes_cli/main_tui_launch.py @@ -724,6 +724,12 @@ def _launch_tui( # the single factory; keep secrets (the TUI/agent needs provider creds). from tools.environments.local import build_subprocess_env env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) + from hermes_cli.shared_session_attach import configure_tui_attachment + try: + configure_tui_attachment(env, resume_session_id) + except (ValueError, RuntimeError) as exc: + print(f"Error: {exc}", file=sys.stderr) + raise SystemExit(1) from None try: from hermes_cli.config import apply_terminal_config_to_env apply_terminal_config_to_env(env=env) diff --git a/hermes_cli/shared_session_attach.py b/hermes_cli/shared_session_attach.py new file mode 100644 index 0000000000..9282c08f7b --- /dev/null +++ b/hermes_cli/shared_session_attach.py @@ -0,0 +1,91 @@ +"""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_already_owned_message + + +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("The live owner does not advertise cooperative attachment. " + + session_already_owned_message(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("The live owner could not authorize cooperative attachment; " + "its lease was left intact.") 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 diff --git a/tests/hermes_cli/test_shared_owner_message.py b/tests/hermes_cli/test_shared_owner_message.py new file mode 100644 index 0000000000..ab53a137ce --- /dev/null +++ b/tests/hermes_cli/test_shared_owner_message.py @@ -0,0 +1,13 @@ +"""Ownership age is not evidence that a model turn is running.""" + +from hermes_cli.active_sessions import session_already_owned_message + + +def test_owner_refusal_distinguishes_lease_age_from_turn_activity(): + message = session_already_owned_message("session", { + "surface": "desktop", "pid": 123, "started_at": 1, + }) + assert "lease age" in message + assert "turn activity is unknown" in message + assert "close the session in its owning surface" in message + assert "running " not in message diff --git a/tests/hermes_cli/test_shared_session_attach.py b/tests/hermes_cli/test_shared_session_attach.py new file mode 100644 index 0000000000..abff77b0ec --- /dev/null +++ b/tests/hermes_cli/test_shared_session_attach.py @@ -0,0 +1,87 @@ +"""Local owner discovery must fence profile and lease identity.""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from hermes_cli.active_sessions import try_acquire_active_session + + +def test_discovery_uses_exact_profile_and_owner_handshake(tmp_path): + from hermes_cli.shared_session_attach import discover_attach_url + + home = tmp_path / "profile" + other = tmp_path / "other" + reply = {} + requests = [] + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + payload = json.dumps(reply).encode() + self.send_response(200) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + origin = f"http://127.0.0.1:{server.server_port}" + lease, error = try_acquire_active_session( + session_id="same-id", surface="desktop", config={}, registry_home=home, + metadata={"live_session_id": "live", "shared_runtime_url": origin}, + ) + assert error is None + reply.update(session_id="same-id", lease_id=lease.lease_id, + profile_home=str(home.resolve()), websocket_url=origin.replace("http:", "ws:") + "/api/ws?token=real-token") + try: + assert discover_attach_url("same-id", registry_home=other) is None + assert requests == [] + assert discover_attach_url("same-id", registry_home=home) == reply["websocket_url"] + assert len(requests) == 1 + from hermes_cli.shared_session_attach import configure_tui_attachment + env = {"HERMES_TUI_GATEWAY_URL": " "} + configure_tui_attachment(env, "same-id", registry_home=home) + assert env["HERMES_TUI_GATEWAY_URL"] == reply["websocket_url"] + reply["profile_home"] = str(other.resolve()) + with pytest.raises(ValueError, match="identity"): + discover_attach_url("same-id", registry_home=home) + reply["profile_home"] = str(home.resolve()) + reply["websocket_url"] = "ws://example.com/api/ws?token=secret" + with pytest.raises(ValueError, match="endpoint"): + discover_attach_url("same-id", registry_home=home) + finally: + lease.release() + server.shutdown() + server.server_close() + thread.join() + + +def test_discovery_refuses_unsupported_owner_without_releasing_lease(tmp_path, monkeypatch): + from hermes_cli.shared_session_attach import discover_attach_url + from hermes_cli.active_sessions import active_session_registry_snapshot + + lease, error = try_acquire_active_session( + session_id="old", surface="desktop", config={}, registry_home=tmp_path, + ) + assert error is None + try: + with pytest.raises(ValueError, match="does not advertise"): + discover_attach_url("old", registry_home=tmp_path) + assert active_session_registry_snapshot(tmp_path)[0]["lease_id"] == lease.lease_id + registry = tmp_path / "runtime" / "active_sessions.json" + before = registry.read_bytes() + with monkeypatch.context() as patch: + def denied(pid): + raise PermissionError("process inspection denied") + patch.setattr("gateway.status._pid_exists", denied) + with pytest.raises(RuntimeError, match="liveness is unknown"): + discover_attach_url("old", registry_home=tmp_path) + assert registry.read_bytes() == before + finally: + lease.release()