From 0cb0ca728e61b04fe11635fea590e2b7fa794f8a 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 4f72973e3e..d5efe0631a 100644 --- a/src/google/adk/integrations/skill_registry/gcp_skill_registry.py +++ b/src/google/adk/integrations/skill_registry/gcp_skill_registry.py @@ -154,9 +154,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 e6d0c75cad..cdfbb7ae67 100644 --- a/tests/unittests/integrations/skill_registry/test_gcp_skill_registry.py +++ b/tests/unittests/integrations/skill_registry/test_gcp_skill_registry.py @@ -16,6 +16,7 @@ import io import os +import ssl from unittest import mock import zipfile @@ -455,7 +456,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" @@ -491,3 +494,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()