@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
"""Private, versioned ASGI transport between ingress and persistent execution.
|
|
2
|
+
|
|
3
|
+
The execution process alone owns the application lifespan and state. Each Unix
|
|
4
|
+
connection carries one authenticated ASGI scope, followed by bounded messages.
|
|
5
|
+
Losing ingress delivers disconnect; it never cancels an accepted application
|
|
6
|
+
call or retries a request whose effects may already have committed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import binascii
|
|
14
|
+
import fcntl
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import secrets
|
|
20
|
+
import socket
|
|
21
|
+
import stat
|
|
22
|
+
import struct
|
|
23
|
+
from contextlib import suppress
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROTOCOL_VERSION = 1
|
|
29
|
+
MAX_FRAME_BYTES = 8 * 1024 * 1024
|
|
30
|
+
MAX_SCOPE_BYTES = 256 * 1024
|
|
31
|
+
MAX_BODY_CHUNK_BYTES = 64 * 1024
|
|
32
|
+
MAX_WEBSOCKET_BYTES = 4 * 1024 * 1024
|
|
33
|
+
MAX_HEADERS = 256
|
|
34
|
+
MAX_HEADER_BYTES = 128 * 1024
|
|
35
|
+
HANDSHAKE_TIMEOUT = 5.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ExecutionTransportError(RuntimeError):
|
|
39
|
+
"""A private connection or message failed validation (never contains data)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _private_parent(path: Path) -> Path:
|
|
43
|
+
path = Path(path)
|
|
44
|
+
if not path.is_absolute() or ".." in path.parts:
|
|
45
|
+
raise ExecutionTransportError("Execution paths must be absolute and canonical")
|
|
46
|
+
for parent in reversed(path.parents):
|
|
47
|
+
info = parent.lstat()
|
|
48
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
49
|
+
raise ExecutionTransportError("Execution path traverses a non-directory or link")
|
|
50
|
+
parent_info = path.parent.lstat()
|
|
51
|
+
if parent_info.st_uid != os.getuid() or stat.S_IMODE(parent_info.st_mode) != 0o700:
|
|
52
|
+
raise ExecutionTransportError("Execution directory must be owned with mode 0700")
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_secret(path: Path) -> str:
|
|
57
|
+
path = _private_parent(path)
|
|
58
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
59
|
+
try:
|
|
60
|
+
info = os.fstat(descriptor)
|
|
61
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
62
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
|
|
63
|
+
raise ExecutionTransportError("Execution secret must be an owned 0600 regular file")
|
|
64
|
+
value = os.read(descriptor, 65)
|
|
65
|
+
if re.fullmatch(rb"[0-9a-f]{64}", value) is None:
|
|
66
|
+
raise ExecutionTransportError("Invalid execution secret file")
|
|
67
|
+
return value.decode("ascii")
|
|
68
|
+
finally:
|
|
69
|
+
os.close(descriptor)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ensure_execution_secret(secret_path: Path) -> str:
|
|
73
|
+
"""Create/read one private secret; the caller creates its 0700 directory."""
|
|
74
|
+
path = _private_parent(secret_path)
|
|
75
|
+
# Worker and ingress are separate native services and may initialize at
|
|
76
|
+
# once. O_EXCL alone exposes an empty token before the first writer fsyncs.
|
|
77
|
+
# Keep this lock file: unlinking it would let later callers lock a different
|
|
78
|
+
# inode. Existing malformed secrets still fail closed without regeneration.
|
|
79
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
80
|
+
lock = os.open(lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
81
|
+
try:
|
|
82
|
+
info = os.fstat(lock)
|
|
83
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
84
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
|
|
85
|
+
raise ExecutionTransportError("Execution initializer lock must be an owned 0600 regular file")
|
|
86
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
87
|
+
current = lock_path.lstat()
|
|
88
|
+
if (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino):
|
|
89
|
+
raise ExecutionTransportError("Execution initializer lock changed during acquisition")
|
|
90
|
+
try:
|
|
91
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
92
|
+
| getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
93
|
+
except FileExistsError:
|
|
94
|
+
return _read_secret(path)
|
|
95
|
+
with os.fdopen(descriptor, "wb") as output:
|
|
96
|
+
output.write(secrets.token_hex(32).encode("ascii"))
|
|
97
|
+
output.flush()
|
|
98
|
+
os.fsync(output.fileno())
|
|
99
|
+
directory = os.open(path.parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
100
|
+
try:
|
|
101
|
+
os.fsync(directory)
|
|
102
|
+
finally:
|
|
103
|
+
os.close(directory)
|
|
104
|
+
return _read_secret(path)
|
|
105
|
+
finally:
|
|
106
|
+
os.close(lock)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _socket_identity(path: Path) -> tuple[int, int]:
|
|
110
|
+
info = _private_parent(path).lstat()
|
|
111
|
+
if (not stat.S_ISSOCK(info.st_mode) or info.st_uid != os.getuid()
|
|
112
|
+
or stat.S_IMODE(info.st_mode) != 0o600):
|
|
113
|
+
raise ExecutionTransportError("Execution socket must be owned with mode 0600")
|
|
114
|
+
return info.st_dev, info.st_ino
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
118
|
+
result: dict[str, Any] = {}
|
|
119
|
+
for name, value in pairs:
|
|
120
|
+
if name in result:
|
|
121
|
+
raise ExecutionTransportError("Duplicate private protocol field")
|
|
122
|
+
result[name] = value
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _bad_constant(_value: str) -> None:
|
|
127
|
+
raise ExecutionTransportError("Invalid private protocol number")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _read_frame(reader: asyncio.StreamReader) -> dict[str, Any]:
|
|
131
|
+
length = struct.unpack("!I", await reader.readexactly(4))[0]
|
|
132
|
+
if not 0 < length <= MAX_FRAME_BYTES:
|
|
133
|
+
raise ExecutionTransportError("Private protocol frame exceeds its limit")
|
|
134
|
+
try:
|
|
135
|
+
value = json.loads(await reader.readexactly(length), object_pairs_hook=_object,
|
|
136
|
+
parse_constant=_bad_constant)
|
|
137
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
138
|
+
raise ExecutionTransportError("Invalid private protocol JSON") from exc
|
|
139
|
+
if not isinstance(value, dict) or type(value.get("v")) is not int or value["v"] != PROTOCOL_VERSION:
|
|
140
|
+
raise ExecutionTransportError("Unsupported private protocol version")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _write_frame(writer: asyncio.StreamWriter, message: dict[str, Any]) -> None:
|
|
145
|
+
if writer.is_closing():
|
|
146
|
+
raise ConnectionError("Execution connection is closing")
|
|
147
|
+
encoded = json.dumps({"v": PROTOCOL_VERSION, **message}, ensure_ascii=True,
|
|
148
|
+
allow_nan=False, separators=(",", ":")).encode("ascii")
|
|
149
|
+
if len(encoded) > MAX_FRAME_BYTES:
|
|
150
|
+
raise ExecutionTransportError("Private protocol frame exceeds its limit")
|
|
151
|
+
writer.write(struct.pack("!I", len(encoded)) + encoded)
|
|
152
|
+
await writer.drain()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _fields(value: Any, required: set[str], optional: set[str] = frozenset()) -> dict[str, Any]:
|
|
156
|
+
if not isinstance(value, dict) or not required <= value.keys() or value.keys() - required - optional:
|
|
157
|
+
raise ExecutionTransportError("Invalid private protocol fields")
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _text(value: Any, limit: int = MAX_SCOPE_BYTES) -> str:
|
|
162
|
+
if not isinstance(value, str) or len(value.encode("utf-8")) > limit:
|
|
163
|
+
raise ExecutionTransportError("Invalid private protocol text")
|
|
164
|
+
return value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _boolean(value: Any) -> bool:
|
|
168
|
+
if type(value) is not bool:
|
|
169
|
+
raise ExecutionTransportError("Invalid private protocol boolean")
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _integer(value: Any, minimum: int, maximum: int) -> int:
|
|
174
|
+
if type(value) is not int or not minimum <= value <= maximum:
|
|
175
|
+
raise ExecutionTransportError("Invalid private protocol integer")
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _bytes(value: Any, *, encode: bool, limit: int) -> Any:
|
|
180
|
+
if encode:
|
|
181
|
+
if not isinstance(value, bytes) or len(value) > limit:
|
|
182
|
+
raise ExecutionTransportError("Invalid private protocol byte field")
|
|
183
|
+
return base64.b64encode(value).decode("ascii")
|
|
184
|
+
if not isinstance(value, str) or len(value) > ((limit + 2) // 3) * 4:
|
|
185
|
+
raise ExecutionTransportError("Invalid private protocol byte field")
|
|
186
|
+
try:
|
|
187
|
+
decoded = base64.b64decode(value, validate=True)
|
|
188
|
+
except (ValueError, binascii.Error) as exc:
|
|
189
|
+
raise ExecutionTransportError("Invalid private protocol base64") from exc
|
|
190
|
+
if len(decoded) > limit or base64.b64encode(decoded).decode("ascii") != value:
|
|
191
|
+
raise ExecutionTransportError("Noncanonical private protocol byte field")
|
|
192
|
+
return decoded
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _headers(value: Any, *, encode: bool) -> list[Any]:
|
|
196
|
+
if not isinstance(value, (list, tuple)) or len(value) > MAX_HEADERS:
|
|
197
|
+
raise ExecutionTransportError("Invalid private protocol headers")
|
|
198
|
+
result = []
|
|
199
|
+
total = 0
|
|
200
|
+
for pair in value:
|
|
201
|
+
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
|
|
202
|
+
raise ExecutionTransportError("Invalid private protocol header pair")
|
|
203
|
+
converted = [_bytes(part, encode=encode, limit=MAX_HEADER_BYTES) for part in pair]
|
|
204
|
+
total += sum(len(part) for part in (pair if encode else converted))
|
|
205
|
+
if total > MAX_HEADER_BYTES:
|
|
206
|
+
raise ExecutionTransportError("Private protocol headers exceed their limit")
|
|
207
|
+
result.append(converted if encode else tuple(converted))
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _address(value: Any, *, encode: bool) -> Any:
|
|
212
|
+
if value is None:
|
|
213
|
+
return None
|
|
214
|
+
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
|
215
|
+
raise ExecutionTransportError("Invalid private protocol address")
|
|
216
|
+
address = [_text(value[0], 1024), _integer(value[1], 0, 65535)]
|
|
217
|
+
return address if encode else tuple(address)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _scope(value: dict[str, Any], *, encode: bool) -> dict[str, Any]:
|
|
221
|
+
names = {"type", "http_version", "scheme", "path", "raw_path", "query_string",
|
|
222
|
+
"root_path", "headers", "client", "server", "method", "subprotocols"}
|
|
223
|
+
# The gateway strips process-local state/extensions; the receiver rejects
|
|
224
|
+
# attempts to smuggle those markers over the private protocol.
|
|
225
|
+
if not encode:
|
|
226
|
+
_fields(value, {"type", "http_version", "scheme", "path", "raw_path",
|
|
227
|
+
"query_string", "root_path", "headers", "client", "server"},
|
|
228
|
+
{"method", "subprotocols"})
|
|
229
|
+
result = {name: item for name, item in value.items() if name in names}
|
|
230
|
+
kind = result.get("type")
|
|
231
|
+
if kind not in {"http", "websocket"}:
|
|
232
|
+
raise ExecutionTransportError("Unsupported ASGI scope type")
|
|
233
|
+
for name, default in (("http_version", "1.1"), ("root_path", "")):
|
|
234
|
+
result[name] = _text(result.get(name, default))
|
|
235
|
+
for name in ("scheme", "path"):
|
|
236
|
+
result[name] = _text(result.get(name))
|
|
237
|
+
if result["scheme"] not in ({"http", "https"} if kind == "http" else {"ws", "wss"}):
|
|
238
|
+
raise ExecutionTransportError("Invalid ASGI scheme")
|
|
239
|
+
result["raw_path"] = _bytes(result.get("raw_path", result["path"].encode("utf-8")), encode=encode, limit=MAX_SCOPE_BYTES)
|
|
240
|
+
result["query_string"] = _bytes(result.get("query_string", b""), encode=encode, limit=MAX_SCOPE_BYTES)
|
|
241
|
+
result["headers"] = _headers(result.get("headers", []), encode=encode)
|
|
242
|
+
result["client"] = _address(result.get("client"), encode=encode)
|
|
243
|
+
result["server"] = _address(result.get("server"), encode=encode)
|
|
244
|
+
if kind == "http":
|
|
245
|
+
if "subprotocols" in result:
|
|
246
|
+
raise ExecutionTransportError("WebSocket field on HTTP scope")
|
|
247
|
+
result["method"] = _text(result.get("method"), 32)
|
|
248
|
+
else:
|
|
249
|
+
if "method" in result:
|
|
250
|
+
raise ExecutionTransportError("HTTP field on WebSocket scope")
|
|
251
|
+
protocols = result.get("subprotocols", [])
|
|
252
|
+
if not isinstance(protocols, list) or len(protocols) > MAX_HEADERS:
|
|
253
|
+
raise ExecutionTransportError("Invalid WebSocket protocol offers")
|
|
254
|
+
result["subprotocols"] = [_text(item, 8192) for item in protocols]
|
|
255
|
+
if encode:
|
|
256
|
+
if len(json.dumps(result, ensure_ascii=True).encode("ascii")) > MAX_SCOPE_BYTES:
|
|
257
|
+
raise ExecutionTransportError("ASGI scope exceeds its limit")
|
|
258
|
+
else:
|
|
259
|
+
result["asgi"] = {"version": "3.0", "spec_version": "2.5"}
|
|
260
|
+
# FileResponse will stream bytes instead of emitting local pathsend.
|
|
261
|
+
result["extensions"] = {"websocket.http.response": {}} if kind == "websocket" else {}
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _event(value: dict[str, Any], *, encode: bool, kind: str, direction: str) -> dict[str, Any]:
|
|
266
|
+
_fields(value, {"type"}, {"body", "more_body", "status", "headers", "trailers",
|
|
267
|
+
"more_trailers", "bytes", "text", "code", "reason", "subprotocol"})
|
|
268
|
+
event_type = value["type"]
|
|
269
|
+
allowed = ({"http.request", "http.disconnect"} if kind == "http" else
|
|
270
|
+
{"websocket.connect", "websocket.receive", "websocket.disconnect"}) if direction == "request" else (
|
|
271
|
+
{"http.response.start", "http.response.body", "http.response.trailers"} if kind == "http" else
|
|
272
|
+
{"websocket.accept", "websocket.send", "websocket.close", "websocket.http.response.start", "websocket.http.response.body"})
|
|
273
|
+
if not isinstance(event_type, str) or event_type not in allowed:
|
|
274
|
+
raise ExecutionTransportError("Unexpected ASGI event type")
|
|
275
|
+
result: dict[str, Any] = {"type": event_type}
|
|
276
|
+
if event_type in {"http.request", "http.response.body", "websocket.http.response.body"}:
|
|
277
|
+
_fields(value, {"type"}, {"body", "more_body"})
|
|
278
|
+
result["body"] = _bytes(value.get("body", b"" if encode else ""), encode=encode, limit=MAX_BODY_CHUNK_BYTES)
|
|
279
|
+
result["more_body"] = _boolean(value.get("more_body", False))
|
|
280
|
+
elif event_type in {"http.response.start", "websocket.http.response.start"}:
|
|
281
|
+
_fields(value, {"type", "status"}, {"headers", "trailers"})
|
|
282
|
+
result["status"] = _integer(value["status"], 100, 599)
|
|
283
|
+
result["headers"] = _headers(value.get("headers", []), encode=encode)
|
|
284
|
+
if "trailers" in value:
|
|
285
|
+
result["trailers"] = _boolean(value["trailers"])
|
|
286
|
+
elif event_type == "http.response.trailers":
|
|
287
|
+
_fields(value, {"type"}, {"headers", "more_trailers"})
|
|
288
|
+
result["headers"] = _headers(value.get("headers", []), encode=encode)
|
|
289
|
+
result["more_trailers"] = _boolean(value.get("more_trailers", False))
|
|
290
|
+
elif event_type == "websocket.accept":
|
|
291
|
+
_fields(value, {"type"}, {"subprotocol", "headers"})
|
|
292
|
+
result["subprotocol"] = None if value.get("subprotocol") is None else _text(value["subprotocol"], 8192)
|
|
293
|
+
result["headers"] = _headers(value.get("headers", []), encode=encode)
|
|
294
|
+
elif event_type in {"websocket.send", "websocket.receive"}:
|
|
295
|
+
_fields(value, {"type"}, {"bytes", "text"})
|
|
296
|
+
has_bytes, has_text = value.get("bytes") is not None, value.get("text") is not None
|
|
297
|
+
if has_bytes == has_text:
|
|
298
|
+
raise ExecutionTransportError("WebSocket message must contain bytes or text")
|
|
299
|
+
if has_bytes:
|
|
300
|
+
result["bytes"] = _bytes(value["bytes"], encode=encode, limit=MAX_WEBSOCKET_BYTES)
|
|
301
|
+
else:
|
|
302
|
+
result["text"] = _text(value["text"], MAX_WEBSOCKET_BYTES)
|
|
303
|
+
elif event_type in {"websocket.close", "websocket.disconnect"}:
|
|
304
|
+
_fields(value, {"type"}, {"code", "reason"})
|
|
305
|
+
result["code"] = _integer(value.get("code", 1000), 1000, 4999)
|
|
306
|
+
result["reason"] = _text(value.get("reason", ""), 123)
|
|
307
|
+
else:
|
|
308
|
+
_fields(value, {"type"})
|
|
309
|
+
return result
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _chunks(message: dict[str, Any]):
|
|
313
|
+
"""HTTP chunks are transport boundaries, not application boundaries."""
|
|
314
|
+
if message.get("type") in {"http.request", "http.response.body", "websocket.http.response.body"}:
|
|
315
|
+
body = message.get("body", b"")
|
|
316
|
+
if not isinstance(body, bytes):
|
|
317
|
+
raise ExecutionTransportError("Invalid HTTP body")
|
|
318
|
+
for offset in range(0, max(1, len(body)), MAX_BODY_CHUNK_BYTES):
|
|
319
|
+
end = offset + MAX_BODY_CHUNK_BYTES
|
|
320
|
+
yield {**message, "body": body[offset:end], "more_body": end < len(body) or message.get("more_body", False)}
|
|
321
|
+
else:
|
|
322
|
+
yield message
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def _close_writer(writer: asyncio.StreamWriter) -> None:
|
|
326
|
+
writer.close()
|
|
327
|
+
with suppress(ConnectionError, OSError):
|
|
328
|
+
await writer.wait_closed()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class ExecutionTransportServer:
|
|
332
|
+
"""Serve an existing ASGI application without entering its lifespan."""
|
|
333
|
+
|
|
334
|
+
def __init__(self, app: Any, *, socket_path: Path, secret_path: Path,
|
|
335
|
+
max_connections: int = 256) -> None:
|
|
336
|
+
self.app = app
|
|
337
|
+
self.socket_path = Path(socket_path)
|
|
338
|
+
self.secret_path = Path(secret_path)
|
|
339
|
+
self.max_connections = _integer(max_connections, 1, 4096)
|
|
340
|
+
self._server: asyncio.AbstractServer | None = None
|
|
341
|
+
self._closing = False
|
|
342
|
+
self._identity: tuple[int, int] | None = None
|
|
343
|
+
self._secret = ""
|
|
344
|
+
self._connections: set[asyncio.Task[Any]] = set()
|
|
345
|
+
self._writers: set[asyncio.StreamWriter] = set()
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def active_connections(self) -> int:
|
|
349
|
+
return len(self._connections)
|
|
350
|
+
|
|
351
|
+
async def start(self) -> None:
|
|
352
|
+
if self._server is not None:
|
|
353
|
+
raise ExecutionTransportError("Execution transport is already started")
|
|
354
|
+
_private_parent(self.socket_path)
|
|
355
|
+
self._secret = _read_secret(self.secret_path)
|
|
356
|
+
self._closing = False
|
|
357
|
+
# Refuse existing paths, even sockets. Stale socket retirement belongs
|
|
358
|
+
# to the supervisor's independently validated process/instance lease.
|
|
359
|
+
if os.path.lexists(self.socket_path):
|
|
360
|
+
raise ExecutionTransportError("Execution socket path already exists")
|
|
361
|
+
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
362
|
+
try:
|
|
363
|
+
listener.bind(str(self.socket_path))
|
|
364
|
+
os.chmod(self.socket_path, 0o600, follow_symlinks=False)
|
|
365
|
+
self._identity = _socket_identity(self.socket_path)
|
|
366
|
+
listener.listen(128)
|
|
367
|
+
listener.setblocking(False)
|
|
368
|
+
self._server = await asyncio.start_unix_server(self._accept, sock=listener)
|
|
369
|
+
except BaseException:
|
|
370
|
+
listener.close()
|
|
371
|
+
self._remove_own_socket()
|
|
372
|
+
raise
|
|
373
|
+
|
|
374
|
+
def _remove_own_socket(self) -> None:
|
|
375
|
+
if self._identity is None:
|
|
376
|
+
return
|
|
377
|
+
with suppress(FileNotFoundError, ExecutionTransportError):
|
|
378
|
+
if _socket_identity(self.socket_path) == self._identity:
|
|
379
|
+
self.socket_path.unlink()
|
|
380
|
+
self._identity = None
|
|
381
|
+
|
|
382
|
+
async def serve_forever(self) -> None:
|
|
383
|
+
if self._server is None:
|
|
384
|
+
raise ExecutionTransportError("Execution transport is not started")
|
|
385
|
+
await self._server.serve_forever()
|
|
386
|
+
|
|
387
|
+
async def close(self, *, timeout: float = 5.0) -> None:
|
|
388
|
+
self._closing = True
|
|
389
|
+
server = self._server
|
|
390
|
+
self._server = None
|
|
391
|
+
if server is not None:
|
|
392
|
+
server.close()
|
|
393
|
+
# Explicit worker shutdown may cancel work after a bounded drain. This
|
|
394
|
+
# is deliberately different from ingress EOF, which never cancels it.
|
|
395
|
+
writers = tuple(self._writers)
|
|
396
|
+
for writer in writers:
|
|
397
|
+
writer.close()
|
|
398
|
+
pending: set[asyncio.Task[Any]] = set()
|
|
399
|
+
if self._connections:
|
|
400
|
+
_done, pending = await asyncio.wait(tuple(self._connections), timeout=max(0.0, timeout))
|
|
401
|
+
for task in pending:
|
|
402
|
+
task.cancel()
|
|
403
|
+
# A peer that stopped reading can prevent close() from flushing its
|
|
404
|
+
# buffered response indefinitely. Only explicit worker shutdown aborts
|
|
405
|
+
# those remaining transports after the drain window.
|
|
406
|
+
for writer in writers:
|
|
407
|
+
connection = writer.get_extra_info("socket")
|
|
408
|
+
# is_closing() also covers live sockets flushing buffered data.
|
|
409
|
+
# A completed close instead retires the actual descriptor and,
|
|
410
|
+
# on CPython, can clear the transport's loop before abort().
|
|
411
|
+
# There is no await between this check and the remaining abort.
|
|
412
|
+
if connection is not None and connection.fileno() >= 0:
|
|
413
|
+
writer.transport.abort()
|
|
414
|
+
if pending:
|
|
415
|
+
await asyncio.wait(pending, timeout=1.0)
|
|
416
|
+
if server is not None:
|
|
417
|
+
await asyncio.wait_for(server.wait_closed(), timeout=1.0)
|
|
418
|
+
self._remove_own_socket()
|
|
419
|
+
|
|
420
|
+
def _accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
421
|
+
if self._closing or len(self._connections) >= self.max_connections:
|
|
422
|
+
writer.close()
|
|
423
|
+
return
|
|
424
|
+
task = asyncio.create_task(self._handle(reader, writer), name="execution-request")
|
|
425
|
+
self._connections.add(task)
|
|
426
|
+
self._writers.add(writer)
|
|
427
|
+
task.add_done_callback(self._connections.discard)
|
|
428
|
+
|
|
429
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
430
|
+
pump: asyncio.Task[Any] | None = None
|
|
431
|
+
accepted = False
|
|
432
|
+
disconnected = asyncio.Event()
|
|
433
|
+
incoming: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=4)
|
|
434
|
+
try:
|
|
435
|
+
opening = await asyncio.wait_for(_read_frame(reader), HANDSHAKE_TIMEOUT)
|
|
436
|
+
_fields(opening, {"v", "kind", "secret", "scope"})
|
|
437
|
+
if (opening["kind"] != "open" or not isinstance(opening["secret"], str)
|
|
438
|
+
or not hmac.compare_digest(opening["secret"], self._secret)):
|
|
439
|
+
raise ExecutionTransportError("Execution handshake rejected")
|
|
440
|
+
if len(json.dumps(opening["scope"]).encode("utf-8")) > MAX_SCOPE_BYTES:
|
|
441
|
+
raise ExecutionTransportError("ASGI scope exceeds its limit")
|
|
442
|
+
scope = _scope(opening["scope"], encode=False)
|
|
443
|
+
kind = scope["type"]
|
|
444
|
+
disconnect = {"type": "http.disconnect"} if kind == "http" else {"type": "websocket.disconnect", "code": 1006, "reason": "Ingress disconnected"}
|
|
445
|
+
await _write_frame(writer, {"kind": "ready"})
|
|
446
|
+
accepted = True
|
|
447
|
+
|
|
448
|
+
async def read_events() -> None:
|
|
449
|
+
try:
|
|
450
|
+
while True:
|
|
451
|
+
frame = await _read_frame(reader)
|
|
452
|
+
_fields(frame, {"v", "kind", "event"})
|
|
453
|
+
if frame["kind"] != "event":
|
|
454
|
+
raise ExecutionTransportError("Unexpected private protocol message")
|
|
455
|
+
event = _event(frame["event"], encode=False, kind=kind, direction="request")
|
|
456
|
+
await incoming.put(event)
|
|
457
|
+
if event["type"].endswith(".disconnect"):
|
|
458
|
+
break
|
|
459
|
+
except (ExecutionTransportError, asyncio.IncompleteReadError, ConnectionError, OSError):
|
|
460
|
+
pass
|
|
461
|
+
# Cancellation by our own completed app's cleanup is not peer
|
|
462
|
+
# disconnection: leave its bounded input drain enabled.
|
|
463
|
+
disconnected.set()
|
|
464
|
+
|
|
465
|
+
async def receive() -> dict[str, Any]:
|
|
466
|
+
if not incoming.empty():
|
|
467
|
+
return incoming.get_nowait()
|
|
468
|
+
if disconnected.is_set():
|
|
469
|
+
return dict(disconnect)
|
|
470
|
+
message = asyncio.create_task(incoming.get())
|
|
471
|
+
ended = asyncio.create_task(disconnected.wait())
|
|
472
|
+
try:
|
|
473
|
+
done, _ = await asyncio.wait({message, ended}, return_when=asyncio.FIRST_COMPLETED)
|
|
474
|
+
return message.result() if message in done else dict(disconnect)
|
|
475
|
+
finally:
|
|
476
|
+
for task in (message, ended):
|
|
477
|
+
if not task.done():
|
|
478
|
+
task.cancel()
|
|
479
|
+
await asyncio.gather(message, ended, return_exceptions=True)
|
|
480
|
+
|
|
481
|
+
async def send(message: dict[str, Any]) -> None:
|
|
482
|
+
for chunk in _chunks(message):
|
|
483
|
+
event = _event(chunk, encode=True, kind=kind, direction="response")
|
|
484
|
+
if disconnected.is_set():
|
|
485
|
+
return
|
|
486
|
+
try:
|
|
487
|
+
await _write_frame(writer, {"kind": "event", "event": event})
|
|
488
|
+
except (ConnectionError, OSError):
|
|
489
|
+
disconnected.set()
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
pump = asyncio.create_task(read_events(), name="execution-request-reader")
|
|
493
|
+
# Never race this task against reader EOF or cancel it on EOF.
|
|
494
|
+
await self.app(scope, receive, send)
|
|
495
|
+
if not disconnected.is_set():
|
|
496
|
+
await _write_frame(writer, {"kind": "complete"})
|
|
497
|
+
except (ExecutionTransportError, asyncio.IncompleteReadError, ConnectionError,
|
|
498
|
+
OSError, asyncio.TimeoutError):
|
|
499
|
+
pass
|
|
500
|
+
except Exception:
|
|
501
|
+
# App failures must not leak request data/secrets over the gateway.
|
|
502
|
+
with suppress(ConnectionError, OSError):
|
|
503
|
+
await _write_frame(writer, {"kind": "error"})
|
|
504
|
+
finally:
|
|
505
|
+
if pump is not None:
|
|
506
|
+
pump.cancel()
|
|
507
|
+
await asyncio.gather(pump, return_exceptions=True)
|
|
508
|
+
if accepted and not writer.is_closing() and not disconnected.is_set():
|
|
509
|
+
# A valid app can respond without consuming its request body.
|
|
510
|
+
# Closing a Unix stream with unread request bytes may reset
|
|
511
|
+
# the peer and discard our response. After the terminal frame,
|
|
512
|
+
# ingress closes its end; drain only bounded chunks until then.
|
|
513
|
+
async def drain_ingress() -> None:
|
|
514
|
+
while await reader.read(MAX_BODY_CHUNK_BYTES):
|
|
515
|
+
pass
|
|
516
|
+
|
|
517
|
+
with suppress(asyncio.TimeoutError, ConnectionError, OSError):
|
|
518
|
+
await asyncio.wait_for(drain_ingress(), 1.0)
|
|
519
|
+
self._writers.discard(writer)
|
|
520
|
+
await _close_writer(writer)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class ExecutionGateway:
|
|
524
|
+
"""ASGI ingress forwarding one request at a time; never retries requests."""
|
|
525
|
+
|
|
526
|
+
def __init__(self, *, socket_path: Path, secret_path: Path,
|
|
527
|
+
connect_timeout: float = HANDSHAKE_TIMEOUT) -> None:
|
|
528
|
+
self.socket_path = Path(socket_path)
|
|
529
|
+
self.secret_path = Path(secret_path)
|
|
530
|
+
self.connect_timeout = float(connect_timeout)
|
|
531
|
+
if not 0 < self.connect_timeout <= 60:
|
|
532
|
+
raise ValueError("connect_timeout must be between zero and sixty seconds")
|
|
533
|
+
|
|
534
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
535
|
+
if scope["type"] == "lifespan":
|
|
536
|
+
while True:
|
|
537
|
+
message = await receive()
|
|
538
|
+
if message["type"] == "lifespan.startup":
|
|
539
|
+
await send({"type": "lifespan.startup.complete"})
|
|
540
|
+
elif message["type"] == "lifespan.shutdown":
|
|
541
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
542
|
+
return
|
|
543
|
+
writer: asyncio.StreamWriter | None = None
|
|
544
|
+
pump: asyncio.Task[Any] | None = None
|
|
545
|
+
response_started = False
|
|
546
|
+
expects_trailers = False
|
|
547
|
+
try:
|
|
548
|
+
secret = _read_secret(self.secret_path)
|
|
549
|
+
identity = _socket_identity(self.socket_path)
|
|
550
|
+
reader, writer = await asyncio.wait_for(
|
|
551
|
+
asyncio.open_unix_connection(str(self.socket_path)), self.connect_timeout)
|
|
552
|
+
if _socket_identity(self.socket_path) != identity:
|
|
553
|
+
raise ExecutionTransportError("Execution socket changed during connection")
|
|
554
|
+
await _write_frame(writer, {"kind": "open", "secret": secret, "scope": _scope(scope, encode=True)})
|
|
555
|
+
ready = await asyncio.wait_for(_read_frame(reader), self.connect_timeout)
|
|
556
|
+
_fields(ready, {"v", "kind"})
|
|
557
|
+
if ready["kind"] != "ready":
|
|
558
|
+
raise ExecutionTransportError("Execution handshake failed")
|
|
559
|
+
|
|
560
|
+
async def forward_requests() -> None:
|
|
561
|
+
try:
|
|
562
|
+
while True:
|
|
563
|
+
message = await receive()
|
|
564
|
+
for chunk in _chunks(message):
|
|
565
|
+
event = _event(chunk, encode=True, kind=scope["type"], direction="request")
|
|
566
|
+
await _write_frame(writer, {"kind": "event", "event": event})
|
|
567
|
+
if message["type"].endswith(".disconnect"):
|
|
568
|
+
writer.close()
|
|
569
|
+
return
|
|
570
|
+
except (ConnectionError, OSError):
|
|
571
|
+
# An early response can close the request side while its
|
|
572
|
+
# response is still buffered. Let the response reader
|
|
573
|
+
# decide whether that operation completed; never retry.
|
|
574
|
+
return
|
|
575
|
+
except Exception:
|
|
576
|
+
writer.close()
|
|
577
|
+
|
|
578
|
+
pump = asyncio.create_task(forward_requests(), name="execution-gateway-reader")
|
|
579
|
+
while True:
|
|
580
|
+
frame = await _read_frame(reader)
|
|
581
|
+
if frame.get("kind") == "complete":
|
|
582
|
+
_fields(frame, {"v", "kind"})
|
|
583
|
+
# Application return is not proof of a complete response.
|
|
584
|
+
# A terminal ASGI response event must have arrived first.
|
|
585
|
+
raise ExecutionTransportError("Execution response ended before its final event")
|
|
586
|
+
_fields(frame, {"v", "kind", "event"})
|
|
587
|
+
if frame["kind"] != "event":
|
|
588
|
+
raise ExecutionTransportError("Execution response failed")
|
|
589
|
+
message = _event(frame["event"], encode=False, kind=scope["type"], direction="response")
|
|
590
|
+
if message["type"] == "http.response.start":
|
|
591
|
+
expects_trailers = message.get("trailers", False)
|
|
592
|
+
response_started = True
|
|
593
|
+
await send(message)
|
|
594
|
+
if (message["type"] == "websocket.close"
|
|
595
|
+
or (message["type"] in {"http.response.body", "websocket.http.response.body"}
|
|
596
|
+
and not message["more_body"] and not expects_trailers)
|
|
597
|
+
or (message["type"] == "http.response.trailers"
|
|
598
|
+
and expects_trailers and not message["more_trailers"])):
|
|
599
|
+
# Uvicorn receive() yields http.disconnect after the final
|
|
600
|
+
# body, even when application background work continues.
|
|
601
|
+
# Finish ingress at its ASGI boundary; closing IPC informs
|
|
602
|
+
# the worker of disconnection without canceling that work.
|
|
603
|
+
return
|
|
604
|
+
except (ExecutionTransportError, asyncio.IncompleteReadError, ConnectionError,
|
|
605
|
+
OSError, asyncio.TimeoutError):
|
|
606
|
+
if not response_started:
|
|
607
|
+
if scope["type"] == "http":
|
|
608
|
+
await send({"type": "http.response.start", "status": 503,
|
|
609
|
+
"headers": [(b"content-type", b"text/plain"), (b"cache-control", b"no-store")]})
|
|
610
|
+
await send({"type": "http.response.body", "body": b"Execution service unavailable"})
|
|
611
|
+
else:
|
|
612
|
+
await send({"type": "websocket.close", "code": 1013, "reason": "Execution service unavailable"})
|
|
613
|
+
else:
|
|
614
|
+
# Do not synthesize a second response or successful ending for
|
|
615
|
+
# an interrupted stream. Ingress must close this connection.
|
|
616
|
+
raise ExecutionTransportError("Execution response interrupted") from None
|
|
617
|
+
finally:
|
|
618
|
+
if pump is not None:
|
|
619
|
+
pump.cancel()
|
|
620
|
+
await asyncio.gather(pump, return_exceptions=True)
|
|
621
|
+
if writer is not None:
|
|
622
|
+
await _close_writer(writer)
|