@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.
Files changed (45) hide show
  1. package/README.md +20 -6
  2. package/package.json +1 -1
  3. package/server/VERSION +1 -1
  4. package/server/agent_server.py +5283 -698
  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 +90 -30
  12. package/server/codex_auth.py +119 -0
  13. package/server/codex_history_repair.py +79 -14
  14. package/server/codex_provider.py +160 -77
  15. package/server/codex_side_question.py +154 -57
  16. package/server/cursor_agent_client.py +27 -1
  17. package/server/cursor_api_key.py +64 -0
  18. package/server/cursor_history.py +219 -0
  19. package/server/cursor_provider_mcp.py +290 -0
  20. package/server/execution_durability.py +10 -6
  21. package/server/execution_preparation.py +6 -2
  22. package/server/install.sh +282 -78
  23. package/server/instances.sh +15 -0
  24. package/server/interactive_chat_native.py +4 -3
  25. package/server/interactive_chat_projection.py +6 -19
  26. package/server/interactive_chat_share_routes.py +71 -45
  27. package/server/interactive_chat_share_web.py +2 -2
  28. package/server/interactive_chat_shares.py +6 -11
  29. package/server/local_session_ownership.py +232 -0
  30. package/server/opencode_agent_client.py +685 -0
  31. package/server/provider_commands.py +579 -0
  32. package/server/provider_connections.py +665 -0
  33. package/server/provider_usage.py +241 -0
  34. package/server/public_chat_shares.py +60 -62
  35. package/server/public_chat_transcript.py +9 -31
  36. package/server/pyproject.toml +1 -0
  37. package/server/server_instances.py +931 -0
  38. package/server/shared_chat_video_stream.py +21 -2
  39. package/server/shared_chat_videos.py +54 -24
  40. package/server/side_questions.py +493 -10
  41. package/server/title_generation.py +4 -1
  42. package/server/uninstall.sh +95 -17
  43. package/server/update_preparation.py +4 -1
  44. package/server/update_runner.py +16 -0
  45. 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) <= 160 or name in {".", ".."}
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", 640)
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 1 <= byte_size <= MAX_UPLOAD_BYTES:
173
- raise ValidationError("Files must contain 1 byte to 8 MiB")
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(upload_ids) > 4 or len(set(str(i) for i in upload_ids)) != len(upload_ids)
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 at most four distinct uploads from this share")
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