From dd3d99b2c376274b681b9ca7e5f2d861fb537245 Mon Sep 17 00:00:00 2001 From: Shinobu Aoki Date: Thu, 20 Aug 2026 14:43:41 +0900 Subject: [PATCH] fix: follow redirects when downloading skills in GcpSkillRegistry The Agent Registry media download endpoint (alt=media) responds with a 302 redirect to a short-lived GCS signed URL instead of streaming the archive directly. httpx does not follow redirects by default and its raise_for_status() raises on 3xx responses, so _make_request treated the 302 as a failure and get_skill() could never download the skill archive. Following redirects is safe here: httpx drops the Authorization header on cross-origin redirects, so the OAuth token is not forwarded to the signed-URL host (GCS would reject a signed URL carrying extra credentials anyway). --- .../skill_registry/gcp_skill_registry.py | 8 +++++-- .../skill_registry/test_gcp_skill_registry.py | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/google/adk/integrations/skill_registry/gcp_skill_registry.py b/src/google/adk/integrations/skill_registry/gcp_skill_registry.py index a129cb427c4..0da9b89f3ea 100644 --- a/src/google/adk/integrations/skill_registry/gcp_skill_registry.py +++ b/src/google/adk/integrations/skill_registry/gcp_skill_registry.py @@ -158,9 +158,13 @@ async def _make_request( def _create_httpx_client(self) -> httpx.AsyncClient: """Creates a new httpx.AsyncClient with appropriate SSL/mTLS configuration.""" + # The Agent Registry media download (alt=media) replies with a 302 to a + # short-lived GCS signed URL, so the client must follow redirects; httpx + # drops the Authorization header on cross-origin redirects, so the OAuth + # token is not forwarded to the signed-URL host. if self._ssl_context is not None: - return httpx.AsyncClient(verify=self._ssl_context) - return httpx.AsyncClient() + return httpx.AsyncClient(verify=self._ssl_context, follow_redirects=True) + return httpx.AsyncClient(follow_redirects=True) async def get_skill(self, *, name: str) -> models.Skill: """Fetches a skill from the registry. diff --git a/tests/unittests/integrations/skill_registry/test_gcp_skill_registry.py b/tests/unittests/integrations/skill_registry/test_gcp_skill_registry.py index 62c9f46e963..9795c53962a 100644 --- a/tests/unittests/integrations/skill_registry/test_gcp_skill_registry.py +++ b/tests/unittests/integrations/skill_registry/test_gcp_skill_registry.py @@ -17,6 +17,7 @@ import io import logging import os +import ssl from unittest import mock import zipfile @@ -542,7 +543,9 @@ async def mock_get(url, *unused_args, **kwargs): skill = await registry.get_skill(name="my-skill") # Verify AsyncClient was instantiated with verify=mock_ssl_context - mock_client_class.assert_called_with(verify=mock_ssl_context) + mock_client_class.assert_called_with( + verify=mock_ssl_context, follow_redirects=True + ) assert skill.frontmatter.name == "my-skill" @@ -578,3 +581,22 @@ async def test_use_custom_credentials(): }), params={"search_string": "query"}, ) + + +@pytest.mark.asyncio +async def test_create_httpx_client_follows_redirects(): + """Clients follow the 302 redirect issued by the media download endpoint.""" + registry = gcp_skill_registry.GCPSkillRegistry() + + client = registry._create_httpx_client() + try: + assert client.follow_redirects is True + finally: + await client.aclose() + + registry._ssl_context = ssl.create_default_context() + client = registry._create_httpx_client() + try: + assert client.follow_redirects is True + finally: + await client.aclose()