@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,262 @@
1
+ """Bounded HTTP video streaming from an already authorized, pinned descriptor.
2
+
3
+ This module does not resolve paths, read a share ledger, or open server state.
4
+ The caller securely opens and validates a regular file and transfers exclusive
5
+ ownership of its descriptor to SharedVideoResponse, including on init failure.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import stat
12
+ import threading
13
+ import time
14
+ from urllib.parse import quote
15
+
16
+ from starlette.concurrency import run_in_threadpool
17
+ from starlette.responses import Response, StreamingResponse
18
+
19
+
20
+ VIDEO_CONTENT_TYPES = frozenset({"video/mp4", "video/webm", "video/quicktime", "video/ogg"})
21
+ VIDEO_CHUNK_BYTES = 64 * 1024
22
+ REAUTHORIZE_INTERVAL_SECONDS = 1.0
23
+ _RANGE = re.compile(r"bytes=([0-9]*)-([0-9]*)", re.IGNORECASE)
24
+ _SECURITY_HEADERS = {
25
+ "cache-control": "no-store",
26
+ "referrer-policy": "no-referrer",
27
+ "x-content-type-options": "nosniff",
28
+ "x-robots-tag": "noindex, nofollow, noarchive",
29
+ "cross-origin-resource-policy": "same-origin",
30
+ }
31
+
32
+
33
+ class SharedVideoUnavailable(RuntimeError):
34
+ """Abort an already started response without exposing internal details."""
35
+
36
+
37
+ class VideoStreamAdmission:
38
+ """A bounded, nonwaiting lease shared by both video route families."""
39
+
40
+ def __init__(self, limit=8):
41
+ if type(limit) is not int or limit < 1:
42
+ raise ValueError("Invalid video stream limit")
43
+ self.limit = limit
44
+ self._active = 0
45
+ self._lock = threading.Lock()
46
+
47
+ def acquire(self):
48
+ with self._lock:
49
+ if self._active >= self.limit:
50
+ return False
51
+ self._active += 1
52
+ return True
53
+
54
+ def release(self):
55
+ with self._lock:
56
+ self._active -= 1
57
+
58
+
59
+ _ADMISSION = VideoStreamAdmission()
60
+
61
+
62
+ def _byte_range(request, byte_size):
63
+ """Return (status, start, length), including strict single-range rejection."""
64
+ values = request.headers.getlist("range")
65
+ if not values:
66
+ return 200, 0, byte_size
67
+ if len(values) != 1 or len(values[0]) > 256:
68
+ return 416, 0, 0
69
+ match = _RANGE.fullmatch(values[0].strip())
70
+ if match is None or not any(match.groups()) or byte_size == 0:
71
+ return 416, 0, 0
72
+ first, last = match.groups()
73
+ if first:
74
+ start = int(first)
75
+ end = min(int(last), byte_size - 1) if last else byte_size - 1
76
+ if start >= byte_size or end < start:
77
+ return 416, 0, 0
78
+ else:
79
+ suffix = int(last)
80
+ if suffix == 0:
81
+ return 416, 0, 0
82
+ start, end = max(0, byte_size - suffix), byte_size - 1
83
+ return 206, start, end - start + 1
84
+
85
+
86
+ def _file_revision(info):
87
+ return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns,
88
+ info.st_ctime_ns, info.st_uid, info.st_mode, info.st_nlink)
89
+
90
+
91
+ class SharedVideoResponse(StreamingResponse):
92
+ """GET/HEAD response owning a descriptor for its complete ASGI lifecycle.
93
+
94
+ ``request`` is a Starlette Request. ``reauthorize`` is an optional async
95
+ zero-argument callback: None/True grants access, False or an exception denies
96
+ it. It runs immediately before headers and, on demand while consuming the
97
+ response, at most once per second. Denial before headers produces an empty
98
+ 404; denial after headers aborts the stream with SharedVideoUnavailable.
99
+
100
+ Admission is acquired only when ASGI invokes the response and released on
101
+ every exit, including header disconnects. ``close()`` explicitly disposes a
102
+ response that will never be invoked; finalization is an additional fallback.
103
+ Neither the caller nor any other object may close/reuse the transferred fd.
104
+ ``file_revision`` may pin the resolver's fstat result as the tuple
105
+ (dev, ino, size, mtime_ns, ctime_ns, uid, mode, nlink). The revision is checked
106
+ at construction, before headers, and before/after every bounded read.
107
+ """
108
+
109
+ def __init__(self, fd, *, byte_size, content_type, filename, request,
110
+ reauthorize=None, extra_headers=None, admission=None, file_revision=None):
111
+ self._fd = fd
112
+ self._fd_lock = threading.Lock()
113
+ self._called = False
114
+ try:
115
+ if (type(fd) is not int or fd < 0 or type(byte_size) is not int
116
+ or byte_size < 0 or content_type not in VIDEO_CONTENT_TYPES):
117
+ raise ValueError("Invalid shared video")
118
+ info = os.fstat(fd)
119
+ if not stat.S_ISREG(info.st_mode) or info.st_size != byte_size:
120
+ raise ValueError("Invalid shared video")
121
+ self._file_revision = _file_revision(info)
122
+ if file_revision is not None and file_revision != self._file_revision:
123
+ raise ValueError("Invalid shared video")
124
+ # The caller supplies a display filename, never a path. Fail closed
125
+ # on path/control characters even if that contract was violated.
126
+ if (not isinstance(filename, str) or not filename or len(filename) > 255
127
+ or filename in {".", ".."}
128
+ or re.search(r"[\x00-\x1f\x7f/\\]", filename)):
129
+ raise ValueError("Invalid shared video")
130
+ self._method = request.method.upper()
131
+ self._reauthorize = reauthorize
132
+ self._last_authorized = None
133
+ self._admission = admission if admission is not None else _ADMISSION
134
+ self._base_headers = {str(key).lower(): value for key, value in (extra_headers or {}).items()}
135
+ # Route CSP is retained; callers cannot replace the mandatory
136
+ # privacy headers or representation/framing headers below.
137
+ for key in ("content-type", "content-length", "content-range", "content-disposition",
138
+ "accept-ranges", "transfer-encoding", "etag", "last-modified"):
139
+ self._base_headers.pop(key, None)
140
+ self._base_headers.update(_SECURITY_HEADERS)
141
+ headers = dict(self._base_headers)
142
+ status, self._start, self._length = _byte_range(request, byte_size)
143
+ if self._method not in {"GET", "HEAD"}:
144
+ status, self._length = 405, 0
145
+ headers["allow"] = "GET, HEAD"
146
+ else:
147
+ headers["accept-ranges"] = "bytes"
148
+ if status == 416:
149
+ headers["content-range"] = f"bytes */{byte_size}"
150
+ else:
151
+ headers["content-type"] = content_type
152
+ headers["content-disposition"] = "inline; filename*=UTF-8''" + quote(filename, safe="")
153
+ if status == 206:
154
+ headers["content-range"] = f"bytes {self._start}-{self._start + self._length - 1}/{byte_size}"
155
+ headers["content-length"] = str(self._length)
156
+ # stream_response owns the body; no generator owns the descriptor.
157
+ super().__init__(iter(()), status_code=status, headers=headers)
158
+ except BaseException:
159
+ self.close()
160
+ raise
161
+
162
+ def close(self):
163
+ """Idempotently close the owned descriptor, including unused responses."""
164
+ with self._fd_lock:
165
+ fd, self._fd = self._fd, None
166
+ if type(fd) is int and fd >= 0:
167
+ try:
168
+ os.close(fd)
169
+ except OSError:
170
+ pass
171
+
172
+ def __del__(self):
173
+ try:
174
+ self.close()
175
+ except Exception:
176
+ pass
177
+
178
+ def _read(self, size, offset):
179
+ # Cancellation may end an await while its worker still runs. Dup under
180
+ # the close lock so a delayed worker can never read a reused descriptor.
181
+ # The worker owns that duplicate until its one bounded read finishes.
182
+ with self._fd_lock:
183
+ if self._fd is None:
184
+ raise SharedVideoUnavailable("Shared video unavailable")
185
+ fd = os.dup(self._fd)
186
+ try:
187
+ if _file_revision(os.fstat(fd)) != self._file_revision:
188
+ raise SharedVideoUnavailable("Shared video unavailable")
189
+ chunk = os.pread(fd, size, offset)
190
+ if _file_revision(os.fstat(fd)) != self._file_revision:
191
+ raise SharedVideoUnavailable("Shared video unavailable")
192
+ return chunk
193
+ finally:
194
+ os.close(fd)
195
+
196
+ def _unchanged(self):
197
+ with self._fd_lock:
198
+ try:
199
+ return self._fd is not None and _file_revision(os.fstat(self._fd)) == self._file_revision
200
+ except OSError:
201
+ return False
202
+
203
+ async def _authorized(self, *, force=False):
204
+ if self._reauthorize is None:
205
+ return True
206
+ if (not force and self._last_authorized is not None
207
+ and time.monotonic() - self._last_authorized < REAUTHORIZE_INTERVAL_SECONDS):
208
+ return True
209
+ try:
210
+ granted = await self._reauthorize()
211
+ except Exception:
212
+ return False
213
+ self._last_authorized = time.monotonic()
214
+ return granted is None or granted is True
215
+
216
+ async def stream_response(self, send):
217
+ if not await self._authorized(force=True):
218
+ await self._empty_response(send, 404)
219
+ return
220
+ if not self._unchanged():
221
+ await self._empty_response(send, 404)
222
+ return
223
+ await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
224
+ if self._method != "HEAD" and self.status_code in {200, 206}:
225
+ offset, remaining = self._start, self._length
226
+ while remaining:
227
+ if not await self._authorized():
228
+ raise SharedVideoUnavailable("Shared video unavailable")
229
+ try:
230
+ chunk = await run_in_threadpool(self._read, min(VIDEO_CHUNK_BYTES, remaining), offset)
231
+ except OSError:
232
+ raise SharedVideoUnavailable("Shared video unavailable") from None
233
+ if not chunk:
234
+ raise SharedVideoUnavailable("Shared video unavailable")
235
+ # A slow disk read can cross the authorization interval.
236
+ if not await self._authorized():
237
+ raise SharedVideoUnavailable("Shared video unavailable")
238
+ await send({"type": "http.response.body", "body": chunk, "more_body": True})
239
+ offset += len(chunk)
240
+ remaining -= len(chunk)
241
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
242
+
243
+ async def _empty_response(self, send, status):
244
+ response = Response(status_code=status, headers=self._base_headers)
245
+ await send({"type": "http.response.start", "status": status, "headers": response.raw_headers})
246
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
247
+
248
+ async def __call__(self, scope, receive, send):
249
+ admitted = False
250
+ try:
251
+ if self._called or self._fd is None:
252
+ raise SharedVideoUnavailable("Shared video unavailable")
253
+ self._called = True
254
+ admitted = self._admission.acquire()
255
+ if not admitted:
256
+ await self._empty_response(send, 503)
257
+ return
258
+ await super().__call__(scope, receive, send)
259
+ finally:
260
+ self.close()
261
+ if admitted:
262
+ self._admission.release()
@@ -0,0 +1,155 @@
1
+ """Read-only, signed references to videos already published in one chat.
2
+
3
+ No workspace paths, file discovery, registry writes, or video copies. Capabilities
4
+ pin the registered copy's metadata and inode revision; changed files fail closed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import stat
16
+
17
+ VIDEO_TYPES = frozenset({"video/mp4", "video/webm", "video/quicktime", "video/ogg"})
18
+ VIDEO_ID = re.compile(r"video_[A-Za-z0-9_-]+\.[a-f0-9]{64}\Z")
19
+ FILE_ID = re.compile(r"(?:file|art)_[a-f0-9]{16,32}\Z")
20
+ SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}\Z")
21
+ EXTENSIONS = {".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm",
22
+ ".mov": "video/quicktime", ".ogv": "video/ogg", ".ogg": "video/ogg"}
23
+
24
+
25
+ class SharedVideoUnavailable(ValueError):
26
+ def __init__(self):
27
+ super().__init__("Shared video is unavailable")
28
+
29
+
30
+ def normalize_shared_chat_videos(value):
31
+ if not isinstance(value, list) or len(value) > 32:
32
+ raise ValueError("Invalid shared videos")
33
+ output, seen = [], set()
34
+ for item in value:
35
+ if not isinstance(item, dict) or set(item) != {"id", "filename", "content_type", "size"}:
36
+ raise ValueError("Invalid shared video")
37
+ identity, name = item["id"], item["filename"]
38
+ if (not isinstance(identity, str) or len(identity) > 1024 or VIDEO_ID.fullmatch(identity) is None
39
+ or identity in seen or not isinstance(name, str) or not 1 <= len(name) <= 255
40
+ or name in {".", ".."} or any(ord(c) < 32 or ord(c) == 127 or c in "/\\" for c in name)
41
+ or len(name.encode("utf-8")) > 1024 or not isinstance(item["content_type"], str) or item["content_type"] not in VIDEO_TYPES
42
+ or type(item["size"]) is not int or not 0 < item["size"] <= (1 << 53) - 1):
43
+ raise ValueError("Invalid shared video")
44
+ output.append(dict(item)); seen.add(identity)
45
+ return output
46
+
47
+
48
+ def _key(secret):
49
+ if not isinstance(secret, str) or not secret:
50
+ raise SharedVideoUnavailable()
51
+ return hmac.new(secret.encode("utf-8"), b"agentsdock-chat-video-v1", hashlib.sha256).digest()
52
+
53
+
54
+ def _regular(info, owner):
55
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != owner:
56
+ raise SharedVideoUnavailable()
57
+
58
+
59
+ def _open_registered(root, session_id, file_id, *, legacy_owner=False):
60
+ """Open through pinned directories; return an owned FD, never reopen a path."""
61
+ if (not isinstance(session_id, str) or SESSION_ID.fullmatch(session_id) is None
62
+ or not isinstance(file_id, str) or FILE_ID.fullmatch(file_id) is None):
63
+ raise SharedVideoUnavailable()
64
+ root = Path(root)
65
+ if not root.is_absolute() or not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
66
+ raise SharedVideoUnavailable()
67
+ directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
68
+ root_fd = directory_fd = meta_fd = video_fd = None
69
+ try:
70
+ root_fd = os.open(root, directory_flags)
71
+ owner = os.fstat(root_fd).st_uid
72
+ directory_fd = os.open(file_id, directory_flags, dir_fd=root_fd)
73
+ if os.fstat(directory_fd).st_uid != owner:
74
+ raise SharedVideoUnavailable()
75
+ meta_fd = os.open("meta.json", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory_fd)
76
+ before = os.fstat(meta_fd)
77
+ _regular(before, owner)
78
+ if not 0 < before.st_size <= 64 * 1024:
79
+ raise SharedVideoUnavailable()
80
+ raw = os.read(meta_fd, 64 * 1024 + 1)
81
+ after = os.fstat(meta_fd)
82
+ if len(raw) != before.st_size or (before.st_mtime_ns, before.st_ctime_ns) != (after.st_mtime_ns, after.st_ctime_ns):
83
+ raise SharedVideoUnavailable()
84
+ meta = json.loads(raw)
85
+ if not isinstance(meta, dict) or meta.get("id") != file_id:
86
+ raise SharedVideoUnavailable()
87
+ if meta.get("session_id") != session_id and not (legacy_owner and meta.get("session_id") in (None, "")):
88
+ raise SharedVideoUnavailable()
89
+ name = meta.get("filename")
90
+ if (not isinstance(name, str) or not 1 <= len(name) <= 255 or name in {".", "..", "meta.json"}
91
+ or any(ord(c) < 32 or ord(c) == 127 or c in "/\\" for c in name)
92
+ or len(name.encode("utf-8")) > 1024 or meta.get("path") != str(root / file_id / name)):
93
+ raise SharedVideoUnavailable()
94
+ guessed = EXTENSIONS.get(Path(name).suffix.lower())
95
+ mime = str(meta.get("content_type") or "").split(";", 1)[0].strip().lower()
96
+ if mime in {"", "application/octet-stream", "binary/octet-stream"}:
97
+ mime = guessed
98
+ if mime not in VIDEO_TYPES or mime != guessed:
99
+ raise SharedVideoUnavailable()
100
+ video_fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory_fd)
101
+ info = os.fstat(video_fd)
102
+ _regular(info, owner)
103
+ if type(meta.get("size")) is not int or meta["size"] != info.st_size or not 0 < info.st_size <= (1 << 53) - 1:
104
+ raise SharedVideoUnavailable()
105
+ revision = hashlib.sha256(json.dumps([name, mime, meta.get("session_id"), info.st_dev, info.st_ino,
106
+ info.st_size, info.st_mtime_ns, info.st_ctime_ns, info.st_uid, info.st_mode],
107
+ ensure_ascii=True, separators=(",", ":")).encode("ascii")).hexdigest()
108
+ result = {"file_fd": video_fd, "filename": name, "content_type": mime, "size": info.st_size, "revision": revision,
109
+ "file_revision": (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns,
110
+ info.st_uid, info.st_mode, info.st_nlink)}
111
+ video_fd = None
112
+ return result
113
+ except (OSError, ValueError, TypeError, KeyError, UnicodeError, RecursionError):
114
+ raise SharedVideoUnavailable() from None
115
+ finally:
116
+ for fd in (video_fd, meta_fd, directory_fd, root_fd):
117
+ if fd is not None:
118
+ os.close(fd)
119
+
120
+
121
+ def shared_chat_video_descriptor(root, secret, session_id, file_id, *, legacy_owner=False):
122
+ key = _key(secret)
123
+ opened = _open_registered(root, session_id, file_id, legacy_owner=legacy_owner)
124
+ try:
125
+ payload = json.dumps([1, session_id, file_id, opened["revision"]], separators=(",", ":")).encode("ascii")
126
+ encoded = base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii")
127
+ identity = "video_" + encoded + "." + hmac.new(key, encoded.encode("ascii"), hashlib.sha256).hexdigest()
128
+ return normalize_shared_chat_videos([{key: opened[key] for key in ("filename", "content_type", "size")} | {"id": identity}])[0]
129
+ finally:
130
+ os.close(opened["file_fd"])
131
+
132
+
133
+ def open_shared_chat_video(root, secret, session_id, identity):
134
+ opened = None
135
+ try:
136
+ if not isinstance(identity, str) or len(identity) > 1024 or VIDEO_ID.fullmatch(identity) is None:
137
+ raise SharedVideoUnavailable()
138
+ encoded, signature = identity[len("video_"):].split(".")
139
+ expected = hmac.new(_key(secret), encoded.encode("ascii"), hashlib.sha256).hexdigest()
140
+ if not hmac.compare_digest(signature, expected):
141
+ raise SharedVideoUnavailable()
142
+ payload = json.loads(base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True))
143
+ if (not isinstance(payload, list) or len(payload) != 4 or type(payload[0]) is not int or payload[0] != 1
144
+ or payload[1] != session_id or not isinstance(payload[3], str)):
145
+ raise SharedVideoUnavailable()
146
+ opened = _open_registered(root, session_id, payload[2], legacy_owner=True)
147
+ if not hmac.compare_digest(opened.pop("revision"), payload[3]):
148
+ raise SharedVideoUnavailable()
149
+ result, opened = opened, None
150
+ return result
151
+ except (ValueError, TypeError, KeyError, UnicodeError, RecursionError):
152
+ raise SharedVideoUnavailable() from None
153
+ finally:
154
+ if opened is not None:
155
+ os.close(opened["file_fd"])