agentapprovalprotocol 0.2.0__tar.gz → 0.3.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.
Files changed (28) hide show
  1. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/.gitignore +1 -0
  2. agentapprovalprotocol-0.2.0/README.md → agentapprovalprotocol-0.3.0/PKG-INFO +71 -1
  3. agentapprovalprotocol-0.2.0/PKG-INFO → agentapprovalprotocol-0.3.0/README.md +40 -28
  4. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/pyproject.toml +6 -1
  5. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/__init__.py +5 -2
  6. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/_client.py +17 -0
  7. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/crewai.py +2 -0
  8. agentapprovalprotocol-0.3.0/src/agentapprovalprotocol/receiver.py +340 -0
  9. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/testing.py +65 -2
  10. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_crewai.py +5 -0
  11. agentapprovalprotocol-0.3.0/tests/test_wire_and_receiver.py +285 -0
  12. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/uv.lock +10 -2
  13. agentapprovalprotocol-0.2.0/src/agentapprovalprotocol/receiver.py +0 -168
  14. agentapprovalprotocol-0.2.0/tests/test_wire_and_receiver.py +0 -121
  15. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/_gate.py +0 -0
  16. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/_wire.py +0 -0
  17. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/adk.py +0 -0
  18. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/claude_agent_sdk.py +0 -0
  19. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/langgraph.py +0 -0
  20. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/openai_agents.py +0 -0
  21. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/py.typed +0 -0
  22. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/src/agentapprovalprotocol/pydantic_ai.py +0 -0
  23. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_adk.py +0 -0
  24. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_claude_agent_sdk.py +0 -0
  25. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_gate.py +0 -0
  26. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_langgraph.py +0 -0
  27. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_openai_agents.py +0 -0
  28. {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.0}/tests/test_pydantic_ai.py +0 -0
@@ -15,5 +15,6 @@ playwright-report/
15
15
  /sdk/python/dist/
16
16
  /sdk/typescript/dist/
17
17
  /sdk/typescript-ai-sdk/dist/
18
+ /sdk/typescript-mastra/dist/
18
19
  __pycache__/
19
20
  /sdk/python/.pytest_cache/
@@ -1,3 +1,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentapprovalprotocol
3
+ Version: 0.3.0
4
+ Summary: Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider.
5
+ Project-URL: Homepage, https://agentapprovalprotocol.io
6
+ Project-URL: Source, https://github.com/agentapprovalprotocol/agentapprovalprotocol
7
+ License-Expression: Apache-2.0
8
+ Keywords: aap,agents,ai,approval,human-in-the-loop
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Typing :: Typed
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.27
13
+ Provides-Extra: adk
14
+ Requires-Dist: google-adk>=2.11; extra == 'adk'
15
+ Provides-Extra: claude-agent-sdk
16
+ Requires-Dist: claude-agent-sdk>=0.2.165; extra == 'claude-agent-sdk'
17
+ Provides-Extra: crewai
18
+ Requires-Dist: crewai>=1.15; (python_full_version < '3.14') and extra == 'crewai'
19
+ Provides-Extra: deep-agents
20
+ Requires-Dist: deepagents>=0.7.24; (python_full_version >= '3.11') and extra == 'deep-agents'
21
+ Requires-Dist: langchain>=1.0; extra == 'deep-agents'
22
+ Requires-Dist: langgraph>=1.0; extra == 'deep-agents'
23
+ Provides-Extra: langgraph
24
+ Requires-Dist: langchain>=1.0; extra == 'langgraph'
25
+ Requires-Dist: langgraph>=1.0; extra == 'langgraph'
26
+ Provides-Extra: openai-agents
27
+ Requires-Dist: openai-agents>=0.23; extra == 'openai-agents'
28
+ Provides-Extra: pydantic-ai
29
+ Requires-Dist: pydantic-ai-slim>=2.55; (python_full_version >= '3.11') and extra == 'pydantic-ai'
30
+ Description-Content-Type: text/markdown
31
+
1
32
  # Agent Approval Protocol for Python
2
33
 
3
34
  Put an AI agent's tool calls behind an [AAP](https://agentapprovalprotocol.io) approval provider. Each call waits for the provider's decision, runs only if approved, and runs at most once.
@@ -30,6 +61,45 @@ With `mode="async"`, `check` returns `Pending` when there is no decision yet, so
30
61
 
31
62
  Framework integrations handle pausing and resuming for you.
32
63
 
64
+ Start the receiver first, because the provider checks that it answers before registering it:
65
+
66
+ ```python
67
+ from fastapi import FastAPI
68
+ from agentapprovalprotocol import Receiver
69
+
70
+ receiver = Receiver(resumers={"langgraph": resumer(agent)})
71
+ app = FastAPI()
72
+ app.add_route("/aap/events", receiver.asgi(), methods=["POST"])
73
+ ```
74
+
75
+ Add it as a route, as shown. `app.mount("/aap/events", ...)` redirects `/aap/events` to `/aap/events/`, and providers do not follow redirects.
76
+
77
+ Once the server is running, register the receiver:
78
+
79
+ ```python
80
+ provisioned = await receiver.provision(
81
+ provider_url="https://approvals.example.com/api/aap",
82
+ provisioner_token=provisioner_token,
83
+ url="https://agent.example.com/aap/events",
84
+ )
85
+ ```
86
+
87
+ `provision` creates the signing secret and registration ID, and the receiver starts using the new instance credential. Save `token`, `secret`, `registration_id` and `instance_id` from the result. Nothing is saved for you. If it raises `WebhookVerificationFailed`, check the receiver is reachable at `url` and call `provision` again.
88
+
89
+ On later starts, pass the saved values. `client` can be a function, called the first time a run is woken:
90
+
91
+ ```python
92
+ receiver = Receiver(
93
+ secret=secret,
94
+ registration_id=registration_id,
95
+ instance_id=instance_id,
96
+ client=lambda: AsyncClient("https://approvals.example.com/api/aap", load_token()),
97
+ resumers={"langgraph": resumer(agent)},
98
+ )
99
+ ```
100
+
101
+ To register in your provider's app instead, start the receiver with the secret and registration ID you enter there. Without the registration ID, the receiver refuses the provider's check.
102
+
33
103
  ## LangChain, LangGraph and Deep Agents
34
104
 
35
105
  ```sh
@@ -45,7 +115,7 @@ agent = create_agent(model, tools=tools, middleware=[approvals])
45
115
 
46
116
  Every tool call the agent makes goes to the provider. A denied call returns the reason to the model as the tool result.
47
117
 
48
- For Deep Agents, also add the middleware to every subagent:
118
+ For Deep Agents (Python 3.11 or later), install `agentapprovalprotocol[deep-agents]` and add the middleware to every subagent:
49
119
 
50
120
  ```python
51
121
  from agentapprovalprotocol.langgraph import deep_agent_subagents
@@ -1,30 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: agentapprovalprotocol
3
- Version: 0.2.0
4
- Summary: Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider.
5
- Project-URL: Homepage, https://agentapprovalprotocol.io
6
- Project-URL: Source, https://github.com/agentapprovalprotocol/agentapprovalprotocol
7
- License-Expression: Apache-2.0
8
- Keywords: aap,agents,ai,approval,human-in-the-loop
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Typing :: Typed
11
- Requires-Python: >=3.10
12
- Requires-Dist: httpx>=0.27
13
- Provides-Extra: adk
14
- Requires-Dist: google-adk>=2.11; extra == 'adk'
15
- Provides-Extra: claude-agent-sdk
16
- Requires-Dist: claude-agent-sdk>=0.2.165; extra == 'claude-agent-sdk'
17
- Provides-Extra: crewai
18
- Requires-Dist: crewai>=1.15; (python_full_version < '3.14') and extra == 'crewai'
19
- Provides-Extra: langgraph
20
- Requires-Dist: langchain>=1.0; extra == 'langgraph'
21
- Requires-Dist: langgraph>=1.0; extra == 'langgraph'
22
- Provides-Extra: openai-agents
23
- Requires-Dist: openai-agents>=0.23; extra == 'openai-agents'
24
- Provides-Extra: pydantic-ai
25
- Requires-Dist: pydantic-ai-slim>=2.55; (python_full_version >= '3.11') and extra == 'pydantic-ai'
26
- Description-Content-Type: text/markdown
27
-
28
1
  # Agent Approval Protocol for Python
29
2
 
30
3
  Put an AI agent's tool calls behind an [AAP](https://agentapprovalprotocol.io) approval provider. Each call waits for the provider's decision, runs only if approved, and runs at most once.
@@ -57,6 +30,45 @@ With `mode="async"`, `check` returns `Pending` when there is no decision yet, so
57
30
 
58
31
  Framework integrations handle pausing and resuming for you.
59
32
 
33
+ Start the receiver first, because the provider checks that it answers before registering it:
34
+
35
+ ```python
36
+ from fastapi import FastAPI
37
+ from agentapprovalprotocol import Receiver
38
+
39
+ receiver = Receiver(resumers={"langgraph": resumer(agent)})
40
+ app = FastAPI()
41
+ app.add_route("/aap/events", receiver.asgi(), methods=["POST"])
42
+ ```
43
+
44
+ Add it as a route, as shown. `app.mount("/aap/events", ...)` redirects `/aap/events` to `/aap/events/`, and providers do not follow redirects.
45
+
46
+ Once the server is running, register the receiver:
47
+
48
+ ```python
49
+ provisioned = await receiver.provision(
50
+ provider_url="https://approvals.example.com/api/aap",
51
+ provisioner_token=provisioner_token,
52
+ url="https://agent.example.com/aap/events",
53
+ )
54
+ ```
55
+
56
+ `provision` creates the signing secret and registration ID, and the receiver starts using the new instance credential. Save `token`, `secret`, `registration_id` and `instance_id` from the result. Nothing is saved for you. If it raises `WebhookVerificationFailed`, check the receiver is reachable at `url` and call `provision` again.
57
+
58
+ On later starts, pass the saved values. `client` can be a function, called the first time a run is woken:
59
+
60
+ ```python
61
+ receiver = Receiver(
62
+ secret=secret,
63
+ registration_id=registration_id,
64
+ instance_id=instance_id,
65
+ client=lambda: AsyncClient("https://approvals.example.com/api/aap", load_token()),
66
+ resumers={"langgraph": resumer(agent)},
67
+ )
68
+ ```
69
+
70
+ To register in your provider's app instead, start the receiver with the secret and registration ID you enter there. Without the registration ID, the receiver refuses the provider's check.
71
+
60
72
  ## LangChain, LangGraph and Deep Agents
61
73
 
62
74
  ```sh
@@ -72,7 +84,7 @@ agent = create_agent(model, tools=tools, middleware=[approvals])
72
84
 
73
85
  Every tool call the agent makes goes to the provider. A denied call returns the reason to the model as the tool result.
74
86
 
75
- For Deep Agents, also add the middleware to every subagent:
87
+ For Deep Agents (Python 3.11 or later), install `agentapprovalprotocol[deep-agents]` and add the middleware to every subagent:
76
88
 
77
89
  ```python
78
90
  from agentapprovalprotocol.langgraph import deep_agent_subagents
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentapprovalprotocol"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Put an AI agent's tool calls behind an Agent Approval Protocol (AAP) approval provider."
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -22,6 +22,11 @@ langgraph = [
22
22
  "langchain>=1.0",
23
23
  "langgraph>=1.0",
24
24
  ]
25
+ deep-agents = [
26
+ "langchain>=1.0",
27
+ "langgraph>=1.0",
28
+ "deepagents>=0.7.24 ; python_full_version >= '3.11'",
29
+ ]
25
30
  pydantic-ai = [
26
31
  "pydantic-ai-slim>=2.55 ; python_full_version >= '3.11'",
27
32
  ]
@@ -5,10 +5,10 @@ Put an agent's tool calls behind an AAP approval provider. ``Gate`` and
5
5
  on them. See https://agentapprovalprotocol.io.
6
6
  """
7
7
 
8
- from ._client import AAPError, AsyncClient, Client, ConflictError, CredentialRefused, NotFound
8
+ from ._client import AAPError, AsyncClient, Client, ConflictError, CredentialRefused, NotFound, ProvisionError, WebhookVerificationFailed
9
9
  from ._gate import Allow, AsyncGate, Call, Deny, Gate, Pending, Verdict
10
10
  from ._wire import Consumption, Decision, Outcome, ProtocolError, Request
11
- from .receiver import Receiver
11
+ from .receiver import Provisioned, Receiver
12
12
 
13
13
  __all__ = [
14
14
  "AAPError",
@@ -27,7 +27,10 @@ __all__ = [
27
27
  "Outcome",
28
28
  "Pending",
29
29
  "ProtocolError",
30
+ "Provisioned",
31
+ "ProvisionError",
30
32
  "Receiver",
31
33
  "Request",
32
34
  "Verdict",
35
+ "WebhookVerificationFailed",
33
36
  ]
@@ -42,6 +42,23 @@ class NotFound(AAPError):
42
42
  """The request is unknown or unavailable to this instance."""
43
43
 
44
44
 
45
+ class ProvisionError(AAPError):
46
+ """Provisioning an instance failed.
47
+
48
+ ``status`` is the HTTP status and ``code`` the provider's error code,
49
+ when the provider answered with them.
50
+ """
51
+
52
+ def __init__(self, message: str, *, status: int | None = None, code: str | None = None):
53
+ super().__init__(message)
54
+ self.status = status
55
+ self.code = code
56
+
57
+
58
+ class WebhookVerificationFailed(ProvisionError):
59
+ """The provider could not verify the receiver (HTTP 422, ``webhook_verification_failed``)."""
60
+
61
+
45
62
  def validate_base_url(base_url: str) -> str:
46
63
  parts = urlsplit(base_url)
47
64
  if not parts.netloc or parts.username or parts.password or parts.query or parts.fragment:
@@ -161,6 +161,8 @@ class ApprovalHooks:
161
161
  ):
162
162
  if gate is None and (base_url is None or token is None):
163
163
  raise ValueError("pass base_url and token, or a gate")
164
+ if gate_options.get("mode") == "async":
165
+ raise ValueError("CrewAI cannot pause a run at a tool call, so it only supports sync mode")
164
166
  self.gate = gate if gate is not None else Gate(Client(base_url, token), **gate_options)
165
167
  self.context = {"framework": "crewai", **(context or {})}
166
168
  self._run = uuid.uuid4().hex
@@ -0,0 +1,340 @@
1
+ """Webhook receiver for asynchronous mode.
2
+
3
+ The receiver verifies AAP webhooks, answers receiver verification during
4
+ provisioning, and wakes paused runs. A notification only wakes a run: the
5
+ gate fetches and consumes the decision itself, so the receiver keeps no
6
+ state and a repeated or forged event at most wakes a run that pauses again.
7
+
8
+ The resume handle is read from the request's ``context.resume``, which the
9
+ gate wrote when it created the request. Its ``framework`` names the resumer
10
+ to call.
11
+
12
+ Verification needs only the signing secret and the registration ID, so a
13
+ receiver can start before the instance exists. Waking a run needs a client
14
+ for the instance credential, which exists only after provisioning: pass a
15
+ client, a factory that builds one on first wake, or call ``provision``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import base64
22
+ import hashlib
23
+ import hmac
24
+ import inspect
25
+ import json
26
+ import logging
27
+ import secrets
28
+ import time
29
+ import uuid
30
+ from dataclasses import dataclass
31
+ from typing import Any, Awaitable, Callable, Mapping
32
+
33
+ import httpx
34
+
35
+ from ._client import MAX_RESPONSE, USER_AGENT, AAPError, AsyncClient, ProvisionError, WebhookVerificationFailed, validate_base_url
36
+ from ._wire import ProtocolError, Request, loads
37
+
38
+ log = logging.getLogger("agentapprovalprotocol")
39
+
40
+ Resumer = Callable[[Mapping[str, Any], Request], Awaitable[None] | None]
41
+ #: Builds the client on first wake, for example from a credential saved after provisioning.
42
+ ClientFactory = Callable[[], AsyncClient | Awaitable[AsyncClient]]
43
+
44
+
45
+ def new_secret() -> str:
46
+ """A fresh signing secret: 32 random bytes as padded base64 with a ``whsec_`` prefix."""
47
+ return "whsec_" + base64.b64encode(secrets.token_bytes(32)).decode()
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Provisioned:
52
+ """An instance created by ``Receiver.provision``.
53
+
54
+ Save ``token`` as the instance credential, and ``secret``,
55
+ ``registration_id`` and ``instance_id`` for the receiver's next start.
56
+ """
57
+
58
+ instance_id: str
59
+ instance_name: str
60
+ token: str
61
+ #: When the credential expires, as the provider returned it. ``None`` if no expiry is scheduled.
62
+ expires_at: str | None
63
+ secret: str
64
+ registration_id: str
65
+
66
+ def __repr__(self) -> str:
67
+ return f"Provisioned(instance_id={self.instance_id!r}, instance_name={self.instance_name!r}, registration_id={self.registration_id!r})"
68
+
69
+
70
+ def signing_key(secret: str) -> bytes:
71
+ if not secret.startswith("whsec_"):
72
+ raise ValueError("signing secret must start with whsec_")
73
+ return base64.b64decode(secret[len("whsec_") :], validate=True)
74
+
75
+
76
+ def sign(secret: str, message_id: str, timestamp: int, body: bytes) -> str:
77
+ """The ``webhook-signature`` header value for one delivery attempt."""
78
+ digest = hmac.new(signing_key(secret), f"{message_id}.{timestamp}.".encode() + body, hashlib.sha256).digest()
79
+ return "v1," + base64.b64encode(digest).decode()
80
+
81
+
82
+ def verify(secret: str, headers: Mapping[str, str], body: bytes, *, tolerance: int = 300, now: float | None = None) -> dict[str, Any]:
83
+ """Verify a delivery and return its parsed body. Raises ``ValueError``."""
84
+ lower = {k.lower(): v for k, v in headers.items()}
85
+ message_id = lower.get("webhook-id", "")
86
+ stamp = lower.get("webhook-timestamp", "")
87
+ signatures = lower.get("webhook-signature", "").split()
88
+ if not message_id or not stamp.isdigit() or not signatures:
89
+ raise ValueError("missing webhook headers")
90
+ if abs((now if now is not None else time.time()) - int(stamp)) > tolerance:
91
+ raise ValueError("webhook timestamp outside tolerance")
92
+ expected = sign(secret, message_id, int(stamp), body)
93
+ if not any(hmac.compare_digest(expected, candidate) for candidate in signatures if candidate.startswith("v1,")):
94
+ raise ValueError("invalid webhook signature")
95
+ try:
96
+ message = loads(body)
97
+ except ProtocolError as err:
98
+ raise ValueError("invalid webhook body") from err
99
+ if not isinstance(message, dict) or message.get("id") != message_id:
100
+ raise ValueError("webhook ID does not match the body")
101
+ return message
102
+
103
+
104
+ class Receiver:
105
+ """Verifies AAP webhooks and wakes paused runs.
106
+
107
+ ``secret`` is the instance's signing secret. ``registration_id`` is the
108
+ ID the provisioner generated for this receiver; verification messages for
109
+ any other registration are refused. ``provision`` generates both when
110
+ they are not given. ``instance_id``, once known, restricts notifications
111
+ to that instance.
112
+
113
+ ``client`` reads requests when a run is woken. It may be a client, a
114
+ zero-argument function returning one (called on first wake, then
115
+ cached), or ``None`` until ``provision`` installs one. ``resumers`` maps
116
+ a framework name to a function that resumes a run from its resume handle.
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ *,
122
+ resumers: Mapping[str, Resumer],
123
+ secret: str | None = None,
124
+ client: AsyncClient | ClientFactory | None = None,
125
+ registration_id: str | None = None,
126
+ instance_id: str | None = None,
127
+ tolerance: int = 300,
128
+ ):
129
+ if secret is not None:
130
+ signing_key(secret)
131
+ self.secret = secret
132
+ self.client: AsyncClient | None = None if callable(client) else client
133
+ self._factory: ClientFactory | None = client if callable(client) else None
134
+ self._loading: asyncio.Future[AsyncClient] | None = None
135
+ self.resumers = dict(resumers)
136
+ self.registration_id = registration_id
137
+ self.instance_id = instance_id
138
+ self.tolerance = tolerance
139
+ self._tasks: set[asyncio.Task[None]] = set()
140
+
141
+ async def handle(self, headers: Mapping[str, str], body: bytes) -> tuple[int, bytes]:
142
+ """Process one delivery. Returns the HTTP status and body to send.
143
+
144
+ A notification is acknowledged at once and the run is woken in the
145
+ background, because a delivery attempt has a ten second limit.
146
+ """
147
+ if self.secret is None:
148
+ return 401, b""
149
+ try:
150
+ message = verify(self.secret, headers, body, tolerance=self.tolerance)
151
+ except ValueError:
152
+ return 401, b""
153
+ kind = message.get("type")
154
+ if kind == "webhook.verification":
155
+ if self.registration_id is None or message.get("registration_id") != self.registration_id or not isinstance(message.get("challenge"), str):
156
+ return 403, b""
157
+ return 200, json.dumps({"challenge": message["challenge"]}).encode()
158
+ if kind == "request.resolved":
159
+ if self.instance_id is not None and message.get("instance_id") != self.instance_id:
160
+ return 403, b""
161
+ request_id = message.get("request_id")
162
+ if isinstance(request_id, str):
163
+ task = asyncio.ensure_future(self.wake(request_id))
164
+ self._tasks.add(task)
165
+ task.add_done_callback(self._tasks.discard)
166
+ return 204, b""
167
+ return 400, b""
168
+
169
+ async def wake(self, request_id: str) -> bool:
170
+ """Resume the run waiting on a request. Also the recovery sweep's entry point.
171
+
172
+ Returns ``False`` when nothing was resumed, including when the
173
+ receiver has no client yet.
174
+ """
175
+ try:
176
+ client = await self._client()
177
+ except Exception: # noqa: BLE001 - a failing factory must not crash the receiver
178
+ log.exception("could not create a client to resume request %s", request_id)
179
+ return False
180
+ if client is None:
181
+ log.warning("cannot resume request %s: the receiver has no client yet", request_id)
182
+ return False
183
+ try:
184
+ request = await client.get(request_id)
185
+ except (AAPError, ProtocolError):
186
+ log.warning("could not read request %s to resume it", request_id, exc_info=True)
187
+ return False
188
+ handle = (request.context or {}).get("resume")
189
+ resumer = self.resumers.get(handle.get("framework", "")) if isinstance(handle, dict) else None
190
+ if resumer is None:
191
+ log.warning("request %s has no resume handle this receiver can use", request_id)
192
+ return False
193
+ try:
194
+ result = resumer(handle, request)
195
+ if inspect.isawaitable(result):
196
+ await result
197
+ return True
198
+ except Exception: # noqa: BLE001 - a failed resume must not crash the receiver
199
+ log.exception("resuming the run for request %s failed", request_id)
200
+ return False
201
+
202
+ async def _client(self) -> AsyncClient | None:
203
+ """The client, building it with the factory once if needed."""
204
+ if self.client is not None or self._factory is None:
205
+ return self.client
206
+ if self._loading is None:
207
+ self._loading = asyncio.ensure_future(_build(self._factory))
208
+ loading = self._loading
209
+ try:
210
+ client = await asyncio.shield(loading)
211
+ except BaseException:
212
+ if self._loading is loading and loading.done():
213
+ self._loading = None
214
+ raise
215
+ if self.client is None:
216
+ self.client = client
217
+ return self.client
218
+
219
+ async def provision(
220
+ self,
221
+ *,
222
+ provider_url: str,
223
+ provisioner_token: str,
224
+ url: str,
225
+ instance_name: str | None = None,
226
+ idempotency_key: str | None = None,
227
+ http: httpx.AsyncClient | None = None,
228
+ ) -> Provisioned:
229
+ """Create an instance whose notifications come to this receiver.
230
+
231
+ ``provider_url`` is the provider's AAP base URL and ``url`` this
232
+ receiver's public HTTPS URL. The provider verifies the receiver before
233
+ it answers, so the receiver must already be serving at ``url``.
234
+
235
+ Generates the signing secret and registration ID if the receiver has
236
+ none. ``idempotency_key`` defaults to the registration ID, so calling
237
+ again after a failure retries the same submission. On success the
238
+ receiver uses the new credential to wake runs and accepts
239
+ notifications only for the new instance. Save the result: nothing is
240
+ stored.
241
+
242
+ Raises ``WebhookVerificationFailed`` when the provider could not
243
+ verify the receiver, ``ProvisionError`` for other failures and
244
+ ``ProtocolError`` for a malformed answer.
245
+ """
246
+ if not provisioner_token or "\n" in provisioner_token or "\r" in provisioner_token:
247
+ raise ValueError("provisioner token is required and must not contain newlines")
248
+ base_url = validate_base_url(provider_url)
249
+ if self.secret is None:
250
+ self.secret = new_secret()
251
+ if self.registration_id is None:
252
+ self.registration_id = str(uuid.uuid4())
253
+ submission: dict[str, Any] = {"delivery": {"type": "webhook", "url": url, "registration_id": self.registration_id, "signing_secret": self.secret}}
254
+ if instance_name is not None:
255
+ submission = {"instance_name": instance_name, **submission}
256
+ headers = {
257
+ "Authorization": f"Bearer {provisioner_token}",
258
+ "Content-Type": "application/json",
259
+ "Idempotency-Key": idempotency_key or self.registration_id,
260
+ "User-Agent": USER_AGENT,
261
+ }
262
+ transport = http or httpx.AsyncClient(timeout=40.0, follow_redirects=False)
263
+ try:
264
+ response = await transport.post(base_url + "/v1/instances", headers=headers, content=json.dumps(submission).encode())
265
+ raw = await response.aread()
266
+ except httpx.TransportError as err:
267
+ raise ProvisionError("the provider could not be reached") from err
268
+ finally:
269
+ if http is None:
270
+ await transport.aclose()
271
+ instance_id, name, token, expires_at = _provisioned(response.status_code, raw)
272
+ self.client = AsyncClient(base_url, token, http=http)
273
+ self._factory = None
274
+ self.instance_id = instance_id
275
+ return Provisioned(instance_id=instance_id, instance_name=name, token=token, expires_at=expires_at, secret=self.secret, registration_id=self.registration_id)
276
+
277
+ def asgi(self) -> Callable[..., Awaitable[None]]:
278
+ """An ASGI app that accepts POSTed webhooks at any path it is served on.
279
+
280
+ It is an object rather than a function, so Starlette and FastAPI
281
+ routes serve it as an ASGI app.
282
+ """
283
+ return _ASGIApp(self)
284
+
285
+
286
+ class _ASGIApp:
287
+ def __init__(self, receiver: Receiver):
288
+ self.receiver = receiver
289
+
290
+ async def __call__(self, scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]]) -> None:
291
+ if scope["type"] != "http":
292
+ return
293
+ if scope["method"] != "POST":
294
+ await _respond(send, 405, b"")
295
+ return
296
+ chunks = []
297
+ more = True
298
+ while more:
299
+ event = await receive()
300
+ chunks.append(event.get("body", b""))
301
+ more = event.get("more_body", False)
302
+ headers = {k.decode("latin-1"): v.decode("latin-1") for k, v in scope.get("headers", [])}
303
+ status, body = await self.receiver.handle(headers, b"".join(chunks))
304
+ await _respond(send, status, body)
305
+
306
+
307
+ async def _build(factory: ClientFactory) -> AsyncClient:
308
+ client = factory()
309
+ return await client if inspect.isawaitable(client) else client
310
+
311
+
312
+ def _provisioned(status: int, raw: bytes) -> tuple[str, str, str, str | None]:
313
+ """Check a provisioning answer and return the instance ID, name, token and expiry."""
314
+ if status not in (200, 201):
315
+ code = None
316
+ try:
317
+ error = loads(raw)["error"]
318
+ code = error["code"] if isinstance(error["code"], str) else None
319
+ except (ProtocolError, KeyError, TypeError):
320
+ pass
321
+ if status == 422 and code == "webhook_verification_failed":
322
+ raise WebhookVerificationFailed("the provider could not verify the receiver", status=status, code=code)
323
+ raise ProvisionError(f"provisioning failed (HTTP {status}{f': {code}' if code else ''})", status=status, code=code)
324
+ if len(raw) > MAX_RESPONSE:
325
+ raise ProtocolError("AAP response too large")
326
+ body = loads(raw)
327
+ instance = body.get("instance") if isinstance(body, dict) else None
328
+ credential = body.get("credential") if isinstance(body, dict) else None
329
+ if not isinstance(instance, dict) or not isinstance(credential, dict):
330
+ raise ProtocolError("invalid provisioning response")
331
+ instance_id, name, token, expires_at = instance.get("id"), instance.get("name"), credential.get("token"), credential.get("expires_at")
332
+ if not all(isinstance(v, str) and v for v in (instance_id, name, token)) or not (expires_at is None or isinstance(expires_at, str)):
333
+ raise ProtocolError("invalid provisioning response")
334
+ return instance_id, name, token, expires_at
335
+
336
+
337
+ async def _respond(send: Callable[[dict[str, Any]], Awaitable[None]], status: int, body: bytes) -> None:
338
+ headers = [(b"content-type", b"application/json")] if body else []
339
+ await send({"type": "http.response.start", "status": status, "headers": headers})
340
+ await send({"type": "http.response.body", "body": body})