hypha-debugger 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/PKG-INFO +1 -1
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/__init__.py +1 -1
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/__main__.py +3 -3
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/debugger.py +118 -68
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/services/execute.py +51 -14
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/services/filesystem.py +75 -7
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/PKG-INFO +1 -1
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/pyproject.toml +1 -1
- hypha_debugger-0.1.5/tests/test_services.py +240 -0
- hypha_debugger-0.1.3/tests/test_services.py +0 -77
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/README.md +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/services/__init__.py +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/services/info.py +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/services/inspect_vars.py +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/utils/__init__.py +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger/utils/env.py +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/SOURCES.txt +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/dependency_links.txt +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/entry_points.txt +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/requires.txt +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/top_level.txt +0 -0
- {hypha_debugger-0.1.3 → hypha_debugger-0.1.5}/setup.cfg +0 -0
|
@@ -42,9 +42,9 @@ def main():
|
|
|
42
42
|
help="Human-readable service name (default: Python Debugger)",
|
|
43
43
|
)
|
|
44
44
|
parser.add_argument(
|
|
45
|
-
"--
|
|
45
|
+
"--require-token",
|
|
46
46
|
action="store_true",
|
|
47
|
-
help="
|
|
47
|
+
help="Require a JWT token for remote access (default: URL-secret mode, no token needed)",
|
|
48
48
|
)
|
|
49
49
|
args = parser.parse_args()
|
|
50
50
|
|
|
@@ -55,7 +55,7 @@ def main():
|
|
|
55
55
|
token=args.token,
|
|
56
56
|
service_id=args.service_id,
|
|
57
57
|
service_name=args.service_name,
|
|
58
|
-
require_token=
|
|
58
|
+
require_token=args.require_token,
|
|
59
59
|
)
|
|
60
60
|
|
|
61
61
|
print()
|
|
@@ -14,30 +14,29 @@ from hypha_debugger.services.inspect_vars import (
|
|
|
14
14
|
list_variables,
|
|
15
15
|
get_stack_trace,
|
|
16
16
|
)
|
|
17
|
-
from hypha_debugger.services.filesystem import list_files, read_file
|
|
17
|
+
from hypha_debugger.services.filesystem import list_files, read_file, write_file
|
|
18
18
|
|
|
19
19
|
logger = logging.getLogger("hypha_debugger")
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _build_service_url(server_url: str, service_id: str) -> str:
|
|
23
|
-
"""Build
|
|
23
|
+
"""Build the HTTP service URL from server URL and full service ID.
|
|
24
24
|
|
|
25
25
|
Strips the clientId prefix so the URL uses only the bare service name.
|
|
26
|
-
Callers append ?_mode=last to resolve the most recent instance.
|
|
27
26
|
|
|
28
27
|
Args:
|
|
29
28
|
server_url: e.g. "https://hypha.aicell.io"
|
|
30
|
-
service_id: e.g. "ws-xxx/clientId:py-debugger"
|
|
29
|
+
service_id: e.g. "ws-xxx/clientId:py-debugger-abc123"
|
|
31
30
|
|
|
32
31
|
Returns:
|
|
33
|
-
e.g. "https://hypha.aicell.io/ws-xxx/services/py-debugger"
|
|
32
|
+
e.g. "https://hypha.aicell.io/ws-xxx/services/py-debugger-abc123"
|
|
34
33
|
"""
|
|
35
34
|
base = server_url.rstrip("/")
|
|
36
35
|
# service_id format: "workspace/clientId:svcName"
|
|
37
36
|
parts = service_id.split("/", 1)
|
|
38
37
|
if len(parts) == 2:
|
|
39
38
|
workspace, svc_part = parts
|
|
40
|
-
# Strip clientId: "abc123:py-debugger" → "py-debugger"
|
|
39
|
+
# Strip clientId: "abc123:py-debugger-xyz" → "py-debugger-xyz"
|
|
41
40
|
if ":" in svc_part:
|
|
42
41
|
svc_name = svc_part.split(":", 1)[1]
|
|
43
42
|
else:
|
|
@@ -46,58 +45,82 @@ def _build_service_url(server_url: str, service_id: str) -> str:
|
|
|
46
45
|
return f"{base}/services/{service_id}"
|
|
47
46
|
|
|
48
47
|
|
|
49
|
-
def _build_instruction_block(
|
|
50
|
-
service_url: str,
|
|
51
|
-
token: str,
|
|
52
|
-
require_token: bool = True,
|
|
53
|
-
) -> str:
|
|
48
|
+
def _build_instruction_block(service_url: str, token: str = "") -> str:
|
|
54
49
|
"""Build the instruction block with copy-paste commands for remote access.
|
|
55
50
|
|
|
56
|
-
Returns a formatted string
|
|
51
|
+
Returns a formatted string that can be pasted directly into an AI agent chat.
|
|
57
52
|
"""
|
|
58
|
-
if
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
53
|
+
auth = f' -H "Authorization: Bearer $TOKEN"' if token else ""
|
|
54
|
+
lines = [
|
|
55
|
+
"# Hypha Remote Debugger — Python Process",
|
|
56
|
+
"# A debugger is attached to a running Python process.",
|
|
57
|
+
"# You can remotely execute code, read/write files, inspect variables,",
|
|
58
|
+
"# and query process info via the HTTP API below.",
|
|
59
|
+
"#",
|
|
60
|
+
"# Available functions:",
|
|
61
|
+
"# get_process_info - PID, Python version, CWD, platform, memory",
|
|
62
|
+
"# execute_code - Run arbitrary Python code (with timeout)",
|
|
63
|
+
"# get_variable - Inspect a variable by name",
|
|
64
|
+
"# list_variables - List variables in a namespace",
|
|
65
|
+
"# get_stack_trace - Stack traces of all threads",
|
|
66
|
+
"# list_files - List directory contents",
|
|
67
|
+
"# read_file - Read file content (with offset/limit)",
|
|
68
|
+
"# write_file - Write/append to a file",
|
|
69
|
+
"# get_installed_packages - List pip packages",
|
|
70
|
+
"#",
|
|
71
|
+
"# POST endpoints accept JSON body with parameter names as keys.",
|
|
72
|
+
"",
|
|
73
|
+
f'SERVICE_URL="{service_url}"',
|
|
74
|
+
]
|
|
75
|
+
if token:
|
|
76
|
+
lines.append(f'TOKEN="{token}"')
|
|
77
|
+
lines += [
|
|
78
|
+
"",
|
|
79
|
+
"# Get process info (PID, Python version, CWD, memory):",
|
|
80
|
+
f'curl "$SERVICE_URL/get_process_info"{auth}',
|
|
81
|
+
"",
|
|
82
|
+
"# Execute Python code (timeout default 30s):",
|
|
83
|
+
f'curl -X POST "$SERVICE_URL/execute_code"{auth}'
|
|
84
|
+
' -H "Content-Type: application/json"'
|
|
85
|
+
" -d '{\"code\": \"import sys; sys.version\"}'",
|
|
86
|
+
"",
|
|
87
|
+
"# Write a file:",
|
|
88
|
+
f'curl -X POST "$SERVICE_URL/write_file"{auth}'
|
|
89
|
+
' -H "Content-Type: application/json"'
|
|
90
|
+
" -d '{\"path\": \"hello.txt\", \"content\": \"Hello, world!\"}'",
|
|
91
|
+
"",
|
|
92
|
+
"# List files in working directory:",
|
|
93
|
+
f'curl "$SERVICE_URL/list_files"{auth}',
|
|
94
|
+
"",
|
|
95
|
+
"# Read a file:",
|
|
96
|
+
f'curl -X POST "$SERVICE_URL/read_file"{auth}'
|
|
97
|
+
' -H "Content-Type: application/json"'
|
|
98
|
+
" -d '{\"path\": \"hello.txt\"}'",
|
|
99
|
+
]
|
|
100
|
+
return "\n".join(lines)
|
|
84
101
|
|
|
85
102
|
|
|
86
103
|
def _print_session_info(
|
|
87
104
|
server_url: str,
|
|
88
|
-
service_id: str,
|
|
89
105
|
service_url: str,
|
|
90
|
-
token: str,
|
|
91
|
-
require_token: bool = True,
|
|
106
|
+
token: str = "",
|
|
92
107
|
) -> None:
|
|
93
108
|
"""Print session information with remote access URLs."""
|
|
94
109
|
print(f"[hypha-debugger] Connected to {server_url}")
|
|
95
|
-
print(f"[hypha-debugger] Service ID: {service_id}")
|
|
96
110
|
print(f"[hypha-debugger] Service URL: {service_url}")
|
|
97
|
-
if
|
|
111
|
+
if token:
|
|
98
112
|
print(f"[hypha-debugger] Token: {token}")
|
|
99
113
|
print()
|
|
100
|
-
|
|
114
|
+
sep = "=" * 60
|
|
115
|
+
print(sep)
|
|
116
|
+
print(" WARNING: The URL below grants full access to this Python")
|
|
117
|
+
print(" process (execute code, read/write files). Only share it")
|
|
118
|
+
print(" with trusted agents or people.")
|
|
119
|
+
print(sep)
|
|
120
|
+
print()
|
|
121
|
+
print(_build_instruction_block(service_url, token))
|
|
122
|
+
print()
|
|
123
|
+
print(sep)
|
|
101
124
|
|
|
102
125
|
|
|
103
126
|
@dataclass
|
|
@@ -117,10 +140,17 @@ class DebugSession:
|
|
|
117
140
|
|
|
118
141
|
def print_instructions(self) -> str:
|
|
119
142
|
"""Print copy-paste instructions for remote access and return them."""
|
|
120
|
-
instructions = _build_instruction_block(
|
|
121
|
-
|
|
122
|
-
)
|
|
143
|
+
instructions = _build_instruction_block(self.service_url, self.token)
|
|
144
|
+
sep = "=" * 60
|
|
145
|
+
print(sep)
|
|
146
|
+
print(" WARNING: The URL below grants full access to this Python")
|
|
147
|
+
print(" process (execute code, read/write files). Only share it")
|
|
148
|
+
print(" with trusted agents or people.")
|
|
149
|
+
print(sep)
|
|
150
|
+
print()
|
|
123
151
|
print(instructions)
|
|
152
|
+
print()
|
|
153
|
+
print(sep)
|
|
124
154
|
return instructions
|
|
125
155
|
|
|
126
156
|
async def serve_forever(self):
|
|
@@ -156,6 +186,27 @@ class DebugSession:
|
|
|
156
186
|
self._thread.join(timeout=5)
|
|
157
187
|
|
|
158
188
|
|
|
189
|
+
def _resolve_id_and_visibility(
|
|
190
|
+
service_id: str,
|
|
191
|
+
visibility: str,
|
|
192
|
+
require_token: bool,
|
|
193
|
+
) -> tuple:
|
|
194
|
+
"""Resolve effective service ID and visibility.
|
|
195
|
+
|
|
196
|
+
By default (require_token=False), generates a unique random service ID
|
|
197
|
+
and registers as unlisted. The URL itself acts as the secret.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
(effective_id, effective_visibility)
|
|
201
|
+
"""
|
|
202
|
+
effective_id = service_id
|
|
203
|
+
# Always add a random suffix unless user provided a custom ID
|
|
204
|
+
if service_id == "py-debugger":
|
|
205
|
+
effective_id = f"py-debugger-{secrets.token_hex(16)}"
|
|
206
|
+
effective_visibility = visibility or ("protected" if require_token else "unlisted")
|
|
207
|
+
return effective_id, effective_visibility
|
|
208
|
+
|
|
209
|
+
|
|
159
210
|
async def start_debugger(
|
|
160
211
|
server_url: str,
|
|
161
212
|
workspace: str = "",
|
|
@@ -163,35 +214,34 @@ async def start_debugger(
|
|
|
163
214
|
service_id: str = "py-debugger",
|
|
164
215
|
service_name: str = "Python Debugger",
|
|
165
216
|
visibility: str = "",
|
|
166
|
-
require_token: bool =
|
|
217
|
+
require_token: bool = False,
|
|
167
218
|
) -> DebugSession:
|
|
168
219
|
"""Start the Hypha debugger (async).
|
|
169
220
|
|
|
170
221
|
Connects to a Hypha server and registers a debug service that remote
|
|
171
222
|
clients can use to inspect and interact with this Python process.
|
|
172
223
|
|
|
224
|
+
By default, the service is registered as "unlisted" with a unique random
|
|
225
|
+
service ID. The URL itself is unguessable — no token needed. Just keep
|
|
226
|
+
the URL secret.
|
|
227
|
+
|
|
173
228
|
Args:
|
|
174
229
|
server_url: Hypha server URL (required).
|
|
175
230
|
workspace: Workspace name (auto-assigned if empty).
|
|
176
231
|
token: Authentication token for connecting to Hypha.
|
|
177
|
-
service_id: Service ID to register as.
|
|
232
|
+
service_id: Service ID to register as. A random suffix is added by default.
|
|
178
233
|
service_name: Human-readable service name.
|
|
179
234
|
visibility: Service visibility override ("public", "protected", "unlisted").
|
|
180
|
-
|
|
181
|
-
require_token: Whether remote callers must supply a JWT token (default True).
|
|
182
|
-
When False, the service is registered as "unlisted" and the service ID
|
|
183
|
-
gets a random suffix — the URL itself acts as the secret, no token needed.
|
|
235
|
+
require_token: Whether to generate a JWT token for remote callers (default False).
|
|
184
236
|
|
|
185
237
|
Returns:
|
|
186
238
|
A DebugSession with service_id, workspace, server, service_url, and token.
|
|
187
239
|
"""
|
|
188
240
|
from hypha_rpc import connect_to_server
|
|
189
241
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
effective_id = f"py-debugger-{secrets.token_hex(8)}"
|
|
194
|
-
effective_visibility = visibility or ("protected" if require_token else "unlisted")
|
|
242
|
+
effective_id, effective_visibility = _resolve_id_and_visibility(
|
|
243
|
+
service_id, visibility, require_token
|
|
244
|
+
)
|
|
195
245
|
|
|
196
246
|
connect_config = {"server_url": server_url}
|
|
197
247
|
if workspace:
|
|
@@ -221,13 +271,14 @@ async def start_debugger(
|
|
|
221
271
|
"get_stack_trace": get_stack_trace,
|
|
222
272
|
"list_files": list_files,
|
|
223
273
|
"read_file": read_file,
|
|
274
|
+
"write_file": write_file,
|
|
224
275
|
}
|
|
225
276
|
|
|
226
277
|
svc_info = await server.register_service(service)
|
|
227
278
|
actual_id = svc_info.get("id", effective_id) if isinstance(svc_info, dict) else effective_id
|
|
228
279
|
ws = server.config.get("workspace", workspace) if hasattr(server.config, "get") else getattr(server.config, "workspace", workspace)
|
|
229
280
|
|
|
230
|
-
# Generate a token for remote access (24h expiry)
|
|
281
|
+
# Generate a token for remote access (24h expiry) only if requested.
|
|
231
282
|
session_token = ""
|
|
232
283
|
if require_token:
|
|
233
284
|
session_token = await server.generate_token({"expires_in": 86400})
|
|
@@ -241,7 +292,7 @@ async def start_debugger(
|
|
|
241
292
|
ws,
|
|
242
293
|
actual_id,
|
|
243
294
|
)
|
|
244
|
-
_print_session_info(server_url,
|
|
295
|
+
_print_session_info(server_url, service_url, session_token)
|
|
245
296
|
|
|
246
297
|
return DebugSession(
|
|
247
298
|
service_id=actual_id,
|
|
@@ -260,7 +311,7 @@ def start_debugger_sync(
|
|
|
260
311
|
service_id: str = "py-debugger",
|
|
261
312
|
service_name: str = "Python Debugger",
|
|
262
313
|
visibility: str = "",
|
|
263
|
-
require_token: bool =
|
|
314
|
+
require_token: bool = False,
|
|
264
315
|
) -> DebugSession:
|
|
265
316
|
"""Start the Hypha debugger (sync).
|
|
266
317
|
|
|
@@ -275,11 +326,9 @@ def start_debugger_sync(
|
|
|
275
326
|
"""
|
|
276
327
|
from hypha_rpc.sync import connect_to_server
|
|
277
328
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
effective_id = f"py-debugger-{secrets.token_hex(8)}"
|
|
282
|
-
effective_visibility = visibility or ("protected" if require_token else "unlisted")
|
|
329
|
+
effective_id, effective_visibility = _resolve_id_and_visibility(
|
|
330
|
+
service_id, visibility, require_token
|
|
331
|
+
)
|
|
283
332
|
|
|
284
333
|
server = connect_to_server({"server_url": server_url, **({"workspace": workspace} if workspace else {}), **({"token": token} if token else {})})
|
|
285
334
|
|
|
@@ -303,13 +352,14 @@ def start_debugger_sync(
|
|
|
303
352
|
"get_stack_trace": get_stack_trace,
|
|
304
353
|
"list_files": list_files,
|
|
305
354
|
"read_file": read_file,
|
|
355
|
+
"write_file": write_file,
|
|
306
356
|
}
|
|
307
357
|
|
|
308
358
|
svc_info = server.register_service(service)
|
|
309
359
|
actual_id = svc_info.get("id", effective_id) if isinstance(svc_info, dict) else effective_id
|
|
310
360
|
ws = server.config.get("workspace", workspace) if hasattr(server.config, "get") else getattr(server.config, "workspace", workspace)
|
|
311
361
|
|
|
312
|
-
# Generate a token for remote access (24h expiry)
|
|
362
|
+
# Generate a token for remote access (24h expiry) only if requested.
|
|
313
363
|
session_token = ""
|
|
314
364
|
if require_token:
|
|
315
365
|
session_token = server.generate_token({"expires_in": 86400})
|
|
@@ -323,7 +373,7 @@ def start_debugger_sync(
|
|
|
323
373
|
ws,
|
|
324
374
|
actual_id,
|
|
325
375
|
)
|
|
326
|
-
_print_session_info(server_url,
|
|
376
|
+
_print_session_info(server_url, service_url, session_token)
|
|
327
377
|
|
|
328
378
|
return DebugSession(
|
|
329
379
|
service_id=actual_id,
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
"""Arbitrary code execution service."""
|
|
1
|
+
"""Arbitrary code execution service with timeout support."""
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
import io
|
|
5
|
+
import signal
|
|
5
6
|
import traceback
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
# Default timeout for code execution (seconds). 0 = no timeout.
|
|
10
|
+
DEFAULT_TIMEOUT = 30
|
|
6
11
|
|
|
7
12
|
try:
|
|
8
13
|
from pydantic import Field
|
|
@@ -15,19 +20,28 @@ try:
|
|
|
15
20
|
default="__main__",
|
|
16
21
|
description='Namespace to execute in. Default: "__main__" (the main module namespace).',
|
|
17
22
|
),
|
|
23
|
+
timeout: int = Field(
|
|
24
|
+
default=DEFAULT_TIMEOUT,
|
|
25
|
+
description="Timeout in seconds. 0 for no timeout. Default: 30.",
|
|
26
|
+
),
|
|
18
27
|
) -> dict:
|
|
19
|
-
"""Execute Python code in the debugger process and return stdout, stderr, and the result
|
|
20
|
-
|
|
28
|
+
"""Execute Python code in the debugger process and return stdout, stderr, and the result.
|
|
29
|
+
|
|
30
|
+
Supports both expressions (returns the value) and statements.
|
|
31
|
+
Code runs in the target namespace so you can define functions, import modules, etc.
|
|
32
|
+
A timeout (default 30s) prevents infinite loops from hanging the debugger.
|
|
33
|
+
"""
|
|
34
|
+
return _execute_impl(code, namespace, timeout)
|
|
21
35
|
|
|
22
36
|
except ImportError:
|
|
23
37
|
|
|
24
|
-
def execute_code(code: str, namespace: str = "__main__") -> dict:
|
|
38
|
+
def execute_code(code: str, namespace: str = "__main__", timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
25
39
|
"""Execute Python code in the debugger process."""
|
|
26
|
-
return _execute_impl(code, namespace)
|
|
40
|
+
return _execute_impl(code, namespace, timeout)
|
|
27
41
|
|
|
28
42
|
|
|
29
|
-
def _execute_impl(code: str, namespace: str = "__main__") -> dict:
|
|
30
|
-
"""Implementation of code execution."""
|
|
43
|
+
def _execute_impl(code: str, namespace: str = "__main__", timeout: int = DEFAULT_TIMEOUT) -> dict:
|
|
44
|
+
"""Implementation of code execution with optional timeout."""
|
|
31
45
|
# Get the target namespace
|
|
32
46
|
if namespace == "__main__":
|
|
33
47
|
ns = vars(sys.modules.get("__main__", {}))
|
|
@@ -42,18 +56,30 @@ def _execute_impl(code: str, namespace: str = "__main__") -> dict:
|
|
|
42
56
|
|
|
43
57
|
result = None
|
|
44
58
|
error = None
|
|
59
|
+
timed_out = False
|
|
45
60
|
|
|
46
61
|
try:
|
|
47
62
|
sys.stdout = stdout_capture
|
|
48
63
|
sys.stderr = stderr_capture
|
|
49
64
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
if timeout > 0 and threading.current_thread() is threading.main_thread():
|
|
66
|
+
# Use SIGALRM for timeout on the main thread (Unix only)
|
|
67
|
+
def _timeout_handler(signum, frame):
|
|
68
|
+
raise TimeoutError(f"Code execution timed out after {timeout}s")
|
|
69
|
+
|
|
70
|
+
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
|
|
71
|
+
signal.alarm(timeout)
|
|
72
|
+
try:
|
|
73
|
+
result = _run_code(code, ns)
|
|
74
|
+
except TimeoutError as e:
|
|
75
|
+
error = str(e)
|
|
76
|
+
timed_out = True
|
|
77
|
+
finally:
|
|
78
|
+
signal.alarm(0)
|
|
79
|
+
signal.signal(signal.SIGALRM, old_handler)
|
|
80
|
+
else:
|
|
81
|
+
# No timeout or not on main thread — run directly
|
|
82
|
+
result = _run_code(code, ns)
|
|
57
83
|
except Exception:
|
|
58
84
|
error = traceback.format_exc()
|
|
59
85
|
finally:
|
|
@@ -74,10 +100,21 @@ def _execute_impl(code: str, namespace: str = "__main__") -> dict:
|
|
|
74
100
|
}
|
|
75
101
|
if error:
|
|
76
102
|
response["error"] = error
|
|
103
|
+
if timed_out:
|
|
104
|
+
response["timed_out"] = True
|
|
77
105
|
|
|
78
106
|
return response
|
|
79
107
|
|
|
80
108
|
|
|
109
|
+
def _run_code(code: str, ns: dict):
|
|
110
|
+
"""Try as expression first (to capture return value), fall back to exec."""
|
|
111
|
+
try:
|
|
112
|
+
return eval(code, ns)
|
|
113
|
+
except SyntaxError:
|
|
114
|
+
exec(code, ns)
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
81
118
|
def _safe_serialize(obj, depth=0, max_depth=3):
|
|
82
119
|
"""Safely serialize an object for RPC transport."""
|
|
83
120
|
if depth > max_depth:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""File system browsing service (sandboxed to CWD)."""
|
|
1
|
+
"""File system browsing and writing service (sandboxed to CWD)."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
|
|
@@ -27,13 +27,44 @@ try:
|
|
|
27
27
|
default=500,
|
|
28
28
|
description="Maximum number of lines to read. Default: 500.",
|
|
29
29
|
),
|
|
30
|
+
offset: int = Field(
|
|
31
|
+
default=0,
|
|
32
|
+
description="Number of lines to skip from the beginning. Default: 0.",
|
|
33
|
+
),
|
|
30
34
|
encoding: str = Field(
|
|
31
35
|
default="utf-8",
|
|
32
36
|
description="File encoding. Default: utf-8.",
|
|
33
37
|
),
|
|
34
38
|
) -> dict:
|
|
35
|
-
"""Read a file relative to the process CWD. Returns the content as a string.
|
|
36
|
-
|
|
39
|
+
"""Read a file relative to the process CWD. Returns the content as a string.
|
|
40
|
+
|
|
41
|
+
Use offset and max_lines to paginate through large files.
|
|
42
|
+
"""
|
|
43
|
+
return _read_file_impl(path, max_lines, offset, encoding)
|
|
44
|
+
|
|
45
|
+
@schema_function
|
|
46
|
+
def write_file(
|
|
47
|
+
path: str = Field(..., description="File path relative to CWD."),
|
|
48
|
+
content: str = Field(..., description="Content to write to the file."),
|
|
49
|
+
mode: str = Field(
|
|
50
|
+
default="w",
|
|
51
|
+
description='Write mode: "w" to overwrite, "a" to append. Default: "w".',
|
|
52
|
+
),
|
|
53
|
+
create_dirs: bool = Field(
|
|
54
|
+
default=True,
|
|
55
|
+
description="Create parent directories if they don't exist. Default: true.",
|
|
56
|
+
),
|
|
57
|
+
encoding: str = Field(
|
|
58
|
+
default="utf-8",
|
|
59
|
+
description="File encoding. Default: utf-8.",
|
|
60
|
+
),
|
|
61
|
+
) -> dict:
|
|
62
|
+
"""Write content to a file relative to the process CWD.
|
|
63
|
+
|
|
64
|
+
Creates parent directories automatically. Use mode="a" to append.
|
|
65
|
+
Sandboxed: cannot write outside the process CWD.
|
|
66
|
+
"""
|
|
67
|
+
return _write_file_impl(path, content, mode, create_dirs, encoding)
|
|
37
68
|
|
|
38
69
|
except ImportError:
|
|
39
70
|
|
|
@@ -42,10 +73,17 @@ except ImportError:
|
|
|
42
73
|
return _list_files_impl(path, pattern)
|
|
43
74
|
|
|
44
75
|
def read_file(
|
|
45
|
-
path: str = "", max_lines: int = 500, encoding: str = "utf-8"
|
|
76
|
+
path: str = "", max_lines: int = 500, offset: int = 0, encoding: str = "utf-8"
|
|
46
77
|
) -> dict:
|
|
47
78
|
"""Read a file relative to CWD."""
|
|
48
|
-
return _read_file_impl(path, max_lines, encoding)
|
|
79
|
+
return _read_file_impl(path, max_lines, offset, encoding)
|
|
80
|
+
|
|
81
|
+
def write_file(
|
|
82
|
+
path: str = "", content: str = "", mode: str = "w",
|
|
83
|
+
create_dirs: bool = True, encoding: str = "utf-8"
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Write content to a file relative to CWD."""
|
|
86
|
+
return _write_file_impl(path, content, mode, create_dirs, encoding)
|
|
49
87
|
|
|
50
88
|
|
|
51
89
|
def _resolve_safe(path: str) -> str:
|
|
@@ -105,7 +143,7 @@ def _entry_info(name: str, full_path: str) -> dict:
|
|
|
105
143
|
return info
|
|
106
144
|
|
|
107
145
|
|
|
108
|
-
def _read_file_impl(path: str, max_lines: int, encoding: str) -> dict:
|
|
146
|
+
def _read_file_impl(path: str, max_lines: int, offset: int, encoding: str) -> dict:
|
|
109
147
|
try:
|
|
110
148
|
resolved = _resolve_safe(path)
|
|
111
149
|
except PermissionError as e:
|
|
@@ -118,7 +156,9 @@ def _read_file_impl(path: str, max_lines: int, encoding: str) -> dict:
|
|
|
118
156
|
with open(resolved, "r", encoding=encoding, errors="replace") as f:
|
|
119
157
|
lines = []
|
|
120
158
|
for i, line in enumerate(f):
|
|
121
|
-
if i
|
|
159
|
+
if i < offset:
|
|
160
|
+
continue
|
|
161
|
+
if len(lines) >= max_lines:
|
|
122
162
|
break
|
|
123
163
|
lines.append(line)
|
|
124
164
|
content = "".join(lines)
|
|
@@ -126,7 +166,35 @@ def _read_file_impl(path: str, max_lines: int, encoding: str) -> dict:
|
|
|
126
166
|
"path": os.path.relpath(resolved, os.getcwd()),
|
|
127
167
|
"content": content,
|
|
128
168
|
"lines_read": len(lines),
|
|
169
|
+
"offset": offset,
|
|
129
170
|
"truncated": len(lines) >= max_lines,
|
|
130
171
|
}
|
|
131
172
|
except Exception as e:
|
|
132
173
|
return {"error": f"Failed to read file: {e}"}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _write_file_impl(
|
|
177
|
+
path: str, content: str, mode: str, create_dirs: bool, encoding: str
|
|
178
|
+
) -> dict:
|
|
179
|
+
if mode not in ("w", "a"):
|
|
180
|
+
return {"error": f'Invalid mode: {mode}. Use "w" or "a".'}
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
resolved = _resolve_safe(path)
|
|
184
|
+
except PermissionError as e:
|
|
185
|
+
return {"error": str(e)}
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
if create_dirs:
|
|
189
|
+
os.makedirs(os.path.dirname(resolved), exist_ok=True)
|
|
190
|
+
|
|
191
|
+
with open(resolved, mode, encoding=encoding) as f:
|
|
192
|
+
f.write(content)
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"path": os.path.relpath(resolved, os.getcwd()),
|
|
196
|
+
"bytes_written": len(content.encode(encoding)),
|
|
197
|
+
"mode": mode,
|
|
198
|
+
}
|
|
199
|
+
except Exception as e:
|
|
200
|
+
return {"error": f"Failed to write file: {e}"}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Basic tests for service functions (no server required)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
from hypha_debugger.services.info import get_process_info, get_installed_packages
|
|
8
|
+
from hypha_debugger.services.execute import execute_code
|
|
9
|
+
from hypha_debugger.services.inspect_vars import get_variable, list_variables, get_stack_trace
|
|
10
|
+
from hypha_debugger.services.filesystem import list_files, read_file, write_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_get_process_info():
|
|
14
|
+
info = get_process_info()
|
|
15
|
+
assert info["pid"] == os.getpid()
|
|
16
|
+
assert info["python_version"] == sys.version
|
|
17
|
+
assert "cwd" in info
|
|
18
|
+
assert "hostname" in info
|
|
19
|
+
assert "platform" in info
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_get_installed_packages():
|
|
23
|
+
pkgs = get_installed_packages()
|
|
24
|
+
assert isinstance(pkgs, list)
|
|
25
|
+
assert len(pkgs) > 0
|
|
26
|
+
# Should find hypha-debugger itself
|
|
27
|
+
names = [p["name"].lower() for p in pkgs]
|
|
28
|
+
assert "hypha-debugger" in names
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_get_installed_packages_filter():
|
|
32
|
+
pkgs = get_installed_packages("hypha")
|
|
33
|
+
names = [p["name"].lower() for p in pkgs]
|
|
34
|
+
assert all("hypha" in n for n in names)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- execute_code ---
|
|
38
|
+
|
|
39
|
+
def test_execute_code_expression():
|
|
40
|
+
result = execute_code("1 + 2")
|
|
41
|
+
assert result["result"] == 3
|
|
42
|
+
assert result["result_type"] == "int"
|
|
43
|
+
assert result.get("error") is None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_execute_code_statement():
|
|
47
|
+
result = execute_code("x = 42\nprint(x)")
|
|
48
|
+
assert "42" in result["stdout"]
|
|
49
|
+
assert result.get("error") is None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_execute_code_error():
|
|
53
|
+
result = execute_code("1 / 0")
|
|
54
|
+
assert "error" in result
|
|
55
|
+
assert "ZeroDivisionError" in result["error"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_execute_code_import():
|
|
59
|
+
# Multi-statement uses exec (no return value), so test via stdout
|
|
60
|
+
result = execute_code("import json; print(json.dumps({'a': 1}))")
|
|
61
|
+
assert '{"a": 1}' in result["stdout"]
|
|
62
|
+
assert result.get("error") is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_execute_code_multiline():
|
|
66
|
+
code = """
|
|
67
|
+
def greet(name):
|
|
68
|
+
return f"Hello, {name}!"
|
|
69
|
+
greet("World")
|
|
70
|
+
"""
|
|
71
|
+
result = execute_code(code)
|
|
72
|
+
assert result.get("error") is None
|
|
73
|
+
# eval/exec may or may not capture the last expression depending on implementation
|
|
74
|
+
# but at least no error
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_execute_code_timeout():
|
|
78
|
+
"""Test that timeout parameter works (execution should not hang)."""
|
|
79
|
+
result = execute_code("'done'", timeout=5)
|
|
80
|
+
assert result["result"] == "done"
|
|
81
|
+
assert result.get("error") is None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_execute_code_stdout_capture():
|
|
85
|
+
result = execute_code("print('hello'); print('world')")
|
|
86
|
+
assert "hello" in result["stdout"]
|
|
87
|
+
assert "world" in result["stdout"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- inspect_vars ---
|
|
91
|
+
|
|
92
|
+
def test_get_variable():
|
|
93
|
+
result = get_variable("sys", namespace="hypha_debugger.services.inspect_vars")
|
|
94
|
+
assert result["name"] == "sys"
|
|
95
|
+
assert result["type"] == "module"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_get_variable_not_found():
|
|
99
|
+
result = get_variable("nonexistent_variable_xyz")
|
|
100
|
+
assert "error" in result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_list_variables():
|
|
104
|
+
result = list_variables(namespace="hypha_debugger.services.info")
|
|
105
|
+
names = [v["name"] for v in result]
|
|
106
|
+
assert "get_process_info" in names
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_get_stack_trace():
|
|
110
|
+
traces = get_stack_trace()
|
|
111
|
+
assert isinstance(traces, list)
|
|
112
|
+
assert len(traces) > 0
|
|
113
|
+
assert "thread_id" in traces[0]
|
|
114
|
+
assert "thread_name" in traces[0]
|
|
115
|
+
assert "stack" in traces[0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- filesystem ---
|
|
119
|
+
|
|
120
|
+
def test_list_files():
|
|
121
|
+
result = list_files(".")
|
|
122
|
+
assert "entries" in result
|
|
123
|
+
assert "total" in result
|
|
124
|
+
names = [e["name"] for e in result["entries"]]
|
|
125
|
+
assert any("hypha_debugger" in n for n in names) or len(names) > 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_list_files_pattern():
|
|
129
|
+
result = list_files(".", pattern="*.toml")
|
|
130
|
+
names = [e["name"] for e in result["entries"]]
|
|
131
|
+
assert "pyproject.toml" in names
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_read_file():
|
|
135
|
+
result = read_file("pyproject.toml")
|
|
136
|
+
if "error" not in result:
|
|
137
|
+
assert "content" in result
|
|
138
|
+
assert "hypha-debugger" in result["content"]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_read_file_with_offset():
|
|
142
|
+
result = read_file("pyproject.toml", offset=1, max_lines=2)
|
|
143
|
+
assert result.get("error") is None
|
|
144
|
+
assert result["offset"] == 1
|
|
145
|
+
assert result["lines_read"] <= 2
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_read_file_sandbox():
|
|
149
|
+
result = read_file("../../etc/passwd")
|
|
150
|
+
assert "error" in result
|
|
151
|
+
assert "Access denied" in result["error"] or "Not a file" in result["error"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_write_file():
|
|
155
|
+
old_cwd = os.getcwd()
|
|
156
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
157
|
+
os.chdir(tmpdir)
|
|
158
|
+
try:
|
|
159
|
+
result = write_file("test_output.txt", "hello world")
|
|
160
|
+
assert result.get("error") is None
|
|
161
|
+
assert result["bytes_written"] == 11
|
|
162
|
+
assert result["mode"] == "w"
|
|
163
|
+
|
|
164
|
+
# Verify written content
|
|
165
|
+
read_result = read_file("test_output.txt")
|
|
166
|
+
assert read_result["content"] == "hello world"
|
|
167
|
+
finally:
|
|
168
|
+
os.chdir(old_cwd)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_write_file_append():
|
|
172
|
+
old_cwd = os.getcwd()
|
|
173
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
174
|
+
os.chdir(tmpdir)
|
|
175
|
+
try:
|
|
176
|
+
write_file("append_test.txt", "first\n")
|
|
177
|
+
write_file("append_test.txt", "second\n", mode="a")
|
|
178
|
+
read_result = read_file("append_test.txt")
|
|
179
|
+
assert "first" in read_result["content"]
|
|
180
|
+
assert "second" in read_result["content"]
|
|
181
|
+
finally:
|
|
182
|
+
os.chdir(old_cwd)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_write_file_create_dirs():
|
|
186
|
+
old_cwd = os.getcwd()
|
|
187
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
188
|
+
os.chdir(tmpdir)
|
|
189
|
+
try:
|
|
190
|
+
result = write_file("sub/dir/test.txt", "nested content")
|
|
191
|
+
assert result.get("error") is None
|
|
192
|
+
assert os.path.isfile(os.path.join(tmpdir, "sub", "dir", "test.txt"))
|
|
193
|
+
finally:
|
|
194
|
+
os.chdir(old_cwd)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_write_file_sandbox():
|
|
198
|
+
result = write_file("../../etc/evil.txt", "pwned")
|
|
199
|
+
assert "error" in result
|
|
200
|
+
assert "Access denied" in result["error"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_write_file_invalid_mode():
|
|
204
|
+
result = write_file("test.txt", "content", mode="x")
|
|
205
|
+
assert "error" in result
|
|
206
|
+
assert "Invalid mode" in result["error"]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- instruction block ---
|
|
210
|
+
|
|
211
|
+
def test_instruction_block_no_token():
|
|
212
|
+
from hypha_debugger.debugger import _build_instruction_block
|
|
213
|
+
block = _build_instruction_block("https://example.com/ws/services/py-debugger-abc")
|
|
214
|
+
assert "SERVICE_URL=" in block
|
|
215
|
+
assert "TOKEN=" not in block
|
|
216
|
+
assert "Authorization" not in block
|
|
217
|
+
assert "execute_code" in block
|
|
218
|
+
assert "write_file" in block
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_instruction_block_with_token():
|
|
222
|
+
from hypha_debugger.debugger import _build_instruction_block
|
|
223
|
+
block = _build_instruction_block("https://example.com/ws/services/py-debugger", "mytoken123")
|
|
224
|
+
assert 'TOKEN="mytoken123"' in block
|
|
225
|
+
assert "Authorization" in block
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_debug_session_print_instructions(capsys):
|
|
229
|
+
from hypha_debugger.debugger import DebugSession
|
|
230
|
+
session = DebugSession(
|
|
231
|
+
service_id="test", workspace="test", server=None,
|
|
232
|
+
service_url="https://example.com/ws/services/py-debugger-abc",
|
|
233
|
+
)
|
|
234
|
+
result = session.print_instructions()
|
|
235
|
+
captured = capsys.readouterr()
|
|
236
|
+
assert "WARNING" in captured.out
|
|
237
|
+
assert "trusted" in captured.out
|
|
238
|
+
assert "SERVICE_URL=" in captured.out
|
|
239
|
+
assert isinstance(result, str)
|
|
240
|
+
assert "SERVICE_URL=" in result
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Basic tests for service functions (no server required)."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
from hypha_debugger.services.info import get_process_info
|
|
7
|
-
from hypha_debugger.services.execute import execute_code
|
|
8
|
-
from hypha_debugger.services.inspect_vars import get_variable, list_variables
|
|
9
|
-
from hypha_debugger.services.filesystem import list_files, read_file
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_get_process_info():
|
|
13
|
-
info = get_process_info()
|
|
14
|
-
assert info["pid"] == os.getpid()
|
|
15
|
-
assert info["python_version"] == sys.version
|
|
16
|
-
assert "cwd" in info
|
|
17
|
-
assert "hostname" in info
|
|
18
|
-
assert "platform" in info
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def test_execute_code_expression():
|
|
22
|
-
result = execute_code("1 + 2")
|
|
23
|
-
assert result["result"] == 3
|
|
24
|
-
assert result["result_type"] == "int"
|
|
25
|
-
assert result.get("error") is None
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_execute_code_statement():
|
|
29
|
-
result = execute_code("x = 42\nprint(x)")
|
|
30
|
-
assert "42" in result["stdout"]
|
|
31
|
-
assert result.get("error") is None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_execute_code_error():
|
|
35
|
-
result = execute_code("1 / 0")
|
|
36
|
-
assert "error" in result
|
|
37
|
-
assert "ZeroDivisionError" in result["error"]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_get_variable():
|
|
41
|
-
result = get_variable("sys", namespace="hypha_debugger.services.inspect_vars")
|
|
42
|
-
assert result["name"] == "sys"
|
|
43
|
-
assert result["type"] == "module"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_get_variable_not_found():
|
|
47
|
-
result = get_variable("nonexistent_variable_xyz")
|
|
48
|
-
assert "error" in result
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def test_list_variables():
|
|
52
|
-
result = list_variables(namespace="hypha_debugger.services.info")
|
|
53
|
-
names = [v["name"] for v in result]
|
|
54
|
-
assert "get_process_info" in names
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_list_files():
|
|
58
|
-
result = list_files(".")
|
|
59
|
-
assert "entries" in result
|
|
60
|
-
assert "total" in result
|
|
61
|
-
# Should find the pyproject.toml
|
|
62
|
-
names = [e["name"] for e in result["entries"]]
|
|
63
|
-
assert any("hypha_debugger" in n for n in names) or len(names) > 0
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_read_file():
|
|
67
|
-
# Read the pyproject.toml
|
|
68
|
-
result = read_file("pyproject.toml")
|
|
69
|
-
if "error" not in result:
|
|
70
|
-
assert "content" in result
|
|
71
|
-
assert "hypha-debugger" in result["content"]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_read_file_sandbox():
|
|
75
|
-
result = read_file("../../etc/passwd")
|
|
76
|
-
assert "error" in result
|
|
77
|
-
assert "Access denied" in result["error"] or "Not a file" in result["error"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|