"""Tests for the Buzz platform adapter plugin.""" import asyncio import base64 import hashlib import json from pathlib import Path from types import SimpleNamespace import pytest from unittest.mock import AsyncMock, MagicMock from gateway.platforms.base import CachedMedia, MessageType from tests.gateway._plugin_adapter_loader import load_plugin_adapter from gateway.platforms.base import MessageType # Load plugins/platforms/buzz/adapter.py under a unique module name # (plugin_adapter_buzz) so it cannot collide with other plugin adapters # loaded by sibling tests in the same xdist worker. _buzz_mod = load_plugin_adapter("buzz") BuzzAdapter = _buzz_mod.BuzzAdapter hex_to_npub = _buzz_mod.hex_to_npub npub_to_hex = _buzz_mod.npub_to_hex _normalize_user_ref = _buzz_mod._normalize_user_ref _cli_error_message = _buzz_mod._cli_error_message _resolve_private_key = _buzz_mod._resolve_private_key _resolve_auth_tag = _buzz_mod._resolve_auth_tag _event_reply_parent_id = _buzz_mod._event_reply_parent_id check_requirements = _buzz_mod.check_requirements validate_config = _buzz_mod.validate_config register = _buzz_mod.register _env_enablement = _buzz_mod._env_enablement _standalone_send = _buzz_mod._standalone_send # Real key pair (Chip's public identity — public information, not a secret) SELF_PUBKEY = "9fd5c7ba6d3ef224da78f541e0fcb9c50f72cc63edb19aae76ac6a0474dfa860" SELF_NPUB = "npub1nl2u0wnd8mezfknc74q7pl9ec58h9nrrakce4tnk434qgaxl4psqe5twr6" OTHER_PUBKEY = "a" * 64 AGENT_PUBKEY = "b" * 64 CHANNEL = "ccc2bc1a-7a82-5a8f-8c4e-57a070cbe7cd" # Real DM conversation as materialized by a hosted relay: `dms list` returns # [] for it (#68871) while `channels list` shows it as name "DM", empty # description, indistinguishable from a channel except via message p-tags. DM_CHANNEL = "6468cc16-a114-4f23-8b8c-02c1655cbf6b" _ENV_VARS = ( "BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY", "BUZZ_CHANNELS", "BUZZ_HOME_CHANNEL", "BUZZ_ALLOWED_USERS", "BUZZ_REACTION_ONLY_USERS", "BUZZ_ALLOW_ALL_USERS", "BUZZ_POLL_INTERVAL", "BUZZ_AUTH_TAG", "BUZZ_CLI_PATH", "BUZZ_CREDENTIALS_FILE", "BUZZ_AUTH_TAG", ) @pytest.fixture(autouse=True) def _clean_env(monkeypatch, tmp_path): """Keep tests hermetic: no ambient Buzz env vars or real credentials.""" for var in _ENV_VARS: monkeypatch.delenv(var, raising=False) monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path / "no-creds") yield def _event(event_id, pubkey=OTHER_PUBKEY, content="hello", created_at=1000, kind=9): return { "id": event_id, "pubkey": pubkey, "content": content, "created_at": created_at, "kind": kind, "tags": [["h", CHANNEL]], } def _make_adapter(extra=None): from gateway.config import PlatformConfig cfg = PlatformConfig(enabled=True, extra={"relay_url": "https://test.relay", **(extra or {})}) adapter = BuzzAdapter(cfg) adapter._self_pubkey = SELF_PUBKEY adapter._self_npub = SELF_NPUB adapter._display_name = "Chip" adapter._private_key = "nsec1test" # GatewayRunner installs this callback before intake starts. Attachment # tests are authorized by default and override the callback at the boundary. adapter.set_authorization_check(lambda *_args: True) return adapter class _ScriptedCli: """Fake ``_run_cli`` that routes on the buzz subcommand and records calls.""" def __init__(self): self.responses = {} # (group, cmd) -> list of (code, stdout, stderr) self.calls = [] def script(self, group, cmd, payload, code=0, stderr=""): stdout = payload if isinstance(payload, str) else json.dumps(payload) self.responses.setdefault((group, cmd), []).append((code, stdout, stderr)) async def __call__(self, args, *, input_text=None): self.calls.append((list(args), input_text)) queue = self.responses.get((args[0], args[1]), []) if len(queue) > 1: return queue.pop(0) if queue: return queue[0] return 0, "[]", "" # ── bech32 / identity helpers ───────────────────────────────────────────── class TestBech32Helpers: def test_hex_to_npub_known_pair(self): assert hex_to_npub(SELF_PUBKEY) == SELF_NPUB def test_npub_to_hex_known_pair(self): assert npub_to_hex(SELF_NPUB) == SELF_PUBKEY # ── Adapter init / config precedence ────────────────────────────────────── class TestBuzzAdapterInit: def test_init_from_config_extra(self): from gateway.config import PlatformConfig cfg = PlatformConfig( enabled=True, extra={ "relay_url": "https://cfg.relay", "channels": ["ccc"], "poll_interval": 2, "home_channel": "ccc", }, ) adapter = BuzzAdapter(cfg) assert adapter.relay_url == "https://cfg.relay" assert adapter.channels == ["ccc"] assert adapter.poll_interval == 2.0 assert adapter.home_channel == "ccc" def test_env_overrides_config(self, monkeypatch): monkeypatch.setenv("BUZZ_RELAY_URL", "https://env.relay") from gateway.config import PlatformConfig adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={"relay_url": "https://cfg.relay"})) assert adapter.relay_url == "https://env.relay" # ── Multiplex secondary-profile scope (#98738) ───────────────────────────── @pytest.fixture def multiplex_scope(): """Install multiplex + a secondary-profile secret scope; restore after.""" tokens = [] def install(scope=None): from agent.secret_scope import set_multiplex_active, set_secret_scope set_multiplex_active(True) tokens.append(set_secret_scope(scope or {})) return tokens[-1] yield install from agent.secret_scope import reset_secret_scope, set_multiplex_active for token in reversed(tokens): reset_secret_scope(token) set_multiplex_active(False) @pytest.fixture def default_profile_env(monkeypatch): """The default profile's YAML-to-env bridge output in os.environ.""" monkeypatch.setenv("BUZZ_RELAY_URL", "https://default.relay") monkeypatch.setenv("BUZZ_CHANNELS", "chan-a,chan-b,chan-c") monkeypatch.setenv("BUZZ_HOME_CHANNEL", "chan-a") monkeypatch.setenv("BUZZ_POLL_INTERVAL", "9") monkeypatch.setenv("BUZZ_CLI_PATH", "/default/bin/buzz") monkeypatch.setenv("BUZZ_TRANSPORT", "poll") monkeypatch.setenv("BUZZ_ALLOWED_USERS", "default-user-npub") monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", "/default/creds.json") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1default") monkeypatch.setenv("BUZZ_AUTH_TAG", '["auth","default-profile-tag","","x"]') class TestMultiplexProfileScope: def test_secondary_extra_wins_over_default_profile_env( self, multiplex_scope, default_profile_env, tmp_path ): """The secondary profile's PlatformConfig is authoritative (#98738).""" from gateway.config import PlatformConfig cli = tmp_path / "buzz" cli.write_text("#!/bin/sh\n", encoding="utf-8") multiplex_scope() cfg = PlatformConfig( enabled=True, extra={ "relay_url": "https://profile.relay", "channels": ["pchan"], "home_channel": "pchan", "poll_interval": 2, "cli_path": str(cli), "transport": "websocket", "allowed_users": [SELF_NPUB], }, ) adapter = BuzzAdapter(cfg) assert adapter.relay_url == "https://profile.relay" assert adapter.channels == ["pchan"] assert adapter.home_channel == "pchan" assert adapter.poll_interval == 2.0 assert adapter.cli_path == str(cli) assert adapter.transport == "websocket" assert adapter._allowed_pubkeys == {SELF_PUBKEY} def test_secondary_missing_keys_fail_closed( self, multiplex_scope, default_profile_env ): """Keys absent from the profile's config must NOT borrow the default profile's bridged env values — that would connect this adapter to the default profile's relay and watch its channels.""" from gateway.config import PlatformConfig multiplex_scope() adapter = BuzzAdapter(PlatformConfig(enabled=True, extra={})) assert adapter.relay_url == "" assert adapter.channels == [] assert adapter.home_channel == "" assert adapter.poll_interval == _buzz_mod._DEFAULT_POLL_INTERVAL assert adapter.transport == "auto" assert adapter._allowed_pubkeys == set() def test_secondary_credentials_file_not_borrowed( self, multiplex_scope, default_profile_env, tmp_path, monkeypatch ): """BUZZ_CREDENTIALS_FILE in env points at the DEFAULT profile's key file; the scoped adapter must not read the default identity's key.""" default_creds = tmp_path / "default-creds.json" default_creds.write_text( json.dumps({"nsec": "nsec1default-identity"}), encoding="utf-8" ) monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(default_creds)) multiplex_scope() # Scope has no key: the profile is unconfigured and must fail closed # to "" rather than resolving the default profile's credentials. assert _buzz_mod._resolve_private_key({}) == "" def test_default_profile_unscoped_keeps_env_precedence( self, monkeypatch, default_profile_env ): """Multiplex ON but no scope (the DEFAULT profile constructs unscoped): env is its own bridge output and still wins.""" from agent.secret_scope import set_multiplex_active from gateway.config import PlatformConfig set_multiplex_active(True) try: adapter = BuzzAdapter( PlatformConfig(enabled=True, extra={"relay_url": "https://cfg.relay"}) ) finally: set_multiplex_active(False) assert adapter.relay_url == "https://default.relay" def test_check_requirements_scoped_reads_profile_config( self, multiplex_scope, default_profile_env, tmp_path ): """The gate must consult the profile's own config.yaml + secret scope, not the default profile's env values.""" import yaml from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) creds = tmp_path / "creds.json" creds.write_text(json.dumps({"nsec": "nsec1profile"}), encoding="utf-8") (tmp_path / "config.yaml").write_text( yaml.safe_dump( { "gateway": { "platforms": { "buzz": { "enabled": True, "extra": { "relay_url": "https://profile.relay", "credentials_file": str(creds), }, } } } } ), encoding="utf-8", ) multiplex_scope() token = set_hermes_home_override(str(tmp_path)) try: # The default profile's env relay+key must NOT pass the gate on # their own for a profile without a buzz config... assert check_requirements() is True # profile config passes finally: reset_hermes_home_override(token) # A profile whose config.yaml has no buzz entry fails closed even # though the default profile's env values are present. empty_home = tmp_path / "empty-profile" empty_home.mkdir() multiplex_scope() token = set_hermes_home_override(str(empty_home)) try: assert check_requirements() is False finally: reset_hermes_home_override(token) def test_env_enablement_scoped_returns_none(self, multiplex_scope, default_profile_env): """Scoped env enablement must not fabricate Buzz for a profile from the default profile's env values.""" multiplex_scope() assert _env_enablement() is None def test_apply_yaml_config_scoped_skips_env_bridge( self, multiplex_scope, default_profile_env, monkeypatch ): """A secondary profile's YAML values must not be pinned into the process env for every other profile (first-writer-wins).""" for var in ("BUZZ_RELAY_URL", "BUZZ_HOME_CHANNEL", "BUZZ_CHANNELS"): monkeypatch.delenv(var, raising=False) multiplex_scope() _buzz_mod._apply_yaml_config( {}, {"extra": {"relay_url": "https://profile.relay", "home_channel": "pchan"}}, ) import os as _os assert "BUZZ_RELAY_URL" not in _os.environ assert "BUZZ_HOME_CHANNEL" not in _os.environ assert "BUZZ_CHANNELS" not in _os.environ def test_standalone_send_scoped_uses_profile_extra( self, multiplex_scope, default_profile_env, monkeypatch, tmp_path ): multiplex_scope() from gateway.config import PlatformConfig cli = tmp_path / "buzz" cli.write_text("#!/bin/sh\n", encoding="utf-8") calls = {} async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=None): calls["relay"] = relay_url return 0, '{"accepted": true, "event_id": "e1"}', "" monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) monkeypatch.setattr( _buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile" ) result = asyncio.run( _standalone_send( PlatformConfig( enabled=True, extra={"relay_url": "https://profile.relay", "cli_path": str(cli)}, ), "chan-x", "hello", ) ) assert result.get("success") is True assert calls["relay"] == "https://profile.relay" def test_secondary_partial_extra_fills_missing_keys_from_defaults( self, multiplex_scope, default_profile_env ): """Partial config: configured keys win, unconfigured keys fall back to their own defaults — never to the default profile's env values.""" from gateway.config import PlatformConfig multiplex_scope() adapter = BuzzAdapter( PlatformConfig(enabled=True, extra={"relay_url": "https://profile.relay"}) ) assert adapter.relay_url == "https://profile.relay" assert adapter.channels == [] assert adapter.home_channel == "" assert adapter.poll_interval == _buzz_mod._DEFAULT_POLL_INTERVAL assert adapter.transport == "auto" assert adapter._allowed_pubkeys == set() def test_ws_auth_tag_not_borrowed_from_default_profile_env( self, multiplex_scope, default_profile_env ): """BUZZ_AUTH_TAG is per-identity NIP-OA owner attestation: a scoped secondary profile without one must not sign its NIP-42 auth event with the default profile's tag from os.environ (#98738).""" import asyncio as _asyncio multiplex_scope() adapter = BuzzAdapter.__new__(BuzzAdapter) adapter._private_key = "00" * 31 + "03" adapter._websocket_url = lambda: "wss://relay.example" class _FakeWS: def __init__(self): self.sent = [] async def recv(self): if self.sent: return json.dumps(["OK", self.sent[0][1]["id"], True, "ok"]) return json.dumps(["AUTH", "challenge-1"]) async def send(self, raw): self.sent.append(json.loads(raw)) ws = _FakeWS() _asyncio.run(adapter._authenticate_websocket(ws)) tags = [t for t in ws.sent[0][1]["tags"] if t and t[0] == "auth"] assert tags == [] def test_ws_auth_tag_scoped_profile_uses_its_own_tag( self, multiplex_scope ): """Positive control: a tag present in the profile's own secret scope IS attached to the NIP-42 auth event.""" import asyncio as _asyncio profile_tag = json.dumps(["auth", "p" * 64, "", "q" * 128]) multiplex_scope({"BUZZ_AUTH_TAG": profile_tag}) adapter = BuzzAdapter.__new__(BuzzAdapter) adapter._private_key = "00" * 31 + "03" adapter._websocket_url = lambda: "wss://relay.example" class _FakeWS: def __init__(self): self.sent = [] async def recv(self): if self.sent: return json.dumps(["OK", self.sent[0][1]["id"], True, "ok"]) return json.dumps(["AUTH", "challenge-1"]) async def send(self, raw): self.sent.append(json.loads(raw)) ws = _FakeWS() _asyncio.run(adapter._authenticate_websocket(ws)) tags = [t for t in ws.sent[0][1]["tags"] if t and t[0] == "auth"] assert tags == [json.loads(profile_tag)] def test_ws_auth_tag_unscoped_default_profile_keeps_env( self, default_profile_env ): """The default profile constructs unscoped even under multiplex, so its env-provided auth tag still applies (legacy behavior kept).""" import asyncio as _asyncio from agent.secret_scope import set_multiplex_active set_multiplex_active(True) try: adapter = BuzzAdapter.__new__(BuzzAdapter) adapter._private_key = "00" * 31 + "03" adapter._websocket_url = lambda: "wss://relay.example" class _FakeWS: def __init__(self): self.sent = [] async def recv(self): if self.sent: return json.dumps(["OK", self.sent[0][1]["id"], True, "ok"]) return json.dumps(["AUTH", "challenge-1"]) async def send(self, raw): self.sent.append(json.loads(raw)) ws = _FakeWS() _asyncio.run(adapter._authenticate_websocket(ws)) finally: set_multiplex_active(False) tags = [t for t in ws.sent[0][1]["tags"] if t and t[0] == "auth"] assert tags == [["auth", "default-profile-tag", "", "x"]] def test_validate_config_scoped_extra_is_authoritative( self, multiplex_scope, default_profile_env, tmp_path ): """Scoped validation reads the profile's extra, not the default profile's env relay/key: unconfigured fails closed, configured passes via its own credentials file.""" creds = tmp_path / "creds.json" creds.write_text(json.dumps({"nsec": "nsec1profile"}), encoding="utf-8") from gateway.config import PlatformConfig multiplex_scope() assert validate_config(PlatformConfig(enabled=True, extra={})) is False assert ( validate_config( PlatformConfig( enabled=True, extra={ "relay_url": "https://profile.relay", "credentials_file": str(creds), }, ) ) is True ) def test_validate_config_unscoped_keeps_env_precedence( self, default_profile_env ): """Single-profile/unscoped: env relay + env key still validate even with an empty extra mapping.""" from gateway.config import PlatformConfig assert validate_config(PlatformConfig(enabled=True, extra={})) is True def test_standalone_send_scoped_target_falls_back_to_profile_home( self, multiplex_scope, default_profile_env, monkeypatch, tmp_path ): """With no explicit chat_id, the scoped standalone send targets the profile's own home_channel — never the default profile's env one.""" multiplex_scope() from gateway.config import PlatformConfig cli = tmp_path / "buzz" cli.write_text("#!/bin/sh\n", encoding="utf-8") calls = {} async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=None): calls["args"] = args return 0, '{"accepted": true, "event_id": "e1"}', "" monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) monkeypatch.setattr( _buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile" ) result = asyncio.run( _standalone_send( PlatformConfig( enabled=True, extra={ "relay_url": "https://profile.relay", "cli_path": str(cli), "home_channel": "pchan", }, ), "", "hello", ) ) assert result.get("success") is True assert calls["args"][calls["args"].index("--channel") + 1] == "pchan" def test_standalone_send_scoped_without_target_fails_closed( self, multiplex_scope, default_profile_env, monkeypatch, tmp_path ): """No chat_id and no profile home_channel: the error is returned — the default profile's env BUZZ_HOME_CHANNEL must not be borrowed.""" multiplex_scope() from gateway.config import PlatformConfig cli = tmp_path / "buzz" cli.write_text("#!/bin/sh\n", encoding="utf-8") async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=None): raise AssertionError("CLI must not run without a resolved target") monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) monkeypatch.setattr( _buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1profile" ) result = asyncio.run( _standalone_send( PlatformConfig( enabled=True, extra={"relay_url": "https://profile.relay", "cli_path": str(cli)}, ), "", "hello", ) ) assert result == { "error": "Buzz standalone send: no target channel (set BUZZ_HOME_CHANNEL)" } # ── CLI error contract ──────────────────────────────────────────────────── class TestCliErrorContract: def test_parses_json_error(self): msg = _cli_error_message('{"error":"relay_error","message":"boom","retryable":false}', 2) assert "relay_error" in msg and "boom" in msg and "exit 2" in msg @pytest.mark.parametrize( "stderr", [ "x" * 100_000, json.dumps({"error": "relay_error", "message": "x" * 100_000}), ], ) def test_bounds_untrusted_cli_error_output(self, stderr): msg = _cli_error_message(stderr, 2) assert len(msg) <= 900 assert msg.endswith("...") # ── Seeding / high-water mark / de-dupe ─────────────────────────────────── class TestPollingDedupe: @pytest.fixture def adapter(self): a = _make_adapter() a._dispatched = [] async def capture(**kwargs): a._dispatched.append(kwargs) a._dispatch_message = capture a._message_handler = AsyncMock() return a @pytest.mark.asyncio async def test_seed_sets_high_water_mark_without_dispatch(self, adapter): cli = _ScriptedCli() cli.script("messages", "get", [ _event("e1", content="@Chip old history", created_at=100), _event("e2", content="@Chip newer history", created_at=200), ]) adapter._run_cli = cli await adapter._seed_channel(CHANNEL, chat_type="group") state = adapter._channel_state[CHANNEL] assert state["last_ts"] == 200 assert set(state["seen"]) == {"e1", "e2"} # Seeding must never replay history into the agent assert adapter._dispatched == [] @pytest.mark.asyncio async def test_new_event_dispatched_once(self, adapter): cli = _ScriptedCli() cli.script("messages", "get", [_event("e1", content="@Chip hi", created_at=100)]) adapter._run_cli = cli await adapter._seed_channel(CHANNEL, chat_type="group") # Poll 1: seeded event + a genuinely new mention cli.responses.clear() cli.script("messages", "get", [ _event("e1", content="@Chip hi", created_at=100), _event("e2", content="hey @Chip, ping", created_at=150), ]) await adapter._poll_channel(CHANNEL) assert [d["message_id"] for d in adapter._dispatched] == ["e2"] assert adapter._dispatched[0]["text"] == "hey @Chip, ping" assert adapter._channel_state[CHANNEL]["last_ts"] == 150 # Poll 2: identical response — the seen-id set must de-dupe await adapter._poll_channel(CHANNEL) assert len(adapter._dispatched) == 1 @pytest.mark.asyncio async def test_malformed_attachment_url_does_not_abort_following_event(self, adapter): adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } malformed = _event("malformed-attachment", content="background chatter", created_at=159) malformed["tags"].append([ "imeta", "url https://[invalid/media.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename invalid.bin", ]) valid = _event("following-valid", content="@Chip still there?", created_at=160) cli = _ScriptedCli() cli.script("messages", "get", [malformed, valid]) adapter._run_cli = cli await adapter._poll_channel(CHANNEL) assert [item["message_id"] for item in adapter._dispatched] == ["following-valid"] @pytest.mark.asyncio async def test_addressed_attachment_is_cached_and_dispatched_to_agent(self, adapter): attachment = CachedMedia( path="/agent/cache/doc_handoff.txt", media_type="text/plain", kind="document", display_name="handoff.txt", ) adapter._download_attachment = AsyncMock(return_value=attachment) adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event("attachment-event", content="@Chip inspect this", created_at=160) event["tags"].append([ "imeta", "url https://test.relay/media/abc.bin", "m text/plain", "x " + "a" * 64, "size 12", "filename handoff.txt", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) adapter._download_attachment.assert_awaited_once() dispatched = adapter._dispatched[-1] assert dispatched["media_urls"] == [attachment.path] assert dispatched["media_types"] == ["text/plain"] assert dispatched["message_type"] is MessageType.DOCUMENT assert attachment.context_note() not in dispatched["text"] assert dispatched["raw_message"] is event class TestInboundAttachments: def test_imeta_total_declared_bytes_are_bounded(self): event = _event("bounded", content="@Chip files") per_file_size = 6 * 1024 * 1024 for index in range(4): event["tags"].append([ "imeta", f"url https://test.relay/media/{index}.bin", "m application/octet-stream", "x " + format(index + 1, "064x"), f"size {per_file_size}", f"filename {index}.bin", ]) attachments = BuzzAdapter._imeta_attachments(event) assert len(attachments) == 3 assert sum(item["size"] for item in attachments) <= 20 * 1024 * 1024 @pytest.mark.asyncio async def test_download_caches_only_exact_size_and_sha256(self, monkeypatch): import httpx payload = b"verified Buzz attachment" digest = hashlib.sha256(payload).hexdigest() real_async_client = httpx.AsyncClient def handler(request): assert request.url.host == "test.relay" return httpx.Response( 200, content=payload, headers={"content-length": str(len(payload))}, ) monkeypatch.setattr( httpx, "AsyncClient", lambda **kwargs: real_async_client( transport=httpx.MockTransport(handler), **kwargs, ), ) adapter = _make_adapter() cached = await adapter._download_attachment({ "url": "https://test.relay/media/verified.bin", "sha256": digest, "size": len(payload), "filename": "verified.txt", "mime_type": "text/plain", }) assert cached is not None assert cached.kind == "document" assert cached.media_type == "text/plain" assert Path(cached.path).read_bytes() == payload @pytest.mark.asyncio async def test_download_rejects_untrusted_attachment_host_before_network(self, monkeypatch): import httpx def must_not_create_client(**kwargs): raise AssertionError("network client must not be created") monkeypatch.setattr(httpx, "AsyncClient", must_not_create_client) adapter = _make_adapter() cached = await adapter._download_attachment({ "url": "https://untrusted.example/media/file.bin", "sha256": "a" * 64, "size": 12, "filename": "file.bin", "mime_type": "application/octet-stream", }) assert cached is None @pytest.mark.asyncio async def test_download_rejects_unconfigured_nondefault_port_before_network(self, monkeypatch): import httpx def must_not_create_client(**kwargs): raise AssertionError("network client must not be created") monkeypatch.setattr(httpx, "AsyncClient", must_not_create_client) adapter = _make_adapter() cached = await adapter._download_attachment({ "url": "https://test.relay:8443/media/file.bin", "sha256": "a" * 64, "size": 12, "filename": "file.bin", "mime_type": "application/octet-stream", }) assert cached is None @pytest.mark.asyncio async def test_download_allows_explicitly_configured_nondefault_port(self, monkeypatch): import httpx payload = b"x" real_async_client = httpx.AsyncClient def handler(request): assert request.url.port == 8443 return httpx.Response(200, content=payload, headers={"content-length": "1"}) monkeypatch.setattr( httpx, "AsyncClient", lambda **kwargs: real_async_client( transport=httpx.MockTransport(handler), **kwargs, ), ) adapter = _make_adapter({"attachment_hosts": ["test.relay:8443"]}) cached = await adapter._download_attachment({ "url": "https://test.relay:8443/media/file.bin", "sha256": hashlib.sha256(payload).hexdigest(), "size": 1, "filename": "file.bin", "mime_type": "application/octet-stream", }) assert cached is not None @pytest.mark.asyncio async def test_unaddressed_channel_attachment_is_not_downloaded(self): adapter = _make_adapter() authorization_check = MagicMock(return_value=True) adapter.set_authorization_check(authorization_check) adapter._cache_inbound_attachments = AsyncMock() adapter._download_attachment = AsyncMock() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event("unaddressed-attachment", content="shared file") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 12", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) authorization_check.assert_not_called() adapter._cache_inbound_attachments.assert_not_awaited() adapter._download_attachment.assert_not_awaited() @pytest.mark.asyncio async def test_failed_attachment_download_is_visible_to_agent(self): adapter = _make_adapter() adapter._download_attachment = AsyncMock(return_value=None) adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } dispatched = [] async def capture(**kwargs): dispatched.append(kwargs) adapter._dispatch_message = capture event = _event("failed-attachment", content="@Chip inspect this") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 12", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) assert "could not be downloaded" in dispatched[-1]["text"] assert dispatched[-1]["media_urls"] == [] @pytest.mark.asyncio async def test_dispatch_builds_document_message_event_with_cached_path(self): adapter = _make_adapter() adapter._message_handler = AsyncMock() adapter.handle_message = AsyncMock() await adapter._dispatch_message( text="inspect", chat_id=CHANNEL, chat_type="group", user_id=OTHER_PUBKEY, user_name="Other", message_id="document-event", created_at=1000, media_urls=["/agent/cache/doc_report.pdf"], media_types=["application/pdf"], message_type=MessageType.DOCUMENT, raw_message={"id": "document-event"}, ) call = adapter.handle_message.await_args assert call is not None dispatched_event = call.args[0] assert dispatched_event.message_type is MessageType.DOCUMENT assert dispatched_event.media_urls == ["/agent/cache/doc_report.pdf"] assert dispatched_event.media_types == ["application/pdf"] assert dispatched_event.media_text_inlined == [False] assert dispatched_event.raw_message == {"id": "document-event"} def test_imeta_sanitizes_filename_and_rejects_incomplete_metadata(self): event = _event("metadata", content="@Chip files") event["tags"].extend([ [ "imeta", "url https://test.relay/media/valid.bin", "m text/plain", "x " + "a" * 64, "size 12", "filename ../../private/report.txt", ], [ "imeta", "url http://test.relay/media/insecure.bin", "x " + "b" * 64, "size 12", "filename insecure.bin", ], [ "imeta", "url https://test.relay/media/no-hash.bin", "size 12", "filename no-hash.bin", ], ]) attachments = BuzzAdapter._imeta_attachments(event) assert len(attachments) == 1 assert attachments[0]["filename"] == "report.txt" @pytest.mark.asyncio async def test_attachment_only_dm_is_downloaded_and_dispatched(self): adapter = _make_adapter() attachment = CachedMedia( path="/agent/cache/doc_attachment.bin", media_type="application/octet-stream", kind="document", display_name="attachment.bin", ) adapter._download_attachment = AsyncMock(return_value=attachment) adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[DM_CHANNEL] = { "chat_type": "dm", "last_ts": 0, "seen": {}, } dispatched = [] async def capture(**kwargs): dispatched.append(kwargs) adapter._dispatch_message = capture event = _event("attachment-only", content="") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 12", "filename attachment.bin", ]) await adapter._handle_event( DM_CHANNEL, adapter._channel_state[DM_CHANNEL], event, ) adapter._download_attachment.assert_awaited_once() assert dispatched[-1]["media_urls"] == [attachment.path] assert dispatched[-1]["message_type"] is MessageType.DOCUMENT @pytest.mark.asyncio async def test_malformed_attachment_only_dm_dispatches_once_with_bounded_note(self): adapter = _make_adapter() adapter._download_attachment = AsyncMock() adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[DM_CHANNEL] = { "chat_type": "dm", "last_ts": 0, "seen": {}, } adapter._dispatch_message = AsyncMock() event = _event("malformed-attachment-only", content="") event["tags"].append(["imeta", "url definitely-not-a-url"]) await adapter._handle_event(DM_CHANNEL, adapter._channel_state[DM_CHANNEL], event) await adapter._handle_event(DM_CHANNEL, adapter._channel_state[DM_CHANNEL], event) adapter._download_attachment.assert_not_awaited() adapter._dispatch_message.assert_awaited_once() call = adapter._dispatch_message.await_args assert call is not None dispatched = call.kwargs assert "1 Buzz attachment(s) rejected" in dispatched["text"] assert len(dispatched["text"]) <= 80 assert dispatched["media_urls"] == [] @pytest.mark.asyncio async def test_excess_imeta_is_reported_while_accepted_files_remain_attached(self): adapter = _make_adapter() adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } adapter._download_attachment = AsyncMock( side_effect=lambda metadata: CachedMedia( f"/cache/{metadata['filename']}", metadata["mime_type"], "document", metadata["filename"], ) ) adapter._dispatch_message = AsyncMock() event = _event("excess-imeta", content="@Chip inspect") for index in range(5): event["tags"].append([ "imeta", f"url https://test.relay/media/{index}.bin", "m application/octet-stream", "x " + format(index + 1, "064x"), "size 1", f"filename {index}.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) assert adapter._download_attachment.await_count == 4 call = adapter._dispatch_message.await_args assert call is not None dispatched = call.kwargs assert "1 Buzz attachment(s) rejected" in dispatched["text"] assert len(dispatched["media_urls"]) == 4 def test_imeta_bounds_filename_to_filesystem_safe_utf8_length(self): event = _event("long-name", content="@Chip file") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename " + ("é" * 180) + ".pdf", ]) filename = BuzzAdapter._imeta_attachments(event)[0]["filename"] assert len(filename.encode("utf-8")) <= 120 assert filename.endswith(".pdf") @pytest.mark.asyncio async def test_cache_write_failure_is_treated_as_failed_attachment(self, monkeypatch): import httpx payload = b"x" digest = hashlib.sha256(payload).hexdigest() real_async_client = httpx.AsyncClient monkeypatch.setattr( httpx, "AsyncClient", lambda **kwargs: real_async_client( transport=httpx.MockTransport( lambda _request: httpx.Response( 200, content=payload, headers={"content-length": "1"}, ) ), **kwargs, ), ) monkeypatch.setattr( _buzz_mod, "cache_media_bytes_async", AsyncMock(side_effect=OSError(36, "File name too long")), ) adapter = _make_adapter() cached = await adapter._download_attachment({ "url": "https://test.relay/media/file.bin", "sha256": digest, "size": 1, "filename": "file.bin", "mime_type": "application/octet-stream", }) assert cached is None @pytest.mark.asyncio async def test_download_has_total_deadline(self, monkeypatch): import httpx payload = b"x" real_async_client = httpx.AsyncClient async def slow_handler(_request): await asyncio.sleep(0.05) return httpx.Response(200, content=payload, headers={"content-length": "1"}) monkeypatch.setattr(_buzz_mod, "_ATTACHMENT_DOWNLOAD_TIMEOUT", 0.01) monkeypatch.setattr( httpx, "AsyncClient", lambda **kwargs: real_async_client( transport=httpx.MockTransport(slow_handler), **kwargs, ), ) adapter = _make_adapter() cached = await adapter._download_attachment({ "url": "https://test.relay/media/file.bin", "sha256": hashlib.sha256(payload).hexdigest(), "size": 1, "filename": "file.bin", "mime_type": "application/octet-stream", }) assert cached is None @pytest.mark.asyncio async def test_multiple_mixed_attachments_use_document_semantics(self): adapter = _make_adapter() adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } cached = [ CachedMedia("/cache/image.png", "image/png", "image", "image.png"), CachedMedia("/cache/audio.mp3", "audio/mpeg", "audio", "audio.mp3"), ] adapter._cache_inbound_attachments = AsyncMock(return_value=cached) dispatched = [] async def capture(**kwargs): dispatched.append(kwargs) adapter._dispatch_message = capture event = _event("mixed", content="@Chip inspect") for index, mime_type in enumerate(("image/png", "audio/mpeg")): event["tags"].append([ "imeta", f"url https://test.relay/media/{index}.bin", f"m {mime_type}", "x " + format(index + 1, "064x"), "size 1", f"filename {index}.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) assert dispatched[-1]["message_type"] is MessageType.DOCUMENT assert dispatched[-1]["media_types"] == ["image/png", "audio/mpeg"] @pytest.mark.asyncio @pytest.mark.parametrize( ("status", "payload", "headers", "declared_size", "expected_digest"), [ (302, b"", {}, 1, "a" * 64), (200, b"x", {"content-length": "invalid"}, 1, hashlib.sha256(b"x").hexdigest()), (200, b"x", {}, 2, hashlib.sha256(b"x").hexdigest()), (200, b"xx", {}, 1, hashlib.sha256(b"xx").hexdigest()), (200, b"x", {}, 1, "a" * 64), ], ) async def test_download_rejects_invalid_response_or_integrity( self, monkeypatch, status, payload, headers, declared_size, expected_digest, ): import httpx real_async_client = httpx.AsyncClient monkeypatch.setattr( httpx, "AsyncClient", lambda **kwargs: real_async_client( transport=httpx.MockTransport( lambda _request: httpx.Response(status, content=payload, headers=headers) ), **kwargs, ), ) adapter = _make_adapter() cached = await adapter._download_attachment({ "url": "https://test.relay/media/file.bin", "sha256": expected_digest, "size": declared_size, "filename": "file.bin", "mime_type": "application/octet-stream", }) assert cached is None def test_imeta_rejects_url_credentials_and_fragments_and_caps_items(self): event = _event("url-safety", content="@Chip files") event["tags"].extend([ [ "imeta", "url https://user:password@test.relay/media/private.bin", "x " + "a" * 64, "size 1", "filename private.bin", ], [ "imeta", "url https://test.relay/media/fragment.bin#hidden", "x " + "b" * 64, "size 1", "filename fragment.bin", ], ]) for index in range(6): event["tags"].append([ "imeta", f"url https://test.relay/media/{index}.bin", "x " + format(index + 1, "064x"), "size 1", f"filename {index}.bin", ]) attachments = BuzzAdapter._imeta_attachments(event) assert len(attachments) == 4 assert all("@" not in item["url"] and "#" not in item["url"] for item in attachments) @pytest.mark.asyncio async def test_self_attachment_stops_before_authorization_or_cache(self): adapter = _make_adapter() authorization_check = MagicMock(return_value=True) adapter.set_authorization_check(authorization_check) adapter._cache_inbound_attachments = AsyncMock() adapter._dispatch_message = AsyncMock() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event("self-attachment", pubkey=SELF_PUBKEY, content="@Chip inspect") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) authorization_check.assert_not_called() adapter._cache_inbound_attachments.assert_not_awaited() adapter._dispatch_message.assert_not_awaited() @pytest.mark.asyncio async def test_unauthorized_sender_attachment_is_not_downloaded(self): adapter = _make_adapter() adapter._allowed_pubkeys = {"f" * 64} authorization_check = MagicMock(return_value=True) adapter.set_authorization_check(authorization_check) adapter._cache_inbound_attachments = AsyncMock() adapter._download_attachment = AsyncMock() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event("unauthorized-attachment", content="@Chip inspect") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) authorization_check.assert_not_called() adapter._cache_inbound_attachments.assert_not_awaited() adapter._download_attachment.assert_not_awaited() @pytest.mark.asyncio @pytest.mark.parametrize("authorization", [False, None, "raise", "truthy"]) async def test_non_true_gateway_authority_never_caches_even_for_locally_allowed_sender( self, authorization, ): adapter = _make_adapter() adapter._run_cli = AsyncMock(side_effect=AssertionError("authorization test invoked Buzz CLI")) adapter._resolve_user_name = AsyncMock(return_value="Other") adapter._allowed_pubkeys = {OTHER_PUBKEY} if authorization is None: adapter.set_authorization_check(None) elif authorization == "raise": def raise_unknown(*_args): raise RuntimeError("authorization backend unavailable") adapter.set_authorization_check(raise_unknown) elif authorization == "truthy": adapter.set_authorization_check(lambda *_args: "AUTHORIZED") else: adapter.set_authorization_check(lambda *_args: False) adapter._cache_inbound_attachments = AsyncMock() adapter._dispatch_message = AsyncMock() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event(f"gateway-{authorization}-attachment", content="@Chip inspect") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) adapter._cache_inbound_attachments.assert_not_awaited() adapter._dispatch_message.assert_awaited_once() call = adapter._dispatch_message.await_args assert call is not None assert call.kwargs["media_urls"] == [] @pytest.mark.asyncio async def test_explicit_true_gateway_authority_caches_attachment(self): adapter = _make_adapter() adapter._run_cli = AsyncMock(side_effect=AssertionError("authorization test invoked Buzz CLI")) adapter._resolve_user_name = AsyncMock(return_value="Other") authorization_check = MagicMock(return_value=True) adapter.set_authorization_check(authorization_check) cached = CachedMedia( "/cache/authorized.bin", "application/octet-stream", "document", "authorized.bin", ) adapter._cache_inbound_attachments = AsyncMock(return_value=[cached]) adapter._dispatch_message = AsyncMock() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event("gateway-authorized-attachment", content="@Chip inspect") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) authorization_check.assert_called_once_with(OTHER_PUBKEY, "group", CHANNEL) adapter._cache_inbound_attachments.assert_awaited_once() call = adapter._dispatch_message.await_args assert call is not None assert call.kwargs["media_urls"] == [cached.path] @pytest.mark.asyncio async def test_real_gateway_auth_callback_defaults_to_no_attachment_side_effects( self, monkeypatch, ): from gateway.config import GatewayConfig from gateway.run import GatewayRunner monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False) monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) runner = object.__new__(GatewayRunner) runner.config = GatewayConfig() runner.adapters = {} runner.pairing_store = MagicMock() runner.pairing_store.is_approved.return_value = False adapter = _make_adapter() adapter._run_cli = AsyncMock(side_effect=AssertionError("authorization test invoked Buzz CLI")) adapter._resolve_user_name = AsyncMock(return_value="Other") adapter._allowed_pubkeys = {OTHER_PUBKEY} adapter.set_authorization_check(runner._make_adapter_auth_check(adapter.platform)) adapter._cache_inbound_attachments = AsyncMock() adapter._dispatch_message = AsyncMock() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } event = _event("gateway-default-denied-attachment", content="@Chip inspect") event["tags"].append([ "imeta", "url https://test.relay/media/file.bin", "m application/octet-stream", "x " + "a" * 64, "size 1", "filename file.bin", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) runner.pairing_store.is_approved.assert_called_once_with("buzz", OTHER_PUBKEY) adapter._cache_inbound_attachments.assert_not_awaited() adapter._dispatch_message.assert_awaited_once() @pytest.mark.asyncio @pytest.mark.parametrize( ("kind", "mime_type", "expected_type"), [ ("image", "image/png", MessageType.PHOTO), ("video", "video/mp4", MessageType.VIDEO), ("audio", "audio/mpeg", MessageType.AUDIO), ("document", "application/pdf", MessageType.DOCUMENT), ], ) async def test_homogeneous_attachment_kind_sets_message_type( self, kind, mime_type, expected_type, ): adapter = _make_adapter() adapter._user_names[OTHER_PUBKEY] = "Other" adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } cached = CachedMedia( f"/cache/file-{kind}", mime_type, kind, f"file-{kind}", ) adapter._cache_inbound_attachments = AsyncMock(return_value=[cached]) dispatched = [] async def capture(**kwargs): dispatched.append(kwargs) adapter._dispatch_message = capture event = _event(f"homogeneous-{kind}", content="@Chip inspect") event["tags"].append([ "imeta", f"url https://test.relay/media/{kind}", f"m {mime_type}", "x " + "a" * 64, "size 1", f"filename file-{kind}", ]) await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) assert dispatched[-1]["message_type"] is expected_type # ── Mention gating / DMs / authorization ────────────────────────────────── class TestMentionGating: @pytest.fixture def adapter(self): a = _make_adapter() a._dispatched = [] async def capture(**kwargs): a._dispatched.append(kwargs) a._dispatch_message = capture a._message_handler = AsyncMock() a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} return a async def _poll_with(self, adapter, *events): cli = _ScriptedCli() cli.script("messages", "get", list(events)) adapter._run_cli = cli await adapter._poll_channel(CHANNEL) @pytest.mark.asyncio async def test_unaddressed_channel_message_ignored(self, adapter): await self._poll_with(adapter, _event("e1", content="just chatting", created_at=10)) assert adapter._dispatched == [] @pytest.mark.asyncio async def test_name_mention_dispatched(self, adapter): await self._poll_with(adapter, _event("e1", content="hey @Chip can you help?", created_at=10)) assert len(adapter._dispatched) == 1 assert adapter._dispatched[0]["text"] == "hey @Chip can you help?" @pytest.mark.asyncio @pytest.mark.parametrize( "content", [ "Chip should stay silent", "ask @Chipmunk instead", "ask @Chip-bot instead", "email chip@example.com", ], ) async def test_bare_or_prefix_name_does_not_dispatch(self, adapter, content): await self._poll_with(adapter, _event("e1", content=content, created_at=10)) assert adapter._dispatched == [] @pytest.mark.asyncio @pytest.mark.parametrize("identity", [SELF_NPUB, SELF_PUBKEY]) async def test_identity_text_dispatches(self, adapter, identity): await self._poll_with( adapter, _event("e1", content=f"please check {identity}", created_at=10), ) assert len(adapter._dispatched) == 1 @pytest.mark.asyncio @pytest.mark.parametrize( "content", [f"a{SELF_PUBKEY}b", f"x{SELF_NPUB}y"], ) async def test_identity_substring_does_not_dispatch(self, adapter, content): await self._poll_with( adapter, _event("e1", content=content, created_at=10), ) assert adapter._dispatched == [] @pytest.mark.asyncio async def test_signed_recipient_tag_dispatches_without_text_mention(self, adapter): event = _event("e1", content="please take a look", created_at=10) event["tags"].append(["p", SELF_PUBKEY]) await self._poll_with(adapter, event) assert len(adapter._dispatched) == 1 @pytest.mark.asyncio async def test_other_recipient_tag_does_not_dispatch(self, adapter): event = _event("e1", content="please take a look", created_at=10) event["tags"].append(["p", "b" * 64]) await self._poll_with(adapter, event) assert adapter._dispatched == [] @pytest.mark.asyncio async def test_require_mention_false_still_dispatches_unaddressed_message(self, adapter): adapter.require_mention = False await self._poll_with(adapter, _event("e1", content="just chatting", created_at=10)) assert len(adapter._dispatched) == 1 def test_strip_mention_requires_at_for_display_name(self, adapter): assert adapter._strip_mention("@Chip: /whoami") == "/whoami" assert adapter._strip_mention("Chip: please review") == "Chip: please review" assert adapter._strip_mention("@Chip-bot: please review") == "@Chip-bot: please review" @pytest.mark.asyncio async def test_allowlist_blocks_unauthorized(self, adapter): adapter._allowed_pubkeys = {"b" * 64} await self._poll_with(adapter, _event("e1", content="@Chip hello", created_at=10)) assert adapter._dispatched == [] @pytest.mark.asyncio async def test_explicit_agent_tag_reacts_without_dispatch(self, adapter): adapter._allowed_pubkeys = {OTHER_PUBKEY} adapter._reaction_only_pubkeys = {AGENT_PUBKEY} adapter.send_reaction = AsyncMock(return_value=True) event = _event("e1", pubkey=AGENT_PUBKEY, content="@Chip coordinate", created_at=10) event["tags"].append(["p", SELF_PUBKEY]) await self._poll_with(adapter, event) adapter.send_reaction.assert_awaited_once_with(CHANNEL, "e1", "👀") assert adapter._dispatched == [] @pytest.mark.asyncio async def test_allowlist_takes_precedence_over_reaction_only(self, adapter): adapter._allowed_pubkeys = {AGENT_PUBKEY} adapter._reaction_only_pubkeys = {AGENT_PUBKEY} adapter.send_reaction = AsyncMock(return_value=True) event = _event("e1", pubkey=AGENT_PUBKEY, content="@Chip coordinate", created_at=10) event["tags"].append(["p", SELF_PUBKEY]) await self._poll_with(adapter, event) adapter.send_reaction.assert_not_awaited() assert len(adapter._dispatched) == 1 @pytest.mark.asyncio async def test_agent_message_without_explicit_recipient_gets_no_reaction(self, adapter): adapter._allowed_pubkeys = {OTHER_PUBKEY} adapter._reaction_only_pubkeys = {AGENT_PUBKEY} adapter.send_reaction = AsyncMock(return_value=True) await self._poll_with( adapter, _event("e1", pubkey=AGENT_PUBKEY, content="@Chip coordinate", created_at=10), ) adapter.send_reaction.assert_not_awaited() assert adapter._dispatched == [] @pytest.mark.asyncio async def test_unknown_sender_tag_gets_no_reaction(self, adapter): adapter._allowed_pubkeys = {OTHER_PUBKEY} adapter._reaction_only_pubkeys = {AGENT_PUBKEY} adapter.send_reaction = AsyncMock(return_value=True) event = _event("e1", pubkey="c" * 64, content="@Chip coordinate", created_at=10) event["tags"].append(["p", SELF_PUBKEY]) await self._poll_with(adapter, event) adapter.send_reaction.assert_not_awaited() assert adapter._dispatched == [] # ── NIP-10 thread replies as addressed (issue #75826) ──────────────────── # # With require_mention (default), channel replies whose direct parent is the # agent's own message must dispatch even when the text has no @name — Buzz # Desktop's natural reply affordance for /approve never types a mention. def _tagged_event(event_id, channel, *, content, pubkey=OTHER_PUBKEY, created_at=1000, kind=9, p=None, reply_to=None, root=None): """Event with the tag shapes observed on a live relay (h/p/e tags).""" tags = [["h", channel]] # NIP-10 order as Desktop emits: root first, then reply (when both set). if root: tags.append(["e", root, "", "root"]) if reply_to: tags.append(["e", reply_to, "", "reply"]) if p: tags.append(["p", p]) return { "id": event_id, "pubkey": pubkey, "content": content, "created_at": created_at, "kind": kind, "tags": tags, } class TestNip10ThreadReplyMentionGate: """require_mention + NIP-10 reply-to-own-message (#75826).""" @pytest.fixture def adapter(self): a = _make_adapter() a._dispatched = [] async def capture(**kwargs): a._dispatched.append(kwargs) a._dispatch_message = capture a._message_handler = AsyncMock() a._channel_state[CHANNEL] = a._new_channel_state("group") return a async def _poll_with(self, adapter, *events): cli = _ScriptedCli() cli.script("messages", "get", list(events)) adapter._run_cli = cli await adapter._poll_channel(CHANNEL) def test_event_reply_parent_prefers_reply_marker(self): ev = _tagged_event( "child", CHANNEL, content="ok", root="root-id", reply_to="parent-id" ) assert _event_reply_parent_id(ev) == "parent-id" assert _event_reply_parent_id( _tagged_event("c2", CHANNEL, content="ok", root="only-root") ) == "only-root" @pytest.mark.asyncio async def test_thread_reply_to_own_message_dispatches_without_mention(self, adapter): # Live agent prompt lands first (self-echo is cached, not dispatched). await self._poll_with( adapter, _tagged_event( "agent-prompt", CHANNEL, content="⚠️ Dangerous command requires approval", pubkey=SELF_PUBKEY, created_at=10, ), _tagged_event( "user-reply", CHANNEL, content="sure go ahead", root="agent-prompt", reply_to="agent-prompt", created_at=11, ), ) assert [d["message_id"] for d in adapter._dispatched] == ["user-reply"] assert adapter._dispatched[0]["text"] == "sure go ahead" assert adapter._dispatched[0]["reply_to_message_id"] == "agent-prompt" assert adapter._dispatched[0]["reply_to_is_own_message"] is True assert "approval" in (adapter._dispatched[0]["reply_to_text"] or "") @pytest.mark.asyncio async def test_approve_thread_reply_dispatches(self, adapter): await self._poll_with( adapter, _tagged_event( "agent-approve-prompt", CHANNEL, content="⚠️ Dangerous command requires approval", pubkey=SELF_PUBKEY, created_at=20, ), _tagged_event( "approve-msg", CHANNEL, content="/approve session", root="agent-approve-prompt", reply_to="agent-approve-prompt", created_at=21, ), ) assert [d["message_id"] for d in adapter._dispatched] == ["approve-msg"] assert adapter._dispatched[0]["text"] == "/approve session" assert adapter._dispatched[0]["reply_to_is_own_message"] is True @pytest.mark.asyncio async def test_reply_to_other_user_stays_gated(self, adapter): third = "c" * 64 await self._poll_with( adapter, _tagged_event( "other-msg", CHANNEL, content="anyone around?", pubkey=third, created_at=30, ), _tagged_event( "reply-other", CHANNEL, content="yeah I'm here", root="other-msg", reply_to="other-msg", created_at=31, ), ) assert adapter._dispatched == [] @pytest.mark.asyncio async def test_reply_to_unknown_parent_stays_gated(self, adapter): await self._poll_with( adapter, _tagged_event( "orphan-reply", CHANNEL, content="/approve session", root="never-seen", reply_to="never-seen", created_at=40, ), ) assert adapter._dispatched == [] @pytest.mark.asyncio async def test_seeded_own_history_matches_thread_reply(self, adapter): """Replies to agent messages sent before a gateway restart still match.""" cli = _ScriptedCli() cli.script( "messages", "get", [ _tagged_event( "pre-restart-agent", CHANNEL, content="⚠️ Dangerous command requires approval", pubkey=SELF_PUBKEY, created_at=50, ), ], ) adapter._run_cli = cli await adapter._seed_channel(CHANNEL, chat_type="group") assert "pre-restart-agent" in adapter._channel_state[CHANNEL]["event_meta"] assert adapter._dispatched == [] cli.responses.clear() cli.script( "messages", "get", [ _tagged_event( "post-restart-approve", CHANNEL, content="/approve always", root="pre-restart-agent", reply_to="pre-restart-agent", created_at=51, ), ], ) await adapter._poll_channel(CHANNEL) assert [d["message_id"] for d in adapter._dispatched] == ["post-restart-approve"] assert adapter._dispatched[0]["reply_to_is_own_message"] is True @pytest.mark.asyncio async def test_send_recorded_id_matches_thread_reply(self, adapter): """send()'s returned event_id is cached even without a WS/poll echo.""" cli = _ScriptedCli() cli.script( "messages", "send", {"accepted": True, "event_id": "sent-prompt", "message": ""}, ) adapter._run_cli = cli result = await adapter.send( CHANNEL, "⚠️ Dangerous command requires approval" ) assert result.success is True assert "sent-prompt" in adapter._channel_state[CHANNEL]["event_meta"] meta = adapter._channel_state[CHANNEL]["event_meta"]["sent-prompt"] assert meta[0] == SELF_PUBKEY cli.responses.clear() cli.script( "messages", "get", [ _tagged_event( "reply-to-send", CHANNEL, content="/approve session", root="sent-prompt", reply_to="sent-prompt", created_at=61, ), ], ) await adapter._poll_channel(CHANNEL) assert [d["message_id"] for d in adapter._dispatched] == ["reply-to-send"] assert adapter._dispatched[0]["reply_to_is_own_message"] is True assert adapter._dispatched[0]["reply_to_message_id"] == "sent-prompt" @pytest.mark.asyncio async def test_mention_path_still_populates_reply_context(self, adapter): """Visible @mention + thread reply still fills reply_to_* on dispatch.""" await self._poll_with( adapter, _tagged_event( "agent-prior", CHANNEL, content="previous answer", pubkey=SELF_PUBKEY, created_at=70, ), _tagged_event( "mentioned-reply", CHANNEL, content="@Chip follow up please", root="agent-prior", reply_to="agent-prior", created_at=71, ), ) assert len(adapter._dispatched) == 1 d = adapter._dispatched[0] assert d["message_id"] == "mentioned-reply" assert d["text"] == "follow up please" # leading @Chip stripped assert d["reply_to_message_id"] == "agent-prior" assert d["reply_to_author_id"] == SELF_PUBKEY assert d["reply_to_is_own_message"] is True assert d["reply_to_text"] == "previous answer" # ── DM classification via p-tags (issue #68871) ────────────────────────── # # `buzz dms list` returns [] on some hosted relays, so DM conversations leak # in via `channels list` and get seeded chat_type="group". The adapter must # reclassify them from the Nostr tags of real traffic: DM messages are # p-tagged to our own pubkey WITHOUT the text mentioning us, while channel # messages only ever p-tag us when the text visibly @mentions us. class TestDmClassification: @pytest.fixture def adapter(self): a = _make_adapter() a._dispatched = [] async def capture(**kwargs): a._dispatched.append(kwargs) a._dispatch_message = capture a._message_handler = AsyncMock() # Metadata exactly as `channels list` returns it on the hosted relay. a._channel_meta = { DM_CHANNEL: {"channel_id": DM_CHANNEL, "name": "DM", "description": ""}, CHANNEL: { "channel_id": CHANNEL, "name": "general", "description": "General conversation and community updates.", }, } a._channel_names = {DM_CHANNEL: "DM", CHANNEL: "general"} # Both leaked in as group — the bug under test. a._channel_state[DM_CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} a._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} return a async def _poll_with(self, adapter, channel, *events): cli = _ScriptedCli() cli.script("messages", "get", list(events)) adapter._run_cli = cli await adapter._poll_channel(channel) @pytest.mark.asyncio async def test_unmentioned_ptagged_dm_latches_and_dispatches(self, adapter): """The reported bug: a DM without an @mention must dispatch.""" await self._poll_with( adapter, DM_CHANNEL, _tagged_event("e1", DM_CHANNEL, content="here's a test message", p=SELF_PUBKEY), ) assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "dm" assert [d["message_id"] for d in adapter._dispatched] == ["e1"] assert adapter._dispatched[0]["chat_type"] == "dm" @pytest.mark.asyncio async def test_general_reply_ptagging_self_stays_channel(self, adapter): """A #general reply to us p-tags our pubkey (observed live) — that must NOT reclassify the channel; mention gating still applies.""" await self._poll_with( adapter, CHANNEL, _tagged_event("e1", CHANNEL, content="@chip what's up?", p=SELF_PUBKEY, reply_to="root-event"), ) assert adapter._channel_state[CHANNEL]["chat_type"] == "group" # It carried a mention, so it dispatches — but as a group message. assert [d["chat_type"] for d in adapter._dispatched] == ["group"] # And once the mention is absent, the channel gate drops the message # even though the earlier reply p-tagged us. await self._poll_with( adapter, CHANNEL, _tagged_event("e2", CHANNEL, content="thanks everyone", created_at=1001), ) assert len(adapter._dispatched) == 1 @pytest.mark.asyncio async def test_channel_ptag_dispatches_without_latching(self, adapter): """A signed recipient tag wakes a channel without turning it into a DM.""" adapter._channel_meta[CHANNEL]["description"] = "" adapter._channel_meta[CHANNEL]["name"] = "announcements" await self._poll_with( adapter, CHANNEL, _tagged_event("e1", CHANNEL, content="fyi everyone", p=SELF_PUBKEY), ) assert adapter._channel_state[CHANNEL]["chat_type"] == "group" assert [d["message_id"] for d in adapter._dispatched] == ["e1"] await self._poll_with( adapter, CHANNEL, _tagged_event( "e2", CHANNEL, content="plain follow-up", created_at=1001, p=None ), ) assert [d["message_id"] for d in adapter._dispatched] == ["e1"] @pytest.mark.asyncio async def test_missing_metadata_never_latches_group_as_dm(self, adapter): adapter._channel_meta.pop(CHANNEL) await self._poll_with( adapter, CHANNEL, _tagged_event("e1", CHANNEL, content="tag-only mention", p=SELF_PUBKEY), ) assert adapter._channel_state[CHANNEL]["chat_type"] == "group" assert [d["message_id"] for d in adapter._dispatched] == ["e1"] assert adapter._may_reclassify_as_dm(CHANNEL) is False @pytest.mark.asyncio async def test_dm_shaped_channel_discovered_when_dms_list_empty(self): """Fallback discovery: with `dms list` broken (returns []), a DM-shaped `channels list` entry gets watched. In watch-all mode a real channel is adopted too (live join, #75107) — but seeded from history, so nothing is replayed.""" a = _make_adapter() cli = _ScriptedCli() cli.script("dms", "list", []) cli.script("channels", "list", [ {"channel_id": DM_CHANNEL, "name": "DM", "description": "", "created_at": 1}, {"channel_id": CHANNEL, "name": "general", "description": "General conversation and community updates.", "created_at": 2}, ]) a._run_cli = cli await a._discover_dms(seed=False) assert a._channel_state[DM_CHANNEL]["chat_type"] == "dm" assert a._may_reclassify_as_dm(DM_CHANNEL) is True # Watch-all mode: the real channel is live-adopted (seeded, never # reclassified as DM). assert CHANNEL in a._channel_state assert a._channel_state[CHANNEL]["chat_type"] == "group" assert a._may_reclassify_as_dm(CHANNEL) is False # Adoption seeded it via a messages get call (history suppressed). assert any(c[0][:2] == ["messages", "get"] for c in cli.calls) @pytest.mark.asyncio async def test_explicit_watch_list_blocks_live_channel_adoption(self): """With an explicit channels: list, discovery must NOT adopt real channels outside that list — the user chose the watch set (#75107 scoping).""" a = _make_adapter(extra={"channels": ["some-other-channel"]}) cli = _ScriptedCli() cli.script("dms", "list", []) cli.script("channels", "list", [ {"channel_id": CHANNEL, "name": "general", "description": "General conversation and community updates.", "created_at": 2}, ]) a._run_cli = cli await a._discover_dms(seed=False) assert CHANNEL not in a._channel_state @pytest.mark.asyncio async def test_dm_metadata_promotes_existing_group_without_recipient_tag(self, adapter): cli = _ScriptedCli() cli.script("dms", "list", []) cli.script("channels", "list", [adapter._channel_meta[DM_CHANNEL]]) adapter._run_cli = cli await adapter._discover_dms(seed=False) assert adapter._channel_state[DM_CHANNEL]["chat_type"] == "dm" await self._poll_with( adapter, DM_CHANNEL, _tagged_event("e1", DM_CHANNEL, content="no mention and no p tag"), ) assert [d["message_id"] for d in adapter._dispatched] == ["e1"] assert adapter._dispatched[0]["chat_type"] == "dm" class TestThreadRoots: @pytest.mark.asyncio async def test_inbound_root_e_tag_propagates_to_session_source(self): adapter = _make_adapter() adapter._channel_state[CHANNEL] = { "chat_type": "group", "last_ts": 0, "seen": {}, } dispatched = [] async def capture(event): dispatched.append(event) adapter._message_handler = AsyncMock() adapter.handle_message = capture adapter._run_cli = _ScriptedCli() adapter.send_reaction = AsyncMock(return_value=True) event = _tagged_event("latest-child", CHANNEL, content="@Chip follow-up") event["tags"] += [ ["e", "stable-root", "", "root"], ["e", "latest-parent", "", "reply"], ] await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) assert dispatched[0].source.thread_id == "stable-root" # ── Sending ─────────────────────────────────────────────────────────────── class TestBuzzAdapterSend: @pytest.mark.asyncio async def test_send_success_via_stdin(self): adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt123", "message": ""}) adapter._run_cli = cli result = await adapter.send(CHANNEL, "hello **markdown**") assert result.success is True assert result.message_id == "evt123" args, stdin_text = cli.calls[0] assert args[:2] == ["messages", "send"] assert args[args.index("--channel") + 1] == CHANNEL # Content travels via stdin (--content -), never argv assert args[args.index("--content") + 1] == "-" assert stdin_text == "hello **markdown**" # Our own event id is marked seen for echo suppression assert "evt123" in adapter._channel_state[CHANNEL]["seen"] @pytest.mark.asyncio async def test_send_metadata_thread_id_uses_reply_to_flag(self): adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt124", "message": ""}) adapter._run_cli = cli result = await adapter.send( CHANNEL, "working", metadata={"thread_id": "buzz-event-123"}, ) assert result.success is True args, _stdin = cli.calls[0] assert args[args.index("--reply-to") + 1] == "buzz-event-123" @pytest.mark.asyncio async def test_send_uses_metadata_reply_to_message_id(self): """Gateway stream/progress pass reply anchors via metadata. Without honoring reply_to_message_id, mid-turn commentary posts as new top-level channel messages instead of thread replies. """ adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt-reply", "message": ""}) adapter._run_cli = cli result = await adapter.send( CHANNEL, "threaded reply", metadata={"reply_to_message_id": "root-event-abc"}, ) assert result.success is True args, _stdin = cli.calls[0] assert "--reply-to" in args assert args[args.index("--reply-to") + 1] == "root-event-abc" @pytest.mark.asyncio async def test_send_prefers_stable_thread_root_over_latest_reply(self): adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt124"}) adapter._run_cli = cli await adapter.send( CHANNEL, "threaded reply", reply_to="latest-child", metadata={"thread_id": "stable-root"}, ) args, _stdin = cli.calls[0] assert args[args.index("--reply-to") + 1] == "stable-root" @pytest.mark.asyncio @pytest.mark.parametrize( ("stdout", "error_fragment"), [ ("not json", "invalid CLI response"), (json.dumps([]), "invalid CLI response"), (json.dumps({"event_id": "evt"}), "invalid CLI response"), (json.dumps({"accepted": True}), "invalid CLI response"), (json.dumps({"accepted": True, "event_id": " "}), "invalid CLI response"), ], ) async def test_send_rejects_invalid_zero_exit_receipt(self, stdout, error_fragment): adapter = _make_adapter() adapter._run_cli = AsyncMock(return_value=(0, stdout, "")) result = await adapter.send(CHANNEL, "hello") assert result.success is False assert error_fragment in result.error assert result.message_id is None assert result.raw_response is None @pytest.mark.asyncio async def test_send_rejection_is_useful_and_bounded(self): adapter = _make_adapter() adapter._run_cli = AsyncMock( return_value=( 0, json.dumps({"accepted": False, "message": "upload rejected " + "x" * 100_000}), "", ) ) result = await adapter.send(CHANNEL, "hello") assert result.success is False assert "upload rejected" in result.error assert len(result.error) <= 1024 assert result.raw_response is None @pytest.mark.asyncio async def test_send_success_exposes_only_verified_receipt(self): adapter = _make_adapter() adapter._run_cli = AsyncMock( return_value=( 0, json.dumps({ "accepted": True, "event_id": " evt-safe ", "message": "x" * 100_000, "raw_response": "y" * 100_000, }), "", ) ) result = await adapter.send(CHANNEL, "hello") assert result.success is True assert result.message_id == "evt-safe" assert result.raw_response is None @pytest.mark.asyncio async def test_send_image_local_file_uses_file_flag(self, tmp_path): img = tmp_path / "shot.png" img.write_bytes(b"\x89PNG fake") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt126", "message": ""}) adapter._run_cli = cli result = await adapter.send_image(CHANNEL, str(img), caption="screenshot") assert result.success is True args, _stdin = cli.calls[0] assert args[args.index("--file") + 1] == str(img) @pytest.mark.asyncio async def test_send_image_local_file_prefers_stable_thread_root(self, tmp_path): img = tmp_path / "shot.png" img.write_bytes(b"\x89PNG fake") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt127"}) adapter._run_cli = cli await adapter.send_image( CHANNEL, str(img), reply_to="latest-child", metadata={"thread_id": "stable-root"}, ) args, _stdin = cli.calls[0] assert args[args.index("--reply-to") + 1] == "stable-root" @pytest.mark.asyncio async def test_send_retries_unresolved_presentation_mention_without_notifying(self): adapter = _make_adapter() cli = _ScriptedCli() cli.script( "messages", "send", "", code=1, stderr=( "mention '@session' does not match a current channel member; " "retry with --mention " ), ) cli.script( "messages", "send", {"accepted": True, "event_id": "evt124", "message": ""}, ) adapter._run_cli = cli result = await adapter.send( CHANNEL, "Continue in @session:default/20260809_092321_24aa09.", ) assert result.success is True assert result.message_id == "evt124" # Composed with #83414 mention resolution: resolution probes # (channels members / messages get) precede the sends; assert on the # publish calls only. sends = [c for c in cli.calls if tuple(c[0][:2]) == ("messages", "send")] assert len(sends) == 2 assert sends[0][1] == ( "Continue in @session:default/20260809_092321_24aa09." ) assert sends[1][1] == ( "Continue in @\u200bsession:default/20260809_092321_24aa09." ) @pytest.mark.asyncio async def test_send_does_not_retry_unrelated_cli_failure(self): adapter = _make_adapter() cli = _ScriptedCli() cli.script( "messages", "send", "", code=1, stderr="relay unavailable", ) adapter._run_cli = cli result = await adapter.send(CHANNEL, "hello @session") assert result.success is False # Unrelated failures never retry the publish (mention-resolution # probes for "@" content are not sends). sends = [c for c in cli.calls if tuple(c[0][:2]) == ("messages", "send")] assert len(sends) == 1 @pytest.mark.asyncio async def test_send_image_retries_unresolved_presentation_mention(self, tmp_path): img = tmp_path / "shot.png" img.write_bytes(b"\x89PNG fake") adapter = _make_adapter() cli = _ScriptedCli() cli.script( "messages", "send", "", code=1, stderr=( "mention '@session' does not match a current channel member; " "retry with --mention " ), ) cli.script( "messages", "send", {"accepted": True, "event_id": "evt127", "message": ""}, ) adapter._run_cli = cli result = await adapter.send_image( CHANNEL, str(img), caption="See @session:default/example.", ) assert result.success is True assert len(cli.calls) == 2 assert cli.calls[0][1] == "See @session:default/example." assert cli.calls[1][1] == "See @\u200bsession:default/example." @pytest.mark.asyncio async def test_send_image_file_existing_local_path_stays_native_upload_after_probe_flip( self, tmp_path, monkeypatch ): img = tmp_path / "shot.png" img.write_bytes(b"\x89PNG fake") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt126b", "message": ""}) adapter._run_cli = cli original_is_file = _buzz_mod.Path.is_file probe_results = iter([True, False]) def sequential_is_file(path): if path == img: return next(probe_results, original_is_file(path)) return original_is_file(path) monkeypatch.setattr(_buzz_mod.Path, "is_file", sequential_is_file) result = await adapter.send_image_file(CHANNEL, str(img), caption="screenshot") assert result.success is True args, stdin_text = cli.calls[0] assert args[:2] == ["messages", "send"] assert args[args.index("--channel") + 1] == CHANNEL assert args[args.index("--file") + 1] == str(img) assert args[args.index("--content") + 1] == "-" assert stdin_text == "screenshot" @pytest.mark.asyncio async def test_send_image_file_uses_metadata_thread_id_when_reply_to_missing(self, tmp_path): img = tmp_path / "reply-shot.png" img.write_bytes(b"\x89PNG fake") thread_id = "b" * 64 adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt126c", "message": ""}) adapter._run_cli = cli result = await adapter.send_image_file( CHANNEL, str(img), caption="screenshot", metadata={"thread_id": thread_id}, ) assert result.success is True args, stdin_text = cli.calls[0] assert args[:2] == ["messages", "send"] assert args[args.index("--channel") + 1] == CHANNEL assert args[args.index("--file") + 1] == str(img) assert args[args.index("--content") + 1] == "-" assert args[args.index("--reply-to") + 1] == thread_id assert stdin_text == "screenshot" @pytest.mark.asyncio async def test_send_image_file_missing_local_path_uses_base_fallback_notice(self, tmp_path): missing = tmp_path / "missing.png" adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt127", "message": ""}) adapter._run_cli = cli result = await adapter.send_image_file(CHANNEL, str(missing), caption="screenshot") assert result.success is True args, stdin_text = cli.calls[0] assert args[:2] == ["messages", "send"] assert "--file" not in args assert str(missing) not in args assert stdin_text == "screenshot\n⚠️ Couldn't deliver the image attachment." assert str(missing) not in stdin_text @pytest.mark.asyncio async def test_send_document_uses_native_file_flag(self, tmp_path): document = tmp_path / "package.zip" document.write_bytes(b"PK fake") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt128", "message": ""}) adapter._run_cli = cli result = await adapter.send_document(CHANNEL, str(document), caption="files") assert result.success is True args, stdin_text = cli.calls[0] assert args[args.index("--file") + 1] == str(document) assert stdin_text == "files" @pytest.mark.asyncio async def test_send_multiple_images_file_url_uses_native_file_send(self, tmp_path): img = tmp_path / "shot with spaces.png" img.write_bytes(b"\x89PNG fake") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt127", "message": ""}) adapter._run_cli = cli await adapter.send_multiple_images(CHANNEL, [(img.as_uri(), "screenshot")]) assert len(cli.calls) == 1 args, stdin_text = cli.calls[0] assert args[:2] == ["messages", "send"] assert args[args.index("--channel") + 1] == CHANNEL assert args[args.index("--file") + 1] == str(img) assert args[args.index("--content") + 1] == "-" assert stdin_text == "screenshot" assert "Couldn't deliver the image attachment." not in stdin_text # ── Thread anchoring ────────────────────────────────────────────────────── class TestThreadAnchoring: """A reply must JOIN the thread it was triggered from, not nest a new one. The gateway hands adapters the triggering message's own id as the reply anchor. For a top-level message that correctly opens a thread; for a message already inside a thread it used to nest a fresh sub-thread under every answer (an endless ladder of one-message threads in Buzz). """ @staticmethod def _event(eid, *e_tags): return {"id": eid, "tags": [["h", CHANNEL], *e_tags]} def test_top_level_message_has_no_root(self): a = _make_adapter() assert a._extract_thread_root(self._event("m1")) is None def test_thread_opener_roots_at_its_parent(self): a = _make_adapter() ev = self._event("m2", ["e", "root1", "", "reply"]) assert a._extract_thread_root(ev) == "root1" def test_in_thread_message_uses_root_marker_not_parent(self): a = _make_adapter() ev = self._event("m3", ["e", "root1", "", "root"], ["e", "m2", "", "reply"]) assert a._extract_thread_root(ev) == "root1" def test_legacy_unmarked_etag_treated_as_parent(self): a = _make_adapter() assert a._extract_thread_root(self._event("m4", ["e", "root1"])) == "root1" def test_reply_to_top_level_still_opens_a_thread(self): """Regression guard: the original (correct) behaviour must survive.""" a = _make_adapter() a._record_thread_root("m1", self._event("m1")) assert a._resolve_reply_anchor("m1") == "m1" def test_reply_inside_thread_joins_that_thread(self): a = _make_adapter() a._record_thread_root("m3", self._event( "m3", ["e", "root1", "", "root"], ["e", "m2", "", "reply"])) assert a._resolve_reply_anchor("m3") == "root1" def test_unknown_and_empty_anchors_pass_through(self): a = _make_adapter() assert a._resolve_reply_anchor("never-seen") == "never-seen" assert a._resolve_reply_anchor(None) is None def test_root_cache_is_bounded_and_evicts_oldest(self): a = _make_adapter() for i in range(a._THREAD_ROOT_CACHE + 50): a._record_thread_root(f"id{i}", self._event(f"id{i}")) assert len(a._thread_roots) == a._THREAD_ROOT_CACHE assert "id0" not in a._thread_roots assert f"id{a._THREAD_ROOT_CACHE + 49}" in a._thread_roots @pytest.mark.asyncio async def test_send_anchors_to_root_not_trigger(self): """End-to-end through send(): argv must carry the thread root.""" adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "dm", "last_ts": 0, "seen": {}} adapter._record_thread_root("m3", self._event( "m3", ["e", "root1", "", "root"], ["e", "m2", "", "reply"])) cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "e9", "message": ""}) adapter._run_cli = cli await adapter.send(CHANNEL, "in-thread answer", reply_to="m3") args, _stdin = cli.calls[0] assert args[args.index("--reply-to") + 1] == "root1" # ── Inbound media localisation ───────────────────────────────────────────────── class TestInboundMediaLocalisation: @staticmethod def _capture_dispatch(adapter, *, failed_urls=None): captured = [] cli_calls = [] failed_urls = set(failed_urls or []) async def capture(event): captured.append(event) async def cli(args, *, input_text=None): cli_calls.append(list(args)) assert args[:2] == ["media", "get"] url = args[-1] if url in failed_urls: return 2, "", '{"error":"relay_error","message":"denied"}' output_path = args[args.index("-o") + 1] if url.endswith(".jpg"): payload = b"\xff\xd8\xff\xe0JFIF test image" elif url.endswith(".pdf"): payload = b"%PDF-1.4\n% test document\n" else: payload = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk" "+A8AAQUBAScY42YAAAAASUVORK5CYII=" ) with open(output_path, "wb") as handle: handle.write(payload) return 0, "", "" adapter.handle_message = capture adapter._message_handler = AsyncMock() adapter.send_reaction = AsyncMock(return_value=True) adapter._run_cli = cli # Localisation spends the agent's Buzz credentials, so it is gated on # an explicit gateway authorization. The gateway registers this check # on every adapter it constructs; tests are authorized by default and # override the callback where the gate itself is under test. adapter.set_authorization_check(lambda *_args: True) return captured, cli_calls @pytest.mark.asyncio async def test_markdown_relay_image_preserves_alt_text_before_dispatch( self, monkeypatch, tmp_path ): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() captured, cli_calls = self._capture_dispatch(adapter) media_url = f"https://test.relay/media/{'a' * 64}.png" await adapter._dispatch_message( text=f"Please inspect this screenshot\n\n![Login error dialog]({media_url})", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="media-event", created_at=1000, ) assert len(captured) == 1 event = captured[0] assert event.text == "Please inspect this screenshot\n\nLogin error dialog" assert event.message_type == MessageType.PHOTO assert event.media_types == ["image/png"] assert len(event.media_urls) == 1 assert event.media_urls[0].startswith(str(tmp_path / "hermes" / "cache")) assert media_url not in event.text assert cli_calls[0][-1] == media_url @pytest.mark.asyncio async def test_bare_relay_image_url_is_localised(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() captured, _calls = self._capture_dispatch(adapter) media_url = f"https://test.relay/media/{'b' * 64}.png" await adapter._dispatch_message( text=f"What is in this? {media_url}", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="bare-media-event", created_at=1001, ) event = captured[0] assert event.text == "What is in this?" assert event.message_type == MessageType.PHOTO assert event.media_types == ["image/png"] assert len(event.media_urls) == 1 @pytest.mark.asyncio async def test_image_only_message_gets_attachment_placeholder(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() captured, _calls = self._capture_dispatch(adapter) media_url = f"https://test.relay/media/{'5' * 64}.png" await adapter._dispatch_message( text=f"![]({media_url})", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="image-only-event", created_at=1002, ) event = captured[0] assert event.text == "(attachment)" assert event.message_type == MessageType.PHOTO assert event.media_types == ["image/png"] assert len(event.media_urls) == 1 @pytest.mark.asyncio async def test_multiple_images_are_localised_in_content_order(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() captured, calls = self._capture_dispatch(adapter) first = f"https://test.relay/media/{'c' * 64}.png" second = f"https://test.relay/media/{'d' * 64}.jpg" await adapter._dispatch_message( text=f"Compare these\n![]({first})\n{second}", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="multi-media-event", created_at=1002, ) event = captured[0] assert event.text == "Compare these" assert event.message_type == MessageType.PHOTO assert event.media_types == ["image/png", "image/jpeg"] assert len(event.media_urls) == 2 assert [call[-1] for call in calls] == [first, second] @pytest.mark.asyncio async def test_non_image_attachment_is_cached_as_document(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() captured, _calls = self._capture_dispatch(adapter) media_url = f"https://test.relay/media/{'e' * 64}.pdf" await adapter._dispatch_message( text=f"Read this report\n{media_url}", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="document-media-event", created_at=1003, ) event = captured[0] assert event.text == "Read this report" assert event.message_type == MessageType.DOCUMENT assert event.media_types == ["application/pdf"] assert len(event.media_urls) == 1 assert "/cache/documents/" in event.media_urls[0] @pytest.mark.asyncio async def test_download_failure_preserves_caption_and_alt_text( self, monkeypatch, tmp_path ): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() media_url = f"https://test.relay/media/{'f' * 64}.png" captured, _calls = self._capture_dispatch(adapter, failed_urls=[media_url]) await adapter._dispatch_message( text=f"The error is visible here\n![Checkout error dialog]({media_url})", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="failed-media-event", created_at=1004, ) event = captured[0] assert event.text == "The error is visible here\nCheckout error dialog" assert event.message_type == MessageType.TEXT assert event.media_urls == [] assert event.media_types == [] @pytest.mark.asyncio async def test_one_failed_download_does_not_drop_other_media(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() failed = f"https://test.relay/media/{'2' * 64}.png" succeeded = f"https://test.relay/media/{'3' * 64}.jpg" captured, calls = self._capture_dispatch(adapter, failed_urls=[failed]) await adapter._dispatch_message( text=f"Compare what loaded\n![]({failed})\n![]({succeeded})", chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="partial-media-event", created_at=1005, ) event = captured[0] assert event.text == "Compare what loaded" assert event.message_type == MessageType.PHOTO assert event.media_types == ["image/jpeg"] assert len(event.media_urls) == 1 assert [call[-1] for call in calls] == [failed, succeeded] @pytest.mark.asyncio async def test_real_event_handler_localises_media_before_gateway_dispatch( self, monkeypatch, tmp_path ): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() adapter._channel_state[DM_CHANNEL] = { "chat_type": "dm", "last_ts": 0, "seen": {}, } captured = [] media_url = f"https://test.relay/media/{'4' * 64}.png" async def capture(event): captured.append(event) async def cli(args, *, input_text=None): if args[:2] == ["users", "get"]: return 0, json.dumps([{"display_name": "Joel"}]), "" assert args[:2] == ["media", "get"] output_path = args[args.index("-o") + 1] payload = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk" "+A8AAQUBAScY42YAAAAASUVORK5CYII=" ) with open(output_path, "wb") as handle: handle.write(payload) return 0, "", "" adapter.handle_message = capture adapter._message_handler = AsyncMock() adapter.send_reaction = AsyncMock(return_value=True) adapter._run_cli = cli # As the gateway does for every adapter it constructs; the gate itself # is covered by TestInboundMediaAuthorizationGate. adapter.set_authorization_check(lambda *_args: True) await adapter._handle_event( DM_CHANNEL, adapter._channel_state[DM_CHANNEL], _tagged_event( "handler-media-event", DM_CHANNEL, content=f"Can you read this? ![]({media_url})", p=SELF_PUBKEY, ), ) assert len(captured) == 1 assert captured[0].text == "Can you read this?" assert captured[0].message_type == MessageType.PHOTO assert len(captured[0].media_urls) == 1 @pytest.mark.asyncio async def test_external_media_url_is_left_untouched(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) adapter = _make_adapter() captured, calls = self._capture_dispatch(adapter) media_url = f"https://cdn.example/media/{'1' * 64}.png" text = f"External reference: ![]({media_url})" await adapter._dispatch_message( text=text, chat_id=CHANNEL, chat_type="dm", user_id=OTHER_PUBKEY, user_name="Joel", message_id="external-media-event", created_at=1006, ) event = captured[0] assert event.text == text assert event.message_type == MessageType.TEXT assert event.media_urls == [] assert calls == [] class TestInboundMediaAuthorizationGate: """Authenticated retrieval must never run for an unauthorized sender. ``buzz media get`` signs the request with this agent's own key, so a relay object named by an unauthorized sender must not be fetched or cached. Every non-``True`` outcome — denial, no registered check, a raising check, or a truthy non-boolean — must fail closed and leave the message text exactly as it arrived. """ @staticmethod def _media_text(): return f"look at this ![shot](https://test.relay/media/{'a' * 64}.png)" async def _dispatch_with_check(self, adapter, monkeypatch, tmp_path, check): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) captured, cli_calls = TestInboundMediaLocalisation._capture_dispatch(adapter) adapter.set_authorization_check(check) text = self._media_text() await adapter._dispatch_message( text=text, chat_id=CHANNEL, chat_type="group", user_id=OTHER_PUBKEY, user_name="Joel", message_id="gated-media-event", created_at=1007, ) return captured, cli_calls, text @pytest.mark.asyncio async def test_denied_sender_media_is_not_downloaded(self, monkeypatch, tmp_path): adapter = _make_adapter() captured, cli_calls, text = await self._dispatch_with_check( adapter, monkeypatch, tmp_path, lambda *_args: False ) assert cli_calls == [] assert captured[0].text == text assert captured[0].message_type == MessageType.TEXT assert captured[0].media_urls == [] @pytest.mark.asyncio async def test_missing_authorization_check_blocks_download(self, monkeypatch, tmp_path): adapter = _make_adapter() captured, cli_calls, text = await self._dispatch_with_check( adapter, monkeypatch, tmp_path, None ) assert cli_calls == [] assert captured[0].text == text assert captured[0].media_urls == [] @pytest.mark.asyncio async def test_raising_authorization_check_blocks_download(self, monkeypatch, tmp_path): def boom(*_args): raise RuntimeError("auth backend down") adapter = _make_adapter() captured, cli_calls, text = await self._dispatch_with_check( adapter, monkeypatch, tmp_path, boom ) assert cli_calls == [] assert captured[0].text == text assert captured[0].media_urls == [] @pytest.mark.asyncio async def test_truthy_non_boolean_is_not_an_authorization(self, monkeypatch, tmp_path): """A non-boolean result must not be coerced into a credentialed fetch.""" adapter = _make_adapter() captured, cli_calls, text = await self._dispatch_with_check( adapter, monkeypatch, tmp_path, lambda *_args: "allowed" ) assert cli_calls == [] assert captured[0].text == text assert captured[0].media_urls == [] @pytest.mark.asyncio async def test_adapter_allowlist_does_not_override_gateway_denial( self, monkeypatch, tmp_path ): """``allowed_users`` is a pre-filter, not a second source of truth.""" adapter = _make_adapter({"allowed_users": [OTHER_PUBKEY]}) captured, cli_calls, _text = await self._dispatch_with_check( adapter, monkeypatch, tmp_path, lambda *_args: False ) assert OTHER_PUBKEY in adapter._allowed_pubkeys assert cli_calls == [] assert captured[0].media_urls == [] @pytest.mark.asyncio async def test_authorized_sender_still_downloads(self, monkeypatch, tmp_path): """The gate must not break the happy path it protects.""" adapter = _make_adapter() captured, cli_calls, _text = await self._dispatch_with_check( adapter, monkeypatch, tmp_path, lambda *_args: True ) assert len(cli_calls) == 1 assert captured[0].message_type == MessageType.PHOTO assert len(captured[0].media_urls) == 1 @pytest.mark.asyncio @pytest.mark.parametrize( ("method_name", "suffix"), [ ("send_image_file", ".png"), ("send_video", ".mp4"), ("send_voice", ".ogg"), ("send_document", ".pdf"), ], ) async def test_live_media_capabilities_upload_local_file_with_caption_and_reply( self, tmp_path, method_name, suffix ): media = tmp_path / f"attachment{suffix}" media.write_bytes(b"media") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt-media"}) adapter._run_cli = cli result = await getattr(adapter, method_name)( CHANNEL, str(media), caption="caption", reply_to="latest-child", metadata={"thread_id": "stable-root"}, ) assert result.success is True assert result.message_id == "evt-media" args, stdin_text = cli.calls[0] assert args[args.index("--file") + 1] == str(media) # Threading contract (#99429): stable thread root beats latest-child. assert args[args.index("--reply-to") + 1] == "stable-root" assert stdin_text == "caption" @pytest.mark.asyncio async def test_live_media_rejects_zero_exit_without_verified_event_id(self, tmp_path): media = tmp_path / "attachment.pdf" media.write_bytes(b"media") adapter = _make_adapter() adapter._run_cli = AsyncMock( return_value=(0, json.dumps({"accepted": True}), "") ) result = await adapter.send_document(CHANNEL, str(media)) assert result.success is False assert result.error == "invalid CLI response" assert result.raw_response is None @pytest.mark.asyncio async def test_live_media_redacts_long_path_before_bounding(self, tmp_path): parent = tmp_path private_parts = [] for index in range(6): part = f"private-{index}-" + ("x" * 150) private_parts.append(part) parent = parent / part parent.mkdir() media = parent / "handoff.txt" media.write_text("safe handoff", encoding="utf-8") adapter = _make_adapter() adapter._run_cli = AsyncMock( return_value=( 2, "", json.dumps( { "error": "network", "message": f"upload failed for {media}: " + ("z" * 1_000), } ), ) ) result = await adapter.send_document(CHANNEL, str(media)) assert result.success is False assert all(part not in result.error for part in private_parts) assert "handoff.txt" in result.error assert len(result.error) <= 900 @pytest.mark.asyncio async def test_send_to_platform_live_buzz_delivers_all_media(self, monkeypatch, tmp_path): from gateway.config import Platform from tools.send_message_tool import _send_to_platform first = tmp_path / "first.txt" second = tmp_path / "second.pdf" first.write_text("first", encoding="utf-8") second.write_text("second", encoding="utf-8") adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "send", {"accepted": True, "event_id": "evt-text"}) cli.script("messages", "send", {"accepted": True, "event_id": "evt-first"}) cli.script("messages", "send", {"accepted": True, "event_id": "evt-second"}) adapter._run_cli = cli platform = Platform("buzz") runner = SimpleNamespace(adapters={platform: adapter}) monkeypatch.setattr("gateway.run._gateway_runner_ref", lambda: runner) result = await _send_to_platform( platform, SimpleNamespace(enabled=True, token=None, extra={}), CHANNEL, "attached files", thread_id="root-event", media_files=[(str(first), False), (str(second), False)], ) assert result == { "success": True, "message_id": "evt-second", "media_delivered": True, } assert len(cli.calls) == 3 assert cli.calls[0][1] == "attached files" assert [call[0][call[0].index("--file") + 1] for call in cli.calls[1:]] == [ str(first), str(second), ] assert all( call[0][call[0].index("--reply-to") + 1] == "root-event" for call in cli.calls ) # ── Lifecycle ───────────────────────────────────────────────────────────── class TestBuzzAdapterLifecycle: @pytest.mark.asyncio async def test_disconnect_releases_scoped_lock(self, monkeypatch): """The identity lock taken in connect() must be released on disconnect.""" import gateway.status as gateway_status released = [] monkeypatch.setattr( gateway_status, "release_scoped_lock", lambda platform, key: released.append((platform, key)), ) adapter = _make_adapter() adapter._lock_key = "wss://relay.example:" + SELF_PUBKEY await adapter.disconnect() assert released == [("buzz", "wss://relay.example:" + SELF_PUBKEY)] assert adapter._lock_key is None @pytest.mark.asyncio async def test_connect_fails_when_identity_lock_held(self, monkeypatch): """A second profile using the same relay+pubkey must fail fast.""" import gateway.status as gateway_status monkeypatch.setattr( gateway_status, "acquire_scoped_lock", lambda platform, key: False ) adapter = _make_adapter() adapter.cli_path = "/fake/buzz" monkeypatch.setattr(_buzz_mod, "_resolve_private_key", lambda extra=None: "nsec1test") cli = _ScriptedCli() cli.script( "users", "get", [{"pubkey": SELF_PUBKEY, "display_name": "Chip"}], ) adapter._run_cli = cli assert await adapter.connect() is False assert adapter._lock_key is None # ── Credentials / requirements ──────────────────────────────────────────── class TestCredentialResolution: def test_env_key_wins(self, monkeypatch): monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1fromenv") assert _resolve_private_key() == "nsec1fromenv" def test_credentials_file_fallback(self, monkeypatch, tmp_path): creds = tmp_path / "agent_credentials.json" creds.write_text(json.dumps({"nsec": "nsec1fromfile", "npub": "npub1x"}), encoding="utf-8") monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds)) assert _resolve_private_key() == "nsec1fromfile" def test_owner_auth_tag_from_credentials_file(self, monkeypatch, tmp_path): tag = ["auth", "b" * 64, "", "c" * 128] creds = tmp_path / "agent_credentials.json" creds.write_text(json.dumps({"nsec": "nsec1fromfile", "auth_tag": tag}), encoding="utf-8") monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds)) assert json.loads(_resolve_auth_tag()) == tag def test_invalid_owner_auth_tag_fails_closed(self, monkeypatch, tmp_path): creds = tmp_path / "agent_credentials.json" creds.write_text(json.dumps({"nsec": "nsec1fromfile", "auth_tag": ["bad"]}), encoding="utf-8") monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds)) with pytest.raises(ValueError, match="auth tag"): _resolve_auth_tag() def test_scoped_credentials_and_tag_do_not_borrow_ambient_profile(self, monkeypatch, tmp_path): from agent import secret_scope as ss ambient_tag = ["auth", "a" * 64, "", "d" * 128] scoped_tag = ["auth", "b" * 64, "", "c" * 128] ambient = tmp_path / "ambient.json" scoped = tmp_path / "scoped.json" ambient.write_text(json.dumps({"nsec": "nsec1ambient", "auth_tag": ambient_tag})) scoped.write_text(json.dumps({"nsec": "nsec1scoped", "auth_tag": scoped_tag})) monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(ambient)) monkeypatch.setenv("BUZZ_AUTH_TAG", json.dumps(ambient_tag)) ss.set_multiplex_active(True) token = ss.set_secret_scope({"BUZZ_CREDENTIALS_FILE": str(scoped)}) try: assert _resolve_private_key() == "nsec1scoped" assert json.loads(_resolve_auth_tag()) == scoped_tag finally: ss.reset_secret_scope(token) ss.set_multiplex_active(False) def test_owner_auth_tag_uses_credentials_autodiscovery(self, monkeypatch, tmp_path): tag = ["auth", "b" * 64, "", "c" * 128] creds = tmp_path / "agent_credentials.json" creds.write_text(json.dumps({"nsec": "nsec1fromfile", "auth_tag": tag}), encoding="utf-8") monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path) assert _resolve_private_key() == "nsec1fromfile" assert json.loads(_resolve_auth_tag()) == tag def test_empty_multiplex_scope_never_autodiscovers_ambient_credentials(self, monkeypatch, tmp_path): from agent import secret_scope as ss tag = ["auth", "a" * 64, "", "d" * 128] (tmp_path / "general_credentials.json").write_text( json.dumps({"nsec": "nsec1ambient", "auth_tag": tag}), encoding="utf-8" ) monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path) ss.set_multiplex_active(True) token = ss.set_secret_scope({}) try: assert _resolve_private_key() == "" assert _resolve_auth_tag() == "" finally: ss.reset_secret_scope(token) ss.set_multiplex_active(False) # ── Env enablement / registration / standalone send ────────────────────── class TestEnvEnablement: def test_returns_none_when_unconfigured(self): assert _env_enablement() is None class TestBuzzPluginRegistration: def test_register_platform_contract(self): from gateway.platform_registry import platform_registry platform_registry.unregister("buzz") ctx = MagicMock() register(ctx) ctx.register_platform.assert_called_once() kwargs = ctx.register_platform.call_args.kwargs assert kwargs["name"] == "buzz" assert kwargs["cron_deliver_env_var"] == "BUZZ_HOME_CHANNEL" assert kwargs["allowed_users_env"] == "BUZZ_ALLOWED_USERS" assert kwargs["allow_all_env"] == "BUZZ_ALLOW_ALL_USERS" assert callable(kwargs["standalone_sender_fn"]) assert callable(kwargs["env_enablement_fn"]) assert set(kwargs["required_env"]) == {"BUZZ_RELAY_URL", "BUZZ_PRIVATE_KEY"} class TestStandaloneSend: @pytest.mark.asyncio async def test_standalone_send_success(self, monkeypatch, tmp_path): from gateway.config import PlatformConfig fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) captured = {} async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0): captured.update(cli_path=cli_path, args=args, relay_url=relay_url, auth_tag=auth_tag, input_text=input_text) return 0, json.dumps({"accepted": True, "event_id": "evt-cron", "message": ""}), "" monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) result = await _standalone_send(PlatformConfig(enabled=True, extra={}), CHANNEL, "cron says hi") assert result == {"success": True, "message_id": "evt-cron"} assert captured["args"][:2] == ["messages", "send"] assert captured["input_text"] == "cron says hi" # The private key must never be part of argv assert all("nsec1x" not in str(a) for a in captured["args"]) @pytest.mark.asyncio async def test_standalone_send_injects_owner_auth_tag_from_credentials_file( self, monkeypatch, tmp_path ): """Cron/standalone path must load NIP-OA auth_tag from credentials JSON. Main-line regression: when only BUZZ_PRIVATE_KEY is ambient and the credentials file holds auth_tag, omitting injection causes relay 403 membership failures on owner-gated relays. """ from gateway.config import PlatformConfig tag = ["auth", "b" * 64, "", "c" * 128] creds = tmp_path / "agent_credentials.json" creds.write_text( json.dumps({"nsec": "nsec1fromfile", "auth_tag": tag}), encoding="utf-8", ) fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) monkeypatch.setenv("BUZZ_CREDENTIALS_FILE", str(creds)) monkeypatch.delenv("BUZZ_PRIVATE_KEY", raising=False) monkeypatch.delenv("BUZZ_AUTH_TAG", raising=False) captured = {} async def fake_exec( cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0 ): captured.update( private_key=private_key, auth_tag=auth_tag, args=args, input_text=input_text, ) return 0, json.dumps({"accepted": True, "event_id": "evt-auth", "message": ""}), "" monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) result = await _standalone_send( PlatformConfig(enabled=True, extra={}), CHANNEL, "cron needs owner auth" ) assert result == {"success": True, "message_id": "evt-auth"} assert captured["private_key"] == "nsec1fromfile" assert json.loads(captured["auth_tag"]) == tag # Secrets stay out of argv (auth_tag is env-injected by _exec_buzz). joined_args = " ".join(str(a) for a in captured["args"]) assert "nsec1fromfile" not in joined_args assert tag[1] not in joined_args assert tag[3] not in joined_args @pytest.mark.asyncio async def test_standalone_send_key_only_does_not_invent_auth_tag( self, monkeypatch, tmp_path ): """Direct private key without credentials_file must not invent an auth tag.""" from gateway.config import PlatformConfig fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) monkeypatch.delenv("BUZZ_AUTH_TAG", raising=False) monkeypatch.delenv("BUZZ_CREDENTIALS_FILE", raising=False) # Ambient credentials dir must not be borrowed when a direct key is set. ambient = tmp_path / "ambient_credentials.json" ambient.write_text( json.dumps({"nsec": "nsec1ambient", "auth_tag": ["auth", "a" * 64, "", "d" * 128]}), encoding="utf-8", ) monkeypatch.setattr(_buzz_mod, "_DEFAULT_CREDENTIALS_DIR", tmp_path) captured = {} async def fake_exec( cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0 ): captured.update(private_key=private_key, auth_tag=auth_tag) return 0, json.dumps({"accepted": True, "event_id": "evt-key", "message": ""}), "" monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) result = await _standalone_send( PlatformConfig(enabled=True, extra={}), CHANNEL, "key only" ) assert result == {"success": True, "message_id": "evt-key"} assert captured["private_key"] == "nsec1x" assert captured["auth_tag"] == "" @pytest.mark.asyncio async def test_standalone_send_retries_unresolved_presentation_mention( self, monkeypatch, tmp_path ): from gateway.config import PlatformConfig fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) sent = [] async def fake_exec( cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0, ): sent.append(input_text) if len(sent) == 1: return ( 1, "", "user_error: mention '@session' does not match a current " "channel member; retry with --mention ", ) return ( 0, json.dumps( {"accepted": True, "event_id": "evt-standalone", "message": ""} ), "", ) monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) result = await _standalone_send( PlatformConfig(enabled=True, extra={}), CHANNEL, "See @session:default/example.", ) assert result == {"success": True, "message_id": "evt-standalone"} assert sent == [ "See @session:default/example.", "See @\u200bsession:default/example.", ] @pytest.mark.asyncio async def test_standalone_send_extracts_path_from_media_descriptor(self, monkeypatch, tmp_path): from gateway.config import PlatformConfig fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") document = tmp_path / "report.txt" document.write_text("report", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) captured = {} async def fake_exec(cli_path, args, *, relay_url, private_key, auth_tag="", input_text=None, timeout=30.0): captured["args"] = args return 0, json.dumps({"accepted": True, "event_id": "evt-media"}), "" monkeypatch.setattr(_buzz_mod, "_exec_buzz", fake_exec) result = await _standalone_send( PlatformConfig(enabled=True, extra={}), CHANNEL, "attached", media_files=[(str(document), False)], ) assert result == { "success": True, "message_id": "evt-media", "media_delivered": True, } file_index = captured["args"].index("--file") assert captured["args"][file_index + 1] == str(document) # ── Editing and deleting (streaming) ────────────────────────────────── class TestBuzzAdapterEdit: @pytest.mark.asyncio async def test_edit_targets_the_original_event_and_uses_stdin(self): adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "edit", {"accepted": True, "event_id": "edit1", "message": ""}) adapter._run_cli = cli result = await adapter.edit_message(CHANNEL, "orig1", "partial answer") assert result.success is True args, stdin_text = cli.calls[0] assert args[:2] == ["messages", "edit"] assert args[args.index("--event") + 1] == "orig1" # Content travels via stdin (--content -), never argv, same as send assert args[args.index("--content") + 1] == "-" assert stdin_text == "partial answer" @pytest.mark.asyncio async def test_edit_returns_the_original_id_not_the_cli_event_id(self): """The stream consumer re-edits ONE message id for the whole stream. buzz-cli reports a fresh event id for each edit; returning that would make the second edit address a message that was never sent. """ adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "edit", {"accepted": True, "event_id": "edit1"}) adapter._run_cli = cli result = await adapter.edit_message(CHANNEL, "orig1", "text") assert result.message_id == "orig1" @pytest.mark.asyncio async def test_edit_marks_its_own_event_seen(self): adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "edit", {"accepted": True, "event_id": "edit1"}) adapter._run_cli = cli await adapter.edit_message(CHANNEL, "orig1", "text") assert "edit1" in adapter._channel_state[CHANNEL]["seen"] @pytest.mark.asyncio async def test_edit_accepts_finalize_without_changing_behaviour(self): adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "edit", {"accepted": True, "event_id": "edit1"}) adapter._run_cli = cli result = await adapter.edit_message(CHANNEL, "orig1", "text", finalize=True) assert result.success is True assert len(cli.calls) == 1 @pytest.mark.asyncio async def test_edit_without_a_message_id_never_calls_the_cli(self): adapter = _make_adapter() cli = _ScriptedCli() adapter._run_cli = cli result = await adapter.edit_message(CHANNEL, "", "text") assert result.success is False assert cli.calls == [] @pytest.mark.asyncio async def test_edit_with_empty_content_never_calls_the_cli(self): adapter = _make_adapter() cli = _ScriptedCli() adapter._run_cli = cli result = await adapter.edit_message(CHANNEL, "orig1", "") assert result.success is False assert cli.calls == [] @pytest.mark.asyncio async def test_edit_relay_error_is_retryable_but_bad_input_is_not(self): adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "edit", "", code=2, stderr="relay unreachable") adapter._run_cli = cli relay_failure = await adapter.edit_message(CHANNEL, "orig1", "text") assert relay_failure.success is False assert relay_failure.retryable is True adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "edit", "", code=1, stderr="bad input") adapter._run_cli = cli input_failure = await adapter.edit_message(CHANNEL, "orig1", "text") assert input_failure.success is False assert input_failure.retryable is False @pytest.mark.asyncio async def test_delete_targets_the_event(self): adapter = _make_adapter() adapter._channel_state[CHANNEL] = {"chat_type": "group", "last_ts": 0, "seen": {}} cli = _ScriptedCli() cli.script("messages", "delete", {"accepted": True, "event_id": "del1"}) adapter._run_cli = cli assert await adapter.delete_message(CHANNEL, "orig1") is True assert cli.calls[0][0] == ["messages", "delete", "--event", "orig1"] @pytest.mark.asyncio async def test_delete_failure_returns_false(self): adapter = _make_adapter() cli = _ScriptedCli() cli.script("messages", "delete", "", code=2, stderr="relay unreachable") adapter._run_cli = cli assert await adapter.delete_message(CHANNEL, "orig1") is False @pytest.mark.asyncio async def test_delete_without_a_message_id_never_calls_the_cli(self): adapter = _make_adapter() cli = _ScriptedCli() adapter._run_cli = cli assert await adapter.delete_message(CHANNEL, "") is False assert cli.calls == [] @pytest.mark.asyncio @pytest.mark.parametrize( "stdout", [ "not json", json.dumps([]), json.dumps({"event_id": "evt"}), json.dumps({"accepted": True}), json.dumps({"accepted": True, "event_id": ""}), ], ) async def test_standalone_send_rejects_invalid_zero_exit_receipt( self, monkeypatch, tmp_path, stdout ): from gateway.config import PlatformConfig fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) monkeypatch.setattr( _buzz_mod, "_exec_buzz", AsyncMock(return_value=(0, stdout, "")), ) result = await _standalone_send( PlatformConfig(enabled=True, extra={}), CHANNEL, "hello" ) assert result == {"error": "Buzz standalone send failed: invalid CLI response"} @pytest.mark.asyncio async def test_standalone_send_rejection_is_useful_bounded_and_not_delivered( self, monkeypatch, tmp_path ): from gateway.config import PlatformConfig fake_cli = tmp_path / "buzz" fake_cli.write_text("#!/bin/sh\n", encoding="utf-8") monkeypatch.setenv("BUZZ_RELAY_URL", "https://r") monkeypatch.setenv("BUZZ_PRIVATE_KEY", "nsec1x") monkeypatch.setenv("BUZZ_CLI_PATH", str(fake_cli)) monkeypatch.setattr( _buzz_mod, "_exec_buzz", AsyncMock( return_value=( 0, json.dumps({"accepted": False, "message": "upload rejected " + "x" * 100_000}), "", ) ), ) result = await _standalone_send( PlatformConfig(enabled=True, extra={}), CHANNEL, "hello", media_files=[("/tmp/report.txt", False)], ) assert "upload rejected" in result["error"] assert len(result["error"]) <= 1024 assert "success" not in result assert "media_delivered" not in result # ── Durable channel cursors across restart (#90464) ─────────────────────── class TestChannelCursorPersistence: """A restart must resume from the saved cursor, not reseed from history. Seeding marks every event currently in the channel as seen. Anything that arrived while the gateway was down is in that history, so an unconditional reseed swallows it permanently even though the relay still has it. """ @pytest.fixture def adapter(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) a = _make_adapter() a._dispatched = [] async def capture(**kwargs): a._dispatched.append(kwargs) a._dispatch_message = capture a._message_handler = AsyncMock() return a @staticmethod def _cursor_file(tmp_path): return tmp_path / "buzz" / "channel-cursors.json" async def _seed(self, adapter, *events): cli = _ScriptedCli() cli.script("messages", "get", list(events)) adapter._run_cli = cli adapter._load_cursors() await adapter._seed_channel(CHANNEL, chat_type="group") adapter._save_cursors() return cli @pytest.mark.asyncio async def test_seed_writes_a_cursor(self, adapter, tmp_path): await self._seed(adapter, _event("e1", created_at=100), _event("e2", created_at=200)) saved = json.loads(self._cursor_file(tmp_path).read_text(encoding="utf-8")) assert saved["identity"] == SELF_PUBKEY assert saved["relay"] == "https://test.relay" assert saved["channels"][CHANNEL]["last_ts"] == 200 assert saved["channels"][CHANNEL]["seen"] == ["e1", "e2"] @pytest.mark.asyncio async def test_restart_resumes_instead_of_reseeding(self, adapter, tmp_path, monkeypatch): await self._seed(adapter, _event("e1", content="@Chip first", created_at=100)) # Restart. The relay now also holds a mention that landed while the # gateway was down, and it sits in the same history a reseed reads. restarted = _make_adapter() restarted._dispatched = [] async def capture(**kwargs): restarted._dispatched.append(kwargs) restarted._dispatch_message = capture restarted._message_handler = AsyncMock() cli = _ScriptedCli() cli.script("messages", "get", [ _event("e1", content="@Chip first", created_at=100), _event("e2", content="@Chip sent while you were down", created_at=150), ]) restarted._run_cli = cli restarted._load_cursors() await restarted._seed_channel(CHANNEL, chat_type="group") # Restoring must not spend a CLI call on history it is not going to use. assert cli.calls == [] state = restarted._channel_state[CHANNEL] assert set(state["seen"]) == {"e1"} assert state["last_ts"] == 100 # The first poll after the restart delivers the missed mention. await restarted._poll_channel(CHANNEL) assert [d["message_id"] for d in restarted._dispatched] == ["e2"] @pytest.mark.asyncio async def test_cursor_survives_only_for_the_same_identity_and_relay(self, adapter, tmp_path, monkeypatch): await self._seed(adapter, _event("e1", created_at=100)) # Same machine, different bot: channel ids would collide but the event # stream behind them is another one, so the cursor must be ignored. other = _make_adapter() other._self_pubkey = OTHER_PUBKEY other._load_cursors() assert other._restored_cursors == {} elsewhere = _make_adapter({"relay_url": "https://other.relay"}) elsewhere._load_cursors() assert elsewhere._restored_cursors == {} @pytest.mark.asyncio async def test_unreadable_cursor_file_falls_back_to_seeding(self, adapter, tmp_path): path = self._cursor_file(tmp_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{ not json", encoding="utf-8") cli = _ScriptedCli() cli.script("messages", "get", [_event("e1", created_at=100)]) adapter._run_cli = cli adapter._load_cursors() await adapter._seed_channel(CHANNEL, chat_type="group") # Degrades to the old behaviour rather than failing the connect. assert adapter._restored_cursors == {} assert set(adapter._channel_state[CHANNEL]["seen"]) == {"e1"} @pytest.mark.asyncio async def test_restored_seen_set_stays_bounded(self, adapter, tmp_path): cap = _buzz_mod._SEEN_CAP path = self._cursor_file(tmp_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps({ "identity": SELF_PUBKEY, "relay": "https://test.relay", "channels": { CHANNEL: { "chat_type": "group", "last_ts": 100, "seen": [f"e{i}" for i in range(cap * 2)], } }, }), encoding="utf-8", ) adapter._load_cursors() await adapter._seed_channel(CHANNEL, chat_type="group") seen = adapter._channel_state[CHANNEL]["seen"] assert len(seen) == cap # The newest ids are the ones worth keeping for de-dupe. assert f"e{cap * 2 - 1}" in seen assert "e0" not in seen @pytest.mark.asyncio async def test_idle_poll_does_not_rewrite_the_cursor(self, adapter, tmp_path): cli = await self._seed(adapter, _event("e1", created_at=100)) path = self._cursor_file(tmp_path) before = path.stat().st_mtime_ns cli.responses.clear() cli.script("messages", "get", [_event("e1", created_at=100)]) await adapter._poll_channel(CHANNEL) assert path.stat().st_mtime_ns == before