From 73eadd54f5048367cabdd1e4389f15ed6bd33986 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:26:31 -0700 Subject: [PATCH] fix(platforms): standalone senders return redacted error envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20 `plugins/platforms/*/adapter.py::_standalone_send` paths (the out-of-process cron / send_message delivery) built `{"error": f"... {e}"}` by hand — 83 literals. The exception text of an httpx/aiohttp failure can carry the Authorization header, a signed URL or a response body with the token in it, and that string became the tool result the model reads. Only sms went through the redacting `tools.send_message_senders._error`; discord kept a private regex that only knew `Authorization: Bot`. `gateway.platforms._shared.send_error(message)` wraps that helper (agent.redact + URL-secret scrub) and every standalone literal now goes through it, including the three envelopes that carry extra keys (discord warnings, photon error_class/retryable, whatsapp's `(None, err)` tuple). The sms and discord local wrappers are deleted. Telegram already delegated to the core sender and is untouched. Behavior change (security): vendor exception text in standalone-send failures is redacted before reaching the model. --- gateway/platforms/_shared.py | 8 ++ plugins/platforms/buzz/adapter.py | 16 ++-- plugins/platforms/dingtalk/adapter.py | 10 +-- plugins/platforms/discord/adapter.py | 27 +++---- plugins/platforms/email/adapter.py | 6 +- plugins/platforms/feishu/adapter.py | 14 ++-- plugins/platforms/google_chat/adapter.py | 6 +- plugins/platforms/homeassistant/adapter.py | 12 +-- plugins/platforms/irc/adapter.py | 8 +- plugins/platforms/line/adapter.py | 6 +- plugins/platforms/matrix/adapter.py | 12 +-- plugins/platforms/mattermost/adapter.py | 14 ++-- plugins/platforms/ntfy/adapter.py | 10 +-- plugins/platforms/photon/adapter.py | 10 +-- plugins/platforms/simplex/adapter.py | 8 +- plugins/platforms/slack/adapter.py | 16 ++-- plugins/platforms/sms/adapter.py | 19 ++--- plugins/platforms/teams/adapter.py | 14 ++-- plugins/platforms/wecom/adapter.py | 12 +-- plugins/platforms/whatsapp/adapter.py | 10 +-- .../gateway/test_standalone_send_redaction.py | 73 +++++++++++++++++++ 21 files changed, 187 insertions(+), 124 deletions(-) create mode 100644 tests/gateway/test_standalone_send_redaction.py diff --git a/gateway/platforms/_shared.py b/gateway/platforms/_shared.py index 202cc3e3ec..1996ded261 100644 --- a/gateway/platforms/_shared.py +++ b/gateway/platforms/_shared.py @@ -67,6 +67,14 @@ def yaml_env_setter() -> Callable[[str, Any], None]: return set_env +def send_error(message: Any) -> dict: + """Standalone-sender failure envelope with vendor exception text redacted (the same helper + ``send_message`` uses), so a token or signed URL in an httpx/aiohttp error never reaches the + model transcript.""" + from tools.send_message_senders import _error + return _error(str(message)) + + def coerce_port(value: Any, default: int) -> int: """``int(value)`` or ``default`` when unparseable.""" try: diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index de09a02aad..fa94cfa0b3 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -32,7 +32,7 @@ from agent.secret_scope import ( UnscopedSecretError as _UnscopedSecretError, current_secret_scope as _current_secret_scope, get_secret as _scoped_get_secret, is_multiplex_active as _is_multiplex_active, ) -from gateway.platforms._shared import profile_scoped as _profile_scoped +from gateway.platforms._shared import profile_scoped as _profile_scoped, send_error def _get_scoped_secret(name, default=None): @@ -2020,14 +2020,14 @@ async def _standalone_send( try: auth_tag = _resolve_auth_tag(extra) except ValueError as exc: - return {"error": f"Buzz standalone send: {exc}"} + return send_error(f"Buzz standalone send: {exc}") cli_path = _configured_cli_path(extra) if not relay or not private_key: - return {"error": "Buzz standalone send: BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY must be configured"} + return send_error("Buzz standalone send: BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY must be configured") if not cli_path: - return {"error": "Buzz standalone send: buzz CLI binary not found"} + return send_error("Buzz standalone send: buzz CLI binary not found") if not (target := (chat_id or "").strip() or _configured_home_channel(extra)): - return {"error": "Buzz standalone send: no target channel (set BUZZ_HOME_CHANNEL)"} + return send_error("Buzz standalone send: no target channel (set BUZZ_HOME_CHANNEL)") args = ["messages", "send", "--channel", target, "--content", "-"] # Same reply_to_mode / reply_in_thread gate as the live adapter. if thread_id and _reply_to_mode(pconfig, extra) != "off": @@ -2044,12 +2044,12 @@ async def _standalone_send( except asyncio.CancelledError: raise except OSError as e: - return {"error": f"Buzz standalone send failed to launch CLI: {_bounded_cli_message(str(e))}"} + return send_error(f"Buzz standalone send failed to launch CLI: {_bounded_cli_message(str(e))}") if code != 0: - return {"error": f"Buzz standalone send failed: {_cli_error_message(err, code)}"} + return send_error(f"Buzz standalone send failed: {_cli_error_message(err, code)}") event_id, receipt_error = _parse_send_receipt(out) if receipt_error: - return {"error": f"Buzz standalone send failed: {receipt_error}"} + return send_error(f"Buzz standalone send failed: {receipt_error}") result = {"success": True, "message_id": event_id} if media_files: result["media_delivered"] = True diff --git a/plugins/platforms/dingtalk/adapter.py b/plugins/platforms/dingtalk/adapter.py index 77815819c6..442d64edff 100644 --- a/plugins/platforms/dingtalk/adapter.py +++ b/plugins/platforms/dingtalk/adapter.py @@ -50,7 +50,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator, compile_mention_patterns from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error, yaml_env_setter as _yaml_env_setter from plugins.platforms.dingtalk.inbound import collect_download_codes, extract_media, extract_text @@ -632,26 +632,26 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f try: import httpx except ImportError: - return {"error": "httpx not installed"} + return send_error("httpx not installed") # Scoped: the webhook URL carries the robot's access_token and IS the delivery target — a raw # environ read would post a secondary profile's cron output to the default profile's robot. webhook_url = (getattr(pconfig, "extra", {}) or {}).get("webhook_url") or _get_scoped_secret("DINGTALK_WEBHOOK_URL", "") if not webhook_url: - return {"error": "DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config."} + return send_error("DingTalk not configured. Set DINGTALK_WEBHOOK_URL env var or webhook_url in dingtalk platform extra config.") try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post(webhook_url, json={"msgtype": "text", "text": {"content": message}}) resp.raise_for_status() data = resp.json() if data.get("errcode", 0) != 0: - return {"error": f"DingTalk API error: {data.get('errmsg', 'unknown')}"} + return send_error(f"DingTalk API error: {data.get('errmsg', 'unknown')}") return {"success": True, "platform": "dingtalk", "chat_id": chat_id} except Exception as e: try: # send_message_tool._error redacts access_token from webhook URLs (lazy import avoids a circular) from tools.send_message_tool import _error as _redact_error return _redact_error(f"DingTalk send failed: {e}") except Exception: - return {"error": f"DingTalk send failed: {e}"} + return send_error(f"DingTalk send failed: {e}") def interactive_setup() -> None: diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index e465d93b04..c6605db022 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -268,7 +268,7 @@ from gateway.platforms.base import ( ) from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome from tools.url_safety import is_safe_url -from gateway.platforms._shared import yaml_env_setter as _yaml_env_setter +from gateway.platforms._shared import send_error, yaml_env_setter as _yaml_env_setter async def _read_url_image_with_redirect_guard( @@ -6544,13 +6544,6 @@ def _derive_forum_thread_name(message: str) -> str: return first_line[:100] -def _standalone_sanitize_error(text) -> str: - """Local copy of tools.send_message_tool._sanitize_error_text (strips bot tokens); avoids hard dep.""" - s = str(text) - import re as _re_san - return _re_san.sub(r"(Authorization:\s*Bot\s+)\S+", r"\1***", s, flags=_re_san.IGNORECASE) - - def _standalone_close_response(resp: Any) -> None: close = getattr(resp, "close", None) if callable(close): @@ -6630,7 +6623,7 @@ async def _standalone_response_json_or_error(resp: Any, error_prefix: str): with the (size-capped) body text appended to ``error_prefix``.""" if resp.status not in {200, 201}: body = await _standalone_read_text_limited(resp, _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES) - return None, {"error": f"{error_prefix} ({resp.status}): {body}"} + return None, send_error(f"{error_prefix} ({resp.status}): {body}") return await _standalone_read_json_limited(resp, _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES), None @@ -6672,14 +6665,14 @@ async def _standalone_send( try: import aiohttp except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") token = (getattr(pconfig, "token", None) or "").strip() if not token: # Profile-scoped read: under multiplex the env may hold another profile's token. from agent.secret_scope import get_secret token = (get_secret("DISCORD_BOT_TOKEN", "") or "").strip() if not token: - return {"error": "Discord standalone send: DISCORD_BOT_TOKEN is not set"} + return send_error("Discord standalone send: DISCORD_BOT_TOKEN is not set") try: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") @@ -6726,7 +6719,7 @@ async def _standalone_send( if err: return err except Exception as e: - return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")} + return send_error(f"Discord forum thread upload failed: {e}") else: # No media: JSON POST creates the thread with the text starter. async with session.post( @@ -6785,20 +6778,18 @@ async def _standalone_send( async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: data, err = await _standalone_response_json_or_error(resp, "Discord API error") if err: - warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {err['error']}") + warning = send_error(f"Failed to send media {media_path}: {err['error']}")["error"] logger.error(warning) warnings.append(warning) continue last_data = data except Exception as e: - warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}") + warning = send_error(f"Failed to send media {media_path}: {e}")["error"] logger.error(warning) warnings.append(warning) if last_data is None: error = "No deliverable text or media remained after processing" - if warnings: - return {"error": error, "warnings": warnings} - return {"error": error} + return {**send_error(error), **({"warnings": warnings} if warnings else {})} result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")} if warnings: result["warnings"] = warnings @@ -6806,7 +6797,7 @@ async def _standalone_send( except Exception as e: # Include the exception type: str(TimeoutError()) is empty. logger.error("Discord standalone send failed", exc_info=True) - return {"error": _standalone_sanitize_error(f"Discord send failed: {type(e).__name__}: {e}")} + return send_error(f"Discord send failed: {type(e).__name__}: {e}") # ── Plugin entry point ──────────────────────────────────────────────────────── diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index 40a89a153a..bcb3a9e682 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -28,7 +28,7 @@ from gateway.platforms.base import ( from gateway.platforms.event import MessageEvent, MessageType from gateway.config import Platform, PlatformConfig from utils import is_truthy_value -from gateway.platforms._shared import get_scoped_secret as _get_secret, coerce_port +from gateway.platforms._shared import get_scoped_secret as _get_secret, coerce_port, send_error logger = logging.getLogger(__name__) @@ -780,7 +780,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f smtp_security = _normalize_security(_get_secret("EMAIL_SMTP_SECURITY", "") or extra.get("smtp_security"), default="tls" if smtp_port == 465 else "starttls") smtp_tls_verify = _esecret_bool("EMAIL_SMTP_TLS_VERIFY", is_truthy_value(extra.get("smtp_tls_verify"), default=True)) if not all([address, password, smtp_host]): - return {"error": "Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)"} + return send_error("Email not configured (EMAIL_ADDRESS, EMAIL_PASSWORD, EMAIL_SMTP_HOST required)") try: msg = MIMEText(message, "plain", "utf-8") for key, value in (("From", address), ("To", chat_id), ("Subject", "Hermes Agent"), ("Date", formatdate(localtime=True))): @@ -795,7 +795,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f from tools.send_message_tool import _error as _e return _e(f"Email send failed: {e}") except Exception: - return {"error": f"Email send failed: {e}"} + return send_error(f"Email send failed: {e}") def _is_connected(config) -> bool: diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index 191184f778..bc7e9cbabc 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -92,7 +92,7 @@ from gateway.status import acquire_scoped_lock, release_scoped_lock from hermes_constants import get_hermes_home from utils import atomic_json_write, env_float, env_int -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error, yaml_env_setter as _yaml_env_setter logger = logging.getLogger(__name__) @@ -4125,7 +4125,7 @@ _MIGRATION_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"} async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): """standalone_sender_fn: out-of-process delivery (cron without gateway) via a transient adapter.""" if not await asyncio.to_thread(_load_lark_oapi): - return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."} + return send_error("Feishu dependencies not installed. Run `hermes setup` to install Feishu support.") try: adapter = FeishuAdapter(pconfig) adapter._client = adapter._build_lark_client(_sdk_domain(getattr(adapter, "_domain_name", "feishu"))) @@ -4134,10 +4134,10 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f if message.strip(): last_result = await adapter.send(chat_id, message, metadata=metadata) if not last_result.success: - return {"error": f"Feishu send failed: {last_result.error}"} + return send_error(f"Feishu send failed: {last_result.error}") for media_path, _is_voice in media_files or []: if not os.path.exists(media_path): - return {"error": f"Media file not found: {media_path}"} + return send_error(f"Media file not found: {media_path}") ext = os.path.splitext(media_path)[1].lower() if ext in _MIGRATION_IMAGE_EXTS: sender = adapter.send_image_file @@ -4149,12 +4149,12 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f sender = adapter.send_document last_result = await sender(chat_id, media_path, metadata=metadata) if not last_result.success: - return {"error": f"Feishu media send failed: {last_result.error}"} + return send_error(f"Feishu media send failed: {last_result.error}") if last_result is None: - return {"error": "No deliverable text or media remained after processing MEDIA tags"} + return send_error("No deliverable text or media remained after processing MEDIA tags") return {"success": True, "platform": "feishu", "chat_id": chat_id, "message_id": last_result.message_id} except Exception as e: - return {"error": f"Feishu send failed: {e}"} + return send_error(f"Feishu send failed: {e}") def interactive_setup() -> None: diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 4b2ee48e91..082d7e4c25 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -24,7 +24,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse from agent.secret_scope import is_multiplex_active -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error from .cards import card_spec_to_cards_v2, format_message as _format_message @@ -1637,7 +1637,7 @@ _STANDALONE_SA_ERRORS = { def _standalone_error(detail: str) -> Dict[str, Any]: - return {"error": f"Google Chat standalone send: {detail}"} + return send_error(f"Google Chat standalone send: {detail}") async def _standalone_send( @@ -1697,7 +1697,7 @@ async def _standalone_send( return {"success": True, "message_id": payload.get("name")} except Exception as e: logger.debug("Google Chat standalone send raised", exc_info=True) - return {"error": f"Google Chat standalone send failed: {e}"} + return send_error(f"Google Chat standalone send failed: {e}") def register(ctx) -> None: diff --git a/plugins/platforms/homeassistant/adapter.py b/plugins/platforms/homeassistant/adapter.py index 69608b399c..e39bb48dcb 100644 --- a/plugins/platforms/homeassistant/adapter.py +++ b/plugins/platforms/homeassistant/adapter.py @@ -22,7 +22,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent, MessageType -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error logger = logging.getLogger(__name__) @@ -317,24 +317,24 @@ async def _standalone_send( ``thread_id``/``media_files``/``force_document`` are signature parity only (HA has no threads/attachments). """ if not AIOHTTP_AVAILABLE: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") extra = getattr(pconfig, "extra", {}) or {} hass_url = (extra.get("url") or _get_scoped_secret("HASS_URL", "")).rstrip("/") token = (getattr(pconfig, "token", None) or _get_scoped_secret("HASS_TOKEN", "")).strip() if not hass_url or not token: - return {"error": "Home Assistant standalone send: HASS_URL and HASS_TOKEN must both be set"} + return send_error("Home Assistant standalone send: HASS_URL and HASS_TOKEN must both be set") url = f"{hass_url}/api/services/notify/notify" payload = {"message": message, "target": chat_id} try: async with HomeAssistantAdapter._new_session() as session: async with session.post(url, headers=_auth_headers(token), json=payload) as resp: if resp.status not in {200, 201}: - return {"error": f"Home Assistant API error ({resp.status}): {await resp.text()}"} + return send_error(f"Home Assistant API error ({resp.status}): {await resp.text()}") return {"success": True, "platform": "homeassistant", "chat_id": chat_id} except asyncio.TimeoutError: - return {"error": "Timeout sending notification to Home Assistant"} + return send_error("Timeout sending notification to Home Assistant") except Exception as e: - return {"error": f"Home Assistant send failed: {e}"} + return send_error(f"Home Assistant send failed: {e}") def _is_connected(config) -> bool: diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index a5cd29340b..b8c052e982 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -15,7 +15,7 @@ import ssl import time from typing import Any, Dict, List, Optional -from gateway.platforms._shared import coerce_port, get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import coerce_port, get_scoped_secret as _get_scoped_secret, send_error from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent, MessageType from gateway.config import Platform @@ -447,7 +447,7 @@ def _is_irc_channel(target: str) -> bool: def _sa_error(detail: str) -> Dict[str, Any]: - return {"error": f"IRC standalone send: {detail}"} + return send_error(f"IRC standalone send: {detail}") class _StandaloneConn: @@ -554,7 +554,7 @@ async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Op except asyncio.CancelledError: raise except Exception as e: - return {"error": f"IRC standalone connect failed: {e}"} + return send_error(f"IRC standalone connect failed: {e}") conn = _StandaloneConn(reader, writer) try: if error := await _sa_register(conn, nick_base, _env_or_extra(extra, "IRC_SERVER_PASSWORD", "server_password")): @@ -582,7 +582,7 @@ async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Op raise except Exception as e: logger.debug("IRC standalone send raised", exc_info=True) - return {"error": f"IRC standalone send failed: {e}"} + return send_error(f"IRC standalone send failed: {e}") finally: await conn.close() diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index f894a175ff..746528d288 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -35,7 +35,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set, Tuple from urllib.parse import quote as _urlquote -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, SendResult, cache_audio_from_bytes_async, cache_document_from_bytes_async, cache_image_from_bytes_async, @@ -950,7 +950,7 @@ async def _standalone_send( extra = getattr(pconfig, "extra", {}) or {} token = _get_scoped_secret("LINE_CHANNEL_ACCESS_TOKEN") or extra.get("channel_access_token", "") if not token or not chat_id: - return {"error": "LINE standalone send: missing token or chat_id"} + return send_error("LINE standalone send: missing token or chat_id") messages = _text_messages(message or "") or [_text_message("")] if media_files: # tell the recipient media was generated but not delivered messages.append(_text_message(f"[{len(media_files)} attachment(s) generated; not deliverable from cron]")) @@ -959,7 +959,7 @@ async def _standalone_send( await _LineClient(token).push(chat_id, messages) return {"success": True, "message_id": None} except Exception as exc: - return {"error": str(exc)} + return send_error(str(exc)) _SETUP_PROMPTS = ( # (env var, prompt, masked) diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 0bde795135..af3a93c6dc 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -38,7 +38,7 @@ from pathlib import Path from typing import Any, Dict, Optional, Set from agent.secret_scope import UnscopedSecretError, get_secret -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error, yaml_env_setter as _yaml_env_setter try: from mautrix.types import ( @@ -2902,14 +2902,14 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f try: import aiohttp except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") try: # In-turn reads inside an installed secret scope: honor get_secret, no env fallback — for the # homeserver too, so the scoped token is never sent to the default profile's server. homeserver = (extra.get("homeserver") or get_secret("MATRIX_HOMESERVER", "") or "").rstrip("/") token = getattr(pconfig, "token", None) or get_secret("MATRIX_ACCESS_TOKEN", "") or "" if not homeserver or not token: - return {"error": "Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)"} + return send_error("Matrix not configured (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN required)") txn_id = f"hermes_{int(time.time() * 1000)}_{os.urandom(4).hex()}" from urllib.parse import quote url = f"{homeserver}/_matrix/client/v3/rooms/{quote(chat_id, safe='')}/send/m.room.message/{txn_id}" @@ -2928,16 +2928,16 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f async def _do_send(): async with session.put(url, headers=headers, json=payload) as resp: if resp.status not in {200, 201}: - return {"error": f"Matrix API error ({resp.status}): {await resp.text()}"} + return send_error(f"Matrix API error ({resp.status}): {await resp.text()}") data = await resp.json() return {"success": True, "platform": "matrix", "chat_id": chat_id, "message_id": data.get("event_id")} try: return await asyncio.wait_for(_do_send(), timeout=30) except asyncio.TimeoutError: - return {"error": "Matrix API timeout (30s)"} + return send_error("Matrix API timeout (30s)") except Exception as e: - return {"error": f"Matrix send failed: {e}"} + return send_error(f"Matrix send failed: {e}") def interactive_setup() -> None: diff --git a/plugins/platforms/mattermost/adapter.py b/plugins/platforms/mattermost/adapter.py index c0b6561ebf..1c28ebdc38 100644 --- a/plugins/platforms/mattermost/adapter.py +++ b/plugins/platforms/mattermost/adapter.py @@ -24,7 +24,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent, MessageType -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, profile_scoped as _profile_scoped_config_load +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, profile_scoped as _profile_scoped_config_load, send_error logger = logging.getLogger(__name__) @@ -609,12 +609,12 @@ async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Op try: import aiohttp except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") base_url, token = _url_and_token(pconfig) base_url, token = base_url.rstrip("/"), token.strip() if not base_url or not token: - return {"error": "Mattermost standalone send: MATTERMOST_URL and MATTERMOST_TOKEN must both be set"} + return send_error("Mattermost standalone send: MATTERMOST_URL and MATTERMOST_TOKEN must both be set") upload_headers = {"Authorization": f"Bearer {token}"} headers = {**upload_headers, "Content-Type": "application/json"} try: @@ -635,7 +635,7 @@ async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Op **_req_kw) as upload_resp: if upload_resp.status not in {200, 201}: body = await upload_resp.text() - return {"error": f"Mattermost file upload failed ({upload_resp.status}): {body[:400]}"} + return send_error(f"Mattermost file upload failed ({upload_resp.status}): {body[:400]}") upload_data = await upload_resp.json() file_ids.extend(info["id"] for info in upload_data.get("file_infos", []) if info.get("id")) payload: Dict[str, Any] = {"channel_id": chat_id, "message": message} @@ -646,13 +646,13 @@ async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Op async with session.post(f"{base_url}/api/v4/posts", headers=headers, json=payload, **_req_kw) as resp: if resp.status not in {200, 201}: body = await resp.text() - return {"error": f"Mattermost API error ({resp.status}): {body[:400]}"} + return send_error(f"Mattermost API error ({resp.status}): {body[:400]}") data = await resp.json() return {"success": True, "platform": "mattermost", "chat_id": chat_id, "message_id": data.get("id")} except aiohttp.ClientError as exc: - return {"error": f"Mattermost send failed (network): {exc}"} + return send_error(f"Mattermost send failed (network): {exc}") except Exception as exc: # noqa: BLE001 - return {"error": f"Mattermost send failed: {exc}"} + return send_error(f"Mattermost send failed: {exc}") # --- Interactive setup wizard --- diff --git a/plugins/platforms/ntfy/adapter.py b/plugins/platforms/ntfy/adapter.py index 145c795c96..8babad3aa7 100644 --- a/plugins/platforms/ntfy/adapter.py +++ b/plugins/platforms/ntfy/adapter.py @@ -27,7 +27,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent, MessageType -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error logger = logging.getLogger(__name__) @@ -355,14 +355,14 @@ async def _standalone_send( OR ``pconfig.extra["markdown"]`` is True. """ if not HTTPX_AVAILABLE: - return {"error": "ntfy standalone send: httpx not installed"} + return send_error("ntfy standalone send: httpx not installed") extra = getattr(pconfig, "extra", {}) or {} server = _server_url(extra) publish_topic = ( chat_id or extra.get("publish_topic") or _get_scoped_secret("NTFY_PUBLISH_TOPIC", "").strip() or extra.get("topic") or _get_scoped_secret("NTFY_TOPIC", "").strip()) if not publish_topic: - return {"error": "ntfy standalone send: NTFY_TOPIC not configured"} + return send_error("ntfy standalone send: NTFY_TOPIC not configured") token = _setting(extra, "token", "NTFY_TOKEN") markdown_env = _get_scoped_secret("NTFY_MARKDOWN", "").strip().lower() markdown = bool(extra.get("markdown")) or markdown_env in _MARKDOWN_TRUTHY @@ -372,10 +372,10 @@ async def _standalone_send( async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post(f"{server}/{publish_topic}", content=body, headers=headers) if resp.status_code >= 300: - return {"error": f"ntfy HTTP {resp.status_code}: {resp.text[:200]}"} + return send_error(f"ntfy HTTP {resp.status_code}: {resp.text[:200]}") return {"success": True, "platform": "ntfy", "chat_id": publish_topic, "message_id": _response_message_id(resp)} except Exception as e: - return {"error": f"ntfy standalone send failed: {e}"} + return send_error(f"ntfy standalone send failed: {e}") def register(ctx) -> None: diff --git a/plugins/platforms/photon/adapter.py b/plugins/platforms/photon/adapter.py index e9e075c9c5..f509df80a2 100644 --- a/plugins/platforms/photon/adapter.py +++ b/plugins/platforms/photon/adapter.py @@ -37,7 +37,7 @@ else: from gateway.config import Platform, PlatformConfig from gateway.platforms._shared import coerce_port as _coerce_port -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent, MessageType from gateway.platforms.helpers import compile_mention_patterns, strip_markdown @@ -1496,7 +1496,7 @@ def _standalone_error(resp: Any) -> Dict[str, Any]: error = f"sidecar returned {resp.status_code}: {resp.text[:200]}" else: error = str(data.get("error") or "sidecar reported failure") - return {"error": error, "error_class": error_class, "retryable": retryable} + return {**send_error(error), "error_class": error_class, "retryable": retryable} def _standalone_token_from_record(port: int) -> Tuple[Optional[str], int, str]: @@ -1523,14 +1523,14 @@ async def _standalone_send( force_document: bool = False, # noqa: ARG001 — iMessage auto-detects file kind ) -> Dict[str, Any]: if not HTTPX_AVAILABLE: - return {"error": "httpx not installed"} + return send_error("httpx not installed") port = _coerce_port( (pconfig.extra or {}).get("sidecar_port") or _get_scoped_secret("PHOTON_SIDECAR_PORT"), _DEFAULT_SIDECAR_PORT) token = _get_scoped_secret("PHOTON_SIDECAR_TOKEN") if not token: token, port, error = _standalone_token_from_record(port) if not token: - return {"error": error} + return send_error(error) base = f"http://{_DEFAULT_SIDECAR_BIND}:{port}" headers = {"X-Hermes-Sidecar-Token": token} last_message_id: Optional[str] = None @@ -1571,7 +1571,7 @@ async def _standalone_send( last_message_id = data.get("messageId") or last_message_id return {"success": True, "message_id": last_message_id} except Exception as e: - return {"error": f"Photon standalone send failed: {e}"} + return send_error(f"Photon standalone send failed: {e}") # -- Plugin entry point ---------------------------------------------------------- diff --git a/plugins/platforms/simplex/adapter.py b/plugins/platforms/simplex/adapter.py index 7c4817a8d9..212a7188a3 100644 --- a/plugins/platforms/simplex/adapter.py +++ b/plugins/platforms/simplex/adapter.py @@ -24,7 +24,7 @@ from typing import Any, Dict, List, Optional from urllib.parse import unquote -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error from gateway.config import Platform, PlatformConfig from gateway.platforms.base import BasePlatformAdapter, SendResult, cache_image_from_url from gateway.platforms.event import MessageEvent, MessageType @@ -619,11 +619,11 @@ async def _standalone_send( try: import websockets as _wsclient except ImportError: - return {"error": "websockets not installed. Run: pip install websockets"} + return send_error("websockets not installed. Run: pip install websockets") extra = getattr(pconfig, "extra", {}) or {} ws_url = _get_scoped_secret("SIMPLEX_WS_URL") or extra.get("ws_url", "ws://127.0.0.1:5225") if not ws_url: - return {"error": "SimpleX standalone send: SIMPLEX_WS_URL is required"} + return send_error("SimpleX standalone send: SIMPLEX_WS_URL is required") try: payload = { "corrId": f"{_CORR_PREFIX}snd-{int(time.time() * 1000)}", @@ -633,7 +633,7 @@ async def _standalone_send( await asyncio.sleep(0.5) # let the daemon process the command before closing return {"success": True, "platform": "simplex", "chat_id": chat_id} except Exception as e: - return {"error": f"SimpleX send failed: {e}"} + return send_error(f"SimpleX send failed: {e}") _SETUP_PROMPTS = ( diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index fc54e654ad..8c1fe11072 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -35,7 +35,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) from agent.secret_scope import UnscopedSecretError, get_secret from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import MessageDeduplicator -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, yaml_env_setter as _yaml_env_setter +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error, yaml_env_setter as _yaml_env_setter from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, ExecApprovalPrompt, SendResult, SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, _TEXT_INJECT_EXTENSIONS, @@ -6213,7 +6213,7 @@ async def _standalone_upload_file( result = await client.files_upload_v2(**kwargs) payload = _slack_response_payload(result) if payload.get("ok") is False: - return {"error": f"Slack API error: {payload.get('error', 'unknown')}"} + return send_error(f"Slack API error: {payload.get('error', 'unknown')}") # files_upload_v2 responses vary by sdk version; prefer file timestamp when present. message_id = None if payload: @@ -6256,10 +6256,10 @@ async def _standalone_send_media( post_payload = await _standalone_post_text( client, chat_id, text_to_send, unfurl_kwargs, thread_id) if not post_payload.get("ok", True): - return {"error": f"Slack API error: {post_payload.get('error', 'unknown')}"} + return send_error(f"Slack API error: {post_payload.get('error', 'unknown')}") last_message_id = post_payload.get("ts") except Exception as e: - return {"error": f"Slack send failed: {e}"} + return send_error(f"Slack send failed: {e}") caption_pending = caption_as_upload_comment uploaded_any = False for media_path, _is_voice in media_files: @@ -6327,7 +6327,7 @@ async def _standalone_send( # Comma-separated multi-workspace list plus slack_tokens.json; no team map, so try each. tokens = _load_slack_bot_tokens(str(raw_token or ""), quiet=True) if not tokens: - return {"error": "Slack send failed: SLACK_BOT_TOKEN not configured"} + return send_error("Slack send failed: SLACK_BOT_TOKEN not configured") token = tokens[0] # Slack rejects bare user IDs (U.../W...) with channel_not_found; open the DM first. # User-targeted delivery: chat.postMessage / files_upload_v2 reject bare user IDs (U.../W...) — resolve @@ -6360,7 +6360,7 @@ async def _standalone_send( try: import aiohttp except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") try: _sess_kw, _req_kw = _standalone_proxy_kwargs() last_error = "unknown" @@ -6376,9 +6376,9 @@ async def _standalone_send( last_error = data.get("error", "unknown") if last_error not in _WRONG_WORKSPACE_TOKEN_ERRORS: break - return {"error": f"Slack API error: {last_error}"} + return send_error(f"Slack API error: {last_error}") except Exception as e: - return {"error": f"Slack send failed: {e}"} + return send_error(f"Slack send failed: {e}") _SETUP_STEPS = ( diff --git a/plugins/platforms/sms/adapter.py b/plugins/platforms/sms/adapter.py index 488a0d16a7..419f4e0752 100644 --- a/plugins/platforms/sms/adapter.py +++ b/plugins/platforms/sms/adapter.py @@ -25,7 +25,7 @@ from gateway.config import Platform, PlatformConfig from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, SendResult from gateway.platforms.event import MessageEvent, MessageType from gateway.platforms.helpers import redact_phone, strip_markdown -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error try: import aiohttp @@ -300,11 +300,11 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f """Out-of-process SMS delivery via the Twilio REST API (standalone_sender_fn contract).""" auth_token = getattr(pconfig, "api_key", None) or _get_scoped_secret("TWILIO_AUTH_TOKEN", "") if not AIOHTTP_AVAILABLE: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") from_number = _get_scoped_secret("TWILIO_PHONE_NUMBER", "") # scoped like account_sid: never the default's number if not account_sid or not auth_token or not from_number: - return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"} + return send_error("SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)") message = _strip_markdown_for_sms(message) try: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp @@ -316,19 +316,10 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f body = await resp.json() if resp.status >= 400: error_msg = body.get("message", str(body)) - return _redacted_error(f"Twilio API error ({resp.status}): {error_msg}") + return send_error(f"Twilio API error ({resp.status}): {error_msg}") return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": body.get("sid", "")} except Exception as e: - return _redacted_error(f"SMS send failed: {e}") - - -def _redacted_error(text: str) -> dict: - """Error dict with phone numbers redacted by send_message_tool when available.""" - try: - from tools.send_message_tool import _error as _e - return _e(text) - except Exception: - return {"error": text} + return send_error(f"SMS send failed: {e}") def _is_connected(config) -> bool: diff --git a/plugins/platforms/teams/adapter.py b/plugins/platforms/teams/adapter.py index 1cc29509c8..9bb8164dc0 100644 --- a/plugins/platforms/teams/adapter.py +++ b/plugins/platforms/teams/adapter.py @@ -58,7 +58,7 @@ from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, ExecApprovalPrompt, SendResult, cache_image_from_url, cache_media_bytes_async, ) from gateway.platforms.event import MessageEvent, MessageType -from gateway.platforms._shared import coerce_port, get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import coerce_port, get_scoped_secret as _get_scoped_secret, send_error logger = logging.getLogger(__name__) @@ -200,7 +200,7 @@ async def _standalone_send( extra = getattr(pconfig, "extra", {}) or {} client_id, client_secret, tenant_id = _credentials(pconfig) if not (client_id and client_secret and tenant_id): - return {"error": "Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required"} + return send_error("Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required") raw_service_url = extra.get("service_url") or _get_scoped_secret("TEAMS_SERVICE_URL", "") or _DEFAULT_TEAMS_SERVICE_URL service_url = _validate_teams_service_url(raw_service_url) for failed, error in ( @@ -211,7 +211,7 @@ async def _standalone_send( (not _TEAMS_CONV_ID_RE.match(tenant_id), "TEAMS_TENANT_ID contains characters outside the expected set"), (not AIOHTTP_AVAILABLE, "aiohttp not installed")): if failed: - return {"error": f"Teams standalone send: {error}"} + return send_error(f"Teams standalone send: {error}") token_url, token_form = _bf_token_request(tenant_id, client_id, client_secret) activities_url = f"{service_url}v3/conversations/{chat_id}/activities" try: @@ -225,11 +225,11 @@ async def _standalone_send( ) as token_resp: if token_resp.status >= 400: body = await token_resp.text() - return {"error": f"Teams standalone send: token request failed ({token_resp.status}): {body[:300]}"} + return send_error(f"Teams standalone send: token request failed ({token_resp.status}): {body[:300]}") token_payload = await token_resp.json() access_token = token_payload.get("access_token") if not access_token: - return {"error": "Teams standalone send: token response missing access_token"} + return send_error("Teams standalone send: token response missing access_token") async with session.post( activities_url, json={"type": "message", "text": message, "textFormat": "markdown"}, headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}, @@ -237,14 +237,14 @@ async def _standalone_send( ) as send_resp: if send_resp.status >= 400: body = await send_resp.text() - return {"error": f"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}"} + return send_error(f"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}") send_payload = await send_resp.json() return {"success": True, "message_id": send_payload.get("id")} except asyncio.CancelledError: raise except Exception as e: logger.debug("Teams standalone send raised", exc_info=True) - return {"error": f"Teams standalone send failed: {e}"} + return send_error(f"Teams standalone send failed: {e}") # SDK module → names rebound into this module's globals by check_teams_requirements(). diff --git a/plugins/platforms/wecom/adapter.py b/plugins/platforms/wecom/adapter.py index 4116da4289..dbc0f29429 100644 --- a/plugins/platforms/wecom/adapter.py +++ b/plugins/platforms/wecom/adapter.py @@ -32,7 +32,7 @@ from gateway.platforms.base import gateway_trust_env, BasePlatformAdapter, SendR from gateway.platforms.event import MessageEvent, MessageType from utils import env_float -from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret +from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret, send_error from plugins.platforms.wecom.send_queue import ChatSendQueueMixin from plugins.platforms.wecom.media import WeComMediaMixin, APP_CMD_SEND from plugins.platforms.wecom.streaming import ( @@ -726,10 +726,10 @@ async def _send_via(adapter, chat_id, message, *, live: bool): try: result = await adapter.send(chat_id, message) except Exception as e: - return {"error": f"WeCom live adapter send failed: {e}" if live else f"WeCom send failed: {e}"} + return send_error(f"WeCom live adapter send failed: {e}" if live else f"WeCom send failed: {e}") if result.success: return {"success": True, "platform": "wecom", "chat_id": chat_id, "message_id": result.message_id} - return {"error": f"WeCom send failed: {result.error}"} + return send_error(f"WeCom send failed: {result.error}") async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False): @@ -745,17 +745,17 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f if adapter is not None: return await _send_via(adapter, chat_id, message, live=True) if not check_wecom_requirements(): - return {"error": "WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET."} + return send_error("WeCom requirements not met. Need aiohttp + WECOM_BOT_ID/SECRET.") try: adapter = WeComAdapter(pconfig) if not await adapter.connect(): - return {"error": f"WeCom: failed to connect - {getattr(adapter, 'fatal_error_message', None) or 'unknown error'}"} + return send_error(f"WeCom: failed to connect - {getattr(adapter, 'fatal_error_message', None) or 'unknown error'}") try: return await _send_via(adapter, chat_id, message, live=False) finally: await adapter.disconnect() except Exception as e: - return {"error": f"WeCom send failed: {e}"} + return send_error(f"WeCom send failed: {e}") _MANUAL_SETUP_STEPS = ( diff --git a/plugins/platforms/whatsapp/adapter.py b/plugins/platforms/whatsapp/adapter.py index 1a918b5a0a..479b5660d7 100644 --- a/plugins/platforms/whatsapp/adapter.py +++ b/plugins/platforms/whatsapp/adapter.py @@ -14,7 +14,7 @@ from functools import wraps from pathlib import Path from typing import Dict, Optional, Any -from gateway.platforms._shared import get_scoped_secret, yaml_env_setter +from gateway.platforms._shared import get_scoped_secret, send_error, yaml_env_setter from hermes_cli._subprocess_compat import windows_detach_popen_kwargs from hermes_constants import (find_node_executable, get_hermes_dir, with_hermes_node_path) @@ -872,7 +872,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f try: import aiohttp except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} + return send_error("aiohttp not installed. Run: pip install aiohttp") try: bridge_port = (getattr(pconfig, "extra", {}) or {}).get("bridge_port", 3000) normalized_chat_id = to_whatsapp_jid(chat_id) @@ -887,7 +887,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=total)) as resp: if resp.status == 200: return (await resp.json()).get("messageId"), None - return None, {} if error_label is None else {"error": f"WhatsApp {error_label} error ({resp.status}): {await resp.text()}"} + return None, {} if error_label is None else send_error(f"WhatsApp {error_label} error ({resp.status}): {await resp.text()}") # 1) Text first (skipped when media-only or when the text rides as the caption). if (message or "").strip() and not media_caption: last_message_id, err = await _post("send", {"chatId": normalized_chat_id, "message": message}, 30, "bridge") @@ -902,7 +902,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f await _post("send", {"chatId": normalized_chat_id, "message": media_caption}, 30) except Exception: logger.warning("WhatsApp caption-fallback send failed for missing media") - return {"error": f"WhatsApp media file not found: {media_path}"} + return send_error(f"WhatsApp media file not found: {media_path}") media_type = _bridge_media_type(media_path, is_voice, force_document) payload: Dict[str, Any] = {"chatId": normalized_chat_id, "filePath": media_path, "mediaType": media_type} payload.update({k: v for k, v in (("fileName", os.path.basename(media_path) if media_type == "document" else None), ("caption", media_caption)) if v}) @@ -912,7 +912,7 @@ async def _standalone_send(pconfig, chat_id, message, *, thread_id=None, media_f last_message_id = mid or last_message_id return {"success": True, "platform": "whatsapp", "chat_id": normalized_chat_id, "message_id": last_message_id} except Exception as e: - return {"error": f"WhatsApp send failed: {e}"} + return send_error(f"WhatsApp send failed: {e}") def interactive_setup() -> None: diff --git a/tests/gateway/test_standalone_send_redaction.py b/tests/gateway/test_standalone_send_redaction.py new file mode 100644 index 0000000000..0bee12af33 --- /dev/null +++ b/tests/gateway/test_standalone_send_redaction.py @@ -0,0 +1,73 @@ +"""Invariant: a standalone sender's failure envelope never carries vendor secrets to the model. + +Every ``plugins/platforms/*/adapter.py::_standalone_send`` used to build ``{"error": f"... {e}"}`` +by hand; a token or signed URL inside an httpx/aiohttp exception went straight into the tool +result. They now share ``gateway.platforms._shared.send_error`` (the ``send_message`` redactor). +This drives two real senders end-to-end with a transport that raises a secret-bearing error. +""" + +import pytest + +import agent.redact as _redact +from gateway.config import PlatformConfig +from gateway.platforms._shared import send_error +from tests.gateway._plugin_adapter_loader import load_plugin_adapter + +_FAKE_TOKEN = "hermes-test-bearer-credential-ABCDEFGHIJKLMNOPQRSTUVWX" +_FAKE_URL_SECRET = "https://hooks.example/send?access_token=sk_live_ABCDEF0123456789" + + +@pytest.fixture(autouse=True) +def _redaction_on(monkeypatch): + # The switch is snapshotted at import; a developer shell with HERMES_REDACT_SECRETS=false must not + # turn this contract test into a no-op. + monkeypatch.setattr(_redact, "_REDACT_ENABLED", True) + + +def test_send_error_redacts_token_and_signed_url(): + out = send_error(f"upstream said 401 for Authorization: Bearer {_FAKE_TOKEN} at {_FAKE_URL_SECRET}") + assert set(out) == {"error"} + assert _FAKE_TOKEN not in out["error"] + assert "sk_live_ABCDEF0123456789" not in out["error"] + assert "401" in out["error"] # the diagnostic shape survives + + +@pytest.mark.asyncio +async def test_ntfy_standalone_failure_is_redacted(monkeypatch): + ntfy = load_plugin_adapter("ntfy") + monkeypatch.setenv("NTFY_TOPIC", "hermes-test") + + class _Boom: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + raise RuntimeError(f"connect failed: Authorization: Bearer {_FAKE_TOKEN} {_FAKE_URL_SECRET}") + + monkeypatch.setattr(ntfy, "HTTPX_AVAILABLE", True) + monkeypatch.setattr(ntfy.httpx, "AsyncClient", _Boom) + result = await ntfy._standalone_send(PlatformConfig(enabled=True, extra={}), "hermes-test", "hi") + assert "error" in result + assert _FAKE_TOKEN not in result["error"] and "sk_live_ABCDEF0123456789" not in result["error"] + assert "connect failed" in result["error"] + + +@pytest.mark.asyncio +async def test_irc_standalone_failure_is_redacted(monkeypatch): + irc = load_plugin_adapter("irc") + + async def _boom(*a, **k): + raise OSError(f"refused; proxy Authorization: Basic {_FAKE_TOKEN}") + + monkeypatch.setattr(irc.asyncio, "open_connection", _boom) + result = await irc._standalone_send( + PlatformConfig(enabled=True, extra={"server": "irc.example", "channel": "#x", "nickname": "h"}), "#x", "hi") + assert "error" in result + assert _FAKE_TOKEN not in result["error"] + assert "refused" in result["error"]