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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
91
hermes_cli/shared_session_attach.py
Normal file
91
hermes_cli/shared_session_attach.py
Normal 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
|
||||
13
tests/hermes_cli/test_shared_owner_message.py
Normal file
13
tests/hermes_cli/test_shared_owner_message.py
Normal 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
|
||||
87
tests/hermes_cli/test_shared_session_attach.py
Normal file
87
tests/hermes_cli/test_shared_session_attach.py
Normal 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()
|
||||
Reference in New Issue
Block a user