Conflict resolutions and semantic fixups:
- tools/environments/base.py: main's hard-exit kill fence (kill a spawn the
fence missed, deregister from _live_foreground in a finally) wrapped around
pm-clean's output collector.
- pyproject.toml: pm-clean's marker list plus main's new `live` marker.
- hermes_cli/main.py: pm-clean runs startup recovery from hermes_bootstrap, so
the old early-recovery block stays gone; main's interrupted-pull restore
(auto-merged above it) runs right after bootstrap, as on main.
- hermes_cli/update_cmd.py: main's interrupted-pull marker now guards
pm-clean's first tree mutation (release-tag detach, ff-only, or reconcile)
and is cleared once git is done. The marker's target is the ref git actually
moves to (a release tag, not always origin/<branch>), since the restore
compares against it.
- hermes_cli/_early_recovery.py: restore `import subprocess`, which pm-clean
had dropped and main's auto-merged restore needs (NameError on the first
launch after a killed update; test_update_interrupted_pull red -> green).
- apps/desktop/src/i18n/{de,es,fr}.ts: main's new locales carry the full
settings.about block; trim it to `updates` as pm-clean's type and the other
overlays do (tsc: 27 errors -> 0).
- main's new e2e tests: `import yaml` -> hermes_yaml; wake-word import table
names pyopen_wakeword (pm-clean's wake-openwakeword extra); the anthropic
key-leak switch leg needs the SDK, and the api_server two-tenant test needs
aiohttp, both PM runtime extras the test env does not carry.
548 lines
24 KiB
Python
548 lines
24 KiB
Python
"""Lane-private harness for the C7 two-tenant canary suites.
|
|
|
|
Every tenant (profile) owns a set of random canaries: its provider key (same env var NAME in every
|
|
profile, different value), its API-server key, a non-secret ``.env`` marker, its model id, memory
|
|
text, SOUL text, terminal cwd and a cron prompt. Each tenant also owns its own loopback provider
|
|
(``FakeLLMServer``) that accepts only its own key, so every request the provider records is proof of
|
|
WHO sent it. The invariant shared by every scenario is ``leaks(...) == []``: no tenant's canary may
|
|
appear in another tenant's provider request, tool-subprocess env snapshot, or on-disk file.
|
|
|
|
Hermes runs for real in child processes with HOME=<tmp>/home and HERMES_HOME=<tmp>/home/.hermes
|
|
(profiles resolve under $HOME, never the real install), every credential env var stripped.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Iterable
|
|
|
|
import hermes_yaml as yaml
|
|
|
|
from tests.fakes.fake_llm_provider import FakeLLMServer, Response, Text, ToolCall
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
ENV_PROBE = "echo SNAPSHOT-BEGIN; env | sort; echo CWD=$(pwd); echo SNAPSHOT-END"
|
|
PROVIDER_KEY_ENV = "TENANT_PROVIDER_KEY" # same NAME in every profile's .env, distinct VALUE
|
|
|
|
class TenantLeak(AssertionError):
|
|
"""A provider request, tool subprocess, file, RPC frame or log carried another tenant's canary."""
|
|
|
|
|
|
class LaunchProfileBleed(AssertionError):
|
|
"""A secondary profile's session reported the launch profile's model, or a settings write
|
|
addressed to one profile changed another profile's files."""
|
|
|
|
|
|
_STRIP_SUFFIXES = ("_API_KEY", "_TOKEN", "_BASE_URL", "_SECRET", "_ACCESS_KEY", "_KEY_ID", "_KEY")
|
|
_STRIP_PREFIXES = ("HERMES_", "OPENAI", "ANTHROPIC", "OPENROUTER", "AWS_", "AZURE_", "GOOGLE_", "GEMINI",
|
|
"PYTEST_", "NOUS_", "XAI_", "LLM_", "CUSTOM_", "TERMINAL_", "TENANT_", "API_SERVER_")
|
|
|
|
|
|
def real_user_home() -> Path:
|
|
import pwd
|
|
return Path(pwd.getpwuid(os.getuid()).pw_dir).resolve()
|
|
|
|
|
|
def hermetic_env(home: Path, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
"""Child env: fake HOME (profile root anchor) + HERMES_HOME under it, nothing credential-shaped."""
|
|
home = home.resolve()
|
|
assert home != real_user_home() and home / ".hermes" != real_user_home() / ".hermes", home
|
|
env = {k: v for k, v in os.environ.items()
|
|
if not (k.endswith(_STRIP_SUFFIXES) or k.startswith(_STRIP_PREFIXES))}
|
|
for var in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "XDG_STATE_HOME",
|
|
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
|
|
# no route to the developer's systemd --user bus: a child can never see or touch the
|
|
# live hermes-gateway unit, whatever it decides about service management.
|
|
"DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"):
|
|
env.pop(var, None)
|
|
env.update(
|
|
HOME=str(home),
|
|
HERMES_HOME=str(home / ".hermes"),
|
|
XDG_STATE_HOME=str(home / ".local" / "state"),
|
|
PYTHONPATH=str(REPO_ROOT),
|
|
NO_COLOR="1",
|
|
TERM="dumb",
|
|
NO_PROXY="127.0.0.1,localhost",
|
|
no_proxy="127.0.0.1,localhost",
|
|
# The live-DB guard treats $HOME/.hermes/state.db of a pytest descendant as production;
|
|
# this HOME is the test's own tmp dir (asserted above).
|
|
HERMES_STATE_DB_GUARD_BYPASS="1",
|
|
HERMES_ACCEPT_HOOKS="1",
|
|
)
|
|
env.update(extra or {})
|
|
return env
|
|
|
|
|
|
def free_port() -> int:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
def poll(pred: Callable[[], Any], timeout: float, what: str, interval: float = 0.1) -> Any:
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
got = pred()
|
|
if got:
|
|
return got
|
|
if time.monotonic() > deadline:
|
|
raise AssertionError(f"timed out after {timeout}s waiting for {what}")
|
|
time.sleep(interval)
|
|
|
|
|
|
# Tenants -------------------------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Tenant:
|
|
name: str
|
|
home: Path # the profile's HERMES_HOME
|
|
workdir: Path
|
|
tag: str = field(default_factory=lambda: secrets.token_hex(5))
|
|
srv: FakeLLMServer | None = None
|
|
extra: dict[str, str] = field(default_factory=dict) # canaries minted mid-scenario (RPC writes)
|
|
|
|
# canaries (each embeds the random tag, so a hit can never be a coincidence)
|
|
@property
|
|
def provider_key(self) -> str:
|
|
return f"sk-prov-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def api_server_key(self) -> str:
|
|
return f"apisrv-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def env_marker(self) -> str:
|
|
return f"envmark-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def model(self) -> str:
|
|
return f"model-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def memory(self) -> str:
|
|
return f"memory-canary-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def soul(self) -> str:
|
|
return f"soul-canary-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def shell_state(self) -> str:
|
|
"""Exported into the tool shell AFTER each env dump: a shell reused by another tenant shows it."""
|
|
return f"shellstate-{self.name}-{self.tag}"
|
|
|
|
@property
|
|
def cron_prompt(self) -> str:
|
|
return f"cron-canary-{self.name}-{self.tag}: run the environment probe"
|
|
|
|
def canaries(self) -> dict[str, str]:
|
|
return {
|
|
"provider_key": self.provider_key, "api_server_key": self.api_server_key,
|
|
"env_marker": self.env_marker, "model": self.model, "memory": self.memory, "soul": self.soul,
|
|
"workdir": str(self.workdir), "cron_prompt": self.cron_prompt.split(":")[0],
|
|
"shell_state": self.shell_state, **self.extra,
|
|
}
|
|
|
|
def secrets(self) -> dict[str, str]:
|
|
return {"provider_key": self.provider_key, "api_server_key": self.api_server_key}
|
|
|
|
|
|
def _responder(t: Tenant) -> Callable[[dict[str, Any]], Response]:
|
|
"""Every user turn runs the env probe in the terminal tool; the follow-up answers with text."""
|
|
def respond(record: dict[str, Any]) -> Response:
|
|
msgs = record["body"].get("messages") or []
|
|
last = msgs[-1] if msgs else {}
|
|
if last.get("role") == "tool":
|
|
return Text(f"probe done for {t.name}")
|
|
return ToolCall("terminal", {"command": f"{ENV_PROBE}; export TENANT_SHELL_STATE={t.shell_state}"})
|
|
return respond
|
|
|
|
|
|
def make_tenants(root: Path, names: Iterable[str], launch: str = "default") -> dict[str, Tenant]:
|
|
"""Build tenant homes on disk: launch profile at HOME/.hermes, the rest under profiles/<name>."""
|
|
hermes_home = root / "home" / ".hermes"
|
|
tenants: dict[str, Tenant] = {}
|
|
for name in names:
|
|
home = hermes_home if name == launch else hermes_home / "profiles" / name
|
|
t = Tenant(name=name, home=home, workdir=root / "work")
|
|
t.workdir = root / f"work-{name}-{t.tag}"
|
|
t.workdir.mkdir(parents=True)
|
|
t.srv = FakeLLMServer(_responder(t), api_key=t.provider_key)
|
|
tenants[name] = t
|
|
return tenants
|
|
|
|
|
|
def write_tenant_home(t: Tenant, extra_config: dict[str, Any] | None = None,
|
|
extra_env: dict[str, str] | None = None) -> None:
|
|
assert t.srv is not None
|
|
t.home.mkdir(parents=True, exist_ok=True)
|
|
cfg: dict[str, Any] = {
|
|
"model": {"provider": "custom", "base_url": t.srv.base_url, "default": t.model,
|
|
"key_env": PROVIDER_KEY_ENV, "context_length": 128000},
|
|
"agent": {"api_max_retries": 1},
|
|
"terminal": {"backend": "local", "cwd": str(t.workdir)},
|
|
"memory": {"memory_enabled": True},
|
|
"compression": {"enabled": False},
|
|
}
|
|
for k, v in (extra_config or {}).items():
|
|
cfg[k] = {**cfg.get(k, {}), **v} if isinstance(v, dict) and isinstance(cfg.get(k), dict) else v
|
|
(t.home / "config.yaml").write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
|
|
env = {PROVIDER_KEY_ENV: t.provider_key, "API_SERVER_KEY": t.api_server_key, "TENANT_MARKER": t.env_marker,
|
|
**(extra_env or {})}
|
|
(t.home / ".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()), encoding="utf-8")
|
|
(t.home / "memories").mkdir(exist_ok=True)
|
|
(t.home / "memories" / "MEMORY.md").write_text(f"{t.memory}\n", encoding="utf-8")
|
|
(t.home / "SOUL.md").write_text(f"You are {t.soul}.\n", encoding="utf-8")
|
|
|
|
|
|
def assert_profiles_root_under(root: Path, home: Path) -> None:
|
|
"""The profile root is HOME-anchored: prove it resolves inside ``root`` before any write."""
|
|
probe = subprocess.run(
|
|
[sys.executable, "-c", "from hermes_cli.profiles import _get_profiles_root as r; print(r())"],
|
|
env=hermetic_env(home), cwd=str(home), capture_output=True, text=True, timeout=120,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
assert probe.returncode == 0, probe.stderr[-2000:]
|
|
got = Path(probe.stdout.strip().splitlines()[-1]).resolve()
|
|
assert str(got).startswith(str(root.resolve())), f"profiles root escaped the sandbox: {got}"
|
|
|
|
|
|
# Invariants ----------------------------------------------------------------------------------------
|
|
|
|
|
|
def _bearer(record: dict[str, Any]) -> str:
|
|
auth = record.get("auth") or ""
|
|
return auth[7:] if auth.lower().startswith("bearer ") else auth
|
|
|
|
|
|
def request_leaks(tenants: dict[str, Tenant]) -> list[str]:
|
|
"""Every provider request carries its own tenant's key/model and no other tenant's canary."""
|
|
problems: list[str] = []
|
|
for t in tenants.values():
|
|
assert t.srv is not None
|
|
for i, r in enumerate(list(t.srv.requests)):
|
|
where = f"provider[{t.name}] request #{i} ({r['kind']} {r['path']})"
|
|
if _bearer(r) != t.provider_key:
|
|
owner = next((u.name for u in tenants.values() if u.provider_key == _bearer(r)), "<none>")
|
|
problems.append(f"{where}: Authorization is {owner!r}'s key, not {t.name!r}'s")
|
|
if r["kind"] == "main" and r["body"].get("model") != t.model:
|
|
problems.append(f"{where}: model {r['body'].get('model')!r} != {t.model!r}")
|
|
blob = json.dumps(r["body"]) + json.dumps(r.get("headers") or {})
|
|
problems += [f"{where}: carries {u.name}'s {kind} ({value})"
|
|
for u in tenants.values() if u is not t
|
|
for kind, value in u.canaries().items() if value in blob]
|
|
return problems
|
|
|
|
|
|
def env_snapshots(t: Tenant) -> list[tuple[str, str]]:
|
|
"""``(origin, output)`` of every ENV_PROBE terminal call as sent back to the tenant's provider;
|
|
origin is ``cron job`` when the conversation is the tenant's cron prompt, else ``turn``."""
|
|
assert t.srv is not None
|
|
seen: dict[str, tuple[str, str]] = {} # tool_call_id -> (origin, output); results are re-sent later
|
|
cron_mark = t.cron_prompt.split(":")[0]
|
|
for r in list(t.srv.requests):
|
|
origin = "cron job" if cron_mark in json.dumps(r["body"].get("messages") or []) else "turn"
|
|
for m in r["body"].get("messages") or []:
|
|
content = m.get("content")
|
|
text = content if isinstance(content, str) else json.dumps(content)
|
|
if m.get("role") == "tool" and "SNAPSHOT-BEGIN" in text:
|
|
seen.setdefault(str(m.get("tool_call_id") or text), (origin, text))
|
|
return list(seen.values())
|
|
|
|
|
|
def snapshot_shortfall(tenants: dict[str, Tenant], min_per_tenant: int = 1) -> list[str]:
|
|
"""Tenants whose provider received fewer env snapshots than the scenario drove (a harness or
|
|
liveness failure, not a leak)."""
|
|
return [f"{t.name}: {n} env snapshot(s) reached its provider, expected >= {min_per_tenant}"
|
|
for t in tenants.values() if (n := len(env_snapshots(t))) < min_per_tenant]
|
|
|
|
|
|
def snapshot_problems(tenants: dict[str, Tenant]) -> list[str]:
|
|
"""Inside a tool subprocess: cwd is the tenant's own, and no other tenant's canary is visible."""
|
|
problems: list[str] = []
|
|
for t in tenants.values():
|
|
for origin, s in env_snapshots(t):
|
|
text = s.encode().decode("unicode_escape", errors="ignore") if "\\n" in s else s
|
|
own_dirs = [str(t.workdir), *(v for k, v in t.extra.items() if k.startswith("workdir"))]
|
|
if not any(f"CWD={d}\n" in text + "\n" for d in own_dirs):
|
|
cwd = next((ln for ln in text.splitlines() if ln.startswith("CWD=")), "<no CWD line>")
|
|
problems.append(f"{t.name}: terminal ({origin}) ran in {cwd.strip()!r}, not its own {own_dirs}")
|
|
problems += [f"{t.name}: tool subprocess ({origin}) carries {u.name}'s {kind}: "
|
|
f"{next((ln.strip() for ln in text.splitlines() if value in ln), value)[:300]!r}"
|
|
for u in tenants.values() if u is not t
|
|
for kind, value in u.canaries().items() if value in text]
|
|
return problems
|
|
|
|
|
|
def file_leaks(tenants: dict[str, Tenant], launch: str = "default") -> list[str]:
|
|
"""Byte-scan every file of every tenant home (state.db + WAL, logs, sessions, config, .env,
|
|
memories, cron output) for any other tenant's canary. The launch home's ``profiles/`` subtree
|
|
belongs to the other tenants and is scanned as theirs."""
|
|
problems: list[str] = []
|
|
for t in tenants.values():
|
|
foreign = [(u.name, kind, value.encode()) for u in tenants.values() if u is not t
|
|
for kind, value in u.canaries().items()]
|
|
for path in t.home.rglob("*"):
|
|
if not path.is_file() or (t.name == launch and "profiles" in path.relative_to(t.home).parts[:1]):
|
|
continue
|
|
try:
|
|
data = path.read_bytes()
|
|
except OSError:
|
|
continue
|
|
problems += [f"{t.name}: {path.relative_to(t.home)} contains {owner}'s {kind}"
|
|
for owner, kind, value in foreign if value in data]
|
|
return problems
|
|
|
|
|
|
def check_isolation(tenants: dict[str, Tenant], *, launch: str = "default", min_snapshots: int = 1,
|
|
extra: Iterable[str] = ()) -> None:
|
|
problems = [*request_leaks(tenants), *snapshot_problems(tenants), *file_leaks(tenants, launch), *extra]
|
|
if problems:
|
|
raise TenantLeak("cross-tenant leak(s):\n " + "\n ".join(dict.fromkeys(problems)))
|
|
shortfall = snapshot_shortfall(tenants, min_snapshots)
|
|
assert not shortfall, "\n".join(shortfall)
|
|
|
|
|
|
def assert_no_text_leaks(label: str, text: str, tenants: dict[str, Tenant], owner: str | None = None) -> None:
|
|
if leaks := text_leaks(label, text, tenants, owner):
|
|
raise TenantLeak("\n".join(leaks))
|
|
|
|
|
|
def text_leaks(label: str, text: str, tenants: dict[str, Tenant], owner: str | None = None) -> list[str]:
|
|
"""Canaries of any tenant other than ``owner`` in a log/stream (``owner=None``: secrets only)."""
|
|
out: list[str] = []
|
|
for u in tenants.values():
|
|
if u.name == owner:
|
|
continue
|
|
values = u.canaries() if owner is not None else u.secrets()
|
|
out += [f"{label}: carries {u.name}'s {kind}" for kind, value in values.items() if value in text]
|
|
return out
|
|
|
|
|
|
_SEED_CRON = ("import sys; from cron.jobs import create_job, trigger_job, list_jobs; "
|
|
"jobs = [j for j in list_jobs(include_disabled=True) if j.get('name') == 'tenancy-canary']; "
|
|
"j = jobs[0] if jobs else create_job(prompt=sys.argv[1], schedule='every 1h', name='tenancy-canary', "
|
|
"deliver='local'); trigger_job(j['id']); print(j['id'])")
|
|
|
|
|
|
def seed_due_cron_job(t: Tenant, home: Path) -> str:
|
|
"""Create (once) an hourly job in the tenant's own cron store and mark it due now (the state the
|
|
"run on next tick" action produces), so the multiplexed ticker's first tick fires it."""
|
|
r = subprocess.run([sys.executable, "-c", _SEED_CRON, t.cron_prompt], cwd=str(home),
|
|
env=hermetic_env(home, {"HERMES_HOME": str(t.home)}), capture_output=True, text=True,
|
|
timeout=120, stdin=subprocess.DEVNULL)
|
|
assert r.returncode == 0, r.stderr[-2000:]
|
|
return r.stdout.strip().splitlines()[-1]
|
|
|
|
|
|
def cron_requests(t: Tenant) -> int:
|
|
"""Provider requests driven by the tenant's cron job (its prompt canary is in the user turn)."""
|
|
assert t.srv is not None
|
|
return sum(t.cron_prompt.split(":")[0] in json.dumps(r["body"]) for r in list(t.srv.requests))
|
|
|
|
|
|
# Processes -----------------------------------------------------------------------------------------
|
|
|
|
|
|
def kill_group(proc: subprocess.Popen, sig: int = signal.SIGKILL) -> None:
|
|
try:
|
|
os.killpg(proc.pid, sig)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
|
|
def run_hermes(argv: list[str], home: Path, timeout: float = 120.0,
|
|
extra_env: dict[str, str] | None = None) -> subprocess.CompletedProcess:
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "-m", "hermes_cli.main", *argv], cwd=str(home), env=hermetic_env(home, extra_env),
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
start_new_session=True,
|
|
)
|
|
try:
|
|
out, err = proc.communicate(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
kill_group(proc)
|
|
out, err = proc.communicate()
|
|
err += f"\n[harness] killed after {timeout}s"
|
|
kill_group(proc)
|
|
return subprocess.CompletedProcess(argv, proc.returncode, out, err)
|
|
|
|
|
|
class TuiBackend:
|
|
"""The real stdio JSON-RPC backend the TUI/Desktop drive (``python -m tui_gateway.entry``)."""
|
|
|
|
def __init__(self, home: Path, log_path: Path, extra_env: dict[str, str] | None = None) -> None:
|
|
import queue
|
|
import threading
|
|
|
|
self._log = open(log_path, "a", encoding="utf-8") # noqa: SIM115 - closed in close()
|
|
self.proc = subprocess.Popen(
|
|
[sys.executable, "-m", "tui_gateway.entry"], cwd=str(home), env=hermetic_env(home, extra_env),
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self._log, text=True, bufsize=1,
|
|
start_new_session=True,
|
|
)
|
|
self._q: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
self._rid = 0
|
|
self.seen: list[dict[str, Any]] = []
|
|
threading.Thread(target=self._read, daemon=True, name="tenancy-tui-reader").start()
|
|
|
|
def _read(self) -> None:
|
|
assert self.proc.stdout is not None
|
|
for line in self.proc.stdout:
|
|
try:
|
|
self._q.put(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
def wait(self, pred: Callable[[dict[str, Any]], bool], timeout: float = 90.0) -> dict[str, Any]:
|
|
import queue
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if self.proc.poll() is not None and self._q.empty():
|
|
break
|
|
try:
|
|
msg = self._q.get(timeout=0.25)
|
|
except queue.Empty:
|
|
continue
|
|
self.seen.append(msg)
|
|
if pred(msg):
|
|
return msg
|
|
tail = [(m.get("method"), (m.get("params") or {}).get("type"), m.get("id")) for m in self.seen[-20:]]
|
|
raise AssertionError(f"tui_gateway: condition not met within {timeout}s (rc={self.proc.poll()}); last {tail}")
|
|
|
|
def call(self, method: str, params: dict[str, Any], timeout: float = 90.0) -> dict[str, Any]:
|
|
self._rid += 1
|
|
rid = self._rid
|
|
assert self.proc.stdin is not None
|
|
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}) + "\n")
|
|
self.proc.stdin.flush()
|
|
return self.wait(lambda m: m.get("id") == rid and "method" not in m, timeout)
|
|
|
|
def ok(self, method: str, params: dict[str, Any], timeout: float = 90.0) -> dict[str, Any]:
|
|
reply = self.call(method, params, timeout)
|
|
assert "result" in reply, f"{method} failed: {reply.get('error')}"
|
|
return reply["result"]
|
|
|
|
def turn(self, sid: str, text: str, timeout: float = 90.0) -> dict[str, Any]:
|
|
"""Submit one prompt and wait for that session's terminal turn event."""
|
|
self.ok("prompt.submit", {"session_id": sid, "text": text})
|
|
|
|
def done(m: dict[str, Any]) -> bool:
|
|
p = m.get("params") or {}
|
|
return (m.get("method") == "event" and p.get("session_id") == sid
|
|
and p.get("type") in {"message.complete", "error"})
|
|
ev = self.wait(done, timeout)
|
|
assert ev["params"]["type"] == "message.complete", f"turn failed: {ev}"
|
|
return ev
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
if self.proc.stdin:
|
|
self.proc.stdin.close()
|
|
self.proc.wait(timeout=60)
|
|
except (subprocess.TimeoutExpired, OSError, ValueError):
|
|
pass
|
|
kill_group(self.proc)
|
|
try:
|
|
self.proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
self._log.close()
|
|
|
|
|
|
class ServeBackend(TuiBackend):
|
|
"""The real Desktop backend: ``hermes serve --port 0`` with the JSON-RPC surface on ``/api/ws``.
|
|
|
|
``HERMES_DESKTOP=1`` marks it app-spawned, which is also what runs the cron ticker in-process.
|
|
"""
|
|
|
|
def __init__(self, home: Path, log_path: Path, extra_env: dict[str, str] | None = None) -> None:
|
|
import queue
|
|
import re
|
|
import threading
|
|
|
|
from websockets.sync.client import connect # ``websockets`` is a core dependency
|
|
|
|
self.token = secrets.token_urlsafe(24)
|
|
self._log = open(log_path, "a", encoding="utf-8") # noqa: SIM115 - closed in close()
|
|
env = {"HERMES_DASHBOARD_SESSION_TOKEN": self.token, "HERMES_DESKTOP": "1", **(extra_env or {})}
|
|
self.proc = subprocess.Popen(
|
|
[sys.executable, "-m", "hermes_cli.main", "serve", "--host", "127.0.0.1", "--port", "0"],
|
|
cwd=str(home), env=hermetic_env(home, env), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True, bufsize=1, start_new_session=True,
|
|
)
|
|
port_box: list[int] = []
|
|
|
|
def pump() -> None:
|
|
assert self.proc.stdout is not None
|
|
for line in self.proc.stdout:
|
|
self._log.write(line)
|
|
self._log.flush()
|
|
m = re.search(r"backend listening on [\d.]+:(\d+)", line)
|
|
if m and not port_box:
|
|
port_box.append(int(m.group(1)))
|
|
threading.Thread(target=pump, daemon=True, name="tenancy-serve-stdout").start()
|
|
self.port = poll(lambda: port_box[0] if port_box else (self.proc.poll() is not None and -1), 120,
|
|
"hermes serve to report its port")
|
|
assert self.port > 0, f"hermes serve exited rc={self.proc.returncode}"
|
|
self.ws = connect(f"ws://127.0.0.1:{self.port}/api/ws?token={self.token}", open_timeout=90, max_size=None)
|
|
self._q: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
self._rid = 0
|
|
self.seen: list[dict[str, Any]] = []
|
|
threading.Thread(target=self._read, daemon=True, name="tenancy-ws-reader").start()
|
|
|
|
def _read(self) -> None:
|
|
while True:
|
|
try:
|
|
raw = self.ws.recv()
|
|
except Exception:
|
|
return
|
|
if not raw:
|
|
return
|
|
try:
|
|
self._q.put(json.loads(raw))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
def call(self, method: str, params: dict[str, Any], timeout: float = 90.0) -> dict[str, Any]:
|
|
self._rid += 1
|
|
rid = self._rid
|
|
self.ws.send(json.dumps({"jsonrpc": "2.0", "id": rid, "method": method, "params": params}))
|
|
return self.wait(lambda m: m.get("id") == rid and "method" not in m, timeout)
|
|
|
|
def close(self) -> None:
|
|
try:
|
|
self.ws.close()
|
|
except Exception:
|
|
pass
|
|
kill_group(self.proc, signal.SIGTERM)
|
|
try:
|
|
self.proc.wait(timeout=60)
|
|
except subprocess.TimeoutExpired:
|
|
kill_group(self.proc)
|
|
self.proc.wait(timeout=10)
|
|
kill_group(self.proc)
|
|
self._log.close()
|
|
|
|
|
|
def pid_alive(pid: int) -> bool:
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
stat = Path(f"/proc/{pid}/stat")
|
|
return not (stat.exists() and stat.read_text().split(") ", 1)[-1].startswith("Z"))
|