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.
This commit is contained in:
Teknium
2026-09-07 21:21:41 -07:00
parent cae1c0c4a9
commit 8b2ef359d1
5 changed files with 204 additions and 6 deletions

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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()