|
26 | 26 | from a2a.client.client_factory import ClientFactory |
27 | 27 | from a2a.types import AgentCapabilities |
28 | 28 | from a2a.types import AgentCard |
| 29 | +from a2a.types import AgentInterface |
29 | 30 | from a2a.types import AgentSkill |
30 | 31 | from a2a.types import Artifact |
31 | 32 | from a2a.types import Message as A2AMessage |
@@ -165,6 +166,37 @@ def create_test_agent_card( |
165 | 166 | ) |
166 | 167 |
|
167 | 168 |
|
| 169 | +def _make_multi_interface_card(interfaces) -> AgentCard: |
| 170 | + """Build a card offering several RPC endpoints, version-agnostically. |
| 171 | +
|
| 172 | + ``interfaces`` is a list of ``(url, transport)`` pairs; the first pair is the |
| 173 | + card's primary endpoint. On 1.x every pair becomes a ``supported_interfaces`` |
| 174 | + entry; on 0.3.x the first pair is the top-level ``url``/``preferredTransport`` |
| 175 | + and the rest land in ``additional_interfaces``. |
| 176 | + """ |
| 177 | + if _compat.IS_A2A_V1: |
| 178 | + return _compat.parse_agent_card({ |
| 179 | + "name": "test-agent", |
| 180 | + "description": "Test agent", |
| 181 | + "version": "1.0", |
| 182 | + "supported_interfaces": [ |
| 183 | + {"url": url, "protocol_binding": transport} |
| 184 | + for url, transport in interfaces |
| 185 | + ], |
| 186 | + "default_input_modes": ["text/plain"], |
| 187 | + "default_output_modes": ["text/plain"], |
| 188 | + }) |
| 189 | + (primary_url, primary_transport), *extra = interfaces |
| 190 | + return _make_agent_card( |
| 191 | + url=primary_url, |
| 192 | + preferred_transport=primary_transport, |
| 193 | + additional_interfaces=[ |
| 194 | + AgentInterface(url=url, transport=transport) |
| 195 | + for url, transport in extra |
| 196 | + ], |
| 197 | + ) |
| 198 | + |
| 199 | + |
168 | 200 | class TestRemoteA2aAgentInit: |
169 | 201 | """Test RemoteA2aAgent initialization and validation.""" |
170 | 202 |
|
@@ -784,6 +816,141 @@ async def test_validate_agent_card_invalid_url(self): |
784 | 816 | with pytest.raises(AgentCardResolutionError, match="Invalid RPC URL"): |
785 | 817 | await agent._validate_agent_card(invalid_card) |
786 | 818 |
|
| 819 | + @pytest.mark.asyncio |
| 820 | + async def test_validate_agent_card_accepts_same_origin_https_rpc_url(self): |
| 821 | + """A fetched card pointing back at its own origin is accepted.""" |
| 822 | + agent = RemoteA2aAgent( |
| 823 | + name="test_agent", agent_card="https://example.com/agent.json" |
| 824 | + ) |
| 825 | + |
| 826 | + # Should not raise any exception. |
| 827 | + await agent._validate_agent_card( |
| 828 | + create_test_agent_card(url="https://example.com/rpc") |
| 829 | + ) |
| 830 | + |
| 831 | + @pytest.mark.asyncio |
| 832 | + async def test_validate_agent_card_rejects_cross_origin_rpc_url(self): |
| 833 | + """A fetched card cannot redirect RPC traffic to an unrelated host.""" |
| 834 | + agent = RemoteA2aAgent( |
| 835 | + name="test_agent", agent_card="https://example.com/agent.json" |
| 836 | + ) |
| 837 | + |
| 838 | + with pytest.raises(AgentCardResolutionError, match="same origin"): |
| 839 | + await agent._validate_agent_card( |
| 840 | + create_test_agent_card(url="https://attacker.example.net/rpc") |
| 841 | + ) |
| 842 | + |
| 843 | + @pytest.mark.asyncio |
| 844 | + async def test_validate_agent_card_rejects_plain_http_rpc_url(self): |
| 845 | + """A fetched card cannot downgrade RPC traffic to cleartext.""" |
| 846 | + agent = RemoteA2aAgent( |
| 847 | + name="test_agent", agent_card="https://example.com/agent.json" |
| 848 | + ) |
| 849 | + |
| 850 | + with pytest.raises(AgentCardResolutionError, match="must use https"): |
| 851 | + await agent._validate_agent_card( |
| 852 | + create_test_agent_card(url="http://example.com/rpc") |
| 853 | + ) |
| 854 | + |
| 855 | + @pytest.mark.asyncio |
| 856 | + @pytest.mark.parametrize( |
| 857 | + "rpc_url", |
| 858 | + [ |
| 859 | + "http://127.0.0.1:8080/rpc", |
| 860 | + "http://[::1]:8080/rpc", |
| 861 | + "http://169.254.169.254/rpc", |
| 862 | + "http://metadata.internal/rpc", |
| 863 | + ], |
| 864 | + ) |
| 865 | + async def test_validate_agent_card_rejects_internal_rpc_url(self, rpc_url): |
| 866 | + """A fetched card cannot aim RPC traffic at host-local or internal hosts.""" |
| 867 | + agent = RemoteA2aAgent( |
| 868 | + name="test_agent", agent_card="https://example.com/agent.json" |
| 869 | + ) |
| 870 | + |
| 871 | + with pytest.raises(AgentCardResolutionError): |
| 872 | + await agent._validate_agent_card(create_test_agent_card(url=rpc_url)) |
| 873 | + |
| 874 | + @pytest.mark.asyncio |
| 875 | + async def test_validate_agent_card_allows_local_development_http(self): |
| 876 | + """Plain http stays allowed for a same-origin loopback card.""" |
| 877 | + agent = RemoteA2aAgent( |
| 878 | + name="test_agent", |
| 879 | + agent_card="http://localhost:8000/.well-known/agent.json", |
| 880 | + ) |
| 881 | + |
| 882 | + # Should not raise any exception. |
| 883 | + await agent._validate_agent_card( |
| 884 | + create_test_agent_card(url="http://localhost:8000/a2a") |
| 885 | + ) |
| 886 | + |
| 887 | + @pytest.mark.asyncio |
| 888 | + async def test_validate_agent_card_file_source_is_not_origin_checked(self): |
| 889 | + """A card read from a local file is configuration, not remote data.""" |
| 890 | + agent = RemoteA2aAgent(name="test_agent", agent_card="/path/to/agent.json") |
| 891 | + |
| 892 | + # Should not raise any exception. |
| 893 | + await agent._validate_agent_card( |
| 894 | + create_test_agent_card(url="http://internal-host:8080/rpc") |
| 895 | + ) |
| 896 | + |
| 897 | + @pytest.mark.asyncio |
| 898 | + @pytest.mark.parametrize( |
| 899 | + "interfaces", |
| 900 | + [ |
| 901 | + # A second interface on the transport the client already prefers |
| 902 | + # displaces the benign endpoint during transport negotiation. |
| 903 | + [ |
| 904 | + ("https://example.com/rpc", "JSONRPC"), |
| 905 | + ("http://169.254.169.254/", "JSONRPC"), |
| 906 | + ], |
| 907 | + # The primary endpoint advertises a transport the client cannot |
| 908 | + # speak, so negotiation falls through to the second interface. |
| 909 | + [ |
| 910 | + ("https://example.com/rpc", "GRPC"), |
| 911 | + ("http://127.0.0.1:9000/", "HTTP+JSON"), |
| 912 | + ], |
| 913 | + ], |
| 914 | + ids=["displaces_primary", "primary_transport_unsupported"], |
| 915 | + ) |
| 916 | + async def test_validate_agent_card_rejects_off_origin_extra_interface( |
| 917 | + self, interfaces |
| 918 | + ): |
| 919 | + """Every endpoint the card offers is constrained, not just the first.""" |
| 920 | + agent = RemoteA2aAgent( |
| 921 | + name="test_agent", agent_card="https://example.com/agent.json" |
| 922 | + ) |
| 923 | + |
| 924 | + with pytest.raises(AgentCardResolutionError): |
| 925 | + await agent._validate_agent_card(_make_multi_interface_card(interfaces)) |
| 926 | + |
| 927 | + @pytest.mark.asyncio |
| 928 | + async def test_validate_agent_card_accepts_same_origin_extra_interface(self): |
| 929 | + """A card may still offer several endpoints on its own origin.""" |
| 930 | + agent = RemoteA2aAgent( |
| 931 | + name="test_agent", agent_card="https://example.com/agent.json" |
| 932 | + ) |
| 933 | + |
| 934 | + # Should not raise any exception. |
| 935 | + await agent._validate_agent_card( |
| 936 | + _make_multi_interface_card([ |
| 937 | + ("https://example.com/rpc", "JSONRPC"), |
| 938 | + ("https://example.com/rest", "HTTP+JSON"), |
| 939 | + ]) |
| 940 | + ) |
| 941 | + |
| 942 | + def test_agent_card_rpc_urls_lists_every_endpoint(self): |
| 943 | + """Validation enumerates every endpoint on the card, in card order.""" |
| 944 | + card = _make_multi_interface_card([ |
| 945 | + ("https://example.com/rpc", "JSONRPC"), |
| 946 | + ("https://example.com/rest", "HTTP+JSON"), |
| 947 | + ]) |
| 948 | + |
| 949 | + assert _compat.agent_card_rpc_urls(card) == [ |
| 950 | + "https://example.com/rpc", |
| 951 | + "https://example.com/rest", |
| 952 | + ] |
| 953 | + |
787 | 954 | @pytest.mark.asyncio |
788 | 955 | async def test_ensure_resolved_with_direct_agent_card(self): |
789 | 956 | """Test _ensure_resolved with direct agent card.""" |
|
0 commit comments