agentapprovalprotocol 0.2.0__tar.gz → 0.3.1__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.
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/.gitignore +1 -0
- agentapprovalprotocol-0.2.0/README.md → agentapprovalprotocol-0.3.1/PKG-INFO +71 -1
- agentapprovalprotocol-0.2.0/PKG-INFO → agentapprovalprotocol-0.3.1/README.md +40 -28
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/pyproject.toml +6 -1
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/__init__.py +5 -2
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/_client.py +17 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/_gate.py +1 -1
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/crewai.py +2 -0
- agentapprovalprotocol-0.3.1/src/agentapprovalprotocol/receiver.py +340 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/testing.py +65 -2
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_adk.py +3 -3
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_claude_agent_sdk.py +1 -1
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_crewai.py +10 -5
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_gate.py +1 -1
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_langgraph.py +1 -1
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_openai_agents.py +4 -4
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/tests/test_pydantic_ai.py +1 -1
- agentapprovalprotocol-0.3.1/tests/test_wire_and_receiver.py +285 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/uv.lock +10 -2
- agentapprovalprotocol-0.2.0/src/agentapprovalprotocol/receiver.py +0 -168
- agentapprovalprotocol-0.2.0/tests/test_wire_and_receiver.py +0 -121
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/_wire.py +0 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/adk.py +0 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/claude_agent_sdk.py +0 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/langgraph.py +0 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/openai_agents.py +0 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/py.typed +0 -0
- {agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/pydantic_ai.py +0 -0
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentapprovalprotocol
|
|
3
|
+
Version: 0.3.1
|
|
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,
|
|
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,
|
|
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.
|
|
3
|
+
version = "0.3.1"
|
|
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
|
]
|
{agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/__init__.py
RENAMED
|
@@ -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
|
]
|
{agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/_client.py
RENAMED
|
@@ -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:
|
{agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/_gate.py
RENAMED
|
@@ -117,7 +117,7 @@ def _settled(request: Request) -> Deny | None:
|
|
|
117
117
|
"""The deny for a terminal request, or None when it may be consumed."""
|
|
118
118
|
if request.status == DENIED:
|
|
119
119
|
note = request.decision.note if request.decision else None
|
|
120
|
-
return Deny(f"The provider denied this call: {note}" if note else "The provider denied this call.", DENIED, request.id)
|
|
120
|
+
return Deny(f"The AAP provider denied this call: {note}" if note else "The AAP provider denied this call.", DENIED, request.id)
|
|
121
121
|
if request.status == CANCELLED:
|
|
122
122
|
return Deny(CANCELLED_TEXT, CANCELLED, request.id)
|
|
123
123
|
if request.status == EXPIRED:
|
{agentapprovalprotocol-0.2.0 → agentapprovalprotocol-0.3.1}/src/agentapprovalprotocol/crewai.py
RENAMED
|
@@ -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})
|