From 5d5505d1652860e47fb4260cc75624ae16adf2a0 Mon Sep 17 00:00:00 2001 From: Biggie Date: Thu, 9 Jul 2026 19:44:03 +0000 Subject: [PATCH 1/2] feat: add BlueBubbles Socket.IO transport --- gateway/config.py | 1 + gateway/platforms/bluebubbles.py | 244 ++++++++- gateway/run.py | 4 +- pyproject.toml | 1 + tests/gateway/test_bluebubbles.py | 517 ++++++++++++++++++ tests/test_project_metadata.py | 18 + tools/lazy_deps.py | 12 + uv.lock | 72 ++- .../docs/user-guide/messaging/bluebubbles.md | 25 +- 9 files changed, 867 insertions(+), 27 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index f321a322b8a46..2f0844552d62d 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -2155,6 +2155,7 @@ def _enable_from_env(platform: Platform) -> PlatformConfig: "webhook_host": getenv("BLUEBUBBLES_WEBHOOK_HOST", "127.0.0.1"), "webhook_port": getenv_int("BLUEBUBBLES_WEBHOOK_PORT", 8645), "webhook_path": getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"), + "transport": getenv("BLUEBUBBLES_TRANSPORT", "webhook"), "send_read_receipts": is_truthy_value(getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true")), }) bluebubbles_require_mention = getenv("BLUEBUBBLES_REQUIRE_MENTION") diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 60fe57031d99e..e4e85787007a1 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -1,8 +1,9 @@ """BlueBubbles iMessage platform adapter. Uses the local BlueBubbles macOS server for outbound REST sends and inbound -webhooks. Supports text messaging, media attachments (images, voice, video, -documents), tapback reactions, typing indicators, and read receipts. +webhook or Socket.IO events. Supports text messaging, media attachments +(images, voice, video, documents), tapback reactions, typing indicators, and +read receipts. Architecture based on PR #5869 (benjaminsehl) with inbound attachment downloading from PR #4588 (YuhangLin). @@ -16,10 +17,11 @@ import uuid from collections import OrderedDict from datetime import datetime -from typing import Any, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from urllib.parse import quote -import httpx +if TYPE_CHECKING: + import httpx from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( @@ -69,6 +71,9 @@ # Webhook event types that carry user messages _MESSAGE_EVENTS = {"new-message", "message", "updated-message"} +_SOCKETIO_TRANSPORT_ALIASES = {"socketio", "socket.io", "socket", "websocket", "ws"} +_VALID_TRANSPORTS = {"webhook", *_SOCKETIO_TRANSPORT_ALIASES} +_DEDUPE_CACHE_SIZE = 500 # Log redaction patterns _PHONE_RE = re.compile(r"\+?\d{7,15}") @@ -88,10 +93,64 @@ def _redact(text: str) -> str: # Helpers # --------------------------------------------------------------------------- -def check_bluebubbles_requirements() -> bool: +def check_bluebubbles_requirements(config: Optional[PlatformConfig] = None) -> bool: + extra = (config.extra if config is not None and config.extra else {}) or {} + try: + transport = _normalize_transport( + extra.get("transport") or os.getenv("BLUEBUBBLES_TRANSPORT", "webhook"), + strict=True, + ) + except ValueError as exc: + logger.error("[bluebubbles] %s", exc) + return False + try: - import aiohttp # noqa: F401 import httpx # noqa: F401 + from tools.lazy_deps import feature_missing, ensure_and_bind + except Exception: + return False + + def _import_webhook() -> dict: + import aiohttp # noqa: F401 + return {} + + def _import_socketio() -> dict: + # python-socketio's AsyncClient uses aiohttp for its async Engine.IO + # HTTP/WebSocket runtime. Keep this under the Socket.IO feature path + # rather than routing through the webhook-only platform.bluebubbles + # dependency check, so default webhook users still avoid socketio. + import aiohttp # noqa: F401 + import socketio # noqa: F401 + return {} + + if transport != "socketio": + try: + missing = feature_missing("platform.bluebubbles") + except Exception: + missing = () + if missing: + if not ensure_and_bind("platform.bluebubbles", _import_webhook, globals(), prompt=False): + return False + else: + try: + _import_webhook() + except ImportError: + return False + return True + + try: + socketio_missing = feature_missing("platform.bluebubbles_socketio") + except Exception: + socketio_missing = () + if socketio_missing: + return ensure_and_bind( + "platform.bluebubbles_socketio", + _import_socketio, + globals(), + prompt=False, + ) + try: + _import_socketio() except ImportError: return False return True @@ -106,6 +165,20 @@ def _normalize_server_url(raw: str) -> str: return value.rstrip("/") +def _normalize_transport(raw: Any, *, strict: bool = False) -> str: + value = str(raw or "webhook").strip().lower() + if not value or value == "webhook": + return "webhook" + if value in _SOCKETIO_TRANSPORT_ALIASES: + return "socketio" + message = ( + f"unknown transport {_redact(value)!r}; expected 'webhook' or one of " + f"{sorted(_SOCKETIO_TRANSPORT_ALIASES)!r}" + ) + if strict: + raise ValueError(message) + logger.warning("[bluebubbles] %s", message) + return "webhook" @@ -141,6 +214,11 @@ def __init__(self, config: PlatformConfig): if not str(self.webhook_path).startswith("/"): self.webhook_path = f"/{self.webhook_path}" self.send_read_receipts = bool(extra.get("send_read_receipts", True)) + self.transport = _normalize_transport( + extra.get("transport") or os.getenv("BLUEBUBBLES_TRANSPORT", "webhook"), + strict=True, + ) + self.use_socketio = self.transport == "socketio" _require_mention = extra.get("require_mention") if _require_mention is None: _require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION") @@ -150,11 +228,13 @@ def __init__(self, config: PlatformConfig): if "mention_patterns" in extra else os.getenv("BLUEBUBBLES_MENTION_PATTERNS") ) - self.client: Optional[httpx.AsyncClient] = None + self.client: Optional["httpx.AsyncClient"] = None self._runner = None self._private_api_enabled: Optional[bool] = None self._helper_connected: bool = False self._guid_cache: OrderedDict[str, str] = OrderedDict() + self._processed_message_guids: OrderedDict[str, None] = OrderedDict() + self._sio = None # ------------------------------------------------------------------ # API helpers @@ -242,9 +322,9 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: "[bluebubbles] BLUEBUBBLES_SERVER_URL and BLUEBUBBLES_PASSWORD are required" ) return False - from aiohttp import web # Tighter keepalive so idle CLOSE_WAIT drains promptly (#18451). + import httpx from gateway.platforms._http_client_limits import platform_httpx_limits self.client = httpx.AsyncClient(timeout=30.0, limits=platform_httpx_limits()) try: @@ -254,10 +334,11 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._private_api_enabled = bool(server_data.get("private_api")) self._helper_connected = bool(server_data.get("helper_connected")) logger.info( - "[bluebubbles] connected to %s (private_api=%s, helper=%s)", + "[bluebubbles] connected to %s (private_api=%s, helper=%s, transport=%s)", self.server_url, self._private_api_enabled, self._helper_connected, + self.transport, ) except Exception as exc: logger.error( @@ -268,6 +349,18 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self.client = None return False + if self.use_socketio: + connected = await self._connect_socketio() + if not connected: + if self.client: + await self.client.aclose() + self.client = None + return False + self._mark_connected() + return True + + from aiohttp import web + # Explicit body cap: BlueBubbles webhook events are small JSON (or # form-encoded) payloads. client_max_size makes aiohttp enforce the # cap on every read path — including chunked requests that carry no @@ -297,8 +390,16 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: return True async def disconnect(self) -> None: + if self._sio is not None: + try: + if getattr(self._sio, "connected", False): + await self._sio.disconnect() + finally: + self._sio = None + # Unregister webhook before cleaning up - await self._unregister_webhook() + if not self.use_socketio: + await self._unregister_webhook() if self.client: await self.client.aclose() @@ -308,6 +409,84 @@ async def disconnect(self) -> None: self._runner = None self._mark_disconnected() + async def _connect_socketio(self) -> bool: + """Connect to BlueBubbles' realtime Socket.IO interface.""" + try: + import socketio + except ImportError: + logger.error( + "[bluebubbles] BLUEBUBBLES_TRANSPORT=socketio requires python-socketio" + ) + return False + + sio = socketio.AsyncClient( + reconnection=True, + logger=False, + engineio_logger=False, + ) + + async def _dispatch(event_name: str, data: Any): + try: + await self._handle_socketio_event(event_name, data) + except Exception as exc: + logger.exception( + "[bluebubbles] socket.io event %s failed: %s", + event_name, + exc, + ) + + @sio.event + async def connect(): + logger.info("[bluebubbles] socket.io connected to %s", self.server_url) + self._mark_connected() + + @sio.event + async def disconnect(): + logger.info("[bluebubbles] socket.io disconnected") + self._mark_disconnected() + + for event_name in sorted(_MESSAGE_EVENTS): + async def _handler(*args: Any, event_name: str = event_name): + data = args[0] if args else {} + await _dispatch(event_name, data) + + sio.on(event_name, handler=_handler) + + self._sio = sio + url = f"{self.server_url}?password={quote(self.password, safe='')}" + try: + await sio.connect( + url, + transports=["websocket", "polling"], + socketio_path="socket.io", + wait_timeout=15, + ) + return True + except Exception as exc: + logger.error("[bluebubbles] socket.io connect failed: %s", exc) + try: + # Engine.IO can allocate its aiohttp ClientSession before the + # Socket.IO client reports connected=True. disconnect() still + # runs Engine.IO reset/HTTP close in that partial state, so call + # it unconditionally instead of keying cleanup off connected. + await sio.disconnect() + except Exception: + logger.debug("[bluebubbles] socket.io cleanup after failed connect failed", exc_info=True) + self._sio = None + return False + + async def _handle_socketio_event(self, event_type: str, data: Any) -> bool: + if isinstance(data, dict): + inner_type = self._value(data.get("type"), data.get("event")) + if inner_type and ("data" in data or "message" in data): + payload = dict(data) + payload.setdefault("type", event_type) + return await self._process_event_payload(payload) + if isinstance(data.get("message"), dict): + return await self._process_event_payload({"type": event_type, "message": data["message"]}) + payload = {"type": event_type, "data": data} + return await self._process_event_payload(payload) + @property def _webhook_url(self) -> str: """Compute the external webhook URL for BlueBubbles registration.""" @@ -902,10 +1081,16 @@ async def _handle_webhook(self, request): logger.error("[bluebubbles] webhook parse error: %s", exc) return web.json_response({"error": "invalid payload"}, status=400) + ok = await self._process_event_payload(payload) + if ok: + return web.Response(text="ok") + return web.json_response({"error": "missing message fields"}, status=400) + + async def _process_event_payload(self, payload: Dict[str, Any]) -> bool: event_type = self._value(payload.get("type"), payload.get("event")) or "" # Only process message events; silently acknowledge everything else if event_type and event_type not in _MESSAGE_EVENTS: - return web.Response(text="ok") + return True record = self._extract_payload_record(payload) or {} is_from_me = bool( @@ -914,7 +1099,7 @@ async def _handle_webhook(self, request): or record.get("is_from_me") ) if is_from_me: - return web.Response(text="ok") + return True # Skip tapback reactions delivered as messages assoc_type = record.get("associatedMessageType") @@ -922,7 +1107,7 @@ async def _handle_webhook(self, request): **_TAPBACK_ADDED, **_TAPBACK_REMOVED, }: - return web.Response(text="ok") + return True text = ( self._value( @@ -1001,7 +1186,7 @@ async def _handle_webhook(self, request): if not (chat_guid or chat_identifier) and sender: chat_identifier = sender if not sender or not (chat_guid or chat_identifier) or not text: - return web.json_response({"error": "missing message fields"}, status=400) + return False session_chat_id = chat_guid or chat_identifier is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or "")) @@ -1010,7 +1195,7 @@ async def _handle_webhook(self, request): logger.debug( "[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)" ) - return web.Response(text="ok") + return True text = self._clean_mention_text(text) source = self.build_source( chat_id=session_chat_id, @@ -1020,16 +1205,33 @@ async def _handle_webhook(self, request): user_name=sender, chat_id_alt=chat_identifier, ) + message_guid = self._value( + record.get("guid"), + record.get("messageGuid"), + record.get("id"), + ) + if self.use_socketio and message_guid: + # BlueBubbles can emit the same iMessage GUID through overlapping + # Socket.IO event names (message/new-message/updated-message). Treat + # later Socket.IO events for an already-dispatched GUID as duplicates + # so edits or status-style updated-message events do not trigger fresh + # agent turns. Keep this scoped to Socket.IO so default webhook mode + # preserves its existing new-message/updated-message contract. + # Tapbacks are skipped above before they can enter this cache. + if message_guid in self._processed_message_guids: + logger.debug("[bluebubbles] ignoring duplicate message event guid=%s", _redact(message_guid)) + return True + self._processed_message_guids[message_guid] = None + self._processed_message_guids.move_to_end(message_guid) + while len(self._processed_message_guids) > _DEDUPE_CACHE_SIZE: + self._processed_message_guids.popitem(last=False) + event = MessageEvent( text=text, message_type=msg_type, source=source, raw_message=payload, - message_id=self._value( - record.get("guid"), - record.get("messageGuid"), - record.get("id"), - ), + message_id=message_guid, reply_to_message_id=self._value( record.get("threadOriginatorGuid"), record.get("associatedMessageGuid"), @@ -1045,4 +1247,4 @@ async def _handle_webhook(self, request): if self.send_read_receipts and session_chat_id: asyncio.create_task(self.mark_read(session_chat_id)) - return web.Response(text="ok") + return True diff --git a/gateway/run.py b/gateway/run.py index ec256d590d097..ad43e179c1cab 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9590,8 +9590,8 @@ def _create_adapter( elif platform == Platform.BLUEBUBBLES: from gateway.platforms.bluebubbles import BlueBubblesAdapter, check_bluebubbles_requirements - if not check_bluebubbles_requirements(): - logger.warning("BlueBubbles: aiohttp/httpx missing or BLUEBUBBLES_SERVER_URL/BLUEBUBBLES_PASSWORD not configured") + if not check_bluebubbles_requirements(config): + logger.warning("BlueBubbles: requirements check failed (missing dependency or invalid transport; see preceding bluebubbles log for details)") return None return BlueBubblesAdapter(config) diff --git a/pyproject.toml b/pyproject.toml index faf5b6efcf4e2..7155bb6f4823d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -159,6 +159,7 @@ daytona = ["daytona==0.155.0"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82) messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.29.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 +bluebubbles-socketio = ["python-socketio==5.16.3", "aiohttp==3.14.1"] # aiohttp required by python-socketio AsyncClient runtime cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.29.0", "slack-sdk==3.43.0", "aiohttp==3.14.1"] matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525 (mautrix/aiohttp-socks only cap aiohttp<4 / >=3.10, so pin the patched floor directly) diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index 11358ab2b8d0c..34e7f83eb8bfa 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -164,6 +164,484 @@ def test_clean_mention_text_strips_leading_wake_word(self, monkeypatch): assert adapter._clean_mention_text("please ask Hermes about this") == "please ask Hermes about this" +class TestBlueBubblesSocketIO: + def test_env_transport_socketio_reaches_platform_extra(self, monkeypatch): + monkeypatch.setenv("BLUEBUBBLES_SERVER_URL", "http://localhost:1234") + monkeypatch.setenv("BLUEBUBBLES_PASSWORD", "secret") + monkeypatch.setenv("BLUEBUBBLES_TRANSPORT", "socketio") + from gateway.config import GatewayConfig, _apply_env_overrides + + config = GatewayConfig() + _apply_env_overrides(config) + + assert config.platforms[Platform.BLUEBUBBLES].extra["transport"] == "socketio" + + def test_adapter_transport_aliases_socketio(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="websocket") + + assert adapter.transport == "socketio" + assert adapter.use_socketio is True + + @pytest.mark.asyncio + async def test_socketio_connect_skips_webhook_registration(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="socketio") + registered = [] + connected_socket = [] + + async def fake_api_get(path): + if path == "/api/v1/ping": + return {"status": 200} + if path == "/api/v1/server/info": + return {"data": {"private_api": True, "helper_connected": True}} + raise AssertionError(path) + + async def fake_register_webhook(): + registered.append(True) + return True + + async def fake_connect_socketio(): + connected_socket.append(True) + return True + + monkeypatch.setattr(adapter, "_api_get", fake_api_get) + monkeypatch.setattr(adapter, "_register_webhook", fake_register_webhook) + monkeypatch.setattr(adapter, "_connect_socketio", fake_connect_socketio) + + assert await adapter.connect() is True + assert connected_socket == [True] + assert registered == [] + assert adapter._runner is None + await adapter.disconnect() + + @pytest.mark.asyncio + async def test_socketio_registers_async_event_handlers(self, monkeypatch): + import inspect + import types + import sys + + adapter = _make_adapter(monkeypatch, transport="socketio") + clients = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.handlers = {} + self.connected = False + clients.append(self) + + def event(self, func): + self.handlers[func.__name__] = func + return func + + def on(self, event_name, handler): + self.handlers[event_name] = handler + + async def connect(self, *args, **kwargs): + self.connected = True + + monkeypatch.setitem(sys.modules, "socketio", types.SimpleNamespace(AsyncClient=FakeAsyncClient)) + + assert await adapter._connect_socketio() is True + client = clients[0] + for event_name in ["message", "new-message", "updated-message"]: + assert inspect.iscoroutinefunction(client.handlers[event_name]) + + @pytest.mark.asyncio + async def test_socketio_new_message_dispatches_like_webhook(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="socketio", send_read_receipts=False) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + + await adapter._handle_socketio_event("new-message", { + "guid": "msg-socket-1", + "text": "hello over socket", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + }) + await asyncio.sleep(0) + + assert [event.text for event in handled] == ["hello over socket"] + assert handled[0].source.chat_id == "iMessage;-;user@example.com" + + + def test_check_requirements_uses_lazy_deps_for_env_socketio(self, monkeypatch): + from gateway.platforms import bluebubbles as bb + + monkeypatch.setenv("BLUEBUBBLES_TRANSPORT", "socketio") + calls = [] + + def fake_feature_missing(feature): + calls.append(("missing", feature)) + if feature == "platform.bluebubbles_socketio": + return ("python-socketio",) + raise AssertionError(feature) + + def fake_ensure_and_bind(feature, importer, target_globals, **kwargs): + calls.append(("ensure", feature, kwargs)) + return False + + monkeypatch.setattr("tools.lazy_deps.feature_missing", fake_feature_missing) + monkeypatch.setattr("tools.lazy_deps.ensure_and_bind", fake_ensure_and_bind) + + assert bb.check_bluebubbles_requirements() is False + assert calls == [ + ("missing", "platform.bluebubbles_socketio"), + ("ensure", "platform.bluebubbles_socketio", {"prompt": False}), + ] + + def test_check_requirements_uses_config_transport_socketio(self, monkeypatch): + from gateway.platforms import bluebubbles as bb + + monkeypatch.delenv("BLUEBUBBLES_TRANSPORT", raising=False) + cfg = PlatformConfig(enabled=True, extra={"transport": "socketio"}) + calls = [] + + def fake_feature_missing(feature): + calls.append(("missing", feature)) + if feature == "platform.bluebubbles_socketio": + return ("python-socketio",) + raise AssertionError(feature) + + def fake_ensure_and_bind(feature, importer, target_globals, **kwargs): + calls.append(("ensure", feature)) + return False + + monkeypatch.setattr("tools.lazy_deps.feature_missing", fake_feature_missing) + monkeypatch.setattr("tools.lazy_deps.ensure_and_bind", fake_ensure_and_bind) + + assert bb.check_bluebubbles_requirements(cfg) is False + assert calls == [ + ("missing", "platform.bluebubbles_socketio"), + ("ensure", "platform.bluebubbles_socketio"), + ] + + def test_check_requirements_webhook_does_not_require_socketio(self, monkeypatch): + from gateway.platforms import bluebubbles as bb + + monkeypatch.delenv("BLUEBUBBLES_TRANSPORT", raising=False) + calls = [] + + def fake_feature_missing(feature): + calls.append(("missing", feature)) + if feature == "platform.bluebubbles": + return () + if feature == "platform.bluebubbles_socketio": + return ("python-socketio",) + raise AssertionError(feature) + + def fake_ensure_and_bind(feature, importer, target_globals, **kwargs): + calls.append(("ensure", feature)) + return False + + monkeypatch.setattr("tools.lazy_deps.feature_missing", fake_feature_missing) + monkeypatch.setattr("tools.lazy_deps.ensure_and_bind", fake_ensure_and_bind) + + assert bb.check_bluebubbles_requirements(PlatformConfig(enabled=True, extra={"transport": "webhook"})) is True + assert calls == [("missing", "platform.bluebubbles")] + + def test_check_requirements_socketio_does_not_require_webhook_aiohttp(self, monkeypatch): + from gateway.platforms import bluebubbles as bb + + calls = [] + + def fake_feature_missing(feature): + calls.append(("missing", feature)) + if feature == "platform.bluebubbles_socketio": + return () + raise AssertionError(feature) + + def fake_ensure_and_bind(feature, importer, target_globals, **kwargs): + calls.append(("ensure", feature)) + return False + + monkeypatch.setattr("tools.lazy_deps.feature_missing", fake_feature_missing) + monkeypatch.setattr("tools.lazy_deps.ensure_and_bind", fake_ensure_and_bind) + + assert bb.check_bluebubbles_requirements(PlatformConfig(enabled=True, extra={"transport": "socketio"})) is True + assert calls == [("missing", "platform.bluebubbles_socketio")] + + def test_check_requirements_socketio_requires_aiohttp_runtime(self, monkeypatch): + import builtins + + from gateway.platforms import bluebubbles as bb + + calls = [] + real_import = builtins.__import__ + + def fake_feature_missing(feature): + calls.append(("missing", feature)) + if feature == "platform.bluebubbles_socketio": + return () + raise AssertionError(feature) + + def fake_import(name, *args, **kwargs): + if name == "aiohttp": + raise ImportError("aiohttp intentionally hidden") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr("tools.lazy_deps.feature_missing", fake_feature_missing) + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert bb.check_bluebubbles_requirements(PlatformConfig(enabled=True, extra={"transport": "socketio"})) is False + assert calls == [("missing", "platform.bluebubbles_socketio")] + + def test_invalid_transport_fails_closed(self, monkeypatch): + from gateway.platforms import bluebubbles as bb + + with pytest.raises(ValueError, match="unknown transport"): + _make_adapter(monkeypatch, transport="socket_io_typo") + + assert bb.check_bluebubbles_requirements( + PlatformConfig(enabled=True, extra={"transport": "socket_io_typo"}) + ) is False + + def test_module_import_does_not_require_httpx(self, monkeypatch): + import importlib + import sys + import types + import builtins + + sys.modules.pop("gateway.platforms.bluebubbles", None) + sys.modules.pop("httpx", None) + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "httpx": + raise ModuleNotFoundError("No module named 'httpx'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + module = importlib.import_module("gateway.platforms.bluebubbles") + assert isinstance(module, types.ModuleType) + + @pytest.mark.asyncio + async def test_socketio_dedupes_same_guid_across_event_names(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="socketio", send_read_receipts=False) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + message = { + "guid": "dup-guid-1", + "text": "only once", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + + assert await adapter._handle_socketio_event("new-message", dict(message)) is True + assert await adapter._handle_socketio_event("message", dict(message)) is True + assert await adapter._handle_socketio_event("updated-message", dict(message)) is True + await asyncio.sleep(0) + + assert [event.text for event in handled] == ["only once"] + + @pytest.mark.asyncio + async def test_socketio_updated_message_same_guid_is_deduped(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="socketio", send_read_receipts=False) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + original = { + "guid": "edited-guid-1", + "text": "original text", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + edited = dict(original, text="edited text") + + assert await adapter._handle_socketio_event("new-message", original) is True + assert await adapter._handle_socketio_event("updated-message", edited) is True + await asyncio.sleep(0) + + assert [event.text for event in handled] == ["original text"] + + @pytest.mark.asyncio + async def test_socketio_accepts_webhook_shaped_payload(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="socketio", send_read_receipts=False) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + + assert await adapter._handle_socketio_event("new-message", { + "type": "new-message", + "data": { + "guid": "msg-webhook-shaped", + "text": "already wrapped", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + }, + }) is True + await asyncio.sleep(0) + + assert [event.text for event in handled] == ["already wrapped"] + + @pytest.mark.asyncio + async def test_socketio_accepts_message_wrapper_payload(self, monkeypatch): + adapter = _make_adapter(monkeypatch, transport="socketio", send_read_receipts=False) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + + assert await adapter._handle_socketio_event("new-message", { + "message": { + "guid": "msg-wrapper-shaped", + "text": "wrapped message", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + }, + }) is True + await asyncio.sleep(0) + + assert [event.text for event in handled] == ["wrapped message"] + + @pytest.mark.asyncio + async def test_socketio_handlers_accept_multiple_args_and_update_state(self, monkeypatch): + import sys + import types + + adapter = _make_adapter(monkeypatch, transport="socketio") + clients = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.handlers = {} + self.connected = False + clients.append(self) + + def event(self, func): + self.handlers[func.__name__] = func + return func + + def on(self, event_name, handler): + self.handlers[event_name] = handler + + async def connect(self, *args, **kwargs): + self.connected = True + + monkeypatch.setitem(sys.modules, "socketio", types.SimpleNamespace(AsyncClient=FakeAsyncClient)) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + + assert await adapter._connect_socketio() is True + client = clients[0] + await client.handlers["connect"]() + assert adapter.is_connected is True + await client.handlers["new-message"]({ + "guid": "multi-arg-guid", + "text": "multi arg", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + }, {"ignored": True}) + await asyncio.sleep(0) + assert [event.text for event in handled] == ["multi arg"] + await client.handlers["disconnect"]() + assert adapter.is_connected is False + + @pytest.mark.asyncio + async def test_socketio_failed_connect_disconnects_partial_client(self, monkeypatch): + import sys + import types + + adapter = _make_adapter(monkeypatch, transport="socketio") + clients = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.handlers = {} + self.connected = False + self.disconnect_called = False + clients.append(self) + + def event(self, func): + self.handlers[func.__name__] = func + return func + + def on(self, event_name, handler): + self.handlers[event_name] = handler + + async def connect(self, *args, **kwargs): + self.connected = True + raise RuntimeError("boom") + + async def disconnect(self): + self.disconnect_called = True + self.connected = False + + monkeypatch.setitem(sys.modules, "socketio", types.SimpleNamespace(AsyncClient=FakeAsyncClient)) + + assert await adapter._connect_socketio() is False + assert clients[0].disconnect_called is True + assert adapter._sio is None + + @pytest.mark.asyncio + async def test_socketio_failed_connect_disconnects_before_connected_state(self, monkeypatch): + import sys + import types + + adapter = _make_adapter(monkeypatch, transport="socketio") + clients = [] + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + self.handlers = {} + self.connected = False + self.disconnect_called = False + clients.append(self) + + def event(self, func): + self.handlers[func.__name__] = func + return func + + def on(self, event_name, handler): + self.handlers[event_name] = handler + + async def connect(self, *args, **kwargs): + # Real python-socketio/Engine.IO can allocate aiohttp resources + # before connected flips true. Cleanup must not depend on this + # flag or the underlying ClientSession can leak. + raise RuntimeError("boom before connected") + + async def disconnect(self): + self.disconnect_called = True + + monkeypatch.setitem(sys.modules, "socketio", types.SimpleNamespace(AsyncClient=FakeAsyncClient)) + + assert await adapter._connect_socketio() is False + assert clients[0].connected is False + assert clients[0].disconnect_called is True + assert adapter._sio is None + + class _FakeBlueBubblesRequest: def __init__(self, payload, password="secret"): self.query = {"password": password} @@ -233,6 +711,45 @@ async def fake_handle_message(event): assert response.status == 200 assert [event.text for event in handled] == ["summarize this"] + @pytest.mark.asyncio + async def test_webhook_same_guid_new_and_updated_messages_both_dispatch(self, monkeypatch): + adapter = _make_adapter( + monkeypatch, + transport="webhook", + send_read_receipts=False, + ) + handled = [] + + async def fake_handle_message(event): + handled.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + message = { + "guid": "webhook-update-guid", + "text": "original webhook text", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chatGuid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + + first = await adapter._handle_webhook(_FakeBlueBubblesRequest({ + "type": "new-message", + "data": dict(message), + })) + second = await adapter._handle_webhook(_FakeBlueBubblesRequest({ + "type": "updated-message", + "data": dict(message, text="updated webhook text"), + })) + await asyncio.sleep(0) + + assert first.status == 200 + assert second.status == 200 + assert [event.text for event in handled] == [ + "original webhook text", + "updated webhook text", + ] + @pytest.mark.asyncio async def test_dm_message_does_not_require_mention(self, monkeypatch): adapter = _make_adapter( diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 8c0836e9059e2..4b70405071491 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -72,6 +72,7 @@ def test_lazy_installable_extras_excluded_from_all(): "voice", # faster-whisper / sounddevice / numpy "modal", "daytona", "messaging", "slack", "matrix", "dingtalk", "feishu", + "bluebubbles-socketio", "honcho", "hindsight", "supermemory", "mem0", "mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral) @@ -101,6 +102,23 @@ def _exact_pins(specs): return pins +def test_bluebubbles_socketio_extra_matches_lazy_runtime_deps(): + """Socket.IO's narrow opt-in path must include its async runtime deps. + + python-socketio imports successfully without aiohttp, but AsyncClient cannot + connect without aiohttp. Keep aiohttp in the Socket.IO-specific extra/lazy + feature instead of depending on the webhook-only platform.bluebubbles path. + """ + from tools.lazy_deps import LAZY_DEPS + + optional_dependencies = _load_optional_dependencies() + extra_pins = _exact_pins(optional_dependencies["bluebubbles-socketio"]) + lazy_pins = _exact_pins(LAZY_DEPS["platform.bluebubbles_socketio"]) + + assert extra_pins["python-socketio"] == lazy_pins["python-socketio"] + assert extra_pins["aiohttp"] == lazy_pins["aiohttp"] + + def test_pyproject_aiohttp_pins_match_lazy_slack_pin(): """Avoid update/lazy-install churn from conflicting aiohttp pins. diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index ec5692ecd5507..f3a808f9327fc 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -182,6 +182,18 @@ # satisfies both — pin the patched floor here too, like platform.discord. "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 ), + "platform.bluebubbles": ( + # BlueBubbles webhook mode needs aiohttp. Socket.IO is opt-in and kept + # separate so default webhook installs do not depend on python-socketio. + "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 + ), + "platform.bluebubbles_socketio": ( + "python-socketio==5.16.3", + # python-socketio AsyncClient requires aiohttp for the async Engine.IO + # HTTP/WebSocket runtime. Keep it in the Socket.IO feature path instead + # of depending on platform.bluebubbles (webhook mode). + "aiohttp==3.14.1", # CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 + ), "platform.dingtalk": ( "dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", diff --git a/uv.lock b/uv.lock index 59f9d8e2628b0..b7ff0acff3b1b 100644 --- a/uv.lock +++ b/uv.lock @@ -481,6 +481,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, ] +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + [[package]] name = "boto3" version = "1.42.89" @@ -1415,7 +1424,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, @@ -1423,7 +1434,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, @@ -1431,7 +1444,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, @@ -1578,6 +1593,10 @@ azure-identity = [ bedrock = [ { name = "boto3" }, ] +bluebubbles-socketio = [ + { name = "aiohttp" }, + { name = "python-socketio" }, +] cli = [ { name = "simple-term-menu" }, ] @@ -1733,6 +1752,7 @@ youtube = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", marker = "extra == 'acp'", specifier = "==0.9.0" }, + { name = "aiohttp", marker = "extra == 'bluebubbles-socketio'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'homeassistant'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'matrix'", specifier = "==3.14.1" }, { name = "aiohttp", marker = "extra == 'messaging'", specifier = "==3.14.1" }, @@ -1821,6 +1841,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = ">=0.0.9,<1" }, { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" }, + { name = "python-socketio", marker = "extra == 'bluebubbles-socketio'", specifier = "==5.16.3" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" }, @@ -1853,7 +1874,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "bluebubbles-socketio", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -3460,6 +3481,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-engineio" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" }, +] + [[package]] name = "python-multipart" version = "0.0.27" @@ -3486,6 +3519,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/93/f6729f10149305262194774d6c8b438c0b084740cf239f48ab97b4df02fa/python_olm-3.2.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a5e68a2f4b5a2bfa5fdb5dbfa22396a551730df6c4a572235acaa96e997d3f", size = 297000, upload-time = "2023-11-28T19:25:31.045Z" }, ] +[[package]] +name = "python-socketio" +version = "5.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, +] + [[package]] name = "python-socks" version = "2.8.1" @@ -3878,6 +3924,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/09/21d993e394c1fe5c44cd90453d88ed44932da8dfca006e424c072d77d29b/simple_term_menu-1.6.6-py3-none-any.whl", hash = "sha256:c2a869efa7a9f7e4a9c25858b42ca6974034951c137d5e281f5339b06ed8c9c2", size = 27600, upload-time = "2024-12-02T16:31:48.934Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -4476,6 +4534,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "yarl" version = "1.22.0" diff --git a/website/docs/user-guide/messaging/bluebubbles.md b/website/docs/user-guide/messaging/bluebubbles.md index 12efd3823bcde..9cbae4ef87527 100644 --- a/website/docs/user-guide/messaging/bluebubbles.md +++ b/website/docs/user-guide/messaging/bluebubbles.md @@ -36,8 +36,25 @@ Or set environment variables directly in `~/.hermes/.env`: ```bash BLUEBUBBLES_SERVER_URL=http://192.168.1.10:1234 BLUEBUBBLES_PASSWORD=your-server-password +# Optional inbound mode. Default is webhook. +BLUEBUBBLES_TRANSPORT=webhook ``` +You can also configure the inbound transport in `~/.hermes/config.yaml`: + +```yaml +platforms: + bluebubbles: + enabled: true + extra: + server_url: http://192.168.1.10:1234 + # Prefer .env for passwords; shown here only to document the key. + password: your-server-password + transport: socketio +``` + +Unknown explicit transport values fail closed; use `webhook` or one of the Socket.IO aliases listed below. If lazy dependency installation is disabled, install the narrow Socket.IO extra first: `pip install 'hermes-agent[bluebubbles-socketio]'`. + #### Optional: Require mentions in group chats By default, Hermes responds to every authorized BlueBubbles/iMessage DM or group message. To make group chats opt-in, enable mention gating: @@ -90,16 +107,17 @@ BLUEBUBBLES_ALLOW_ALL_USERS=true hermes gateway run ``` -Hermes will connect to your BlueBubbles server, register a webhook, and start listening for iMessage messages. +Hermes will connect to your BlueBubbles server and start listening for iMessage messages. By default it registers a webhook. If `BLUEBUBBLES_TRANSPORT=socketio` is set, Hermes instead opens an outbound Socket.IO connection to the BlueBubbles server. ## How It Works ``` -iMessage → Messages.app → BlueBubbles Server → Webhook → Hermes +iMessage → Messages.app → BlueBubbles Server → Webhook or Socket.IO → Hermes Hermes → BlueBubbles REST API → Messages.app → iMessage ``` -- **Inbound:** BlueBubbles sends webhook events to a local listener when new messages arrive. No polling — instant delivery. +- **Inbound:** By default, BlueBubbles sends webhook events to a local Hermes listener when new messages arrive. No polling — instant delivery. +- **Socket.IO inbound:** Set `BLUEBUBBLES_TRANSPORT=socketio` or `platforms.bluebubbles.extra.transport: socketio` when Hermes cannot expose a webhook listener that the Mac can reach. In this mode Hermes initiates the realtime connection outward to BlueBubbles. Accepted aliases are `socketio`, `socket.io`, `socket`, `websocket`, and `ws`. Duplicate events with the same iMessage GUID, including later `updated-message` events for edits/retractions, are acknowledged but not dispatched as new agent turns. - **Outbound:** Hermes sends messages via the BlueBubbles REST API. - **Media:** Images, voice messages, videos, and documents are supported in both directions. Inbound attachments are downloaded and cached locally for the agent to process. @@ -112,6 +130,7 @@ Hermes → BlueBubbles REST API → Messages.app → iMessage | `BLUEBUBBLES_WEBHOOK_HOST` | No | `127.0.0.1` | Webhook listener bind address | | `BLUEBUBBLES_WEBHOOK_PORT` | No | `8645` | Webhook listener port | | `BLUEBUBBLES_WEBHOOK_PATH` | No | `/bluebubbles-webhook` | Webhook URL path | +| `BLUEBUBBLES_TRANSPORT` | No | `webhook` | Inbound transport: `webhook` or `socketio` | | `BLUEBUBBLES_HOME_CHANNEL` | No | — | Phone/email for cron delivery | | `BLUEBUBBLES_ALLOWED_USERS` | No | — | Comma-separated authorized users | | `BLUEBUBBLES_ALLOW_ALL_USERS` | No | `false` | Allow all users | From ccbe01e877e177c772bc5ff95fcc4cdffc50a964 Mon Sep 17 00:00:00 2001 From: Biggie Date: Tue, 28 Jul 2026 14:19:26 -0600 Subject: [PATCH 2/2] fix(cron): reject stale agent model-pin arguments --- cron/scheduler.py | 18 ++++----- hermes_cli/config.py | 4 +- tests/cron/test_cron_provider_pin.py | 2 +- tests/cron/test_scheduler.py | 5 +++ tests/hermes_cli/test_set_config_value.py | 6 ++- tests/tools/test_cronjob_tools.py | 48 +++++++++++++++-------- tools/cronjob_tools.py | 35 ++++++++++++++--- 7 files changed, 82 insertions(+), 36 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 2428d500a73e5..7da2a8771d56b 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -3153,9 +3153,9 @@ def run_job( # Model resolution precedence: per-job override > cron.model (the # cron-fleet default) > HERMES_MODEL env > config.yaml ``model:`` # (string or ``{default: ...}``). The per-job value is intentionally - # re-read from storage every tick so a ``cronjob action=update - # model=...`` after a failed run takes effect on the next tick — there - # is no in-memory cache. + # re-read from storage every tick so a ``hermes cron edit + # --model ... --provider ...`` after a failed run takes effect on the + # next tick — there is no in-memory cache. model = job.get("model") or os.getenv("HERMES_MODEL") or "" # cron.model / cron.model_provider: a deliberate cron-fleet default @@ -3219,9 +3219,9 @@ def run_job( f"(job.model={job.get('model')!r}, " f"HERMES_MODEL={os.getenv('HERMES_MODEL', '')!r}, " "config.yaml model.default missing or empty). " - f"Set a per-job model via " - f"`cronjob action=update job_id={job_id} model=` or set a " - "default with `hermes model `." + "Set a per-job model via " + f"`hermes cron edit {job_id} --model --provider ` " + "or set a default with `hermes model `." ) # Apply IPv4 preference if configured. @@ -3422,7 +3422,7 @@ def run_job( "Job '%s': SKIPPED — global inference config drifted since " "creation (%s) and this job is unpinned. Skipped to prevent " "unintended spend. Pin explicitly to proceed: " - "`cronjob action=update job_id=%s provider=

model=`.", + "`hermes cron edit %s --provider --model `.", job_id, _changes, job_id, @@ -3431,8 +3431,8 @@ def run_job( f"Skipped to prevent unintended spend: global inference config " f"drifted since this job was created ({_changes}), and this job " f"is unpinned. No inference call was made. To run on the new " - f"config, pin it explicitly: `cronjob action=update " - f"job_id={job_id} provider= model=` " + f"config, pin it explicitly: `hermes cron edit {job_id} " + f"--provider --model ` " f"(or pin the original values to keep them). See #44585." ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6c492d818a93c..14ae87f0972af 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -9003,8 +9003,8 @@ def warn_unpinned_cron_jobs_after_model_config_change( f"{snapshot_field} values that differ from the new global {axis}. " "They will fail closed on their next run instead of silently using the " "changed model/provider. Inspect with `hermes cron list`, then pin the " - "intended values with `cronjob action=update job_id= " - "provider= model=`." + "intended values with `hermes cron edit --model " + "--provider `." ) diff --git a/tests/cron/test_cron_provider_pin.py b/tests/cron/test_cron_provider_pin.py index 7c3a9c66ac421..8de40357e4729 100644 --- a/tests/cron/test_cron_provider_pin.py +++ b/tests/cron/test_cron_provider_pin.py @@ -107,7 +107,7 @@ def test_b_unpinned_snapshot_differs_fails_closed(self, tmp_path): assert "openrouter" in blob assert "nous" in blob assert "spend" in blob - assert "cronjob action=update" in blob + assert "hermes cron edit" in blob assert "44585" in blob def test_c_no_snapshot_runs_backcompat(self, tmp_path): diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index ec8be40631318..05c5b6b360eae 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -2629,6 +2629,11 @@ def test_no_model_anywhere_fails_with_actionable_error(self, tmp_path, monkeypat assert success is False assert error is not None assert "no model configured" in error + assert ( + "hermes cron edit no-model-job --model --provider " + in error + ) + assert "cronjob action=update" not in error # AIAgent must never be constructed with an empty model — that's # precisely the bug we're guarding against. mock_agent_cls.assert_not_called() diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 9798204ba166c..8fb7d9914b105 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -418,7 +418,8 @@ def test_model_default_change_warns_for_unpinned_snapshot_jobs( assert "1 enabled unpinned cron job" in captured.out assert "model_snapshot" in captured.out assert "fail closed" in captured.out - assert "cronjob action=update job_id= provider= model=" in captured.out + assert "hermes cron edit --model --provider " in captured.out + assert "cronjob action=update" not in captured.out assert "do not print this prompt" not in captured.out def test_provider_change_warns_for_unpinned_snapshot_jobs( @@ -447,7 +448,8 @@ def test_provider_change_warns_for_unpinned_snapshot_jobs( assert "1 enabled unpinned cron job" in captured.out assert "provider_snapshot" in captured.out assert "new global provider" in captured.out - assert "cronjob action=update job_id= provider= model=" in captured.out + assert "hermes cron edit --model --provider " in captured.out + assert "cronjob action=update" not in captured.out def test_pinned_jobs_and_missing_snapshots_do_not_warn( self, diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index a3827fd5b70a6..13d831fc3a9ab 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -537,8 +537,8 @@ def test_update_normalizes_list_form_deliver(self): class TestAgentCannotSetModelPin: """Per-job inference pins are user-owned (dashboard / `hermes cron` --model / hand-edited jobs). The agent-facing tool schema must not expose - model/provider/base_url, and the registered handler must ignore them even - if a model hallucinates the old parameters.""" + model/provider/base_url, and the registered handler must reject them when + an upgraded long-lived session still sends the stale arguments.""" def test_schema_has_no_inference_pin_params(self): from tools.cronjob_tools import CRONJOB_SCHEMA @@ -548,8 +548,7 @@ def test_schema_has_no_inference_pin_params(self): assert "provider" not in props assert "base_url" not in props - def test_handler_ignores_hallucinated_model_args(self): - from cron.jobs import get_job + def test_handler_rejects_stale_model_args(self): from tools.registry import registry result = json.loads( @@ -565,16 +564,14 @@ def test_handler_ignores_hallucinated_model_args(self): }, ) ) - assert result["success"] is True - stored = get_job(result["job_id"]) - assert stored is not None - assert stored["model"] is None - assert stored["provider"] is None - assert stored["base_url"] is None - - def test_handler_update_leaves_user_pin_untouched(self): - """An update through the agent handler must not clear or change a - user-set pin (grandfathered agent-era pins included).""" + assert "error" in result + assert "user-owned" in result["error"] + assert "hermes cron create" in result["error"] + assert "job_id" not in result + + def test_handler_rejects_stale_update_without_partial_mutation(self): + """A stale update must fail atomically instead of reporting success + while silently ignoring the requested model pin.""" from cron.jobs import get_job from tools.registry import registry @@ -600,12 +597,31 @@ def test_handler_update_leaves_user_pin_untouched(self): }, ) ) - assert updated["success"] is True + assert "error" in updated + assert "hermes cron edit" in updated["error"] stored = get_job(job_id) assert stored is not None assert stored["model"] == "anthropic/claude-sonnet-4" assert stored["provider"] == "anthropic" - assert stored["name"] == "renamed" + assert stored["name"] != "renamed" + + @pytest.mark.parametrize("action", ["UPDATE", " update "]) + def test_handler_normalizes_stale_update_action(self, action): + from tools.registry import registry + + result = json.loads( + registry.dispatch( + "cronjob", + { + "action": action, + "job_id": "cron-123", + "provider": "openrouter", + }, + ) + ) + assert "error" in result + assert "hermes cron edit " in result["error"] + assert "hermes cron create" not in result["error"] class TestLocalDeliveryNotice: diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3738486af24cd..c1aadfbee75f3 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -1065,11 +1065,27 @@ def check_cronjob_requirements() -> bool: # --- Registry --- from tools.registry import registry, tool_error -registry.register( - name="cronjob", - toolset="cronjob", - schema=CRONJOB_SCHEMA, - handler=lambda args, **kw: cronjob( + +def _cronjob_handler(args, **kw): + """Dispatch agent cron calls while rejecting user-owned pin arguments.""" + stale_pin_args = [ + key for key in ("model", "provider", "base_url") if key in args + ] + if stale_pin_args: + action = str(args.get("action") or "").strip().lower() + if action == "update": + command = "hermes cron edit " + else: + command = "hermes cron create" + return tool_error( + "Per-job inference pins are user-owned and cannot be changed by the " + "agent-facing cronjob tool. No cron mutation was made. Use " + f"`{command} --model --provider ` instead; manage " + "custom base URLs in the dashboard or job configuration. " + f"Rejected stale arguments: {', '.join(stale_pin_args)}." + ) + + return cronjob( action=args.get("action", ""), job_id=args.get("job_id"), prompt=args.get("prompt"), @@ -1092,7 +1108,14 @@ def check_cronjob_requirements() -> bool: workdir=args.get("workdir"), no_agent=args.get("no_agent"), task_id=kw.get("task_id"), - ), + ) + + +registry.register( + name="cronjob", + toolset="cronjob", + schema=CRONJOB_SCHEMA, + handler=_cronjob_handler, check_fn=check_cronjob_requirements, emoji="⏰", )