Download scripts/probe_active_session_exclusivity.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 7.41 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/scripts/probe_active_session_exclusivity.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/scripts/probe_active_session_exclusivity.py
-
curl -L -o probe_active_session_exclusivity.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/scripts/probe_active_session_exclusivity.py
7.41 kB
| """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()) | |