Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
a2a

Check warning on line 1 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 1 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
A2A

Check warning on line 2 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 2 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
A2AFastAPI

Check warning on line 3 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 3 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
AAgent
Expand Down Expand Up @@ -28,7 +28,7 @@
AUser
autouse
backticks
base64url

Check warning on line 31 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 31 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
buf
bufbuild
cla
Expand All @@ -46,7 +46,7 @@
drivername
DSNs
dunders
ES256

Check warning on line 49 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 49 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
euo
EUR
evt
Expand All @@ -63,8 +63,8 @@
gowebpki
GVsb
hazmat
HS256

Check warning on line 66 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 66 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
HS384

Check warning on line 67 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 67 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
ietf
importlib
initdb
Expand Down Expand Up @@ -107,10 +107,10 @@
Oneof
OpenAPI
openapiv
openapiv2

Check warning on line 110 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 110 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
opensource
otherurl
pb2

Check warning on line 113 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 113 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
podman
Podman
poolclass
Expand All @@ -133,12 +133,13 @@
respx
resub
rmi
RS256

Check warning on line 136 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)

Check warning on line 136 in .github/actions/spelling/allow.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

Ignoring entry because it contains non-alpha characters (non-alpha-in-dictionary)
RUF
Rundgren
SECP
SECP256R1
SFIXED
signings
SLF
socio
sourced
Expand Down
72 changes: 70 additions & 2 deletions src/a2a/server/routes/agent_card_routes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import hashlib
import json

from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -28,12 +31,65 @@
from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH


def _etag_for(card_dict: dict[str, Any]) -> str:
"""A weak ETag derived from the card being served.

Hashed rather than taken from `version`, because `card_modifier` can
return a different card per request without touching `version`.

`signatures` is excluded and the tag is weak, which is what makes
revalidation work for a card signed per request. The signature covers
the card with `signatures` stripped, so it carries no information the
rest of the card does not -- but with a randomized algorithm such as
ES256 it differs on every signing, and a strong tag over it would change
on every request and never once match. A weak tag claims only semantic
equivalence, which two signings of identical content have.

The cost is that a 304 leaves the client on the signature it already
holds. That signature stays valid, since it covers content that has not
changed.

`sort_keys` rather than RFC 8785 canonicalization: an ETag is opaque and
is only ever compared against one this server produced, so determinism
is the whole requirement, and JCS would add a depth limit and a failure
mode for no gain.
"""
unsigned = {k: v for k, v in card_dict.items() if k != 'signatures'}
body = json.dumps(unsigned, sort_keys=True, separators=(',', ':'))
return f'W/"{hashlib.sha256(body.encode("utf-8")).hexdigest()}"'


def _if_none_match_hits(header: str, etag: str) -> bool:
"""Whether an If-None-Match header selects this entity (RFC 9110 8.2.2)."""
candidates = [candidate.strip() for candidate in header.split(',')]
if '*' in candidates:
return True
# If-None-Match uses the weak comparison function, so W/"x" and "x" are
# the same entity on both sides of the comparison.
return any(
candidate.removeprefix('W/') == etag.removeprefix('W/')
for candidate in candidates
)


def create_agent_card_routes(
agent_card: AgentCard,
card_modifier: Callable[[AgentCard], Awaitable[AgentCard]] | None = None,
card_url: str = AGENT_CARD_WELL_KNOWN_PATH,
cache_control: str | None = None,
) -> list['Route']:
"""Creates the Starlette Route for the A2A protocol agent card endpoint."""
"""Creates the Starlette Route for the A2A protocol agent card endpoint.

Args:
agent_card: The `AgentCard` to serve.
card_modifier: Optional callback to adjust the card per request.
card_url: Path the card is served from.
cache_control: Value for the `Cache-Control` response header, e.g.
``'public, max-age=3600'``. A `max-age` has to suit the agent's
expected update frequency, which only the deployment knows, so
there is no default. The `ETag` is sent either way, and
revalidating against it costs one 304.
"""
if not _package_starlette_installed:
raise ImportError(
'The `starlette` package is required to use `create_agent_card_routes`. '
Expand All @@ -45,7 +101,19 @@ async def _get_agent_card(request: Request) -> Response:
card_to_serve = agent_card
if card_modifier:
card_to_serve = await card_modifier(card_to_serve)
Comment thread
JakubWorek marked this conversation as resolved.
return JSONResponse(agent_card_to_dict(card_to_serve))

card_dict = agent_card_to_dict(card_to_serve)
headers = {'ETag': _etag_for(card_dict)}
if cache_control:
headers['Cache-Control'] = cache_control

if_none_match = request.headers.get('if-none-match')
if if_none_match and _if_none_match_hits(
if_none_match, headers['ETag']
):
return Response(status_code=304, headers=headers)

return JSONResponse(card_dict, headers=headers)

return [
Route(
Expand Down
162 changes: 162 additions & 0 deletions tests/server/routes/test_agent_card_routes.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import copy

from unittest.mock import AsyncMock

import pytest

from a2a.server.routes.agent_card_routes import create_agent_card_routes
from a2a.types.a2a_pb2 import AgentCard
from a2a.utils.signing import create_agent_card_signer
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from starlette.applications import Starlette
from starlette.testclient import TestClient

Expand Down Expand Up @@ -69,3 +74,160 @@ def test_agent_card_custom_url(agent_card):
assert client.get('/.well-known/agent-card.json').status_code == 404
# Check that custom returns 200
assert client.get(custom_url).status_code == 200


def card_client(**kwargs) -> TestClient:
return TestClient(
Starlette(routes=create_agent_card_routes(**kwargs)),
# Otherwise httpx resends the ETag it already saw and turns a
# deliberate unconditional GET into a 304.
headers={'cache-control': 'no-cache'},
)


def test_response_carries_a_weak_etag():
response = card_client(agent_card=AgentCard(name='a', version='1')).get(
'/.well-known/agent-card.json'
)

assert response.status_code == 200
assert response.headers['etag'].startswith('W/"')


def test_cache_control_is_sent_only_when_configured():
url = '/.well-known/agent-card.json'
card = AgentCard(name='a', version='1')

assert 'cache-control' not in card_client(agent_card=card).get(url).headers
configured = card_client(
agent_card=card, cache_control='public, max-age=3600'
).get(url)
assert configured.headers['cache-control'] == 'public, max-age=3600'


def test_etag_changes_with_the_card():
url = '/.well-known/agent-card.json'

first = card_client(agent_card=AgentCard(name='a', version='1')).get(url)
second = card_client(agent_card=AgentCard(name='b', version='1')).get(url)

assert first.headers['etag'] != second.headers['etag']


def test_etag_tracks_the_modified_card_not_the_original():
"""card_modifier can vary the body per request, so the hash must follow it."""
url = '/.well-known/agent-card.json'

async def rename(card: AgentCard) -> AgentCard:
return AgentCard(name='modified', version=card.version)

plain = card_client(agent_card=AgentCard(name='a', version='1')).get(url)
modified = card_client(
agent_card=AgentCard(name='a', version='1'), card_modifier=rename
).get(url)

assert plain.headers['etag'] != modified.headers['etag']


@pytest.mark.parametrize(
'make_header',
[
lambda etag: etag,
# Weak comparison, so the strong spelling of the same tag matches.
lambda etag: etag.removeprefix('W/'),
lambda etag: '*',
lambda etag: f'"other", {etag}',
],
ids=['exact', 'strong-spelling', 'star', 'list'],
)
def test_matching_if_none_match_gets_304(make_header):
url = '/.well-known/agent-card.json'
client = card_client(agent_card=AgentCard(name='a', version='1'))
etag = client.get(url).headers['etag']

response = client.get(url, headers={'If-None-Match': make_header(etag)})

assert response.status_code == 304
assert response.content == b''


def test_stale_if_none_match_gets_the_card():
url = '/.well-known/agent-card.json'
client = card_client(agent_card=AgentCard(name='a', version='1'))

response = client.get(url, headers={'If-None-Match': '"stale"'})

assert response.status_code == 200
assert response.json()['name'] == 'a'


def test_a_card_re_signed_per_request_still_revalidates():
"""The card_modifier that re-signs on every request must still get a 304.

ES256 signatures are randomized, so an unchanged card signs differently
every time. Hashing the signature in would produce a tag that changes on
every request and never once matches.
"""
url = '/.well-known/agent-card.json'
private_key = ec.generate_private_key(ec.SECP256R1()).private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
sign = create_agent_card_signer(
signing_key=private_key,
protected_header={
'alg': 'ES256',
'kid': 'k',
'jku': None,
'typ': 'JOSE',
},
)

async def sign_per_request(card: AgentCard) -> AgentCard:
return sign(copy.deepcopy(card))

client = card_client(
agent_card=AgentCard(name='a', version='1'),
card_modifier=sign_per_request,
)
first = client.get(url)
second = client.get(url)

# Same card, different signature, so the bodies differ byte for byte.
assert first.json()['signatures'] != second.json()['signatures']
assert first.headers['etag'] == second.headers['etag']
assert (
client.get(
url, headers={'If-None-Match': first.headers['etag']}
).status_code
== 304
)


def test_etag_still_changes_when_a_signed_card_changes():
"""Excluding signatures must not blind the tag to real content changes."""
url = '/.well-known/agent-card.json'
sign = create_agent_card_signer(
signing_key='a-secret-long-enough-for-hs256-hmac',
protected_header={
'alg': 'HS256',
'kid': 'k',
'jku': None,
'typ': 'JOSE',
},
)

def signed_client(name: str) -> TestClient:
async def modifier(card: AgentCard) -> AgentCard:
return sign(copy.deepcopy(card))

return card_client(
agent_card=AgentCard(name=name, version='1'),
card_modifier=modifier,
)

first = signed_client('a').get(url)
second = signed_client('b').get(url)

assert first.headers['etag'] != second.headers['etag']
Loading