@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
|
@@ -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 &
|
|
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 &
|
|
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 &
|
|
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 &
|
|
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=
|
|
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")
|