@agentsdock/server 1.0.7-beta.5 → 1.0.8
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/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
"
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
322
|
+
opened = await opener(grant["session_id"], handle)
|
|
323
323
|
except (SharedVideoUnavailable, OSError, ValueError):
|
|
324
|
-
raise HTTPException(404, "Shared
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|