{"repo":"NousResearch/hermes-agent","public_id":"6829","display_label":"Slack image cache receiving HTML instead of images","summary":"A cluster of bug reports and fixes addressing the problem of Slack file downloads being cached as HTML sign-in pages rather than actual image data, which breaks downstream vision processing. The members cover detection, validation, and rejection of invalid HTML payloads before they enter the image cache.","subsystem":null,"counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7125},"resolution":{"state":"resolved","by":"pr-7125,pr-16206"},"freshness":{"state":"verified","last_verified_at":"2026-04-26T19:47:43+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3028571428571428},"fix_edges":[{"issue_number":6829,"pr_number":7125,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":7015,"pr_number":7125,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":7015,"pr_number":16206,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":6829,"title":"[Bug]: Slack image attachments can be cached as HTML sign-in pages, causing downstream vision failures","url":null,"state":"closed"},{"kind":"issue","number":7015,"title":"[Bug]: Repro: Slack file downloads still land as HTML sign-in pages in image_cache and break vision","url":null,"state":"closed"},{"kind":"pr","number":6971,"title":"fix(gateway): validate Slack image downloads before caching","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7125","number":7125}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7002,"title":"fix(slack): validate downloaded media before caching as image","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7125","number":7125}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":7017,"title":"fix(slack): reject HTML file payloads before caching media","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7125","number":7125}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":7125,"title":"fix(gateway): validate image downloads before caching (cross-platform)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/email.py","gateway/platforms/slack.py","gateway/platforms/wecom.py","tests/gateway/test_media_download_retry.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -216,6 +216,23 @@ def get_image_cache_dir() -> Path:\n     return IMAGE_CACHE_DIR\n \n \n+def _looks_like_image(data: bytes) -> bool:\n+    \"\"\"Return True if *data* starts with a known image magic-byte sequence.\"\"\"\n+    if len(data) < 4:\n+        return False\n+    if data[:8] == b\"\\x89PNG\\r\\n\\x1a\\n\":\n+        return True\n+    if data[:3] == b\"\\xff\\xd8\\xff\":\n+        return True\n+    if data[:6] in (b\"GIF87a\", b\"GIF89a\"):\n+        return True\n+    if data[:2] == b\"BM\":\n+        return True\n+    if data[:4] == b\"RIFF\" and len(data) >= 12 and data[8:12] == b\"WEBP\":\n+        return True\n+    return False\n+\n+\n def cache_image_from_bytes(data: bytes, ext: str = \".jpg\") -> str:\n     \"\"\"\n     Save raw image bytes to the cache and return the absolute file path.","@@ -226,7 +243,17 @@ def cache_image_from_bytes(data: bytes, ext: str = \".jpg\") -> str:\n \n     Returns:\n         Absolute path to the cached image file as a string.\n+\n+    Raises:\n+        ValueError: If *data* does not look like a valid image (e.g. an HTML\n+            error page returned by the upstream server).\n     \"\"\"\n+    if not _looks_like_image(data):\n+        snippet = data[:80].decode(\"utf-8\", errors=\"replace\")\n+        raise ValueError(\n+            f\"Refusing to cache non-image data as {ext} \"\n+            f\"(starts with: {snippet!r})\"\n+        )\n     cache_dir = get_image_cache_dir()\n     filename = f\"img_{uuid.uuid4().hex[:12]}{ext}\"\n     filepath = cache_dir / filename"]},{"file":"gateway/platforms/email.py","hunks":["@@ -195,7 +195,11 @@ def _extract_attachments(\n \n         ext = Path(filename).suffix.lower()\n         if ext in _IMAGE_EXTS:\n-            cached_path = cache_image_from_bytes(payload, ext)\n+            try:\n+                cached_path = cache_image_from_bytes(payload, ext)\n+            except ValueError:\n+                logger.debug(\"Skipping non-image attachment %s (invalid magic bytes)\", filename)\n+                continue\n             attachments.append({\n                 \"path\": cached_path,\n                 \"filename\": filename,"]},{"file":"gateway/platforms/slack.py","hunks":["@@ -1596,6 +1596,18 @@ async def _download_slack_file(self, url: str, ext: str, audio: bool = False, te\n                     )\n                     response.raise_for_status()\n \n+                    # Slack may return an HTML sign-in/redirect page\n+                    # instead of actual media bytes (e.g. expired token,\n+                    # restricted file access).  Detect this early so we\n+                    # don't cache bogus data and confuse downstream tools.\n+                    ct = response.headers.get(\"content-type\", \"\")\n+                    if \"text/html\" in ct:\n+                        raise ValueError(\n+                            \"Slack returned HTML instead of media \"\n+                            f\"(content-type: {ct}); \"\n+                            \"check bot token scopes and file permissions\"\n+                        )\n+\n                     if audio:\n                         from gateway.platforms.base import cache_audio_from_bytes\n                         return cache_audio_from_bytes(response.content, ext)"]},{"file":"gateway/platforms/wecom.py","hunks":["@@ -696,7 +696,11 @@ async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple\n \n             if kind == \"image\":\n                 ext = self._detect_image_ext(raw)\n-                return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback=\"image/jpeg\")\n+                try:\n+                    return cache_image_from_bytes(raw, ext), self._mime_for_ext(ext, fallback=\"image/jpeg\")\n+                except ValueError as exc:\n+                    logger.warning(\"[%s] Rejected non-image bytes: %s\", self.name, exc)\n+                    return None\n \n             filename = str(media.get(\"filename\") or media.get(\"name\") or \"wecom_file\")\n             return cache_document_from_bytes(raw, filename), mimetypes.guess_type(filename)[0] or \"application/octet-stream\"","@@ -722,7 +726,11 @@ async def _cache_media(self, kind: str, media: Dict[str, Any]) -> Optional[Tuple\n         content_type = str(headers.get(\"content-type\") or \"\").split(\";\", 1)[0].strip() or \"application/octet-stream\"\n         if kind == \"image\":\n             ext = self._guess_extension(url, content_type, fallback=self._detect_image_ext(raw))\n-            return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback=\"image/jpeg\")\n+            try:\n+                return cache_image_from_bytes(raw, ext), content_type or self._mime_for_ext(ext, fallback=\"image/jpeg\")\n+            except ValueError as exc:\n+                logger.warning(\"[%s] Rejected non-image bytes from %s: %s\", self.name, url, exc)\n+                return None\n \n         filename = self._guess_filename(url, headers.get(\"content-disposition\"), content_type)\n         return cache_document_from_bytes(raw, filename), content_type"]},{"file":"tests/gateway/test_media_download_retry.py","hunks":["@@ -34,6 +34,45 @@ def _make_timeout_error() -> httpx.TimeoutException:\n     return httpx.TimeoutException(\"timed out\")\n \n \n+# ---------------------------------------------------------------------------\n+# cache_image_from_bytes (base.py)\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestCacheImageFromBytes:\n+    \"\"\"Tests for gateway.platforms.base.cache_image_from_bytes\"\"\"\n+\n+    def test_caches_valid_jpeg(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n+        from gateway.platforms.base import cache_image_from_bytes\n+        path = cache_image_from_bytes(b\"\\xff\\xd8\\xff fake jpeg data\", \".jpg\")\n+        assert path.endswith(\".jpg\")\n+\n+    def test_caches_valid_png(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n+        from gateway.platforms.base import cache_image_from_bytes\n+        path = cache_image_from_bytes(b\"\\x89PNG\\r\\n\\x1a\\n fake png data\", \".png\")\n+        assert path.endswith(\".png\")\n+\n+    def test_rejects_html_content(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n+        from gateway.platforms.base import cache_image_from_bytes\n+        with pytest.raises(ValueError, match=\"non-image data\"):\n+            cache_image_from_bytes(b\"<!DOCTYPE html><html><title>Slack</title></html>\", \".png\")\n+\n+    def test_rejects_empty_data(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n+        from gateway.platforms.base import cache_image_from_bytes\n+        with pytest.raises(ValueError, match=\"non-image data\"):\n+            cache_image_from_bytes(b\"\", \".jpg\")\n+\n+    def test_rejects_plain_text(self, tmp_path, monkeypatch):\n+        monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n+        from gateway.platforms.base import cache_image_from_bytes\n+        with pytest.raises(ValueError, match=\"non-image data\"):\n+            cache_image_from_bytes(b\"just some text, not an image\", \".jpg\")\n+\n+\n # ---------------------------------------------------------------------------\n # cache_image_from_url (base.py)\n # ---------------------------------------------------------------------------","@@ -71,7 +110,7 @@ def test_retries_on_timeout_then_succeeds(self, _mock_safe, tmp_path, monkeypatc\n         monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n \n         fake_response = MagicMock()\n-        fake_response.content = b\"image data\"\n+        fake_response.content = b\"\\xff\\xd8\\xff image data\"\n         fake_response.raise_for_status = MagicMock()\n \n         mock_client = AsyncMock()","@@ -101,7 +140,7 @@ def test_retries_on_429_then_succeeds(self, _mock_safe, tmp_path, monkeypatch):\n         monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n \n         ok_response = MagicMock()\n-        ok_response.content = b\"image data\"\n+        ok_response.content = b\"\\xff\\xd8\\xff image data\"\n         ok_response.raise_for_status = MagicMock()\n \n         mock_client = AsyncMock()","@@ -395,8 +434,9 @@ def test_success_on_first_attempt(self, tmp_path, monkeypatch):\n         adapter = _make_slack_adapter()\n \n         fake_response = MagicMock()\n-        fake_response.content = b\"fake image bytes\"\n+        fake_response.content = b\"\\x89PNG\\r\\n\\x1a\\n fake png\"\n         fake_response.raise_for_status = MagicMock()\n+        fake_response.headers = {\"content-type\": \"image/png\"}\n \n         mock_client = AsyncMock()\n         mock_client.get = AsyncMock(return_value=fake_response)","@@ -413,14 +453,44 @@ async def run():\n         assert path.endswith(\".jpg\")\n         mock_client.get.assert_called_once()\n \n+    def test_rejects_html_response(self, tmp_path, monkeypatch):\n+        \"\"\"An HTML sign-in page from Slack is rejected, not cached as image.\"\"\"\n+        monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n+        adapter = _make_slack_adapter()\n+\n+        fake_response = MagicMock()\n+        fake_response.content = b\"<!DOCTYPE html><html><title>Slack</title></html>\"\n+        fake_response.raise_for_status = MagicMock()\n+        fake_response.headers = {\"content-type\": \"text/html; charset=utf-8\"}\n+\n+        mock_client = AsyncMock()\n+        mock_client.get = AsyncMock(return_value=fake_response)\n+        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n+        mock_client.__aexit__ = AsyncMock(return_value=False)\n+\n+        async def run():\n+            with patch(\"httpx.AsyncClient\", return_value=mock_client):\n+                await adapter._download_slack_file(\n+                    \"https://files.slack.com/img.jpg\", ext=\".jpg\"\n+                )\n+\n+        with pytest.raises(ValueError, match=\"HTML instead of media\"):\n+            asyncio.run(run())\n+\n+        # Verify nothing was cached\n+        img_dir = tmp_path / \"img\"\n+        if img_dir.exists():\n+            assert list(img_dir.iterdir()) == []\n+\n     def test_retries_on_timeout_then_succeeds(self, tmp_path, monkeypatch):\n         \"\"\"Timeout on first attempt triggers retry; success on second.\"\"\"\n         monkeypatch.setattr(\"gateway.platforms.base.IMAGE_CACHE_DIR\", tmp_path / \"img\")\n         adapter = _make_slack_adapter()\n \n         fake_response = MagicMock()\n-        fake_response.content = b\"image bytes\"\n+        fake_response.content = b\"\\x89PNG\\r\\n\\x1a\\n image bytes\"\n         fake_response.raise_for_status = MagicMock()\n+        fake_response.headers = {\"content-type\": \"image/png\"}\n \n         mock_client = AsyncMock()\n         mock_client.get = AsyncMock(\n"]}]},{"kind":"pr","number":16206,"title":"fix(slack): surface attachment access diagnostics","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/slack.py","tests/gateway/test_media_download_retry.py","tests/gateway/test_slack.py","website/docs/user-guide/messaging/slack.md"],"fix_hunks":[{"file":"gateway/platforms/slack.py","hunks":["@@ -15,7 +15,7 @@\n import re\n import time\n from dataclasses import dataclass, field\n-from typing import Dict, Optional, Any, Tuple\n+from typing import Dict, Optional, Any, Tuple, List\n \n try:\n     from slack_bolt.async_app import AsyncApp","@@ -121,6 +121,63 @@ def __init__(self, config: PlatformConfig):\n         # clear them (chat_id → thread_ts).\n         self._active_status_threads: Dict[str, str] = {}\n \n+    def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:\n+        \"\"\"Convert Slack API auth/permission failures into actionable user-facing text.\"\"\"\n+        if response is None or not hasattr(response, \"get\"):\n+            return None\n+\n+        error = str(response.get(\"error\", \"\") or \"\").strip()\n+        if not error:\n+            return None\n+\n+        file_label = str((file_obj or {}).get(\"name\") or (file_obj or {}).get(\"id\") or \"this attachment\")\n+        needed = str(response.get(\"needed\", \"\") or \"\").strip()\n+        provided = str(response.get(\"provided\", \"\") or \"\").strip()\n+        reinstall_hint = \" Update the Slack app scopes/settings and reinstall the app to the workspace.\"\n+        provided_hint = f\" Current bot scopes: {provided}.\" if provided else \"\"\n+\n+        if error == \"missing_scope\":\n+            needed_hint = f\"Missing scope: {needed}.\" if needed else \"Missing required Slack scope.\"\n+            return f\"Slack attachment access failed for {file_label}. {needed_hint}{provided_hint}{reinstall_hint}\"\n+        if error in {\"not_authed\", \"invalid_auth\", \"account_inactive\", \"token_revoked\"}:\n+            return f\"Slack attachment access failed for {file_label} because the bot token is not authorized ({error}). Refresh the token/reinstall the app.\"\n+        if error in {\"file_not_found\", \"file_deleted\"}:\n+            return f\"Slack attachment {file_label} is no longer available ({error}).\"\n+        if error in {\"access_denied\", \"file_access_denied\", \"no_permission\", \"not_allowed_token_type\", \"restricted_action\"}:\n+            return f\"Slack attachment access failed for {file_label} because the bot does not have permission ({error}). Check workspace permissions/scopes and reinstall if needed.\"\n+        return None\n+\n+    def _describe_slack_download_failure(self, exc: Exception, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]:\n+        \"\"\"Translate Slack download exceptions into user-facing attachment diagnostics.\"\"\"\n+        file_label = str((file_obj or {}).get(\"name\") or (file_obj or {}).get(\"id\") or \"this attachment\")\n+\n+        response = getattr(exc, \"response\", None)\n+        api_detail = self._describe_slack_api_error(response, file_obj=file_obj)\n+        if api_detail:\n+            return api_detail\n+\n+        try:\n+            import httpx\n+        except Exception:  # pragma: no cover\n+            httpx = None\n+\n+        if httpx is not None and isinstance(exc, httpx.HTTPStatusError):\n+            status = exc.response.status_code\n+            if status == 401:\n+                return f\"Slack attachment access failed for {file_label} with HTTP 401. The bot token is not authorized for this file.\"\n+            if status == 403:\n+                return f\"Slack attachment access failed for {file_label} with HTTP 403. The bot likely lacks permission or scope to read this file.\"\n+            if status == 404:\n+                return f\"Slack attachment {file_label} returned HTTP 404 and is no longer reachable.\"\n+\n+        message = str(exc)\n+        if \"Slack returned HTML instead of media\" in message or \"non-image data\" in message:\n+            return (\n+                f\"Slack attachment access failed for {file_label}: Slack returned an HTML/login or non-media response. \"\n+                \"This usually means a scope, auth, or file-permission problem.\"\n+            )\n+        return None\n+\n     async def connect(self) -> bool:\n         \"\"\"Connect to Slack via Socket Mode.\"\"\"\n         if not SLACK_AVAILABLE:","@@ -1193,6 +1250,7 @@ async def _handle_slack_message(self, event: dict) -> None:\n         # Handle file attachments\n         media_urls = []\n         media_types = []\n+        attachment_notices: List[str] = []\n         files = event.get(\"files\", [])\n         for f in files:\n             # Slack Connect channels return stub file objects with","@@ -1209,13 +1267,24 @@ async def _handle_slack_message(self, event: dict) -> None:\n                     if info_resp.get(\"ok\"):\n                         f = info_resp[\"file\"]\n                     else:\n-                        logger.warning(\n-                            \"[Slack] files.info failed for %s: %s\",\n-                            file_id, info_resp.get(\"error\"),\n-                        )\n+                        detail = self._describe_slack_api_error(info_resp, file_obj=f)\n+                        if detail:\n+                            attachment_notices.append(detail)\n+                            logger.warning(\"[Slack] %s\", detail)\n+                        else:\n+                            logger.warning(\n+                                \"[Slack] files.info failed for %s: %s\",\n+                                file_id, info_resp.get(\"error\"),\n+                            )\n                         continue\n                 except Exception as e:\n-                    logger.warning(\"[Slack] files.info error for %s: %s\", file_id, e, exc_info=True)\n+                    response = getattr(e, \"response\", None)\n+                    detail = self._describe_slack_api_error(response, file_obj=f)\n+                    if detail:\n+                        attachment_notices.append(detail)\n+                        logger.warning(\"[Slack] %s\", detail)\n+                    else:\n+                        logger.warning(\"[Slack] files.info error for %s: %s\", file_id, e, exc_info=True)\n                     continue\n \n             mimetype = f.get(\"mimetype\", \"unknown\")","@@ -1231,7 +1300,12 @@ async def _handle_slack_message(self, event: dict) -> None:\n                     media_types.append(mimetype)\n                     msg_type = MessageType.PHOTO\n                 except Exception as e:  # pragma: no cover - defensive logging\n-                    logger.warning(\"[Slack] Failed to cache image from %s: %s\", url, e, exc_info=True)\n+                    detail = self._describe_slack_download_failure(e, file_obj=f)\n+                    if detail:\n+                        attachment_notices.append(detail)\n+                        logger.warning(\"[Slack] %s\", detail)\n+                    else:\n+                        logger.warning(\"[Slack] Failed to cache image from %s: %s\", url, e, exc_info=True)\n             elif mimetype.startswith(\"audio/\") and url:\n                 try:\n                     ext = \".\" + mimetype.split(\"/\")[-1].split(\";\")[0]","@@ -1242,7 +1316,12 @@ async def _handle_slack_message(self, event: dict) -> None:\n                     media_types.append(mimetype)\n                     msg_type = MessageType.VOICE\n                 except Exception as e:  # pragma: no cover - defensive logging\n-                    logger.warning(\"[Slack] Failed to cache audio from %s: %s\", url, e, exc_info=True)\n+                    detail = self._describe_slack_download_failure(e, file_obj=f)\n+                    if detail:\n+                        attachment_notices.append(detail)\n+                        logger.warning(\"[Slack] %s\", detail)\n+                    else:\n+                        logger.warning(\"[Slack] Failed to cache audio from %s: %s\", url, e, exc_info=True)\n             elif url:\n                 # Try to handle as a document attachment\n                 try:","@@ -1294,7 +1373,16 @@ async def _handle_slack_message(self, event: dict) -> None:\n                             pass  # Binary content, skip injection\n \n                 except Exception as e:  # pragma: no cover - defensive logging\n-                    logger.warning(\"[Slack] Failed to cache document from %s: %s\", url, e, exc_info=True)\n+                    detail = self._describe_slack_download_failure(e, file_obj=f)\n+                    if detail:\n+                        attachment_notices.append(detail)\n+                        logger.warning(\"[Slack] %s\", detail)\n+                    else:\n+                        logger.warning(\"[Slack] Failed to cache document from %s: %s\", url, e, exc_info=True)\n+\n+        if attachment_notices:\n+            notice_block = \"[Slack attachment notice]\\n\" + \"\\n\".join(f\"- {n}\" for n in attachment_notices)\n+            text = f\"{notice_block}\\n\\n{text}\" if text else notice_block\n \n         # Resolve user display name (cached after first lookup)\n         user_name = await self._resolve_user_name(user_id, chat_id=channel_id)"]},{"file":"tests/gateway/test_media_download_retry.py","hunks":["@@ -540,7 +540,7 @@ def _ensure_slack_mock():\n \n \n def _make_slack_adapter():\n-    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    config = PlatformConfig(enabled=True, token=\"***\")\n     adapter = SlackAdapter(config)\n     adapter._app = MagicMock()\n     adapter._app.client = AsyncMock()","@@ -549,6 +549,39 @@ def _make_slack_adapter():\n     return adapter\n \n \n+# ---------------------------------------------------------------------------\n+# SlackAdapter diagnostics helpers\n+# ---------------------------------------------------------------------------\n+\n+class TestSlackAttachmentDiagnostics:\n+    def test_missing_scope_error_returns_actionable_notice(self):\n+        \"\"\"_describe_slack_api_error translates a missing_scope response into\n+        a user-facing notice mentioning the needed scope and the reinstall\n+        step. This is the helper used by every files.info call site (Slack\n+        Connect stubs + post-download failures) to surface scope problems\n+        without making an extra probe call per attachment.\n+        \"\"\"\n+        adapter = _make_slack_adapter()\n+\n+        response = {\n+            \"error\": \"missing_scope\",\n+            \"needed\": \"files:read\",\n+            \"provided\": \"chat:write,files:write\",\n+        }\n+        detail = adapter._describe_slack_api_error(response, file_obj={\"id\": \"F123\", \"name\": \"photo.jpg\"})\n+        assert detail is not None\n+        assert \"files:read\" in detail\n+        assert \"reinstall\" in detail.lower()\n+        assert \"chat:write,files:write\" in detail\n+\n+    def test_download_failure_403_returns_permission_notice(self):\n+        adapter = _make_slack_adapter()\n+        exc = _make_http_status_error(403)\n+        detail = adapter._describe_slack_download_failure(exc, file_obj={\"name\": \"report.pdf\"})\n+        assert \"403\" in detail\n+        assert \"permission or scope\" in detail\n+\n+\n # ---------------------------------------------------------------------------\n # SlackAdapter._download_slack_file\n # ---------------------------------------------------------------------------"]},{"file":"tests/gateway/test_slack.py","hunks":["@@ -511,6 +511,35 @@ async def test_image_still_handled(self, adapter):\n         msg_event = adapter.handle_message.call_args[0][0]\n         assert msg_event.message_type == MessageType.PHOTO\n \n+    @pytest.mark.asyncio\n+    async def test_download_failure_is_surfaced_in_message_text(self, adapter):\n+        \"\"\"Attachment download failures (401/403/HTML-body/etc.) should be\n+        translated into a user-facing `[Slack attachment notice]` block so\n+        the agent can tell the user what to fix (e.g. missing files:read\n+        scope). No proactive files.info probe is made — the diagnostic\n+        runs only when the download actually fails.\n+        \"\"\"\n+        import httpx\n+        req = httpx.Request(\"GET\", \"https://files.slack.com/photo.jpg\")\n+        resp = httpx.Response(403, request=req)\n+\n+        with patch.object(adapter, \"_download_slack_file\", new_callable=AsyncMock) as dl:\n+            dl.side_effect = httpx.HTTPStatusError(\"403\", request=req, response=resp)\n+            event = self._make_event(text=\"what's in this?\", files=[{\n+                \"id\": \"F123\",\n+                \"mimetype\": \"image/jpeg\",\n+                \"name\": \"photo.jpg\",\n+                \"url_private_download\": \"https://files.slack.com/photo.jpg\",\n+                \"size\": 1024,\n+            }])\n+            await adapter._handle_slack_message(event)\n+\n+        msg_event = adapter.handle_message.call_args[0][0]\n+        assert msg_event.message_type == MessageType.TEXT\n+        assert \"[Slack attachment notice]\" in msg_event.text\n+        assert \"403\" in msg_event.text\n+        assert \"what's in this?\" in msg_event.text\n+\n \n # ---------------------------------------------------------------------------\n # TestMessageRouting"]},{"file":"website/docs/user-guide/messaging/slack.md","hunks":["@@ -82,7 +82,8 @@ Navigate to **Features → OAuth & Permissions** in the sidebar. Scroll to **Sco\n \n :::caution Missing scopes = missing features\n Without `channels:history` and `groups:history`, the bot **will not receive messages in channels** —\n-it will only work in DMs. These are the most commonly missed scopes.\n+it will only work in DMs. Without `files:read`, Hermes can chat but **cannot reliably read user-uploaded attachments**.\n+These are the most commonly missed scopes.\n :::\n \n **Optional scopes:**","@@ -520,7 +521,8 @@ Keys are Slack channel IDs (find them via channel details → \"About\" → scroll\n | \"Sending messages to this app has been turned off\" in DMs | Enable the **Messages Tab** in App Home settings (see Step 5) |\n | \"not_authed\" or \"invalid_auth\" errors | Regenerate your Bot Token and App Token, update `.env` |\n | Bot responds but can't post in a channel | Invite the bot to the channel with `/invite @Hermes Agent` |\n-| \"missing_scope\" error | Add the required scope in OAuth & Permissions, then **reinstall** the app |\n+| Bot can chat but can't read uploaded images/files | Add `files:read`, then **reinstall** the app. Hermes now surfaces attachment access diagnostics in-chat when Slack returns scope/auth/permission failures. |\n+| `missing_scope` error | Add the required scope in OAuth & Permissions, then **reinstall** the app |\n | Socket disconnects frequently | Check your network; Bolt auto-reconnects but unstable connections cause lag |\n | Changed scopes/events but nothing changed | You **must reinstall** the app to your workspace after any scope or event subscription change |\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}