Skip to content
Open
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
47 changes: 47 additions & 0 deletions .github/workflows/a2a-tck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Pinned A2A ordinary-path TCK
on:
workflow_dispatch:
pull_request:
paths:
- 'experiments/a2a-tck/**'
- 'examples/a2a-sdk/**'
- 'src/**'
- 'requirements/**'
- 'pyproject.toml'
- '.github/workflows/a2a-tck.yml'
permissions:
contents: read
jobs:
differential:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Prepare isolated pinned environments
run: |
python -m pip install uv==0.12.23
uv venv --python 3.12 ../ca2a-tck-sut
uv pip install --python ../ca2a-tck-sut/bin/python -r experiments/a2a-tck/environment.txt
git clone https://github.com/a2aproject/a2a-tck.git ../a2a-tck
git -C ../a2a-tck checkout 5daf0144b4a4adbdefb62180739b13c5bd7d8196
cd ../a2a-tck
UV_PROJECT_ENVIRONMENT="$RUNNER_TEMP/tck-env" uv sync --frozen
- name: Run paired TCK and protected cA2A tests
env:
PYTHONPATH: src
UV_PROJECT_ENVIRONMENT: ${{ runner.temp }}/tck-env
run: |
../ca2a-tck-sut/bin/python experiments/a2a-tck/run.py --tck ../a2a-tck --output tck-baseline-results --mode baseline
../ca2a-tck-sut/bin/python experiments/a2a-tck/run.py --tck ../a2a-tck --output tck-extension-results --mode extension
python experiments/a2a-tck/compare.py tck-baseline-results tck-extension-results
../ca2a-tck-sut/bin/python -m pytest experiments/a2a-tck/test_compare.py tests/unit/test_a2a_sdk_bridge.py tests/unit/test_a2a_sdk_loopback.py
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.0
if: always()
with:
name: a2a-tck-evidence
path: |
tck-baseline-results
tck-extension-results
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Raise the A2A SDK and development extras' `pyasn1` security floor to 0.6.5,
alongside the development/CI hash lock, clearing four dependency-audit
findings for 0.6.4.

- Sealing to a low-order X25519 peer key raises `SealedChannelError`, consistent
with other invalid channel keys, instead of leaking a backend `ValueError`.
These keys were already refused; no ciphertext format or acceptance rule changes.
Expand Down
60 changes: 60 additions & 0 deletions experiments/a2a-tck/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Pinned A2A ordinary-path compatibility experiment

The runner serves the existing cA2A SDK loopback adapter with the official TCK's
scenario executor for ordinary messages. Protected messages retain the existing
cA2A executor. Both modes are cA2A-aware, and both enable streaming for the pinned scenario executor.
The differential between the two modes is only the extension advertisement. This isolates advertisement compatibility; it is not a comparison
against a separate, unmodified SDK server or a protected-path TCK certification.

Test dependencies and upstream scenario code are isolated from runtime packages.
Linux and Python 3.12 are required. From the repository root:

```sh
git clone https://github.com/a2aproject/a2a-tck.git ../a2a-tck
git -C ../a2a-tck checkout 5daf0144b4a4adbdefb62180739b13c5bd7d8196
uv venv --python 3.12 ../ca2a-tck-sut
uv pip install --python ../ca2a-tck-sut/bin/python -r experiments/a2a-tck/environment.txt
export UV_PROJECT_ENVIRONMENT="$(cd .. && pwd)/tck-clean-env"
(cd ../a2a-tck && uv sync --frozen)
PYTHONPATH=src ../ca2a-tck-sut/bin/python experiments/a2a-tck/run.py --tck ../a2a-tck --output ../tck-baseline-results --mode baseline
PYTHONPATH=src ../ca2a-tck-sut/bin/python experiments/a2a-tck/run.py --tck ../a2a-tck --output ../tck-extension-results --mode extension
python experiments/a2a-tck/compare.py ../tck-baseline-results ../tck-extension-results
PYTHONPATH=src ../ca2a-tck-sut/bin/python -m pytest tests/unit/test_a2a_sdk_bridge.py tests/unit/test_a2a_sdk_loopback.py
```

Output directories must be new. Each run retains logs, JUnit, HTML, compatibility
report, dependency/source identifiers, timeout status and protected-handler count.
The comparison rejects unsuccessful runs, changed test identities/outcomes and
mismatched provenance, modified tracked cA2A source, and any protected-handler
count other than integer zero on either side. The counter measures successful
protected `_lookup()` invocations after authentication and payload checks; it
does not count early protected-path refusals or prove that path was never entered.
Skips remain skips. The TCK checkout must have no tracked or untracked changes.
Inherited proxies and `PYTEST_*` settings are cleared for the local TCK child. Timeout cleanup terminates its process group.

## Observed result

On cA2A `806d1147c0bc834ee8aabb698b341c926cd8531e`, A2A SDK 1.2.2,
Python 3.12.14 and the pinned TCK above:

| Check | Result |
| --- | --- |
| Baseline JSON-RPC MUST | 79 passed, 156 skipped, 30 deselected |
| Extension-advertised JSON-RPC MUST | 79 passed, 156 skipped, 30 deselected |
| Per-test identity/outcome comparison | identical |
| Protected handler calls in either TCK run | 0 |
| Separate cA2A bridge + signed loopback suite | 55 passed |
| Dependency consistency | `uv pip check` passed |

Initial runs using the immediate-response inventory demo instead of the upstream
scenario executor had five task/artifact/text failures in both modes. They were
fixture coverage gaps; this change makes no production compatibility fix.

This does not cover HTTP+JSON, gRPC, SHOULD/MAY, real hardware attestation,
independent Go/JavaScript SDKs or cross-language cryptographic interoperability.
The existing signed loopback tests are separate evidence, not TCK coverage.

The environment and action pins remain the historical experiment's fixed inputs;
they are not claims to track the latest dependency or action versions. Updating
those pins and remeasuring is a separate comparison. Current comparator controls
do not re-date the historical observed TCK results below.
52 changes: 52 additions & 0 deletions experiments/a2a-tck/compare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Fail on missing, failed, or changed per-test outcomes in paired TCK runs."""
import argparse
import json
import sys
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path


def outcomes(directory):
manifest = json.loads((directory / 'manifest.json').read_text())
if manifest['exit_code'] != 0 or manifest['timed_out']:
raise ValueError('TCK run did not complete successfully')
rows = {}
for case in ET.parse(directory / 'junit.xml').iter('testcase'):
key = case.attrib['classname'] + '::' + case.attrib['name']
if key in rows:
raise ValueError('duplicate test identity: ' + key)
rows[key] = next((tag for tag in ('failure', 'error', 'skipped') if case.find(tag) is not None), 'passed')
if not rows or 'passed' not in rows.values():
raise ValueError('empty test coverage')
if any(status in {'failure', 'error'} for status in rows.values()):
raise ValueError('failed tests')
return manifest, rows


def compare(baseline, extension):
left, a = outcomes(baseline)
right, b = outcomes(extension)
if left['mode'] != 'baseline' or right['mode'] != 'extension':
raise ValueError('expected baseline then extension')
for key in ('ca2a_commit', 'tck_commit', 'tck_lock_sha256', 'runner_sha256', 'sdk'):
if left[key] != right[key]:
raise ValueError('mismatched provenance: ' + key)
for manifest in (left, right):
count = manifest.get('protected_handler_invocations')
if type(count) is not int or count != 0:
raise ValueError('protected-handler count must be integer zero')
if manifest.get('ca2a_tracked_changes') != '':
raise ValueError('cA2A tracked files must be unchanged')
if a != b:
raise ValueError('per-test outcomes differ')
return {'identical_test_outcomes': True, 'counts': dict(Counter(a.values())),
'protected_handler_invocations': [left['protected_handler_invocations'], right['protected_handler_invocations']]}


if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('baseline', type=Path)
parser.add_argument('extension', type=Path)
args = parser.parse_args()
sys.stdout.write(json.dumps(compare(args.baseline, args.extension), indent=2) + "\n")
97 changes: 97 additions & 0 deletions experiments/a2a-tck/environment.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
a2a-sdk==1.2.2
agent-manifest==0.15.0
agentrust-trace==0.11.0
agentrust-trace-tests==0.6.2
aiohappyeyeballs==2.7.1
aiohttp==3.14.5
aiologic==0.17.1
aiosignal==1.4.0
annotated-types==0.8.0
anyio==4.15.1
ast-serialize==0.13.0
attrs==26.1.0
bandit==1.9.4
boolean-py==5.0
cachecontrol==0.14.4
cbor2==6.1.4
cedarpy==4.12.1
certifi==2026.10.19
cffi==2.1.1
charset-normalizer==3.5.1
click==8.5.0
coverage==7.16.0
cryptography==50.0.1
culsans==0.11.0
cyclonedx-python-lib==11.12.0
defusedxml==0.7.1
filelock==3.32.5
frozenlist==1.8.0
google-api-core==2.36.0
google-auth==2.57.1
googleapis-common-protos==1.75.3
grpcio==1.84.0
grpcio-reflection==1.84.0
grpcio-tools==1.84.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
idna==3.19
iniconfig==2.3.0
json-rpc==1.15.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
librt==0.16.0
license-expression==30.4.4
markdown-it-py==4.2.0
mdurl==0.1.2
msgpack==1.2.2
multidict==6.9.1
mypy==2.4.0
mypy-extensions==1.1.0
opentelemetry-api==1.44.0
packageurl-python==0.17.6
packaging==26.3
pathspec==1.1.1
pip==26.2.1
pip-api==0.0.34
pip-audit==2.10.1
pip-requirements-parser==32.0.1
platformdirs==4.13.1
pluggy==1.6.0
propcache==0.5.2
proto-plus==1.28.4
protobuf==7.36.2
py-serializable==2.1.0
pyasn1==0.6.4
pyasn1-modules==0.4.2
pycparser==3.0
pydantic==2.13.5
pydantic-core==2.46.5
pygments==2.21.0
pyparsing==3.3.2
pytest==9.1.1
pytest-asyncio==1.4.0
pytest-cov==7.1.0
pyyaml==6.0.3
referencing==0.37.0
requests==2.34.2
rfc3986-validator==0.1.1
rfc8785==0.1.4
rich==15.0.0
rpds-py==2026.6.3
ruff==0.17.1
setuptools==84.0.0
sniffio==1.3.1
sortedcontainers==2.4.0
sse-starlette==3.4.11
starlette==1.6.0
stevedore==5.9.1
tomli==2.4.1
tomli-w==1.2.0
types-pyyaml==6.0.12.20260906
typing-extensions==4.16.0
typing-inspection==0.4.4
urllib3==2.8.0
uvicorn==0.54.0
wrapt==2.4.0
yarl==1.25.1
118 changes: 118 additions & 0 deletions experiments/a2a-tck/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Pinned, Linux-only differential TCK characterization of the existing SDK demo."""
from __future__ import annotations

import argparse
import asyncio
import hashlib
import importlib.metadata
import importlib.util
import json
import os
import signal
import subprocess
import sys
from pathlib import Path

TCK_SHA = "5daf0144b4a4adbdefb62180739b13c5bd7d8196"
ROOT = Path(__file__).resolve().parents[2]


def git(path: Path, *args: str) -> str:
return subprocess.check_output(["git", "-C", str(path), *args], text=True).strip()


async def run(args: argparse.Namespace) -> int:
if sys.platform != "linux":
raise SystemExit("This characterization runner requires Linux process groups")
tck = args.tck.resolve()
if git(tck, "rev-parse", "HEAD") != TCK_SHA:
raise SystemExit(f"TCK must be checked out at {TCK_SHA}")
if git(tck, "status", "--porcelain", "--untracked-files=all"):
raise SystemExit("TCK checkout must have no tracked or untracked changes")
out = args.output.resolve()
out.mkdir(parents=True, exist_ok=False)
spec = importlib.util.spec_from_file_location("sdk_demo", ROOT / "examples/a2a-sdk/loopback.py")
assert spec and spec.loader
demo = importlib.util.module_from_spec(spec)
spec.loader.exec_module(demo)
original_card = demo.operator_card
scenario_spec = importlib.util.spec_from_file_location(
"tck_scenario", tck / "sut/a2a-python/sut_agent.py"
)
assert scenario_spec and scenario_spec.loader
scenario = importlib.util.module_from_spec(scenario_spec)
scenario_spec.loader.exec_module(scenario)

class ScenarioExecutor(demo.InventoryExecutor):
async def execute(self, context, event_queue):
message = context.message
metadata = demo.metadata_from_sdk_message(message) if message else {}
opted = message is not None and demo.EXTENSION_URI in message.extensions
if (demo.has_ca2a_metadata(metadata) or opted
or demo.EXTENSION_URI in context.requested_extensions):
await super().execute(context, event_queue)
else:
await scenario.TckAgentExecutor().execute(context, event_queue)

async def cancel(self, context, event_queue):
await scenario.TckAgentExecutor().cancel(context, event_queue)

def card(url, node):
value = original_card(url, node)
value.capabilities.streaming = True
if args.mode == "baseline":
del value.capabilities.extensions[:]
return value

demo.operator_card = card
demo.InventoryExecutor = ScenarioExecutor
chain, _ = demo.demo_credentials()
node = demo.PeerNode(demo.LocalPolicy.of({demo.READ}), trusted_root_issuers={chain[0].issuer})
env = {key: value for key, value in os.environ.items()
if key.lower() not in {"http_proxy", "https_proxy", "all_proxy"}
and not key.startswith("PYTEST_")}
env["NO_PROXY"] = env["no_proxy"] = "127.0.0.1,localhost"
command = ["uv", "run", "--frozen", "python", "run_tck.py"]
async with demo.serve(node) as (url, executor):
command += ["--sut-host", url, "--transport", "jsonrpc", "--level", "must", "--",
f"--compatibility-report={out / 'compatibility'}",
f"--html={out / 'pytest.html'}", f"--junitxml={out / 'junit.xml'}"]
timed_out = False
with (out / "run.log").open("wb") as log:
process = await asyncio.create_subprocess_exec(
*command, cwd=tck, env=env, stdout=log, stderr=asyncio.subprocess.STDOUT,
start_new_session=True,
)
try:
code = await asyncio.wait_for(process.wait(), timeout=args.timeout)
except TimeoutError:
timed_out = True
os.killpg(process.pid, signal.SIGTERM)
try:
await asyncio.wait_for(process.wait(), timeout=5)
except TimeoutError:
os.killpg(process.pid, signal.SIGKILL)
await process.wait()
code = 124
manifest = {
"mode": args.mode, "ca2a_commit": git(ROOT, "rev-parse", "HEAD"),
"ca2a_tracked_changes": git(ROOT, "status", "--porcelain", "--untracked-files=no"),
"runner_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"tck_commit": TCK_SHA,
"tck_lock_sha256": hashlib.sha256((tck / "uv.lock").read_bytes()).hexdigest(),
"python": sys.version, "sdk": importlib.metadata.version("a2a-sdk"),
"command": command, "exit_code": code, "timed_out": timed_out,
"protected_handler_invocations": executor.invocations,
"scope": "JSON-RPC MUST with pinned upstream scenario executor; cA2A ordinary-path differential",
}
(out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
return code


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tck", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--mode", choices=["baseline", "extension"], required=True)
parser.add_argument("--timeout", type=float, default=180)
raise SystemExit(asyncio.run(run(parser.parse_args())))
Loading
Loading