@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. 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)