diff --git a/src/google/adk/a2a/agent/__init__.py b/src/google/adk/a2a/agent/__init__.py index 0e8fc6cc6d..6dfbf59250 100644 --- a/src/google/adk/a2a/agent/__init__.py +++ b/src/google/adk/a2a/agent/__init__.py @@ -20,6 +20,7 @@ "A2aCardRequestConfig", "A2AClientError", "A2aRemoteAgentConfig", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AgentCardResolutionError", "CardRequestInterceptor", "ParametersConfig", @@ -33,6 +34,7 @@ def __getattr__(name: str) -> object: "A2aCardRequestConfig", "A2AClientError", "A2aRemoteAgentConfig", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AgentCardResolutionError", "CardRequestInterceptor", "ParametersConfig", @@ -45,6 +47,7 @@ def __getattr__(name: str) -> object: from ._remote_a2a_agent import RemoteA2aAgent from .config import A2aCardRequestConfig from .config import A2aRemoteAgentConfig + from .config import ADK_A2A_ALLOW_INSECURE_HTTP from .config import CardRequestInterceptor from .config import ParametersConfig from .config import RequestInterceptor @@ -55,6 +58,8 @@ def __getattr__(name: str) -> object: return A2AClientError elif name == "A2aRemoteAgentConfig": return A2aRemoteAgentConfig + elif name == "ADK_A2A_ALLOW_INSECURE_HTTP": + return ADK_A2A_ALLOW_INSECURE_HTTP elif name == "AgentCardResolutionError": return AgentCardResolutionError elif name == "CardRequestInterceptor": diff --git a/src/google/adk/a2a/agent/_remote_a2a_agent.py b/src/google/adk/a2a/agent/_remote_a2a_agent.py index adfcce5463..6f8263b8ab 100644 --- a/src/google/adk/a2a/agent/_remote_a2a_agent.py +++ b/src/google/adk/a2a/agent/_remote_a2a_agent.py @@ -69,6 +69,7 @@ from ...flows.llm_flows.functions import REQUEST_INPUT_FUNCTION_CALL_NAME from ...sessions.session import Session from ...utils.context_utils import Aclosing +from ...utils.env_utils import is_env_enabled from ..converters.event_converter import convert_a2a_message_to_event from ..converters.event_converter import convert_a2a_task_to_event from ..converters.event_converter import convert_event_to_a2a_message @@ -84,6 +85,7 @@ from ..logs.log_utils import build_a2a_response_log from .config import A2aCardRequestConfig from .config import A2aRemoteAgentConfig +from .config import ADK_A2A_ALLOW_INSECURE_HTTP from .config import CardRequestInterceptor from .config import ParametersConfig from .config import RequestInterceptor @@ -95,6 +97,7 @@ __all__ = [ "A2AClientError", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AGENT_CARD_WELL_KNOWN_PATH", "AgentCardResolutionError", "RemoteA2aAgent", @@ -680,6 +683,7 @@ def __init__( auth_scheme: Optional[AuthScheme] = None, auth_credential: Optional[AuthCredential] = None, credential_key: Optional[str] = None, + allow_insecure_http: bool = False, **kwargs: Any, ) -> None: """Initialize RemoteA2aAgent. @@ -714,6 +718,10 @@ def __init__( `auth_scheme` is None. credential_key: Optional key under which the resolved credential is cached. Defaults to a digest of the scheme and the credential. + allow_insecure_http: If True, allow plaintext HTTP for agent card + resolution and RPC targets (e.g. within a service mesh with mTLS). + Defaults to False. Can also be enabled via config or the + ``ADK_A2A_ALLOW_INSECURE_HTTP=1`` environment variable. **kwargs: Additional arguments passed to BaseAgent Raises: @@ -742,7 +750,10 @@ def __init__( self._a2a_request_meta_provider = a2a_request_meta_provider self._full_history_when_stateless_param = full_history_when_stateless self._context_builder = context_builder + self._allow_insecure_http_param = allow_insecure_http self._config = config or A2aRemoteAgentConfig() + if allow_insecure_http: + self._config.allow_insecure_http = True if not use_legacy: if self._config.request_interceptors is None: @@ -818,6 +829,23 @@ def _full_history_when_stateless(self) -> bool: def _full_history_when_stateless(self, value: bool) -> None: self._full_history_when_stateless_param = value + @property + def _allow_insecure_http(self) -> bool: + return ( + self._allow_insecure_http_param + or self._config.allow_insecure_http + or is_env_enabled(ADK_A2A_ALLOW_INSECURE_HTTP) + ) + + @_allow_insecure_http.setter + def _allow_insecure_http(self, value: bool) -> None: + self._allow_insecure_http_param = value + self._config.allow_insecure_http = value + + @property + def allow_insecure_http(self) -> bool: + return self._allow_insecure_http + async def _resolve_auth_credential( self, ctx: InvocationContext ) -> Optional[Event]: @@ -956,11 +984,16 @@ async def _resolve_agent_card( # The card request interceptors attach the credential resolved for this # invocation, so the scheme is checked before the fetch rather than with # the card's RPC targets afterwards -- by then the credential has already - # gone out on the wire. Plain http stays allowed on a loopback host, the - # same carve-out `_validate_card_rpc_targets` applies. + # gone out on the wire. Plain http stays allowed on a loopback host, or + # when allow_insecure_http is True. parsed_source = urlparse(agent_card_source) - if parsed_source.scheme.lower() != "https" and not _is_loopback_host( - parsed_source.hostname + scheme = parsed_source.scheme.lower() + if scheme != "https" and not ( + scheme == "http" + and ( + self._allow_insecure_http + or _is_loopback_host(parsed_source.hostname) + ) ): raise AgentCardResolutionError( "Agent card URL must use https, or http on a loopback host:" @@ -995,9 +1028,9 @@ def _validate_card_rpc_targets(self, agent_card: AgentCard) -> None: Every URL the card offers is checked, not only the one this ADK version would select, because the client factory negotiates the endpoint across - the card's whole interface list. Each must be https and share the origin - the card was fetched from; plain http stays allowed on a loopback host, - the local-development shape the A2A helpers emit. + the card's whole interface list. Each must be https (or http if + allow_insecure_http is True or on a loopback host) and share the origin + the card was fetched from. A card passed in directly or read from a local file did not come off the network here, so its target is left to the caller. @@ -1015,8 +1048,13 @@ def _validate_card_rpc_targets(self, agent_card: AgentCard) -> None: for card_url in _compat.agent_card_rpc_urls(agent_card): parsed_card = urlparse(card_url) - if parsed_card.scheme.lower() != "https" and not _is_loopback_host( - parsed_card.hostname + card_scheme = parsed_card.scheme.lower() + if card_scheme != "https" and not ( + card_scheme == "http" + and ( + self._allow_insecure_http + or _is_loopback_host(parsed_card.hostname) + ) ): raise AgentCardResolutionError( "Agent card RPC URL must use https, or http on a loopback host:" diff --git a/src/google/adk/a2a/agent/config.py b/src/google/adk/a2a/agent/config.py index e7388190f4..4a644e8c6f 100644 --- a/src/google/adk/a2a/agent/config.py +++ b/src/google/adk/a2a/agent/config.py @@ -25,6 +25,7 @@ from a2a.server.events import Event as A2AEvent from a2a.types import Message as A2AMessage from pydantic import BaseModel +from pydantic import Field from typing_extensions import Self from .. import _compat @@ -40,8 +41,11 @@ from ...a2a.converters.to_adk_event import convert_a2a_task_to_event from ...agents.invocation_context import InvocationContext from ...events.event import Event +from ...utils.env_utils import is_env_enabled from .._compat import A2AClientEvent +ADK_A2A_ALLOW_INSECURE_HTTP = "ADK_A2A_ALLOW_INSECURE_HTTP" + class ParametersConfig(BaseModel): """Configuration for the parameters passed to the A2A send_message request.""" @@ -141,6 +145,10 @@ class A2aRemoteAgentConfig(BaseModel): forward_session_id_as_context_id: bool = False """Whether to forward the local session ID as context_id when no context_id is present.""" + allow_insecure_http: bool = Field( + default_factory=lambda: is_env_enabled(ADK_A2A_ALLOW_INSECURE_HTTP) + ) + def __deepcopy__( self, memo: dict[int, Any] | None = None ) -> A2aRemoteAgentConfig: @@ -149,7 +157,7 @@ def __deepcopy__( cls = self.__class__ copied_values: dict[str, Any] = {} for k, v in self.__dict__.items(): - if not k.startswith('_'): + if not k.startswith("_"): if callable(v): copied_values[k] = v else: diff --git a/src/google/adk/agents/remote_a2a_agent.py b/src/google/adk/agents/remote_a2a_agent.py index 1e3d1d0f11..10053b6f45 100644 --- a/src/google/adk/agents/remote_a2a_agent.py +++ b/src/google/adk/agents/remote_a2a_agent.py @@ -17,6 +17,7 @@ from ..a2a.agent._remote_a2a_agent import A2A_METADATA_PREFIX as A2A_METADATA_PREFIX from ..a2a.agent._remote_a2a_agent import A2AClientError as A2AClientError +from ..a2a.agent._remote_a2a_agent import ADK_A2A_ALLOW_INSECURE_HTTP as ADK_A2A_ALLOW_INSECURE_HTTP from ..a2a.agent._remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH as AGENT_CARD_WELL_KNOWN_PATH from ..a2a.agent._remote_a2a_agent import AgentCardResolutionError as AgentCardResolutionError from ..a2a.agent._remote_a2a_agent import DEFAULT_TIMEOUT as DEFAULT_TIMEOUT @@ -24,6 +25,7 @@ __all__ = [ "A2AClientError", + "ADK_A2A_ALLOW_INSECURE_HTTP", "AGENT_CARD_WELL_KNOWN_PATH", "AgentCardResolutionError", "RemoteA2aAgent", diff --git a/tests/unittests/a2a/agent/test_remote_a2a_agent.py b/tests/unittests/a2a/agent/test_remote_a2a_agent.py index 7fb8aedb5e..cc08f6da80 100644 --- a/tests/unittests/a2a/agent/test_remote_a2a_agent.py +++ b/tests/unittests/a2a/agent/test_remote_a2a_agent.py @@ -14,6 +14,7 @@ import copy import json +import os from pathlib import Path import tempfile import threading @@ -44,6 +45,7 @@ from google.adk.a2a.agent import RequestInterceptor import google.adk.a2a.agent._remote_a2a_agent as remote_a2a_agent from google.adk.a2a.agent.config import A2aRemoteAgentConfig +from google.adk.a2a.agent.config import ADK_A2A_ALLOW_INSECURE_HTTP from google.adk.a2a.agent.utils import execute_after_request_interceptors from google.adk.a2a.agent.utils import execute_before_card_request_interceptors from google.adk.a2a.agent.utils import execute_before_request_interceptors @@ -385,6 +387,54 @@ def test_init_with_custom_timeout(self): assert agent._timeout == 300.0 + def test_init_allow_insecure_http_default(self): + agent = RemoteA2aAgent( + name="test_agent", agent_card=create_test_agent_card() + ) + assert agent.allow_insecure_http is False + assert agent._config.allow_insecure_http is False + + def test_init_allow_insecure_http_param(self): + """Test allow_insecure_http can be explicitly enabled via parameter.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card=create_test_agent_card(), + allow_insecure_http=True, + ) + assert agent.allow_insecure_http is True + assert agent._config.allow_insecure_http is True + + def test_init_allow_insecure_http_from_config(self): + config = A2aRemoteAgentConfig(allow_insecure_http=True) + agent = RemoteA2aAgent( + name="test_agent", + agent_card=create_test_agent_card(), + config=config, + ) + assert agent.allow_insecure_http is True + assert agent._config.allow_insecure_http is True + + def test_init_allow_insecure_http_from_env_var(self): + + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent = RemoteA2aAgent( + name="test_agent", agent_card=create_test_agent_card() + ) + assert agent.allow_insecure_http is True + assert agent._config.allow_insecure_http is True + + def test_a2a_remote_agent_config_allow_insecure_http_env_var(self): + + assert A2aRemoteAgentConfig().allow_insecure_http is False + + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + assert A2aRemoteAgentConfig().allow_insecure_http is True + + assert ( + A2aRemoteAgentConfig(allow_insecure_http=False).allow_insecure_http + is False + ) + class TestRemoteA2aAgentResolution: """Test agent card resolution functionality.""" @@ -597,6 +647,68 @@ async def test_resolve_agent_card_allows_loopback_http_source(self): assert await agent._resolve_agent_card(Mock()) == self.agent_card + @pytest.mark.asyncio + async def test_resolve_agent_card_allows_non_loopback_http_when_opted_in( + self, + ): + """Plain http is allowed for non-loopback host when allow_insecure_http=True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + allow_insecure_http=True, + ) + + with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client: + mock_ensure_client.return_value = AsyncMock() + with patch( + "google.adk.a2a.agent._remote_a2a_agent.A2ACardResolver" + ) as mock_resolver_class: + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = self.agent_card + mock_resolver_class.return_value = mock_resolver + + assert await agent._resolve_agent_card(Mock()) == self.agent_card + + @pytest.mark.asyncio + async def test_resolve_agent_card_allows_non_loopback_http_via_config(self): + """Plain http is allowed when config.allow_insecure_http is True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + config=A2aRemoteAgentConfig(allow_insecure_http=True), + ) + + with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client: + mock_ensure_client.return_value = AsyncMock() + with patch( + "google.adk.a2a.agent._remote_a2a_agent.A2ACardResolver" + ) as mock_resolver_class: + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = self.agent_card + mock_resolver_class.return_value = mock_resolver + + assert await agent._resolve_agent_card(Mock()) == self.agent_card + + @pytest.mark.asyncio + async def test_resolve_agent_card_allows_non_loopback_http_via_env_var(self): + """Plain http is allowed when ADK_A2A_ALLOW_INSECURE_HTTP=1.""" + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + + with patch.object(agent, "_ensure_httpx_client") as mock_ensure_client: + mock_ensure_client.return_value = AsyncMock() + with patch( + "google.adk.a2a.agent._remote_a2a_agent.A2ACardResolver" + ) as mock_resolver_class: + mock_resolver = AsyncMock() + mock_resolver.get_agent_card.return_value = self.agent_card + mock_resolver_class.return_value = mock_resolver + + assert await agent._resolve_agent_card(Mock()) == self.agent_card + @pytest.mark.asyncio async def test_card_request_interceptors_injects_headers(self): """Header provider headers (from session state) are sent for the card.""" @@ -1037,6 +1149,84 @@ async def test_validate_agent_card_allows_local_development_http(self): create_test_agent_card(url="http://localhost:8000/a2a") ) + @pytest.mark.asyncio + async def test_validate_agent_card_rejects_insecure_http_rpc_target_by_default( + self, + ): + """Card with non-loopback HTTP RPC URL is rejected by default.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + + with pytest.raises(AgentCardResolutionError, match="must use https"): + agent._validate_card_rpc_targets( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_allows_insecure_http_rpc_target_when_opted_in( + self, + ): + """Non-loopback HTTP RPC target succeeds when allow_insecure_http=True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + allow_insecure_http=True, + ) + + await agent._validate_agent_card( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_allows_insecure_http_rpc_target_via_config( + self, + ): + """Non-loopback HTTP RPC target succeeds when config.allow_insecure_http=True.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + config=A2aRemoteAgentConfig(allow_insecure_http=True), + ) + + await agent._validate_agent_card( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_allows_insecure_http_rpc_target_via_env_var( + self, + ): + """Non-loopback HTTP RPC target succeeds when ADK_A2A_ALLOW_INSECURE_HTTP=1.""" + with patch.dict(os.environ, {ADK_A2A_ALLOW_INSECURE_HTTP: "1"}): + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + ) + + await agent._validate_agent_card( + create_test_agent_card(url="http://mesh-service.internal:8080/rpc") + ) + + @pytest.mark.asyncio + async def test_validate_agent_card_insecure_http_still_enforces_same_origin( + self, + ): + """Even with allow_insecure_http=True, off-origin RPC target is rejected.""" + agent = RemoteA2aAgent( + name="test_agent", + agent_card="http://mesh-service.internal:8080/agent.json", + allow_insecure_http=True, + ) + + with pytest.raises( + AgentCardResolutionError, match="must have the same origin" + ): + await agent._validate_agent_card( + create_test_agent_card(url="http://other-service.internal:8080/rpc") + ) + @pytest.mark.asyncio async def test_validate_agent_card_file_source_is_not_origin_checked(self): """A card read from a local file is configuration, not remote data."""