agentbadge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentbadge-0.1.0/.gitignore +37 -0
- agentbadge-0.1.0/PKG-INFO +39 -0
- agentbadge-0.1.0/README.md +18 -0
- agentbadge-0.1.0/pyproject.toml +37 -0
- agentbadge-0.1.0/src/agentbadge/__init__.py +55 -0
- agentbadge-0.1.0/src/agentbadge/auth.py +29 -0
- agentbadge-0.1.0/src/agentbadge/client.py +296 -0
- agentbadge-0.1.0/src/agentbadge/types.py +101 -0
- agentbadge-0.1.0/src/agentbadge/verify.py +20 -0
- agentbadge-0.1.0/tests/test_sdk_client.py +191 -0
- agentbadge-0.1.0/tests/test_sdk_e2e.py +290 -0
- agentbadge-0.1.0/tests/test_sdk_messaging.py +171 -0
- agentbadge-0.1.0/tests/test_sdk_placement.py +53 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
.venv/
|
|
7
|
+
venv/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
|
|
16
|
+
# Node
|
|
17
|
+
node_modules/
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
.pnpm-store/
|
|
20
|
+
|
|
21
|
+
# Environment
|
|
22
|
+
.env
|
|
23
|
+
.env.local
|
|
24
|
+
.env.*.local
|
|
25
|
+
|
|
26
|
+
# Docker
|
|
27
|
+
docker-data/
|
|
28
|
+
|
|
29
|
+
# IDE
|
|
30
|
+
.idea/
|
|
31
|
+
.vscode/
|
|
32
|
+
*.swp
|
|
33
|
+
.DS_Store
|
|
34
|
+
|
|
35
|
+
# Local dev artifacts
|
|
36
|
+
*.log
|
|
37
|
+
.local-storage/
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentbadge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Agent Badge Python SDK — HTTP client for the Agent Badge API
|
|
5
|
+
Project-URL: Homepage, https://badge.swarmintel.xyz
|
|
6
|
+
Project-URL: Documentation, https://badge.swarmintel.xyz/.well-known/agentbadge.json
|
|
7
|
+
Project-URL: Repository, https://github.com/casey1088/agent-badge
|
|
8
|
+
Author: Swarm Intel
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: a2a,agent,agentcard,identity,verification,x402
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: agentbadge-core~=0.1.0
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2.5
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# agentbadge
|
|
23
|
+
|
|
24
|
+
Python HTTP client for the Agent Badge API.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from agentbadge import AgentBadgeClient
|
|
28
|
+
|
|
29
|
+
async with AgentBadgeClient("https://badge.swarmintel.xyz") as client:
|
|
30
|
+
status = await client.verify("bdg_...")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Verification is public and needs no credentials. Buying a badge requires an
|
|
34
|
+
x402 payment from your own wallet; this client never holds a key and never
|
|
35
|
+
pays on your behalf.
|
|
36
|
+
|
|
37
|
+
Nothing here is required to integrate. The API is plain HTTP and the full
|
|
38
|
+
flow is published at
|
|
39
|
+
`https://badge.swarmintel.xyz/.well-known/agentbadge.json`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# agentbadge
|
|
2
|
+
|
|
3
|
+
Python HTTP client for the Agent Badge API.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from agentbadge import AgentBadgeClient
|
|
7
|
+
|
|
8
|
+
async with AgentBadgeClient("https://badge.swarmintel.xyz") as client:
|
|
9
|
+
status = await client.verify("bdg_...")
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Verification is public and needs no credentials. Buying a badge requires an
|
|
13
|
+
x402 payment from your own wallet; this client never holds a key and never
|
|
14
|
+
pays on your behalf.
|
|
15
|
+
|
|
16
|
+
Nothing here is required to integrate. The API is plain HTTP and the full
|
|
17
|
+
flow is published at
|
|
18
|
+
`https://badge.swarmintel.xyz/.well-known/agentbadge.json`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "agentbadge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Agent Badge Python SDK — HTTP client for the Agent Badge API"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Swarm Intel" }]
|
|
9
|
+
keywords = ["agent", "identity", "a2a", "agentcard", "x402", "verification"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Topic :: Security",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"agentbadge-core~=0.1.0",
|
|
20
|
+
"httpx>=0.27",
|
|
21
|
+
"pydantic>=2.5",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.uv.sources]
|
|
25
|
+
agentbadge-core = { workspace = true }
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://badge.swarmintel.xyz"
|
|
29
|
+
Documentation = "https://badge.swarmintel.xyz/.well-known/agentbadge.json"
|
|
30
|
+
Repository = "https://github.com/casey1088/agent-badge"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/agentbadge"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Agent Badge Python SDK."""
|
|
2
|
+
|
|
3
|
+
from agentbadge_core.agent_card import AgentCard, badge_fragment, patch_badge
|
|
4
|
+
from agentbadge_core.protocols import (
|
|
5
|
+
SUPPORTED_PROTOCOLS,
|
|
6
|
+
badge_headers,
|
|
7
|
+
find_badge,
|
|
8
|
+
placement_table,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from agentbadge.auth import agent_auth_headers
|
|
12
|
+
from agentbadge.client import AgentBadgeClient
|
|
13
|
+
from agentbadge.types import (
|
|
14
|
+
AckResponse,
|
|
15
|
+
AgentBadgeAPIError,
|
|
16
|
+
AgentMessage,
|
|
17
|
+
BadgeDescriptor,
|
|
18
|
+
BadgeFeatures,
|
|
19
|
+
BadgeMetadata,
|
|
20
|
+
ChallengeInfo,
|
|
21
|
+
EnrollResponse,
|
|
22
|
+
MessagesResponse,
|
|
23
|
+
SendMessageResponse,
|
|
24
|
+
SubscribeResponse,
|
|
25
|
+
VerificationResponse,
|
|
26
|
+
)
|
|
27
|
+
from agentbadge.verify import verify_agent_card, verify_badge
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"SUPPORTED_PROTOCOLS",
|
|
33
|
+
"AckResponse",
|
|
34
|
+
"AgentBadgeAPIError",
|
|
35
|
+
"AgentBadgeClient",
|
|
36
|
+
"AgentCard",
|
|
37
|
+
"AgentMessage",
|
|
38
|
+
"BadgeDescriptor",
|
|
39
|
+
"BadgeFeatures",
|
|
40
|
+
"BadgeMetadata",
|
|
41
|
+
"ChallengeInfo",
|
|
42
|
+
"EnrollResponse",
|
|
43
|
+
"MessagesResponse",
|
|
44
|
+
"SendMessageResponse",
|
|
45
|
+
"SubscribeResponse",
|
|
46
|
+
"VerificationResponse",
|
|
47
|
+
"agent_auth_headers",
|
|
48
|
+
"badge_fragment",
|
|
49
|
+
"badge_headers",
|
|
50
|
+
"find_badge",
|
|
51
|
+
"patch_badge",
|
|
52
|
+
"placement_table",
|
|
53
|
+
"verify_agent_card",
|
|
54
|
+
"verify_badge",
|
|
55
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Agent-side authentication for the messaging endpoints.
|
|
2
|
+
|
|
3
|
+
The agent proves control of its card key by signing
|
|
4
|
+
"<badgeId>:<timestamp>:<nonce>" with the Ed25519 key its AgentCard exposes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from agentbadge_core.crypto import Ed25519KeyPair
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def agent_auth_headers(
|
|
15
|
+
badge_id: str,
|
|
16
|
+
keypair: Ed25519KeyPair,
|
|
17
|
+
timestamp: int | None = None,
|
|
18
|
+
nonce: str | None = None,
|
|
19
|
+
) -> dict[str, str]:
|
|
20
|
+
"""The X-Agent-Signature / X-Agent-Timestamp / X-Agent-Nonce headers for one
|
|
21
|
+
request. Each call uses a fresh nonce — headers must not be reused."""
|
|
22
|
+
ts = timestamp if timestamp is not None else int(time.time())
|
|
23
|
+
n = nonce if nonce is not None else secrets.token_hex(16)
|
|
24
|
+
signature = keypair.sign(f"{badge_id}:{ts}:{n}".encode("ascii"))
|
|
25
|
+
return {
|
|
26
|
+
"X-Agent-Signature": base64.b64encode(signature).decode("ascii"),
|
|
27
|
+
"X-Agent-Timestamp": str(ts),
|
|
28
|
+
"X-Agent-Nonce": n,
|
|
29
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Async HTTP client for the Agent Badge API."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from agentbadge_core.agent_card import AgentCard
|
|
8
|
+
from agentbadge_core.constants import EXTENSION_URI
|
|
9
|
+
from agentbadge_core.crypto import Ed25519KeyPair
|
|
10
|
+
|
|
11
|
+
from agentbadge.auth import agent_auth_headers
|
|
12
|
+
from agentbadge.types import (
|
|
13
|
+
AckResponse,
|
|
14
|
+
AgentBadgeAPIError,
|
|
15
|
+
BadgeFeatures,
|
|
16
|
+
BadgeMetadata,
|
|
17
|
+
EnrollResponse,
|
|
18
|
+
MessagesResponse,
|
|
19
|
+
SendMessageResponse,
|
|
20
|
+
SubscribeResponse,
|
|
21
|
+
VerificationResponse,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentBadgeClient:
|
|
26
|
+
"""Thin typed client for the Agent Badge API.
|
|
27
|
+
|
|
28
|
+
An `httpx.AsyncClient` may be injected (tests pass one backed by
|
|
29
|
+
`httpx.MockTransport`); it is used both for API calls and for fetching
|
|
30
|
+
AgentCards in `verify_agent_card`. Operator-side messaging
|
|
31
|
+
(`send_message`) requires `admin_api_key`; agent-side messaging (poll,
|
|
32
|
+
ack, webhooks) signs each request with the agent's Ed25519 keypair.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
base_url: str = "http://localhost:8000",
|
|
38
|
+
client: httpx.AsyncClient | None = None,
|
|
39
|
+
admin_api_key: str | None = None,
|
|
40
|
+
):
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self._client = client
|
|
43
|
+
self._owns_client = client is None
|
|
44
|
+
self.admin_api_key = admin_api_key
|
|
45
|
+
|
|
46
|
+
async def _http(self) -> httpx.AsyncClient:
|
|
47
|
+
if self._client is None:
|
|
48
|
+
self._client = httpx.AsyncClient(timeout=10.0, follow_redirects=True)
|
|
49
|
+
return self._client
|
|
50
|
+
|
|
51
|
+
async def aclose(self) -> None:
|
|
52
|
+
if self._owns_client and self._client is not None:
|
|
53
|
+
await self._client.aclose()
|
|
54
|
+
|
|
55
|
+
async def __aenter__(self) -> "AgentBadgeClient":
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
59
|
+
await self.aclose()
|
|
60
|
+
|
|
61
|
+
async def _request(self, method: str, path_or_url: str, **kwargs: Any) -> Any:
|
|
62
|
+
url = path_or_url if path_or_url.startswith("http") else f"{self.base_url}{path_or_url}"
|
|
63
|
+
client = await self._http()
|
|
64
|
+
response = await client.request(method, url, **kwargs)
|
|
65
|
+
if response.status_code >= 400:
|
|
66
|
+
try:
|
|
67
|
+
body = response.json()
|
|
68
|
+
except ValueError:
|
|
69
|
+
body = {}
|
|
70
|
+
raise AgentBadgeAPIError(
|
|
71
|
+
status_code=response.status_code,
|
|
72
|
+
error=body.get("error", "http_error"),
|
|
73
|
+
message=body.get("message", response.text[:200]),
|
|
74
|
+
request_id=body.get("requestId"),
|
|
75
|
+
)
|
|
76
|
+
if response.status_code == 204 or not response.content:
|
|
77
|
+
return None
|
|
78
|
+
try:
|
|
79
|
+
return response.json()
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise AgentBadgeAPIError(
|
|
82
|
+
status_code=response.status_code,
|
|
83
|
+
error="invalid_response",
|
|
84
|
+
message=f"Response from {url} is not valid JSON",
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
async def verify_badge(self, badge_id: str) -> VerificationResponse:
|
|
88
|
+
"""GET /v1/verify/{badge_id} as a typed VerificationResponse."""
|
|
89
|
+
data = await self._request("GET", f"/v1/verify/{badge_id}")
|
|
90
|
+
return VerificationResponse.model_validate(data)
|
|
91
|
+
|
|
92
|
+
async def verify_agent_card(self, card_url: str) -> VerificationResponse:
|
|
93
|
+
"""Fetch an AgentCard, find its Agent Badge extension, verify the badge."""
|
|
94
|
+
card = await self._request("GET", card_url)
|
|
95
|
+
if not isinstance(card, dict):
|
|
96
|
+
raise AgentBadgeAPIError(
|
|
97
|
+
200, "card_invalid", f"AgentCard at {card_url} is not an object"
|
|
98
|
+
)
|
|
99
|
+
extension = AgentCard.find_extension(card, EXTENSION_URI)
|
|
100
|
+
if extension is None:
|
|
101
|
+
raise AgentBadgeAPIError(
|
|
102
|
+
200,
|
|
103
|
+
"badge_extension_not_found",
|
|
104
|
+
f"AgentCard at {card_url} carries no Agent Badge extension ({EXTENSION_URI})",
|
|
105
|
+
)
|
|
106
|
+
badge_id = (extension.get("params") or {}).get("badgeId")
|
|
107
|
+
if not badge_id:
|
|
108
|
+
raise AgentBadgeAPIError(
|
|
109
|
+
200,
|
|
110
|
+
"badge_id_missing",
|
|
111
|
+
"Agent Badge extension params carry no badgeId",
|
|
112
|
+
)
|
|
113
|
+
return await self.verify_badge(badge_id)
|
|
114
|
+
|
|
115
|
+
async def get_credential(self, badge_id: str) -> dict:
|
|
116
|
+
"""GET /v1/credentials/{badge_id} — the full flattened JWS credential."""
|
|
117
|
+
return await self._request("GET", f"/v1/credentials/{badge_id}")
|
|
118
|
+
|
|
119
|
+
async def create_subscribe_intent(
|
|
120
|
+
self,
|
|
121
|
+
card_url: str,
|
|
122
|
+
plan: str = "monthly",
|
|
123
|
+
badge_type: str = "trust",
|
|
124
|
+
evidence: dict | None = None,
|
|
125
|
+
linked_credentials: list[dict] | None = None,
|
|
126
|
+
) -> dict:
|
|
127
|
+
"""POST /v1/subscribe/intents — free, and the first half of buying a badge.
|
|
128
|
+
|
|
129
|
+
Returns the terms the agent's own wallet needs in order to pay:
|
|
130
|
+
`subscribe_url`, `amount_usdc_base_units`, `pay_to`, `network`,
|
|
131
|
+
`asset_contract`. The SDK deliberately stops here — it holds no key and
|
|
132
|
+
never pays.
|
|
133
|
+
"""
|
|
134
|
+
body: dict = {"card_url": card_url, "plan": plan, "badge_type": badge_type}
|
|
135
|
+
if evidence is not None:
|
|
136
|
+
body["evidence"] = evidence
|
|
137
|
+
if linked_credentials is not None:
|
|
138
|
+
body["linked_credentials"] = linked_credentials
|
|
139
|
+
return await self._request("POST", "/v1/subscribe/intents", json=body)
|
|
140
|
+
|
|
141
|
+
async def subscribe_status(self, intent_id: str) -> dict:
|
|
142
|
+
"""GET /v1/subscribe/status — has this intent been paid?
|
|
143
|
+
|
|
144
|
+
A subscription carrying a settle tx is the proof of payment; poll this
|
|
145
|
+
rather than trusting any party's self-report.
|
|
146
|
+
"""
|
|
147
|
+
return await self._request("GET", "/v1/subscribe/status", params={"intent_id": intent_id})
|
|
148
|
+
|
|
149
|
+
async def wear(self, intent_id: str, card_path: str, *, dry_run: bool = False) -> dict:
|
|
150
|
+
"""Place the badge bought under `intent_id` onto this agent's own card.
|
|
151
|
+
|
|
152
|
+
Runs entirely in the caller's process against the caller's own file:
|
|
153
|
+
the SDK fetches the descriptor it already paid for, then patches the
|
|
154
|
+
local card. Nothing here writes to anybody else's document — placement
|
|
155
|
+
is the badge holder's own act, and this only removes the JSON surgery.
|
|
156
|
+
|
|
157
|
+
Raises if the intent has not settled: there is no descriptor to wear
|
|
158
|
+
until it has been paid for.
|
|
159
|
+
"""
|
|
160
|
+
from agentbadge_core.wear import wear_badge
|
|
161
|
+
|
|
162
|
+
status = await self.subscribe_status(intent_id)
|
|
163
|
+
descriptor = status.get("badge")
|
|
164
|
+
if not descriptor:
|
|
165
|
+
raise AgentBadgeAPIError(
|
|
166
|
+
409,
|
|
167
|
+
"not_settled",
|
|
168
|
+
f"Intent {intent_id} has no badge to wear yet (status: {status.get('status')}). "
|
|
169
|
+
"Pay the subscribe_url first.",
|
|
170
|
+
)
|
|
171
|
+
return wear_badge(descriptor, card_path, dry_run=dry_run)
|
|
172
|
+
|
|
173
|
+
async def enroll(
|
|
174
|
+
self, card_url: str, badge_type: str, evidence: dict | None = None
|
|
175
|
+
) -> EnrollResponse:
|
|
176
|
+
"""Removed — badges are bought, not enrolled.
|
|
177
|
+
|
|
178
|
+
Raises instead of calling the endpoint so an integration pinned to the
|
|
179
|
+
old SDK gets an error that says where issuance went, rather than a bare
|
|
180
|
+
410 from the wire.
|
|
181
|
+
"""
|
|
182
|
+
raise AgentBadgeAPIError(
|
|
183
|
+
410,
|
|
184
|
+
"enroll_gone",
|
|
185
|
+
"Free enrollment was removed. Call create_subscribe_intent(...), have the "
|
|
186
|
+
"agent's own wallet pay the returned subscribe_url with an x402 `exact` "
|
|
187
|
+
"payment, then poll subscribe_status(intent_id).",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def get_jwks(self) -> dict:
|
|
191
|
+
"""GET /.well-known/jwks.json."""
|
|
192
|
+
return await self._request("GET", "/.well-known/jwks.json")
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# Messaging channel
|
|
196
|
+
|
|
197
|
+
def _admin_headers(self) -> dict[str, str]:
|
|
198
|
+
if not self.admin_api_key:
|
|
199
|
+
raise AgentBadgeAPIError(
|
|
200
|
+
0, "admin_api_key_required", "This call requires admin_api_key on the client"
|
|
201
|
+
)
|
|
202
|
+
return {"X-API-Key": self.admin_api_key}
|
|
203
|
+
|
|
204
|
+
async def send_message(
|
|
205
|
+
self,
|
|
206
|
+
badge_id: str,
|
|
207
|
+
payload: dict,
|
|
208
|
+
message_type: str | None = None,
|
|
209
|
+
expires_at: str | datetime | None = None,
|
|
210
|
+
) -> SendMessageResponse:
|
|
211
|
+
"""Operator → agent: POST /v1/agent-messages/{badge_id}.
|
|
212
|
+
|
|
213
|
+
The message type defaults to payload["type"] (or "arbitrary").
|
|
214
|
+
"""
|
|
215
|
+
body: dict = {
|
|
216
|
+
"message_type": message_type or payload.get("type", "arbitrary"),
|
|
217
|
+
"payload": payload,
|
|
218
|
+
}
|
|
219
|
+
if expires_at is not None:
|
|
220
|
+
body["expires_at"] = (
|
|
221
|
+
expires_at.isoformat() if isinstance(expires_at, datetime) else expires_at
|
|
222
|
+
)
|
|
223
|
+
data = await self._request(
|
|
224
|
+
"POST",
|
|
225
|
+
f"/v1/agent-messages/{badge_id}",
|
|
226
|
+
json=body,
|
|
227
|
+
headers=self._admin_headers(),
|
|
228
|
+
)
|
|
229
|
+
return SendMessageResponse.model_validate(data)
|
|
230
|
+
|
|
231
|
+
async def poll_messages(
|
|
232
|
+
self,
|
|
233
|
+
badge_id: str,
|
|
234
|
+
keypair: Ed25519KeyPair,
|
|
235
|
+
since: str | datetime | None = None,
|
|
236
|
+
) -> MessagesResponse:
|
|
237
|
+
"""Agent: GET /v1/agent-messages/{badge_id} with a signed request."""
|
|
238
|
+
params: dict = {}
|
|
239
|
+
if since is not None:
|
|
240
|
+
params["since"] = since.isoformat() if isinstance(since, datetime) else since
|
|
241
|
+
data = await self._request(
|
|
242
|
+
"GET",
|
|
243
|
+
f"/v1/agent-messages/{badge_id}",
|
|
244
|
+
params=params,
|
|
245
|
+
headers=agent_auth_headers(badge_id, keypair),
|
|
246
|
+
)
|
|
247
|
+
return MessagesResponse.model_validate(data)
|
|
248
|
+
|
|
249
|
+
async def ack_messages(
|
|
250
|
+
self, badge_id: str, keypair: Ed25519KeyPair, message_ids: list[str]
|
|
251
|
+
) -> AckResponse:
|
|
252
|
+
"""Agent: POST /v1/agent-messages/{badge_id}/ack with a signed request."""
|
|
253
|
+
data = await self._request(
|
|
254
|
+
"POST",
|
|
255
|
+
f"/v1/agent-messages/{badge_id}/ack",
|
|
256
|
+
json={"message_ids": message_ids},
|
|
257
|
+
headers=agent_auth_headers(badge_id, keypair),
|
|
258
|
+
)
|
|
259
|
+
return AckResponse.model_validate(data)
|
|
260
|
+
|
|
261
|
+
async def register_webhook(
|
|
262
|
+
self, badge_id: str, keypair: Ed25519KeyPair, webhook_url: str
|
|
263
|
+
) -> SubscribeResponse:
|
|
264
|
+
"""Agent: POST /v1/agent-subscriptions. The returned HMAC secret is
|
|
265
|
+
shown exactly once — store it."""
|
|
266
|
+
data = await self._request(
|
|
267
|
+
"POST",
|
|
268
|
+
"/v1/agent-subscriptions",
|
|
269
|
+
json={"badge_id": badge_id, "webhook_url": webhook_url},
|
|
270
|
+
headers=agent_auth_headers(badge_id, keypair),
|
|
271
|
+
)
|
|
272
|
+
return SubscribeResponse.model_validate(data)
|
|
273
|
+
|
|
274
|
+
async def unregister_webhook(
|
|
275
|
+
self, subscription_id: str, badge_id: str, keypair: Ed25519KeyPair
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Agent: DELETE /v1/agent-subscriptions/{id}, signed with the badge
|
|
278
|
+
that owns the subscription."""
|
|
279
|
+
await self._request(
|
|
280
|
+
"DELETE",
|
|
281
|
+
f"/v1/agent-subscriptions/{subscription_id}",
|
|
282
|
+
headers=agent_auth_headers(badge_id, keypair),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Live metadata (Strategy B)
|
|
287
|
+
|
|
288
|
+
async def get_badge_metadata(self, badge_id: str) -> BadgeMetadata:
|
|
289
|
+
"""GET /v1/badges/{badge_id}/metadata — live, never-frozen badge data."""
|
|
290
|
+
data = await self._request("GET", f"/v1/badges/{badge_id}/metadata")
|
|
291
|
+
return BadgeMetadata.model_validate(data)
|
|
292
|
+
|
|
293
|
+
async def get_badge_features(self, badge_id: str) -> BadgeFeatures:
|
|
294
|
+
"""GET /v1/badges/{badge_id}/features — cacheable feature flags."""
|
|
295
|
+
data = await self._request("GET", f"/v1/badges/{badge_id}/features")
|
|
296
|
+
return BadgeFeatures.model_validate(data)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Typed responses mirroring the canonical JSON Schemas."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class VerificationResponse(BaseModel):
|
|
9
|
+
badgeId: str
|
|
10
|
+
status: Literal["active", "expired", "revoked", "downgraded", "unknown"]
|
|
11
|
+
tier: Literal["unknown", "monitored", "verified", "certified"]
|
|
12
|
+
claims: list[str]
|
|
13
|
+
issuer: str
|
|
14
|
+
expires: str
|
|
15
|
+
cardHash: str
|
|
16
|
+
revocationReason: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BadgeDescriptor(BaseModel):
|
|
20
|
+
uri: str
|
|
21
|
+
description: str
|
|
22
|
+
required: bool = False
|
|
23
|
+
params: dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ChallengeInfo(BaseModel):
|
|
27
|
+
domain: str
|
|
28
|
+
method: Literal["dns_txt", "well_known_http"]
|
|
29
|
+
token: str
|
|
30
|
+
instructions: str
|
|
31
|
+
expires_at: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EnrollResponse(BaseModel):
|
|
35
|
+
status: Literal["enrolled", "pending_domain_verification"]
|
|
36
|
+
badge: BadgeDescriptor | None = None
|
|
37
|
+
badge_id: str | None = None
|
|
38
|
+
challenge: ChallengeInfo | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SendMessageResponse(BaseModel):
|
|
42
|
+
message_id: str
|
|
43
|
+
badge_id: str
|
|
44
|
+
status: Literal["pending", "delivered"]
|
|
45
|
+
webhook_deliveries: int = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AgentMessage(BaseModel):
|
|
49
|
+
message_id: str
|
|
50
|
+
message_type: str
|
|
51
|
+
direction: str
|
|
52
|
+
payload: dict
|
|
53
|
+
status: str
|
|
54
|
+
created_at: str
|
|
55
|
+
expires_at: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MessagesResponse(BaseModel):
|
|
59
|
+
badge_id: str
|
|
60
|
+
messages: list[AgentMessage]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AckResponse(BaseModel):
|
|
64
|
+
badge_id: str
|
|
65
|
+
acked: list[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SubscribeResponse(BaseModel):
|
|
69
|
+
subscription_id: str
|
|
70
|
+
badge_id: str
|
|
71
|
+
webhook_url: str
|
|
72
|
+
secret: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class BadgeMetadata(BaseModel):
|
|
76
|
+
badgeId: str
|
|
77
|
+
trustScore: float
|
|
78
|
+
verificationCount: int
|
|
79
|
+
features: list[str]
|
|
80
|
+
ambassadorCode: str | None = None
|
|
81
|
+
recruitUrl: str | None = None
|
|
82
|
+
linkedCredentials: list[dict] = []
|
|
83
|
+
openOffers: int
|
|
84
|
+
lastUpdated: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class BadgeFeatures(BaseModel):
|
|
88
|
+
badgeId: str
|
|
89
|
+
features: list[str]
|
|
90
|
+
lastUpdated: str
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AgentBadgeAPIError(Exception):
|
|
94
|
+
"""Raised when the API returns an error response."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, status_code: int, error: str, message: str, request_id: str | None = None):
|
|
97
|
+
self.status_code = status_code
|
|
98
|
+
self.error = error
|
|
99
|
+
self.message = message
|
|
100
|
+
self.request_id = request_id
|
|
101
|
+
super().__init__(f"[{status_code}] {error}: {message}")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Convenience verification functions."""
|
|
2
|
+
|
|
3
|
+
from agentbadge.client import AgentBadgeClient
|
|
4
|
+
from agentbadge.types import VerificationResponse
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def verify_badge(
|
|
8
|
+
badge_id: str, api_url: str = "http://localhost:8000"
|
|
9
|
+
) -> VerificationResponse:
|
|
10
|
+
"""Verify a badge by ID against the given API."""
|
|
11
|
+
async with AgentBadgeClient(api_url) as client:
|
|
12
|
+
return await client.verify_badge(badge_id)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def verify_agent_card(
|
|
16
|
+
card_url: str, api_url: str = "http://localhost:8000"
|
|
17
|
+
) -> VerificationResponse:
|
|
18
|
+
"""Fetch an AgentCard, find its badge extension, and verify the badge."""
|
|
19
|
+
async with AgentBadgeClient(api_url) as client:
|
|
20
|
+
return await client.verify_agent_card(card_url)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tests for the Python SDK client (T15). All HTTP is mocked."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
from agentbadge import (
|
|
8
|
+
AgentBadgeAPIError,
|
|
9
|
+
AgentBadgeClient,
|
|
10
|
+
VerificationResponse,
|
|
11
|
+
)
|
|
12
|
+
from agentbadge_core.config import CoreConfig
|
|
13
|
+
from agentbadge_core.schemas import SchemaValidator
|
|
14
|
+
|
|
15
|
+
FIXTURES = CoreConfig().fixtures_dir
|
|
16
|
+
API = "https://api.agentbadge.example"
|
|
17
|
+
BADGE_ID = "bdg_01HZXK7Q8W5N4YT2R3V6M9EAAA"
|
|
18
|
+
CARD_URL = "https://agent.example.com/.well-known/agent-card.json"
|
|
19
|
+
|
|
20
|
+
VERIFICATION_JSON = {
|
|
21
|
+
"badgeId": BADGE_ID,
|
|
22
|
+
"status": "active",
|
|
23
|
+
"tier": "verified",
|
|
24
|
+
"claims": ["domain-verified"],
|
|
25
|
+
"issuer": "https://badge.swarmintel.xyz",
|
|
26
|
+
"expires": "2026-11-05T12:00:00Z",
|
|
27
|
+
"cardHash": "sha256:" + "ab" * 32,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_fixture(rel: str) -> dict:
|
|
32
|
+
return json.loads((FIXTURES / rel).read_text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def make_client(handler) -> AgentBadgeClient:
|
|
36
|
+
http = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
|
37
|
+
return AgentBadgeClient(API, client=http)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestVerifyBadge:
|
|
41
|
+
async def test_returns_typed_response(self) -> None:
|
|
42
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
43
|
+
assert str(request.url) == f"{API}/v1/verify/{BADGE_ID}"
|
|
44
|
+
return httpx.Response(200, json=VERIFICATION_JSON)
|
|
45
|
+
|
|
46
|
+
client = make_client(handler)
|
|
47
|
+
result = await client.verify_badge(BADGE_ID)
|
|
48
|
+
assert isinstance(result, VerificationResponse)
|
|
49
|
+
assert result.status == "active"
|
|
50
|
+
assert result.tier == "verified"
|
|
51
|
+
|
|
52
|
+
async def test_unknown_badge_raises_typed_error(self) -> None:
|
|
53
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
54
|
+
return httpx.Response(
|
|
55
|
+
404,
|
|
56
|
+
json={
|
|
57
|
+
"error": "badge_not_found",
|
|
58
|
+
"message": "No badge",
|
|
59
|
+
"details": {},
|
|
60
|
+
"requestId": "r-1",
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
client = make_client(handler)
|
|
65
|
+
with pytest.raises(AgentBadgeAPIError) as excinfo:
|
|
66
|
+
await client.verify_badge("bdg_01HZXK7Q8W5N4YT2R3V6M9EZZZ")
|
|
67
|
+
assert excinfo.value.status_code == 404
|
|
68
|
+
assert excinfo.value.error == "badge_not_found"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestVerifyAgentCard:
|
|
72
|
+
async def test_fetches_card_and_verifies(self) -> None:
|
|
73
|
+
card = load_fixture("agent-cards/valid-agent-card-with-badge.json")
|
|
74
|
+
|
|
75
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
76
|
+
if str(request.url) == CARD_URL:
|
|
77
|
+
return httpx.Response(200, json=card)
|
|
78
|
+
if str(request.url) == f"{API}/v1/verify/{BADGE_ID}":
|
|
79
|
+
return httpx.Response(200, json=VERIFICATION_JSON)
|
|
80
|
+
return httpx.Response(404)
|
|
81
|
+
|
|
82
|
+
client = make_client(handler)
|
|
83
|
+
result = await client.verify_agent_card(CARD_URL)
|
|
84
|
+
assert result.badgeId == BADGE_ID
|
|
85
|
+
assert result.status == "active"
|
|
86
|
+
|
|
87
|
+
async def test_card_without_badge_raises(self) -> None:
|
|
88
|
+
card = load_fixture("agent-cards/valid-agent-card.json")
|
|
89
|
+
client = make_client(lambda r: httpx.Response(200, json=card))
|
|
90
|
+
with pytest.raises(AgentBadgeAPIError) as excinfo:
|
|
91
|
+
await client.verify_agent_card(CARD_URL)
|
|
92
|
+
assert excinfo.value.error == "badge_extension_not_found"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestGetCredential:
|
|
96
|
+
async def test_returns_jws_dict(self) -> None:
|
|
97
|
+
jws = {"protected": "aGVhZGVy", "payload": "cGF5bG9hZA", "signature": "c2ln"}
|
|
98
|
+
client = make_client(lambda r: httpx.Response(200, json=jws))
|
|
99
|
+
assert await client.get_credential(BADGE_ID) == jws
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestSubscribe:
|
|
103
|
+
async def test_create_intent_sends_the_plan_and_issuance_terms(self) -> None:
|
|
104
|
+
body = {
|
|
105
|
+
"intent_id": "int_01ABC",
|
|
106
|
+
"identity_key": CARD_URL,
|
|
107
|
+
"plan": "monthly",
|
|
108
|
+
"amount_usdc_base_units": 50_000_000,
|
|
109
|
+
"subscribe_url": f"{API}/v1/subscribe/int_01ABC",
|
|
110
|
+
"renew_url": f"{API}/v1/billing/int_01ABC/renew",
|
|
111
|
+
"network": "eip155:84532",
|
|
112
|
+
"asset_contract": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
113
|
+
"pay_to": "0x" + "aa" * 20,
|
|
114
|
+
"expires_at": "2026-08-08T12:00:00Z",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
118
|
+
assert request.method == "POST"
|
|
119
|
+
assert str(request.url) == f"{API}/v1/subscribe/intents"
|
|
120
|
+
sent = json.loads(request.content)
|
|
121
|
+
assert sent["card_url"] == CARD_URL
|
|
122
|
+
assert sent["plan"] == "annual"
|
|
123
|
+
assert sent["badge_type"] == "compliance"
|
|
124
|
+
return httpx.Response(200, json=body)
|
|
125
|
+
|
|
126
|
+
client = make_client(handler)
|
|
127
|
+
result = await client.create_subscribe_intent(
|
|
128
|
+
CARD_URL, plan="annual", badge_type="compliance"
|
|
129
|
+
)
|
|
130
|
+
assert result["intent_id"] == "int_01ABC"
|
|
131
|
+
assert result["amount_usdc_base_units"] == 50_000_000
|
|
132
|
+
|
|
133
|
+
async def test_status_is_queried_by_intent_id(self) -> None:
|
|
134
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
135
|
+
assert request.url.params["intent_id"] == "int_01ABC"
|
|
136
|
+
return httpx.Response(200, json={"intent_id": "int_01ABC", "status": "unpaid"})
|
|
137
|
+
|
|
138
|
+
client = make_client(handler)
|
|
139
|
+
assert (await client.subscribe_status("int_01ABC"))["status"] == "unpaid"
|
|
140
|
+
|
|
141
|
+
async def test_enroll_is_gone_and_says_where_issuance_went(self) -> None:
|
|
142
|
+
"""The SDK refuses locally rather than calling a 410, so an old
|
|
143
|
+
integration gets an actionable error instead of a bare status code."""
|
|
144
|
+
called = False
|
|
145
|
+
|
|
146
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
147
|
+
nonlocal called
|
|
148
|
+
called = True
|
|
149
|
+
return httpx.Response(410, json={})
|
|
150
|
+
|
|
151
|
+
client = make_client(handler)
|
|
152
|
+
with pytest.raises(AgentBadgeAPIError) as exc:
|
|
153
|
+
await client.enroll(CARD_URL, "trust")
|
|
154
|
+
assert exc.value.error == "enroll_gone"
|
|
155
|
+
assert "create_subscribe_intent" in exc.value.message
|
|
156
|
+
assert not called, "the SDK must not spend a round trip on a removed endpoint"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestGetJwks:
|
|
160
|
+
async def test_returns_jwks(self) -> None:
|
|
161
|
+
jwks = load_fixture("jwks/test-public-jwks.json")
|
|
162
|
+
|
|
163
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
164
|
+
assert str(request.url) == f"{API}/.well-known/jwks.json"
|
|
165
|
+
return httpx.Response(200, json=jwks)
|
|
166
|
+
|
|
167
|
+
client = make_client(handler)
|
|
168
|
+
result = await client.get_jwks()
|
|
169
|
+
SchemaValidator.validate("jwks", result)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestConvenienceFunctions:
|
|
173
|
+
async def test_verify_badge_function(self, monkeypatch) -> None:
|
|
174
|
+
from agentbadge import verify as verify_module
|
|
175
|
+
|
|
176
|
+
class FakeClient:
|
|
177
|
+
def __init__(self, api_url: str):
|
|
178
|
+
assert api_url == API
|
|
179
|
+
|
|
180
|
+
async def __aenter__(self):
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
async def __aexit__(self, *args):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
async def verify_badge(self, badge_id: str) -> VerificationResponse:
|
|
187
|
+
return VerificationResponse.model_validate(VERIFICATION_JSON)
|
|
188
|
+
|
|
189
|
+
monkeypatch.setattr(verify_module, "AgentBadgeClient", FakeClient)
|
|
190
|
+
result = await verify_module.verify_badge(BADGE_ID, API)
|
|
191
|
+
assert result.status == "active"
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""SDK end-to-end: the Python SDK drives the real API app (T19).
|
|
2
|
+
|
|
3
|
+
The SDK's injected httpx client routes API calls into the FastAPI app via
|
|
4
|
+
ASGITransport and serves the fixture AgentCard for card-URL fetches — the same
|
|
5
|
+
API surface, no network, real test PostgreSQL underneath.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import fakeredis.aioredis
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
from agentbadge import AgentBadgeClient, EnrollResponse, VerificationResponse
|
|
16
|
+
from agentbadge.client import AgentBadgeAPIError
|
|
17
|
+
from agentbadge_core.config import find_project_root
|
|
18
|
+
from agentbadge_core.crypto import JWSVerifier, KeyManager
|
|
19
|
+
from alembic import command
|
|
20
|
+
from alembic.config import Config
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _synthetic_cdp_key() -> tuple[str, str]:
|
|
24
|
+
"""A structurally valid CDP Ed25519 key: base64(seed || public), 64 bytes.
|
|
25
|
+
|
|
26
|
+
CDP authenticates with a JWT signed by the key, so the signing path runs
|
|
27
|
+
for real and a placeholder string cannot produce a token.
|
|
28
|
+
"""
|
|
29
|
+
import base64
|
|
30
|
+
|
|
31
|
+
from nacl.signing import SigningKey
|
|
32
|
+
|
|
33
|
+
key = SigningKey.generate()
|
|
34
|
+
return (
|
|
35
|
+
"organizations/sdk-e2e/apiKeys/sdk-e2e",
|
|
36
|
+
base64.b64encode(bytes(key) + bytes(key.verify_key)).decode(),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_CDP_KEY_ID, _CDP_KEY_SECRET = _synthetic_cdp_key()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ROOT = find_project_root()
|
|
45
|
+
API_DIR = ROOT / "apps" / "api"
|
|
46
|
+
DOMAIN = "test-agent.example.com"
|
|
47
|
+
CARD_URL = f"https://{DOMAIN}/.well-known/agent-card.json"
|
|
48
|
+
TEST_DATABASE_URL = os.environ.get(
|
|
49
|
+
"AGENTBADGE_TEST_DATABASE_URL",
|
|
50
|
+
"postgresql+asyncpg://agentbadge:agentbadge@localhost:5432/agentbadge_test",
|
|
51
|
+
)
|
|
52
|
+
ADMIN_KEY = "sdk-e2e-admin-key"
|
|
53
|
+
FACILITATOR_URL = "https://facilitator.test"
|
|
54
|
+
PAY_TO = "0x" + "aa" * 20
|
|
55
|
+
PAYER = "0x" + "bb" * 20
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def payment_header(resource: str) -> str:
|
|
59
|
+
"""Stand-in for a signed x402 payload, carrying the one field
|
|
60
|
+
`FacilitatorClient._assert_bound` checks so intent binding is exercised."""
|
|
61
|
+
import base64
|
|
62
|
+
|
|
63
|
+
return base64.b64encode(
|
|
64
|
+
json.dumps({"payer": PAYER, "resource": resource}).encode()
|
|
65
|
+
).decode()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def facilitator_response(request: httpx.Request) -> httpx.Response | None:
|
|
69
|
+
"""Mock CDP facilitator; None when the request is for something else.
|
|
70
|
+
|
|
71
|
+
A real x402 v2 facilitator cannot check intent binding:
|
|
72
|
+
PaymentRequirements no longer carries `resource`, so two purchases at the
|
|
73
|
+
same price are byte-identical to it. `FacilitatorClient._assert_bound`
|
|
74
|
+
enforces the binding before the request leaves, which is why this mock no
|
|
75
|
+
longer pretends to -- a mock enforcing a property production does not have
|
|
76
|
+
would hide the gap instead of testing it.
|
|
77
|
+
"""
|
|
78
|
+
url = str(request.url)
|
|
79
|
+
if not url.startswith(FACILITATOR_URL):
|
|
80
|
+
return None
|
|
81
|
+
if url.endswith("/verify"):
|
|
82
|
+
return httpx.Response(200, json={"isValid": True, "payer": PAYER})
|
|
83
|
+
return httpx.Response(200, json={"success": True, "txHash": "0xsdktx", "payer": PAYER})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_card() -> dict:
|
|
87
|
+
return json.loads((ROOT / "fixtures" / "agent-cards" / "valid-agent-card.json").read_text())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RoutingTransport(httpx.AsyncBaseTransport):
|
|
91
|
+
"""Route the card URL to a canned response; everything else to the app."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, app):
|
|
94
|
+
self._asgi = httpx.ASGITransport(app=app)
|
|
95
|
+
|
|
96
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
97
|
+
settled = facilitator_response(request)
|
|
98
|
+
if settled is not None:
|
|
99
|
+
return settled
|
|
100
|
+
if str(request.url) == CARD_URL:
|
|
101
|
+
return httpx.Response(200, json=load_card())
|
|
102
|
+
return await self._asgi.handle_async_request(request)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.fixture(scope="session")
|
|
106
|
+
def migrated_database() -> str:
|
|
107
|
+
config = Config(str(API_DIR / "alembic.ini"))
|
|
108
|
+
config.set_main_option(
|
|
109
|
+
"script_location", str(API_DIR / "src" / "agentbadge_api" / "migrations")
|
|
110
|
+
)
|
|
111
|
+
config.set_main_option("sqlalchemy.url", TEST_DATABASE_URL)
|
|
112
|
+
previous = os.environ.pop("AGENTBADGE_DATABASE_URL", None)
|
|
113
|
+
try:
|
|
114
|
+
command.downgrade(config, "base")
|
|
115
|
+
command.upgrade(config, "head")
|
|
116
|
+
finally:
|
|
117
|
+
if previous is not None:
|
|
118
|
+
os.environ["AGENTBADGE_DATABASE_URL"] = previous
|
|
119
|
+
return TEST_DATABASE_URL
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.fixture
|
|
123
|
+
async def app(migrated_database: str, tmp_path: Path):
|
|
124
|
+
from agentbadge_api.database import create_engine
|
|
125
|
+
from agentbadge_api.dependencies import get_settings
|
|
126
|
+
from agentbadge_api.main import create_app
|
|
127
|
+
from agentbadge_api.models.db import Base
|
|
128
|
+
from agentbadge_api.settings import Settings
|
|
129
|
+
from sqlalchemy import text
|
|
130
|
+
|
|
131
|
+
engine = create_engine(migrated_database)
|
|
132
|
+
async with engine.begin() as conn:
|
|
133
|
+
tables = ", ".join(t.name for t in Base.metadata.sorted_tables)
|
|
134
|
+
await conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE"))
|
|
135
|
+
await engine.dispose()
|
|
136
|
+
|
|
137
|
+
key_path = ROOT / "fixtures" / "jwks" / "test-private-key.jwk"
|
|
138
|
+
settings = Settings(
|
|
139
|
+
database_url=migrated_database,
|
|
140
|
+
admin_api_key=ADMIN_KEY,
|
|
141
|
+
signing_key_path=str(key_path),
|
|
142
|
+
signing_kid=json.loads(key_path.read_text())["kid"],
|
|
143
|
+
public_base_url="http://testserver",
|
|
144
|
+
credential_storage_dir=str(tmp_path / "credentials"),
|
|
145
|
+
cdp_api_key_id=_CDP_KEY_ID,
|
|
146
|
+
cdp_api_key_secret=_CDP_KEY_SECRET,
|
|
147
|
+
x402_pay_to_address=PAY_TO,
|
|
148
|
+
x402_facilitator_url=FACILITATOR_URL,
|
|
149
|
+
)
|
|
150
|
+
application = create_app()
|
|
151
|
+
application.state.tmp_card_dir = str(tmp_path)
|
|
152
|
+
application.dependency_overrides[get_settings] = lambda: settings
|
|
153
|
+
application.state.redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
|
154
|
+
application.state.signing = (
|
|
155
|
+
KeyManager.load_private_key(settings.signing_key_path),
|
|
156
|
+
settings.signing_kid,
|
|
157
|
+
)
|
|
158
|
+
yield application
|
|
159
|
+
engine = getattr(application.state, "engine", None)
|
|
160
|
+
if engine is not None:
|
|
161
|
+
await engine.dispose()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.fixture
|
|
165
|
+
async def sdk_client(app):
|
|
166
|
+
routing = RoutingTransport(app)
|
|
167
|
+
# The app's own outbound card fetches use the same routing transport.
|
|
168
|
+
app.state.http_client = httpx.AsyncClient(transport=routing)
|
|
169
|
+
http = httpx.AsyncClient(transport=routing, base_url="http://testserver")
|
|
170
|
+
client = AgentBadgeClient("http://testserver", client=http)
|
|
171
|
+
yield client
|
|
172
|
+
await http.aclose()
|
|
173
|
+
await app.state.http_client.aclose()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def _verify_domain(app, sdk_http: httpx.AsyncClient) -> None:
|
|
177
|
+
pending = await sdk_http.post(
|
|
178
|
+
"http://testserver/v1/domain/verify", json={"domain": DOMAIN, "method": "dns_txt"}
|
|
179
|
+
)
|
|
180
|
+
token = pending.json()["challenge"]["token"]
|
|
181
|
+
|
|
182
|
+
async def resolver(name: str) -> list[str]:
|
|
183
|
+
return [token]
|
|
184
|
+
|
|
185
|
+
app.state.txt_resolver = resolver
|
|
186
|
+
verified = await sdk_http.post(
|
|
187
|
+
"http://testserver/v1/domain/verify", json={"domain": DOMAIN, "method": "dns_txt"}
|
|
188
|
+
)
|
|
189
|
+
assert verified.json()["status"] == "verified"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def test_sdk_full_flow(app, sdk_client: AgentBadgeClient) -> None:
|
|
193
|
+
# The retired free path is gone, and the SDK says where issuance went
|
|
194
|
+
# rather than surfacing a bare 410.
|
|
195
|
+
with pytest.raises(AgentBadgeAPIError) as gone:
|
|
196
|
+
await sdk_client.enroll(CARD_URL, "trust")
|
|
197
|
+
assert gone.value.error == "enroll_gone"
|
|
198
|
+
|
|
199
|
+
http = await sdk_client._http()
|
|
200
|
+
|
|
201
|
+
# An intent is free, and before the domain is verified the paid call still
|
|
202
|
+
# refuses to charge -- nobody pays for a badge that cannot be issued yet.
|
|
203
|
+
early = await sdk_client.create_subscribe_intent(CARD_URL)
|
|
204
|
+
unpayable = await http.post(f"http://testserver/v1/subscribe/{early['intent_id']}")
|
|
205
|
+
assert unpayable.json()["status"] == "pending_domain_verification"
|
|
206
|
+
|
|
207
|
+
await _verify_domain(app, http)
|
|
208
|
+
|
|
209
|
+
# Buy the badge: intent -> the agent's own wallet pays -> settlement.
|
|
210
|
+
intent = await sdk_client.create_subscribe_intent(CARD_URL, plan="monthly")
|
|
211
|
+
assert intent["amount_usdc_base_units"] == 50_000_000
|
|
212
|
+
assert intent["pay_to"] == PAY_TO
|
|
213
|
+
resource = f"/v1/subscribe/{intent['intent_id']}"
|
|
214
|
+
paid = await http.post(
|
|
215
|
+
f"http://testserver{resource}", headers={"PAYMENT-SIGNATURE": payment_header(resource)}
|
|
216
|
+
)
|
|
217
|
+
assert paid.status_code == 200, paid.text
|
|
218
|
+
settled = paid.json()
|
|
219
|
+
assert settled["status"] == "paid"
|
|
220
|
+
assert settled["settle_tx"] == "0xsdktx"
|
|
221
|
+
badge_id = settled["badge_id"]
|
|
222
|
+
|
|
223
|
+
# The public status lookup is the proof of payment a poller relies on.
|
|
224
|
+
status = await sdk_client.subscribe_status(intent["intent_id"])
|
|
225
|
+
assert status["status"] == "paid"
|
|
226
|
+
assert status["badge_id"] == badge_id
|
|
227
|
+
|
|
228
|
+
# The settle body names the exact command that closes the loop, and the
|
|
229
|
+
# SDK ships that command: the buyer patches its OWN card, in its own
|
|
230
|
+
# process. Nothing on our side ever writes to a card we do not host.
|
|
231
|
+
assert "agentbadge wear" in settled["wear_command"]
|
|
232
|
+
assert status["wear_command"] == settled["wear_command"]
|
|
233
|
+
|
|
234
|
+
card_path = Path(app.state.tmp_card_dir) / "agent-card.json"
|
|
235
|
+
card_path.write_text(json.dumps(load_card(), indent=2))
|
|
236
|
+
worn = await sdk_client.wear(intent["intent_id"], str(card_path))
|
|
237
|
+
from agentbadge_core.wear import is_worn
|
|
238
|
+
|
|
239
|
+
assert is_worn(worn, badge_id)
|
|
240
|
+
assert is_worn(json.loads(card_path.read_text()), badge_id)
|
|
241
|
+
|
|
242
|
+
enrolled = EnrollResponse.model_validate(
|
|
243
|
+
{"status": "enrolled", "badge": settled["badge"], "badge_id": badge_id}
|
|
244
|
+
)
|
|
245
|
+
assert enrolled.badge is not None
|
|
246
|
+
assert enrolled.badge.params["badgeId"] == badge_id
|
|
247
|
+
|
|
248
|
+
# verify_badge — typed VerificationResponse, active.
|
|
249
|
+
verification = await sdk_client.verify_badge(badge_id)
|
|
250
|
+
assert isinstance(verification, VerificationResponse)
|
|
251
|
+
assert verification.status == "active"
|
|
252
|
+
assert verification.tier == "monitored"
|
|
253
|
+
|
|
254
|
+
# verify_agent_card resolves the badge straight from the (patched) card.
|
|
255
|
+
from agentbadge_core.agent_card import AgentCard
|
|
256
|
+
|
|
257
|
+
patched_card = AgentCard.patch_extension(load_card(), enrolled.badge.model_dump())
|
|
258
|
+
original_handler = RoutingTransport.handle_async_request
|
|
259
|
+
|
|
260
|
+
async def patched_handler(self, request):
|
|
261
|
+
if str(request.url) == CARD_URL:
|
|
262
|
+
return httpx.Response(200, json=patched_card)
|
|
263
|
+
return await original_handler(self, request)
|
|
264
|
+
|
|
265
|
+
RoutingTransport.handle_async_request = patched_handler
|
|
266
|
+
try:
|
|
267
|
+
from_card = await sdk_client.verify_agent_card(CARD_URL)
|
|
268
|
+
assert from_card.badgeId == badge_id
|
|
269
|
+
assert from_card.status == "active"
|
|
270
|
+
finally:
|
|
271
|
+
RoutingTransport.handle_async_request = original_handler
|
|
272
|
+
|
|
273
|
+
# Credential + JWKS from the same API verify cryptographically.
|
|
274
|
+
jws = await sdk_client.get_credential(badge_id)
|
|
275
|
+
jwks = await sdk_client.get_jwks()
|
|
276
|
+
is_valid, payload = JWSVerifier.verify(jws, jwks)
|
|
277
|
+
assert is_valid
|
|
278
|
+
assert payload["badgeId"] == badge_id
|
|
279
|
+
|
|
280
|
+
# Revoke via the admin endpoint; the SDK sees the new status.
|
|
281
|
+
http = await sdk_client._http()
|
|
282
|
+
revoke = await http.post(
|
|
283
|
+
f"http://testserver/v1/badges/{badge_id}/revoke",
|
|
284
|
+
json={"reason": "sdk e2e"},
|
|
285
|
+
headers={"X-API-Key": ADMIN_KEY},
|
|
286
|
+
)
|
|
287
|
+
assert revoke.status_code == 200
|
|
288
|
+
after = await sdk_client.verify_badge(badge_id)
|
|
289
|
+
assert after.status == "revoked"
|
|
290
|
+
assert after.revocationReason == "sdk e2e"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""SDK messaging + metadata methods against a mocked API."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
from agentbadge import AgentBadgeClient, agent_auth_headers
|
|
9
|
+
from agentbadge.types import AgentBadgeAPIError
|
|
10
|
+
from agentbadge_core.crypto import Ed25519KeyPair
|
|
11
|
+
|
|
12
|
+
API = "https://api.agentbadge.example"
|
|
13
|
+
BADGE_ID = "bdg_01HZXK7Q8W5N4YT2R3V6M9EAAA"
|
|
14
|
+
KEYPAIR = Ed25519KeyPair.generate()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_client(handler, admin_api_key: str | None = None) -> AgentBadgeClient:
|
|
18
|
+
transport = httpx.MockTransport(handler)
|
|
19
|
+
return AgentBadgeClient(
|
|
20
|
+
API,
|
|
21
|
+
client=httpx.AsyncClient(transport=transport),
|
|
22
|
+
admin_api_key=admin_api_key,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestAgentAuthHeaders:
|
|
27
|
+
def test_headers_shape_and_signature(self) -> None:
|
|
28
|
+
headers = agent_auth_headers(BADGE_ID, KEYPAIR, timestamp=1700000000, nonce="abcd")
|
|
29
|
+
assert headers["X-Agent-Timestamp"] == "1700000000"
|
|
30
|
+
assert headers["X-Agent-Nonce"] == "abcd"
|
|
31
|
+
signature = base64.b64decode(headers["X-Agent-Signature"])
|
|
32
|
+
assert KEYPAIR.verify(f"{BADGE_ID}:1700000000:abcd".encode(), signature)
|
|
33
|
+
|
|
34
|
+
def test_fresh_nonce_each_call(self) -> None:
|
|
35
|
+
first = agent_auth_headers(BADGE_ID, KEYPAIR)
|
|
36
|
+
second = agent_auth_headers(BADGE_ID, KEYPAIR)
|
|
37
|
+
assert first["X-Agent-Nonce"] != second["X-Agent-Nonce"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestSendMessage:
|
|
41
|
+
async def test_posts_with_admin_key(self) -> None:
|
|
42
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
43
|
+
assert request.url.path == f"/v1/agent-messages/{BADGE_ID}"
|
|
44
|
+
assert request.headers["X-API-Key"] == "admin-secret"
|
|
45
|
+
body = json.loads(request.content)
|
|
46
|
+
assert body["message_type"] == "opportunity"
|
|
47
|
+
assert body["payload"]["value_usdc"] == 0.10
|
|
48
|
+
return httpx.Response(
|
|
49
|
+
200,
|
|
50
|
+
json={
|
|
51
|
+
"message_id": "m-1",
|
|
52
|
+
"badge_id": BADGE_ID,
|
|
53
|
+
"status": "pending",
|
|
54
|
+
"webhook_deliveries": 0,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
client = make_client(handler, admin_api_key="admin-secret")
|
|
59
|
+
result = await client.send_message(
|
|
60
|
+
BADGE_ID, {"type": "opportunity", "title": "Gig", "value_usdc": 0.10}
|
|
61
|
+
)
|
|
62
|
+
assert result.message_id == "m-1"
|
|
63
|
+
assert result.status == "pending"
|
|
64
|
+
|
|
65
|
+
async def test_requires_admin_key(self) -> None:
|
|
66
|
+
client = make_client(lambda request: httpx.Response(500))
|
|
67
|
+
with pytest.raises(AgentBadgeAPIError, match="admin_api_key_required"):
|
|
68
|
+
await client.send_message(BADGE_ID, {"type": "reward"})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestPollAndAck:
|
|
72
|
+
async def test_poll_sends_signed_headers(self) -> None:
|
|
73
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
74
|
+
ts = int(request.headers["X-Agent-Timestamp"])
|
|
75
|
+
nonce = request.headers["X-Agent-Nonce"]
|
|
76
|
+
signature = base64.b64decode(request.headers["X-Agent-Signature"])
|
|
77
|
+
assert KEYPAIR.verify(f"{BADGE_ID}:{ts}:{nonce}".encode(), signature)
|
|
78
|
+
return httpx.Response(
|
|
79
|
+
200,
|
|
80
|
+
json={
|
|
81
|
+
"badge_id": BADGE_ID,
|
|
82
|
+
"messages": [
|
|
83
|
+
{
|
|
84
|
+
"message_id": "m-1",
|
|
85
|
+
"message_type": "llm_txt_update",
|
|
86
|
+
"direction": "operator_to_agent",
|
|
87
|
+
"payload": {"content": "# hi"},
|
|
88
|
+
"status": "pending",
|
|
89
|
+
"created_at": "2026-08-12T00:00:00Z",
|
|
90
|
+
}
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
client = make_client(handler)
|
|
96
|
+
result = await client.poll_messages(BADGE_ID, KEYPAIR)
|
|
97
|
+
assert result.messages[0].payload["content"] == "# hi"
|
|
98
|
+
|
|
99
|
+
async def test_ack(self) -> None:
|
|
100
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
101
|
+
assert request.url.path == f"/v1/agent-messages/{BADGE_ID}/ack"
|
|
102
|
+
assert json.loads(request.content) == {"message_ids": ["m-1"]}
|
|
103
|
+
return httpx.Response(200, json={"badge_id": BADGE_ID, "acked": ["m-1"]})
|
|
104
|
+
|
|
105
|
+
client = make_client(handler)
|
|
106
|
+
result = await client.ack_messages(BADGE_ID, KEYPAIR, ["m-1"])
|
|
107
|
+
assert result.acked == ["m-1"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestWebhooks:
|
|
111
|
+
async def test_register_and_unregister(self) -> None:
|
|
112
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
113
|
+
if request.method == "POST":
|
|
114
|
+
assert json.loads(request.content) == {
|
|
115
|
+
"badge_id": BADGE_ID,
|
|
116
|
+
"webhook_url": "https://agent.example.com/hook",
|
|
117
|
+
}
|
|
118
|
+
return httpx.Response(
|
|
119
|
+
200,
|
|
120
|
+
json={
|
|
121
|
+
"subscription_id": "s-1",
|
|
122
|
+
"badge_id": BADGE_ID,
|
|
123
|
+
"webhook_url": "https://agent.example.com/hook",
|
|
124
|
+
"secret": "whsec_x",
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
assert request.method == "DELETE"
|
|
128
|
+
assert request.url.path == "/v1/agent-subscriptions/s-1"
|
|
129
|
+
return httpx.Response(204)
|
|
130
|
+
|
|
131
|
+
client = make_client(handler)
|
|
132
|
+
subscription = await client.register_webhook(
|
|
133
|
+
BADGE_ID, KEYPAIR, "https://agent.example.com/hook"
|
|
134
|
+
)
|
|
135
|
+
assert subscription.secret == "whsec_x"
|
|
136
|
+
await client.unregister_webhook("s-1", BADGE_ID, KEYPAIR)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestMetadata:
|
|
140
|
+
async def test_get_metadata_and_features(self) -> None:
|
|
141
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
142
|
+
if request.url.path.endswith("/metadata"):
|
|
143
|
+
return httpx.Response(
|
|
144
|
+
200,
|
|
145
|
+
json={
|
|
146
|
+
"badgeId": BADGE_ID,
|
|
147
|
+
"trustScore": 0.87,
|
|
148
|
+
"verificationCount": 42,
|
|
149
|
+
"features": ["caching", "opportunity_feed"],
|
|
150
|
+
"ambassadorCode": "amb_9F3KQ2",
|
|
151
|
+
"recruitUrl": "https://badge.swarmintel.xyz/r/amb_9F3KQ2",
|
|
152
|
+
"linkedCredentials": [],
|
|
153
|
+
"openOffers": 3,
|
|
154
|
+
"lastUpdated": "2026-08-12T00:00:00Z",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
return httpx.Response(
|
|
158
|
+
200,
|
|
159
|
+
json={
|
|
160
|
+
"badgeId": BADGE_ID,
|
|
161
|
+
"features": ["caching"],
|
|
162
|
+
"lastUpdated": "2026-08-12T00:00:00Z",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
client = make_client(handler)
|
|
167
|
+
metadata = await client.get_badge_metadata(BADGE_ID)
|
|
168
|
+
assert metadata.trustScore == 0.87
|
|
169
|
+
assert metadata.openOffers == 3
|
|
170
|
+
features = await client.get_badge_features(BADGE_ID)
|
|
171
|
+
assert features.features == ["caching"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The SDK re-exports placement so callers need one import site."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import agentbadge
|
|
6
|
+
from agentbadge_core.config import CoreConfig
|
|
7
|
+
from agentbadge_core.constants import EXTENSION_URI
|
|
8
|
+
|
|
9
|
+
FIXTURES = CoreConfig().fixtures_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def descriptor() -> dict:
|
|
13
|
+
return json.loads((FIXTURES / "badges" / "valid-badge-descriptor.json").read_text())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def card() -> dict:
|
|
17
|
+
return json.loads((FIXTURES / "agent-cards" / "valid-agent-card.json").read_text())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestPlacementReExports:
|
|
21
|
+
def test_patch_badge_defaults_to_a2a(self) -> None:
|
|
22
|
+
patched = agentbadge.patch_badge(card(), descriptor())
|
|
23
|
+
assert patched["capabilities"]["extensions"][0]["uri"] == EXTENSION_URI
|
|
24
|
+
|
|
25
|
+
def test_patch_badge_renders_several_protocols(self) -> None:
|
|
26
|
+
artifacts = agentbadge.patch_badge(
|
|
27
|
+
card(), descriptor(), protocols=["a2a", "mcp", "x402"]
|
|
28
|
+
)
|
|
29
|
+
assert set(artifacts) == {"a2a", "mcp", "x402"}
|
|
30
|
+
|
|
31
|
+
def test_badge_fragment_is_mergeable(self) -> None:
|
|
32
|
+
fragment = agentbadge.badge_fragment(descriptor(), "acp")
|
|
33
|
+
assert fragment["metadata"]["annotations"]["io.agentbadge/badge"]["uri"] == EXTENSION_URI
|
|
34
|
+
|
|
35
|
+
def test_badge_headers_carry_the_badge(self) -> None:
|
|
36
|
+
headers = agentbadge.badge_headers(descriptor())
|
|
37
|
+
assert headers["X-Agent-Badge"].startswith("bdg_")
|
|
38
|
+
|
|
39
|
+
def test_find_badge_round_trips(self) -> None:
|
|
40
|
+
patched = agentbadge.patch_badge(card(), descriptor(), protocols=["langgraph"])
|
|
41
|
+
assert agentbadge.find_badge(patched) is not None
|
|
42
|
+
|
|
43
|
+
def test_supported_protocols_and_table_agree(self) -> None:
|
|
44
|
+
table = agentbadge.placement_table()
|
|
45
|
+
assert [row["protocol"] for row in table] == list(agentbadge.SUPPORTED_PROTOCOLS)
|
|
46
|
+
assert len(agentbadge.SUPPORTED_PROTOCOLS) == 20
|
|
47
|
+
|
|
48
|
+
def test_card_hash_survives_placement(self) -> None:
|
|
49
|
+
original = card()
|
|
50
|
+
patched = agentbadge.patch_badge(original, descriptor(), protocols=["mcp"])
|
|
51
|
+
assert agentbadge.AgentCard.compute_hash(patched) == agentbadge.AgentCard.compute_hash(
|
|
52
|
+
original
|
|
53
|
+
)
|