@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,136 @@
1
+ """Bounded, secret-free durable chat-to-server mail grant projections.
2
+
3
+ Only server-authored admission code may create these records. Runtime authority
4
+ is deliberately absent: every use must intersect a fresh live capability.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from copy import deepcopy
9
+ import re
10
+ from typing import Any
11
+
12
+ ROUTES_KEY = "provider_team_mail_routes"
13
+ PENDING_KEY = "_pending_provider_team_mail_grant"
14
+ MAX_ROUTES = 16
15
+ ROUTE_RE = re.compile(r"mailgrant_[0-9a-f]{32}")
16
+ REVISION_RE = re.compile(r"rev_[0-9a-f]{32}")
17
+ ADMISSION_RE = re.compile(r"grant_admission_[0-9a-f]{32}")
18
+
19
+
20
+ def text(value: Any, limit: int = 256) -> str:
21
+ return value if isinstance(value, str) and 0 < len(value) <= limit and not any(
22
+ ord(char) < 32 for char in value
23
+ ) else ""
24
+
25
+
26
+ def normalize_routes(value: Any) -> list[dict[str, Any]]:
27
+ if not isinstance(value, list) or len(value) > MAX_ROUTES:
28
+ return []
29
+ result: list[dict[str, Any]] = []
30
+ seen: set[tuple[str, str]] = set()
31
+ route_ids: set[str] = set()
32
+ for raw in value:
33
+ if not isinstance(raw, dict):
34
+ return []
35
+ route_id, revision = text(raw.get("route_id")), text(raw.get("revision"))
36
+ team_id, target_id = text(raw.get("team_id")), text(raw.get("target_id"))
37
+ binding = raw.get("durable_server_binding")
38
+ # Runtime supplies a strictly projected identity-only binding. Keep
39
+ # this parser independent of credentials and transient run authority.
40
+ if (not ROUTE_RE.fullmatch(route_id) or not REVISION_RE.fullmatch(revision)
41
+ or not team_id or not target_id or (team_id, target_id) in seen
42
+ or route_id in route_ids or raw.get("recipient_kind") != "server"
43
+ or not isinstance(binding, dict)
44
+ or set(binding) != {"version", "team_id", "hub_id", "target_id", "server_identity", "lifecycle_id"}
45
+ or type(binding.get("version")) is not int or binding["version"] != 1
46
+ or any(not text(binding.get(key), 512) for key in (
47
+ "team_id", "hub_id", "target_id", "server_identity", "lifecycle_id"))
48
+ or binding["team_id"] != team_id or binding["target_id"] != target_id
49
+ or not text(raw.get("display_name"))):
50
+ return []
51
+ seen.add((team_id, target_id))
52
+ route_ids.add(route_id)
53
+ result.append({
54
+ "route_id": route_id, "revision": revision,
55
+ "team_id": team_id, "target_id": target_id,
56
+ "recipient_kind": "server", "display_name": raw["display_name"],
57
+ "durable_server_binding": dict(binding),
58
+ "created_at": text(raw.get("created_at"), 40),
59
+ "updated_at": text(raw.get("updated_at"), 40),
60
+ })
61
+ return result
62
+
63
+
64
+ def snapshot(value: Any) -> list[dict[str, str]]:
65
+ if not isinstance(value, list) or len(value) > MAX_ROUTES * 2:
66
+ return []
67
+ result: list[dict[str, str]] = []
68
+ seen: set[str] = set()
69
+ for raw in value:
70
+ if not isinstance(raw, dict):
71
+ return []
72
+ if raw.get("kind") == "legacy_reference":
73
+ if (set(raw) != {"kind", "team_id", "target_id", "display_name"}
74
+ or not all(text(raw.get(key)) for key in ("team_id", "target_id", "display_name"))):
75
+ return []
76
+ key = "legacy:" + raw["team_id"] + "\0" + raw["target_id"]
77
+ if key in seen:
78
+ return []
79
+ seen.add(key)
80
+ result.append(dict(raw))
81
+ continue
82
+ route_id, revision = text(raw.get("route_id")), text(raw.get("revision"))
83
+ if not ROUTE_RE.fullmatch(route_id) or not REVISION_RE.fullmatch(revision) or route_id in seen:
84
+ return []
85
+ seen.add(route_id)
86
+ result.append({"route_id": route_id, "revision": revision})
87
+ return result
88
+
89
+
90
+ def admission_snapshot(mutation: dict[str, Any] | None, session: dict[str, Any] | None) -> list[dict[str, str]]:
91
+ return snapshot([
92
+ *snapshot(mutation["after"] if mutation else live_routes(session)),
93
+ *((mutation or {}).get("legacy_references") or []),
94
+ ])
95
+
96
+
97
+ def pending(value: Any) -> dict[str, Any] | None:
98
+ if not isinstance(value, dict) or not ADMISSION_RE.fullmatch(text(value.get("admission_id"))):
99
+ return None
100
+ if value.get("event_type") not in {"turn_started", "turn_queued"}:
101
+ return None
102
+ before, after = normalize_routes(value.get("before")), normalize_routes(value.get("after"))
103
+ if before != value.get("before") or after != value.get("after") or before == after:
104
+ return None
105
+ return {"admission_id": value["admission_id"], "event_type": value["event_type"],
106
+ "before": before, "after": after}
107
+
108
+
109
+ def live_routes(session: dict[str, Any] | None) -> list[dict[str, Any]]:
110
+ session = session or {}
111
+ if session.get("archived"):
112
+ return []
113
+ routes = normalize_routes(session.get(ROUTES_KEY, []))
114
+ if session.get(PENDING_KEY) is None:
115
+ return routes
116
+ staged = pending(session[PENDING_KEY])
117
+ # A malformed or concurrently replaced marker cannot prove permission.
118
+ return deepcopy(staged["before"]) if staged and routes == staged["after"] else []
119
+
120
+
121
+ def intersect(session: dict[str, Any] | None, ceiling: Any) -> list[dict[str, Any]]:
122
+ allowed = {(item["route_id"], item["revision"]) for item in snapshot(ceiling) if "route_id" in item}
123
+ return [route for route in live_routes(session) if (route["route_id"], route["revision"]) in allowed]
124
+
125
+
126
+ def settle(session: dict[str, Any], admission_id: str, *, accepted: bool) -> bool:
127
+ staged = pending(session.get(PENDING_KEY))
128
+ if staged is None or staged["admission_id"] != admission_id:
129
+ return False
130
+ if normalize_routes(session.get(ROUTES_KEY)) != staged["after"]:
131
+ # Never resurrect an older revision over a later policy mutation.
132
+ session[ROUTES_KEY] = []
133
+ elif not accepted:
134
+ session[ROUTES_KEY] = deepcopy(staged["before"])
135
+ session.pop(PENDING_KEY, None)
136
+ return True
@@ -0,0 +1,302 @@
1
+ """Passive Mail stream ownership for one AgentsServer runtime.
2
+
3
+ This module is dormant unless its owner explicitly enables the next-beta
4
+ transport. One Member upstream serves all local subscribers. It owns a bounded
5
+ dedicated reader thread, never an interactive executor or request lease. A
6
+ failed upstream closes that cohort; client transport reconnects request a fresh
7
+ scalar proof. There is no reconnect timer, Inbox read, or background refresh.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from contextlib import suppress
12
+ import threading
13
+ from typing import Any
14
+
15
+ from agentsdock_team_hub.mail_hints import MailArrival, MailHintBroker, MailHintClosed
16
+ from agentsdock_team_hub.mail_hint_streams import MailHintLease, MailHintScopeChanged, MailHintUnsupported, owned_mail_snapshot
17
+ from agentsdock_team_hub.notification_hints import NotificationBroker, NotificationCursor, NotificationLease, owned_notification_snapshot
18
+
19
+
20
+ class _MemberFeed:
21
+ def __init__(self, owner: RuntimeMailHints, realm: dict[str, Any]) -> None:
22
+ self.owner, self.realm = owner, dict(realm)
23
+ self.broker = MailHintBroker(max_subscriptions=16, max_per_recipient=16)
24
+ self.notifications = NotificationBroker(max_subscriptions=16, max_per_recipient=16)
25
+ self.version = 2 if owner._member_negotiated(realm, version=2) else 1
26
+ self.ready = threading.Event()
27
+ self.guard = threading.RLock()
28
+ self.stream = None
29
+ self.snapshot = None
30
+ self.closed = False
31
+ self.close_code = 1012
32
+ self.thread = threading.Thread(target=self._read, name="agentsdock-mail-upstream", daemon=True)
33
+
34
+ def _read(self) -> None:
35
+ try:
36
+ opener = (self.owner.runtime.client.open_notification_hint_stream if self.version == 2
37
+ else self.owner.runtime.client.open_mail_hint_stream)
38
+ stream = opener(self.realm["connection_id"])
39
+ with self.guard:
40
+ if self.closed:
41
+ stream.close()
42
+ return
43
+ self.stream = stream
44
+ first = True
45
+ previous_mail = previous_bulletin = None
46
+ while True:
47
+ frame = stream.read()
48
+ if frame is None:
49
+ break
50
+ cursor = (NotificationCursor.from_dict(frame["cursor"]) if self.version == 2
51
+ else MailArrival.from_dict(frame["cursor"]))
52
+ mail = cursor.mail if self.version == 2 else cursor
53
+ if frame["hub_id"] != self.realm["hub_id"] or mail.team_id != self.realm["team_id"]:
54
+ raise MailHintClosed("Member Mail stream changed realm")
55
+ if first:
56
+ if frame["type"] != "snapshot":
57
+ raise MailHintClosed("Member Mail stream omitted snapshot")
58
+ with self.guard:
59
+ self.snapshot = dict(frame["cursor"])
60
+ first = False
61
+ elif frame["type"] != "hint" or (self.version == 1 and frame["cursor"].get("reset") is not False) or (
62
+ self.version == 2 and any(frame["cursor"][lane].get("reset") is not False for lane in ("mail", "bulletin"))):
63
+ raise MailHintClosed("Member Mail stream changed generation")
64
+ if mail != previous_mail:
65
+ self.broker.publish(mail)
66
+ if self.version == 2:
67
+ if mail != previous_mail:
68
+ self.notifications.publish_mail(mail)
69
+ if cursor.bulletin != previous_bulletin:
70
+ self.notifications.publish_bulletin(cursor.bulletin)
71
+ previous_bulletin = cursor.bulletin
72
+ previous_mail = mail
73
+ self.ready.set()
74
+ except Exception as exc:
75
+ # The local sockets close without serializing peer errors/tokens.
76
+ if getattr(exc, "status_code", None) in (401, 403):
77
+ self.close_code = 4403
78
+ elif getattr(exc, "status_code", None) in (404, 501):
79
+ self.close_code = 4406
80
+ elif isinstance(exc, ValueError) or getattr(exc, "code", None) == "remote_invalid":
81
+ self.close_code = 1008
82
+ finally:
83
+ self.close()
84
+
85
+ def close(self) -> None:
86
+ with self.guard:
87
+ self.closed = True
88
+ stream, self.stream = self.stream, None
89
+ self.broker.close()
90
+ self.notifications.close()
91
+ self.ready.set()
92
+ if stream is not None:
93
+ with suppress(Exception):
94
+ stream.close()
95
+
96
+
97
+ class RuntimeMailHints:
98
+ def __init__(self, runtime: Any, *, enabled: bool = False) -> None:
99
+ self.runtime = runtime
100
+ self.enabled = enabled is True
101
+ self.guard = threading.RLock()
102
+ self.leases: set[MailHintLease | NotificationLease] = set()
103
+ self.pending = 0
104
+ self.member: _MemberFeed | None = None
105
+ self._scope: dict[str, Any] | None = None
106
+ self._generation = 0
107
+
108
+ def _member_negotiated(self, realm: dict[str, Any], *, version: int = 1) -> bool:
109
+ # The client getter is a bounded in-memory authenticated health receipt,
110
+ # not discovery I/O. Missing/expired/other-certificate receipts fail shut.
111
+ getter = getattr(self.runtime.client, "notification_hint_capability" if version == 2 else "mail_hint_capability", None)
112
+ if not callable(getter):
113
+ return False
114
+ try:
115
+ return getter(realm["connection_id"], realm["certificate_fingerprint"]) is True
116
+ except Exception:
117
+ return False
118
+
119
+ def _capture(self, team_id: str | None = None, *, version: int = 1) -> dict[str, Any]:
120
+ if not self.enabled or self.runtime._completion_closing:
121
+ raise MailHintClosed("Mail notifications are unavailable")
122
+ realms = self.runtime.team_realms()
123
+ choices = [realm for realm in realms if team_id is None or realm["team_id"] == team_id]
124
+ if len(choices) != 1:
125
+ raise MailHintScopeChanged("Mail notification realm is unavailable")
126
+ with self.guard:
127
+ generation = self._generation
128
+ realm = {**choices[0], "epoch": self.runtime._team_authority_epoch,
129
+ "generation": generation, "admission_epoch": getattr(self.runtime, "_host_admission_epoch", 0)}
130
+ # Cache only metadata even if capability is not negotiated yet. Later
131
+ # health renders can inspect the receipt without another realm DB read.
132
+ with self.guard:
133
+ if realm["generation"] == self._generation:
134
+ self._scope = realm
135
+ if realm["realm"] == "secure_peer" and not self._member_negotiated(realm, version=version):
136
+ raise MailHintUnsupported("Member Mail notifications are not negotiated")
137
+ return realm
138
+
139
+ def capability(self, *, version: int = 1) -> dict[str, Any]:
140
+ result = {"enabled": False, "version": version, "websocket_path": "/api/team-mail-hints/events",
141
+ "websocket_protocol": f"agentsdock.team-mail-hints.v{version}", "mailbox_coverage": True,
142
+ "mailbox": None}
143
+ if version == 2:
144
+ result["bulletin_coverage"] = True
145
+ if not self.enabled:
146
+ return result
147
+ try:
148
+ # Metadata discovery is cached, not an arrival query in health.
149
+ with self.guard:
150
+ scope = self._scope
151
+ if scope is None:
152
+ scope = self._capture(version=version)
153
+ with self.guard:
154
+ if scope["generation"] != self._generation:
155
+ return result
156
+ self._scope = scope
157
+ if scope["realm"] == "secure_peer" and not self._member_negotiated(scope, version=version):
158
+ return result
159
+ result.update(enabled=True, mailbox={"hub_id": scope["hub_id"],
160
+ "team_id": scope["team_id"], "recipient_server_id": None})
161
+ except Exception:
162
+ pass
163
+ return result
164
+
165
+ def _authorize(self, realm: dict[str, Any], store=None, *, version: int = 1) -> None:
166
+ runtime = self.runtime
167
+ if (not self.enabled or runtime._completion_closing
168
+ or runtime._team_authority_epoch != realm["epoch"]
169
+ or realm["generation"] != self._generation):
170
+ raise MailHintClosed("Mail notification authority changed")
171
+ if realm["realm"] == "host":
172
+ if (runtime._host_admission_closed or runtime._hub_store is not store
173
+ or store.hub_id != realm["hub_id"]
174
+ or runtime._host_admission_epoch != realm["admission_epoch"]):
175
+ raise MailHintClosed("Host Mail admission closed")
176
+ else:
177
+ if runtime._host_role_active:
178
+ raise MailHintClosed("Member Mail authority changed")
179
+ if not self._member_negotiated(realm, version=version):
180
+ raise MailHintUnsupported("Member Mail notifications are not negotiated")
181
+ active = runtime._require_active_proxy_connection(realm["connection_id"])
182
+ if (active.get("status") != "connected" or "teamspace.read" not in active.get("scopes", ())
183
+ or any(active.get(key) != realm.get(key) for key in (
184
+ "hub_id", "team_id", "host_server_identity", "certificate_fingerprint"))):
185
+ raise MailHintClosed("Member Mail binding changed")
186
+
187
+ def subscribe(self, team_id: str, previous_cursor=None, *, version: int = 1) -> MailHintLease | NotificationLease:
188
+ if type(version) is not int or version not in (1, 2):
189
+ raise ValueError("Unsupported notification version")
190
+ realm = self._capture(team_id, version=version)
191
+ with self.guard:
192
+ if self.pending + len(self.leases) >= 16:
193
+ raise MailHintClosed("Local Mail stream capacity reached")
194
+ self.pending += 1
195
+ subscription = lease = None
196
+ feed = None
197
+ try:
198
+ if realm["realm"] == "host":
199
+ store = self.runtime._hub_store
200
+ self._authorize(realm, store, version=version)
201
+ claims = store.local_agent_mail_claims(team_id)
202
+ owned = owned_notification_snapshot if version == 2 else owned_mail_snapshot
203
+ _owned, retained = owned(store, claims, team_id, previous_cursor)
204
+ subscriber = store.subscribe_team_notifications if version == 2 else store.subscribe_team_mail_arrivals
205
+ subscription, snapshot = subscriber(claims, team_id, previous_cursor=retained)
206
+
207
+ def authorize():
208
+ self._authorize(realm, store, version=version)
209
+ live = store.team_mail_arrival_snapshot(store.local_agent_mail_claims(team_id), team_id)
210
+ mail_snapshot = snapshot["mail"] if version == 2 else snapshot
211
+ if live["recipient_server_id"] != mail_snapshot["recipient_server_id"]:
212
+ raise MailHintClosed("Host Mail recipient changed")
213
+
214
+ expires_at = None # Local control is role-owned, not a bearer lease.
215
+ else:
216
+ self._authorize(realm, version=version)
217
+ with self.guard:
218
+ feed = self.member
219
+ if feed is not None and not feed.closed and feed.realm == realm and version > feed.version:
220
+ # Retire an older negotiated cohort before opening its
221
+ # replacement. Existing v1 sockets reconnect onto the
222
+ # same v2 feed; never keep both upstreams alive.
223
+ feed.close()
224
+ if feed is None or feed.closed:
225
+ feed = _MemberFeed(self, realm)
226
+ self.member = feed
227
+ feed.thread.start()
228
+ elif feed.realm != realm or version > feed.version:
229
+ raise MailHintClosed("Member Mail generation changed")
230
+ if not feed.ready.wait(timeout=15) or feed.closed or feed.snapshot is None:
231
+ if feed.close_code == 4406:
232
+ raise MailHintUnsupported("Member Mail notifications are unsupported")
233
+ if feed.close_code == 4403:
234
+ raise MailHintScopeChanged("Member Mail authority is unavailable")
235
+ if feed.close_code == 1008:
236
+ raise ValueError("Member Mail protocol is unavailable")
237
+ raise MailHintClosed("Member Mail stream unavailable")
238
+ # Subscribe BEFORE the per-desktop scalar proof. An upstream
239
+ # cursor alone cannot validate this desktop's retained anchor.
240
+ feed_mail = feed.snapshot["mail"] if feed.version == 2 else feed.snapshot
241
+ broker = feed.notifications if version == 2 else feed.broker
242
+ subscription = broker.subscribe(team_id, feed_mail["recipient_server_id"])
243
+ snapshotter = (self.runtime.client.team_notification_hint_snapshot if version == 2
244
+ else self.runtime.client.team_mail_hint_snapshot)
245
+ result = snapshotter(realm["connection_id"], previous_cursor)
246
+ snapshot = result["cursor"]
247
+ mail_snapshot = snapshot["mail"] if version == 2 else snapshot
248
+ if result["hub_id"] != realm["hub_id"] or mail_snapshot["recipient_server_id"] != subscription.mailbox[1]:
249
+ raise MailHintClosed("Member Mail snapshot changed realm")
250
+
251
+ def authorize():
252
+ self._authorize(realm, version=version)
253
+ if feed.closed or self.member is not feed:
254
+ raise MailHintClosed("Member Mail upstream closed")
255
+
256
+ active = self.runtime._require_active_proxy_connection(realm["connection_id"])
257
+ expires_at = float(active["certificate_expires_at"])
258
+
259
+ def retired():
260
+ with self.guard:
261
+ self.leases.discard(lease)
262
+ last = not self.leases and not self.pending
263
+ old = self.member if last else None
264
+ if last:
265
+ self.member = None
266
+ if old is not None:
267
+ old.close()
268
+
269
+ lease_type = NotificationLease if version == 2 else MailHintLease
270
+ lease = lease_type(subscription, snapshot, hub_id=realm["hub_id"],
271
+ authorize=authorize, expires_at=expires_at, on_close=retired,
272
+ close_code=(lambda: feed.close_code) if feed is not None else None)
273
+ with self.guard:
274
+ self.leases.add(lease)
275
+ lease.revalidate()
276
+ return lease
277
+ except BaseException:
278
+ if lease is not None:
279
+ lease.close()
280
+ elif subscription is not None:
281
+ subscription.close()
282
+ raise
283
+ finally:
284
+ with self.guard:
285
+ self.pending -= 1
286
+ if not self.pending and not self.leases:
287
+ old, self.member = self.member, None
288
+ else:
289
+ old = None
290
+ if old is not None:
291
+ old.close()
292
+
293
+ def invalidate(self) -> None:
294
+ with self.guard:
295
+ self._generation += 1
296
+ self._scope = None
297
+ leases = tuple(self.leases)
298
+ old, self.member = self.member, None
299
+ if old is not None:
300
+ old.close()
301
+ for lease in leases:
302
+ lease.close()
@@ -0,0 +1,211 @@
1
+ """Authenticated local passive Mail socket, outside managed Hub ASGI drains.
2
+
3
+ No global subscription copies, mailbox fetches, shared executor waits or idle
4
+ queries. At most sixteen dedicated Mail workers each own one coalescing broker
5
+ lease and one serial bounded ASGI writer. Production remains feature-gated.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from contextlib import suppress
11
+ import json
12
+ import threading
13
+ import uuid
14
+ from typing import Any, Callable
15
+
16
+ from agentsdock_team_hub.mail_hints import MAIL_IDENTIFIER_RE, MailArrival
17
+ from agentsdock_team_hub.notification_hints import NotificationCursor
18
+
19
+ MAIL_WEBSOCKET_PROTOCOL = "agentsdock.team-mail-hints.v1"
20
+ NOTIFICATION_WEBSOCKET_PROTOCOL = "agentsdock.team-mail-hints.v2"
21
+ _SLOTS = threading.BoundedSemaphore(16)
22
+
23
+
24
+ class _MailSocketWriter:
25
+ """Bridge one dedicated worker to an actual event-loop Task completion.
26
+
27
+ A cancelled concurrent Future is not proof its asyncio Task settled. Keep
28
+ the worker's lease/slot until a Task done callback proves completion, even
29
+ if an ASGI implementation resists cancellation. That consumes only this
30
+ bounded Mail capacity, never a control request slot or event-loop wait.
31
+ """
32
+ def __init__(self, loop, *, timeout=5):
33
+ self.loop, self.timeout = loop, timeout
34
+ self.guard = threading.Lock()
35
+ self.stopped = False
36
+ self.task = None
37
+
38
+ def cancel(self):
39
+ with self.guard:
40
+ self.stopped = True
41
+
42
+ def cancel_task():
43
+ with self.guard:
44
+ task = self.task
45
+ if task is not None:
46
+ task.cancel()
47
+ with suppress(RuntimeError):
48
+ self.loop.call_soon_threadsafe(cancel_task)
49
+
50
+ def send(self, coroutine_factory):
51
+ settled = threading.Event()
52
+ outcome = {}
53
+
54
+ def finished(task):
55
+ try:
56
+ outcome["result"] = task.result()
57
+ except BaseException as exc:
58
+ outcome["error"] = exc
59
+ with self.guard:
60
+ if self.task is task:
61
+ self.task = None
62
+ settled.set()
63
+
64
+ def start():
65
+ try:
66
+ task = self.loop.create_task(coroutine_factory())
67
+ # This callback also runs when cancelled before its first step.
68
+ task.add_done_callback(finished)
69
+ with self.guard:
70
+ self.task = task
71
+ stopped = self.stopped
72
+ if stopped:
73
+ task.cancel()
74
+ except BaseException as exc:
75
+ outcome["error"] = exc
76
+ settled.set()
77
+
78
+ self.loop.call_soon_threadsafe(start)
79
+ if not settled.wait(self.timeout):
80
+ self.cancel()
81
+ settled.wait()
82
+ if "error" in outcome:
83
+ raise outcome["error"]
84
+
85
+
86
+ async def serve_team_mail_hints(ws: Any, runtime: Any, *, server_identity: str,
87
+ authorized: Callable[[], bool], protocols: list[str]) -> None:
88
+ protocol = (NOTIFICATION_WEBSOCKET_PROTOCOL if NOTIFICATION_WEBSOCKET_PROTOCOL in protocols
89
+ else MAIL_WEBSOCKET_PROTOCOL if MAIL_WEBSOCKET_PROTOCOL in protocols else None)
90
+ version = 2 if protocol == NOTIFICATION_WEBSOCKET_PROTOCOL else 1
91
+ await ws.accept(subprotocol=protocol)
92
+ if not authorized() or ws.query_params:
93
+ await ws.close(code=4401)
94
+ return
95
+ capability = (getattr(runtime, "team_notification_hint_capability", lambda: {}) if version == 2
96
+ else runtime.team_mail_hint_capability)
97
+ if protocol is None or not capability().get("enabled"):
98
+ await ws.close(code=4406)
99
+ return
100
+ if not _SLOTS.acquire(blocking=False):
101
+ await ws.close(code=1013)
102
+ return
103
+ loop = asyncio.get_running_loop()
104
+ done = asyncio.Event()
105
+ stopped = threading.Event()
106
+ guard = threading.Lock()
107
+ holder: dict[str, Any] = {}
108
+ writer = _MailSocketWriter(loop)
109
+ stream_id = uuid.uuid4().hex
110
+ worker = None
111
+ try:
112
+ raw = await asyncio.wait_for(ws.receive_text(), timeout=5)
113
+ if len(raw.encode("utf-8")) > 4096:
114
+ raise ValueError("Mail subscription frame is too large")
115
+ request = json.loads(raw)
116
+ if (not isinstance(request, dict) or set(request) != {"version", "team_id", "previous_cursor"}
117
+ or type(request["version"]) is not int or request["version"] != version
118
+ or not isinstance(request["team_id"], str) or MAIL_IDENTIFIER_RE.fullmatch(request["team_id"]) is None):
119
+ raise ValueError("Mail subscription frame is invalid")
120
+ if request["previous_cursor"] is not None:
121
+ previous = (NotificationCursor.from_dict(request["previous_cursor"]) if version == 2
122
+ else MailArrival.from_dict(request["previous_cursor"]))
123
+ if previous.mailbox[0] != request["team_id"]:
124
+ await ws.close(code=4403)
125
+ return
126
+
127
+ def abort():
128
+ stopped.set()
129
+ writer.cancel()
130
+
131
+ def run():
132
+ lease = None
133
+ try:
134
+ subscriber = (runtime.subscribe_team_notification_hints if version == 2
135
+ else runtime.subscribe_team_mail_hints)
136
+ lease = subscriber(request["team_id"], request["previous_cursor"])
137
+ with guard:
138
+ holder["lease"] = lease
139
+ lease.set_aborter(abort)
140
+ if stopped.is_set():
141
+ return
142
+
143
+ def write(kind, cursor):
144
+ async def send():
145
+ if stopped.is_set() or lease.closed or not authorized():
146
+ raise RuntimeError("Mail socket authority changed")
147
+ frame = {"type": kind, "server_identity": server_identity,
148
+ "hub_id": lease.hub_id, "stream_id": stream_id, "cursor": cursor}
149
+ data = json.dumps(frame, separators=(",", ":"))
150
+ if len(data.encode("utf-8")) > 4096:
151
+ raise ValueError("Mail socket metadata is too large")
152
+ await ws.send_text(data)
153
+ writer.send(send)
154
+
155
+ lease.write(lambda cursor: write("snapshot", cursor), lease.snapshot)
156
+ while not stopped.is_set():
157
+ arrival = lease.take()
158
+ if arrival is None:
159
+ break
160
+ lease.write(lambda cursor: write("hint", cursor), arrival.as_dict(reset=False))
161
+ except asyncio.CancelledError:
162
+ pass
163
+ except Exception as exc:
164
+ holder["close_code"] = (4403 if getattr(exc, "status_code", None) in (401, 403)
165
+ else 4406 if getattr(exc, "status_code", None) in (404, 501)
166
+ else 1008 if isinstance(exc, ValueError) or getattr(exc, "code", None) == "remote_invalid"
167
+ else 1012)
168
+ finally:
169
+ if lease is not None:
170
+ holder.setdefault("close_code", lease.close_code)
171
+ lease.close()
172
+ _SLOTS.release()
173
+ with suppress(RuntimeError):
174
+ loop.call_soon_threadsafe(done.set)
175
+
176
+ pending_worker = threading.Thread(target=run, name="agentsdock-mail-local", daemon=True)
177
+ pending_worker.start()
178
+ worker = pending_worker
179
+ receive = asyncio.create_task(ws.receive_text())
180
+ finished = asyncio.create_task(done.wait())
181
+ try:
182
+ completed, _pending = await asyncio.wait((receive, finished), return_when=asyncio.FIRST_COMPLETED)
183
+ if receive in completed:
184
+ # Subscriptions have no command channel or acknowledgement.
185
+ receive.result()
186
+ await ws.close(code=1008)
187
+ else:
188
+ await ws.close(code=holder.get("close_code", 1012))
189
+ finally:
190
+ for task in (receive, finished):
191
+ task.cancel()
192
+ with suppress(BaseException):
193
+ await task
194
+ except (ValueError, asyncio.TimeoutError):
195
+ with suppress(Exception):
196
+ await ws.close(code=1008)
197
+ except Exception:
198
+ pass
199
+ finally:
200
+ stopped.set()
201
+ with guard:
202
+ lease = holder.get("lease")
203
+ if lease is not None:
204
+ lease.cancel()
205
+ if worker is not None:
206
+ # No join/default executor on the event loop. The socket's bounded
207
+ # worker signals completion after closing its subscription.
208
+ with suppress(asyncio.TimeoutError):
209
+ await asyncio.wait_for(done.wait(), timeout=15)
210
+ else:
211
+ _SLOTS.release()