@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.
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
@@ -0,0 +1,219 @@
1
+ """Read-only Cursor CLI discovery (verified with 2026.09.18).
2
+
3
+ Only the CLI's metadata and plaintext user-facing JSONL export are read. Never
4
+ open conversation blobs, decrypt data, invoke the CLI, or migrate native stores.
5
+ Cursor IDE and cloud histories are different formats and are not advertised.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import sqlite3
16
+ import stat
17
+ import unicodedata
18
+ from typing import Iterator
19
+
20
+ MAX_METADATA_BYTES = 64 * 1024
21
+ MAX_SCAN_ENTRIES = 10_000
22
+ IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}")
23
+
24
+
25
+ def native_title(value: object) -> str | None:
26
+ """Bound display metadata before choosing between native naming sources.
27
+
28
+ Cursor's default is New Agent. Other names (including a user's literal
29
+ 'New chat' or 'Untitled') are not generic AgentsDock placeholders here.
30
+ """
31
+ if not isinstance(value, str) or len(value) > 4096:
32
+ return None
33
+ clean = " ".join("".join(
34
+ character for character in value
35
+ if character.isspace()
36
+ or unicodedata.category(character) not in {"Cc", "Cf", "Cs"}
37
+ ).split())[:120]
38
+ return clean if clean and clean.casefold() != "new agent" else None
39
+
40
+
41
+ def config_root(cwd: str | None = None) -> Path:
42
+ config = os.environ.get("CURSOR_CONFIG_DIR", "").strip()
43
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
44
+ root = Path(config) if config else Path(xdg) / "cursor" if xdg else Path.home() / ".cursor"
45
+ return Path(os.path.abspath(root if root.is_absolute() else Path(cwd or os.getcwd()) / root))
46
+
47
+
48
+ def data_root(cwd: str) -> Path:
49
+ data = os.environ.get("CURSOR_DATA_DIR", "").strip()
50
+ root = Path(data) if data else Path.home() / ".cursor"
51
+ return Path(os.path.abspath(root if root.is_absolute() else Path(cwd) / root))
52
+
53
+
54
+ def project_slug(cwd: str) -> str:
55
+ # Cursor utils/dist/workspace-paths.js; do not guess a cwd by reversing it.
56
+ return re.sub(r"-+", "-", re.sub(r"[^a-zA-Z0-9]", "-", cwd)).strip("-")
57
+
58
+
59
+ def safe_path(path: Path, root: Path) -> bool:
60
+ """Do not follow symlinks beneath the configured native root."""
61
+ try:
62
+ relative = path.relative_to(root)
63
+ if ".." in relative.parts or root.is_symlink():
64
+ return False
65
+ current = root
66
+ for part in relative.parts:
67
+ current = current / part
68
+ if current.is_symlink():
69
+ return False
70
+ return True
71
+ except (OSError, ValueError):
72
+ return False
73
+
74
+
75
+ def read_json(path: Path, root: Path) -> dict | None:
76
+ if not safe_path(path, root):
77
+ return None
78
+ try:
79
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
80
+ with os.fdopen(descriptor, "rb") as stream:
81
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
82
+ return None
83
+ raw = stream.read(MAX_METADATA_BYTES + 1)
84
+ if len(raw) > MAX_METADATA_BYTES:
85
+ return None
86
+ value = json.loads(raw)
87
+ return value if isinstance(value, dict) else None
88
+ except (OSError, ValueError, RecursionError):
89
+ return None
90
+
91
+
92
+ def store_metadata(database: Path, root: Path) -> dict | None:
93
+ if not safe_path(database, root) or not database.is_file():
94
+ return None
95
+ try:
96
+ connection = sqlite3.connect(database.resolve().as_uri() + "?mode=ro", uri=True, timeout=0.05)
97
+ try:
98
+ connection.execute("PRAGMA query_only = ON")
99
+ connection.execute("PRAGMA trusted_schema = OFF")
100
+ connection.set_progress_handler(lambda: 1, 10000)
101
+ row = connection.execute(
102
+ "SELECT substr(value, 1, 32769) FROM meta WHERE key = '0' AND typeof(value) = 'text'",
103
+ ).fetchone()
104
+ finally:
105
+ connection.close()
106
+ if not row or len(row[0]) > 32768:
107
+ return None
108
+ value = json.loads(bytes.fromhex(row[0]))
109
+ if not isinstance(value, dict):
110
+ return None
111
+ # Do not return encryption keys, root blobs, or unrelated metadata.
112
+ return {
113
+ "id": value.get("agentId"), "title": value.get("name"),
114
+ "is_subagent": value.get("subagentInfo") is not None,
115
+ }
116
+ except (OSError, ValueError, RecursionError, sqlite3.Error):
117
+ return None
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class LocalSession:
122
+ provider_id: str
123
+ cwd: str
124
+ title: str | None
125
+ updated_at: float
126
+ transcript: Path
127
+
128
+
129
+ def read_session(directory: Path, root: Path, *, expected_cwd: str | None = None) -> LocalSession | None:
130
+ provider_id = directory.name
131
+ if not IDENTIFIER.fullmatch(provider_id) or not safe_path(directory, root):
132
+ return None
133
+ sidecar = read_json(directory / "meta.json", root)
134
+ if not sidecar or type(sidecar.get("schemaVersion")) is not int or sidecar["schemaVersion"] != 1:
135
+ return None
136
+ if sidecar.get("hasConversation") is not True or sidecar.get("isSubagent") is True:
137
+ return None
138
+ cwd = sidecar.get("cwd")
139
+ if (not isinstance(cwd, str) or not cwd or len(cwd) > 16384
140
+ or any(ord(char) < 32 or ord(char) == 127 for char in cwd)
141
+ or not Path(cwd).is_absolute() or os.path.normpath(cwd) != cwd):
142
+ return None
143
+ if expected_cwd is not None and cwd != expected_cwd:
144
+ return None
145
+ try:
146
+ bucket = hashlib.md5(cwd.encode(), usedforsecurity=False).hexdigest()
147
+ usable = Path(cwd).is_dir() and config_root(cwd).absolute() == root.absolute()
148
+ except (OSError, ValueError):
149
+ return None
150
+ if bucket != directory.parent.name:
151
+ return None
152
+ # CLI resume is workspace-bound. Do not silently substitute DEFAULT_CWD
153
+ # for a vanished temporary project, which would create an unrelated chat.
154
+ if not usable:
155
+ return None
156
+ metadata = store_metadata(directory / "store.db", root)
157
+ if not metadata or metadata["id"] != provider_id or metadata["is_subagent"]:
158
+ return None
159
+ # Native configuration and transcript data have independent roots.
160
+ data = data_root(cwd)
161
+ projects = data / "projects"
162
+ slug = project_slug(cwd)
163
+ if not slug:
164
+ return None
165
+ transcript = projects / slug / "agent-transcripts" / provider_id / f"{provider_id}.jsonl"
166
+ if not safe_path(transcript, data):
167
+ return None
168
+ try:
169
+ info = transcript.stat()
170
+ if not stat.S_ISREG(info.st_mode):
171
+ return None
172
+ except OSError:
173
+ return None
174
+ updated = sidecar.get("updatedAtMs")
175
+ timestamp = updated / 1000 if type(updated) is int and 0 < updated < 253402300800000 else info.st_mtime
176
+ title = native_title(metadata["title"]) or native_title(sidecar.get("title"))
177
+ return LocalSession(provider_id, cwd, title, timestamp, transcript)
178
+
179
+
180
+ def find_session(provider_id: str, cwd: str) -> LocalSession | None:
181
+ if not isinstance(provider_id, str) or not IDENTIFIER.fullmatch(provider_id) or not isinstance(cwd, str):
182
+ return None
183
+ root = config_root(cwd)
184
+ try:
185
+ bucket = hashlib.md5(cwd.encode(), usedforsecurity=False).hexdigest()
186
+ except ValueError:
187
+ return None
188
+ return read_session(root / "chats" / bucket / provider_id, root, expected_cwd=cwd)
189
+
190
+
191
+ def local_sessions() -> Iterator[LocalSession]:
192
+ root = config_root()
193
+ chats = root / "chats"
194
+ if not safe_path(chats, root):
195
+ return
196
+ remaining = MAX_SCAN_ENTRIES
197
+ try:
198
+ with os.scandir(chats) as buckets:
199
+ for bucket in buckets:
200
+ remaining -= 1
201
+ if remaining < 0:
202
+ return
203
+ if not re.fullmatch(r"[0-9a-f]{32}", bucket.name) or not bucket.is_dir(follow_symlinks=False):
204
+ continue
205
+ try:
206
+ with os.scandir(bucket.path) as directories:
207
+ for entry in directories:
208
+ remaining -= 1
209
+ if remaining < 0:
210
+ return
211
+ if not entry.is_dir(follow_symlinks=False):
212
+ continue
213
+ session = read_session(Path(entry.path), root)
214
+ if session is not None:
215
+ yield session
216
+ except OSError:
217
+ continue
218
+ except OSError:
219
+ return
@@ -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")