"""Tests: cross-connection bot relay (tools/bot_relay.py + message_agent route). Connections ARE the peer set: every Desktop-connected gateway must be message_agent-reachable. These tests pin the gateway-side plumbing — roster validation, target resolution (incl. ambiguity), outbox claim atomicity, reply write validation — and the two behavior contracts the relay adds to message_agent: - a target resolving against the Desktop-synced relay roster is queued as an envelope and acknowledged like any DM (fire-and-forget, waiter spawned); - the legacy-SOUL dedupe (empty protocol section) NO LONGER strips the tool: the injection/execution gates key on managed-install, not section text. """ import json import re from pathlib import Path import pytest from tools import bot_relay from tools.bot_mode_dm import ( MESSAGE_AGENT_TOOL_NAME, ensure_message_agent_tool, message_agent_tool, ) @pytest.fixture() def root(tmp_path): return tmp_path def _rows(): return [ { "profile": "default", "handle": "hermes", "connection_id": "cloud-1", "connection_label": "Hermes Cloud", "title": "Moxie", "description": "Main cloud agent", }, { "profile": "researcher", "handle": "researcher", "connection_id": "ssh-vps", "connection_label": "VPS", }, ] # ── roster ─────────────────────────────────────────────────────────────────── def test_roster_roundtrip_and_validation(root): rows = _rows() + [ {"profile": "", "handle": "x", "connection_id": "c"}, # no profile {"profile": "bad name!", "connection_id": "c"}, # bad charset "not-a-dict", {"profile": "default", "handle": "hermes", "connection_id": "cloud-1"}, # dupe ] count = bot_relay.write_remote_roster(root, rows) assert count == 2 back = bot_relay.read_remote_roster(root) assert [r["profile"] for r in back] == ["default", "researcher"] assert back[0]["title"] == "Moxie" def test_roster_read_missing_and_corrupt(root): assert bot_relay.read_remote_roster(root) == [] base = bot_relay.relay_root(root) base.mkdir(parents=True) (base / bot_relay.ROSTER_FILE).write_text("{corrupt", encoding="utf-8") assert bot_relay.read_remote_roster(root) == [] def test_resolve_remote_target_forms(root): bot_relay.write_remote_roster(root, _rows()) roster = bot_relay.read_remote_roster(root) assert bot_relay.resolve_remote_target("researcher", roster)["connection_id"] == "ssh-vps" assert bot_relay.resolve_remote_target("@hermes", roster)["profile"] == "default" # profile name resolves too assert bot_relay.resolve_remote_target("default", roster)["connection_id"] == "cloud-1" # exact connection-qualified form assert bot_relay.resolve_remote_target("hermes@cloud-1", roster)["profile"] == "default" # profile@connection — the form Desktop's mention middleware annotates # for remote bots (#97678); the UI alias form must not be required assert bot_relay.resolve_remote_target("default@cloud-1", roster)["profile"] == "default" assert bot_relay.resolve_remote_target("hermes@nope", roster) is None assert bot_relay.resolve_remote_target("ghost", roster) is None def test_resolve_ambiguous_handle_across_connections(root): rows = _rows() + [ {"profile": "researcher", "handle": "researcher", "connection_id": "cloud-1"} ] bot_relay.write_remote_roster(root, rows) roster = bot_relay.read_remote_roster(root) assert bot_relay.resolve_remote_target("researcher", roster) == "ambiguous" match = bot_relay.resolve_remote_target("researcher@ssh-vps", roster) assert match["connection_id"] == "ssh-vps" forms = bot_relay.remote_target_forms(roster) assert "researcher@ssh-vps" in forms and "researcher@cloud-1" in forms assert "hermes" in forms # unique handle stays bare # ── outbox / replies ───────────────────────────────────────────────────────── def test_enqueue_claim_is_atomic_and_single_shot(root): bot_relay.write_remote_roster(root, _rows()) roster = bot_relay.read_remote_roster(root) target = bot_relay.resolve_remote_target("researcher", roster) env = bot_relay.enqueue_envelope( root, target=target, message="hi", sender_profile="work", sender_handle="work" ) assert re.match(r"^[0-9a-f]{32}$", env["id"]) claimed = bot_relay.claim_pending_envelopes(root) assert [e["id"] for e in claimed] == [env["id"]] assert claimed[0]["target_connection"] == "ssh-vps" assert claimed[0]["message"] == "hi" # second drain: nothing (no double delivery) assert bot_relay.claim_pending_envelopes(root) == [] def test_write_reply_validates_envelope_id(root): with pytest.raises(ValueError): bot_relay.write_reply(root, "../../etc/passwd", reply="x") path = bot_relay.write_reply(root, "a" * 32, reply="pong") data = json.loads(Path(path).read_text(encoding="utf-8")) assert data["reply"] == "pong" and not data["error"] def test_write_reply_reason_passthrough_and_classification(root): # explicit reason is persisted verbatim path = bot_relay.write_reply(root, "c" * 32, error="boom", reason="delivery_timeout") data = json.loads(Path(path).read_text(encoding="utf-8")) assert data["reason"] == "delivery_timeout" and data["error"] == "boom" # no reason given → classified from error text path = bot_relay.write_reply(root, "d" * 32, error="Error code: 429 - rate limit") data = json.loads(Path(path).read_text(encoding="utf-8")) assert data["reason"] == "provider_rate_limit" # success reply carries an empty reason path = bot_relay.write_reply(root, "e" * 32, reply="ok") data = json.loads(Path(path).read_text(encoding="utf-8")) assert data["reason"] == "" and data["reply"] == "ok" def test_waiter_command_quotes_and_targets_reply_file(root): env = {"id": "b" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"} cmd = bot_relay.waiter_command(root, env) assert ("b" * 32) in cmd and "-c" in cmd assert "rm -rf" not in cmd # sanity: single quoted -c payload def test_waiter_picks_up_reply_within_a_sub_second_cadence(root): """The reply file is written once; the waiter must notice it fast, not on a multi-second sleep (dead air the sender's completion notification inherits on every cross-machine reply).""" import shlex import subprocess import threading import time env = {"id": "c" * 32, "target_handle": "researcher", "target_connection": "ssh-vps"} reply_path = bot_relay.relay_root(root) / bot_relay.REPLIES_DIR / f"{env['id']}.json" reply_path.parent.mkdir(parents=True, exist_ok=True) def write_reply(): time.sleep(0.3) reply_path.write_text(json.dumps({"reply": "pong"}), encoding="utf-8") threading.Thread(target=write_reply, daemon=True).start() started = time.monotonic() proc = subprocess.run(shlex.split(bot_relay.waiter_command(root, env)), capture_output=True, text=True, timeout=10) elapsed = time.monotonic() - started assert proc.returncode == 0 and "pong" in proc.stdout assert elapsed < 1.5, f"waiter took {elapsed:.2f}s to notice a reply written at 0.3s" def test_roster_rejects_connection_id_outside_handle_charset(root): bad = [ {"profile": "researcher", "handle": "researcher", "connection_id": "vps'); print(1)"}, {"profile": "researcher", "handle": "researcher", "connection_id": "foo'bar"}, {"profile": "researcher", "handle": "researcher", "connection_id": "ssh vps"}, {"profile": "researcher", "handle": "researcher", "connection_id": "a" * 65}, ] assert bot_relay.write_remote_roster(root, bad) == 0 good = { "profile": "researcher", "handle": "researcher", "connection_id": "ssh-vps", } assert bot_relay.write_remote_roster(root, [good]) == 1 def test_waiter_command_repr_encodes_hostile_connection_id(root): import ast import shlex inj = "x'); open(r'/tmp/pwned','w').write('pwned'); print('x" env = { "id": "c" * 32, "target_handle": "researcher", "target_connection": inj, } cmd = bot_relay.waiter_command(root, env) parts = shlex.split(cmd) code = parts[parts.index("-c") + 1] compile(code, "", "exec") tree = ast.parse(code) opens = [ n for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == "open" ] # Only json.load(open(p, ...)) is a real open(); the payload must stay data. assert len(opens) == 1 # A quote in the id used to SyntaxError the waiter. It must compile. quoted = bot_relay.waiter_command( root, {"id": "a" * 32, "target_handle": "h", "target_connection": "foo'bar"}, ) qcode = shlex.split(quoted)[shlex.split(quoted).index("-c") + 1] compile(qcode, "", "exec") # ── message_agent integration: relay route + legacy-SOUL gate fix ─────────── import textwrap def _managed_home(tmp_path, *, legacy_soul=False): home = tmp_path / ".hermes" home.mkdir(exist_ok=True) d = home / "profiles" / "researcher" d.mkdir(parents=True, exist_ok=True) (d / "profile.yaml").write_text( textwrap.dedent( """\ description: teammate for tests ui_meta: hermes-bots: shape: cloud """ ), encoding="utf-8", ) if legacy_soul: (home / "SOUL.md").write_text( "# Soul\n\n## Messaging other agents\nold shellout protocol\n", encoding="utf-8", ) return home class _FakeDB: def __init__(self, home, title): self.db_path = str(home / "state.db") self._title = title def get_session_title(self, _sid): return self._title class _FakeAgent: def __init__(self, home, title="Bot Chat"): self._session_db = _FakeDB(home, title) self.session_id = "sess-1" self._session_title_hint = None self._bot_mode_protocol = True self.tools: list = [] self.valid_tool_names: set = set() @pytest.fixture(autouse=True) def _fresh_probe_cache(): from tools import bot_mode_probe bot_mode_probe._reset_cache_for_tests() yield bot_mode_probe._reset_cache_for_tests() def test_tool_injects_despite_legacy_soul_protocol(tmp_path): """The legacy-SOUL dedupe empties the SECTION, never the TOOL. Regression: upgraded installs whose SOUL.md still carries the old plugin-appended protocol silently lost message_agent because the gate keyed on section non-emptiness. """ from tools import bot_mode_probe home = _managed_home(tmp_path, legacy_soul=True) # Premise: the dedupe really does empty the section for this profile... assert bot_mode_probe.get_bot_mode_protocol_section(home) == "" # ...but the install is managed, so the tool must still inject. agent = _FakeAgent(home) assert ensure_message_agent_tool(agent) is True assert [t["function"]["name"] for t in agent.tools] == [MESSAGE_AGENT_TOOL_NAME] def test_relay_route_queues_envelope_and_spawns_waiter(tmp_path, monkeypatch): home = _managed_home(tmp_path) bot_relay.write_remote_roster(home, [ {"profile": "default", "handle": "hermes", "connection_id": "cloud-1", "connection_label": "Hermes Cloud", "title": "Moxie"}, ]) spawned = {} def _fake_spawn(command, label, *, task_id, agent): spawned["command"] = command spawned["label"] = label return json.dumps({"status": "sent", "to": label}) monkeypatch.setattr("tools.bot_mode_dm._spawn_delivery", _fake_spawn) agent = _FakeAgent(home) out = json.loads(message_agent_tool(target="hermes", message="ping", agent=agent)) assert out.get("status") == "sent" assert "Hermes Cloud" in spawned["label"] # envelope landed in the outbox with attribution prefixed pending = bot_relay.claim_pending_envelopes(home) assert len(pending) == 1 assert pending[0]["target_connection"] == "cloud-1" assert pending[0]["target_profile"] == "default" assert pending[0]["message"].startswith("Message from 🤖 hermes (@hermes): ping") # waiter watches this envelope's reply file assert pending[0]["id"] in spawned["command"] def test_relay_route_ambiguous_target_errors_with_forms(tmp_path, monkeypatch): home = _managed_home(tmp_path) bot_relay.write_remote_roster(home, [ {"profile": "scout", "handle": "scout", "connection_id": "cloud-1"}, {"profile": "scout", "handle": "scout", "connection_id": "ssh-vps"}, ]) monkeypatch.setattr( "tools.bot_mode_dm._spawn_delivery", lambda *a, **k: json.dumps({"status": "sent"}), ) agent = _FakeAgent(home) out = json.loads(message_agent_tool(target="scout", message="hi", agent=agent)) assert "scout@cloud-1" in out.get("error", "") and "scout@ssh-vps" in out["error"] # connection-qualified form goes through out2 = json.loads(message_agent_tool(target="scout@ssh-vps", message="hi", agent=agent)) assert out2.get("status") == "sent" def test_unknown_target_error_mentions_connected_machines(tmp_path): home = _managed_home(tmp_path) agent = _FakeAgent(home) out = json.loads(message_agent_tool(target="ghost", message="hi", agent=agent)) assert "connected machine" in out.get("error", "") def test_protocol_section_lists_remote_teammates(tmp_path): from tools import bot_mode_probe home = _managed_home(tmp_path) bot_relay.write_remote_roster(home, [ {"profile": "default", "handle": "hermes", "connection_id": "cloud-1", "connection_label": "Hermes Cloud", "title": "Moxie"}, ]) section = bot_mode_probe.get_bot_mode_protocol_section(home, force_refresh=True) assert "OTHER connected machines" in section assert "`@hermes` — on Hermes Cloud — Moxie" in section def test_capability_fingerprint_changes_with_relay_roster(tmp_path): from tools import bot_mode_probe home = _managed_home(tmp_path) before = bot_mode_probe.capability_fingerprint(home) bot_relay.write_remote_roster(home, [ {"profile": "default", "handle": "hermes", "connection_id": "cloud-1"}, ]) after = bot_mode_probe.capability_fingerprint(home) assert before != after # eternal Bot Chats refresh once on roster change # ── stale artifact sweep (housekeeping contract) ───────────────────────────── def test_cleanup_bot_relay_artifacts_sweeps_stale_plaintext(tmp_path, monkeypatch): import os as _os import time as _time monkeypatch.setenv("HERMES_HOME", str(tmp_path)) target = {"profile": "scout", "handle": "scout", "connection_id": "cloud-1", "connection_label": "", "title": "", "description": ""} stale_env = bot_relay.enqueue_envelope( tmp_path, target=target, message="old secret", sender_profile="default", sender_handle="hermes", ) fresh_env = bot_relay.enqueue_envelope( tmp_path, target=target, message="new secret", sender_profile="default", sender_handle="hermes", ) base = bot_relay.relay_root(tmp_path) stale_reply = bot_relay.write_reply(tmp_path, stale_env["id"], reply="done") old = _time.time() - bot_relay.STALE_AFTER_SECONDS - 1 _os.utime(base / bot_relay.OUTBOX_DIR / f"{stale_env['id']}.json", (old, old)) _os.utime(stale_reply, (old, old)) removed = bot_relay.cleanup_bot_relay_artifacts() assert removed == 2 assert not (base / bot_relay.OUTBOX_DIR / f"{stale_env['id']}.json").exists() assert not stale_reply.exists() assert (base / bot_relay.OUTBOX_DIR / f"{fresh_env['id']}.json").exists() def test_cleanup_bot_relay_artifacts_missing_dir_is_zero(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "nope")) assert bot_relay.cleanup_bot_relay_artifacts() == 0 # ── #93091 item 2: offline fail-fast + drain-time TTL ──────────────────────── import os as _os2 import time as _time2 def _target(conn="cloud-1", profile="scout", handle="scout"): return {"profile": profile, "handle": handle, "connection_id": conn, "connection_label": "", "title": "", "description": ""} def test_enqueue_fails_fast_when_row_explicitly_offline(root): bot_relay.write_remote_roster(root, [ {"profile": "scout", "handle": "scout", "connection_id": "cloud-1", "online": False}, ]) roster = bot_relay.read_remote_roster(root) assert roster[0]["online"] is False # additive field survives normalize with pytest.raises(bot_relay.EnvelopeRefusedError) as ei: bot_relay.enqueue_envelope( root, target=roster[0], message="hi", sender_profile="default", sender_handle="hermes", ) assert ei.value.reason == "runtime_offline" assert "offline" in str(ei.value) # nothing was written to the outbox outdir = bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR assert not outdir.exists() or list(outdir.glob("*.json")) == [] def test_enqueue_fails_fast_when_target_absent_from_fresh_roster(root): bot_relay.write_remote_roster(root, _rows()) # fresh, no 'scout' row with pytest.raises(bot_relay.EnvelopeRefusedError) as ei: bot_relay.enqueue_envelope( root, target=_target(), message="hi", sender_profile="default", sender_handle="hermes", ) assert ei.value.reason == "runtime_offline" def test_enqueue_fails_open_when_liveness_unknown(root): # 1. no roster ever synced → unknown → enqueue env = bot_relay.enqueue_envelope( root, target=_target(), message="hi", sender_profile="default", sender_handle="hermes", ) assert (bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR / f"{env['id']}.json").exists() # 2. stale roster missing the target → unknown → enqueue bot_relay.write_remote_roster(root, _rows()) roster_path = bot_relay.relay_root(root) / bot_relay.ROSTER_FILE old = _time2.time() - bot_relay.ROSTER_FRESH_SECONDS - 5 _os2.utime(roster_path, (old, old)) env2 = bot_relay.enqueue_envelope( root, target=_target(), message="hi again", sender_profile="default", sender_handle="hermes", ) assert (bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR / f"{env2['id']}.json").exists() # 3. fresh roster listing the target without an online flag → enqueue bot_relay.write_remote_roster(root, _rows()) target = bot_relay.read_remote_roster(root)[1] # researcher@ssh-vps env3 = bot_relay.enqueue_envelope( root, target=target, message="hello", sender_profile="default", sender_handle="hermes", ) assert (bot_relay.relay_root(root) / bot_relay.OUTBOX_DIR / f"{env3['id']}.json").exists() def test_drain_expires_old_envelope_with_queued_expired_reply(root): env = bot_relay.enqueue_envelope( root, target=_target(), message="too late", sender_profile="default", sender_handle="hermes", ) base = bot_relay.relay_root(root) out_path = base / bot_relay.OUTBOX_DIR / f"{env['id']}.json" # backdate the envelope beyond the TTL env["created_at"] = int(_time2.time()) - bot_relay.DEFAULT_ENVELOPE_TTL_SECONDS - 10 out_path.write_text(json.dumps(env), encoding="utf-8") claimed = bot_relay.claim_pending_envelopes(root) assert claimed == [] # not delivered assert not out_path.exists() # expired outbox file removed reply = json.loads( (base / bot_relay.REPLIES_DIR / f"{env['id']}.json").read_text(encoding="utf-8") ) assert reply["reason"] == "queued_expired" assert "expired" in reply["error"] and "NOT delivered" in reply["error"] assert not reply["reply"] def test_drain_delivers_fresh_envelope_under_ttl(root): env = bot_relay.enqueue_envelope( root, target=_target(), message="on time", sender_profile="default", sender_handle="hermes", ) claimed = bot_relay.claim_pending_envelopes(root) assert [e["id"] for e in claimed] == [env["id"]] # no spurious expiry reply for a delivered envelope base = bot_relay.relay_root(root) assert not (base / bot_relay.REPLIES_DIR / f"{env['id']}.json").exists() def test_drain_ttl_zero_disables_expiry(root, monkeypatch): monkeypatch.setattr(bot_relay, "_envelope_ttl_seconds", lambda: 0) env = bot_relay.enqueue_envelope( root, target=_target(), message="never expires", sender_profile="default", sender_handle="hermes", ) base = bot_relay.relay_root(root) out_path = base / bot_relay.OUTBOX_DIR / f"{env['id']}.json" env["created_at"] = int(_time2.time()) - 10 * 3600 out_path.write_text(json.dumps(env), encoding="utf-8") claimed = bot_relay.claim_pending_envelopes(root) assert [e["id"] for e in claimed] == [env["id"]] def test_ttl_config_read_is_lazy_and_defensive(monkeypatch): import builtins real_import = builtins.__import__ def _boom(name, *a, **k): if name.startswith("hermes_cli"): raise ImportError("config unavailable") return real_import(name, *a, **k) monkeypatch.setattr(builtins, "__import__", _boom) assert bot_relay._envelope_ttl_seconds() == bot_relay.DEFAULT_ENVELOPE_TTL_SECONDS def test_message_agent_surfaces_runtime_offline_refusal(tmp_path, monkeypatch): home = _managed_home(tmp_path) bot_relay.write_remote_roster(home, [ {"profile": "default", "handle": "hermes", "connection_id": "cloud-1", "connection_label": "Hermes Cloud", "online": False}, ]) monkeypatch.setattr( "tools.bot_mode_dm._spawn_delivery", lambda *a, **k: json.dumps({"status": "sent"}), ) agent = _FakeAgent(home) out = json.loads(message_agent_tool(target="hermes", message="ping", agent=agent)) assert out.get("reason") == "runtime_offline" assert "offline" in out.get("error", "") # fail-fast means no envelope was queued assert bot_relay.claim_pending_envelopes(home) == []