@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,534 @@
1
+ """Narrow guest routes; callbacks never receive arbitrary chat IDs or tool options."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import hmac
6
+ import json
7
+ from pathlib import Path
8
+ import re
9
+ import threading
10
+ from urllib.parse import unquote
11
+ import weakref
12
+
13
+ from fastapi import APIRouter, HTTPException, Request
14
+ from fastapi.responses import JSONResponse, Response, StreamingResponse
15
+
16
+ from interactive_chat_shares import (
17
+ InteractiveChatShareStore, Unavailable, ValidationError, Conflict, SHARE_ID,
18
+ MAX_PROMPT_BYTES, MAX_UPLOAD_BYTES, csrf_token, _utf8_size,
19
+ )
20
+ from public_chat_share_routes import chat_share_origin
21
+ import interactive_chat_share_web as web
22
+ from interactive_chat_controls import ChatControlError
23
+ from shared_chat_video_stream import SharedVideoResponse
24
+ from shared_chat_videos import VIDEO_ID, SharedVideoUnavailable
25
+
26
+ WARNING = (
27
+ "Trusted full chat control: this person can read this chat, send or steer prompts, upload files, "
28
+ "stop work, manage the queue and goals, change model/permission settings, respond to approvals, "
29
+ "and create or run persistent scheduled jobs for this chat. "
30
+ "Attached and published videos are playable and can be saved by viewers. "
31
+ "No general file browsing, terminal, other chats, or server administration are shared. "
32
+ "The existing agent retains its normal tools and context, so they can ask it to use tools "
33
+ "or return sensitive information. This is not a sandbox. Share the URL and reusable access token separately. "
34
+ "Revocation stops future access but cannot erase saved copies, undo accepted work, or remove already configured jobs."
35
+ )
36
+ CONTROL_ACTIONS = frozenset({
37
+ "turn.stop", "turn.steer", "queue.run_now", "queue.edit", "queue.delete", "queue.move",
38
+ "settings.update", "goal.set", "goal.resume", "goal.pause", "goal.delete", "job.create", "job.update",
39
+ "job.delete", "job.toggle", "job.run", "approval.respond",
40
+ })
41
+ CONTROL_READ_ACTIONS = frozenset({"timeline.older", "timeline.around", "timeline.trace", "timeline.index", "jobs.runs", "runtime.catalog", "handoffs.get"})
42
+ NATIVE_STATE_FIELDS = frozenset({"revision", "session", "events", "queue", "active", "goal", "jobs",
43
+ "codex_runtime", "claude_runtime", "health", "runtime_catalog", "hasMoreEvents", "nextTimelineBefore", "eventsTotal"})
44
+ COOKIE = "__Secure-AgentsDock-Chat"
45
+ HTTP_COOKIE = "AgentsDock-Chat"
46
+ HEADERS = {
47
+ "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
48
+ "X-Robots-Tag": "noindex, nofollow, noarchive", "Cross-Origin-Resource-Policy": "same-origin",
49
+ "Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
50
+ }
51
+
52
+
53
+ def create_interactive_chat_share_router(*, storage_root, authorize, session_exists, public_base_url,
54
+ load_transcript, submit_prompt, save_upload, wait_for_change, chat_control=None, open_video=None):
55
+ """Callbacks are scoped by the durable share ledger, not browser identities.
56
+
57
+ A durable request ledger prevents repeated callback execution; ambiguous
58
+ pending results fail closed. wait_for_change returns False on timeout without reading a
59
+ transcript. load_transcript supplies sanitized one-chat DTOs, never raw provider transcripts.
60
+ """
61
+ router = APIRouter()
62
+ cached_store = None
63
+ writable_store = False
64
+ store_lock = threading.Lock()
65
+ locks = weakref.WeakValueDictionary()
66
+ streams = 0
67
+ active_workers = 0
68
+ active_submissions = 0
69
+ active_uploads = 0
70
+
71
+ class SharedChatStreamResponse(StreamingResponse):
72
+ async def __call__(self, scope, receive, send):
73
+ nonlocal streams
74
+ if streams >= 32:
75
+ raise HTTPException(503, "Too many shared conversation streams")
76
+ streams += 1
77
+ try:
78
+ # Header delivery can disconnect before the body generator
79
+ # starts, so its finally block cannot own this lease.
80
+ await super().__call__(scope, receive, send)
81
+ finally:
82
+ streams -= 1
83
+
84
+ def share_lock(share_id):
85
+ lock = locks.get(share_id)
86
+ if lock is None:
87
+ lock = asyncio.Lock()
88
+ locks[share_id] = lock
89
+ return lock
90
+
91
+ async def worker(operation):
92
+ nonlocal active_workers
93
+ if active_workers >= 8:
94
+ raise HTTPException(503, "Sharing is busy; retry shortly")
95
+ active_workers += 1
96
+ task = asyncio.create_task(asyncio.to_thread(operation))
97
+ def finished(_):
98
+ nonlocal active_workers
99
+ active_workers -= 1
100
+ if not task.cancelled():
101
+ task.exception()
102
+ task.add_done_callback(finished)
103
+ return await asyncio.shield(task)
104
+
105
+ def store(create=False):
106
+ nonlocal cached_store, writable_store
107
+ with store_lock:
108
+ if cached_store is None or (create and not writable_store):
109
+ if not create and not (Path(storage_root) / "interactive.sqlite3").is_file():
110
+ raise Unavailable()
111
+ cached_store = InteractiveChatShareStore(storage_root) if create else InteractiveChatShareStore.open_existing(storage_root)
112
+ writable_store = create
113
+ return cached_store
114
+
115
+ def origin(base):
116
+ if not isinstance(base, str) or not base:
117
+ raise HTTPException(409, "This link has no server address; create a new share")
118
+ try:
119
+ return chat_share_origin(base)
120
+ except ValidationError:
121
+ raise HTTPException(400, "Chat link must use an HTTP or HTTPS origin") from None
122
+
123
+ async def public_guard(request, share_id, *, write=False, shell=False):
124
+ if SHARE_ID.fullmatch(share_id) is None:
125
+ raise HTTPException(404, "Shared conversation unavailable")
126
+ try:
127
+ bound_origin = await worker(lambda: store().share_origin(share_id))
128
+ except Unavailable:
129
+ raise HTTPException(404, "Shared conversation unavailable") from None
130
+ expected = origin(bound_origin or public_base_url())
131
+ # Never use forwarded/Host headers to choose the allowed origin. The
132
+ # configured origin is the authority; ingress must preserve its host.
133
+ if str(request.base_url).rstrip("/") != expected or request.url.query:
134
+ raise HTTPException(403, "This origin is not permitted")
135
+ supplied = request.headers.get("origin")
136
+ if (write and supplied != expected) or (supplied is not None and supplied != expected):
137
+ raise HTTPException(403, "This origin is not permitted")
138
+ public_navigation = (
139
+ shell and request.method in {"GET", "HEAD"}
140
+ and request.headers.get("sec-fetch-mode") == "navigate"
141
+ and request.headers.get("sec-fetch-dest") == "document"
142
+ )
143
+ if request.headers.get("sec-fetch-site") in {"cross-site", "same-site"} and not public_navigation:
144
+ raise HTTPException(403, "Cross-origin access is not permitted")
145
+ return expected
146
+
147
+ def management(request, session_id, *, exists=False):
148
+ authorize(request)
149
+ if re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id) is None or (exists and not session_exists(session_id)):
150
+ raise HTTPException(404, "Chat not found")
151
+
152
+ def result(value, status=200):
153
+ return JSONResponse(value, status_code=status, headers=HEADERS)
154
+
155
+ async def body_bytes(request, limit, *, timeout=10):
156
+ async def read():
157
+ data = bytearray()
158
+ async for chunk in request.stream():
159
+ if len(data) + len(chunk) > limit:
160
+ raise HTTPException(413, "Request is too large")
161
+ data.extend(chunk)
162
+ return bytes(data)
163
+ try:
164
+ return await asyncio.wait_for(read(), timeout)
165
+ except asyncio.TimeoutError:
166
+ raise HTTPException(408, "Request body was not received") from None
167
+
168
+ async def json_body(request, *, limit=8192):
169
+ if request.headers.get("content-type", "").split(";")[0].strip().lower() != "application/json":
170
+ raise HTTPException(415, "Use application/json")
171
+ try:
172
+ value = json.loads(await body_bytes(request, limit))
173
+ except (ValueError, UnicodeError, RecursionError):
174
+ raise HTTPException(400, "Invalid JSON") from None
175
+ if not isinstance(value, dict):
176
+ raise HTTPException(400, "Expected a JSON object")
177
+ return value
178
+
179
+ async def auth(request, share_id, *, write=False):
180
+ expected = await public_guard(request, share_id, write=write)
181
+ if SHARE_ID.fullmatch(share_id) is None:
182
+ raise HTTPException(404, "Shared conversation unavailable")
183
+ # Reject ambiguous same-name cookies rather than choosing browser order.
184
+ cookies = [part.strip().partition("=")[2] for part in request.headers.get("cookie", "").split(";")
185
+ if part.strip().partition("=")[0] == (COOKIE if expected.startswith("https:") else HTTP_COOKIE)]
186
+ if len(cookies) != 1:
187
+ raise HTTPException(404, "Shared conversation unavailable")
188
+ token = cookies[0]
189
+ try:
190
+ grant = await worker(lambda: store().authenticate(share_id, token))
191
+ except Unavailable:
192
+ raise HTTPException(404, "Shared conversation unavailable") from None
193
+ if not session_exists(grant["session_id"]):
194
+ raise HTTPException(404, "Shared conversation unavailable")
195
+ supplied_csrf = request.headers.get("x-chat-csrf", "")
196
+ if write and (re.fullmatch(r"[a-f0-9]{64}", supplied_csrf) is None or not hmac.compare_digest(supplied_csrf, csrf_token(token))):
197
+ raise HTTPException(403, "Missing or invalid request confirmation")
198
+ return grant, token
199
+
200
+ async def snapshot(grant):
201
+ try:
202
+ value = await load_transcript(grant["session_id"])
203
+ except Exception:
204
+ raise HTTPException(503, "Shared conversation temporarily unavailable; reopen it after checking the original chat") from None
205
+ if not isinstance(value, dict) or not isinstance(value.get("revision"), str) or len(value["revision"]) > 128:
206
+ raise HTTPException(503, "Shared conversation temporarily unavailable")
207
+ if "session" in value:
208
+ if (set(value) - NATIVE_STATE_FIELDS or not isinstance(value["session"], dict)
209
+ or value["session"].get("id") != grant["session_id"] or not isinstance(value.get("events"), list)
210
+ or any(not isinstance(item, dict) or item.get("session_id", grant["session_id"]) != grant["session_id"] for item in value["events"])):
211
+ raise HTTPException(503, "Shared conversation temporarily unavailable")
212
+ projected = {**value, "title": grant["title"]}
213
+ else:
214
+ # Compatibility for the isolated text-projection adapter; the
215
+ # deployed renderer receives native DTOs, never invented events.
216
+ if type(value.get("busy")) is not bool or not isinstance(value.get("messages"), list) or len(value["messages"]) > 1000:
217
+ raise HTTPException(503, "Shared conversation temporarily unavailable")
218
+ messages = []
219
+ for item in value["messages"]:
220
+ if not isinstance(item, dict) or item.get("role") not in {"user", "assistant"} or not isinstance(item.get("text"), str):
221
+ raise HTTPException(503, "Shared conversation temporarily unavailable")
222
+ message = {"role": item["role"], "text": item["text"]}
223
+ if type(item.get("timestamp")) in (int, float):
224
+ message["timestamp"] = item["timestamp"]
225
+ if item.get("pending") is True:
226
+ message["pending"] = True
227
+ messages.append(message)
228
+ projected = {"revision": value["revision"], "busy": value["busy"], "messages": messages, "title": grant["title"]}
229
+ try:
230
+ size = len(json.dumps(projected, ensure_ascii=False, allow_nan=False).encode("utf-8"))
231
+ except (ValueError, UnicodeError):
232
+ raise HTTPException(503, "Shared conversation temporarily unavailable") from None
233
+ if size > 2 * 1024 * 1024:
234
+ raise HTTPException(503, "Shared conversation exceeds the safe display size")
235
+ return projected
236
+
237
+ @router.post("/api/admin/interactive-chat-shares/{session_id}")
238
+ async def create(session_id: str, request: Request):
239
+ management(request, session_id, exists=True)
240
+ value = await json_body(request)
241
+ if value.get("confirmed_interactive") is not True or set(value) - {"confirmed_interactive", "title", "expires_at", "base_url"}:
242
+ raise HTTPException(400, "Trusted interactive collaboration must be explicitly confirmed")
243
+ if "base_url" in value and (not isinstance(value["base_url"], str) or not value["base_url"]):
244
+ raise HTTPException(400, "Invalid chat link origin")
245
+ base = origin(value.get("base_url") or public_base_url() or str(request.base_url).rstrip("/"))
246
+ try:
247
+ created = await worker(lambda: store(True).create_share(session_id, title=value.get("title"), expires_at=value.get("expires_at"), public_origin=base))
248
+ except ValidationError as exc:
249
+ raise HTTPException(400, str(exc)) from None
250
+ invite = created.pop("invitation_token")
251
+ path = f"/interactive-chat/{created['id']}"
252
+ return result({**created, "path": path, "url": base + path, "access_token": invite, "warning": WARNING}, 201)
253
+
254
+ @router.get("/api/admin/interactive-chat-shares/{session_id}")
255
+ async def listing(session_id: str, request: Request):
256
+ management(request, session_id)
257
+ try:
258
+ shares = await worker(lambda: store().list_shares(session_id))
259
+ except Unavailable:
260
+ shares = []
261
+ return result({"shares": shares})
262
+
263
+ @router.delete("/api/admin/interactive-chat-shares/{session_id}/{share_id}")
264
+ async def revoke(session_id: str, share_id: str, request: Request):
265
+ management(request, session_id)
266
+ async with share_lock(share_id):
267
+ try:
268
+ revoked = await worker(lambda: store().revoke_share(share_id, session_id=session_id))
269
+ except Unavailable:
270
+ revoked = False
271
+ if not revoked:
272
+ raise HTTPException(404, "Share not found")
273
+ return result({"revoked": True})
274
+
275
+ @router.api_route("/interactive-chat/assets/{asset:path}", methods=["GET", "HEAD"], include_in_schema=False)
276
+ async def asset(asset: str, request: Request):
277
+ assets = getattr(web, "ASSETS", None)
278
+ if assets is None:
279
+ assets = {"viewer.js": ("text/javascript", web.JAVASCRIPT), "viewer.css": ("text/css", web.CSS)}
280
+ selected = assets.get(asset)
281
+ if selected is None:
282
+ raise HTTPException(404)
283
+ media_type, data = selected
284
+ return Response(data if request.method == "GET" else b"", media_type=media_type, headers=HEADERS)
285
+
286
+ @router.api_route("/interactive-chat/{share_id}", methods=["GET", "HEAD"], include_in_schema=False)
287
+ async def shell(share_id: str, request: Request):
288
+ await public_guard(request, share_id, shell=True)
289
+ if SHARE_ID.fullmatch(share_id) is None:
290
+ raise HTTPException(404)
291
+ return Response(web.HTML if request.method == "GET" else b"", media_type="text/html", headers=HEADERS)
292
+
293
+ @router.post("/interactive-chat/{share_id}/redeem", include_in_schema=False)
294
+ async def redeem(share_id: str, request: Request):
295
+ expected = await public_guard(request, share_id, write=True)
296
+ value = await json_body(request)
297
+ if set(value) != {"invitation_token"} or SHARE_ID.fullmatch(share_id) is None:
298
+ raise HTTPException(404, "Invitation unavailable")
299
+ async with share_lock(share_id):
300
+ try:
301
+ token = await worker(lambda: store().redeem(share_id, value["invitation_token"]))
302
+ except Unavailable:
303
+ raise HTTPException(404, "Invitation unavailable") from None
304
+ response = result({"redeemed": True, "csrf": csrf_token(token)})
305
+ secure = expected.startswith("https:")
306
+ response.set_cookie(COOKIE if secure else HTTP_COOKIE, token, secure=secure, httponly=True, samesite="strict", path=f"/interactive-chat/{share_id}")
307
+ return response
308
+
309
+ @router.get("/interactive-chat/{share_id}/state", include_in_schema=False)
310
+ async def state(share_id: str, request: Request):
311
+ grant, token = await auth(request, share_id)
312
+ value = await snapshot(grant)
313
+ await auth(request, share_id)
314
+ return result({**value, "csrf": csrf_token(token)})
315
+
316
+ @router.api_route("/interactive-chat/{share_id}/media/{handle}", methods=["GET", "HEAD"], include_in_schema=False)
317
+ async def video(share_id: str, handle: str, request: Request):
318
+ grant, _ = await auth(request, share_id)
319
+ if open_video is None or len(handle) > 1024 or VIDEO_ID.fullmatch(handle) is None:
320
+ raise HTTPException(404, "Shared video is unavailable")
321
+ try:
322
+ opened = await open_video(grant["session_id"], handle)
323
+ except (SharedVideoUnavailable, OSError, ValueError):
324
+ raise HTTPException(404, "Shared video is unavailable") from None
325
+ async def reauthorize():
326
+ await auth(request, share_id)
327
+ try:
328
+ # Construction owns the descriptor even if its pinned revision
329
+ # changed between resolution and response initialization.
330
+ return SharedVideoResponse(opened["file_fd"], byte_size=opened["size"],
331
+ content_type=opened["content_type"], filename=opened["filename"], request=request,
332
+ reauthorize=reauthorize, extra_headers=HEADERS, file_revision=opened.get("file_revision"))
333
+ except (OSError, ValueError):
334
+ raise HTTPException(404, "Shared video is unavailable") from None
335
+
336
+ @router.post("/interactive-chat/{share_id}/prompts", include_in_schema=False)
337
+ async def prompt(share_id: str, request: Request):
338
+ nonlocal active_submissions
339
+ await auth(request, share_id, write=True)
340
+ value = await json_body(request, limit=MAX_PROMPT_BYTES + 4096)
341
+ if set(value) - {"prompt", "upload_ids", "request_id"} or not isinstance(value.get("prompt"), str):
342
+ raise HTTPException(400, "Only a prompt and this share's uploads are accepted")
343
+ request_id = value.get("request_id")
344
+ if not isinstance(request_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request_id) is None:
345
+ raise HTTPException(400, "A stable request ID is required")
346
+ async def deliver():
347
+ async with share_lock(share_id):
348
+ grant, token = await auth(request, share_id, write=True)
349
+ try:
350
+ _utf8_size(value["prompt"], "Prompt", MAX_PROMPT_BYTES)
351
+ refs = await worker(lambda: store().upload_refs(share_id, token, value.get("upload_ids", [])))
352
+ except ValidationError as exc:
353
+ raise HTTPException(400, str(exc)) from None
354
+ except Unavailable:
355
+ raise HTTPException(404, "Upload unavailable") from None
356
+ if not value["prompt"].strip() and not refs:
357
+ raise HTTPException(400, "Write a prompt or attach a file")
358
+ try:
359
+ previous = await worker(lambda: store().reserve_submission(share_id, token, request_id, value["prompt"], refs))
360
+ except Conflict as exc:
361
+ raise HTTPException(409, str(exc)) from None
362
+ if previous is not None:
363
+ return previous
364
+ try:
365
+ receipt = await submit_prompt(grant["session_id"], share_id, value["prompt"], refs, request_id)
366
+ if not isinstance(receipt, dict) or receipt.get("accepted") is not True or type(receipt.get("queued")) is not bool:
367
+ raise ValueError("Invalid acceptance receipt")
368
+ public_receipt = {"accepted": True, "queued": receipt["queued"], "request_id": request_id}
369
+ queued_id = receipt.get("queued_id")
370
+ if queued_id is not None:
371
+ if not receipt["queued"] or not isinstance(queued_id, str) or re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", queued_id) is None:
372
+ raise ValueError("Invalid queued message receipt")
373
+ public_receipt["queued_id"] = queued_id
374
+ await worker(lambda: store().accept_submission(share_id, request_id, public_receipt))
375
+ except Exception:
376
+ raise HTTPException(503, "Message acceptance is unconfirmed. Inspect the chat before sending again; this request will not be automatically retried") from None
377
+ return public_receipt
378
+ if active_submissions >= 8:
379
+ raise HTTPException(503, "Message submission is busy; retry shortly")
380
+ active_submissions += 1
381
+ task = asyncio.create_task(deliver())
382
+ def finished(_):
383
+ nonlocal active_submissions
384
+ active_submissions -= 1
385
+ if not task.cancelled():
386
+ task.exception()
387
+ task.add_done_callback(finished)
388
+ return result(await asyncio.shield(task), 202)
389
+
390
+ @router.post("/interactive-chat/{share_id}/uploads", include_in_schema=False)
391
+ async def upload(share_id: str, request: Request):
392
+ nonlocal active_uploads
393
+ await auth(request, share_id, write=True)
394
+ if active_uploads >= 4:
395
+ raise HTTPException(503, "Uploads are busy; retry shortly")
396
+ active_uploads += 1
397
+ task = None
398
+ try:
399
+ name = unquote(request.headers.get("x-chat-filename", ""))
400
+ media_type = request.headers.get("content-type", "application/octet-stream").split(";")[0]
401
+ data = await body_bytes(request, MAX_UPLOAD_BYTES, timeout=30)
402
+ async def commit_upload():
403
+ async with share_lock(share_id):
404
+ grant, token = await auth(request, share_id, write=True)
405
+ try:
406
+ upload_id = await worker(lambda: store().reserve_upload(share_id, token, name=name, media_type=media_type, byte_size=len(data)))
407
+ except ValidationError as exc:
408
+ raise HTTPException(400, str(exc)) from None
409
+ try:
410
+ private_ref = await save_upload(grant["session_id"], share_id, name, media_type, data)
411
+ await worker(lambda: store().complete_upload(share_id, token, upload_id, private_ref))
412
+ except Exception:
413
+ # A callback may already have durably written the file.
414
+ # Keep its reservation charged when completion is unknown.
415
+ raise HTTPException(503, "File upload could not be confirmed; its storage reservation is retained") from None
416
+ return {"id": upload_id, "name": name, "media_type": media_type, "byte_size": len(data)}
417
+ task = asyncio.create_task(commit_upload())
418
+ def finished(_):
419
+ nonlocal active_uploads
420
+ active_uploads -= 1
421
+ if not task.cancelled():
422
+ task.exception()
423
+ task.add_done_callback(finished)
424
+ return result(await asyncio.shield(task), 201)
425
+ finally:
426
+ if task is None:
427
+ active_uploads -= 1
428
+
429
+ @router.get("/interactive-chat/{share_id}/controls", include_in_schema=False)
430
+ async def controls(share_id: str, request: Request):
431
+ grant, _ = await auth(request, share_id)
432
+ value = await snapshot(grant)
433
+ await auth(request, share_id)
434
+ return result(value)
435
+
436
+ @router.post("/interactive-chat/{share_id}/controls", include_in_schema=False)
437
+ async def control(share_id: str, request: Request):
438
+ nonlocal active_submissions
439
+ await auth(request, share_id, write=True)
440
+ value = await json_body(request, limit=MAX_PROMPT_BYTES + 4096)
441
+ if (set(value) != {"action", "payload", "request_id"} or not isinstance(value.get("action"), str)
442
+ or value["action"] not in CONTROL_ACTIONS | CONTROL_READ_ACTIONS
443
+ or not isinstance(value.get("payload"), dict)):
444
+ raise HTTPException(400, "Choose an explicitly supported chat action")
445
+ request_id = value["request_id"]
446
+ if not isinstance(request_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request_id) is None:
447
+ raise HTTPException(400, "A stable request ID is required")
448
+ if chat_control is None:
449
+ raise HTTPException(503, "Chat controls are unavailable")
450
+ action, payload = value["action"], value["payload"]
451
+ try:
452
+ encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
453
+ _utf8_size(encoded, "Action payload", MAX_PROMPT_BYTES)
454
+ except (ValueError, UnicodeError):
455
+ raise HTTPException(400, "Invalid action payload") from None
456
+ if action in CONTROL_READ_ACTIONS:
457
+ grant, _ = await auth(request, share_id, write=True)
458
+ try:
459
+ public_result = await chat_control(grant["session_id"], action, payload)
460
+ if len(json.dumps(public_result, ensure_ascii=False, allow_nan=False).encode("utf-8")) > 2 * 1024 * 1024:
461
+ raise ValueError("Read result is too large")
462
+ except ChatControlError as exc:
463
+ raise HTTPException(403 if exc.code == "forbidden" else 400, "Invalid or unavailable chat read") from None
464
+ except Exception:
465
+ raise HTTPException(503, "This chat read could not be completed") from None
466
+ await auth(request, share_id)
467
+ return result({"accepted": True, "action": action, "request_id": request_id, "result": public_result})
468
+ async def apply_control():
469
+ async with share_lock(share_id):
470
+ grant, token = await auth(request, share_id, write=True)
471
+ try:
472
+ previous = await worker(lambda: store().reserve_submission(share_id, token, request_id, encoded, [], operation="control:" + action))
473
+ except Conflict as exc:
474
+ raise HTTPException(409, str(exc)) from None
475
+ if previous is not None:
476
+ return previous
477
+ try:
478
+ accepted = await chat_control(grant["session_id"], action, payload,
479
+ share_id=share_id, request_id=request_id)
480
+ if not isinstance(accepted, dict) or accepted.get("accepted") is not True:
481
+ raise ValueError("Invalid control receipt")
482
+ public_result = accepted.get("result")
483
+ if len(json.dumps(public_result, ensure_ascii=False, allow_nan=False).encode("utf-8")) > 2 * 1024 * 1024:
484
+ raise ValueError("Control result is too large")
485
+ receipt = {"accepted": True, "action": action, "request_id": request_id, "result": public_result}
486
+ await worker(lambda: store().accept_submission(share_id, request_id, receipt))
487
+ except ChatControlError as exc:
488
+ # Only this typed validator error guarantees no callback ran.
489
+ receipt = {"accepted": False, "action": action, "request_id": request_id,
490
+ "error_code": "forbidden" if exc.code == "forbidden" else "invalid_request",
491
+ "detail": "This chat control request is not permitted" if exc.code == "forbidden" else "Invalid chat control request"}
492
+ await worker(lambda: store().accept_submission(share_id, request_id, receipt))
493
+ except Exception:
494
+ raise HTTPException(503, "Action acceptance is unconfirmed. Inspect this chat before trying again; this request will not be automatically retried") from None
495
+ return receipt
496
+ if active_submissions >= 8:
497
+ raise HTTPException(503, "Chat controls are busy; retry shortly")
498
+ active_submissions += 1
499
+ task = asyncio.create_task(apply_control())
500
+ def finished(_):
501
+ nonlocal active_submissions
502
+ active_submissions -= 1
503
+ if not task.cancelled():
504
+ task.exception()
505
+ task.add_done_callback(finished)
506
+ receipt = await asyncio.shield(task)
507
+ return result(receipt, 202 if receipt["accepted"] else 403 if receipt["error_code"] == "forbidden" else 400)
508
+
509
+ @router.get("/interactive-chat/{share_id}/events", include_in_schema=False)
510
+ async def events(share_id: str, request: Request):
511
+ grant, _ = await auth(request, share_id)
512
+ async def stream():
513
+ try:
514
+ value = await snapshot(grant)
515
+ await auth(request, share_id)
516
+ revision = value["revision"]
517
+ yield "event: state\ndata: " + json.dumps(value, ensure_ascii=False) + "\n\n"
518
+ while not await request.is_disconnected():
519
+ changed = await wait_for_change(grant["session_id"], revision, 20)
520
+ await auth(request, share_id)
521
+ if not changed:
522
+ yield ": keepalive\n\n"
523
+ continue
524
+ # Coalesce bursty text notifications; this is not a poll.
525
+ await asyncio.sleep(1)
526
+ value = await snapshot(grant)
527
+ await auth(request, share_id)
528
+ revision = value["revision"]
529
+ yield "event: state\ndata: " + json.dumps(value, ensure_ascii=False) + "\n\n"
530
+ except (Unavailable, HTTPException):
531
+ yield "event: unavailable\ndata: {}\n\n"
532
+ return SharedChatStreamResponse(stream(), media_type="text/event-stream", headers={**HEADERS, "X-Accel-Buffering": "no"})
533
+
534
+ return router