@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
@@ -0,0 +1,290 @@
1
+ """Run-scoped Cursor CLI MCP transport; never edits native login or permissions.
2
+
3
+ Native print-mode sessions, login and cwd remain unchanged. A private per-run
4
+ configuration adds only the exact internal MCP tool permission, retaining
5
+ native permissions and explicit denies. A bounded loopback broker keeps
6
+ helper authority in AgentsServer and expires when the run ends.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import hmac
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ import secrets
17
+ import socket
18
+ import stat
19
+ import itertools
20
+ import sys
21
+ import tempfile
22
+ import subprocess
23
+ from typing import Any
24
+
25
+ PLUGIN_NAME = "agentsdock-internal-9f3a2c71"
26
+ MCP_SERVER_KEY = "provider"
27
+ MCP_NAME = "plugin-" + PLUGIN_NAME + "-" + MCP_SERVER_KEY
28
+ MCP_ALLOW = "Mcp(" + MCP_NAME + ":run)"
29
+ MAX_MESSAGE = 2 * 1024 * 1024
30
+ ENV_PORT = "AGENTSDOCK_CURSOR_TOOL_PORT"
31
+ ENV_SECRET = "AGENTSDOCK_CURSOR_TOOL_SECRET"
32
+
33
+
34
+ class CursorToolBroker:
35
+ """One run's authenticated IPC endpoint, with bounded in-flight work."""
36
+
37
+ def __init__(self, definition: dict, execute: Any):
38
+ self.definition = definition
39
+ self.execute = execute
40
+ self.secret = secrets.token_urlsafe(48)
41
+ self.server = None
42
+ self.tasks: set[asyncio.Task] = set()
43
+ self.closed = False
44
+
45
+ async def start(self) -> dict[str, str]:
46
+ self.server = await asyncio.start_server(
47
+ self.handle, "127.0.0.1", 0, limit=MAX_MESSAGE + 1,
48
+ )
49
+ return {ENV_PORT: str(self.server.sockets[0].getsockname()[1]),
50
+ ENV_SECRET: self.secret}
51
+
52
+ async def close(self) -> None:
53
+ self.closed = True
54
+ if self.server:
55
+ self.server.close()
56
+ tasks = list(self.tasks)
57
+ for task in tasks:
58
+ task.cancel()
59
+ if tasks:
60
+ await asyncio.gather(*tasks, return_exceptions=True)
61
+ if self.server:
62
+ await self.server.wait_closed()
63
+
64
+ async def handle(self, reader, writer) -> None:
65
+ task = asyncio.current_task()
66
+ if self.closed or len(self.tasks) >= 8:
67
+ writer.close()
68
+ return
69
+ self.tasks.add(task)
70
+ response = None
71
+ try:
72
+ line = await asyncio.wait_for(reader.readline(), 5)
73
+ if not line.endswith(b"\n") or len(line) > MAX_MESSAGE:
74
+ raise ValueError("invalid provider IPC frame")
75
+ request = json.loads(line)
76
+ if (not isinstance(request, dict) or self.closed
77
+ or not isinstance(request.get("secret"), str)
78
+ or not hmac.compare_digest(request["secret"], self.secret)):
79
+ raise ValueError("provider IPC is forbidden")
80
+ if request.get("method") == "definition":
81
+ result = self.definition
82
+ elif request.get("method") == "call":
83
+ key = request.get("key")
84
+ if not isinstance(key, str) or not 1 <= len(key) <= 160:
85
+ raise ValueError("invalid provider call identity")
86
+ # The callback rechecks the actual live process/run/capability.
87
+ text, error = await self.execute(request.get("arguments"), key)
88
+ result = {"content": [{"type": "text", "text": text}], "isError": error}
89
+ else:
90
+ raise ValueError("unsupported provider IPC method")
91
+ response = {"result": result}
92
+ except asyncio.CancelledError:
93
+ raise
94
+ except Exception:
95
+ # Never reflect bearer material or an untrusted request in errors.
96
+ response = {"error": "Provider tool unavailable or authorization expired."}
97
+ finally:
98
+ if response is not None and not self.closed:
99
+ data = (json.dumps(response) + "\n").encode()
100
+ if len(data) <= MAX_MESSAGE:
101
+ with contextlib.suppress(Exception):
102
+ writer.write(data)
103
+ await asyncio.wait_for(writer.drain(), 5)
104
+ writer.close()
105
+ with contextlib.suppress(Exception):
106
+ await asyncio.wait_for(writer.wait_closed(), 5)
107
+ self.tasks.discard(task)
108
+
109
+
110
+ def broker_request(method: str, **payload) -> Any:
111
+ request = {"method": method, "secret": os.environ[ENV_SECRET], **payload}
112
+ data = (json.dumps(request) + "\n").encode()
113
+ if len(data) > MAX_MESSAGE:
114
+ raise ValueError("provider request exceeds limit")
115
+ with socket.create_connection(("127.0.0.1", int(os.environ[ENV_PORT])), timeout=5) as conn:
116
+ conn.settimeout(240)
117
+ conn.sendall(data)
118
+ with conn.makefile("rb") as stream:
119
+ line = stream.readline(MAX_MESSAGE + 1)
120
+ if len(line) > MAX_MESSAGE or not line.endswith(b"\n"):
121
+ raise ValueError("invalid provider response")
122
+ response = json.loads(line)
123
+ if "error" in response:
124
+ raise ValueError(response["error"])
125
+ return response["result"]
126
+
127
+
128
+ def mcp_main() -> None:
129
+ """Native Cursor launches this stdio MCP, with a private per-run env."""
130
+ connection_id = secrets.token_hex(16)
131
+ while True:
132
+ line = sys.stdin.buffer.readline(MAX_MESSAGE + 1)
133
+ if not line:
134
+ return
135
+ if len(line) > MAX_MESSAGE or not line.endswith(b"\n"):
136
+ raise ValueError("MCP frame exceeds limit")
137
+ message = json.loads(line)
138
+ if "id" not in message:
139
+ continue
140
+ method, params = message.get("method"), message.get("params") or {}
141
+ error = None
142
+ try:
143
+ if method == "initialize":
144
+ result = {"protocolVersion": params.get("protocolVersion", "2024-11-05"),
145
+ "capabilities": {"tools": {}},
146
+ "serverInfo": {"name": MCP_NAME, "version": "1"}}
147
+ elif method == "ping":
148
+ result = {}
149
+ elif method == "tools/list":
150
+ result = {"tools": [broker_request("definition")]}
151
+ elif method == "tools/call" and params.get("name") == "run":
152
+ result = broker_request("call", arguments=params.get("arguments"),
153
+ key=connection_id + ":" + str(message["id"]))
154
+ else:
155
+ error = {"code": -32601, "message": "Method not found"}
156
+ except Exception:
157
+ result = {"content": [{"type": "text", "text":
158
+ "Provider tool unavailable or authorization expired. Do not resend an accepted message."}],
159
+ "isError": True}
160
+ response = {"jsonrpc": "2.0", "id": message["id"],
161
+ **({"error": error} if error else {"result": result})}
162
+ print(json.dumps(response), flush=True)
163
+
164
+
165
+
166
+ def read_config(path: Path) -> dict:
167
+ # A repository-controlled FIFO/device must not hang configuration startup.
168
+ try:
169
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
170
+ except FileNotFoundError:
171
+ return {}
172
+ with os.fdopen(fd, "rb") as source:
173
+ info = os.fstat(source.fileno())
174
+ if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_MESSAGE:
175
+ raise ValueError("Cursor configuration must be a bounded regular file")
176
+ data = source.read(MAX_MESSAGE + 1)
177
+ if len(data) > MAX_MESSAGE:
178
+ raise ValueError("Cursor configuration exceeds limit")
179
+ value = json.loads(data)
180
+ if not isinstance(value, dict):
181
+ raise ValueError("Cursor configuration must be an object")
182
+ return value
183
+
184
+
185
+ def config_root(env: dict[str, str]) -> Path:
186
+ home = Path(env.get("HOME") or Path.home())
187
+ explicit = env.get("CURSOR_CONFIG_DIR", "").strip()
188
+ if explicit:
189
+ return Path(explicit).expanduser().resolve()
190
+ xdg = env.get("XDG_CONFIG_HOME", "").strip()
191
+ return (Path(xdg) / "cursor" if xdg else home / ".cursor").resolve()
192
+
193
+
194
+ def permission_snapshot(base: dict, projects: list[dict]) -> dict:
195
+ """Match native replacement of allow lists; retain every explicit deny.
196
+
197
+ Project permission lists override the global lists in Cursor. Keeping
198
+ the union of explicit denies is deliberately conservative: a later empty
199
+ deny list must not remove an earlier user prohibition to admit our tool.
200
+ """
201
+ result = dict(base)
202
+ permissions = {"allow": ["Shell(ls)"], "deny": []}
203
+ denies = []
204
+ for config in [base, *projects]:
205
+ supplied = config.get("permissions", {})
206
+ if not isinstance(supplied, dict) or set(supplied) - {"allow", "deny"}:
207
+ raise ValueError("Unsupported Cursor permission configuration")
208
+ for key in ("allow", "deny"):
209
+ if key not in supplied:
210
+ continue
211
+ values = supplied[key]
212
+ if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
213
+ raise ValueError("Invalid Cursor permission list")
214
+ permissions[key] = list(values)
215
+ denies.extend(permissions["deny"])
216
+ permissions["deny"] = list(dict.fromkeys(denies))
217
+ permissions["allow"] = list(dict.fromkeys([*permissions["allow"], MCP_ALLOW]))
218
+ result["permissions"] = permissions
219
+ result.setdefault("version", 1)
220
+ result.setdefault("editor", {"vimMode": False})
221
+ return result
222
+
223
+
224
+ class CursorRuntimeProfile:
225
+ """Ephemeral config/plugin; never writes native config, auth or history."""
226
+
227
+ def __init__(self, env: dict[str, str], cwd: str):
228
+ self.temp = None
229
+ self.path = None
230
+ self.env = env
231
+ self.cwd = Path(cwd).resolve()
232
+
233
+ def prepare(self) -> tuple[dict[str, str], list[str]]:
234
+ original = config_root(self.env)
235
+ base = read_config(original / "cli-config.json")
236
+ try:
237
+ git = subprocess.run(["git", "-C", str(self.cwd), "rev-parse", "--show-toplevel"],
238
+ capture_output=True, text=True, timeout=5, env=self.env)
239
+ except FileNotFoundError:
240
+ git = None
241
+ project_root = Path(git.stdout.strip()).resolve() if git and git.returncode == 0 else self.cwd
242
+ try:
243
+ relative = self.cwd.relative_to(project_root)
244
+ except ValueError:
245
+ raise ValueError("Cursor workspace root does not contain cwd") from None
246
+ roots = [project_root]
247
+ for part in relative.parts:
248
+ roots.append(roots[-1] / part)
249
+ projects = []
250
+ for root in roots:
251
+ config = read_config(root / ".cursor" / "cli.json")
252
+ if set(config) - {"permissions"}:
253
+ raise ValueError("Unsupported Cursor project configuration; internal tools were not enabled")
254
+ projects.append(config)
255
+ merged = permission_snapshot(base, projects)
256
+ self.temp = tempfile.TemporaryDirectory(prefix="agentsdock-cursor-")
257
+ self.path = Path(self.temp.name)
258
+ self.path.chmod(0o700)
259
+ config_dir = self.path / "config"
260
+ config_dir.mkdir(mode=0o700)
261
+ # Preserve native sibling configuration (plugins, rules, MCP settings).
262
+ # Credential stores and CLI history use the unchanged HOME/data roots.
263
+ if original.is_dir():
264
+ children = list(itertools.islice(original.iterdir(), 1025))
265
+ if len(children) > 1024:
266
+ raise ValueError("Cursor configuration directory exceeds limit")
267
+ for child in children:
268
+ if child.name != "cli-config.json":
269
+ (config_dir / child.name).symlink_to(child, target_is_directory=child.is_dir())
270
+ config_file = config_dir / "cli-config.json"
271
+ config_file.write_text(json.dumps(merged), encoding="utf-8")
272
+ config_file.chmod(0o600)
273
+ plugin = self.path / PLUGIN_NAME
274
+ (plugin / ".cursor-plugin").mkdir(parents=True, mode=0o700)
275
+ (plugin / ".cursor-plugin" / "plugin.json").write_text(json.dumps({"name": PLUGIN_NAME, "version": "1.0.0"}))
276
+ (plugin / "mcp.json").write_text(json.dumps({"mcpServers": {MCP_SERVER_KEY: {
277
+ "command": sys.executable, "args": [str(Path(__file__).resolve()), "--mcp"],
278
+ "env": {key: self.env[key] for key in (ENV_PORT, ENV_SECRET)}}}}))
279
+ (plugin / "mcp.json").chmod(0o600)
280
+ return {"CURSOR_CONFIG_DIR": str(config_dir)}, ["--disable-project-configs", "--plugin-dir", str(plugin)]
281
+
282
+ def close(self) -> None:
283
+ if self.temp:
284
+ self.temp.cleanup()
285
+
286
+
287
+ if __name__ == "__main__":
288
+ if sys.argv[1:] != ["--mcp"]:
289
+ raise SystemExit("Only the run-bound MCP transport may invoke this module")
290
+ mcp_main()
@@ -47,8 +47,11 @@ def flush_tree(root: Path, *, dependency_prefix: bool = False) -> dict[str, int]
47
47
  if not root.is_absolute() or root.resolve(strict=True) != root:
48
48
  raise ValueError("durability tree must have no linked ancestors")
49
49
  root_info = root.lstat()
50
+ # uv can retain a same-user runtime with 0775 directories and 0664 files.
51
+ # Flushing that admitted dependency must not change its permission policy.
52
+ writable_mask = 0o002 if dependency_prefix else 0o022
50
53
  if (not stat.S_ISDIR(root_info.st_mode) or root_info.st_uid != os.getuid()
51
- or root_info.st_mode & 0o022):
54
+ or root_info.st_mode & writable_mask):
52
55
  raise PermissionError("durability tree root is unsafe")
53
56
  total = {"entries": 0, "bytes": 0}
54
57
  external: set[Path] = set()
@@ -70,7 +73,7 @@ def flush_tree(root: Path, *, dependency_prefix: bool = False) -> dict[str, int]
70
73
  info = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
71
74
  if info.st_uid != os.getuid() or info.st_dev != root_info.st_dev:
72
75
  raise PermissionError("durability member ownership or device changed")
73
- if dependency_prefix and not stat.S_ISLNK(info.st_mode) and info.st_mode & 0o022:
76
+ if dependency_prefix and not stat.S_ISLNK(info.st_mode) and info.st_mode & 0o002:
74
77
  raise PermissionError("standalone Python durability member is writable by others")
75
78
  total["entries"] += 1
76
79
  if total["entries"] > MAX_ENTRIES:
@@ -116,16 +119,17 @@ def flush_tree(root: Path, *, dependency_prefix: bool = False) -> dict[str, int]
116
119
  visit(root)
117
120
  for interpreter in sorted(external):
118
121
  info = interpreter.lstat()
122
+ prefix = interpreter.parent.parent
123
+ match = re.fullmatch(r"python([0-9]+\.[0-9]+)", interpreter.name)
124
+ standalone = re.fullmatch(r"cpython-[0-9]+\.[0-9]+\.[0-9]+(?:[a-z0-9.]*)?-(?:macos|linux)-[a-z0-9_]+-[a-z0-9_]+", prefix.name)
125
+ writable_mask = 0o002 if info.st_uid == os.getuid() else 0o022
119
126
  if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.getuid()}
120
- or info.st_mode & 0o022 or not info.st_mode & 0o111):
127
+ or info.st_mode & writable_mask or not info.st_mode & 0o111):
121
128
  raise PermissionError("external durability interpreter is unsafe")
122
129
  if info.st_uid == 0:
123
130
  # System interpreters are provisioned outside this updater. Never
124
131
  # traverse /usr or another OS-managed Python installation.
125
132
  continue
126
- prefix = interpreter.parent.parent
127
- match = re.fullmatch(r"python([0-9]+\.[0-9]+)", interpreter.name)
128
- standalone = re.fullmatch(r"cpython-[0-9]+\.[0-9]+\.[0-9]+(?:[a-z0-9.]*)?-(?:macos|linux)-[a-z0-9_]+-[a-z0-9_]+", prefix.name)
129
133
  if standalone:
130
134
  if (interpreter.parent.name != "bin" or match is None
131
135
  or not (prefix / "lib" / ("python" + match[1])).is_dir()
@@ -67,8 +67,12 @@ def _file(path: Path, *, private: bool = False, dependency: bool = False,
67
67
  mode = stat.S_IMODE(before.st_mode)
68
68
  maximum = MAX_RECEIPT_BYTES if private else MAX_FILE_BYTES
69
69
  owners = {os.getuid(), 0} if external else {os.getuid()}
70
+ # Existing user-managed Python installations can inherit a group-write
71
+ # umask. Bind their bytes and mode in the receipt without chmodding a
72
+ # shared prerequisite. Private staging files retain their usual policy.
73
+ writable_mask = 0o002 if external and dependency and before.st_uid == os.getuid() else 0o022
70
74
  if (not stat.S_ISREG(before.st_mode) or before.st_uid not in owners
71
- or (mode & 0o022 and not uv_lock) or mode & 0o7000 or (private and mode != 0o600)
75
+ or (mode & writable_mask and not uv_lock) or mode & 0o7000 or (private and mode != 0o600)
72
76
  or (uv_lock and (before.st_size != 0 or before.st_nlink != 1))
73
77
  or (not dependency and before.st_nlink != 1) or before.st_size > maximum):
74
78
  raise PermissionError("preparation file is unsafe or too large")
@@ -201,7 +205,7 @@ def _inventory_tree(candidate: Path, source: dict[str, dict]) -> dict[str, dict]
201
205
  or not re.fullmatch(r"python(?:[0-9]+(?:\.[0-9]+)*)?", resolved.name)
202
206
  or not os.access(resolved, os.X_OK)):
203
207
  raise ValueError("external prepared dependency link is not an interpreter")
204
- target_info, _data = _file(resolved, dependency=True, external=True)
208
+ target_info, _data = _file(resolved, dependency=True, external=candidate not in resolved.parents)
205
209
  total += target_info["size"]
206
210
  if _stable(info) != _stable(child.lstat()) or target != os.readlink(child):
207
211
  raise RuntimeError("prepared dependency link changed")