diff --git a/backend/packages/harness/deerflow/community/image_search/AGENTS.md b/backend/packages/harness/deerflow/community/image_search/AGENTS.md index 000a0c8d54..5e41bb41a8 100644 --- a/backend/packages/harness/deerflow/community/image_search/AGENTS.md +++ b/backend/packages/harness/deerflow/community/image_search/AGENTS.md @@ -1,12 +1,12 @@ # Image Search -DDG, Brave, and Serper `image_search` screen returned image and thumbnail URLs with -`community.url_safety.safe_public_result_url`. This pure string check does not -resolve hostnames; any later downloader must enforce its own connection-time -address policy. The screen reuses `is_blocked_address` for literal and embedded -IPv4 addresses, including multicast/reserved forms. Shared regression coverage -is in `tests/test_image_result_url_safety.py`; DDG tool coverage is in -`tests/test_ddg_image_search_urls.py`. +DDG, Brave, and Serper `image_search` screen returned image, thumbnail and +source page URLs with `community.url_safety.safe_public_result_url`. This pure +string check does not resolve hostnames; any later downloader must enforce its +own connection-time address policy. The screen reuses `is_blocked_address` for +literal and embedded IPv4 addresses, including multicast/reserved forms. Shared +regression coverage is in `tests/test_image_result_url_safety.py`; DDG tool +coverage is in `tests/test_ddg_image_search_urls.py`. Normalize IDNA's Unicode dot separators before checking blocked hostnames or IP literals; preserve the original URL for allowed results. Client-normalization diff --git a/backend/packages/harness/deerflow/community/image_search/tools.py b/backend/packages/harness/deerflow/community/image_search/tools.py index cf055aa6db..7b98d447f4 100644 --- a/backend/packages/harness/deerflow/community/image_search/tools.py +++ b/backend/packages/harness/deerflow/community/image_search/tools.py @@ -147,6 +147,7 @@ async def image_search_tool( "title": title if isinstance(title, str) else "", "image_url": image_url, "thumbnail_url": thumbnail_url, + "source_url": safe_public_result_url(row.get("url")), } ) diff --git a/backend/packages/harness/deerflow/community/serper/tools.py b/backend/packages/harness/deerflow/community/serper/tools.py index e6063cfdf6..232962a102 100644 --- a/backend/packages/harness/deerflow/community/serper/tools.py +++ b/backend/packages/harness/deerflow/community/serper/tools.py @@ -497,6 +497,7 @@ def image_search_tool(query: str, max_results: int = 5) -> str: "title": r.get("title", ""), "image_url": image_url, "thumbnail_url": thumbnail_url, + "source_url": _safe_public_url(r.get("link")), } ) if len(normalized_results) >= max_results: diff --git a/backend/tests/test_ddg_image_search_urls.py b/backend/tests/test_ddg_image_search_urls.py index dc399f2d7f..e8bd630531 100644 --- a/backend/tests/test_ddg_image_search_urls.py +++ b/backend/tests/test_ddg_image_search_urls.py @@ -10,8 +10,8 @@ from deerflow.community.url_safety import safe_public_result_url @pytest.mark.asyncio async def test_image_search_drops_unsafe_result_urls() -> None: rows = [ - {"title": "safe", "image": "https://images.example/full.jpg", "thumbnail": "https://images.example/thumb.jpg"}, - {"title": "unsafe original", "image": "http://127.0.0.1/private", "thumbnail": "https://images.example/thumb2.jpg"}, + {"title": "safe", "image": "https://images.example/full.jpg", "thumbnail": "https://images.example/thumb.jpg", "url": "https://images.example/page"}, + {"title": "unsafe original", "image": "http://127.0.0.1/private", "thumbnail": "https://images.example/thumb2.jpg", "url": "http://10.0.0.1/page"}, {"title": "unsafe thumbnail", "image": "https://images.example/full3.jpg", "thumbnail": "javascript:alert(1)"}, {"title": "both unsafe", "image": "http://2130706433/private", "thumbnail": "http://localhost/thumb"}, ] @@ -21,9 +21,9 @@ async def test_image_search_drops_unsafe_result_urls() -> None: assert result["total_results"] == 3 assert result["results"] == [ - {"title": "safe", "image_url": "https://images.example/full.jpg", "thumbnail_url": "https://images.example/thumb.jpg"}, - {"title": "unsafe original", "image_url": "", "thumbnail_url": "https://images.example/thumb2.jpg"}, - {"title": "unsafe thumbnail", "image_url": "https://images.example/full3.jpg", "thumbnail_url": ""}, + {"title": "safe", "image_url": "https://images.example/full.jpg", "thumbnail_url": "https://images.example/thumb.jpg", "source_url": "https://images.example/page"}, + {"title": "unsafe original", "image_url": "", "thumbnail_url": "https://images.example/thumb2.jpg", "source_url": ""}, + {"title": "unsafe thumbnail", "image_url": "https://images.example/full3.jpg", "thumbnail_url": "", "source_url": ""}, ] diff --git a/backend/tests/test_image_result_idna_hosts.py b/backend/tests/test_image_result_idna_hosts.py index 07168abd49..54b91f35bd 100644 --- a/backend/tests/test_image_result_idna_hosts.py +++ b/backend/tests/test_image_result_idna_hosts.py @@ -49,4 +49,4 @@ async def test_ddg_image_search_keeps_safe_thumbnail_when_idna_image_is_private( result = json.loads(await tools.image_search_tool.ainvoke({"query": "reference"})) assert result["total_results"] == 1 - assert result["results"] == [{"title": "reference", "image_url": "", "thumbnail_url": "https://images.example/thumb.jpg"}] + assert result["results"] == [{"title": "reference", "image_url": "", "thumbnail_url": "https://images.example/thumb.jpg", "source_url": ""}] diff --git a/backend/tests/test_image_search.py b/backend/tests/test_image_search.py index 742768bcad..d841b9e2b8 100644 --- a/backend/tests/test_image_search.py +++ b/backend/tests/test_image_search.py @@ -18,6 +18,7 @@ async def test_image_search_uses_full_image_url_not_thumbnail(): "title": "a cat", "image": "https://example.com/full.jpg", "thumbnail": "https://example.com/thumb.jpg", + "url": "https://example.com/cats/page", } ] cfg = MagicMock() @@ -32,6 +33,8 @@ async def test_image_search_uses_full_image_url_not_thumbnail(): result = output["results"][0] assert result["image_url"] == "https://example.com/full.jpg" assert result["thumbnail_url"] == "https://example.com/thumb.jpg" + # The web UI links each thumbnail through `source_url`, as Brave's image_search emits it. + assert result["source_url"] == "https://example.com/cats/page" async def _search_kwargs(query="a cat", **kwargs): diff --git a/backend/tests/test_serper_tools.py b/backend/tests/test_serper_tools.py index f9a78c2998..d2af2d35ec 100644 --- a/backend/tests/test_serper_tools.py +++ b/backend/tests/test_serper_tools.py @@ -846,11 +846,13 @@ class TestImageSearchTool: "title": "Cat 1", "imageUrl": "https://example.com/cat1.jpg", "thumbnailUrl": "https://example.com/cat1_thumb.jpg", + "link": "https://example.com/cats/1", }, { "title": "Cat 2", "imageUrl": "https://example.com/cat2.jpg", "thumbnailUrl": "https://example.com/cat2_thumb.jpg", + "link": "https://example.com/cats/2", }, ] mock_resp = _make_serper_images_response(images) @@ -868,6 +870,7 @@ class TestImageSearchTool: assert parsed["results"][0]["title"] == "Cat 1" assert parsed["results"][0]["image_url"] == "https://example.com/cat1.jpg" assert parsed["results"][0]["thumbnail_url"] == "https://example.com/cat1_thumb.jpg" + assert parsed["results"][0]["source_url"] == "https://example.com/cats/1" assert parsed["usage_hint"] == "Use the 'image_url' values as reference images in image generation. Download them first if needed." def test_sends_correct_headers_and_payload_to_images_endpoint(self, mock_config_with_key): @@ -1277,7 +1280,7 @@ class TestImageSearchTool: images = [ {"title": "Local", "imageUrl": "file:///etc/passwd", "thumbnailUrl": "http://127.0.0.1/thumb.jpg"}, {"title": "Data", "imageUrl": "data:image/png;base64,abc", "thumbnailUrl": "http://10.0.0.1/thumb.jpg"}, - {"title": "Safe", "imageUrl": "https://example.com/i.jpg", "thumbnailUrl": "http://example.com/t.jpg"}, + {"title": "Safe", "imageUrl": "https://example.com/i.jpg", "thumbnailUrl": "http://example.com/t.jpg", "link": "http://127.0.0.1/page"}, ] mock_resp = _make_serper_images_response(images) @@ -1293,6 +1296,7 @@ class TestImageSearchTool: assert parsed["results"][0]["title"] == "Safe" assert parsed["results"][0]["image_url"] == "https://example.com/i.jpg" assert parsed["results"][0]["thumbnail_url"] == "http://example.com/t.jpg" + assert parsed["results"][0]["source_url"] == "" def test_all_unsafe_image_urls_return_error(self, mock_config_with_key): images = [