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.
@@ -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
+ )