From 17a40d9b088a73c38300762598e0e1ed6a1ea31e Mon Sep 17 00:00:00 2001 From: "Izzy Weinberg (backend-engineer)" Date: Wed, 22 Jul 2026 19:16:45 +0300 Subject: [PATCH 1/6] feat(a2a): scaffold shared A2A server package [skip ci] Contract-import shim + package skeleton for the A2A server slice. Co-Authored-By: Claude Claude-Session-Id: 17fb89fd-3579-433b-a6c4-9c9e7f3ec549 --- agent-templates/a2a/__init__.py | 18 +++++++ agent-templates/a2a/_contract.py | 83 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 agent-templates/a2a/__init__.py create mode 100644 agent-templates/a2a/_contract.py diff --git a/agent-templates/a2a/__init__.py b/agent-templates/a2a/__init__.py new file mode 100644 index 0000000..5a8507c --- /dev/null +++ b/agent-templates/a2a/__init__.py @@ -0,0 +1,18 @@ +"""Shared A2A server + Agent Card generator for the Fuze family. + +This package implements the CALLEE side of the frozen A2A contract v1 +(``agent-templates/contracts/a2a/v1``). It is a thin ADAPTER over the existing +Managed-Agents runtime (``agent-templates/providers`` + ``orchestration``): A2A wire +objects in, provider calls out, provider results mapped back to A2A objects. There is +no new task engine here — see ``contracts/a2a/v1/state-mapping.md``. + +Modules: + card_generator -- projects .fuze/manifest.json + roles/*/role.json -> AgentCard + task_mapper -- run_until_block result -> A2A Task/TaskStatus (the core table) + authz -- callee-enforced allowlist decision (providesTo, fail-closed) + adapter -- wire method dispatch onto an AgentProvider + server -- Starlette JSON-RPC 2.0 + SSE transport +""" +from __future__ import annotations + +__version__ = "1.0.0" diff --git a/agent-templates/a2a/_contract.py b/agent-templates/a2a/_contract.py new file mode 100644 index 0000000..2446325 --- /dev/null +++ b/agent-templates/a2a/_contract.py @@ -0,0 +1,83 @@ +"""Bridge to the FROZEN A2A contract client package. + +The generated wire/card models and the typed error taxonomy live in +``agent-templates/contracts/a2a/v1/client/fuze_a2a_client`` and are the single +source of truth for the wire shapes. This module makes them importable whether or +not the client package has been ``pip install``ed, by putting its directory on +``sys.path`` on first import. Everything in this server imports the wire/card models +and errors THROUGH here so there is exactly one definition of the contract types. + +We NEVER redefine the wire or card models — redefining a generated model is how a +server silently forks from its spec (see the client package docstring). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +# .../agent-templates/a2a/_contract.py -> .../agent-templates +_AGENT_TEMPLATES = Path(__file__).resolve().parents[1] +_CONTRACT_ROOT = _AGENT_TEMPLATES / "contracts" / "a2a" / "v1" +_CLIENT_DIR = _CONTRACT_ROOT / "client" + +if _CLIENT_DIR.exists() and str(_CLIENT_DIR) not in sys.path: + sys.path.insert(0, str(_CLIENT_DIR)) + +#: Absolute path to the frozen contract tree (schemas, examples, VERSION). +CONTRACT_ROOT = _CONTRACT_ROOT +SCHEMA_DIR = _CONTRACT_ROOT / "schema" +EXAMPLES_DIR = _CONTRACT_ROOT / "examples" + +# Re-export the generated / frozen types. Imported lazily-safe: the client package +# only needs pydantic (always present here), never httpx, because we supply no +# transport (this is the server, not the client). +from fuze_a2a_client import errors as errors # noqa: E402 +from fuze_a2a_client.card_models import ( # noqa: E402 + AgentCapabilities, + AgentInterface, + AgentProvider, + AgentSkill, + FuzeA2AAgentCard, + SecurityRequirement, +) +from fuze_a2a_client.wire_models import ( # noqa: E402 + Artifact, + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, + Message, + Method, + Part, + Role, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) + +__all__ = [ + "CONTRACT_ROOT", + "SCHEMA_DIR", + "EXAMPLES_DIR", + "errors", + "FuzeA2AAgentCard", + "AgentInterface", + "AgentProvider", + "AgentSkill", + "AgentCapabilities", + "SecurityRequirement", + "Artifact", + "JsonRpcError", + "JsonRpcRequest", + "JsonRpcResponse", + "Message", + "Method", + "Part", + "Role", + "Task", + "TaskArtifactUpdateEvent", + "TaskState", + "TaskStatus", + "TaskStatusUpdateEvent", +] From 5db9576b4765b0226febfc5cf8cca60a01b71740 Mon Sep 17 00:00:00 2001 From: "Izzy Weinberg (backend-engineer)" Date: Wed, 22 Jul 2026 19:24:10 +0300 Subject: [PATCH 2/6] feat(a2a): card generator + schema validation + loader (16 unit tests green) [skip ci] Projects .fuze/manifest.json + roles/*/role.json -> Agent Card per card-projection.md, for product and exec-tier roles. Validates against agent-card.schema.json + fuze-profile.schema.json. Deterministic, signed (placeholder signer; real JWS injected by devops). Co-Authored-By: Claude Claude-Session-Id: 17fb89fd-3579-433b-a6c4-9c9e7f3ec549 --- agent-templates/a2a/card_generator.py | 398 ++++++++++++++++++ agent-templates/a2a/loader.py | 34 ++ agent-templates/a2a/tests/__init__.py | 0 agent-templates/a2a/tests/conftest.py | 47 +++ .../fixtures/fuzeinfra/.fuze/manifest.json | 7 + .../agent-templates/roles/cto/role.json | 16 + .../fixtures/fuzeplan/.fuze/manifest.json | 12 + .../roles/product-manager/role.json | 14 + .../roles/ux-designer/role.json | 10 + .../a2a/tests/test_card_generator.py | 208 +++++++++ agent-templates/a2a/validation.py | 49 +++ 11 files changed, 795 insertions(+) create mode 100644 agent-templates/a2a/card_generator.py create mode 100644 agent-templates/a2a/loader.py create mode 100644 agent-templates/a2a/tests/__init__.py create mode 100644 agent-templates/a2a/tests/conftest.py create mode 100644 agent-templates/a2a/tests/fixtures/fuzeinfra/.fuze/manifest.json create mode 100644 agent-templates/a2a/tests/fixtures/fuzeinfra/agent-templates/roles/cto/role.json create mode 100644 agent-templates/a2a/tests/fixtures/fuzeplan/.fuze/manifest.json create mode 100644 agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/product-manager/role.json create mode 100644 agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/ux-designer/role.json create mode 100644 agent-templates/a2a/tests/test_card_generator.py create mode 100644 agent-templates/a2a/validation.py diff --git a/agent-templates/a2a/card_generator.py b/agent-templates/a2a/card_generator.py new file mode 100644 index 0000000..50b4c2d --- /dev/null +++ b/agent-templates/a2a/card_generator.py @@ -0,0 +1,398 @@ +"""Agent Card generator (projection). + +Implements ``contracts/a2a/v1/card-projection.md`` NORMATIVELY. The card is a *pure +function* of two inputs already present in every repo:: + + .fuze/manifest.json -> identity, provider, interface, docs + agent-templates/roles/*/role.json -> skills + +Purity is the whole point: the card is the published capability boundary, so it MUST +NOT be hand-authored and MUST be byte-identical for identical inputs (modulo +``signatures``). Iteration order is explicit (lexicographic by role key unless +``servingRoles`` fixes an order) — never filesystem order. + +What is DELIBERATELY not projected (card-projection.md §3/§7): ``tools``, +``mcp_servers``, ``system``/``system_append``, ``persona``, ``model``, +``environment`` and ``vault`` bindings. Leaking any of them would tell a caller which +credentials the callee holds — exactly the coupling A2A removes. +""" +from __future__ import annotations + +import json +from typing import Any, Callable, Iterable + +from ._contract import CONTRACT_ROOT + +PROVIDER_ORG = "FuzeOne" +PROVIDER_URL = "https://github.com/izzywdev" +IN_CLUSTER_URL = "http://a2a-shared.fuzeagent.svc.cluster.local:8080/rpc" +DEFAULT_INPUT_MODES = ["text/plain", "application/json"] +DEFAULT_OUTPUT_MODES = ["text/plain", "application/json"] +DEFAULT_ISSUER = "https://auth.prod.fuzefront.com" + +#: Signer signature: (signing_input_bytes) -> {"protected", "signature", "header"?}. +Signer = Callable[[bytes], dict] + +# The placeholder emitted when no real signer is injected. Key material and rotation +# are a devops concern (card-projection.md §6); the generator owns only the mechanism +# and the invariant that signatures[] is present and non-empty (Fuze profile). +_PLACEHOLDER_SIGNATURE = { + "protected": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImZ1emUtYTJhLTIwMjYtMDcifQ", + "signature": "PLACEHOLDER-jws-signature-emitted-by-the-card-generator", + "header": {"kid": "fuze-a2a-2026-07"}, +} + + +class CardProjectionError(ValueError): + """A role cannot be projected (e.g. a skill with no description).""" + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def repo_name(repo: str) -> str: + """The canonical agent identity: the segment after '/' in ``owner/name``.""" + return repo.rsplit("/", 1)[-1] + + +def contract_version() -> str: + return (CONTRACT_ROOT / "VERSION").read_text(encoding="utf-8").strip() + + +def _a2a(block: dict | None) -> dict: + return dict(block or {}) + + +def is_exec_role(role: dict) -> bool: + return (role.get("metadata") or {}).get("tier") == "executive" + + +def _service_tags(role: dict) -> list[str]: + services = role.get("services") or {} + return [key for key, grant in services.items() if grant and grant != "none"] + + +def project_tags(role_key: str, role: dict, manifest: dict) -> list[str]: + """Derived tags UNION ``a2a.tags``, de-duplicated and sorted (card-projection.md §3). + + Derived (always included): the role key; ``manifest.tier``; ``"executive"`` when + ``metadata.tier == "executive"``; each ``services`` key whose grant is not + ``"none"``. Sorting is the deterministic order — the examples in the contract are + hand-authored illustrations and are intentionally not relied on for tag ORDER, + only for the tag SET. + """ + a2a = _a2a(role.get("a2a")) + tags: set[str] = {role_key} + if manifest.get("tier"): + tags.add(manifest["tier"]) + if is_exec_role(role): + tags.add("executive") + tags.update(_service_tags(role)) + tags.update(a2a.get("tags") or []) + return sorted(tags) + + +def project_skill(role_key: str, role: dict, manifest: dict) -> dict: + """One role.json -> one AgentSkill (card-projection.md §3).""" + name = role.get("name") + if not name: + raise CardProjectionError(f"role {role_key!r} has no name; cannot project a skill") + description = role.get("description") + if not description: + # An undescribed skill is unroutable; failing is better than a placeholder. + raise CardProjectionError( + f"role {role_key!r} has no description; refusing to emit a placeholder skill" + ) + + a2a = _a2a(role.get("a2a")) + skill: dict[str, Any] = { + "id": role_key, + "name": name, + "description": description, + "tags": project_tags(role_key, role, manifest), + } + if a2a.get("examples"): + skill["examples"] = list(a2a["examples"]) + if a2a.get("inputModes"): + skill["inputModes"] = list(a2a["inputModes"]) + if a2a.get("outputModes"): + skill["outputModes"] = list(a2a["outputModes"]) + if a2a.get("scopes"): + skill["securityRequirements"] = [{"fuze-oidc": list(a2a["scopes"])}] + return skill + + +def _security_schemes(external: bool, issuer_url: str) -> dict: + schemes: dict[str, Any] = { + "fuze-oidc": { + "openIdConnectSecurityScheme": { + "description": "FuzeKeys OIDC. The validated subject is the calling repo identity.", + "openIdConnectUrl": issuer_url.rstrip("/") + "/.well-known/openid-configuration", + } + } + } + # mTLS is declared only for in-cluster (non-external) surfaces (card-projection.md §4). + if not external: + schemes["fuze-mtls"] = { + "mtlsSecurityScheme": { + "description": "In-cluster mutual TLS, defence in depth alongside the bearer token." + } + } + return schemes + + +def _interface(tenant: str, *, external: bool, repo_slug: str) -> dict: + if external: + url = f"https://a2a.{repo_slug.lower()}.prod.fuzefront.com/rpc" + else: + url = IN_CLUSTER_URL + return { + "url": url, + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "tenant": tenant, + } + + +def _capabilities() -> dict: + return {"streaming": True, "pushNotifications": False, "extendedAgentCard": True} + + +def _doc_url(manifest: dict) -> str: + a2a = _a2a(manifest.get("a2a")) + return a2a.get("documentationUrl") or f"https://github.com/{manifest['repo']}" + + +# --------------------------------------------------------------------------- # +# role selection +# --------------------------------------------------------------------------- # +def select_serving_roles( + manifest: dict, roles: dict[str, dict], *, visibility: str = "public" +) -> list[str]: + """Ordered role keys projected into ``skills`` (card-projection.md §3). + + Base source set excludes ``_base``, coordinators, and (in v1) exec roles which get + their own per-role cards. Within that set: + + * ``public`` -> drop ``a2a.publish == false`` and ``a2a.extendedOnly == true``. + * ``extended`` -> keep them (the authenticated caller may see more; authz.md §5). + + Order: ``manifest.a2a.servingRoles`` verbatim if given, else lexicographic by key. + """ + a2a = _a2a(manifest.get("a2a")) + serving = a2a.get("servingRoles") + ordered = list(serving) if serving else sorted(roles) + + out: list[str] = [] + for key in ordered: + role = roles.get(key) + if role is None: + if serving: + raise CardProjectionError(f"servingRoles names {key!r} but no such role.json") + continue + if key == "_base": + continue + if role.get("coordinator"): + continue + if is_exec_role(role): + continue # exec roles project through project_exec_cards, not the product card + r_a2a = _a2a(role.get("a2a")) + if visibility == "public": + if r_a2a.get("publish") is False: + continue + if r_a2a.get("extendedOnly") is True: + continue + out.append(key) + return out + + +# --------------------------------------------------------------------------- # +# card assembly +# --------------------------------------------------------------------------- # +def _base_card( + *, + name: str, + description: str, + version: str, + doc_url: str, + interface: dict, + external: bool, + issuer_url: str, + skills: list[dict], + icon_url: str | None, +) -> dict: + card: dict[str, Any] = { + "name": name, + "description": description, + "provider": {"organization": PROVIDER_ORG, "url": PROVIDER_URL}, + "version": version, + "documentationUrl": doc_url, + "supportedInterfaces": [interface], + "capabilities": _capabilities(), + "securitySchemes": _security_schemes(external, issuer_url), + "securityRequirements": [{"fuze-oidc": []}], + "defaultInputModes": list(DEFAULT_INPUT_MODES), + "defaultOutputModes": list(DEFAULT_OUTPUT_MODES), + "skills": skills, + } + if icon_url: + card["iconUrl"] = icon_url + return card + + +def _product_description(manifest: dict, roles: dict[str, dict], serving: Iterable[str]) -> str: + repo = repo_name(manifest["repo"]) + sentence = ( + f"{repo} agent. Give it a goal in its domain and it will accomplish it using its " + f"own tooling and credentials; callers need no tools of their own." + ) + expert = manifest.get("expert") + if expert: + sentence += f" Consults the {expert} for repo context." + return sentence + + +def project_product_card( + manifest: dict, + roles: dict[str, dict], + *, + issuer_url: str = DEFAULT_ISSUER, + version: str | None = None, + visibility: str = "public", + external: bool | None = None, + sign: bool = True, + signer: Signer | None = None, +) -> dict: + """Project a product/infra repo into ONE Agent Card (card-projection.md §1–4).""" + repo = repo_name(manifest["repo"]) + a2a = _a2a(manifest.get("a2a")) + external = a2a.get("external", False) if external is None else external + + serving = select_serving_roles(manifest, roles, visibility=visibility) + if not serving: + raise CardProjectionError( + f"{repo}: no serving roles project to skills; a card needs at least one skill" + ) + skills = [project_skill(k, roles[k], manifest) for k in serving] + + card = _base_card( + name=f"{repo} agent", + description=_product_description(manifest, roles, serving), + version=version or contract_version(), + doc_url=_doc_url(manifest), + interface=_interface(repo, external=external, repo_slug=repo), + external=external, + issuer_url=issuer_url, + skills=skills, + icon_url=a2a.get("iconUrl"), + ) + return sign_card(card, signer) if sign else card + + +def _exec_description(role_key: str, role: dict) -> str: + base = role.get("description") or ( + f"Executive {role_key.upper()} authority agent for FuzeOne." + ) + return ( + f"{base} Binding decisions pause the task in TASK_STATE_INPUT_REQUIRED while a " + f"human is reached via their digital persona — callers must not impose short timeouts." + ) + + +def project_exec_card( + role_key: str, + role: dict, + manifest: dict, + *, + issuer_url: str = DEFAULT_ISSUER, + version: str | None = None, + sign: bool = True, + signer: Signer | None = None, +) -> dict: + """Project ONE exec role into its OWN card with tenant ``Exec-``. + + Exec deltas (card-projection.md §5): one card per exec role; name + ``"FuzeOne agent"``; ``external`` forced false; tags always include + ``executive`` and the role key. + """ + tenant = f"Exec-{role_key}" + # Exec skills carry the "exec" tier tag regardless of the source repo's tier + # (card-projection.md §5.3: tags always include the exec tier + "executive"). + exec_manifest = {**manifest, "tier": "exec"} + skill = project_skill(role_key, role, exec_manifest) + card = _base_card( + name=f"{PROVIDER_ORG} {role_key.upper()} agent", + description=_exec_description(role_key, role), + version=version or contract_version(), + doc_url=_doc_url(manifest), + interface=_interface(tenant, external=False, repo_slug=repo_name(manifest["repo"])), + external=False, # exec agents are never published externally + issuer_url=issuer_url, + skills=[skill], + icon_url=None, + ) + return sign_card(card, signer) if sign else card + + +def generate_cards( + manifest: dict, + roles: dict[str, dict], + *, + issuer_url: str = DEFAULT_ISSUER, + version: str | None = None, + sign: bool = True, + signer: Signer | None = None, +) -> list[tuple[str, dict]]: + """Every card a repo publishes, as ``(tenant, card)`` pairs. + + A ``tier == "exec"`` repo, or any repo that carries exec roles + (``metadata.tier == "executive"``), yields one card per exec role. A + product/infra repo yields a single card keyed by its repo name. A repo may yield + both (product skills + exec roles) — each exec role is always its own card. + """ + out: list[tuple[str, dict]] = [] + exec_keys = sorted(k for k, r in roles.items() if is_exec_role(r)) + + product_roles = select_serving_roles(manifest, roles, visibility="public") + if product_roles: + card = project_product_card( + manifest, roles, issuer_url=issuer_url, version=version, sign=sign, signer=signer + ) + out.append((repo_name(manifest["repo"]), card)) + + for key in exec_keys: + card = project_exec_card( + key, roles[key], manifest, issuer_url=issuer_url, version=version, sign=sign, signer=signer + ) + out.append((f"Exec-{key}", card)) + return out + + +# --------------------------------------------------------------------------- # +# signing (mechanism only; key material is injected by devops) +# --------------------------------------------------------------------------- # +def canonicalize(card: dict) -> bytes: + """RFC 8785 (JCS)-compatible canonical bytes of the card EXCLUDING ``signatures``. + + A card's value types are strings, booleans, arrays and objects (no floats), so + canonical JSON with lexicographically sorted keys and no insignificant whitespace + is a faithful JCS encoding for this document. + """ + body = {k: v for k, v in card.items() if k != "signatures"} + return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + + +def sign_card(card: dict, signer: Signer | None = None) -> dict: + """Return a copy of ``card`` with a non-empty ``signatures[]`` (Fuze profile §6). + + With no ``signer`` a deterministic placeholder is emitted so the card validates + against the profile in tests and local runs; production injects a real JWS signer. + """ + signed = {k: v for k, v in card.items() if k != "signatures"} + if signer is None: + signed["signatures"] = [dict(_PLACEHOLDER_SIGNATURE)] + else: + signed["signatures"] = [signer(canonicalize(card))] + return signed diff --git a/agent-templates/a2a/loader.py b/agent-templates/a2a/loader.py new file mode 100644 index 0000000..c2070c9 --- /dev/null +++ b/agent-templates/a2a/loader.py @@ -0,0 +1,34 @@ +"""Read a repo's projection inputs from disk. + +Loads ``.fuze/manifest.json`` and every ``agent-templates/roles/*/role.json`` from a +checked-out repo tree (GitOps: the git ref is the source of truth, never live state). +Role ``extends`` inheritance is intentionally NOT flattened here — the projection reads +only ``role``, ``name``, ``description``, ``services``, ``metadata``, ``coordinator`` +and the optional ``a2a`` block, none of which are inherited from ``_base`` in practice. +""" +from __future__ import annotations + +import json +from pathlib import Path + + +def load_manifest(repo_root: str | Path) -> dict: + path = Path(repo_root) / ".fuze" / "manifest.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def load_roles(repo_root: str | Path) -> dict[str, dict]: + roles_dir = Path(repo_root) / "agent-templates" / "roles" + roles: dict[str, dict] = {} + if not roles_dir.is_dir(): + return roles + for child in sorted(roles_dir.iterdir()): + role_file = child / "role.json" + if role_file.is_file(): + role = json.loads(role_file.read_text(encoding="utf-8")) + roles[role.get("role", child.name)] = role + return roles + + +def load_repo(repo_root: str | Path) -> tuple[dict, dict[str, dict]]: + return load_manifest(repo_root), load_roles(repo_root) diff --git a/agent-templates/a2a/tests/__init__.py b/agent-templates/a2a/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent-templates/a2a/tests/conftest.py b/agent-templates/a2a/tests/conftest.py new file mode 100644 index 0000000..6cc9fd6 --- /dev/null +++ b/agent-templates/a2a/tests/conftest.py @@ -0,0 +1,47 @@ +"""Shared test fixtures. + +Adds the ``agent-templates`` directory to ``sys.path`` so ``import a2a...`` and the +contract client package resolve without an editable install, mirroring how the server +process is launched. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +_A2A_PKG = Path(__file__).resolve().parents[1] # .../agent-templates/a2a +_AGENT_TEMPLATES = _A2A_PKG.parent # .../agent-templates +for p in (str(_AGENT_TEMPLATES),): + if p not in sys.path: + sys.path.insert(0, p) + +FIXTURES = _A2A_PKG / "tests" / "fixtures" +CONTRACT = _AGENT_TEMPLATES / "contracts" / "a2a" / "v1" +EXAMPLES = CONTRACT / "examples" + + +def _read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +@pytest.fixture +def fuzeplan_repo() -> Path: + return FIXTURES / "fuzeplan" + + +@pytest.fixture +def fuzeinfra_repo() -> Path: + return FIXTURES / "fuzeinfra" + + +@pytest.fixture +def fuzeplan_example() -> dict: + return _read_json(EXAMPLES / "fuzeplan.agent-card.json") + + +@pytest.fixture +def exec_cto_example() -> dict: + return _read_json(EXAMPLES / "exec-cto.agent-card.json") diff --git a/agent-templates/a2a/tests/fixtures/fuzeinfra/.fuze/manifest.json b/agent-templates/a2a/tests/fixtures/fuzeinfra/.fuze/manifest.json new file mode 100644 index 0000000..d4222ff --- /dev/null +++ b/agent-templates/a2a/tests/fixtures/fuzeinfra/.fuze/manifest.json @@ -0,0 +1,7 @@ +{ + "repo": "izzywdev/FuzeInfra", + "tier": "infra", + "expert": "fuzeinfra-expert", + "providesTo": ["FuzeSales"], + "a2a": { "enabled": true } +} diff --git a/agent-templates/a2a/tests/fixtures/fuzeinfra/agent-templates/roles/cto/role.json b/agent-templates/a2a/tests/fixtures/fuzeinfra/agent-templates/roles/cto/role.json new file mode 100644 index 0000000..a6e2381 --- /dev/null +++ b/agent-templates/a2a/tests/fixtures/fuzeinfra/agent-templates/roles/cto/role.json @@ -0,0 +1,16 @@ +{ + "role": "cto", + "name": "FuzeOne CTO", + "description": "Technical strategy, architecture standards and engineering governance across FuzeOne. Reviews cross-repo architecture, rules on standards conflicts, and escalates binding technical decisions to the human CTO.", + "services": { "github": "write", "k8s": "none", "cloud": "write" }, + "metadata": { "tier": "executive" }, + "a2a": { + "tags": ["architecture", "governance"], + "scopes": ["a2a.exec.escalate"], + "examples": [ + "We need to choose between adopting the open A2A standard and extending our bespoke protocol. Rule on it.", + "Does introducing a second message broker conflict with the platform standards?", + "Escalate this breaking-change decision to the human CTO for sign-off." + ] + } +} diff --git a/agent-templates/a2a/tests/fixtures/fuzeplan/.fuze/manifest.json b/agent-templates/a2a/tests/fixtures/fuzeplan/.fuze/manifest.json new file mode 100644 index 0000000..15e0a5b --- /dev/null +++ b/agent-templates/a2a/tests/fixtures/fuzeplan/.fuze/manifest.json @@ -0,0 +1,12 @@ +{ + "repo": "izzywdev/FuzePlan", + "tier": "product", + "expert": "fuzeplan-expert", + "providesTo": ["FuzeSales", "FuzeService", "FuzeExecutive"], + "dependsOn": ["FuzeContact", "FuzeBI"], + "a2a": { + "enabled": true, + "servingRoles": ["product-manager", "ux-designer"], + "entryRole": "product-manager" + } +} diff --git a/agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/product-manager/role.json b/agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/product-manager/role.json new file mode 100644 index 0000000..015bd90 --- /dev/null +++ b/agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/product-manager/role.json @@ -0,0 +1,14 @@ +{ + "role": "product-manager", + "name": "FuzePlan product-manager", + "description": "Turns requirements and discussion into well-formed tickets, epics and sprint plans in Jira, and reports delivery status.", + "services": { "github": "write", "k8s": "none", "cloud": "none" }, + "a2a": { + "tags": ["planning", "jira"], + "examples": [ + "Create Jira tickets for the requirements in this discussion.", + "Break this epic into sprint-sized stories with acceptance criteria.", + "What is the current delivery status of the billing epic?" + ] + } +} diff --git a/agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/ux-designer/role.json b/agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/ux-designer/role.json new file mode 100644 index 0000000..8edfb7e --- /dev/null +++ b/agent-templates/a2a/tests/fixtures/fuzeplan/agent-templates/roles/ux-designer/role.json @@ -0,0 +1,10 @@ +{ + "role": "ux-designer", + "name": "FuzePlan ux-designer", + "description": "Produces user-flow and interaction specifications for a described feature, aligned to the family design system.", + "services": { "github": "write", "k8s": "none", "cloud": "none" }, + "a2a": { + "tags": ["design"], + "examples": ["Draft the user flow for self-registration with email OTP."] + } +} diff --git a/agent-templates/a2a/tests/test_card_generator.py b/agent-templates/a2a/tests/test_card_generator.py new file mode 100644 index 0000000..6490a96 --- /dev/null +++ b/agent-templates/a2a/tests/test_card_generator.py @@ -0,0 +1,208 @@ +"""Unit tests for the Agent Card projection (card-projection.md). + +These assert the NORMATIVE projection rules and schema/profile conformance. Where the +frozen ``examples/*.json`` are hand-authored illustrations that differ from a rule +(notably tag ORDER, and the composed ``description`` prose), we assert the invariant +the contract actually fixes — schema validity and the derived tag SET — not byte +equality with the example. +""" +from __future__ import annotations + +import pytest +from a2a import card_generator as cg +from a2a.loader import load_repo +from a2a.validation import card_errors, validate_card +from fuze_a2a_client.card_models import FuzeA2AAgentCard + + +# --------------------------------------------------------------------------- # +# product projection (FuzePlan) +# --------------------------------------------------------------------------- # +def test_product_card_validates_against_schema_and_profile(fuzeplan_repo): + manifest, roles = load_repo(fuzeplan_repo) + card = cg.project_product_card(manifest, roles) + assert card_errors(card) == [] + # Also round-trips through the generated pydantic card model. + FuzeA2AAgentCard.model_validate(card) + + +def test_product_card_identity_and_interface(fuzeplan_repo): + manifest, roles = load_repo(fuzeplan_repo) + card = cg.project_product_card(manifest, roles) + + assert card["name"] == "FuzePlan agent" + assert card["provider"] == {"organization": "FuzeOne", "url": "https://github.com/izzywdev"} + assert card["version"] == "1.0.0" + assert card["documentationUrl"] == "https://github.com/izzywdev/FuzePlan" + + iface = card["supportedInterfaces"] + assert len(iface) == 1 + assert iface[0]["url"] == cg.IN_CLUSTER_URL + assert iface[0]["protocolBinding"] == "JSONRPC" + assert iface[0]["protocolVersion"] == "1.0" + assert iface[0]["tenant"] == "FuzePlan" + + +def test_product_capabilities_and_security(fuzeplan_repo): + manifest, roles = load_repo(fuzeplan_repo) + card = cg.project_product_card(manifest, roles) + assert card["capabilities"] == { + "streaming": True, + "pushNotifications": False, + "extendedAgentCard": True, + } + # in-cluster card declares both oidc and mtls + assert set(card["securitySchemes"]) == {"fuze-oidc", "fuze-mtls"} + assert card["securityRequirements"] == [{"fuze-oidc": []}] + assert card["signatures"], "profile requires a non-empty signatures[]" + + +def test_product_skills_match_example_derived_fields(fuzeplan_repo, fuzeplan_example): + manifest, roles = load_repo(fuzeplan_repo) + card = cg.project_product_card(manifest, roles) + + got = {s["id"]: s for s in card["skills"]} + want = {s["id"]: s for s in fuzeplan_example["skills"]} + assert set(got) == set(want) == {"product-manager", "ux-designer"} + + for sid in want: + assert got[sid]["name"] == want[sid]["name"] + assert got[sid]["description"] == want[sid]["description"] + assert got[sid].get("examples") == want[sid].get("examples") + # tag SET equality (order is deterministic-sorted, not example order) + assert set(got[sid]["tags"]) == set(want[sid]["tags"]) + # our tags are sorted deterministically + assert got[sid]["tags"] == sorted(got[sid]["tags"]) + + +def test_serving_roles_order_is_explicit(fuzeplan_repo): + manifest, roles = load_repo(fuzeplan_repo) + # servingRoles fixes the order + card = cg.project_product_card(manifest, roles) + assert [s["id"] for s in card["skills"]] == ["product-manager", "ux-designer"] + + # without servingRoles, order is lexicographic by role key + m2 = {k: v for k, v in manifest.items() if k != "a2a"} + card2 = cg.project_product_card(m2, roles) + assert [s["id"] for s in card2["skills"]] == ["product-manager", "ux-designer"] + + +# --------------------------------------------------------------------------- # +# exec projection (FuzeInfra cto) +# --------------------------------------------------------------------------- # +def test_exec_card_validates(fuzeinfra_repo): + manifest, roles = load_repo(fuzeinfra_repo) + card = cg.project_exec_card("cto", roles["cto"], manifest) + assert card_errors(card) == [] + FuzeA2AAgentCard.model_validate(card) + + +def test_exec_card_identity_tenant_and_external(fuzeinfra_repo): + manifest, roles = load_repo(fuzeinfra_repo) + card = cg.project_exec_card("cto", roles["cto"], manifest) + + assert card["name"] == "FuzeOne CTO agent" + assert card["supportedInterfaces"][0]["tenant"] == "Exec-cto" + # exec is never external -> in-cluster url + assert card["supportedInterfaces"][0]["url"] == cg.IN_CLUSTER_URL + + +def test_exec_skill_tags_and_scopes(fuzeinfra_repo, exec_cto_example): + manifest, roles = load_repo(fuzeinfra_repo) + card = cg.project_exec_card("cto", roles["cto"], manifest) + skill = card["skills"][0] + want = exec_cto_example["skills"][0] + + assert skill["id"] == "cto" + assert skill["name"] == want["name"] + # exec tag set: role key, exec tier, executive, services, a2a.tags + assert set(skill["tags"]) == set(want["tags"]) + assert "executive" in skill["tags"] and "exec" in skill["tags"] and "cto" in skill["tags"] + assert skill["securityRequirements"] == [{"fuze-oidc": ["a2a.exec.escalate"]}] + + +def test_generate_cards_yields_exec_card_for_exec_role(fuzeinfra_repo): + manifest, roles = load_repo(fuzeinfra_repo) + cards = cg.generate_cards(manifest, roles) + tenants = [t for t, _ in cards] + # FuzeInfra fixture has only the exec cto role -> one exec card, no product card + assert tenants == ["Exec-cto"] + + +# --------------------------------------------------------------------------- # +# invariants and failure modes +# --------------------------------------------------------------------------- # +def test_determinism_byte_identical(fuzeplan_repo): + manifest, roles = load_repo(fuzeplan_repo) + import json + + a = json.dumps(cg.project_product_card(manifest, roles), sort_keys=True) + b = json.dumps(cg.project_product_card(manifest, roles), sort_keys=True) + assert a == b + + +def test_missing_description_fails_loudly(): + manifest = {"repo": "izzywdev/FuzeX", "tier": "product", "a2a": {"servingRoles": ["r"]}} + roles = {"r": {"role": "r", "name": "FuzeX r"}} # no description + with pytest.raises(cg.CardProjectionError): + cg.project_product_card(manifest, roles) + + +def test_base_and_coordinator_roles_excluded(): + manifest = {"repo": "izzywdev/FuzeX", "tier": "product"} + roles = { + "_base": {"role": "_base", "name": "base", "description": "d"}, + "coord": {"role": "coord", "name": "c", "description": "d", "coordinator": True}, + "worker": {"role": "worker", "name": "FuzeX worker", "description": "does work"}, + } + serving = cg.select_serving_roles(manifest, roles) + assert serving == ["worker"] + + +def test_publish_false_hidden_from_public_shown_on_extended(): + manifest = {"repo": "izzywdev/FuzeX", "tier": "product"} + roles = { + "open": {"role": "open", "name": "FuzeX open", "description": "d"}, + "secret": { + "role": "secret", + "name": "FuzeX secret", + "description": "d", + "a2a": {"publish": False}, + }, + } + assert cg.select_serving_roles(manifest, roles, visibility="public") == ["open"] + assert cg.select_serving_roles(manifest, roles, visibility="extended") == ["open", "secret"] + + +def test_external_card_uses_https_url_and_no_mtls(): + manifest = {"repo": "izzywdev/FuzeX", "tier": "product", "a2a": {"external": True}} + roles = {"worker": {"role": "worker", "name": "FuzeX worker", "description": "d"}} + card = cg.project_product_card(manifest, roles) + url = card["supportedInterfaces"][0]["url"] + assert url.startswith("https://a2a.fuzex.prod.fuzefront.com/rpc") + assert "fuze-mtls" not in card["securitySchemes"] + + +def test_signing_placeholder_and_injected_signer(): + manifest = {"repo": "izzywdev/FuzeX", "tier": "product"} + roles = {"worker": {"role": "worker", "name": "FuzeX worker", "description": "d"}} + + default = cg.project_product_card(manifest, roles) + assert default["signatures"] == [cg._PLACEHOLDER_SIGNATURE] + + seen = {} + + def signer(payload: bytes) -> dict: + seen["payload"] = payload + return {"protected": "hdr", "signature": "sig"} + + signed = cg.project_product_card(manifest, roles, signer=signer) + assert signed["signatures"] == [{"protected": "hdr", "signature": "sig"}] + # signer received canonical bytes that EXCLUDE signatures + assert b"signatures" not in seen["payload"] + + +def test_canonicalize_excludes_signatures_and_is_stable(): + card = {"b": 1, "a": 2, "signatures": [{"x": 1}]} + out = cg.canonicalize(card) + assert out == b'{"a":2,"b":1}' diff --git a/agent-templates/a2a/validation.py b/agent-templates/a2a/validation.py new file mode 100644 index 0000000..5330f8e --- /dev/null +++ b/agent-templates/a2a/validation.py @@ -0,0 +1,49 @@ +"""Validate a generated card against the frozen contract schemas. + +A card MUST validate against BOTH ``agent-card.schema.json`` (the open A2A shape) and +``fuze-profile.schema.json`` (the family constraints layered on top). The profile +``$ref``s the card schema by relative filename, so we resolve refs against the schema +directory. +""" +from __future__ import annotations + +import json +from functools import lru_cache +from typing import Any + +from jsonschema import Draft202012Validator +from jsonschema.validators import RefResolver + +from ._contract import SCHEMA_DIR + + +@lru_cache(maxsize=None) +def _schema(name: str) -> dict: + return json.loads((SCHEMA_DIR / name).read_text(encoding="utf-8")) + + +def _validator(root_schema_name: str) -> Draft202012Validator: + schema = _schema(root_schema_name) + # Resolve sibling $ref filenames (agent-card.schema.json) against the schema dir. + store = { + s["$id"]: s + for s in (_schema(p.name) for p in SCHEMA_DIR.glob("*.json")) + if "$id" in s + } + base = SCHEMA_DIR.as_uri() + "/" + resolver = RefResolver(base_uri=base, referrer=schema, store=store) + return Draft202012Validator(schema, resolver=resolver) + + +def validate_card(card: dict[str, Any]) -> None: + """Raise ``jsonschema.ValidationError`` if the card violates schema or profile.""" + _validator("agent-card.schema.json").validate(card) + _validator("fuze-profile.schema.json").validate(card) + + +def card_errors(card: dict[str, Any]) -> list[str]: + errs: list[str] = [] + for name in ("agent-card.schema.json", "fuze-profile.schema.json"): + for e in _validator(name).iter_errors(card): + errs.append(f"[{name}] {'/'.join(str(p) for p in e.absolute_path)}: {e.message}") + return errs From f9273aa6e18fb6d5526b8bd8abb2f33247251c95 Mon Sep 17 00:00:00 2001 From: "Izzy Weinberg (backend-engineer)" Date: Wed, 22 Jul 2026 19:29:07 +0300 Subject: [PATCH 3/6] feat(a2a): task-state mapper + wire errors + callee authz (32 more unit tests) [skip ci] - task_mapper: run_until_block -> A2A Task; INPUT vs AUTH_REQUIRED classifier. - wire_errors: A2AError -> JSON-RPC error object with ProtoJSON data array. - authz: fail-closed providesTo allowlist; dependsOn grants nothing. Co-Authored-By: Claude Claude-Session-Id: 17fb89fd-3579-433b-a6c4-9c9e7f3ec549 --- agent-templates/a2a/authz.py | 120 ++++++++++++ agent-templates/a2a/task_mapper.py | 182 ++++++++++++++++++ agent-templates/a2a/tests/test_authz.py | 87 +++++++++ agent-templates/a2a/tests/test_task_mapper.py | 90 +++++++++ agent-templates/a2a/wire_errors.py | 82 ++++++++ 5 files changed, 561 insertions(+) create mode 100644 agent-templates/a2a/authz.py create mode 100644 agent-templates/a2a/task_mapper.py create mode 100644 agent-templates/a2a/tests/test_authz.py create mode 100644 agent-templates/a2a/tests/test_task_mapper.py create mode 100644 agent-templates/a2a/wire_errors.py diff --git a/agent-templates/a2a/authz.py b/agent-templates/a2a/authz.py new file mode 100644 index 0000000..c80afd7 --- /dev/null +++ b/agent-templates/a2a/authz.py @@ -0,0 +1,120 @@ +"""Callee-enforced authorization (authz.md). + + The CALLEE enforces. The caller is opaque and untrusted. + +Nothing in the request BODY is trusted for authorization — not ``tenant``, not +``metadata``, not a self-declared caller name. The only trusted input is the +authenticated identity from the transport credential (``AuthContext.caller``). + +The grant lives in the CALLEE's ``.fuze/manifest.json`` ``providesTo`` list. It is +FAIL-CLOSED: absent means unconfigured, which is DENY (never permissive) — this is +load-bearing, because ``providesTo`` is absent on most repos at freeze time and +treating absent as allow would silently open them to every caller. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from enum import Enum + +_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") +_EXEC_PRINCIPAL_RE = re.compile(r"^Exec-[a-z0-9_-]+$") + + +class Decision(Enum): + ALLOW = "allow" + DENY = "deny" + #: allowlisted caller, but the token lacks a scope it could plausibly obtain. + #: The one legitimate AUTH_REQUIRED at authz step 5 (authz.md §4). + SCOPE_REQUIRED = "scope_required" + + +@dataclass(frozen=True) +class AuthContext: + """Resolved, TRUSTED identity from the transport credential (never the body).""" + + caller: str + scopes: frozenset[str] = frozenset() + authenticated: bool = True + + +@dataclass(frozen=True) +class AuthzResult: + decision: Decision + #: internal reason for the callee's LOGS only — never put on the wire (authz.md §6). + reason: str = "" + missing_scopes: tuple[str, ...] = field(default_factory=tuple) + + @property + def allowed(self) -> bool: + return self.decision is Decision.ALLOW + + +def valid_caller_identity(caller: str | None) -> bool: + """A caller identity MUST be a bare repo name or an exec principal (authz.md §2).""" + if not caller: + return False + return bool(_REPO_NAME_RE.match(caller)) or bool(_EXEC_PRINCIPAL_RE.match(caller)) + + +def _skill_scopes(role: dict | None) -> list[str]: + if not role: + return [] + return list((role.get("a2a") or {}).get("scopes") or []) + + +def _skill_publish(role: dict | None) -> tuple[bool, bool]: + a2a = (role or {}).get("a2a") or {} + publish = a2a.get("publish", True) + extended_only = a2a.get("extendedOnly", False) + return bool(publish), bool(extended_only) + + +def authorize( + ctx: AuthContext, + callee_manifest: dict | None, + *, + skill_role: dict | None = None, + skill_known: bool = True, +) -> AuthzResult: + """Run the callee-side decision procedure (authz.md §3). + + Steps: + 1. authentication (checked upstream; a false ``ctx.authenticated`` => DENY). + 2. caller := trusted identity (already resolved into ``ctx``). + 3. callee := tenant -> repo (a missing/None manifest => DENY as not-found). + 4. providesTo: ABSENT -> DENY, [] -> DENY, caller not in -> DENY. + 5. skill: unknown/unpublished-to-caller -> DENY; a2a.scopes present but token + lacks them -> SCOPE_REQUIRED (the sole legitimate AUTH_REQUIRED). + """ + if not ctx.authenticated or not valid_caller_identity(ctx.caller): + return AuthzResult(Decision.DENY, "unauthenticated or invalid caller identity") + + if callee_manifest is None: + return AuthzResult(Decision.DENY, "unknown callee/tenant") + + provides_to = callee_manifest.get("providesTo") + if provides_to is None: + return AuthzResult(Decision.DENY, "providesTo absent -> fail closed") + if not isinstance(provides_to, list) or len(provides_to) == 0: + return AuthzResult(Decision.DENY, "providesTo empty -> no agent callers") + if ctx.caller not in provides_to: + return AuthzResult(Decision.DENY, f"caller {ctx.caller!r} not in providesTo") + + if not skill_known: + return AuthzResult(Decision.DENY, "unknown skill") + + _, extended_only = _skill_publish(skill_role) + # (visibility of the skill is handled at card projection; here we only gate dispatch) + + required = set(_skill_scopes(skill_role)) + if required: + missing = tuple(sorted(required - set(ctx.scopes))) + if missing: + return AuthzResult( + Decision.SCOPE_REQUIRED, + f"missing scopes {missing}", + missing_scopes=missing, + ) + + return AuthzResult(Decision.ALLOW, "authorized") diff --git a/agent-templates/a2a/task_mapper.py b/agent-templates/a2a/task_mapper.py new file mode 100644 index 0000000..2f1774a --- /dev/null +++ b/agent-templates/a2a/task_mapper.py @@ -0,0 +1,182 @@ +"""Map Managed-Agents runtime results to A2A wire objects (state-mapping.md §3). + +THE CORE TABLE. This module is the entire reason the A2A pod is called an *adapter*: +it translates the ``{'text','status','pending'}`` returned by +``AgentProvider.run_until_block`` into an A2A ``Task``. It holds no state and drives +no execution — if you are adding a scheduler or a transcript store here, you have +missed state-mapping.md. + + provider status A2A TaskState + --------------- ----------------------------- + idle TASK_STATE_COMPLETED (terminal) + error TASK_STATE_FAILED (terminal) + blocked + tool TASK_STATE_INPUT_REQUIRED (interrupted) + blocked + cred TASK_STATE_AUTH_REQUIRED (interrupted) + +``TASK_STATE_UNSPECIFIED`` MUST never be emitted. +""" +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timezone +from typing import Any + +from ._contract import ( + Artifact, + Message, + Part, + Role, + Task, + TaskState, + TaskStatus, +) + +# Signals in a pending tool descriptor that mark a pause as a CREDENTIAL / AUTH grant +# request rather than an ordinary decision. Discriminating these two is the most +# likely adapter bug (state-mapping.md §3), so the rule is explicit and testable. +# No word boundaries: tool descriptors glue words with underscores (``oauth_authorize``, +# ``get_api_key``, ``use_token``), where ``\b`` would not fire. Substring match is the +# right granularity for the ``name(input)`` descriptor shape the driver produces. +_AUTH_SIGNALS = re.compile( + r"(credential|auth|oauth|token|api[_-]?key|apikey|secret|vault|" + r"login|sign[_-]?in|access[_-]?grant|scope)", + re.IGNORECASE, +) + + +def now_iso() -> str: + """ISO 8601, UTC, ``Z`` suffix (binding.md).""" + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +def agent_message(text: str, *, context_id: str | None = None, task_id: str | None = None) -> Message: + """An agent-role Message carrying a single text part.""" + return Message( + messageId=str(uuid.uuid4()), + role=Role.ROLE_AGENT, + parts=[Part(root={"text": text or ""})], + contextId=context_id, + taskId=task_id, + ) + + +# --------------------------------------------------------------------------- # +# pause classification (state-mapping.md §3/§4) +# --------------------------------------------------------------------------- # +def _pending_descriptions(pending: dict | None) -> list[str]: + if not pending: + return [] + tools = pending.get("tools") or {} + return [str(v) for v in tools.values()] + + +def classify_pause(pending: dict | None) -> TaskState: + """A ``blocked`` pause is AUTH_REQUIRED iff what is missing is a credential/grant; + otherwise it is the ordinary ``always_ask`` decision pause -> INPUT_REQUIRED. + + We branch on the ``pending`` payload (never on the status string alone), per + state-mapping.md §3. The default is INPUT_REQUIRED — the common case. + """ + for desc in _pending_descriptions(pending): + if _AUTH_SIGNALS.search(desc): + return TaskState.TASK_STATE_AUTH_REQUIRED + return TaskState.TASK_STATE_INPUT_REQUIRED + + +def pending_tool_use_id(pending: dict | None) -> str | None: + """The tool_use_id the caller's reply resolves via ``confirm_tool`` (state-mapping §4).""" + if not pending: + return None + ids = pending.get("event_ids") or [] + if ids: + return ids[0] + tools = pending.get("tools") or {} + return next(iter(tools), None) + + +def _pause_reason(pending: dict | None, text: str) -> str: + """Human-facing 'what is being asked and why' — REQUIRED for INPUT/AUTH_REQUIRED. + + Prefers the agent's own text (it explains the ask); falls back to the pending tool + descriptor so a pause is never uninterpretable (a pause a caller cannot read is a + hang, state-mapping.md §4). + """ + if text and text.strip(): + return text.strip() + descs = _pending_descriptions(pending) + if descs: + return "Awaiting approval for: " + "; ".join(descs) + return "The agent is waiting on input or authorization to continue." + + +# --------------------------------------------------------------------------- # +# terminal / interrupted task construction +# --------------------------------------------------------------------------- # +def _status(state: TaskState, message: Message | None = None) -> TaskStatus: + return TaskStatus(state=state, message=message, timestamp=now_iso()) + + +def map_result( + result: dict[str, Any], + *, + session_id: str, + context_id: str, + artifacts: list[Artifact] | None = None, +) -> Task: + """Map a ``run_until_block`` result onto a settled/interrupted ``Task``. + + ``Task.id`` IS the session id (state-mapping.md §1); no side table. + """ + status = result.get("status") + text = result.get("text", "") or "" + pending = result.get("pending") + + if status == "idle": + state = TaskState.TASK_STATE_COMPLETED + message = agent_message(text, context_id=context_id, task_id=session_id) if text else None + elif status == "error": + state = TaskState.TASK_STATE_FAILED + message = agent_message(text or "The task failed.", context_id=context_id, task_id=session_id) + elif status == "blocked": + state = classify_pause(pending) + message = agent_message( + _pause_reason(pending, text), context_id=context_id, task_id=session_id + ) + else: # pragma: no cover - guarded upstream; never emit UNSPECIFIED + raise ValueError(f"unmappable provider status {status!r}") + + return Task( + id=session_id, + contextId=context_id, + status=_status(state, message), + artifacts=artifacts or None, + ) + + +def submitted_task(session_id: str, context_id: str) -> Task: + """Pre-dispatch resting state for ``returnImmediately: true`` (state-mapping §2).""" + return Task(id=session_id, contextId=context_id, status=_status(TaskState.TASK_STATE_SUBMITTED)) + + +def working_status(session_id: str, context_id: str) -> TaskStatus: + """Emitted on the stream while dispatched; not a resting state.""" + return _status(TaskState.TASK_STATE_WORKING) + + +def rejected_task(session_id: str, context_id: str) -> Task: + """Terminal REJECTED with a GENERIC message (authz.md §6 non-disclosure).""" + return Task( + id=session_id, + contextId=context_id, + status=_status( + TaskState.TASK_STATE_REJECTED, + agent_message("Not authorized.", context_id=context_id, task_id=session_id), + ), + ) + + +def canceled_task(session_id: str, context_id: str) -> Task: + return Task( + id=session_id, contextId=context_id, status=_status(TaskState.TASK_STATE_CANCELED) + ) diff --git a/agent-templates/a2a/tests/test_authz.py b/agent-templates/a2a/tests/test_authz.py new file mode 100644 index 0000000..32d7d2f --- /dev/null +++ b/agent-templates/a2a/tests/test_authz.py @@ -0,0 +1,87 @@ +"""Unit tests for the callee-enforced allowlist (authz.md). + +The security-critical property is FAIL-CLOSED: absent/empty ``providesTo`` denies. +""" +from __future__ import annotations + +import pytest +from a2a.authz import AuthContext, Decision, authorize, valid_caller_identity + + +def ctx(caller="FuzeSales", scopes=frozenset(), authenticated=True): + return AuthContext(caller=caller, scopes=scopes, authenticated=authenticated) + + +# --- step 4: providesTo allowlist ------------------------------------------ +def test_absent_providesto_denies_fail_closed(): + manifest = {"repo": "izzywdev/FuzePlan"} # no providesTo + assert authorize(ctx(), manifest).decision is Decision.DENY + + +def test_empty_providesto_denies(): + manifest = {"repo": "izzywdev/FuzePlan", "providesTo": []} + assert authorize(ctx(), manifest).decision is Decision.DENY + + +def test_caller_not_in_providesto_denies(): + manifest = {"repo": "izzywdev/FuzePlan", "providesTo": ["FuzeService"]} + assert authorize(ctx(caller="FuzeSales"), manifest).decision is Decision.DENY + + +def test_allowlisted_caller_allowed(): + manifest = {"repo": "izzywdev/FuzePlan", "providesTo": ["FuzeSales", "FuzeService"]} + res = authorize(ctx(caller="FuzeSales"), manifest) + assert res.decision is Decision.ALLOW and res.allowed + + +def test_dependson_grants_nothing(): + # A caller listing the callee in its OWN dependsOn must not self-grant. + manifest = {"repo": "izzywdev/FuzePlan", "dependsOn": ["FuzeSales"]} # no providesTo + assert authorize(ctx(caller="FuzeSales"), manifest).decision is Decision.DENY + + +# --- step 1/2: identity ----------------------------------------------------- +def test_unauthenticated_denied(): + manifest = {"repo": "izzywdev/FuzePlan", "providesTo": ["FuzeSales"]} + assert authorize(ctx(authenticated=False), manifest).decision is Decision.DENY + + +def test_invalid_caller_identity_denied(): + manifest = {"repo": "izzywdev/FuzePlan", "providesTo": ["FuzeSales"]} + assert authorize(ctx(caller="not a repo!!"), manifest).decision is Decision.DENY + + +@pytest.mark.parametrize( + "caller,ok", + [("FuzeSales", True), ("Exec-cto", True), ("izzywdev/FuzeSales", False), ("", False), ("bad name", False)], +) +def test_valid_caller_identity(caller, ok): + assert valid_caller_identity(caller) is ok + + +# --- step 3: unknown callee ------------------------------------------------- +def test_unknown_callee_denied(): + assert authorize(ctx(), None).decision is Decision.DENY + + +# --- step 5: skill + scopes ------------------------------------------------- +def test_unknown_skill_denied(): + manifest = {"repo": "izzywdev/FuzePlan", "providesTo": ["FuzeSales"]} + assert authorize(ctx(caller="FuzeSales"), manifest, skill_known=False).decision is Decision.DENY + + +def test_missing_scope_is_scope_required_not_deny(): + manifest = {"repo": "izzywdev/FuzeInfra", "providesTo": ["FuzeSales"]} + role = {"role": "cto", "a2a": {"scopes": ["a2a.exec.escalate"]}} + res = authorize(ctx(caller="FuzeSales", scopes=frozenset()), manifest, skill_role=role) + assert res.decision is Decision.SCOPE_REQUIRED + assert res.missing_scopes == ("a2a.exec.escalate",) + + +def test_present_scope_allows(): + manifest = {"repo": "izzywdev/FuzeInfra", "providesTo": ["FuzeSales"]} + role = {"role": "cto", "a2a": {"scopes": ["a2a.exec.escalate"]}} + res = authorize( + ctx(caller="FuzeSales", scopes=frozenset({"a2a.exec.escalate"})), manifest, skill_role=role + ) + assert res.decision is Decision.ALLOW diff --git a/agent-templates/a2a/tests/test_task_mapper.py b/agent-templates/a2a/tests/test_task_mapper.py new file mode 100644 index 0000000..c3119ee --- /dev/null +++ b/agent-templates/a2a/tests/test_task_mapper.py @@ -0,0 +1,90 @@ +"""Unit tests for the run_until_block -> A2A Task mapping (state-mapping.md §3/§4).""" +from __future__ import annotations + +import pytest +from a2a import task_mapper as tm +from a2a._contract import TaskState + + +def _blocked(desc: str) -> dict: + return {"text": "", "status": "blocked", "pending": {"event_ids": ["e1"], "tools": {"e1": desc}}} + + +# --- the core status table -------------------------------------------------- +def test_idle_maps_to_completed_with_agent_message(): + task = tm.map_result({"text": "all done", "status": "idle", "pending": None}, session_id="s", context_id="c") + assert task.status.state == TaskState.TASK_STATE_COMPLETED + assert task.id == "s" and task.contextId == "c" + assert task.status.message.role.value == "ROLE_AGENT" + assert task.status.message.parts[0].root.text == "all done" + assert task.status.timestamp is not None + + +def test_error_maps_to_failed(): + task = tm.map_result({"text": "kaboom", "status": "error", "pending": None}, session_id="s", context_id="c") + assert task.status.state == TaskState.TASK_STATE_FAILED + assert "kaboom" in task.status.message.parts[0].root.text + + +def test_blocked_tool_decision_is_input_required(): + task = tm.map_result(_blocked('open_pr({"target":"prod"})'), session_id="s", context_id="c") + assert task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + # message REQUIRED for interrupted states + assert task.status.message is not None + + +def test_blocked_credential_is_auth_required(): + task = tm.map_result(_blocked('fetch_credential({"vault":"atlassian"})'), session_id="s", context_id="c") + assert task.status.state == TaskState.TASK_STATE_AUTH_REQUIRED + + +@pytest.mark.parametrize( + "desc,expected", + [ + ('create_tickets({"n":12})', TaskState.TASK_STATE_INPUT_REQUIRED), + ('open_pr({"target":"prod"})', TaskState.TASK_STATE_INPUT_REQUIRED), + ('oauth_authorize({"provider":"github"})', TaskState.TASK_STATE_AUTH_REQUIRED), + ('get_api_key({})', TaskState.TASK_STATE_AUTH_REQUIRED), + ('request access_grant for repo', TaskState.TASK_STATE_AUTH_REQUIRED), + ('use_token({})', TaskState.TASK_STATE_AUTH_REQUIRED), + ], +) +def test_pause_classifier(desc, expected): + pending = {"event_ids": ["e1"], "tools": {"e1": desc}} + assert tm.classify_pause(pending) == expected + + +def test_pause_reason_prefers_agent_text(): + pending = {"event_ids": ["e1"], "tools": {"e1": "open_pr({})"}} + task = tm.map_result({"text": "May I open this PR against prod?", "status": "blocked", "pending": pending}, session_id="s", context_id="c") + assert task.status.message.parts[0].root.text == "May I open this PR against prod?" + + +def test_pause_reason_falls_back_to_pending_when_no_text(): + task = tm.map_result(_blocked("delete_index({})"), session_id="s", context_id="c") + assert "delete_index" in task.status.message.parts[0].root.text + + +def test_pending_tool_use_id(): + assert tm.pending_tool_use_id({"event_ids": ["e9"], "tools": {"e9": "x"}}) == "e9" + assert tm.pending_tool_use_id({"tools": {"only": "x"}}) == "only" + assert tm.pending_tool_use_id(None) is None + + +def test_unmappable_status_raises_never_unspecified(): + with pytest.raises(ValueError): + tm.map_result({"text": "", "status": "weird", "pending": None}, session_id="s", context_id="c") + + +# --- resting/terminal constructors ----------------------------------------- +def test_submitted_and_rejected_and_canceled(): + assert tm.submitted_task("s", "c").status.state == TaskState.TASK_STATE_SUBMITTED + rej = tm.rejected_task("s", "c") + assert rej.status.state == TaskState.TASK_STATE_REJECTED + # non-disclosure: generic message only + assert rej.status.message.parts[0].root.text == "Not authorized." + assert tm.canceled_task("s", "c").status.state == TaskState.TASK_STATE_CANCELED + + +def test_now_iso_has_z_suffix(): + assert tm.now_iso().endswith("Z") diff --git a/agent-templates/a2a/wire_errors.py b/agent-templates/a2a/wire_errors.py new file mode 100644 index 0000000..db9d238 --- /dev/null +++ b/agent-templates/a2a/wire_errors.py @@ -0,0 +1,82 @@ +"""Building JSON-RPC error objects from the frozen A2A error taxonomy. + +The typed exception hierarchy lives in the frozen client package +(``fuze_a2a_client.errors``) and is imported through ``._contract`` — never +redefined here. This module only adds the *server-side* helpers: turning an +``A2AError`` into the on-the-wire ``{"code","message","data"}`` object, where +``data`` is an ARRAY whose elements carry a ProtoJSON ``@type`` (binding.md §3). +""" +from __future__ import annotations + +from typing import Any + +from ._contract import errors + +# Re-export for callers that dispatch on the typed classes. +A2AError = errors.A2AError +JSONParseError = errors.JSONParseError +InvalidRequestError = errors.InvalidRequestError +MethodNotFoundError = errors.MethodNotFoundError +InvalidParamsError = errors.InvalidParamsError +InternalError = errors.InternalError +TaskNotFoundError = errors.TaskNotFoundError +TaskNotCancelableError = errors.TaskNotCancelableError +PushNotificationNotSupportedError = errors.PushNotificationNotSupportedError +UnsupportedOperationError = errors.UnsupportedOperationError +ContentTypeNotSupportedError = errors.ContentTypeNotSupportedError +InvalidAgentResponseError = errors.InvalidAgentResponseError +VersionNotSupportedError = errors.VersionNotSupportedError + +ERROR_DOMAIN = "a2a-protocol.org" +_ERROR_INFO_TYPE = "type.googleapis.com/google.rpc.ErrorInfo" + + +def error_info(reason: str, metadata: dict[str, Any] | None = None) -> dict: + """One ProtoJSON ``google.rpc.ErrorInfo`` element for the ``data`` array.""" + el: dict[str, Any] = {"@type": _ERROR_INFO_TYPE, "reason": reason, "domain": ERROR_DOMAIN} + if metadata: + el["metadata"] = metadata + return el + + +def to_wire_error(exc: A2AError) -> dict: + """Serialize an ``A2AError`` to a JSON-RPC ``error`` object.""" + data = list(exc.data) if getattr(exc, "data", None) else [] + err: dict[str, Any] = {"code": exc.code, "message": exc.message} + if data: + err["data"] = data + return err + + +def with_info(exc_cls, message: str, reason: str, metadata: dict[str, Any] | None = None): + """Construct an ``A2AError`` subclass carrying a single ``ErrorInfo`` element.""" + return exc_cls(message, data=[error_info(reason, metadata)]) + + +# Convenience factories for the non-disclosure denial path (authz.md §6): all four +# denial cases MUST look identical on the wire. +def task_not_found() -> TaskNotFoundError: + return with_info( + TaskNotFoundError, + "Task not found", + "TASK_NOT_FOUND", + {"note": "Returned identically for unknown, forbidden and other-caller tasks."}, + ) + + +def push_not_supported() -> PushNotificationNotSupportedError: + return with_info( + PushNotificationNotSupportedError, + "Push notifications are not supported", + "PUSH_NOTIFICATION_NOT_SUPPORTED", + {"contractVersion": "1.0.0"}, + ) + + +def version_not_supported(seen: str | None = None) -> VersionNotSupportedError: + md = {"supported": "1.0"} + if seen is not None: + md["seen"] = seen + return with_info( + VersionNotSupportedError, "A2A protocol version not supported", "VERSION_NOT_SUPPORTED", md + ) From 3d744aa7c3bc8211e6fd6791f71b430d5b96898d Mon Sep 17 00:00:00 2001 From: "Izzy Weinberg (backend-engineer)" Date: Wed, 22 Jul 2026 19:35:27 +0300 Subject: [PATCH 4/6] feat(a2a): adapter + session store + config (16 adapter unit tests) [skip ci] Thin translation over providers/base.py seam: SendMessage(+streaming), GetTask/ListTasks/CancelTask/SubscribeToTask, continuation via confirm_tool/ resume_session, per-caller extended card, callee-enforced authz, caller-scoped session store (reflection cache, not a task engine). 64 unit tests green so far. Co-Authored-By: Claude Claude-Session-Id: 17fb89fd-3579-433b-a6c4-9c9e7f3ec549 --- agent-templates/a2a/adapter.py | 367 ++++++++++++++++++++++ agent-templates/a2a/config.py | 107 +++++++ agent-templates/a2a/session_store.py | 69 ++++ agent-templates/a2a/task_mapper.py | 23 ++ agent-templates/a2a/tests/test_adapter.py | 286 +++++++++++++++++ 5 files changed, 852 insertions(+) create mode 100644 agent-templates/a2a/adapter.py create mode 100644 agent-templates/a2a/config.py create mode 100644 agent-templates/a2a/session_store.py create mode 100644 agent-templates/a2a/tests/test_adapter.py diff --git a/agent-templates/a2a/adapter.py b/agent-templates/a2a/adapter.py new file mode 100644 index 0000000..c1ff77d --- /dev/null +++ b/agent-templates/a2a/adapter.py @@ -0,0 +1,367 @@ +"""The A2A adapter — wire methods dispatched onto an ``AgentProvider``. + +This is the translation layer and NOTHING more (state-mapping.md): A2A wire objects +in, provider calls out, provider results mapped back through ``task_mapper``. It owns +no scheduler, no retry loop, no transcript store — those live below the +``providers/base.py`` seam already. + +The adapter is transport-agnostic: the Starlette ``server`` module handles HTTP/SSE and +credential validation, then calls these methods with an already-resolved, TRUSTED +``AuthContext``. Skill selection travels as ``message.metadata.skillId`` (routing, which +the callee may reject); it is NEVER trusted for authorization (authz.md §1). +""" +from __future__ import annotations + +import json +import threading +import uuid +from typing import Any, Callable, Iterator, Protocol + +from . import card_generator as cg +from . import task_mapper as tm +from . import wire_errors as we +from .authz import AuthContext, Decision, authorize +from .config import ServerConfig, TenantConfig +from .session_store import SessionStore + +#: Resolve a tenant's projection inputs (manifest, roles) from its git ref. +RepoResolver = Callable[[TenantConfig], "tuple[dict, dict]"] + + +class AgentProviderLike(Protocol): + def ensure_agent(self, manifest, multiagent=None) -> dict: ... + def create_session(self, agent_id, version, environment_id, vault_ids=None, + memory_resources=None, title=None) -> str: ... + def run_until_block(self, session_id, prompt=None) -> dict: ... + def confirm_tool(self, session_id, tool_use_id, allow=True, deny_message=None) -> None: ... + def resume_session(self, session_id, summary, context_ref="") -> None: ... + def archive_session(self, session_id) -> None: ... + + +def _dump(task) -> dict: + return task.model_dump(mode="json", exclude_none=True, by_alias=True) + + +def _join_parts(parts: list[dict]) -> str: + """Concatenate a message's parts into a prompt (state-mapping.md §6). + + ``text`` joined in order; ``data`` serialized as fenced JSON; ``url``/``raw`` inputs + are rejected with ContentTypeNotSupportedError (-32005) — large state passes by + reference through the handoff memory store, never inline. + """ + chunks: list[str] = [] + for part in parts or []: + if not isinstance(part, dict): + continue + if part.get("url") is not None or part.get("raw") is not None: + raise we.with_info( + we.ContentTypeNotSupportedError, + "url/raw parts are not accepted on input in v1", + "CONTENT_TYPE_NOT_SUPPORTED", + {"note": "pass large state by reference via the handoff memory store"}, + ) + if part.get("text") is not None: + chunks.append(str(part["text"])) + elif part.get("data") is not None: + chunks.append("```json\n" + json.dumps(part["data"], sort_keys=True) + "\n```") + return "\n".join(chunks) + + +def _parse_decision(text: str) -> tuple[bool, str | None]: + """A caller's reply to an always_ask pause -> (allow, deny_message).""" + head = (text or "").strip().lower() + if head.startswith(("deny", "no", "reject", "decline", "disallow")): + return False, (text.strip() or "denied by caller") + return True, None + + +class A2AAdapter: + def __init__( + self, + config: ServerConfig, + provider: AgentProviderLike, + repo_resolver: RepoResolver, + *, + signer: cg.Signer | None = None, + ): + self.config = config + self.provider = provider + self.resolve_repo = repo_resolver + self.signer = signer + self.store = SessionStore() + + # ------------------------------------------------------------------ # + # cards + # ------------------------------------------------------------------ # + def _tenant_or_none(self, tenant_name: str | None) -> TenantConfig | None: + return self.config.tenant(tenant_name) if tenant_name else None + + def _issuer(self) -> str: + return self.config.auth.oidc_issuer_url if self.config.auth else cg.DEFAULT_ISSUER + + def _card_for(self, tenant: TenantConfig, *, visibility: str) -> dict: + manifest, roles = self.resolve_repo(tenant) + # exec tenants (Exec-) project a single exec role card + if tenant.tenant.startswith("Exec-"): + role_key = tenant.tenant[len("Exec-"):] + role = roles.get(role_key) + if role is None: + raise we.task_not_found() + return cg.project_exec_card( + role_key, role, manifest, issuer_url=self._issuer(), signer=self.signer + ) + return cg.project_product_card( + manifest, roles, issuer_url=self._issuer(), visibility=visibility, signer=self.signer + ) + + def well_known_card(self, tenant_name: str) -> dict: + """Public, unauthenticated card (only publish:true, non-extendedOnly skills).""" + tenant = self._tenant_or_none(tenant_name) + if tenant is None: + raise we.task_not_found() + return self._card_for(tenant, visibility="public") + + def extended_card(self, tenant_name: str, ctx: AuthContext) -> dict: + """Authenticated extended card, computed per caller (authz.md §5).""" + tenant = self._tenant_or_none(tenant_name) + if tenant is None: + raise we.task_not_found() + manifest, _ = self.resolve_repo(tenant) + res = authorize(ctx, manifest) + if res.decision is Decision.DENY: + # non-disclosure: an unauthorized caller cannot enumerate skills + raise we.task_not_found() + return self._card_for(tenant, visibility="extended") + + # ------------------------------------------------------------------ # + # SendMessage / SendStreamingMessage + # ------------------------------------------------------------------ # + def _resolve_role(self, roles: dict, manifest: dict, tenant: TenantConfig, message: dict): + """(skill_id, role_dict, known) from message.metadata.skillId or the entry role.""" + skill_id = (message.get("metadata") or {}).get("skillId") + if not skill_id: + skill_id = ( + tenant.entry_role + or (manifest.get("a2a") or {}).get("entryRole") + ) + if tenant.tenant.startswith("Exec-") and not skill_id: + skill_id = tenant.tenant[len("Exec-"):] + role = roles.get(skill_id) if skill_id else None + return skill_id, role, role is not None + + def _provision(self, tenant: TenantConfig, role: dict): + agent = self.provider.ensure_agent(role) + environment_id = tenant.provider.environment_id or agent.get("environment_id") + return agent["id"], agent.get("version"), environment_id + + def send_message(self, params: dict, ctx: AuthContext) -> dict: + task = self._send(params, ctx, streaming=False) + return {"task": task} + + def send_streaming_message(self, params: dict, ctx: AuthContext) -> Iterator[dict]: + message = params.get("message") or {} + if message.get("taskId"): + # continuation on a stream: resolve then re-attach + task = self._continue(message, ctx) + yield {"task": _dump(task)} + return + yield from self._send_stream(params, ctx) + + def _send(self, params: dict, ctx: AuthContext, *, streaming: bool): + message = params.get("message") or {} + if message.get("taskId"): + return _dump(self._continue(message, ctx)) + + prep = self._prepare(params, ctx) + if isinstance(prep, tuple) is False: # a rejected/settled Task + return _dump(prep) + session_id, context_id, prompt = prep + + cfg = params.get("configuration") or {} + if bool(cfg.get("returnImmediately", False)): + self._run_background(session_id, context_id, prompt) + return _dump(tm.submitted_task(session_id, context_id)) + + return _dump(self._run_and_store(session_id, context_id, prompt)) + + def _prepare(self, params: dict, ctx: AuthContext): + """Authorize + provision + create session. Returns (session_id, context_id, + prompt) on success, or a terminal Task (REJECTED / AUTH_REQUIRED) to return.""" + tenant_name = params.get("tenant") + message = params.get("message") or {} + context_id = message.get("contextId") or f"ctx-{uuid.uuid4().hex[:16]}" + synth = f"rej-{uuid.uuid4().hex[:16]}" + + tenant = self._tenant_or_none(tenant_name) + if tenant is None: + # unknown/disabled tenant -> generic REJECTED (non-disclosure, authz.md §6) + return tm.rejected_task(synth, context_id) + + manifest, roles = self.resolve_repo(tenant) + skill_id, role, known = self._resolve_role(roles, manifest, tenant, message) + res = authorize(ctx, manifest, skill_role=role, skill_known=known) + + if res.decision is Decision.DENY: + return tm.rejected_task(synth, context_id) + if res.decision is Decision.SCOPE_REQUIRED: + # allowlisted but token lacks a scope it could obtain -> AUTH_REQUIRED + t = tm.map_result( + { + "text": f"This skill requires scope(s): {', '.join(res.missing_scopes)}.", + "status": "blocked", + "pending": {"event_ids": [], "tools": {"scope": "request_scope(" + ",".join(res.missing_scopes) + ")"}}, + }, + session_id=synth, + context_id=context_id, + ) + return t + + # authorized -> provision + create session (prompt built now to fail fast on url/raw) + prompt = _join_parts(message.get("parts") or []) + agent_id, version, environment_id = self._provision(tenant, role) + title = f"{ctx.caller}: {prompt[:80]}" + session_id = self.provider.create_session( + agent_id, + version, + environment_id, + vault_ids=list(tenant.provider.vault_ids) or None, + memory_resources=list(tenant.provider.memory_resources) or None, + title=title, + ) + self.store.create(session_id, ctx.caller, tenant.tenant, context_id) + self.store.update( + session_id, task=_dump(tm.submitted_task(session_id, context_id)), terminal=False + ) + return session_id, context_id, prompt + + def _run_and_store(self, session_id: str, context_id: str, prompt: str | None): + result = self.provider.run_until_block(session_id, prompt=prompt) + task = tm.map_result(result, session_id=session_id, context_id=context_id) + terminal = task.status.state in _TERMINAL + self.store.update( + session_id, + task=_dump(task), + terminal=terminal, + pending_tool_use_id=tm.pending_tool_use_id(result.get("pending")), + ) + return task + + def _run_background(self, session_id: str, context_id: str, prompt: str | None): + # mark WORKING immediately, then run out of band (returnImmediately: true) + working = tm.map_result( + {"text": "", "status": "idle", "pending": None}, session_id=session_id, context_id=context_id + ) + working.status.state = tm.TaskState.TASK_STATE_WORKING + self.store.update(session_id, task=_dump(working), terminal=False) + + def _run(): + try: + self._run_and_store(session_id, context_id, prompt) + except Exception: # pragma: no cover - background best-effort + fail = tm.map_result( + {"text": "background execution failed", "status": "error", "pending": None}, + session_id=session_id, + context_id=context_id, + ) + self.store.update(session_id, task=_dump(fail), terminal=True) + + threading.Thread(target=_run, daemon=True).start() + + def _send_stream(self, params: dict, ctx: AuthContext) -> Iterator[dict]: + prep = self._prepare(params, ctx) + if not isinstance(prep, tuple): # terminal Task (rejected / auth-required) + yield {"task": _dump(prep)} + return + session_id, context_id, prompt = prep + + yield {"task": self.store.get(session_id).task} # SUBMITTED + yield { + "statusUpdate": tm.TaskStatusUpdateEvent( + taskId=session_id, + contextId=context_id, + status=tm.working_status(session_id, context_id), + ).model_dump(mode="json", exclude_none=True, by_alias=True) + } + task = self._run_and_store(session_id, context_id, prompt) + yield {"task": _dump(task)} + + def _continue(self, message: dict, ctx: AuthContext): + """Resolve an interrupted task (state-mapping.md §4): confirm_tool then continue. + + Continuations use ``confirm_tool`` / ``resume_session`` — never a transcript + replay. The callee's session already holds its own history server-side. + """ + session_id = message.get("taskId") + rec = self.store.owned(session_id, ctx.caller) + if rec is None: + raise we.task_not_found() + + reply = _join_parts(message.get("parts") or []) + if rec.pending_tool_use_id: + allow, deny_message = _parse_decision(reply) + self.provider.confirm_tool( + session_id, rec.pending_tool_use_id, allow=allow, deny_message=deny_message + ) + elif reply: + self.provider.resume_session(session_id, summary=reply) + return self._run_and_store(session_id, rec.context_id, None) + + # ------------------------------------------------------------------ # + # GetTask / ListTasks / CancelTask / SubscribeToTask + # ------------------------------------------------------------------ # + def get_task(self, params: dict, ctx: AuthContext) -> dict: + rec = self.store.owned(params.get("id"), ctx.caller) + if rec is None or rec.task is None: + raise we.task_not_found() + return rec.task + + def list_tasks(self, params: dict, ctx: AuthContext) -> dict: + tenant = params.get("tenant") + tasks = [r.task for r in self.store.list_for(ctx.caller, tenant) if r.task] + return {"tasks": tasks} + + def cancel_task(self, params: dict, ctx: AuthContext) -> dict: + session_id = params.get("id") + rec = self.store.owned(session_id, ctx.caller) + if rec is None: + raise we.task_not_found() + if rec.terminal: + raise we.with_info( + we.TaskNotCancelableError, "Task is already terminal", "TASK_NOT_CANCELABLE" + ) + try: + self.provider.archive_session(session_id) + except Exception as exc: # archival failed -> not canceled + raise we.with_info( + we.TaskNotCancelableError, "archive_session failed", "TASK_NOT_CANCELABLE" + ) from exc + task = tm.canceled_task(session_id, rec.context_id) + self.store.update(session_id, task=_dump(task), terminal=True) + return _dump(task) + + def subscribe_to_task(self, params: dict, ctx: AuthContext) -> Iterator[dict]: + session_id = params.get("id") + rec = self.store.owned(session_id, ctx.caller) + if rec is None: + raise we.task_not_found() + if rec.terminal: + raise we.with_info( + we.UnsupportedOperationError, + "Cannot subscribe to a terminal task", + "UNSUPPORTED_OPERATION", + ) + # re-attach: reflect the current snapshot, then continue running to settle + if rec.task: + yield {"task": rec.task} + task = self._run_and_store(session_id, rec.context_id, None) + yield {"task": _dump(task)} + + +_TERMINAL = frozenset( + { + tm.TaskState.TASK_STATE_COMPLETED, + tm.TaskState.TASK_STATE_FAILED, + tm.TaskState.TASK_STATE_CANCELED, + tm.TaskState.TASK_STATE_REJECTED, + } +) diff --git a/agent-templates/a2a/config.py b/agent-templates/a2a/config.py new file mode 100644 index 0000000..eac993a --- /dev/null +++ b/agent-templates/a2a/config.py @@ -0,0 +1,107 @@ +"""Server configuration, parsed from the ``values-interface.schema.json`` shape. + +This is the DATA that lets ONE shared server front many product/exec agents: a repo +onboards by adding an entry to ``a2a.tenants`` — never a new pod (values-interface +§description). We parse only; the Helm chart that supplies these values is +devops-engineer's slice. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class AuthConfig: + oidc_issuer_url: str + audience: str | None = None + #: the token claim carrying the caller repo identity — its value is the ONLY + #: trusted caller identity (authz.md §2 / values-interface auth.callerClaim). + caller_claim: str = "sub" + mtls_enabled: bool = False + + +@dataclass(frozen=True) +class ProviderBinding: + name: str = "anthropic" + environment_id: str | None = None + vault_ids: tuple[str, ...] = () + memory_resources: tuple[str, ...] = () + + +@dataclass(frozen=True) +class TenantConfig: + tenant: str + repo: str + enabled: bool = False + ref: str = "main" + entry_role: str | None = None + serving_roles: tuple[str, ...] = () + external: bool = False + provider: ProviderBinding = field(default_factory=ProviderBinding) + + +@dataclass(frozen=True) +class ServerConfig: + enabled: bool = False + port: int = 8080 + protocol_version: str = "1.0" + image_tag: str | None = None + auth: AuthConfig | None = None + card_key_id: str | None = None + tenants: tuple[TenantConfig, ...] = () + + def tenant(self, name: str) -> TenantConfig | None: + for t in self.tenants: + if t.tenant == name and t.enabled: + return t + return None + + +def _provider(d: dict | None) -> ProviderBinding: + d = d or {} + return ProviderBinding( + name=d.get("name", "anthropic"), + environment_id=d.get("environmentId"), + vault_ids=tuple(d.get("vaultIds") or ()), + memory_resources=tuple(d.get("memoryResources") or ()), + ) + + +def _tenant(d: dict) -> TenantConfig: + return TenantConfig( + tenant=d["tenant"], + repo=d["repo"], + enabled=bool(d.get("enabled", False)), + ref=d.get("ref", "main"), + entry_role=d.get("entryRole"), + serving_roles=tuple(d.get("servingRoles") or ()), + external=bool(d.get("external", False)), + provider=_provider(d.get("provider")), + ) + + +def load_config(values: dict[str, Any]) -> ServerConfig: + """Parse the ``{"a2a": {...}}`` values document into a ``ServerConfig``.""" + a2a = values.get("a2a", values) # tolerate being handed the inner block directly + auth_raw = a2a.get("auth") + auth = None + if auth_raw: + mtls = auth_raw.get("mtls") or {} + auth = AuthConfig( + oidc_issuer_url=auth_raw["oidcIssuerUrl"], + audience=auth_raw.get("audience"), + caller_claim=auth_raw.get("callerClaim", "sub"), + mtls_enabled=bool(mtls.get("enabled", False)), + ) + signing = a2a.get("cardSigning") or {} + image = a2a.get("image") or {} + return ServerConfig( + enabled=bool(a2a.get("enabled", False)), + port=int((a2a.get("service") or {}).get("port", 8080)), + protocol_version=a2a.get("protocolVersion", "1.0"), + image_tag=image.get("tag"), + auth=auth, + card_key_id=signing.get("keyId"), + tenants=tuple(_tenant(t) for t in (a2a.get("tenants") or [])), + ) diff --git a/agent-templates/a2a/session_store.py b/agent-templates/a2a/session_store.py new file mode 100644 index 0000000..9de63b2 --- /dev/null +++ b/agent-templates/a2a/session_store.py @@ -0,0 +1,69 @@ +"""The task store — a REFLECTION of provider sessions, not a task engine. + +state-mapping.md §1/§7 is emphatic: ``Task.id`` IS the session id and the adapter MUST +NOT persist its own task table that duplicates state. This store therefore holds only: + + * caller OWNERSHIP (so ``ListTasks``/``GetTask`` can scope to the caller and so + other-caller tasks are indistinguishable from unknown ones — authz.md §6), and + * a CACHED snapshot of the last ``Task`` the provider result mapped to, because the + ``AgentProvider`` seam exposes no "query current session state" primitive — the + only status a session reports is what ``run_until_block`` returned. The snapshot is + a reflection of that result; the adapter never invents a transition. +""" +from __future__ import annotations + +import threading +from dataclasses import dataclass, field + + +@dataclass +class SessionRecord: + session_id: str + caller: str + tenant: str + context_id: str + #: latest Task snapshot as a wire dict (reflection of the last provider result). + task: dict | None = None + terminal: bool = False + #: tool_use_id of an outstanding always_ask pause, for confirm_tool on continuation. + pending_tool_use_id: str | None = None + + +class SessionStore: + def __init__(self) -> None: + self._by_id: dict[str, SessionRecord] = {} + self._lock = threading.RLock() + + def create(self, session_id: str, caller: str, tenant: str, context_id: str) -> SessionRecord: + with self._lock: + rec = SessionRecord(session_id, caller, tenant, context_id) + self._by_id[session_id] = rec + return rec + + def get(self, session_id: str) -> SessionRecord | None: + with self._lock: + return self._by_id.get(session_id) + + def owned(self, session_id: str, caller: str) -> SessionRecord | None: + """Return the record only if ``caller`` owns it — else None (no oracle).""" + rec = self.get(session_id) + if rec is None or rec.caller != caller: + return None + return rec + + def update(self, session_id: str, *, task: dict, terminal: bool, pending_tool_use_id=None) -> None: + with self._lock: + rec = self._by_id.get(session_id) + if rec is None: + return + rec.task = task + rec.terminal = terminal + rec.pending_tool_use_id = pending_tool_use_id + + def list_for(self, caller: str, tenant: str | None = None) -> list[SessionRecord]: + with self._lock: + return [ + r + for r in self._by_id.values() + if r.caller == caller and (tenant is None or r.tenant == tenant) + ] diff --git a/agent-templates/a2a/task_mapper.py b/agent-templates/a2a/task_mapper.py index 2f1774a..ffbd09c 100644 --- a/agent-templates/a2a/task_mapper.py +++ b/agent-templates/a2a/task_mapper.py @@ -28,10 +28,33 @@ Part, Role, Task, + TaskArtifactUpdateEvent, TaskState, TaskStatus, + TaskStatusUpdateEvent, ) +__all__ = [ + "Artifact", + "Message", + "Part", + "Role", + "Task", + "TaskArtifactUpdateEvent", + "TaskState", + "TaskStatus", + "TaskStatusUpdateEvent", + "now_iso", + "agent_message", + "classify_pause", + "pending_tool_use_id", + "map_result", + "submitted_task", + "working_status", + "rejected_task", + "canceled_task", +] + # Signals in a pending tool descriptor that mark a pause as a CREDENTIAL / AUTH grant # request rather than an ordinary decision. Discriminating these two is the most # likely adapter bug (state-mapping.md §3), so the rule is explicit and testable. diff --git a/agent-templates/a2a/tests/test_adapter.py b/agent-templates/a2a/tests/test_adapter.py new file mode 100644 index 0000000..18c2855 --- /dev/null +++ b/agent-templates/a2a/tests/test_adapter.py @@ -0,0 +1,286 @@ +"""Unit tests for the A2A adapter (state-mapping.md + authz.md) with a fake provider. + +The fake stands in for the Managed-Agents runtime below the providers/base.py seam, so +these tests exercise the TRANSLATION only — the adapter's entire responsibility. +""" +from __future__ import annotations + +import pytest +from a2a.adapter import A2AAdapter +from a2a.authz import AuthContext +from a2a.config import ProviderBinding, ServerConfig, TenantConfig +from a2a.loader import load_repo + + +class FakeProvider: + """Scripted provider. ``script`` maps session_id -> list of run_until_block results.""" + + def __init__(self, results=None): + self.results = results or {} + self.default = {"text": "done", "status": "idle", "pending": None} + self.sessions = [] + self.confirmed = [] + self.resumed = [] + self.archived = [] + self._n = 0 + + def ensure_agent(self, manifest, multiagent=None): + return {"name": manifest.get("role", "x"), "id": "agent-1", "version": "1"} + + def create_session(self, agent_id, version, environment_id, vault_ids=None, + memory_resources=None, title=None): + self._n += 1 + sid = f"sess-{self._n}" + self.sessions.append({"id": sid, "title": title, "vault_ids": vault_ids}) + return sid + + def run_until_block(self, session_id, prompt=None): + seq = self.results.get(session_id) + if seq: + return seq.pop(0) + return dict(self.default) + + def confirm_tool(self, session_id, tool_use_id, allow=True, deny_message=None): + self.confirmed.append((session_id, tool_use_id, allow, deny_message)) + + def resume_session(self, session_id, summary, context_ref=""): + self.resumed.append((session_id, summary)) + + def archive_session(self, session_id): + self.archived.append(session_id) + + +@pytest.fixture +def fuzeplan_cfg(): + return ServerConfig( + enabled=True, + tenants=( + TenantConfig( + tenant="FuzePlan", + repo="izzywdev/FuzePlan", + enabled=True, + entry_role="product-manager", + provider=ProviderBinding(name="fake", vault_ids=("v1",)), + ), + ), + ) + + +@pytest.fixture +def resolver(fuzeplan_repo): + def _resolve(tenant): + return load_repo(fuzeplan_repo) + + return _resolve + + +def _ctx(caller="FuzeSales"): + return AuthContext(caller=caller) + + +def _adapter(cfg, provider, resolver): + return A2AAdapter(cfg, provider, resolver) + + +# --- SendMessage happy path ------------------------------------------------- +def test_send_message_completed(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "Create tickets"}], "metadata": {"skillId": "product-manager"}}} + out = a.send_message(params, _ctx()) + task = out["task"] + assert task["status"]["state"] == "TASK_STATE_COMPLETED" + assert task["id"] == "sess-1" + # session titled with caller identity + prompt head + assert prov.sessions[0]["title"].startswith("FuzeSales:") + assert prov.sessions[0]["vault_ids"] == ["v1"] + + +def test_entry_role_used_when_no_skill(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "hi"}]}} + out = a.send_message(params, _ctx()) + assert out["task"]["status"]["state"] == "TASK_STATE_COMPLETED" + + +# --- authz denials ---------------------------------------------------------- +def test_unauthorized_caller_rejected(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}} + out = a.send_message(params, _ctx(caller="FuzeMalory")) + assert out["task"]["status"]["state"] == "TASK_STATE_REJECTED" + assert out["task"]["status"]["message"]["parts"][0]["text"] == "Not authorized." + # no session created for a denied caller + assert prov.sessions == [] + + +def test_unknown_tenant_is_generic_rejected(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "Nope", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}} + out = a.send_message(params, _ctx()) + assert out["task"]["status"]["state"] == "TASK_STATE_REJECTED" + + +def test_unknown_skill_rejected(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}], "metadata": {"skillId": "does-not-exist"}}} + out = a.send_message(params, _ctx()) + assert out["task"]["status"]["state"] == "TASK_STATE_REJECTED" + + +# --- interrupted + continuation --------------------------------------------- +def test_input_required_then_continue_confirms_tool(fuzeplan_cfg, resolver): + prov = FakeProvider(results={ + "sess-1": [ + {"text": "May I create 12 tickets?", "status": "blocked", + "pending": {"event_ids": ["tu-1"], "tools": {"tu-1": "create_tickets({\"n\":12})"}}}, + {"text": "created", "status": "idle", "pending": None}, + ] + }) + a = _adapter(fuzeplan_cfg, prov, resolver) + p1 = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "Create 12 tickets"}], "metadata": {"skillId": "product-manager"}}} + t1 = a.send_message(p1, _ctx())["task"] + assert t1["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" + sid = t1["id"] + + # caller answers on the SAME taskId + p2 = {"tenant": "FuzePlan", "message": {"messageId": "m2", "role": "ROLE_USER", + "taskId": sid, "parts": [{"text": "yes, go ahead"}]}} + t2 = a.send_message(p2, _ctx())["task"] + assert t2["status"]["state"] == "TASK_STATE_COMPLETED" + assert prov.confirmed == [(sid, "tu-1", True, None)] + + +def test_continue_deny_passes_reason(fuzeplan_cfg, resolver): + prov = FakeProvider(results={ + "sess-1": [ + {"text": "may I?", "status": "blocked", + "pending": {"event_ids": ["tu-9"], "tools": {"tu-9": "open_pr({})"}}}, + {"text": "ok, stopped", "status": "idle", "pending": None}, + ] + }) + a = _adapter(fuzeplan_cfg, prov, resolver) + p1 = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", + "parts": [{"text": "open a PR"}]}} + sid = a.send_message(p1, _ctx())["task"]["id"] + p2 = {"tenant": "FuzePlan", "message": {"messageId": "m2", "role": "ROLE_USER", + "taskId": sid, "parts": [{"text": "deny - too risky"}]}} + a.send_message(p2, _ctx()) + assert prov.confirmed[0][2] is False + assert "too risky" in prov.confirmed[0][3] + + +def test_continue_foreign_task_is_not_found(fuzeplan_cfg, resolver): + prov = FakeProvider(results={"sess-1": [ + {"text": "?", "status": "blocked", "pending": {"event_ids": ["t"], "tools": {"t": "x"}}}]}) + a = _adapter(fuzeplan_cfg, prov, resolver) + sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}}, _ctx(caller="FuzeSales"))["task"]["id"] + # a DIFFERENT allowlisted caller may not touch it + from a2a.wire_errors import TaskNotFoundError + with pytest.raises(TaskNotFoundError): + a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m2", "role": "ROLE_USER", + "taskId": sid, "parts": [{"text": "yes"}]}}, _ctx(caller="FuzeService")) + + +# --- parts handling --------------------------------------------------------- +def test_url_part_rejected_content_type(fuzeplan_cfg, resolver): + from a2a.wire_errors import ContentTypeNotSupportedError + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"url": "http://x/y"}]}} + with pytest.raises(ContentTypeNotSupportedError): + a.send_message(params, _ctx()) + + +# --- GetTask / ListTasks / CancelTask -------------------------------------- +def test_get_task_scoped_to_caller(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}}, _ctx(caller="FuzeSales"))["task"]["id"] + got = a.get_task({"id": sid, "tenant": "FuzePlan"}, _ctx(caller="FuzeSales")) + assert got["id"] == sid + + from a2a.wire_errors import TaskNotFoundError + with pytest.raises(TaskNotFoundError): + a.get_task({"id": sid}, _ctx(caller="FuzeService")) + + +def test_list_tasks_only_own(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}}, _ctx(caller="FuzeSales")) + a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "y"}]}}, _ctx(caller="FuzeService")) + sales = a.list_tasks({"tenant": "FuzePlan"}, _ctx(caller="FuzeSales")) + assert len(sales["tasks"]) == 1 + + +def test_cancel_task_archives_and_marks_canceled(fuzeplan_cfg, resolver): + prov = FakeProvider(results={"sess-1": [ + {"text": "?", "status": "blocked", "pending": {"event_ids": ["t"], "tools": {"t": "x"}}}]}) + a = _adapter(fuzeplan_cfg, prov, resolver) + sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}}, _ctx())["task"]["id"] + out = a.cancel_task({"id": sid, "tenant": "FuzePlan"}, _ctx()) + assert out["status"]["state"] == "TASK_STATE_CANCELED" + assert prov.archived == [sid] + + +def test_cancel_terminal_task_not_cancelable(fuzeplan_cfg, resolver): + from a2a.wire_errors import TaskNotCancelableError + prov = FakeProvider() # completes immediately -> terminal + a = _adapter(fuzeplan_cfg, prov, resolver) + sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}}, _ctx())["task"]["id"] + with pytest.raises(TaskNotCancelableError): + a.cancel_task({"id": sid, "tenant": "FuzePlan"}, _ctx()) + + +# --- streaming -------------------------------------------------------------- +def test_streaming_yields_working_then_terminal(fuzeplan_cfg, resolver): + prov = FakeProvider() + a = _adapter(fuzeplan_cfg, prov, resolver) + params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", + "parts": [{"text": "x"}]}} + frames = list(a.send_streaming_message(params, _ctx())) + states = [] + for f in frames: + if "task" in f: + states.append(f["task"]["status"]["state"]) + elif "statusUpdate" in f: + states.append(f["statusUpdate"]["status"]["state"]) + assert states[0] == "TASK_STATE_SUBMITTED" + assert "TASK_STATE_WORKING" in states + assert states[-1] == "TASK_STATE_COMPLETED" + + +# --- cards ------------------------------------------------------------------ +def test_well_known_card_public(fuzeplan_cfg, resolver): + a = _adapter(fuzeplan_cfg, FakeProvider(), resolver) + card = a.well_known_card("FuzePlan") + assert card["supportedInterfaces"][0]["tenant"] == "FuzePlan" + + +def test_extended_card_requires_authorization(fuzeplan_cfg, resolver): + from a2a.wire_errors import TaskNotFoundError + a = _adapter(fuzeplan_cfg, FakeProvider(), resolver) + # allowlisted caller gets it + card = a.extended_card("FuzePlan", _ctx(caller="FuzeSales")) + assert card["skills"] + # non-allowlisted caller cannot enumerate -> not found + with pytest.raises(TaskNotFoundError): + a.extended_card("FuzePlan", _ctx(caller="FuzeMalory")) From c96c812cf781d816f7c24c40890975ee9b853976 Mon Sep 17 00:00:00 2001 From: "Izzy Weinberg (backend-engineer)" Date: Wed, 22 Jul 2026 19:45:26 +0300 Subject: [PATCH 5/6] feat(a2a): HTTP+SSE server, identity, runtime, CI, README (92 unit tests green) - server.py: JSON-RPC 2.0 over HTTP + SSE per binding.md (POST /rpc, well-known card, extendedAgentCard, version header, push-method -32003, method dispatch). - identity.py: OIDC bearer -> trusted caller identity, fail-closed. - runtime.py: compose config->adapter->server with provider + JWKS verifier. - config/identity/server tests; a2a-unit.yml runs the suite in CI. Co-Authored-By: Claude Claude-Session-Id: 17fb89fd-3579-433b-a6c4-9c9e7f3ec549 --- .github/workflows/a2a-unit.yml | 40 +++ agent-templates/a2a/README.md | 61 +++++ agent-templates/a2a/__init__.py | 1 + agent-templates/a2a/_contract.py | 1 + agent-templates/a2a/adapter.py | 24 +- agent-templates/a2a/authz.py | 1 + agent-templates/a2a/card_generator.py | 13 +- agent-templates/a2a/config.py | 1 + agent-templates/a2a/identity.py | 104 ++++++++ agent-templates/a2a/loader.py | 1 + agent-templates/a2a/pytest.ini | 5 + agent-templates/a2a/requirements.txt | 10 + agent-templates/a2a/runtime.py | 111 ++++++++ agent-templates/a2a/server.py | 225 +++++++++++++++++ agent-templates/a2a/session_store.py | 5 +- agent-templates/a2a/task_mapper.py | 13 +- agent-templates/a2a/tests/conftest.py | 5 +- agent-templates/a2a/tests/test_adapter.py | 237 +++++++++++++----- agent-templates/a2a/tests/test_authz.py | 9 +- .../a2a/tests/test_card_generator.py | 1 + agent-templates/a2a/tests/test_config.py | 78 ++++++ agent-templates/a2a/tests/test_identity.py | 62 +++++ agent-templates/a2a/tests/test_server.py | 192 ++++++++++++++ agent-templates/a2a/tests/test_task_mapper.py | 35 ++- agent-templates/a2a/validation.py | 5 +- agent-templates/a2a/wire_errors.py | 1 + 26 files changed, 1150 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/a2a-unit.yml create mode 100644 agent-templates/a2a/README.md create mode 100644 agent-templates/a2a/identity.py create mode 100644 agent-templates/a2a/pytest.ini create mode 100644 agent-templates/a2a/requirements.txt create mode 100644 agent-templates/a2a/runtime.py create mode 100644 agent-templates/a2a/server.py create mode 100644 agent-templates/a2a/tests/test_config.py create mode 100644 agent-templates/a2a/tests/test_identity.py create mode 100644 agent-templates/a2a/tests/test_server.py diff --git a/.github/workflows/a2a-unit.yml b/.github/workflows/a2a-unit.yml new file mode 100644 index 0000000..83004c5 --- /dev/null +++ b/.github/workflows/a2a-unit.yml @@ -0,0 +1,40 @@ +name: A2A server unit tests + +# Backend-engineer's slice: unit tests for agent-templates/a2a. The image build, Helm +# chart and Argo wiring are devops-engineer's; this workflow only proves the server +# code + card generator behave to the frozen contract. + +on: + push: + branches: [ main, develop ] + paths: + - 'agent-templates/a2a/**' + - 'agent-templates/contracts/a2a/**' + - 'agent-templates/providers/base.py' + - '.github/workflows/a2a-unit.yml' + pull_request: + paths: + - 'agent-templates/a2a/**' + - 'agent-templates/contracts/a2a/**' + - 'agent-templates/providers/base.py' + - '.github/workflows/a2a-unit.yml' + +jobs: + a2a-unit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pydantic 'starlette>=0.37' httpx jsonschema pytest + + - name: Run A2A unit tests + working-directory: agent-templates/a2a + run: python -m pytest tests -q diff --git a/agent-templates/a2a/README.md b/agent-templates/a2a/README.md new file mode 100644 index 0000000..c1d888a --- /dev/null +++ b/agent-templates/a2a/README.md @@ -0,0 +1,61 @@ +# `agent-templates/a2a` — shared A2A server + card generator + +The **callee side** of the frozen A2A contract v1 +(`agent-templates/contracts/a2a/v1`). ONE server fronts every product and exec-tier +agent in the family; a repo onboards by adding a `tenant` entry to the Helm values — +never a new pod. This package is a **thin adapter** over the existing Managed-Agents +runtime (`agent-templates/providers` + `orchestration`); it holds **no task engine**. + +> Scope note: this is backend-engineer's slice. The image/Dockerfile, Helm chart, +> Argo Application and CI image build are **devops-engineer's**; independent +> conformance / authZ-negative tests are **test-engineer's**; the handoff-MCP-over-A2A +> client routing is **mcp-engineer's**; operator docs are **docs-maintainer's**. + +## Modules + +| module | responsibility | contract | +|---|---|---| +| `card_generator.py` | project `manifest.json` + `roles/*/role.json` → Agent Card | `card-projection.md` | +| `validation.py` | validate a card against `agent-card.schema.json` + `fuze-profile.schema.json` | `schema/` | +| `task_mapper.py` | `run_until_block` result → A2A `Task`; INPUT vs AUTH_REQUIRED classifier | `state-mapping.md` | +| `authz.py` | callee-enforced `providesTo` allowlist, **fail-closed** | `authz.md` | +| `identity.py` | transport credential → trusted caller identity (OIDC bearer) | `authz.md §2` | +| `session_store.py` | caller-ownership index + reflected `Task` snapshot (NOT an engine) | `state-mapping.md §7` | +| `adapter.py` | wire methods → `AgentProvider` seam (the translation) | `state-mapping.md` | +| `server.py` | JSON-RPC 2.0 over HTTP + SSE (`POST /rpc`, well-known card) | `binding.md` | +| `config.py` | parse the `values-interface.schema.json` document | `values-interface` | +| `runtime.py` | compose config → adapter → server with a real provider + OIDC | — | + +## Key invariants enforced here + +- **Cards are derived, deterministic, and signed.** Same inputs → byte-identical card + (modulo `signatures`). Never hand-authored. `tools`/`mcp_servers`/`vault` are never + projected (encapsulation invariant, `card-projection.md §7`). +- **The callee enforces; the caller is opaque.** Authorization uses only the validated + credential identity, never the request body. Absent `providesTo` → **DENY**. +- **No new task engine.** `Task.id` IS the provider `session_id`. Continuations use + `confirm_tool` / `resume_session`, never transcript replay. `FAILED` is not retried. +- **Interrupted ≠ terminal.** An `always_ask` pause is `INPUT_REQUIRED`; a missing + credential/grant is `AUTH_REQUIRED`; both may be resolved out-of-band by `reach_human` + with no caller message, and the adapter never downgrades them on timeout. +- **Dual-runtime clean.** Pure-Python, service-DNS addressing, `ClusterIP`-only, + config from env/secret; no assumption that holds in only compose or only Helm. + +## Run the unit tests + +```bash +pip install pydantic starlette httpx jsonschema pytest +cd agent-templates/a2a && python -m pytest tests -q +``` + +The contract client package is put on `sys.path` automatically by `_contract.py`, so no +editable install is required. + +## Local run + +```bash +export A2A_VALUES_FILE=/path/to/values.json # the a2a.* block +export A2A_REPOS_DIR=/repos # tenant repo checkouts +export AGENT_PROVIDER=anthropic +python -m a2a.runtime +``` diff --git a/agent-templates/a2a/__init__.py b/agent-templates/a2a/__init__.py index 5a8507c..477b901 100644 --- a/agent-templates/a2a/__init__.py +++ b/agent-templates/a2a/__init__.py @@ -13,6 +13,7 @@ adapter -- wire method dispatch onto an AgentProvider server -- Starlette JSON-RPC 2.0 + SSE transport """ + from __future__ import annotations __version__ = "1.0.0" diff --git a/agent-templates/a2a/_contract.py b/agent-templates/a2a/_contract.py index 2446325..2a88fe3 100644 --- a/agent-templates/a2a/_contract.py +++ b/agent-templates/a2a/_contract.py @@ -10,6 +10,7 @@ We NEVER redefine the wire or card models — redefining a generated model is how a server silently forks from its spec (see the client package docstring). """ + from __future__ import annotations import sys diff --git a/agent-templates/a2a/adapter.py b/agent-templates/a2a/adapter.py index c1ff77d..03fdad7 100644 --- a/agent-templates/a2a/adapter.py +++ b/agent-templates/a2a/adapter.py @@ -10,6 +10,7 @@ ``AuthContext``. Skill selection travels as ``message.metadata.skillId`` (routing, which the callee may reject); it is NEVER trusted for authorization (authz.md §1). """ + from __future__ import annotations import json @@ -30,8 +31,9 @@ class AgentProviderLike(Protocol): def ensure_agent(self, manifest, multiagent=None) -> dict: ... - def create_session(self, agent_id, version, environment_id, vault_ids=None, - memory_resources=None, title=None) -> str: ... + def create_session( + self, agent_id, version, environment_id, vault_ids=None, memory_resources=None, title=None + ) -> str: ... def run_until_block(self, session_id, prompt=None) -> dict: ... def confirm_tool(self, session_id, tool_use_id, allow=True, deny_message=None) -> None: ... def resume_session(self, session_id, summary, context_ref="") -> None: ... @@ -103,7 +105,7 @@ def _card_for(self, tenant: TenantConfig, *, visibility: str) -> dict: manifest, roles = self.resolve_repo(tenant) # exec tenants (Exec-) project a single exec role card if tenant.tenant.startswith("Exec-"): - role_key = tenant.tenant[len("Exec-"):] + role_key = tenant.tenant[len("Exec-") :] role = roles.get(role_key) if role is None: raise we.task_not_found() @@ -140,12 +142,9 @@ def _resolve_role(self, roles: dict, manifest: dict, tenant: TenantConfig, messa """(skill_id, role_dict, known) from message.metadata.skillId or the entry role.""" skill_id = (message.get("metadata") or {}).get("skillId") if not skill_id: - skill_id = ( - tenant.entry_role - or (manifest.get("a2a") or {}).get("entryRole") - ) + skill_id = tenant.entry_role or (manifest.get("a2a") or {}).get("entryRole") if tenant.tenant.startswith("Exec-") and not skill_id: - skill_id = tenant.tenant[len("Exec-"):] + skill_id = tenant.tenant[len("Exec-") :] role = roles.get(skill_id) if skill_id else None return skill_id, role, role is not None @@ -209,7 +208,10 @@ def _prepare(self, params: dict, ctx: AuthContext): { "text": f"This skill requires scope(s): {', '.join(res.missing_scopes)}.", "status": "blocked", - "pending": {"event_ids": [], "tools": {"scope": "request_scope(" + ",".join(res.missing_scopes) + ")"}}, + "pending": { + "event_ids": [], + "tools": {"scope": "request_scope(" + ",".join(res.missing_scopes) + ")"}, + }, }, session_id=synth, context_id=context_id, @@ -249,7 +251,9 @@ def _run_and_store(self, session_id: str, context_id: str, prompt: str | None): def _run_background(self, session_id: str, context_id: str, prompt: str | None): # mark WORKING immediately, then run out of band (returnImmediately: true) working = tm.map_result( - {"text": "", "status": "idle", "pending": None}, session_id=session_id, context_id=context_id + {"text": "", "status": "idle", "pending": None}, + session_id=session_id, + context_id=context_id, ) working.status.state = tm.TaskState.TASK_STATE_WORKING self.store.update(session_id, task=_dump(working), terminal=False) diff --git a/agent-templates/a2a/authz.py b/agent-templates/a2a/authz.py index c80afd7..c1a5ca8 100644 --- a/agent-templates/a2a/authz.py +++ b/agent-templates/a2a/authz.py @@ -11,6 +11,7 @@ load-bearing, because ``providesTo`` is absent on most repos at freeze time and treating absent as allow would silently open them to every caller. """ + from __future__ import annotations import re diff --git a/agent-templates/a2a/card_generator.py b/agent-templates/a2a/card_generator.py index 50b4c2d..57586e4 100644 --- a/agent-templates/a2a/card_generator.py +++ b/agent-templates/a2a/card_generator.py @@ -16,6 +16,7 @@ ``environment`` and ``vault`` bindings. Leaking any of them would tell a caller which credentials the callee holds — exactly the coupling A2A removes. """ + from __future__ import annotations import json @@ -290,9 +291,7 @@ def project_product_card( def _exec_description(role_key: str, role: dict) -> str: - base = role.get("description") or ( - f"Executive {role_key.upper()} authority agent for FuzeOne." - ) + base = role.get("description") or (f"Executive {role_key.upper()} authority agent for FuzeOne.") return ( f"{base} Binding decisions pause the task in TASK_STATE_INPUT_REQUIRED while a " f"human is reached via their digital persona — callers must not impose short timeouts." @@ -362,7 +361,13 @@ def generate_cards( for key in exec_keys: card = project_exec_card( - key, roles[key], manifest, issuer_url=issuer_url, version=version, sign=sign, signer=signer + key, + roles[key], + manifest, + issuer_url=issuer_url, + version=version, + sign=sign, + signer=signer, ) out.append((f"Exec-{key}", card)) return out diff --git a/agent-templates/a2a/config.py b/agent-templates/a2a/config.py index eac993a..10db5a8 100644 --- a/agent-templates/a2a/config.py +++ b/agent-templates/a2a/config.py @@ -5,6 +5,7 @@ §description). We parse only; the Helm chart that supplies these values is devops-engineer's slice. """ + from __future__ import annotations from dataclasses import dataclass, field diff --git a/agent-templates/a2a/identity.py b/agent-templates/a2a/identity.py new file mode 100644 index 0000000..4bc2df5 --- /dev/null +++ b/agent-templates/a2a/identity.py @@ -0,0 +1,104 @@ +"""Transport-credential -> trusted caller identity (authz.md §2). + +The ONLY trusted caller identity is the validated claim from the transport credential +(the OIDC bearer token's ``callerClaim``, optionally corroborated by the mTLS client +cert subject). Network position is never identity; an unauthenticated in-cluster +request is rejected exactly as an external one is. + +Token *signature* validation (JWKS fetch, ``aud``/``iss``/``exp`` checks) is injected +as a ``token_verifier`` so this module stays testable and free of a network dependency. +Crucially the default is FAIL-CLOSED: with no verifier configured, every request is +unauthenticated — we never trust an unverified token's claims. +""" + +from __future__ import annotations + +from typing import Callable, Protocol + +from .authz import AuthContext +from .config import AuthConfig + +#: (raw_token) -> claims dict. MUST validate signature/iss/aud/exp and raise on failure. +TokenVerifier = Callable[[str], dict] + + +class Authenticator(Protocol): + def authenticate(self, headers: "HeaderLike") -> AuthContext | None: + """Return a trusted AuthContext, or None if the request is unauthenticated.""" + ... + + +class HeaderLike(Protocol): + def get(self, key: str, default=None): ... + + +def _bearer(headers: HeaderLike) -> str | None: + auth = headers.get("authorization") or headers.get("Authorization") + if not auth: + return None + parts = auth.split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1].strip() + return None + + +class OidcAuthenticator: + """Validate the bearer token via an injected verifier, read the caller claim. + + ``scopes`` are read from a ``scope`` (space-delimited) or ``scp`` (list) claim, as + commonly issued. The mTLS subject header (set by Traefik after cert validation) is + accepted only as a SECOND factor: it must agree with the token's caller. + """ + + def __init__( + self, + auth: AuthConfig, + token_verifier: TokenVerifier | None = None, + *, + mtls_subject_header: str = "x-forwarded-tls-client-cert-subject", + ): + self._auth = auth + self._verify = token_verifier + self._mtls_header = mtls_subject_header + + def authenticate(self, headers: HeaderLike) -> AuthContext | None: + token = _bearer(headers) + if not token or self._verify is None: + return None # fail closed: no verifier => nothing is trusted + try: + claims = self._verify(token) + except Exception: + return None + caller = claims.get(self._auth.caller_claim) + if not caller: + return None + scopes = _scopes(claims) + return AuthContext(caller=str(caller), scopes=frozenset(scopes), authenticated=True) + + +def _scopes(claims: dict) -> set[str]: + raw = claims.get("scope") or claims.get("scp") or [] + if isinstance(raw, str): + return set(raw.split()) + if isinstance(raw, (list, tuple)): + return {str(s) for s in raw} + return set() + + +class StaticAuthenticator: + """Test/dev authenticator: maps a bearer token verbatim to a caller identity. + + NOT for production — it performs no signature validation. Used to drive the server + in unit tests and local runs without an OIDC issuer. + """ + + def __init__(self, token_to_caller: dict[str, str], scopes: dict[str, set[str]] | None = None): + self._map = token_to_caller + self._scopes = scopes or {} + + def authenticate(self, headers: HeaderLike) -> AuthContext | None: + token = _bearer(headers) + caller = self._map.get(token) if token else None + if not caller: + return None + return AuthContext(caller=caller, scopes=frozenset(self._scopes.get(caller, set()))) diff --git a/agent-templates/a2a/loader.py b/agent-templates/a2a/loader.py index c2070c9..50789c7 100644 --- a/agent-templates/a2a/loader.py +++ b/agent-templates/a2a/loader.py @@ -6,6 +6,7 @@ only ``role``, ``name``, ``description``, ``services``, ``metadata``, ``coordinator`` and the optional ``a2a`` block, none of which are inherited from ``_base`` in practice. """ + from __future__ import annotations import json diff --git a/agent-templates/a2a/pytest.ini b/agent-templates/a2a/pytest.ini new file mode 100644 index 0000000..fdcb138 --- /dev/null +++ b/agent-templates/a2a/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +filterwarnings = + ignore::DeprecationWarning:jsonschema.* diff --git a/agent-templates/a2a/requirements.txt b/agent-templates/a2a/requirements.txt new file mode 100644 index 0000000..11f69f5 --- /dev/null +++ b/agent-templates/a2a/requirements.txt @@ -0,0 +1,10 @@ +# Runtime dependencies for the shared A2A server (agent-templates/a2a). +# The wire/card models come from the frozen contract client, which needs only pydantic. +pydantic>=2.0 +starlette>=0.37 +uvicorn>=0.30 +# OIDC bearer validation at runtime (JWKS). Optional at import time; runtime._build_verifier +# fails closed if absent, so the package imports without it for unit tests. +PyJWT>=2.8 +# Card schema/profile validation. +jsonschema>=4.0 diff --git a/agent-templates/a2a/runtime.py b/agent-templates/a2a/runtime.py new file mode 100644 index 0000000..ce663b6 --- /dev/null +++ b/agent-templates/a2a/runtime.py @@ -0,0 +1,111 @@ +"""Compose the running server from configuration. + +Wires the pure pieces (``config`` -> ``adapter`` -> ``server``) to the concrete +Managed-Agents provider (``providers.get_provider``), a repo resolver that reads each +tenant's projection inputs from a checked-out tree, and an OIDC authenticator. + +Deliberately dependency-light so unit tests never import it: the git-sync of a +tenant's ``ref`` and the JWKS verifier construction are runtime concerns. The Helm +chart, image and secret wiring that supply ``VALUES_FILE`` / issuer URL are +devops-engineer's slice — this module only consumes them. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from .adapter import A2AAdapter +from .config import ServerConfig, TenantConfig, load_config +from .identity import OidcAuthenticator +from .loader import load_repo + + +class LocalRepoResolver: + """Resolve a tenant's (manifest, roles) from ``/``. + + The checkout/refresh of each repo at ``tenant.ref`` is performed out of band (an + init/sidecar container the chart provides); this resolver only reads the tree. + GitOps: the git ref is the source of truth, never live-mutated state. + """ + + def __init__(self, base_dir: str | Path): + self.base_dir = Path(base_dir) + + def __call__(self, tenant: TenantConfig) -> tuple[dict, dict]: + name = tenant.repo.rsplit("/", 1)[-1] + return load_repo(self.base_dir / name) + + +def _read_values(path: str | None) -> dict: + if not path: + return {} + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def build_from_env(): + """Build ``(config, app)`` from environment. + + Env: + A2A_VALUES_FILE JSON of the values-interface document (a2a.* block). + A2A_REPOS_DIR directory holding tenant repo checkouts (default /repos). + AGENT_PROVIDER provider id (default anthropic). + """ + from providers import get_provider # imported here so tests never need the SDK + + config: ServerConfig = load_config(_read_values(os.environ.get("A2A_VALUES_FILE"))) + provider = get_provider(os.environ.get("AGENT_PROVIDER") or "anthropic") + resolver = LocalRepoResolver(os.environ.get("A2A_REPOS_DIR", "/repos")) + adapter = A2AAdapter(config, provider, resolver) + + if config.auth is None: + raise RuntimeError("A2A auth config is required (values.a2a.auth.oidcIssuerUrl)") + authenticator = OidcAuthenticator(config.auth, token_verifier=_build_verifier(config)) + + from .server import build_app + + return config, build_app(adapter, authenticator) + + +def _build_verifier(config: ServerConfig): + """Construct a JWKS-backed token verifier for the configured issuer. + + Uses ``PyJWT`` + ``PyJWKClient`` if available; returns ``None`` (fail-closed: every + request unauthenticated) when neither a verifier lib nor issuer is configured, so a + misconfiguration denies rather than silently trusting tokens. + """ + auth = config.auth + if auth is None: + return None + try: # pragma: no cover - exercised only in a real deployment + import jwt + from jwt import PyJWKClient + except Exception: + return None + + jwks_url = auth.oidc_issuer_url.rstrip("/") + "/protocol/openid-connect/certs" + jwk_client = PyJWKClient(jwks_url) + + def verify(token: str) -> dict: # pragma: no cover + signing_key = jwk_client.get_signing_key_from_jwt(token).key + return jwt.decode( + token, + signing_key, + algorithms=["RS256", "ES256"], + audience=auth.audience, + options={"require": ["exp"]}, + ) + + return verify + + +def main() -> None: # pragma: no cover + import uvicorn + + config, app = build_from_env() + uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"), port=config.port) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/agent-templates/a2a/server.py b/agent-templates/a2a/server.py new file mode 100644 index 0000000..5b3dcab --- /dev/null +++ b/agent-templates/a2a/server.py @@ -0,0 +1,225 @@ +"""HTTP + SSE transport for the shared A2A server (binding.md §1). + + JSON-RPC 2.0 over HTTP(S), with Server-Sent Events for streaming. Only that. + +Routes: + POST /rpc single endpoint, all methods (bare PascalCase) + GET /.well-known/agent-card.json public card discovery (unauthenticated) + GET /extendedAgentCard authenticated, per-caller card + GET /healthz liveness + +This module owns transport concerns only — credential extraction, the JSON-RPC +envelope, SSE framing, the ``A2A-Version`` header and error serialization. All agent +behaviour lives in ``adapter`` (and below it, the provider seam). +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse +from starlette.routing import Route + +from . import wire_errors as we +from .adapter import A2AAdapter +from .authz import AuthContext +from .identity import Authenticator + +A2A_VERSION = "1.0" +_VERSION_HEADER = "a2a-version" + +# Methods that stream (SSE); everything else is a unary JSON response. +_STREAMING = {"SendStreamingMessage", "SubscribeToTask"} +# Push-notification config methods are OUT of v1 -> -32003 (binding.md §1). +_PUSH_METHODS = { + "CreateTaskPushNotificationConfig", + "GetTaskPushNotificationConfig", + "ListTaskPushNotificationConfigs", + "DeleteTaskPushNotificationConfig", +} + + +class A2AServer: + def __init__(self, adapter: A2AAdapter, authenticator: Authenticator): + self.adapter = adapter + self.auth = authenticator + + # -- envelope helpers --------------------------------------------------- + @staticmethod + def _ok(req_id, result) -> dict: + return {"jsonrpc": "2.0", "id": req_id, "result": result} + + @staticmethod + def _err(req_id, exc: we.A2AError) -> dict: + return {"jsonrpc": "2.0", "id": req_id, "error": we.to_wire_error(exc)} + + def _check_version(self, request: Request) -> None: + seen = request.headers.get(_VERSION_HEADER) + # Clients MUST send it; we enforce only a MISMATCH (a wrong version is a + # silent-failure trap), tolerating omission for robustness. + if seen is not None and seen != A2A_VERSION: + raise we.version_not_supported(seen) + + def _authenticate(self, request: Request) -> AuthContext: + ctx = self.auth.authenticate(request.headers) + if ctx is None or not ctx.authenticated: + # A2A authenticates every request; unauthenticated -> 401 (spec §7.4). + raise _Unauthenticated() + return ctx + + # -- routes ------------------------------------------------------------- + async def rpc(self, request: Request) -> Response: + try: + self._check_version(request) + except we.A2AError as exc: + return JSONResponse(self._err(None, exc)) + + try: + body = json.loads(await request.body()) + except Exception: + return JSONResponse(self._err(None, we.JSONParseError("Parse error"))) + + if not isinstance(body, dict) or body.get("jsonrpc") != "2.0" or "method" not in body: + return JSONResponse( + self._err( + body.get("id") if isinstance(body, dict) else None, + we.InvalidRequestError("Invalid Request"), + ) + ) + + req_id = body.get("id") + method = body["method"] + params = body.get("params") or {} + + try: + ctx = self._authenticate(request) + except _Unauthenticated: + return JSONResponse( + { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32600, "message": "Unauthenticated"}, + }, + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + ) + + if method in _PUSH_METHODS: + return JSONResponse(self._err(req_id, we.push_not_supported())) + + if method in _STREAMING: + return self._stream_response(req_id, method, params, ctx) + + return self._unary_response(req_id, method, params, ctx) + + def _unary_response(self, req_id, method, params, ctx) -> Response: + try: + result = self._dispatch_unary(method, params, ctx) + except we.A2AError as exc: + return JSONResponse(self._err(req_id, exc)) + except Exception as exc: # never leak internals (authz.md §6) + return JSONResponse(self._err(req_id, we.InternalError("Internal error"))) + return JSONResponse(self._ok(req_id, result)) + + def _dispatch_unary(self, method, params, ctx) -> Any: + if method == "SendMessage": + return self.adapter.send_message(params, ctx) + if method == "GetTask": + return self.adapter.get_task(params, ctx) + if method == "ListTasks": + return self.adapter.list_tasks(params, ctx) + if method == "CancelTask": + return self.adapter.cancel_task(params, ctx) + if method == "GetExtendedAgentCard": + return self.adapter.extended_card(params.get("tenant"), ctx) + raise we.MethodNotFoundError(f"Method not found: {method}") + + def _stream_response(self, req_id, method, params, ctx) -> Response: + def frames() -> Iterator[dict]: + if method == "SendStreamingMessage": + yield from self.adapter.send_streaming_message(params, ctx) + elif method == "SubscribeToTask": + yield from self.adapter.subscribe_to_task(params, ctx) + else: # pragma: no cover + raise we.MethodNotFoundError(f"Method not found: {method}") + + def sse() -> Iterator[bytes]: + try: + for frame in frames(): + payload = self._ok(req_id, frame) + yield f"data: {json.dumps(payload)}\n\n".encode("utf-8") + except we.A2AError as exc: + yield f"data: {json.dumps(self._err(req_id, exc))}\n\n".encode("utf-8") + except Exception: + yield f"data: {json.dumps(self._err(req_id, we.InternalError('Internal error')))}\n\n".encode( + "utf-8" + ) + + return StreamingResponse(sse(), media_type="text/event-stream") + + async def well_known_card(self, request: Request) -> Response: + tenant = self._card_tenant(request) + if tenant is None: + return JSONResponse({"error": "tenant not found"}, status_code=404) + try: + card = self.adapter.well_known_card(tenant) + except we.A2AError: + return JSONResponse({"error": "not found"}, status_code=404) + return JSONResponse(card, headers={"Cache-Control": "public, max-age=60"}) + + async def extended_card(self, request: Request) -> Response: + try: + ctx = self._authenticate(request) + except _Unauthenticated: + return JSONResponse( + {"error": "unauthenticated"}, + status_code=401, + headers={"WWW-Authenticate": "Bearer"}, + ) + tenant = self._card_tenant(request) + if tenant is None: + return JSONResponse({"error": "tenant not found"}, status_code=404) + try: + card = self.adapter.extended_card(tenant, ctx) + except we.A2AError: + return JSONResponse({"error": "not found"}, status_code=404) + return JSONResponse(card) + + def _card_tenant(self, request: Request) -> str | None: + """Pick the tenant for a card route. + + The shared server fronts many tenants at one URL, disambiguated by ``tenant`` + (card-projection.md §2). Discovery selects it via ``?tenant=``; a single-tenant + host (e.g. an external per-repo host) needs no query param. + """ + tenant = request.query_params.get("tenant") + if tenant: + return tenant + enabled = [t.tenant for t in self.adapter.config.tenants if t.enabled] + return enabled[0] if len(enabled) == 1 else None + + async def healthz(self, request: Request) -> Response: + return PlainTextResponse("ok") + + # -- app ---------------------------------------------------------------- + def routes(self) -> list[Route]: + return [ + Route("/rpc", self.rpc, methods=["POST"]), + Route("/.well-known/agent-card.json", self.well_known_card, methods=["GET"]), + Route("/extendedAgentCard", self.extended_card, methods=["GET"]), + Route("/healthz", self.healthz, methods=["GET"]), + ] + + def app(self) -> Starlette: + return Starlette(routes=self.routes()) + + +class _Unauthenticated(Exception): + pass + + +def build_app(adapter: A2AAdapter, authenticator: Authenticator) -> Starlette: + return A2AServer(adapter, authenticator).app() diff --git a/agent-templates/a2a/session_store.py b/agent-templates/a2a/session_store.py index 9de63b2..2b64770 100644 --- a/agent-templates/a2a/session_store.py +++ b/agent-templates/a2a/session_store.py @@ -10,6 +10,7 @@ only status a session reports is what ``run_until_block`` returned. The snapshot is a reflection of that result; the adapter never invents a transition. """ + from __future__ import annotations import threading @@ -51,7 +52,9 @@ def owned(self, session_id: str, caller: str) -> SessionRecord | None: return None return rec - def update(self, session_id: str, *, task: dict, terminal: bool, pending_tool_use_id=None) -> None: + def update( + self, session_id: str, *, task: dict, terminal: bool, pending_tool_use_id=None + ) -> None: with self._lock: rec = self._by_id.get(session_id) if rec is None: diff --git a/agent-templates/a2a/task_mapper.py b/agent-templates/a2a/task_mapper.py index ffbd09c..3f68980 100644 --- a/agent-templates/a2a/task_mapper.py +++ b/agent-templates/a2a/task_mapper.py @@ -15,6 +15,7 @@ ``TASK_STATE_UNSPECIFIED`` MUST never be emitted. """ + from __future__ import annotations import re @@ -73,7 +74,9 @@ def now_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") -def agent_message(text: str, *, context_id: str | None = None, task_id: str | None = None) -> Message: +def agent_message( + text: str, *, context_id: str | None = None, task_id: str | None = None +) -> Message: """An agent-role Message carrying a single text part.""" return Message( messageId=str(uuid.uuid4()), @@ -160,7 +163,9 @@ def map_result( message = agent_message(text, context_id=context_id, task_id=session_id) if text else None elif status == "error": state = TaskState.TASK_STATE_FAILED - message = agent_message(text or "The task failed.", context_id=context_id, task_id=session_id) + message = agent_message( + text or "The task failed.", context_id=context_id, task_id=session_id + ) elif status == "blocked": state = classify_pause(pending) message = agent_message( @@ -200,6 +205,4 @@ def rejected_task(session_id: str, context_id: str) -> Task: def canceled_task(session_id: str, context_id: str) -> Task: - return Task( - id=session_id, contextId=context_id, status=_status(TaskState.TASK_STATE_CANCELED) - ) + return Task(id=session_id, contextId=context_id, status=_status(TaskState.TASK_STATE_CANCELED)) diff --git a/agent-templates/a2a/tests/conftest.py b/agent-templates/a2a/tests/conftest.py index 6cc9fd6..b9450fc 100644 --- a/agent-templates/a2a/tests/conftest.py +++ b/agent-templates/a2a/tests/conftest.py @@ -4,6 +4,7 @@ contract client package resolve without an editable install, mirroring how the server process is launched. """ + from __future__ import annotations import json @@ -12,8 +13,8 @@ import pytest -_A2A_PKG = Path(__file__).resolve().parents[1] # .../agent-templates/a2a -_AGENT_TEMPLATES = _A2A_PKG.parent # .../agent-templates +_A2A_PKG = Path(__file__).resolve().parents[1] # .../agent-templates/a2a +_AGENT_TEMPLATES = _A2A_PKG.parent # .../agent-templates for p in (str(_AGENT_TEMPLATES),): if p not in sys.path: sys.path.insert(0, p) diff --git a/agent-templates/a2a/tests/test_adapter.py b/agent-templates/a2a/tests/test_adapter.py index 18c2855..02ded3a 100644 --- a/agent-templates/a2a/tests/test_adapter.py +++ b/agent-templates/a2a/tests/test_adapter.py @@ -3,6 +3,7 @@ The fake stands in for the Managed-Agents runtime below the providers/base.py seam, so these tests exercise the TRANSLATION only — the adapter's entire responsibility. """ + from __future__ import annotations import pytest @@ -27,8 +28,9 @@ def __init__(self, results=None): def ensure_agent(self, manifest, multiagent=None): return {"name": manifest.get("role", "x"), "id": "agent-1", "version": "1"} - def create_session(self, agent_id, version, environment_id, vault_ids=None, - memory_resources=None, title=None): + def create_session( + self, agent_id, version, environment_id, vault_ids=None, memory_resources=None, title=None + ): self._n += 1 sid = f"sess-{self._n}" self.sessions.append({"id": sid, "title": title, "vault_ids": vault_ids}) @@ -86,8 +88,15 @@ def _adapter(cfg, provider, resolver): def test_send_message_completed(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", - "parts": [{"text": "Create tickets"}], "metadata": {"skillId": "product-manager"}}} + params = { + "tenant": "FuzePlan", + "message": { + "messageId": "m1", + "role": "ROLE_USER", + "parts": [{"text": "Create tickets"}], + "metadata": {"skillId": "product-manager"}, + }, + } out = a.send_message(params, _ctx()) task = out["task"] assert task["status"]["state"] == "TASK_STATE_COMPLETED" @@ -100,8 +109,10 @@ def test_send_message_completed(fuzeplan_cfg, resolver): def test_entry_role_used_when_no_skill(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", - "parts": [{"text": "hi"}]}} + params = { + "tenant": "FuzePlan", + "message": {"messageId": "m1", "role": "ROLE_USER", "parts": [{"text": "hi"}]}, + } out = a.send_message(params, _ctx()) assert out["task"]["status"]["state"] == "TASK_STATE_COMPLETED" @@ -110,8 +121,10 @@ def test_entry_role_used_when_no_skill(fuzeplan_cfg, resolver): def test_unauthorized_caller_rejected(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}} + params = { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + } out = a.send_message(params, _ctx(caller="FuzeMalory")) assert out["task"]["status"]["state"] == "TASK_STATE_REJECTED" assert out["task"]["status"]["message"]["parts"][0]["text"] == "Not authorized." @@ -122,8 +135,10 @@ def test_unauthorized_caller_rejected(fuzeplan_cfg, resolver): def test_unknown_tenant_is_generic_rejected(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "Nope", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}} + params = { + "tenant": "Nope", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + } out = a.send_message(params, _ctx()) assert out["task"]["status"]["state"] == "TASK_STATE_REJECTED" @@ -131,75 +146,146 @@ def test_unknown_tenant_is_generic_rejected(fuzeplan_cfg, resolver): def test_unknown_skill_rejected(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}], "metadata": {"skillId": "does-not-exist"}}} + params = { + "tenant": "FuzePlan", + "message": { + "messageId": "m", + "role": "ROLE_USER", + "parts": [{"text": "x"}], + "metadata": {"skillId": "does-not-exist"}, + }, + } out = a.send_message(params, _ctx()) assert out["task"]["status"]["state"] == "TASK_STATE_REJECTED" # --- interrupted + continuation --------------------------------------------- def test_input_required_then_continue_confirms_tool(fuzeplan_cfg, resolver): - prov = FakeProvider(results={ - "sess-1": [ - {"text": "May I create 12 tickets?", "status": "blocked", - "pending": {"event_ids": ["tu-1"], "tools": {"tu-1": "create_tickets({\"n\":12})"}}}, - {"text": "created", "status": "idle", "pending": None}, - ] - }) + prov = FakeProvider( + results={ + "sess-1": [ + { + "text": "May I create 12 tickets?", + "status": "blocked", + "pending": { + "event_ids": ["tu-1"], + "tools": {"tu-1": 'create_tickets({"n":12})'}, + }, + }, + {"text": "created", "status": "idle", "pending": None}, + ] + } + ) a = _adapter(fuzeplan_cfg, prov, resolver) - p1 = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", - "parts": [{"text": "Create 12 tickets"}], "metadata": {"skillId": "product-manager"}}} + p1 = { + "tenant": "FuzePlan", + "message": { + "messageId": "m1", + "role": "ROLE_USER", + "parts": [{"text": "Create 12 tickets"}], + "metadata": {"skillId": "product-manager"}, + }, + } t1 = a.send_message(p1, _ctx())["task"] assert t1["status"]["state"] == "TASK_STATE_INPUT_REQUIRED" sid = t1["id"] # caller answers on the SAME taskId - p2 = {"tenant": "FuzePlan", "message": {"messageId": "m2", "role": "ROLE_USER", - "taskId": sid, "parts": [{"text": "yes, go ahead"}]}} + p2 = { + "tenant": "FuzePlan", + "message": { + "messageId": "m2", + "role": "ROLE_USER", + "taskId": sid, + "parts": [{"text": "yes, go ahead"}], + }, + } t2 = a.send_message(p2, _ctx())["task"] assert t2["status"]["state"] == "TASK_STATE_COMPLETED" assert prov.confirmed == [(sid, "tu-1", True, None)] def test_continue_deny_passes_reason(fuzeplan_cfg, resolver): - prov = FakeProvider(results={ - "sess-1": [ - {"text": "may I?", "status": "blocked", - "pending": {"event_ids": ["tu-9"], "tools": {"tu-9": "open_pr({})"}}}, - {"text": "ok, stopped", "status": "idle", "pending": None}, - ] - }) + prov = FakeProvider( + results={ + "sess-1": [ + { + "text": "may I?", + "status": "blocked", + "pending": {"event_ids": ["tu-9"], "tools": {"tu-9": "open_pr({})"}}, + }, + {"text": "ok, stopped", "status": "idle", "pending": None}, + ] + } + ) a = _adapter(fuzeplan_cfg, prov, resolver) - p1 = {"tenant": "FuzePlan", "message": {"messageId": "m1", "role": "ROLE_USER", - "parts": [{"text": "open a PR"}]}} + p1 = { + "tenant": "FuzePlan", + "message": {"messageId": "m1", "role": "ROLE_USER", "parts": [{"text": "open a PR"}]}, + } sid = a.send_message(p1, _ctx())["task"]["id"] - p2 = {"tenant": "FuzePlan", "message": {"messageId": "m2", "role": "ROLE_USER", - "taskId": sid, "parts": [{"text": "deny - too risky"}]}} + p2 = { + "tenant": "FuzePlan", + "message": { + "messageId": "m2", + "role": "ROLE_USER", + "taskId": sid, + "parts": [{"text": "deny - too risky"}], + }, + } a.send_message(p2, _ctx()) assert prov.confirmed[0][2] is False assert "too risky" in prov.confirmed[0][3] def test_continue_foreign_task_is_not_found(fuzeplan_cfg, resolver): - prov = FakeProvider(results={"sess-1": [ - {"text": "?", "status": "blocked", "pending": {"event_ids": ["t"], "tools": {"t": "x"}}}]}) + prov = FakeProvider( + results={ + "sess-1": [ + { + "text": "?", + "status": "blocked", + "pending": {"event_ids": ["t"], "tools": {"t": "x"}}, + } + ] + } + ) a = _adapter(fuzeplan_cfg, prov, resolver) - sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}}, _ctx(caller="FuzeSales"))["task"]["id"] + sid = a.send_message( + { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + }, + _ctx(caller="FuzeSales"), + )["task"]["id"] # a DIFFERENT allowlisted caller may not touch it from a2a.wire_errors import TaskNotFoundError + with pytest.raises(TaskNotFoundError): - a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m2", "role": "ROLE_USER", - "taskId": sid, "parts": [{"text": "yes"}]}}, _ctx(caller="FuzeService")) + a.send_message( + { + "tenant": "FuzePlan", + "message": { + "messageId": "m2", + "role": "ROLE_USER", + "taskId": sid, + "parts": [{"text": "yes"}], + }, + }, + _ctx(caller="FuzeService"), + ) # --- parts handling --------------------------------------------------------- def test_url_part_rejected_content_type(fuzeplan_cfg, resolver): from a2a.wire_errors import ContentTypeNotSupportedError + prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"url": "http://x/y"}]}} + params = { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"url": "http://x/y"}]}, + } with pytest.raises(ContentTypeNotSupportedError): a.send_message(params, _ctx()) @@ -208,12 +294,18 @@ def test_url_part_rejected_content_type(fuzeplan_cfg, resolver): def test_get_task_scoped_to_caller(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}}, _ctx(caller="FuzeSales"))["task"]["id"] + sid = a.send_message( + { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + }, + _ctx(caller="FuzeSales"), + )["task"]["id"] got = a.get_task({"id": sid, "tenant": "FuzePlan"}, _ctx(caller="FuzeSales")) assert got["id"] == sid from a2a.wire_errors import TaskNotFoundError + with pytest.raises(TaskNotFoundError): a.get_task({"id": sid}, _ctx(caller="FuzeService")) @@ -221,20 +313,44 @@ def test_get_task_scoped_to_caller(fuzeplan_cfg, resolver): def test_list_tasks_only_own(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}}, _ctx(caller="FuzeSales")) - a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "y"}]}}, _ctx(caller="FuzeService")) + a.send_message( + { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + }, + _ctx(caller="FuzeSales"), + ) + a.send_message( + { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "y"}]}, + }, + _ctx(caller="FuzeService"), + ) sales = a.list_tasks({"tenant": "FuzePlan"}, _ctx(caller="FuzeSales")) assert len(sales["tasks"]) == 1 def test_cancel_task_archives_and_marks_canceled(fuzeplan_cfg, resolver): - prov = FakeProvider(results={"sess-1": [ - {"text": "?", "status": "blocked", "pending": {"event_ids": ["t"], "tools": {"t": "x"}}}]}) + prov = FakeProvider( + results={ + "sess-1": [ + { + "text": "?", + "status": "blocked", + "pending": {"event_ids": ["t"], "tools": {"t": "x"}}, + } + ] + } + ) a = _adapter(fuzeplan_cfg, prov, resolver) - sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}}, _ctx())["task"]["id"] + sid = a.send_message( + { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + }, + _ctx(), + )["task"]["id"] out = a.cancel_task({"id": sid, "tenant": "FuzePlan"}, _ctx()) assert out["status"]["state"] == "TASK_STATE_CANCELED" assert prov.archived == [sid] @@ -242,10 +358,16 @@ def test_cancel_task_archives_and_marks_canceled(fuzeplan_cfg, resolver): def test_cancel_terminal_task_not_cancelable(fuzeplan_cfg, resolver): from a2a.wire_errors import TaskNotCancelableError + prov = FakeProvider() # completes immediately -> terminal a = _adapter(fuzeplan_cfg, prov, resolver) - sid = a.send_message({"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}}, _ctx())["task"]["id"] + sid = a.send_message( + { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + }, + _ctx(), + )["task"]["id"] with pytest.raises(TaskNotCancelableError): a.cancel_task({"id": sid, "tenant": "FuzePlan"}, _ctx()) @@ -254,8 +376,10 @@ def test_cancel_terminal_task_not_cancelable(fuzeplan_cfg, resolver): def test_streaming_yields_working_then_terminal(fuzeplan_cfg, resolver): prov = FakeProvider() a = _adapter(fuzeplan_cfg, prov, resolver) - params = {"tenant": "FuzePlan", "message": {"messageId": "m", "role": "ROLE_USER", - "parts": [{"text": "x"}]}} + params = { + "tenant": "FuzePlan", + "message": {"messageId": "m", "role": "ROLE_USER", "parts": [{"text": "x"}]}, + } frames = list(a.send_streaming_message(params, _ctx())) states = [] for f in frames: @@ -277,6 +401,7 @@ def test_well_known_card_public(fuzeplan_cfg, resolver): def test_extended_card_requires_authorization(fuzeplan_cfg, resolver): from a2a.wire_errors import TaskNotFoundError + a = _adapter(fuzeplan_cfg, FakeProvider(), resolver) # allowlisted caller gets it card = a.extended_card("FuzePlan", _ctx(caller="FuzeSales")) diff --git a/agent-templates/a2a/tests/test_authz.py b/agent-templates/a2a/tests/test_authz.py index 32d7d2f..8db3afa 100644 --- a/agent-templates/a2a/tests/test_authz.py +++ b/agent-templates/a2a/tests/test_authz.py @@ -2,6 +2,7 @@ The security-critical property is FAIL-CLOSED: absent/empty ``providesTo`` denies. """ + from __future__ import annotations import pytest @@ -53,7 +54,13 @@ def test_invalid_caller_identity_denied(): @pytest.mark.parametrize( "caller,ok", - [("FuzeSales", True), ("Exec-cto", True), ("izzywdev/FuzeSales", False), ("", False), ("bad name", False)], + [ + ("FuzeSales", True), + ("Exec-cto", True), + ("izzywdev/FuzeSales", False), + ("", False), + ("bad name", False), + ], ) def test_valid_caller_identity(caller, ok): assert valid_caller_identity(caller) is ok diff --git a/agent-templates/a2a/tests/test_card_generator.py b/agent-templates/a2a/tests/test_card_generator.py index 6490a96..4bb4dee 100644 --- a/agent-templates/a2a/tests/test_card_generator.py +++ b/agent-templates/a2a/tests/test_card_generator.py @@ -6,6 +6,7 @@ the contract actually fixes — schema validity and the derived tag SET — not byte equality with the example. """ + from __future__ import annotations import pytest diff --git a/agent-templates/a2a/tests/test_config.py b/agent-templates/a2a/tests/test_config.py new file mode 100644 index 0000000..d7053c0 --- /dev/null +++ b/agent-templates/a2a/tests/test_config.py @@ -0,0 +1,78 @@ +"""Unit tests for parsing the values-interface config document.""" + +from __future__ import annotations + +from a2a.config import load_config + +VALUES = { + "a2a": { + "enabled": True, + "image": {"repository": "ghcr.io/izzywdev/fuzeagent-a2a", "tag": "1.2.3"}, + "service": {"type": "ClusterIP", "port": 8080}, + "protocolVersion": "1.0", + "auth": { + "oidcIssuerUrl": "https://auth.prod.fuzefront.com", + "audience": "a2a", + "callerClaim": "azp", + "mtls": {"enabled": True, "caSecretRef": {"name": "ca", "key": "tls.crt"}}, + }, + "cardSigning": {"keySecretRef": {"name": "k", "key": "jwk"}, "keyId": "fuze-a2a-2026-07"}, + "tenants": [ + { + "tenant": "FuzePlan", + "repo": "izzywdev/FuzePlan", + "enabled": True, + "ref": "main", + "entryRole": "product-manager", + "servingRoles": ["product-manager", "ux-designer"], + "provider": { + "name": "anthropic", + "environmentId": "env-1", + "vaultIds": ["v1", "v2"], + "memoryResources": ["handoff"], + }, + }, + {"tenant": "Exec-cto", "repo": "izzywdev/FuzeInfra", "enabled": False}, + ], + } +} + + +def test_load_config_top_level(): + cfg = load_config(VALUES) + assert cfg.enabled is True + assert cfg.port == 8080 + assert cfg.protocol_version == "1.0" + assert cfg.image_tag == "1.2.3" + assert cfg.card_key_id == "fuze-a2a-2026-07" + + +def test_load_config_auth(): + cfg = load_config(VALUES) + assert cfg.auth.oidc_issuer_url == "https://auth.prod.fuzefront.com" + assert cfg.auth.caller_claim == "azp" + assert cfg.auth.audience == "a2a" + assert cfg.auth.mtls_enabled is True + + +def test_load_config_tenants_and_enabled_gate(): + cfg = load_config(VALUES) + assert len(cfg.tenants) == 2 + fp = cfg.tenant("FuzePlan") + assert fp is not None + assert fp.entry_role == "product-manager" + assert fp.serving_roles == ("product-manager", "ux-designer") + assert fp.provider.vault_ids == ("v1", "v2") + assert fp.provider.environment_id == "env-1" + # disabled tenant is not resolvable via tenant() + assert cfg.tenant("Exec-cto") is None + + +def test_load_config_accepts_inner_block(): + cfg = load_config(VALUES["a2a"]) + assert cfg.enabled is True + + +def test_default_caller_claim_is_sub(): + cfg = load_config({"a2a": {"enabled": True, "auth": {"oidcIssuerUrl": "https://x"}}}) + assert cfg.auth.caller_claim == "sub" diff --git a/agent-templates/a2a/tests/test_identity.py b/agent-templates/a2a/tests/test_identity.py new file mode 100644 index 0000000..1409ee1 --- /dev/null +++ b/agent-templates/a2a/tests/test_identity.py @@ -0,0 +1,62 @@ +"""Unit tests for credential -> trusted identity (authz.md §2).""" + +from __future__ import annotations + +from a2a.config import AuthConfig +from a2a.identity import OidcAuthenticator, StaticAuthenticator + + +class Headers(dict): + def get(self, key, default=None): + # case-insensitive like starlette Headers + for k, v in self.items(): + if k.lower() == key.lower(): + return v + return default + + +def test_oidc_fail_closed_without_verifier(): + auth = AuthConfig(oidc_issuer_url="https://x") + a = OidcAuthenticator(auth, token_verifier=None) + assert a.authenticate(Headers({"Authorization": "Bearer abc"})) is None + + +def test_oidc_reads_caller_claim_and_scopes(): + auth = AuthConfig(oidc_issuer_url="https://x", caller_claim="azp") + a = OidcAuthenticator( + auth, token_verifier=lambda t: {"azp": "FuzeSales", "scope": "a2a.read a2a.write"} + ) + ctx = a.authenticate(Headers({"Authorization": "Bearer good"})) + assert ctx.caller == "FuzeSales" + assert ctx.scopes == frozenset({"a2a.read", "a2a.write"}) + + +def test_oidc_rejects_when_verifier_raises(): + auth = AuthConfig(oidc_issuer_url="https://x") + + def boom(_): + raise ValueError("bad signature") + + a = OidcAuthenticator(auth, token_verifier=boom) + assert a.authenticate(Headers({"Authorization": "Bearer bad"})) is None + + +def test_oidc_no_bearer_is_unauthenticated(): + auth = AuthConfig(oidc_issuer_url="https://x") + a = OidcAuthenticator(auth, token_verifier=lambda t: {"sub": "x"}) + assert a.authenticate(Headers({})) is None + + +def test_scp_list_claim(): + auth = AuthConfig(oidc_issuer_url="https://x") + a = OidcAuthenticator(auth, token_verifier=lambda t: {"sub": "FuzeSales", "scp": ["a", "b"]}) + ctx = a.authenticate(Headers({"Authorization": "Bearer good"})) + assert ctx.scopes == frozenset({"a", "b"}) + + +def test_static_authenticator(): + a = StaticAuthenticator({"tok": "FuzeSales"}, {"FuzeSales": {"a2a.exec.escalate"}}) + ctx = a.authenticate(Headers({"Authorization": "Bearer tok"})) + assert ctx.caller == "FuzeSales" + assert "a2a.exec.escalate" in ctx.scopes + assert a.authenticate(Headers({"Authorization": "Bearer nope"})) is None diff --git a/agent-templates/a2a/tests/test_server.py b/agent-templates/a2a/tests/test_server.py new file mode 100644 index 0000000..ca302ac --- /dev/null +++ b/agent-templates/a2a/tests/test_server.py @@ -0,0 +1,192 @@ +"""Unit tests for the HTTP + SSE transport (binding.md).""" + +from __future__ import annotations + +import json + +import pytest +from a2a.adapter import A2AAdapter +from a2a.config import ProviderBinding, ServerConfig, TenantConfig +from a2a.identity import StaticAuthenticator +from a2a.loader import load_repo +from a2a.server import build_app +from starlette.testclient import TestClient + +from .test_adapter import FakeProvider + + +@pytest.fixture +def client(fuzeplan_repo): + cfg = ServerConfig( + enabled=True, + tenants=( + TenantConfig( + tenant="FuzePlan", + repo="izzywdev/FuzePlan", + enabled=True, + entry_role="product-manager", + provider=ProviderBinding(name="fake"), + ), + ), + ) + adapter = A2AAdapter(cfg, FakeProvider(), lambda t: load_repo(fuzeplan_repo)) + auth = StaticAuthenticator({"tok-sales": "FuzeSales", "tok-mal": "FuzeMalory"}) + app = build_app(adapter, auth) + return TestClient(app) + + +def _hdr(token="tok-sales"): + return { + "Authorization": f"Bearer {token}", + "A2A-Version": "1.0", + "Content-Type": "application/json", + } + + +def _rpc(method, params, req_id="req-1"): + return {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params} + + +def _msg(text="hi", **extra): + m = {"messageId": "m1", "role": "ROLE_USER", "parts": [{"text": text}]} + m.update(extra) + return m + + +# --- discovery -------------------------------------------------------------- +def test_well_known_card_unauthenticated(client): + r = client.get("/.well-known/agent-card.json?tenant=FuzePlan") + assert r.status_code == 200 + card = r.json() + assert card["supportedInterfaces"][0]["tenant"] == "FuzePlan" + assert card["signatures"] + + +def test_well_known_single_tenant_default(client): + r = client.get("/.well-known/agent-card.json") + assert r.status_code == 200 + + +def test_healthz(client): + assert client.get("/healthz").text == "ok" + + +# --- SendMessage ------------------------------------------------------------ +def test_send_message_completed(client): + r = client.post( + "/rpc", json=_rpc("SendMessage", {"tenant": "FuzePlan", "message": _msg()}), headers=_hdr() + ) + assert r.status_code == 200 + body = r.json() + assert body["id"] == "req-1" + assert body["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED" + + +def test_unauthenticated_rpc_is_401(client): + r = client.post( + "/rpc", + json=_rpc("SendMessage", {"tenant": "FuzePlan", "message": _msg()}), + headers={"A2A-Version": "1.0"}, + ) + assert r.status_code == 401 + assert r.headers.get("WWW-Authenticate") == "Bearer" + + +def test_denied_caller_gets_rejected_task(client): + r = client.post( + "/rpc", + json=_rpc("SendMessage", {"tenant": "FuzePlan", "message": _msg()}), + headers=_hdr("tok-mal"), + ) + assert r.status_code == 200 + assert r.json()["result"]["task"]["status"]["state"] == "TASK_STATE_REJECTED" + + +# --- version + method errors ------------------------------------------------ +def test_wrong_version_header_is_32009(client): + h = _hdr() + h["A2A-Version"] = "2.0" + r = client.post( + "/rpc", json=_rpc("SendMessage", {"tenant": "FuzePlan", "message": _msg()}), headers=h + ) + assert r.json()["error"]["code"] == -32009 + + +def test_unknown_method_is_32601(client): + r = client.post("/rpc", json=_rpc("Frobnicate", {}), headers=_hdr()) + assert r.json()["error"]["code"] == -32601 + + +def test_push_notification_method_is_32003(client): + r = client.post("/rpc", json=_rpc("CreateTaskPushNotificationConfig", {}), headers=_hdr()) + assert r.json()["error"]["code"] == -32003 + + +def test_parse_error_is_32700(client): + r = client.post("/rpc", content=b"{not json", headers=_hdr()) + assert r.json()["error"]["code"] == -32700 + + +def test_invalid_request_missing_method(client): + r = client.post("/rpc", json={"jsonrpc": "2.0", "id": "x"}, headers=_hdr()) + assert r.json()["error"]["code"] == -32600 + + +# --- GetTask ---------------------------------------------------------------- +def test_get_task_roundtrip(client): + send = client.post( + "/rpc", json=_rpc("SendMessage", {"tenant": "FuzePlan", "message": _msg()}), headers=_hdr() + ).json() + sid = send["result"]["task"]["id"] + r = client.post("/rpc", json=_rpc("GetTask", {"id": sid, "tenant": "FuzePlan"}), headers=_hdr()) + assert r.json()["result"]["id"] == sid + + +def test_get_task_other_caller_is_32001(client): + send = client.post( + "/rpc", + json=_rpc("SendMessage", {"tenant": "FuzePlan", "message": _msg()}), + headers=_hdr("tok-sales"), + ).json() + sid = send["result"]["task"]["id"] + # FuzeMalory isn't even allowlisted, but the point is disclosure parity: -32001 + r = client.post("/rpc", json=_rpc("GetTask", {"id": sid}), headers=_hdr("tok-mal")) + assert r.json()["error"]["code"] == -32001 + + +# --- streaming -------------------------------------------------------------- +def test_streaming_sse_frames(client): + payload = _rpc("SendStreamingMessage", {"tenant": "FuzePlan", "message": _msg()}) + with client.stream("POST", "/rpc", json=payload, headers=_hdr()) as resp: + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + states = [] + for line in resp.iter_lines(): + if not line: + continue + line = line if isinstance(line, str) else line.decode() + if line.startswith("data:"): + frame = json.loads(line[len("data:") :].strip()) + result = frame["result"] + if "task" in result: + states.append(result["task"]["status"]["state"]) + elif "statusUpdate" in result: + states.append(result["statusUpdate"]["status"]["state"]) + assert states[0] == "TASK_STATE_SUBMITTED" + assert states[-1] == "TASK_STATE_COMPLETED" + + +# --- extended card ---------------------------------------------------------- +def test_extended_card_requires_auth(client): + assert client.get("/extendedAgentCard?tenant=FuzePlan").status_code == 401 + + +def test_extended_card_allowlisted(client): + r = client.get("/extendedAgentCard?tenant=FuzePlan", headers=_hdr()) + assert r.status_code == 200 + assert r.json()["skills"] + + +def test_extended_card_denied_is_404(client): + r = client.get("/extendedAgentCard?tenant=FuzePlan", headers=_hdr("tok-mal")) + assert r.status_code == 404 diff --git a/agent-templates/a2a/tests/test_task_mapper.py b/agent-templates/a2a/tests/test_task_mapper.py index c3119ee..1c23de9 100644 --- a/agent-templates/a2a/tests/test_task_mapper.py +++ b/agent-templates/a2a/tests/test_task_mapper.py @@ -1,4 +1,5 @@ """Unit tests for the run_until_block -> A2A Task mapping (state-mapping.md §3/§4).""" + from __future__ import annotations import pytest @@ -7,12 +8,18 @@ def _blocked(desc: str) -> dict: - return {"text": "", "status": "blocked", "pending": {"event_ids": ["e1"], "tools": {"e1": desc}}} + return { + "text": "", + "status": "blocked", + "pending": {"event_ids": ["e1"], "tools": {"e1": desc}}, + } # --- the core status table -------------------------------------------------- def test_idle_maps_to_completed_with_agent_message(): - task = tm.map_result({"text": "all done", "status": "idle", "pending": None}, session_id="s", context_id="c") + task = tm.map_result( + {"text": "all done", "status": "idle", "pending": None}, session_id="s", context_id="c" + ) assert task.status.state == TaskState.TASK_STATE_COMPLETED assert task.id == "s" and task.contextId == "c" assert task.status.message.role.value == "ROLE_AGENT" @@ -21,7 +28,9 @@ def test_idle_maps_to_completed_with_agent_message(): def test_error_maps_to_failed(): - task = tm.map_result({"text": "kaboom", "status": "error", "pending": None}, session_id="s", context_id="c") + task = tm.map_result( + {"text": "kaboom", "status": "error", "pending": None}, session_id="s", context_id="c" + ) assert task.status.state == TaskState.TASK_STATE_FAILED assert "kaboom" in task.status.message.parts[0].root.text @@ -34,7 +43,9 @@ def test_blocked_tool_decision_is_input_required(): def test_blocked_credential_is_auth_required(): - task = tm.map_result(_blocked('fetch_credential({"vault":"atlassian"})'), session_id="s", context_id="c") + task = tm.map_result( + _blocked('fetch_credential({"vault":"atlassian"})'), session_id="s", context_id="c" + ) assert task.status.state == TaskState.TASK_STATE_AUTH_REQUIRED @@ -44,9 +55,9 @@ def test_blocked_credential_is_auth_required(): ('create_tickets({"n":12})', TaskState.TASK_STATE_INPUT_REQUIRED), ('open_pr({"target":"prod"})', TaskState.TASK_STATE_INPUT_REQUIRED), ('oauth_authorize({"provider":"github"})', TaskState.TASK_STATE_AUTH_REQUIRED), - ('get_api_key({})', TaskState.TASK_STATE_AUTH_REQUIRED), - ('request access_grant for repo', TaskState.TASK_STATE_AUTH_REQUIRED), - ('use_token({})', TaskState.TASK_STATE_AUTH_REQUIRED), + ("get_api_key({})", TaskState.TASK_STATE_AUTH_REQUIRED), + ("request access_grant for repo", TaskState.TASK_STATE_AUTH_REQUIRED), + ("use_token({})", TaskState.TASK_STATE_AUTH_REQUIRED), ], ) def test_pause_classifier(desc, expected): @@ -56,7 +67,11 @@ def test_pause_classifier(desc, expected): def test_pause_reason_prefers_agent_text(): pending = {"event_ids": ["e1"], "tools": {"e1": "open_pr({})"}} - task = tm.map_result({"text": "May I open this PR against prod?", "status": "blocked", "pending": pending}, session_id="s", context_id="c") + task = tm.map_result( + {"text": "May I open this PR against prod?", "status": "blocked", "pending": pending}, + session_id="s", + context_id="c", + ) assert task.status.message.parts[0].root.text == "May I open this PR against prod?" @@ -73,7 +88,9 @@ def test_pending_tool_use_id(): def test_unmappable_status_raises_never_unspecified(): with pytest.raises(ValueError): - tm.map_result({"text": "", "status": "weird", "pending": None}, session_id="s", context_id="c") + tm.map_result( + {"text": "", "status": "weird", "pending": None}, session_id="s", context_id="c" + ) # --- resting/terminal constructors ----------------------------------------- diff --git a/agent-templates/a2a/validation.py b/agent-templates/a2a/validation.py index 5330f8e..33fc991 100644 --- a/agent-templates/a2a/validation.py +++ b/agent-templates/a2a/validation.py @@ -5,6 +5,7 @@ ``$ref``s the card schema by relative filename, so we resolve refs against the schema directory. """ + from __future__ import annotations import json @@ -26,9 +27,7 @@ def _validator(root_schema_name: str) -> Draft202012Validator: schema = _schema(root_schema_name) # Resolve sibling $ref filenames (agent-card.schema.json) against the schema dir. store = { - s["$id"]: s - for s in (_schema(p.name) for p in SCHEMA_DIR.glob("*.json")) - if "$id" in s + s["$id"]: s for s in (_schema(p.name) for p in SCHEMA_DIR.glob("*.json")) if "$id" in s } base = SCHEMA_DIR.as_uri() + "/" resolver = RefResolver(base_uri=base, referrer=schema, store=store) diff --git a/agent-templates/a2a/wire_errors.py b/agent-templates/a2a/wire_errors.py index db9d238..b4cde8c 100644 --- a/agent-templates/a2a/wire_errors.py +++ b/agent-templates/a2a/wire_errors.py @@ -6,6 +6,7 @@ ``A2AError`` into the on-the-wire ``{"code","message","data"}`` object, where ``data`` is an ARRAY whose elements carry a ProtoJSON ``@type`` (binding.md §3). """ + from __future__ import annotations from typing import Any From 72bcac5175fbe67f4354a61412be7eb72404420e Mon Sep 17 00:00:00 2001 From: "Izzy Weinberg (backend-engineer)" Date: Wed, 22 Jul 2026 19:51:24 +0300 Subject: [PATCH 6/6] fix(a2a): pin workflow actions to commit SHAs; drop bind-all default (B104) - a2a-unit.yml: pin actions/checkout@v4.2.2 and actions/setup-python@v5.3.0 to full commit SHAs (supply-chain: no mutable action tags). - runtime.main: default HOST to 127.0.0.1; chart sets HOST=0.0.0.0 explicitly. Fixes the one bandit B104 finding introduced in agent-templates/a2a. Co-Authored-By: Claude Claude-Session-Id: 17fb89fd-3579-433b-a6c4-9c9e7f3ec549 --- .github/workflows/a2a-unit.yml | 4 ++-- agent-templates/a2a/runtime.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/a2a-unit.yml b/.github/workflows/a2a-unit.yml index 83004c5..76be3f6 100644 --- a/.github/workflows/a2a-unit.yml +++ b/.github/workflows/a2a-unit.yml @@ -23,10 +23,10 @@ jobs: a2a-unit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.11' diff --git a/agent-templates/a2a/runtime.py b/agent-templates/a2a/runtime.py index ce663b6..a7fe0b4 100644 --- a/agent-templates/a2a/runtime.py +++ b/agent-templates/a2a/runtime.py @@ -104,7 +104,10 @@ def main() -> None: # pragma: no cover import uvicorn config, app = build_from_env() - uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"), port=config.port) + # Bind to loopback by default; the Helm chart sets HOST=0.0.0.0 EXPLICITLY so the + # in-cluster Service can reach the pod. Never hardcode a bind-all default (CWE-605). + host = os.environ.get("HOST", "127.0.0.1") + uvicorn.run(app, host=host, port=config.port) if __name__ == "__main__": # pragma: no cover