@agentsdock/server 1.0.7-beta.5 → 1.0.7

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 (39) hide show
  1. package/README.md +21 -7
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +4913 -660
  5. package/server/agentsdock_chats.py +2 -1
  6. package/server/chat_mailbox.py +23 -6
  7. package/server/claude_history_repair.py +45 -4
  8. package/server/claude_model_catalog.py +200 -0
  9. package/server/claude_sdk_client.py +63 -24
  10. package/server/claude_side_question.py +1 -8
  11. package/server/codex_app_server.py +78 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_provider.py +1 -1
  14. package/server/codex_side_question.py +154 -57
  15. package/server/cursor_agent_client.py +27 -1
  16. package/server/cursor_history.py +219 -0
  17. package/server/cursor_provider_mcp.py +290 -0
  18. package/server/execution_durability.py +10 -6
  19. package/server/execution_preparation.py +6 -2
  20. package/server/install.sh +281 -78
  21. package/server/instances.sh +15 -0
  22. package/server/interactive_chat_native.py +4 -3
  23. package/server/interactive_chat_projection.py +6 -19
  24. package/server/interactive_chat_share_routes.py +71 -45
  25. package/server/interactive_chat_share_web.py +2 -2
  26. package/server/interactive_chat_shares.py +6 -11
  27. package/server/local_session_ownership.py +232 -0
  28. package/server/opencode_agent_client.py +653 -0
  29. package/server/provider_commands.py +579 -0
  30. package/server/provider_usage.py +241 -0
  31. package/server/public_chat_shares.py +60 -62
  32. package/server/public_chat_transcript.py +9 -31
  33. package/server/server_instances.py +931 -0
  34. package/server/shared_chat_video_stream.py +21 -2
  35. package/server/shared_chat_videos.py +54 -24
  36. package/server/side_questions.py +493 -10
  37. package/server/uninstall.sh +95 -17
  38. package/server/update_preparation.py +4 -1
  39. package/server/update_runner.py +16 -0
@@ -2,7 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import math
5
- from shared_chat_videos import normalize_shared_chat_videos
5
+ from shared_chat_videos import normalize_shared_chat_videos, normalize_shared_chat_files
6
6
 
7
7
  PRIVATE_FIELDS = frozenset({
8
8
  "cwd", "path", "source_path", "root", "file", "files", "artifact", "artifacts",
@@ -57,10 +57,11 @@ def shared_events(events, session_id):
57
57
  if not isinstance(event, dict) or event.get("session_id") not in (None, "", session_id):
58
58
  raise ValueError("Shared chat event ownership is invalid")
59
59
  kind = str(event.get("type") or "")
60
- # Only explicitly projected video descriptors cross the media boundary.
60
+ # Only explicitly projected attachment descriptors cross the media boundary.
61
61
  # Tool output is chat content, not direct filesystem access.
62
62
  videos = normalize_shared_chat_videos(event.get("shared_videos", []))
63
- if kind.startswith(("file_", "artifact_", "workspace_", "terminal_")) and not (kind == "artifact_created" and len(videos) == 1):
63
+ files = normalize_shared_chat_files(event.get("shared_files", []))
64
+ if kind.startswith(("file_", "artifact_", "workspace_", "terminal_")) and not (kind == "artifact_created" and len(videos) + len(files) == 1):
64
65
  continue
65
66
  output.append(shared_native_value(event))
66
67
  return output
@@ -1,4 +1,4 @@
1
- """Incremental, bounded text-only chat projection; no server imports or timers."""
1
+ """Incremental text-only chat projection; no server imports or timers."""
2
2
  from __future__ import annotations
3
3
 
4
4
  import json
@@ -9,8 +9,7 @@ import threading
9
9
  from typing import Callable
10
10
 
11
11
  from public_chat_transcript import (
12
- MAX_LINE_BYTES, MAX_LOG_BYTES, MAX_MESSAGE_BYTES, MAX_MESSAGES,
13
- MAX_RECORDS, MAX_TEXT_BYTES, PublicTranscriptError,
12
+ PublicTranscriptError,
14
13
  _is_goal_followup, _public_timestamp,
15
14
  )
16
15
 
@@ -31,8 +30,6 @@ class IncrementalChatTranscript:
31
30
  self._stamp = None
32
31
  self._offset = 0
33
32
  self._revision = 0
34
- self._records = 0
35
- self._total_text = 0
36
33
  self._messages: list[dict] = []
37
34
  self._outputs: dict[str, list[str]] = {}
38
35
  self._failed = False
@@ -72,8 +69,6 @@ class IncrementalChatTranscript:
72
69
  if not stat.S_ISREG(initial.st_mode):
73
70
  raise PublicTranscriptError("Chat history is unavailable")
74
71
  stamp = self._file_stamp(initial)
75
- if initial.st_size > MAX_LOG_BYTES:
76
- raise PublicTranscriptError("Chat exceeds the 64 MiB sharing limit")
77
72
  if self._stamp is not None:
78
73
  if (stamp[:2] != self._stamp[:2] or stamp[2] < self._stamp[2]
79
74
  or stamp[2] == self._stamp[2] and stamp[3:] != self._stamp[3:]):
@@ -82,15 +77,12 @@ class IncrementalChatTranscript:
82
77
  return self._snapshot()
83
78
  stream.seek(self._offset)
84
79
  while self._offset < initial.st_size:
85
- line = stream.readline(min(MAX_LINE_BYTES + 1, initial.st_size - self._offset))
86
- if len(line) > MAX_LINE_BYTES:
87
- raise PublicTranscriptError("A chat record exceeds the sharing limit")
80
+ # Consume complete records through the captured file boundary.
81
+ # Large tool payloads are private, not a reason to reject a chat.
82
+ line = stream.readline(initial.st_size - self._offset)
88
83
  if not line.endswith(b"\n"):
89
84
  break # Re-read this uncommitted tail only after it grows.
90
85
  self._offset += len(line)
91
- self._records += 1
92
- if self._records > MAX_RECORDS:
93
- raise PublicTranscriptError("Chat exceeds the sharing record limit")
94
86
  try:
95
87
  raw = json.loads(line)
96
88
  except (ValueError, UnicodeError, RecursionError) as exc:
@@ -130,20 +122,15 @@ class IncrementalChatTranscript:
130
122
  if not isinstance(text, str) or not text.strip():
131
123
  return
132
124
  try:
133
- size = len(text.encode("utf-8"))
125
+ text.encode("utf-8")
134
126
  except UnicodeEncodeError as exc:
135
127
  raise PublicTranscriptError("Chat history contains invalid Unicode text") from exc
136
- if size > MAX_MESSAGE_BYTES:
137
- raise PublicTranscriptError("A message exceeds the 256 KiB sharing limit")
138
128
  normalized = " ".join(text.split())
139
129
  previous = self._outputs.setdefault(run, [])
140
130
  if kind == "turn_finished" and (normalized in previous or normalized == " ".join(previous)):
141
131
  return
142
132
  if kind == "assistant_text":
143
133
  previous.append(normalized)
144
- self._total_text += size
145
- if self._total_text > MAX_TEXT_BYTES or len(self._messages) >= MAX_MESSAGES:
146
- raise PublicTranscriptError("Chat exceeds the 1,000-message / 2 MiB sharing limit")
147
134
  message = {"role": role, "text": text}
148
135
  timestamp = _public_timestamp(event.get("ts"))
149
136
  if timestamp is not None:
@@ -7,6 +7,7 @@ import json
7
7
  from pathlib import Path
8
8
  import re
9
9
  import threading
10
+ import tempfile
10
11
  from urllib.parse import unquote
11
12
  import weakref
12
13
 
@@ -15,19 +16,19 @@ from fastapi.responses import JSONResponse, Response, StreamingResponse
15
16
 
16
17
  from interactive_chat_shares import (
17
18
  InteractiveChatShareStore, Unavailable, ValidationError, Conflict, SHARE_ID,
18
- MAX_PROMPT_BYTES, MAX_UPLOAD_BYTES, csrf_token, _utf8_size,
19
+ MAX_PROMPT_BYTES, csrf_token, _utf8_size,
19
20
  )
20
21
  from public_chat_share_routes import chat_share_origin
21
22
  import interactive_chat_share_web as web
22
23
  from interactive_chat_controls import ChatControlError
23
- from shared_chat_video_stream import SharedVideoResponse
24
- from shared_chat_videos import VIDEO_ID, SharedVideoUnavailable
24
+ from shared_chat_video_stream import SharedVideoResponse, SharedFileResponse, SharedImageResponse
25
+ from shared_chat_videos import VIDEO_ID, SHARED_FILE_ID, SharedVideoUnavailable
25
26
 
26
27
  WARNING = (
27
28
  "Trusted full chat control: this person can read this chat, send or steer prompts, upload files, "
28
29
  "stop work, manage the queue and goals, change model/permission settings, respond to approvals, "
29
30
  "and create or run persistent scheduled jobs for this chat. "
30
- "Attached and published videos are playable and can be saved by viewers. "
31
+ "Sent and published attachments can be downloaded; images and videos can be viewed. "
31
32
  "No general file browsing, terminal, other chats, or server administration are shared. "
32
33
  "The existing agent retains its normal tools and context, so they can ask it to use tools "
33
34
  "or return sensitive information. This is not a sandbox. Share the URL and reusable access token separately. "
@@ -46,12 +47,13 @@ HTTP_COOKIE = "AgentsDock-Chat"
46
47
  HEADERS = {
47
48
  "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
48
49
  "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
+ "Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
50
51
  }
51
52
 
52
53
 
53
54
  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
+ load_transcript, submit_prompt, save_upload, wait_for_change, chat_control=None, open_video=None,
56
+ open_file=None, max_upload_bytes=25 * 1024 * 1024 * 1024):
55
57
  """Callbacks are scoped by the durable share ledger, not browser identities.
56
58
 
57
59
  A durable request ledger prevents repeated callback execution; ambiguous
@@ -66,7 +68,6 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
66
68
  streams = 0
67
69
  active_workers = 0
68
70
  active_submissions = 0
69
- active_uploads = 0
70
71
 
71
72
  class SharedChatStreamResponse(StreamingResponse):
72
73
  async def __call__(self, scope, receive, send):
@@ -227,11 +228,9 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
227
228
  messages.append(message)
228
229
  projected = {"revision": value["revision"], "busy": value["busy"], "messages": messages, "title": grant["title"]}
229
230
  try:
230
- size = len(json.dumps(projected, ensure_ascii=False, allow_nan=False).encode("utf-8"))
231
+ json.dumps(projected, ensure_ascii=False, allow_nan=False)
231
232
  except (ValueError, UnicodeError):
232
233
  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
234
  return projected
236
235
 
237
236
  @router.post("/api/admin/interactive-chat-shares/{session_id}")
@@ -313,25 +312,35 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
313
312
  await auth(request, share_id)
314
313
  return result({**value, "csrf": csrf_token(token)})
315
314
 
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):
315
+ async def attachment_response(share_id, handle, request, *, download):
318
316
  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")
317
+ video = VIDEO_ID.fullmatch(handle) is not None
318
+ opener = open_video if video else open_file
319
+ if opener is None or len(handle) > 1024 or not (video or SHARED_FILE_ID.fullmatch(handle)):
320
+ raise HTTPException(404, "Shared attachment is unavailable")
321
321
  try:
322
- opened = await open_video(grant["session_id"], handle)
322
+ opened = await opener(grant["session_id"], handle)
323
323
  except (SharedVideoUnavailable, OSError, ValueError):
324
- raise HTTPException(404, "Shared video is unavailable") from None
324
+ raise HTTPException(404, "Shared attachment is unavailable") from None
325
325
  async def reauthorize():
326
326
  await auth(request, share_id)
327
327
  try:
328
328
  # Construction owns the descriptor even if its pinned revision
329
329
  # changed between resolution and response initialization.
330
- return SharedVideoResponse(opened["file_fd"], byte_size=opened["size"],
330
+ response_class = SharedFileResponse if download else SharedVideoResponse if video else SharedImageResponse
331
+ return response_class(opened["file_fd"], byte_size=opened["size"],
331
332
  content_type=opened["content_type"], filename=opened["filename"], request=request,
332
333
  reauthorize=reauthorize, extra_headers=HEADERS, file_revision=opened.get("file_revision"))
333
334
  except (OSError, ValueError):
334
- raise HTTPException(404, "Shared video is unavailable") from None
335
+ raise HTTPException(404, "Shared attachment is unavailable") from None
336
+
337
+ @router.api_route("/interactive-chat/{share_id}/media/{handle}", methods=["GET", "HEAD"], include_in_schema=False)
338
+ async def video(share_id: str, handle: str, request: Request):
339
+ return await attachment_response(share_id, handle, request, download=False)
340
+
341
+ @router.api_route("/interactive-chat/{share_id}/files/{handle}", methods=["GET", "HEAD"], include_in_schema=False)
342
+ async def download_file(share_id: str, handle: str, request: Request):
343
+ return await attachment_response(share_id, handle, request, download=True)
335
344
 
336
345
  @router.post("/interactive-chat/{share_id}/prompts", include_in_schema=False)
337
346
  async def prompt(share_id: str, request: Request):
@@ -389,42 +398,55 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
389
398
 
390
399
  @router.post("/interactive-chat/{share_id}/uploads", include_in_schema=False)
391
400
  async def upload(share_id: str, request: Request):
392
- nonlocal active_uploads
393
401
  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
402
  task = None
403
+ data = tempfile.SpooledTemporaryFile(max_size=1024 * 1024, mode="w+b")
398
404
  try:
399
405
  name = unquote(request.headers.get("x-chat-filename", ""))
400
406
  media_type = request.headers.get("content-type", "application/octet-stream").split(";")[0]
401
- data = await body_bytes(request, MAX_UPLOAD_BYTES, timeout=30)
407
+ size = 0
408
+ async for chunk in request.stream():
409
+ size += len(chunk)
410
+ if size > max_upload_bytes:
411
+ raise HTTPException(413, "File exceeds this server's upload size limit")
412
+ writing = asyncio.create_task(asyncio.to_thread(data.write, chunk))
413
+ try:
414
+ await asyncio.shield(writing)
415
+ except asyncio.CancelledError:
416
+ # Keep the temporary file owned until its disk write ends.
417
+ while not writing.done():
418
+ try:
419
+ await asyncio.shield(writing)
420
+ except asyncio.CancelledError:
421
+ continue
422
+ writing.result()
423
+ raise
424
+ data.seek(0)
402
425
  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)}
426
+ try:
427
+ async with share_lock(share_id):
428
+ grant, token = await auth(request, share_id, write=True)
429
+ try:
430
+ upload_id = await worker(lambda: store().reserve_upload(share_id, token, name=name, media_type=media_type, byte_size=size))
431
+ except ValidationError as exc:
432
+ raise HTTPException(400, str(exc)) from None
433
+ try:
434
+ private_ref = await save_upload(grant["session_id"], share_id, name, media_type, data)
435
+ await worker(lambda: store().complete_upload(share_id, token, upload_id, private_ref))
436
+ except Exception:
437
+ raise HTTPException(503, "File upload could not be confirmed; inspect this chat before uploading again") from None
438
+ return {"id": upload_id, "name": name, "media_type": media_type, "byte_size": size}
439
+ finally:
440
+ data.close()
417
441
  task = asyncio.create_task(commit_upload())
418
442
  def finished(_):
419
- nonlocal active_uploads
420
- active_uploads -= 1
421
443
  if not task.cancelled():
422
444
  task.exception()
423
445
  task.add_done_callback(finished)
424
446
  return result(await asyncio.shield(task), 201)
425
447
  finally:
426
448
  if task is None:
427
- active_uploads -= 1
449
+ data.close()
428
450
 
429
451
  @router.get("/interactive-chat/{share_id}/controls", include_in_schema=False)
430
452
  async def controls(share_id: str, request: Request):
@@ -457,8 +479,7 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
457
479
  grant, _ = await auth(request, share_id, write=True)
458
480
  try:
459
481
  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")
482
+ json.dumps(public_result, ensure_ascii=False, allow_nan=False)
462
483
  except ChatControlError as exc:
463
484
  raise HTTPException(403 if exc.code == "forbidden" else 400, "Invalid or unavailable chat read") from None
464
485
  except Exception:
@@ -480,8 +501,7 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
480
501
  if not isinstance(accepted, dict) or accepted.get("accepted") is not True:
481
502
  raise ValueError("Invalid control receipt")
482
503
  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")
504
+ json.dumps(public_result, ensure_ascii=False, allow_nan=False)
485
505
  receipt = {"accepted": True, "action": action, "request_id": request_id, "result": public_result}
486
506
  await worker(lambda: store().accept_submission(share_id, request_id, receipt))
487
507
  except ChatControlError as exc:
@@ -527,8 +547,14 @@ def create_interactive_chat_share_router(*, storage_root, authorize, session_exi
527
547
  await auth(request, share_id)
528
548
  revision = value["revision"]
529
549
  yield "event: state\ndata: " + json.dumps(value, ensure_ascii=False) + "\n\n"
530
- except (Unavailable, HTTPException):
550
+ except Unavailable:
531
551
  yield "event: unavailable\ndata: {}\n\n"
552
+ except HTTPException as exc:
553
+ if exc.status_code in {401, 403, 404, 410}:
554
+ yield "event: unavailable\ndata: {}\n\n"
555
+ # A failed snapshot or busy ledger does not revoke access.
556
+ # End the response so EventSource's existing reconnect path
557
+ # can obtain fresh state once the server is available again.
532
558
  return SharedChatStreamResponse(stream(), media_type="text/event-stream", headers={**HEADERS, "X-Accel-Buffering": "no"})
533
559
 
534
560
  return router