@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.
- package/README.md +21 -7
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +4913 -660
- 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 +78 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_provider.py +1 -1
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- 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 +281 -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 +653 -0
- package/server/provider_commands.py +579 -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/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/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- 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 &
|
|
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")
|