fix(media): route attachments via a fail-closed per-adapter MEDIA_KINDS capability - #36817
fix(media): route attachments via a fail-closed per-adapter MEDIA_KINDS capability#36817banditburai wants to merge 12 commits into
Conversation
…xplicit error when all media dropped
…tch-method backing
The unified media branch dispatches MediaKind.IMAGE via send_image_file
(_dispatch_media_one), but EmailAdapter declared {IMAGE, DOCUMENT} while
overriding only send_image/send_multiple_images — so a single image to
email inherited the base stub and leaked the local path as body text
(🖼️ Image: /path), the exact leak this epic closes. The reply/kanban
paths were unaffected because they batch via send_multiple_images, which
email does override.
- Add EmailAdapter.send_image_file delegating to _send_email_with_attachment
(the same helper send_document uses), so IMAGE and DOCUMENT deliver
symmetrically and honor the SendResult contract.
- Harden the MEDIA_KINDS pin: assert every declared kind overrides the
method its dispatch site actually calls (IMAGE→send_image_file, etc.),
making the descriptor's documented "must be backed by a real override"
invariant executable so the next adapter can't silently re-open the leak.
Closes NousResearch#18422, NousResearch#23760
…very warning merge Two edge cases in the unified capability branch that lacked explicit coverage: - media attaches only to the final text chunk (never glued to an earlier one) - a partially-deliverable set returns success with the delivered message_id while still surfacing the skip warning for the undeliverable kind
mxnstrexgl
left a comment
There was a problem hiding this comment.
🤖 Automated PR Review
Security Scan
- ✓ No hardcoded secrets, injection sinks, unsafe deserialization, or dependency red flags found by this automated scan.
Code Quality
- ✓ No blocking code-quality issues found by this automated scan.
Summary
Status: APPROVE — security findings: 0, quality suggestions: 0.
Automated review; raw diff content intentionally omitted.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the thorough routing analysis and capability/backing tests. The native cross-channel delivery gap is still present: current tools/send_message_tool.py:1043-1104 warns that attachments were omitted, and #17261 independently reports that behavior.
Problems
- The kanban target moved after this PR:
_deliver_kanban_artifactsis nowgateway/kanban_watchers.py:635following1c68f6f81f, so thegateway/run.pyhunk and its test need porting. tools/send_message_tool.py:540constructs metadata with onlythread_id. Current main preserves NTFYpublish_topicmetadata in the analogous live-adapter path attools/send_message_tool.py:720-725; retain that behavior when salvaging.- The path-leak fallback itself is already fixed by
cb9d18c759ingateway/platforms/base.py:3364-3489; current work should concentrate on native attachment routing.
Suggested changes
- Port the capability gate to the current kanban mixin and current plugin adapter layout.
- Share or preserve the NTFY metadata construction and cover it with a regression test.
Automated hermes-sweeper review.
| if result.success: | ||
| return {"success": True, "message_id": result.message_id} | ||
| return {"error": f"Adapter send failed: {result.error}"} | ||
| metadata = {"thread_id": thread_id} if thread_id else None |
There was a problem hiding this comment.
When salvaging onto current main, preserve NTFY's publish_topic=chat_id metadata too. The current live-adapter path builds it at tools/send_message_tool.py:720-725; keeping only thread_id here would make NTFY publish to its default topic rather than the requested target.
Adds a fail-closed per-adapter
MEDIA_KINDScapability descriptor onBasePlatformAdapter, consulted at the three in-scope send-pipeline dispatch sites (thesend_messagetool path, the agent reply path, and the kanban notifier), so a platform that can't natively deliver a given media kind warns-and-skips instead of leaking a local file path as chat text.12 commits · 33 files · +798/−30 · 25 adapters declare capability (24 × +2 lines: import + declaration; email also adds a substantive override); logic concentrated in base.py / run.py / send_message_tool.py / email.pyProblem
Base
send_*methods are text-emitters, not deliverers — any media kind that reaches a stub is posted as📎 File: / 🖼️ Image: / 🎬 Video: / 🔊 Audio:+ the local filesystem path. The send pipeline had no capability negotiation, so:send_messagetool path covered media for only 7 hand-tuned platform branches; every other platform fell through to the text-only path → path leaked or media silently dropped (tools/send_message_tool.py::_send_to_platform)._send_via_adapterforwardedchunkbut droppedmedia_filesentirely — qqbot attachments broken ([Bug]: _send_via_adapter ignores media_files parameter — file attachments broken for qqbot #23760).gateway/run.py::_deliver_media_from_response) and kanban notifier (_deliver_kanban_artifacts) dispatchedsend_image_file/send_voice/send_video/send_documentunconditionally; any adapter lacking that override inherited the leaking base stub.Three distinct dispatch sites, one defect class: the dispatcher never asks the adapter what it can actually deliver. (Two further sibling sites — the background-task completion path and the auto-TTS voice reply — share this defect class but are out of scope for this PR; see Out of scope.)
Approach / Design
MediaKindenum (IMAGE/VIDEO/VOICE/DOCUMENT) +MEDIA_KINDS: frozenset[MediaKind]onBasePlatformAdapter, fail-closed defaultfrozenset()(an adapter advertises nothing until it declares). Hand-declared per adapter; pinned and backing-asserted bytests/gateway/test_media_kinds.py.classify_media_kindmirrors the reply-path routing exactly:force_document/[[as_document]]wins → DOCUMENT, image/video by extension, audio viashould_send_media_as_audio, unknown → DOCUMENT. One classifier, identical decisions at all three sites._resolve_live_adapter→ per-fileclassify_media_kind→_dispatch_media_one); reply path and kanban notifier each gate every kind withif kind not in adapter.MEDIA_KINDS: warn + skip.media_files+ a live adapter + non-emptyMEDIA_KINDSare all present. Hand-tuned quirks are preserved, generic coverage is added, and the text fallback stays reachable for non-media sends.Design decisions
send_*are overridden — structural ≠ semantic: dingtalk/simplex override media methods but deliberately refuse/emit-text, so they declare EMPTY. Auto-derivation would mis-mark them capable and re-open the leak.send_image_filedelegates to_send_email_with_attachment(the same helpersend_documentuses) so IMAGE and DOCUMENT stay symmetric and honor theSendResultcontract.Adapter capability matrix
Fail-closed default = none; the declared set below is pinned by
tests/gateway/test_media_kinds.py.EMPTY tier = structurally text-only or deliberately refuses (dingtalk/simplex override but emit-text). The 7 hand-tuned branches (telegram/weixin/discord/matrix/signal/yuanbao/feishu) and the out-of-process standalone path are left as-is; the matrix governs the unified tool branch + reply + kanban gates.
Linkage
media_filesto qqbot's adapter, so attachments deliver instead of being dropped.tts_tool.pythat this PR doesn't touch.mainbycdde0c841; referenced here for cluster context.Out of scope
This PR is the routing/capability layer only. The items below share the same defect class or issue but are not addressed here; all are pre-existing, not regressions:
gateway/run.py::_run_background_task(ungatedsend_document)gateway/run.py::_send_voice_reply(hasattr-gated only)MEDIA_KINDSdispatch pathtools/tts_tool.py(want_opusexcludes feishu); that file is untouched hereTesting
Coverage:
MEDIA_KINDSmatches its pin;MediaKindis exactly the four members; base default is empty;PlatformEntryhas nomedia_kindsfield (the descriptor is adapter-only, leaving the standalone path uncoupled);classify_media_kindroutes png→IMAGE / mp4→VIDEO / pdf→DOCUMENT / mp3→VOICE / ogg(is_voice)→VOICE / force_document→DOCUMENT.force_document→send_document·thread_idforwarded · out-of-process (no live adapter)→text fallback + warning · adapter exception→error-dict · media-only all-dropped→explicit non-empty error · media attaches only to the last chunk · partial delivery→success + skip-warning merge._resolve_live_adapteris never called for those branches.Regression & blast radius
PlatformEntry; pinned)._send_via_adapterrefactor (_resolve_live_adapterextraction): behavior-identical.MEDIA_KINDSadapters — they now skip-and-warn instead of leaking.MEDIA_KINDS, no logic); email is the exception (+35 — the newsend_image_fileoverride). Behavioral change is concentrated inbase.py(+35/−0),email.py(+35/−0),run.py(+41/−7),send_message_tool.py(+82/−22).