{"repo":"NousResearch/hermes-agent","public_id":"35270","display_label":"Gateway MEDIA tag Windows file path parsing fixes","summary":"Multiple bug reports and fixes addressing failures in gateway regex logic to correctly parse Windows-style absolute paths, including drive letters and backslashes, in MEDIA tags.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":35388,"merged_at":"2026-05-30T14:38:03+00:00"},"resolution":{"state":"resolved","by":"pr-35388"},"freshness":{"state":"verified","last_verified_at":"2026-05-30T14:38:03+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":28989,"pr_number":35388,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-30T14:38:03+00:00"},{"issue_number":34632,"pr_number":35388,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-05-30T14:38:03+00:00"},{"issue_number":35270,"pr_number":35388,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-30T14:38:03+00:00"}],"members":[{"kind":"issue","number":35270,"title":"[Bug]: Gateway extract_media and extract_local_files fail to parse Windows file paths","url":null,"state":"closed"},{"kind":"pr","number":35328,"title":"fix(gateway): support Windows drive letters and backslashes in file path regex (fixes #35270)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#35388","number":35388},{"kind":"pr","value":"#35161","number":35161}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":35388,"title":"fix(gateway): support Windows absolute paths in MEDIA tag regexes","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","tests/gateway/test_extract_local_files.py","tests/gateway/test_platform_base.py","tests/gateway/test_run_tool_media_re.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1191,10 +1191,12 @@ def _log_safe_path(path: str) -> str:\n # bare-path detector (extract_local_files) downstream rather than silently\n # deleted. Shared by the non-streaming dispatch path and the streaming\n # consumer so both behave identically.\n+# Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute),\n+# ``X:\\\\`` or ``X:/`` (Windows drive-letter absolute — #34632).\n MEDIA_TAG_CLEANUP_RE = re.compile(\n     r'''[`\"']?MEDIA:\\s*'''\n     r'''(?P<path>`[^`\\n]+`|\"[^\"\\n]+\"|'[^'\\n]+'|'''\n-    r'''(?:~/|/)\\S+(?:[^\\S\\n]+\\S+)*?\\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''\n+    r'''(?:~/|/|[A-Za-z]:[/\\\\])\\S+(?:[^\\S\\n]+\\S+)*?\\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''\n     r'''(?=[\\s`\"',;:)\\]}]|$)[`\"']?''',\n     re.IGNORECASE,\n )","@@ -2665,9 +2667,10 @@ def extract_local_files(content: str) -> Tuple[List[str], str]:\n \n         # (?<![/:\\w.]) prevents matching inside URLs (e.g. https://…/img.png)\n         #             and relative paths (./foo.png)\n-        # (?:~/|/)    anchors to absolute or home-relative paths\n+        # (?:~/|/)    anchors to absolute or home-relative Unix paths\n+        # (?:[A-Za-z]:[/\\\\]) anchors to Windows drive-letter paths (#34632)\n         path_re = re.compile(\n-            r'(?<![/:\\w.])(?:~/|/)(?:[\\w.\\-]+/)*[\\w.\\-]+\\.(?:' + ext_part + r')\\b',\n+            r'(?<![/:\\w.])(?:~/|/|[A-Za-z]:[/\\\\])(?:[\\w.\\-]+[/\\\\])*[\\w.\\-]+\\.(?:' + ext_part + r')\\b',\n             re.IGNORECASE,\n         )\n "]},{"file":"gateway/run.py","hunks":["@@ -17281,7 +17281,7 @@ def _clarify_callback_sync(question: str, choices) -> str:\n                     _hc = _hm.get(\"content\", \"\")\n                     if \"MEDIA:\" in _hc:\n                         _TOOL_MEDIA_RE = re.compile(\n-                            r'MEDIA:((?:/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n+                            r'MEDIA:((?:[A-Za-z]:[/\\\\]|/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n                             r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'\n                             r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'\n                             r'txt|csv|apk|ipa))',","@@ -17607,7 +17607,7 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                         content = msg.get(\"content\", \"\")\n                         if \"MEDIA:\" in content:\n                             _TOOL_MEDIA_RE = re.compile(\n-                                r'MEDIA:((?:/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n+                                r'MEDIA:((?:[A-Za-z]:[/\\\\]|/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n                                 r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'\n                                 r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'\n                                 r'txt|csv|apk|ipa))',"]},{"file":"tests/gateway/test_extract_local_files.py","hunks":["@@ -336,9 +336,35 @@ def test_path_with_spaces_not_matched(self):\n         paths, _ = _extract(\"File at /tmp/my file.png here\")\n         assert paths == []\n \n-    def test_windows_path_not_matched(self):\n-        \"\"\"Windows-style paths should not match.\"\"\"\n-        paths, _ = _extract(\"See C:\\\\Users\\\\test\\\\image.png\")\n+    @pytest.mark.parametrize(\n+        \"content,expected\",\n+        [\n+            # Backslash separators (native Windows style)\n+            (\"See C:\\\\Users\\\\test\\\\image.png here\", \"C:\\\\Users\\\\test\\\\image.png\"),\n+            # Forward slashes with drive letter (common in cross-platform code)\n+            (\"See C:/Users/test/image.png here\", \"C:/Users/test/image.png\"),\n+            # Non-C: drive\n+            (\"Video at D:/data/clip.mp4 ready\", \"D:/data/clip.mp4\"),\n+            # Lowercase drive letter\n+            (\"Path e:/audio/track.mp3 done\", \"e:/audio/track.mp3\"),\n+        ],\n+    )\n+    def test_windows_drive_letter_paths_matched(self, content, expected):\n+        \"\"\"Windows drive-letter paths (C:/..., C:\\\\...) must be detected (#34632).\n+\n+        Prior behavior anchored on (?:~/|/) only, which silently dropped\n+        Windows absolute paths so the agent's bare-path references were\n+        sent as text instead of native uploads.\n+        \"\"\"\n+        paths, cleaned = _extract(content)\n+        assert paths == [expected]\n+        assert expected not in cleaned\n+\n+    def test_relative_windows_path_not_matched(self):\n+        \"\"\"A bare Windows-style filename without a drive letter must still\n+        not match (e.g. ``foo\\\\bar.png`` is treated as relative, like its\n+        Unix sibling ``foo/bar.png``).\"\"\"\n+        paths, _ = _extract(\"File at foo\\\\bar.png here\")\n         assert paths == []\n \n     def test_relative_path_not_matched(self):"]},{"file":"tests/gateway/test_platform_base.py","hunks":["@@ -361,6 +361,45 @@ def test_both_directives_can_coexist(self):\n         assert \"[[audio_as_voice]]\" not in cleaned\n         assert \"[[as_document]]\" not in cleaned\n \n+    # Windows path support — regression coverage for #34632\n+\n+    def test_media_tag_windows_backslash_path(self):\n+        \"\"\"extract_media should recognise Windows backslash paths.\"\"\"\n+        media, cleaned = BasePlatformAdapter.extract_media(\n+            r\"MEDIA:C:\\Users\\kotsu\\file.pdf\"\n+        )\n+        assert len(media) == 1\n+        assert media[0][0].endswith(\"file.pdf\")\n+\n+    def test_media_tag_windows_forward_slash_path(self):\n+        \"\"\"extract_media should recognise Windows forward-slash paths.\"\"\"\n+        media, cleaned = BasePlatformAdapter.extract_media(\n+            \"MEDIA:C:/Users/kotsu/file.pdf\"\n+        )\n+        assert len(media) == 1\n+        assert media[0][0].endswith(\"file.pdf\")\n+\n+    def test_media_tag_windows_drive_root(self):\n+        \"\"\"extract_media should recognise a path at the drive root.\"\"\"\n+        media, cleaned = BasePlatformAdapter.extract_media(\n+            r\"MEDIA:D:\\report.md\"\n+        )\n+        assert len(media) == 1\n+        assert media[0][0].endswith(\"report.md\")\n+\n+    def test_media_tag_unix_paths_still_work(self):\n+        \"\"\"Unix absolute and tilde paths must still extract after Windows change.\"\"\"\n+        for content in [\"MEDIA:/tmp/audio.ogg\", r\"MEDIA:~/docs/notes.md\"]:\n+            media, _ = BasePlatformAdapter.extract_media(content)\n+            assert len(media) == 1, f\"Failed for: {content}\"\n+\n+    def test_relative_path_still_ignored(self):\n+        \"\"\"Relative Windows-style paths (no drive letter) must not match.\"\"\"\n+        media, _ = BasePlatformAdapter.extract_media(\n+            r\"MEDIA:Users\\kotsu\\file.pdf\"\n+        )\n+        assert media == []\n+\n \n class TestMediaExtensionAllowlistParity:\n     \"\"\"Regression coverage for issue #34517 — the MEDIA: extension black hole."]},{"file":"tests/gateway/test_run_tool_media_re.py","hunks":["@@ -0,0 +1,147 @@\n+r\"\"\"Tests for _TOOL_MEDIA_RE regex patterns in gateway/run.py.\n+\n+Issue #34632: The _TOOL_MEDIA_RE patterns in GatewayRunner used (?:/|~\\/) to\n+anchor paths, which only matched Unix-style absolute and home-relative paths.\n+Windows absolute paths (C:\\\\Users\\\\..., D:/...) were silently ignored, causing\n+MEDIA directive delivery to fail on Windows.\n+\n+Fix: Add [A-Za-z]:[/\\\\\\\\] as a third anchor alternative in both patterns.\n+\n+Two identical _TOOL_MEDIA_RE patterns exist in run.py:\n+1. History scanning (~L17223): collects already-seen media paths\n+2. Result scanning (~L17549): extracts new media tags from agent output\n+\n+This test file validates that both equivalent regex patterns correctly match\n+Windows paths while preserving existing Unix path matching behavior.\n+\"\"\"\n+\n+import re\n+\n+import pytest\n+\n+\n+# Reconstruct the exact _TOOL_MEDIA_RE pattern from gateway/run.py\n+# The pattern is built by concatenating raw string parts:\n+#   r'MEDIA:((?:[A-Za-z]:[/\\\\]|/|~\\/)\\S+\\.(?:png|...))'\n+_TOOL_MEDIA_RE = re.compile(\n+    r'MEDIA:((?:[A-Za-z]:[/\\\\]|/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n+    r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'\n+    r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'\n+    r'txt|csv|apk|ipa))',\n+    re.IGNORECASE,\n+)\n+\n+\n+# Reconstruct the pre-fix pattern (without Windows anchor) for regression proof\n+_TOOL_MEDIA_RE_PRE_FIX = re.compile(\n+    r'MEDIA:((?:/|~\\/)\\S+\\.(?:png|jpe?g|gif|webp|'\n+    r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'\n+    r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'\n+    r'txt|csv|apk|ipa))',\n+    re.IGNORECASE,\n+)\n+\n+\n+class TestToolMediaReWindowsPaths:\n+    \"\"\"Issue #34632: _TOOL_MEDIA_RE must match Windows absolute paths.\"\"\"\n+\n+    # ── Positive: Windows paths now match ──────────────────────────\n+\n+    @pytest.mark.parametrize(\"media_tag, expected_path\", [\n+        # Windows backslash paths\n+        (\"MEDIA:C:\\\\Users\\\\test\\\\image.png\", \"C:\\\\Users\\\\test\\\\image.png\"),\n+        (\"MEDIA:D:\\\\data\\\\report.pdf\", \"D:\\\\data\\\\report.pdf\"),\n+        (\"MEDIA:E:\\\\Photos\\\\vacation.jpg\", \"E:\\\\Photos\\\\vacation.jpg\"),\n+        # Windows forward-slash paths\n+        (\"MEDIA:C:/Users/test/image.png\", \"C:/Users/test/image.png\"),\n+        (\"MEDIA:D:/data/report.pdf\", \"D:/data/report.pdf\"),\n+        # Mixed separators\n+        (\"MEDIA:C:\\\\Users/test\\\\image.webp\", \"C:\\\\Users/test\\\\image.webp\"),\n+        # Various extensions\n+        (\"MEDIA:F:\\\\videos\\\\clip.mp4\", \"F:\\\\videos\\\\clip.mp4\"),\n+        (\"MEDIA:G:\\\\audio\\\\song.mp3\", \"G:\\\\audio\\\\song.mp3\"),\n+        (\"MEDIA:H:\\\\docs\\\\sheet.xlsx\", \"H:\\\\docs\\\\sheet.xlsx\"),\n+        (\"MEDIA:Z:\\\\archive\\\\backup.zip\", \"Z:\\\\archive\\\\backup.zip\"),\n+    ])\n+    def test_windows_paths_match(self, media_tag, expected_path):\n+        \"\"\"Windows absolute paths with drive letters are matched.\"\"\"\n+        match = _TOOL_MEDIA_RE.search(media_tag)\n+        assert match is not None, f\"Should match: {media_tag}\"\n+        assert match.group(1) == expected_path\n+\n+    # ── Positive: Unix paths still match ───────────────────────────\n+\n+    @pytest.mark.parametrize(\"media_tag, expected_path\", [\n+        (\"MEDIA:/tmp/output.png\", \"/tmp/output.png\"),\n+        (\"MEDIA:/var/log/report.pdf\", \"/var/log/report.pdf\"),\n+        (\"MEDIA:/home/user/docs/file.txt\", \"/home/user/docs/file.txt\"),\n+        # Home-relative\n+        (\"MEDIA:~/Downloads/image.jpg\", \"~/Downloads/image.jpg\"),\n+        (\"MEDIA:~/Documents/report.pdf\", \"~/Documents/report.pdf\"),\n+    ])\n+    def test_unix_paths_still_match(self, media_tag, expected_path):\n+        \"\"\"Unix-style absolute and home-relative paths still match.\"\"\"\n+        match = _TOOL_MEDIA_RE.search(media_tag)\n+        assert match is not None, f\"Should match: {media_tag}\"\n+        assert match.group(1) == expected_path\n+\n+    # ── Negative: invalid paths don't match ────────────────────────\n+\n+    @pytest.mark.parametrize(\"text\", [\n+        \"No MEDIA tag here\",\n+        \"MEDIA:relative/path/file.png\",       # relative path, no anchor\n+        \"MEDIA:file.png\",                      # no directory\n+        \"MEDIA:C:file.png\",                    # drive letter but no separator\n+        \"MEDIA:/path/to/file.unknown\",         # unsupported extension\n+        \"MEDIA:/path/to/file\",                 # no extension\n+        \"MEDIA:\",                               # empty path\n+    ])\n+    def test_invalid_paths_dont_match(self, text):\n+        \"\"\"Non-MEDIA text, relative paths, and unsupported extensions are ignored.\"\"\"\n+        match = _TOOL_MEDIA_RE.search(text)\n+        assert match is None, f\"Should NOT match: {text}\"\n+\n+    # ── Negative/preserved: old pattern rejects Windows paths ──────\n+\n+    @pytest.mark.parametrize(\"media_tag\", [\n+        \"MEDIA:C:\\\\Users\\\\test\\\\image.png\",\n+        \"MEDIA:D:/data/report.pdf\",\n+        \"MEDIA:C:\\\\path\\\\file.jpg\",\n+    ])\n+    def test_pre_fix_pattern_rejects_windows(self, media_tag):\n+        \"\"\"The pre-fix pattern (without Windows anchor) does NOT match Windows paths.\n+        This proves the fix is necessary — without it, these paths are silently ignored.\"\"\"\n+        match = _TOOL_MEDIA_RE_PRE_FIX.search(media_tag)\n+        assert match is None, f\"Pre-fix pattern should NOT match: {media_tag}\"\n+\n+    # ── Edge cases ─────────────────────────────────────────────────\n+\n+    def test_multiple_media_tags_in_content(self):\n+        \"\"\"Multiple MEDIA tags in the same content are all found.\"\"\"\n+        content = (\n+            \"Some text MEDIA:C:\\\\path\\\\img.png and more MEDIA:/tmp/out.pdf trailing\"\n+        )\n+        matches = list(_TOOL_MEDIA_RE.finditer(content))\n+        assert len(matches) == 2\n+        paths = [m.group(1) for m in matches]\n+        assert \"C:\\\\path\\\\img.png\" in paths\n+        assert \"/tmp/out.pdf\" in paths\n+\n+    def test_case_insensitive_drive_letter(self):\n+        \"\"\"Drive letters are case-insensitive due to re.IGNORECASE.\"\"\"\n+        match_lower = _TOOL_MEDIA_RE.search(\"MEDIA:c:\\\\path\\\\file.png\")\n+        match_upper = _TOOL_MEDIA_RE.search(\"MEDIA:C:\\\\path\\\\file.png\")\n+        assert match_lower is not None\n+        assert match_upper is not None\n+        assert match_lower.group(1).lower() == match_upper.group(1).lower()\n+\n+    @pytest.mark.parametrize(\"media_tag\", [\n+        \"MEDIA:C:\\\\path\\\\file.jpeg\",\n+        \"MEDIA:C:\\\\path\\\\file.JPG\",\n+        \"MEDIA:C:\\\\path\\\\file.GIF\",\n+        \"MEDIA:C:\\\\path\\\\file.MP4\",\n+    ])\n+    def test_case_insensitive_extensions(self, media_tag):\n+        \"\"\"File extensions are matched case-insensitively.\"\"\"\n+        match = _TOOL_MEDIA_RE.search(media_tag)\n+        assert match is not None, f\"Should match: {media_tag}\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}