The unit tests share one interpreter, so they cannot exercise the failure the
fence exists to prevent: two SEPARATE gateway processes, each holding its own
snapshot of a conversation, both writing to it. That is how the defect was found
and it is the only way to show it is closed.
This drives two real `python -m tui_gateway.entry` processes over stdio and
checks the whole sequence, including the parts that are easy to get wrong:
session.create claims nothing an idle composer must not hold a session
the lease keys on the STORED id a lease keyed on the runtime handle would
fence nothing, since two processes
resuming one conversation have different
runtime ids by construction
B may still RESUME reading is never fenced; only writing is
B's submit -> SESSION_NOT_OWNED typed, and the registry is unchanged
A killed, B retries -> accepted a dead owner is pruned, not permanent
No provider is needed. The fence is checked before the agent is built, so a
submit that later fails for want of a model still proves who owns the session --
which keeps the probe free of credentials and of inference cost.
Against the parent commit it stops at the second check with an empty registry,
which is the defect stated exactly: with no cap configured, nothing was recorded
and therefore nothing could be refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
"""Cross-process falsification of the per-session fence.
|
|
|
|
The unit tests share one interpreter, so they cannot see the failure this whole
|
|
change exists to prevent: two SEPARATE gateway processes, each with its own
|
|
snapshot of a conversation, both writing to it. That is how the defect was found
|
|
and it is the only way to prove it is closed.
|
|
|
|
Run against the fork's own HERMES_HOME so nothing here touches a real profile:
|
|
|
|
python scripts/probe_active_session_exclusivity.py
|
|
|
|
It drives two real ``python -m tui_gateway.entry`` processes over stdio JSON-RPC
|
|
and asserts the sequence the reviewer specified:
|
|
|
|
A resume S, submit -> claims the session
|
|
B resume S, submit -> typed SESSION_NOT_OWNED, no row, no turn
|
|
A exits -> its lease is pruned as a dead owner
|
|
B submit again -> succeeds
|
|
|
|
No provider is required. The fence is checked BEFORE the agent is built, so a
|
|
submit that later fails for want of a model still proves who owns the session --
|
|
which is the property under test, and keeps the probe free of credentials and of
|
|
inference cost.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
PYTHON = REPO / "venv" / "Scripts" / "python.exe"
|
|
if not PYTHON.exists(): # posix layout
|
|
PYTHON = REPO / "venv" / "bin" / "python"
|
|
|
|
|
|
class Gateway:
|
|
"""One gateway process, spoken to the way the TUI speaks to it."""
|
|
|
|
def __init__(self, name: str, home: Path):
|
|
env = dict(os.environ)
|
|
env["HERMES_HOME"] = str(home)
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
self.name = name
|
|
self.proc = subprocess.Popen(
|
|
[str(PYTHON), "-u", "-m", "tui_gateway.entry"],
|
|
cwd=str(REPO),
|
|
env=env,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
self._next = 1
|
|
self.ready()
|
|
|
|
def _read(self):
|
|
line = self.proc.stdout.readline()
|
|
if not line:
|
|
raise RuntimeError(f"[{self.name}] gateway closed its pipe")
|
|
line = line.strip()
|
|
if not line:
|
|
return None
|
|
try:
|
|
return json.loads(line)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def ready(self, timeout: float = 180.0) -> None:
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
msg = self._read()
|
|
if msg and msg.get("method") == "event":
|
|
if msg.get("params", {}).get("type") == "gateway.ready":
|
|
return
|
|
raise RuntimeError(f"[{self.name}] never announced gateway.ready")
|
|
|
|
def call(self, method: str, params: dict, timeout: float = 180.0) -> dict:
|
|
rid = str(self._next)
|
|
self._next += 1
|
|
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n")
|
|
self.proc.stdin.flush()
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
msg = self._read()
|
|
if msg and msg.get("id") == rid:
|
|
return msg
|
|
raise RuntimeError(f"[{self.name}] timed out calling {method}")
|
|
|
|
def close(self):
|
|
try:
|
|
self.proc.stdin.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.proc.terminate()
|
|
self.proc.wait(timeout=15)
|
|
except Exception:
|
|
try:
|
|
self.proc.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def reason_of(response: dict):
|
|
return (response.get("error") or {}).get("data", {}).get("reason")
|
|
|
|
|
|
def registry(home: Path):
|
|
path = home / "runtime" / "active_sessions.json"
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8")).get("entries", [])
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def main() -> int:
|
|
home = REPO / ".probe-home"
|
|
# A fresh profile each run: a lease left by a previous run would make the
|
|
# first check pass or fail for the wrong reason.
|
|
import shutil
|
|
|
|
shutil.rmtree(home, ignore_errors=True)
|
|
failures = []
|
|
|
|
def check(label: str, ok: bool, detail: str = ""):
|
|
print(f" {'PASS' if ok else 'FAIL'} {label}{(' -- ' + detail) if detail else ''}")
|
|
if not ok:
|
|
failures.append(label)
|
|
|
|
a = Gateway("A", home)
|
|
b = None
|
|
try:
|
|
created = a.call("session.create", {"cols": 80})
|
|
sid_a = created["result"]["session_id"]
|
|
|
|
# Opening a chat must not claim anything -- an idle composer is invisible
|
|
# and a slot held by one would fence a real turn for no reason.
|
|
check("session.create claims nothing", registry(home) == [], f"{len(registry(home))} entries")
|
|
|
|
a.call("prompt.submit", {"session_id": sid_a, "text": "probe: A takes the session"})
|
|
held = registry(home)
|
|
check("A's first turn claims a session", len(held) == 1, json.dumps(held)[:200])
|
|
if not held:
|
|
raise RuntimeError("A never claimed anything; nothing further can be tested")
|
|
|
|
# The STORED key, which only materialises when a turn is first submitted --
|
|
# and which is what the lease must be keyed on. A lease keyed on the live
|
|
# runtime id would fence nothing: two processes resuming one conversation
|
|
# have different runtime ids by construction.
|
|
key = held[0].get("session_id")
|
|
print(f"A live session {sid_a}, stored key {key}")
|
|
check("the lease is keyed on the STORED session, not the runtime handle",
|
|
bool(key) and key != sid_a, f"key={key} runtime={sid_a}")
|
|
|
|
b = Gateway("B", home)
|
|
resumed = b.call("session.resume", {"session_id": key})
|
|
check("B may still RESUME (reading is never fenced)", "result" in resumed,
|
|
json.dumps(resumed.get("error", ""))[:160])
|
|
sid_b = resumed.get("result", {}).get("session_id")
|
|
|
|
before = len(registry(home))
|
|
refused = b.call("prompt.submit", {"session_id": sid_b, "text": "probe: B must not write"})
|
|
check("B's submit is refused", refused.get("error") is not None,
|
|
json.dumps(refused.get("result", ""))[:120])
|
|
check("refusal is typed SESSION_NOT_OWNED", reason_of(refused) == "SESSION_NOT_OWNED",
|
|
str(reason_of(refused)))
|
|
check("refusal left the registry untouched", len(registry(home)) == before)
|
|
|
|
# A dies without releasing -- the crash case, not a clean handoff.
|
|
a.proc.kill()
|
|
a.proc.wait(timeout=30)
|
|
time.sleep(1.0)
|
|
|
|
retried = b.call("prompt.submit", {"session_id": sid_b, "text": "probe: B may write now"})
|
|
check("after A dies, B's retry is accepted", retried.get("error") is None,
|
|
json.dumps(retried.get("error", ""))[:200])
|
|
held = registry(home)
|
|
check("and B now owns the session", len(held) == 1 and held[0].get("session_id") == key,
|
|
json.dumps(held)[:160])
|
|
finally:
|
|
if b is not None:
|
|
b.close()
|
|
a.close()
|
|
|
|
print()
|
|
if failures:
|
|
print(f"FAILED: {len(failures)} check(s): {', '.join(failures)}")
|
|
return 1
|
|
print("All cross-process checks passed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|