@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,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"])
|