readyagentsdev 0.8.2__py3-none-any.whl
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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
readyagents/mcp/http.py
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
"""Loopback Streamable HTTP composition: auth, DNS-rebinding, body limit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import inspect
|
|
8
|
+
import ipaddress
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import secrets
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
from collections import deque
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from readyagents.config import (
|
|
20
|
+
DEFAULT_MCP_TOKEN_ENV,
|
|
21
|
+
LOOPBACK_HOSTS,
|
|
22
|
+
MAX_CONCURRENT_RUNS_HARD,
|
|
23
|
+
MAX_HTTP_BODY_BYTES,
|
|
24
|
+
MAX_PENDING_RUNS_HARD,
|
|
25
|
+
get_settings,
|
|
26
|
+
)
|
|
27
|
+
from readyagents.errors import MCPError
|
|
28
|
+
from readyagents.mcp.server import construct_server, streamable_http_app
|
|
29
|
+
|
|
30
|
+
_SCOPE_REQUEST_ID = "readyagents.request_id"
|
|
31
|
+
_MAX_REQUEST_ID_LEN = 128
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def assert_loopback_host(host: str) -> str:
|
|
35
|
+
"""Reject non-loopback bind hosts. Allow 127.0.0.1, localhost, and ::1."""
|
|
36
|
+
if host is None or not str(host).strip():
|
|
37
|
+
raise MCPError("MCP HTTP bind host must be loopback, not empty.")
|
|
38
|
+
raw = str(host).strip()
|
|
39
|
+
if raw.startswith("[") and raw.endswith("]") and len(raw) > 2:
|
|
40
|
+
raw = raw[1:-1]
|
|
41
|
+
lowered = raw.lower()
|
|
42
|
+
if lowered in {"0.0.0.0", "::", "[::]"}:
|
|
43
|
+
raise MCPError(
|
|
44
|
+
f"MCP HTTP bind host '{host}' is not loopback. Use 127.0.0.1, localhost, or ::1."
|
|
45
|
+
)
|
|
46
|
+
if lowered in LOOPBACK_HOSTS:
|
|
47
|
+
return raw
|
|
48
|
+
try:
|
|
49
|
+
ip = ipaddress.ip_address(raw)
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise MCPError(
|
|
52
|
+
f"MCP HTTP bind host '{host}' is not loopback. Use 127.0.0.1, localhost, or ::1."
|
|
53
|
+
) from exc
|
|
54
|
+
if ip.is_unspecified or not ip.is_loopback:
|
|
55
|
+
raise MCPError(
|
|
56
|
+
f"MCP HTTP bind host '{host}' is not loopback. Use 127.0.0.1, localhost, or ::1."
|
|
57
|
+
)
|
|
58
|
+
return raw
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_bearer_token(*, auth_mode: str, token_env: str, bind_host: str) -> str | None:
|
|
62
|
+
"""Return a bearer token, or None when ``--auth none`` on loopback."""
|
|
63
|
+
mode = (auth_mode or "").strip().lower()
|
|
64
|
+
env_name = (token_env or DEFAULT_MCP_TOKEN_ENV).strip() or DEFAULT_MCP_TOKEN_ENV
|
|
65
|
+
bind_host = assert_loopback_host(bind_host)
|
|
66
|
+
if mode == "none":
|
|
67
|
+
return None
|
|
68
|
+
if mode != "token":
|
|
69
|
+
raise MCPError("Invalid --auth value. Use token or none.")
|
|
70
|
+
existing = os.environ.get(env_name)
|
|
71
|
+
if existing is not None and existing.strip():
|
|
72
|
+
return existing.strip()
|
|
73
|
+
return secrets.token_urlsafe(32)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compose_http_app(
|
|
77
|
+
*,
|
|
78
|
+
server: Any,
|
|
79
|
+
coordinator: Any = None,
|
|
80
|
+
token: str | None,
|
|
81
|
+
bind_host: str,
|
|
82
|
+
bind_port: int,
|
|
83
|
+
max_body_bytes: int = MAX_HTTP_BODY_BYTES,
|
|
84
|
+
) -> Any:
|
|
85
|
+
"""SDK Streamable HTTP app plus /runs routes, wrapped in pure ASGI middleware."""
|
|
86
|
+
bind_host = assert_loopback_host(bind_host)
|
|
87
|
+
if not (1 <= int(bind_port) <= 65535):
|
|
88
|
+
raise MCPError("MCP HTTP port must be between 1 and 65535.")
|
|
89
|
+
secret = token.strip() if isinstance(token, str) and token.strip() else None
|
|
90
|
+
mcp_app = streamable_http_app(
|
|
91
|
+
server, host=bind_host, port=int(bind_port), max_body_bytes=max_body_bytes
|
|
92
|
+
)
|
|
93
|
+
router = getattr(mcp_app, "router", None)
|
|
94
|
+
routes = getattr(router, "routes", None)
|
|
95
|
+
if routes is not None:
|
|
96
|
+
routes[0:0] = _run_routes(coordinator)
|
|
97
|
+
# Do not wrap in a second Starlette: StreamableHTTPSessionManager.run() is once-only.
|
|
98
|
+
return CacheControlMiddleware(
|
|
99
|
+
AuthMiddleware(
|
|
100
|
+
HostOriginMiddleware(
|
|
101
|
+
BodyLimitMiddleware(mcp_app, max_body_bytes=max_body_bytes),
|
|
102
|
+
bind_host=bind_host,
|
|
103
|
+
bind_port=int(bind_port),
|
|
104
|
+
),
|
|
105
|
+
token=secret,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def serve_streamable_http(
|
|
111
|
+
*,
|
|
112
|
+
host: str,
|
|
113
|
+
port: int,
|
|
114
|
+
auth_mode: str,
|
|
115
|
+
token_env: str,
|
|
116
|
+
max_concurrent_runs: int,
|
|
117
|
+
max_pending_runs: int,
|
|
118
|
+
allow_http: bool | None = None,
|
|
119
|
+
workspace: Path | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Foreground loopback Streamable HTTP server. Blocking. Not started on import."""
|
|
122
|
+
coordinator: Any = None
|
|
123
|
+
try:
|
|
124
|
+
host = assert_loopback_host(host)
|
|
125
|
+
if not (1 <= int(port) <= 65535):
|
|
126
|
+
raise MCPError("MCP HTTP port must be between 1 and 65535.")
|
|
127
|
+
max_concurrent_runs = max(1, min(int(max_concurrent_runs), MAX_CONCURRENT_RUNS_HARD))
|
|
128
|
+
max_pending_runs = max(1, min(int(max_pending_runs), MAX_PENDING_RUNS_HARD))
|
|
129
|
+
mode = (auth_mode or "").strip().lower()
|
|
130
|
+
env_name = (token_env or DEFAULT_MCP_TOKEN_ENV).strip() or DEFAULT_MCP_TOKEN_ENV
|
|
131
|
+
prior = (os.environ.get(env_name) or "").strip()
|
|
132
|
+
token = resolve_bearer_token(auth_mode=mode, token_env=env_name, bind_host=host)
|
|
133
|
+
if mode == "token" and not prior:
|
|
134
|
+
print("Generated MCP bearer token (shown once):", file=sys.stderr)
|
|
135
|
+
print(token, file=sys.stderr)
|
|
136
|
+
elif mode == "none":
|
|
137
|
+
print(
|
|
138
|
+
"Warning: MCP HTTP authentication is disabled (--auth none). "
|
|
139
|
+
"Loopback-only; do not expose this process.",
|
|
140
|
+
file=sys.stderr,
|
|
141
|
+
)
|
|
142
|
+
settings = get_settings()
|
|
143
|
+
root = Path(workspace) if workspace is not None else settings.workspace_path()
|
|
144
|
+
server = construct_server(allow_http=allow_http, workspace=root)
|
|
145
|
+
coordinator = _try_run_coordinator(
|
|
146
|
+
workspace=root,
|
|
147
|
+
max_concurrent_runs=max_concurrent_runs,
|
|
148
|
+
max_pending_runs=max_pending_runs,
|
|
149
|
+
)
|
|
150
|
+
app = compose_http_app(
|
|
151
|
+
server=server,
|
|
152
|
+
coordinator=coordinator,
|
|
153
|
+
token=token,
|
|
154
|
+
bind_host=host,
|
|
155
|
+
bind_port=int(port),
|
|
156
|
+
max_body_bytes=MAX_HTTP_BODY_BYTES,
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
import uvicorn
|
|
160
|
+
except ImportError as exc:
|
|
161
|
+
raise MCPError('MCP extra is not installed. Run: pip install -e ".[mcp]"') from exc
|
|
162
|
+
uvicorn.run(app, host=host, port=int(port), log_level="warning")
|
|
163
|
+
finally:
|
|
164
|
+
shutdown = getattr(coordinator, "shutdown", None) if coordinator is not None else None
|
|
165
|
+
if callable(shutdown):
|
|
166
|
+
shutdown()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run_routes(coordinator: Any) -> list[Any]:
|
|
170
|
+
if coordinator is None:
|
|
171
|
+
return []
|
|
172
|
+
try:
|
|
173
|
+
from readyagents.mcp.run_api import build_run_routes
|
|
174
|
+
except ImportError:
|
|
175
|
+
return []
|
|
176
|
+
return list(build_run_routes(coordinator))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _try_run_coordinator(
|
|
180
|
+
*,
|
|
181
|
+
workspace: Path | None,
|
|
182
|
+
max_concurrent_runs: int,
|
|
183
|
+
max_pending_runs: int,
|
|
184
|
+
) -> Any | None:
|
|
185
|
+
try:
|
|
186
|
+
from readyagents.mcp.run_api import RunCoordinator
|
|
187
|
+
except ImportError:
|
|
188
|
+
return None
|
|
189
|
+
desired = {
|
|
190
|
+
"settings": get_settings(),
|
|
191
|
+
"workspace": workspace,
|
|
192
|
+
"max_concurrent_runs": max_concurrent_runs,
|
|
193
|
+
"max_pending_runs": max_pending_runs,
|
|
194
|
+
}
|
|
195
|
+
try:
|
|
196
|
+
signature = inspect.signature(RunCoordinator)
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
return RunCoordinator()
|
|
199
|
+
params = signature.parameters
|
|
200
|
+
if any(param.kind is inspect.Parameter.VAR_KEYWORD for param in params.values()):
|
|
201
|
+
return RunCoordinator(**desired)
|
|
202
|
+
accepted = {key: value for key, value in desired.items() if key in params}
|
|
203
|
+
return RunCoordinator(**accepted)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _header(scope: dict[str, Any], name: str) -> str | None:
|
|
207
|
+
key = name.lower().encode("latin-1")
|
|
208
|
+
for raw_key, raw_value in scope.get("headers") or ():
|
|
209
|
+
if raw_key.lower() == key:
|
|
210
|
+
return raw_value.decode("latin-1")
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _header_values(scope: dict[str, Any], name: str) -> list[str]:
|
|
215
|
+
key = name.lower().encode("latin-1")
|
|
216
|
+
values: list[str] = []
|
|
217
|
+
for raw_key, raw_value in scope.get("headers") or ():
|
|
218
|
+
if raw_key.lower() == key:
|
|
219
|
+
values.append(raw_value.decode("latin-1"))
|
|
220
|
+
return values
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_request_id(scope: dict[str, Any]) -> str:
|
|
224
|
+
raw = _header(scope, "x-request-id")
|
|
225
|
+
if raw is None:
|
|
226
|
+
return uuid.uuid4().hex
|
|
227
|
+
value = raw.strip()
|
|
228
|
+
if (
|
|
229
|
+
1 <= len(value) <= _MAX_REQUEST_ID_LEN
|
|
230
|
+
and value.isascii()
|
|
231
|
+
and value.isprintable()
|
|
232
|
+
and all(ch not in value for ch in " \t\r\n")
|
|
233
|
+
):
|
|
234
|
+
return value
|
|
235
|
+
return uuid.uuid4().hex
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _ensure_request_id(scope: dict[str, Any]) -> str:
|
|
239
|
+
existing = scope.get(_SCOPE_REQUEST_ID)
|
|
240
|
+
if isinstance(existing, str) and existing:
|
|
241
|
+
return existing
|
|
242
|
+
request_id = _parse_request_id(scope)
|
|
243
|
+
scope[_SCOPE_REQUEST_ID] = request_id
|
|
244
|
+
return request_id
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _error_payload(*, error: str, message: str, request_id: str) -> dict[str, Any]:
|
|
248
|
+
return {
|
|
249
|
+
"ok": False,
|
|
250
|
+
"error": error,
|
|
251
|
+
"message": message,
|
|
252
|
+
"run_id": None,
|
|
253
|
+
"request_id": request_id,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def _send_json(
|
|
258
|
+
send: Any,
|
|
259
|
+
*,
|
|
260
|
+
status: int,
|
|
261
|
+
payload: dict[str, Any],
|
|
262
|
+
extra_headers: list[tuple[bytes, bytes]] | None = None,
|
|
263
|
+
) -> None:
|
|
264
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
265
|
+
request_id = str(payload.get("request_id") or "")
|
|
266
|
+
headers: list[tuple[bytes, bytes]] = [
|
|
267
|
+
(b"content-type", b"application/json; charset=utf-8"),
|
|
268
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
269
|
+
(b"cache-control", b"no-store"),
|
|
270
|
+
]
|
|
271
|
+
if request_id:
|
|
272
|
+
headers.append((b"x-request-id", request_id.encode("utf-8", errors="replace")))
|
|
273
|
+
if extra_headers:
|
|
274
|
+
headers.extend(extra_headers)
|
|
275
|
+
await send({"type": "http.response.start", "status": status, "headers": headers})
|
|
276
|
+
await send({"type": "http.response.body", "body": body})
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _canonical_host(host: str) -> str:
|
|
280
|
+
value = host.strip().lower()
|
|
281
|
+
if value.startswith("[") and value.endswith("]") and len(value) > 2:
|
|
282
|
+
value = value[1:-1]
|
|
283
|
+
if value == "0:0:0:0:0:0:0:1":
|
|
284
|
+
return "::1"
|
|
285
|
+
try:
|
|
286
|
+
return str(ipaddress.ip_address(value))
|
|
287
|
+
except ValueError:
|
|
288
|
+
return value
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _split_host_port(value: str) -> tuple[str, int | None]:
|
|
292
|
+
text = value.strip()
|
|
293
|
+
if not text:
|
|
294
|
+
raise ValueError("empty host")
|
|
295
|
+
if text.startswith("["):
|
|
296
|
+
end = text.find("]")
|
|
297
|
+
if end == -1:
|
|
298
|
+
raise ValueError("bad ipv6 host")
|
|
299
|
+
host = text[1:end]
|
|
300
|
+
rest = text[end + 1 :]
|
|
301
|
+
if not rest:
|
|
302
|
+
return host, None
|
|
303
|
+
if rest.startswith(":") and rest[1:].isdigit():
|
|
304
|
+
return host, int(rest[1:])
|
|
305
|
+
raise ValueError("bad ipv6 host port")
|
|
306
|
+
if text.count(":") == 1:
|
|
307
|
+
host, port_s = text.rsplit(":", 1)
|
|
308
|
+
if port_s.isdigit():
|
|
309
|
+
return host, int(port_s)
|
|
310
|
+
return text, None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _hostname_allowed(hostname: str, bind_host: str) -> bool:
|
|
314
|
+
got = _canonical_host(hostname)
|
|
315
|
+
allowed = {_canonical_host(bind_host)}
|
|
316
|
+
for item in LOOPBACK_HOSTS:
|
|
317
|
+
allowed.add(_canonical_host(item))
|
|
318
|
+
return got in allowed
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _extract_bearer(authorization: str | None) -> str | None:
|
|
322
|
+
if not authorization:
|
|
323
|
+
return None
|
|
324
|
+
parts = authorization.split(None, 1)
|
|
325
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
326
|
+
return None
|
|
327
|
+
provided = parts[1].strip()
|
|
328
|
+
return provided or None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _tokens_match(provided: str, expected: str) -> bool:
|
|
332
|
+
got = hashlib.sha256(provided.encode("utf-8")).digest()
|
|
333
|
+
want = hashlib.sha256(expected.encode("utf-8")).digest()
|
|
334
|
+
return hmac.compare_digest(got, want)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class BodyLimitMiddleware:
|
|
338
|
+
"""Reject oversized HTTP bodies with 413 before the inner app."""
|
|
339
|
+
|
|
340
|
+
def __init__(self, app: Any, *, max_body_bytes: int) -> None:
|
|
341
|
+
self.app = app
|
|
342
|
+
self.max_body_bytes = max_body_bytes
|
|
343
|
+
|
|
344
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
345
|
+
if scope["type"] != "http":
|
|
346
|
+
await self.app(scope, receive, send)
|
|
347
|
+
return
|
|
348
|
+
request_id = _ensure_request_id(scope)
|
|
349
|
+
declared = _header(scope, "content-length")
|
|
350
|
+
if declared is not None:
|
|
351
|
+
try:
|
|
352
|
+
size = int(declared)
|
|
353
|
+
except ValueError:
|
|
354
|
+
await _send_json(
|
|
355
|
+
send,
|
|
356
|
+
status=400,
|
|
357
|
+
payload=_error_payload(
|
|
358
|
+
error="BadRequest",
|
|
359
|
+
message="Invalid Content-Length header.",
|
|
360
|
+
request_id=request_id,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
return
|
|
364
|
+
if size < 0:
|
|
365
|
+
await _send_json(
|
|
366
|
+
send,
|
|
367
|
+
status=400,
|
|
368
|
+
payload=_error_payload(
|
|
369
|
+
error="BadRequest",
|
|
370
|
+
message="Invalid Content-Length header.",
|
|
371
|
+
request_id=request_id,
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
return
|
|
375
|
+
if size > self.max_body_bytes:
|
|
376
|
+
await _send_json(
|
|
377
|
+
send,
|
|
378
|
+
status=413,
|
|
379
|
+
payload=_error_payload(
|
|
380
|
+
error="PayloadTooLarge",
|
|
381
|
+
message="Request body exceeds the maximum allowed size.",
|
|
382
|
+
request_id=request_id,
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
method = str(scope.get("method") or "").upper()
|
|
387
|
+
if method not in {"POST", "PUT", "PATCH"}:
|
|
388
|
+
await self.app(scope, receive, send)
|
|
389
|
+
return
|
|
390
|
+
received = bytearray()
|
|
391
|
+
saw_request = False
|
|
392
|
+
complete = False
|
|
393
|
+
trailing: dict[str, Any] | None = None
|
|
394
|
+
while True:
|
|
395
|
+
message = await receive()
|
|
396
|
+
if message["type"] != "http.request":
|
|
397
|
+
trailing = message
|
|
398
|
+
break
|
|
399
|
+
saw_request = True
|
|
400
|
+
chunk = message.get("body", b"") or b""
|
|
401
|
+
if len(received) + len(chunk) > self.max_body_bytes:
|
|
402
|
+
await _send_json(
|
|
403
|
+
send,
|
|
404
|
+
status=413,
|
|
405
|
+
payload=_error_payload(
|
|
406
|
+
error="PayloadTooLarge",
|
|
407
|
+
message="Request body exceeds the maximum allowed size.",
|
|
408
|
+
request_id=request_id,
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
return
|
|
412
|
+
received.extend(chunk)
|
|
413
|
+
if not message.get("more_body", False):
|
|
414
|
+
complete = True
|
|
415
|
+
break
|
|
416
|
+
cached: deque[dict[str, Any]] = deque()
|
|
417
|
+
if saw_request:
|
|
418
|
+
cached.append(
|
|
419
|
+
{
|
|
420
|
+
"type": "http.request",
|
|
421
|
+
"body": bytes(received),
|
|
422
|
+
"more_body": not complete,
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
if trailing is not None:
|
|
426
|
+
cached.append(trailing)
|
|
427
|
+
|
|
428
|
+
async def replay() -> dict[str, Any]:
|
|
429
|
+
if cached:
|
|
430
|
+
return cached.popleft()
|
|
431
|
+
return await receive()
|
|
432
|
+
|
|
433
|
+
await self.app(scope, replay, send)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class HostOriginMiddleware:
|
|
437
|
+
"""Reject bad Host/Origin before the inner app (DNS-rebinding defense)."""
|
|
438
|
+
|
|
439
|
+
def __init__(self, app: Any, *, bind_host: str, bind_port: int) -> None:
|
|
440
|
+
self.app = app
|
|
441
|
+
self.bind_host = bind_host
|
|
442
|
+
self.bind_port = bind_port
|
|
443
|
+
|
|
444
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
445
|
+
if scope["type"] != "http":
|
|
446
|
+
await self.app(scope, receive, send)
|
|
447
|
+
return
|
|
448
|
+
request_id = _ensure_request_id(scope)
|
|
449
|
+
hosts = _header_values(scope, "host")
|
|
450
|
+
if not hosts:
|
|
451
|
+
await _send_json(
|
|
452
|
+
send,
|
|
453
|
+
status=400,
|
|
454
|
+
payload=_error_payload(
|
|
455
|
+
error="InvalidHost",
|
|
456
|
+
message="Missing Host header.",
|
|
457
|
+
request_id=request_id,
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
return
|
|
461
|
+
if len(hosts) > 1 or not self._host_ok(hosts[0]):
|
|
462
|
+
await _send_json(
|
|
463
|
+
send,
|
|
464
|
+
status=421,
|
|
465
|
+
payload=_error_payload(
|
|
466
|
+
error="InvalidHost",
|
|
467
|
+
message="Invalid Host header.",
|
|
468
|
+
request_id=request_id,
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
return
|
|
472
|
+
origins = _header_values(scope, "origin")
|
|
473
|
+
if len(origins) > 1 or (origins and not self._origin_ok(origins[0])):
|
|
474
|
+
await _send_json(
|
|
475
|
+
send,
|
|
476
|
+
status=403,
|
|
477
|
+
payload=_error_payload(
|
|
478
|
+
error="Forbidden",
|
|
479
|
+
message="Invalid Origin header.",
|
|
480
|
+
request_id=request_id,
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
return
|
|
484
|
+
await self.app(scope, receive, send)
|
|
485
|
+
|
|
486
|
+
def _host_ok(self, host_header: str) -> bool:
|
|
487
|
+
try:
|
|
488
|
+
hostname, port = _split_host_port(host_header)
|
|
489
|
+
except ValueError:
|
|
490
|
+
return False
|
|
491
|
+
if not hostname or not _hostname_allowed(hostname, self.bind_host):
|
|
492
|
+
return False
|
|
493
|
+
if port is not None and port != self.bind_port:
|
|
494
|
+
return False
|
|
495
|
+
return True
|
|
496
|
+
|
|
497
|
+
def _origin_ok(self, origin: str) -> bool:
|
|
498
|
+
parsed = urlparse(origin)
|
|
499
|
+
if parsed.scheme != "http":
|
|
500
|
+
return False
|
|
501
|
+
if parsed.path not in {"", "/"}:
|
|
502
|
+
return False
|
|
503
|
+
if parsed.params or parsed.query or parsed.fragment:
|
|
504
|
+
return False
|
|
505
|
+
if parsed.username or parsed.password:
|
|
506
|
+
return False
|
|
507
|
+
hostname = parsed.hostname
|
|
508
|
+
if not hostname or not _hostname_allowed(hostname, self.bind_host):
|
|
509
|
+
return False
|
|
510
|
+
port = parsed.port if parsed.port is not None else 80
|
|
511
|
+
return port == self.bind_port
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
class AuthMiddleware:
|
|
515
|
+
"""Require Authorization: Bearer when a token is configured."""
|
|
516
|
+
|
|
517
|
+
def __init__(self, app: Any, *, token: str | None) -> None:
|
|
518
|
+
self.app = app
|
|
519
|
+
self.token = token
|
|
520
|
+
|
|
521
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
522
|
+
if scope["type"] != "http" or self.token is None:
|
|
523
|
+
await self.app(scope, receive, send)
|
|
524
|
+
return
|
|
525
|
+
request_id = _ensure_request_id(scope)
|
|
526
|
+
provided = _extract_bearer(_header(scope, "authorization"))
|
|
527
|
+
if provided is None or not _tokens_match(provided, self.token):
|
|
528
|
+
await _send_json(
|
|
529
|
+
send,
|
|
530
|
+
status=401,
|
|
531
|
+
payload=_error_payload(
|
|
532
|
+
error="Unauthorized",
|
|
533
|
+
message="Authorization required.",
|
|
534
|
+
request_id=request_id,
|
|
535
|
+
),
|
|
536
|
+
extra_headers=[(b"www-authenticate", b"Bearer")],
|
|
537
|
+
)
|
|
538
|
+
return
|
|
539
|
+
await self.app(scope, receive, send)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class CacheControlMiddleware:
|
|
543
|
+
"""Echo X-Request-Id and set Cache-Control: no-store on HTTP responses."""
|
|
544
|
+
|
|
545
|
+
def __init__(self, app: Any) -> None:
|
|
546
|
+
self.app = app
|
|
547
|
+
|
|
548
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
549
|
+
if scope["type"] != "http":
|
|
550
|
+
await self.app(scope, receive, send)
|
|
551
|
+
return
|
|
552
|
+
request_id = _ensure_request_id(scope)
|
|
553
|
+
|
|
554
|
+
async def send_wrapper(message: dict[str, Any]) -> None:
|
|
555
|
+
if message.get("type") == "http.response.start":
|
|
556
|
+
headers = list(message.get("headers") or [])
|
|
557
|
+
headers = _apply_response_headers(headers, request_id)
|
|
558
|
+
message = {**message, "headers": headers}
|
|
559
|
+
await send(message)
|
|
560
|
+
|
|
561
|
+
await self.app(scope, receive, send_wrapper)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _apply_response_headers(
|
|
565
|
+
headers: list[tuple[bytes, bytes]], request_id: str
|
|
566
|
+
) -> list[tuple[bytes, bytes]]:
|
|
567
|
+
out: list[tuple[bytes, bytes]] = []
|
|
568
|
+
saw_cache = False
|
|
569
|
+
rid = request_id.encode("utf-8", errors="replace")
|
|
570
|
+
for key, value in headers:
|
|
571
|
+
lowered = key.lower()
|
|
572
|
+
if lowered == b"cache-control":
|
|
573
|
+
text = value.decode("latin-1")
|
|
574
|
+
if "no-store" not in text.lower():
|
|
575
|
+
text = f"{text}, no-store" if text.strip() else "no-store"
|
|
576
|
+
out.append((b"cache-control", text.encode("latin-1")))
|
|
577
|
+
saw_cache = True
|
|
578
|
+
elif lowered == b"x-request-id":
|
|
579
|
+
continue
|
|
580
|
+
else:
|
|
581
|
+
out.append((key, value))
|
|
582
|
+
if not saw_cache:
|
|
583
|
+
out.append((b"cache-control", b"no-store"))
|
|
584
|
+
out.append((b"x-request-id", rid))
|
|
585
|
+
return out
|