@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,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()
|