@agentsdock/server 1.0.7-beta.5 → 1.0.8-beta.2
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
|
@@ -23,8 +23,6 @@ Unavailable = PublicChatShareUnavailable
|
|
|
23
23
|
ValidationError = PublicChatShareValidationError
|
|
24
24
|
SHARE_ID = re.compile(r"interactive_[a-f0-9]{32}\Z")
|
|
25
25
|
UPLOAD_ID = re.compile(r"upload_[a-f0-9]{32}\Z")
|
|
26
|
-
MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
|
27
|
-
MAX_SHARE_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
28
26
|
MAX_PROMPT_BYTES = 64 * 1024
|
|
29
27
|
|
|
30
28
|
|
|
@@ -163,21 +161,18 @@ class InteractiveChatShareStore(PublicChatShareStore):
|
|
|
163
161
|
return {**self.metadata(row), "session_id": row["session_id"]}
|
|
164
162
|
|
|
165
163
|
def reserve_upload(self, share_id, browser_token, *, name, media_type, byte_size):
|
|
166
|
-
if (not isinstance(name, str) or not 1 <= len(name) <=
|
|
164
|
+
if (not isinstance(name, str) or not 1 <= len(name) <= 255 or name in {".", ".."}
|
|
167
165
|
or any(ord(c) < 32 or c in "/\\" for c in name)):
|
|
168
166
|
raise ValidationError("Use a simple filename without directories")
|
|
169
|
-
_utf8_size(name, "Filename",
|
|
167
|
+
_utf8_size(name, "Filename", 1024)
|
|
170
168
|
if not isinstance(media_type, str) or re.fullmatch(r"[a-zA-Z0-9!#$&^_.+-]+/[a-zA-Z0-9!#$&^_.+-]+", media_type) is None:
|
|
171
169
|
raise ValidationError("Invalid upload media type")
|
|
172
|
-
if type(byte_size) is not int or not
|
|
173
|
-
raise ValidationError("
|
|
170
|
+
if type(byte_size) is not int or not 0 <= byte_size <= (1 << 53) - 1:
|
|
171
|
+
raise ValidationError("Invalid file size")
|
|
174
172
|
upload_id = "upload_" + secrets.token_hex(16)
|
|
175
173
|
with self._connection(write=True) as db:
|
|
176
174
|
db.execute("BEGIN IMMEDIATE")
|
|
177
175
|
self._authorized(db, share_id, browser_token)
|
|
178
|
-
used = db.execute("SELECT COALESCE(SUM(byte_size),0) FROM interactive_uploads WHERE share_id=?", (share_id,)).fetchone()[0]
|
|
179
|
-
if used + byte_size > MAX_SHARE_UPLOAD_BYTES:
|
|
180
|
-
raise ValidationError("This share has reached its 64 MiB upload storage bound")
|
|
181
176
|
db.execute("INSERT INTO interactive_uploads VALUES(?,?,?,?,?,?,?)",
|
|
182
177
|
(upload_id, share_id, name, media_type, byte_size, None, self._now()))
|
|
183
178
|
return upload_id
|
|
@@ -197,9 +192,9 @@ class InteractiveChatShareStore(PublicChatShareStore):
|
|
|
197
192
|
db.execute("DELETE FROM interactive_uploads WHERE id=? AND share_id=? AND private_ref IS NULL", (upload_id, share_id))
|
|
198
193
|
|
|
199
194
|
def upload_refs(self, share_id, browser_token, upload_ids):
|
|
200
|
-
if (not isinstance(upload_ids, list) or len(
|
|
195
|
+
if (not isinstance(upload_ids, list) or len(set(str(i) for i in upload_ids)) != len(upload_ids)
|
|
201
196
|
or any(not isinstance(i, str) or UPLOAD_ID.fullmatch(i) is None for i in upload_ids)):
|
|
202
|
-
raise ValidationError("Select
|
|
197
|
+
raise ValidationError("Select distinct uploads from this share")
|
|
203
198
|
with self._connection() as db:
|
|
204
199
|
self._authorized(db, share_id, browser_token)
|
|
205
200
|
refs = []
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Same-user provider-history ownership, compatible with main's instance registry.
|
|
2
|
+
|
|
3
|
+
Backported from main's server_instances.py (8fcfeec) without service installation,
|
|
4
|
+
removal, or manager commands. Discovery reads only; the import guard creates a
|
|
5
|
+
private shared lock. Keep registry/path/ownership semantics aligned with main.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import fcntl
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
INSTANCE_PROTOCOL = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def instance_name(value: str) -> str:
|
|
24
|
+
if not isinstance(value, str) or not re.fullmatch(r"[a-z][a-z0-9-]{0,31}", value):
|
|
25
|
+
raise ValueError("Instance names must be 1–32 lowercase letters, digits or dashes, starting with a letter.")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def service_name(name: str) -> str:
|
|
30
|
+
return "agents-server" + ("" if instance_name(name) == "default" else "-" + name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def launchd_label(name: str) -> str:
|
|
34
|
+
return "com.agentsdock.server" + ("" if instance_name(name) == "default" else "." + name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Instance:
|
|
39
|
+
name: str
|
|
40
|
+
home: Path
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
instance_name(self.name)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def config(self) -> Path:
|
|
47
|
+
return self.home / ".config" / ("agents-server" if self.name == "default" else f"agents-server-instances/{self.name}")
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def state(self) -> Path:
|
|
51
|
+
return self.home / (".agentsdock" if self.name == "default" else f".agentsdock-instances/{self.name}")
|
|
52
|
+
|
|
53
|
+
def service_file(self, platform: str = sys.platform) -> Path:
|
|
54
|
+
if platform == "darwin":
|
|
55
|
+
return self.home / "Library/LaunchAgents" / (launchd_label(self.name) + ".plist")
|
|
56
|
+
if platform.startswith("linux"):
|
|
57
|
+
return self.home / ".config/systemd/user" / (service_name(self.name) + ".service")
|
|
58
|
+
raise ValueError("Only macOS and Linux user services are supported.")
|
|
59
|
+
|
|
60
|
+
def check_path(path: Path, home: Path) -> None:
|
|
61
|
+
"""Reject symlinks, foreign owners and unsafe writable parents before mutation."""
|
|
62
|
+
path.relative_to(home)
|
|
63
|
+
if path == home:
|
|
64
|
+
raise ValueError("Refusing a home-directory target.")
|
|
65
|
+
for parent in (path, *path.parents):
|
|
66
|
+
if parent == home.parent:
|
|
67
|
+
break
|
|
68
|
+
try:
|
|
69
|
+
info = parent.lstat()
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
continue
|
|
72
|
+
if stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
|
|
73
|
+
raise ValueError(f"Unsafe managed path (link, owner or permissions): {parent}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def read_regular(path: Path, *, max_bytes: int = 1024 * 1024) -> bytes:
|
|
77
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
78
|
+
with os.fdopen(fd, "rb") as handle:
|
|
79
|
+
info = os.fstat(handle.fileno())
|
|
80
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o022:
|
|
81
|
+
raise ValueError(f"Unsafe managed file: {path}")
|
|
82
|
+
data = handle.read(max_bytes + 1)
|
|
83
|
+
if len(data) > max_bytes:
|
|
84
|
+
raise ValueError(f"Managed file too large: {path}")
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_config(instance: Instance) -> dict[str, str]:
|
|
89
|
+
try:
|
|
90
|
+
text = read_regular(instance.config / "env").decode()
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
return {}
|
|
93
|
+
result = {}
|
|
94
|
+
for line in text.splitlines():
|
|
95
|
+
key, separator, value = line.partition("=")
|
|
96
|
+
if separator and re.fullmatch(r"[A-Z_]+", key):
|
|
97
|
+
values = shlex.split(value, comments=False)
|
|
98
|
+
result[key] = values[0] if len(values) == 1 else value
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Registry:
|
|
103
|
+
def __init__(self, home: Path | None = None):
|
|
104
|
+
self.home = (home or Path.home()).resolve()
|
|
105
|
+
self.root = self.home / ".config/agents-server-manager"
|
|
106
|
+
self.file = self.root / "instances.json"
|
|
107
|
+
|
|
108
|
+
def records(self) -> dict:
|
|
109
|
+
check_path(self.file, self.home)
|
|
110
|
+
try:
|
|
111
|
+
value = json.loads(read_regular(self.file))
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
return {}
|
|
114
|
+
if not isinstance(value, dict) or value.get("version") != INSTANCE_PROTOCOL or not isinstance(value.get("instances"), dict):
|
|
115
|
+
raise ValueError("Invalid instance registry; refusing to guess removal targets.")
|
|
116
|
+
for name, record in value["instances"].items():
|
|
117
|
+
instance_name(name)
|
|
118
|
+
if not isinstance(record, dict) or not {"status"} <= set(record) <= {"status", "port"} or record["status"] not in {"pending", "installed", "removed", "failed"}:
|
|
119
|
+
raise ValueError("Invalid instance record.")
|
|
120
|
+
if "port" in record and (type(record["port"]) is not int or not 1 <= record["port"] <= 65535):
|
|
121
|
+
raise ValueError("Invalid saved instance port.")
|
|
122
|
+
return value["instances"]
|
|
123
|
+
|
|
124
|
+
def instances(self, include_removed: bool = False) -> list[Instance]:
|
|
125
|
+
records = self.records()
|
|
126
|
+
names = {name for name, record in records.items() if include_removed or record["status"] != "removed"}
|
|
127
|
+
default = Instance("default", self.home)
|
|
128
|
+
if default.service_file().exists() or (default.config / "env").exists():
|
|
129
|
+
names.add("default")
|
|
130
|
+
# Direct --instance installs are discovered only in fixed owned roots.
|
|
131
|
+
configs = self.home / ".config/agents-server-instances"
|
|
132
|
+
check_path(configs, self.home)
|
|
133
|
+
if configs.exists():
|
|
134
|
+
for child in configs.iterdir():
|
|
135
|
+
instance_name(child.name)
|
|
136
|
+
check_path(child, self.home)
|
|
137
|
+
if (child / "env").is_file():
|
|
138
|
+
names.add(child.name)
|
|
139
|
+
return [Instance(name, self.home) for name in sorted(names, key=lambda name: (name != "default", name))]
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def exclusive_lock(path: Path):
|
|
143
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
144
|
+
try:
|
|
145
|
+
info = os.fstat(fd)
|
|
146
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_mode & 0o077:
|
|
147
|
+
raise ValueError(f"Unsafe lock file: {path}")
|
|
148
|
+
try:
|
|
149
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
150
|
+
except BlockingIOError as exc:
|
|
151
|
+
raise ValueError(f"Another process owns {path}; no changes made.") from exc
|
|
152
|
+
yield
|
|
153
|
+
finally:
|
|
154
|
+
os.close(fd) # Never unlink: waiters must keep the same lock inode.
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
PROVIDER_ID_FIELDS = {
|
|
158
|
+
"claude": "claude_session_id", "codex": "codex_thread_id",
|
|
159
|
+
"cursor": "cursor_session_id", "opencode": "opencode_session_id",
|
|
160
|
+
}
|
|
161
|
+
MAX_IMPORT_INDEX_BYTES = 64 * 1024 * 1024
|
|
162
|
+
MAX_IMPORT_INDEX_TOTAL_BYTES = 128 * 1024 * 1024
|
|
163
|
+
MAX_IMPORT_INSTANCES = 256
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def provider_session_keys(session: dict, default_backend: str = "claude") -> set[tuple[str, str]]:
|
|
167
|
+
"""Include parked provider identities as well as the active legacy ID."""
|
|
168
|
+
backend = str(session.get("backend") or default_backend).strip().lower()
|
|
169
|
+
keys = set()
|
|
170
|
+
for provider, field in PROVIDER_ID_FIELDS.items():
|
|
171
|
+
value = session.get(field)
|
|
172
|
+
if not value and provider == backend:
|
|
173
|
+
value = session.get("provider_session_id") or session.get("session_id")
|
|
174
|
+
if isinstance(value, str) and value.strip():
|
|
175
|
+
keys.add((provider, value.strip()))
|
|
176
|
+
return keys
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def other_instance_provider_keys(current_state: Path, *, registry: Registry | None = None) -> set[tuple[str, str]]:
|
|
180
|
+
"""Read installed instances' indexes, never transcripts or another service API.
|
|
181
|
+
|
|
182
|
+
Stopped services and archived chats still own their provider IDs. Removed
|
|
183
|
+
instances with preserved history do not. No registry/configuration writes
|
|
184
|
+
occur during discovery. Bad or unsafe indexes fail closed, not silently open.
|
|
185
|
+
"""
|
|
186
|
+
registry = registry or Registry()
|
|
187
|
+
instances = registry.instances()
|
|
188
|
+
if len(instances) > MAX_IMPORT_INSTANCES:
|
|
189
|
+
raise ValueError("Too many local instances to verify import ownership.")
|
|
190
|
+
keys = set()
|
|
191
|
+
remaining = MAX_IMPORT_INDEX_TOTAL_BYTES
|
|
192
|
+
current_state = current_state.resolve()
|
|
193
|
+
for instance in instances:
|
|
194
|
+
check_path(instance.config, registry.home)
|
|
195
|
+
config = read_config(instance)
|
|
196
|
+
configured_state = (config.get("AGENTSDOCK_STATE_DIR")
|
|
197
|
+
or config.get("AGENTS_SERVER_STATE_DIR")
|
|
198
|
+
or config.get("ZENITHBOT_AGENT_DIR"))
|
|
199
|
+
state = Path(configured_state) if configured_state else instance.state
|
|
200
|
+
if instance.name != "default" and state != instance.state:
|
|
201
|
+
raise ValueError(f"{instance.name}: unexpected state binding.")
|
|
202
|
+
if not state.is_absolute() or ".." in state.parts:
|
|
203
|
+
raise ValueError(f"{instance.name}: unsafe state binding.")
|
|
204
|
+
if state == current_state:
|
|
205
|
+
continue # The current process has the authoritative in-memory map.
|
|
206
|
+
check_path(state / "sessions.json", registry.home)
|
|
207
|
+
try:
|
|
208
|
+
data = read_regular(state / "sessions.json", max_bytes=min(MAX_IMPORT_INDEX_BYTES, remaining))
|
|
209
|
+
except FileNotFoundError:
|
|
210
|
+
continue # A registered/new instance may not have any chats yet.
|
|
211
|
+
remaining -= len(data)
|
|
212
|
+
sessions = json.loads(data)
|
|
213
|
+
if not isinstance(sessions, dict) or any(not isinstance(row, dict) for row in sessions.values()):
|
|
214
|
+
raise ValueError(f"{instance.name}: invalid sessions index.")
|
|
215
|
+
default_backend = config.get("AGENTSDOCK_BACKEND") or config.get("ZENITHBOT_BACKEND") or "claude"
|
|
216
|
+
for session in sessions.values():
|
|
217
|
+
keys.update(provider_session_keys(session, default_backend))
|
|
218
|
+
return keys
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@contextmanager
|
|
222
|
+
def history_import_lock(*, registry: Registry | None = None):
|
|
223
|
+
"""Serialize cooperating local imports until their index writes have landed.
|
|
224
|
+
|
|
225
|
+
Separate from service-management locks; nonblocking and OS-released on exit
|
|
226
|
+
or crash. Older servers do not participate, so this is not a provider lock.
|
|
227
|
+
"""
|
|
228
|
+
registry = registry or Registry()
|
|
229
|
+
check_path(registry.root, registry.home)
|
|
230
|
+
registry.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
231
|
+
with exclusive_lock(registry.root / "history-import.lock"):
|
|
232
|
+
yield
|