hypha-debugger 0.1.4__tar.gz → 0.1.6__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.4 → hypha_debugger-0.1.6}/PKG-INFO +1 -1
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/__init__.py +1 -1
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/__main__.py +3 -3
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/debugger.py +102 -67
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/services/execute.py +51 -14
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/services/filesystem.py +75 -7
- hypha_debugger-0.1.6/hypha_debugger/services/source.py +187 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger.egg-info/PKG-INFO +1 -1
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger.egg-info/SOURCES.txt +1 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/pyproject.toml +1 -1
- hypha_debugger-0.1.6/tests/test_services.py +284 -0
- hypha_debugger-0.1.4/tests/test_services.py +0 -77
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/README.md +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/services/__init__.py +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/services/info.py +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/services/inspect_vars.py +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/utils/__init__.py +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger/utils/env.py +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger.egg-info/dependency_links.txt +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger.egg-info/entry_points.txt +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger.egg-info/requires.txt +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/hypha_debugger.egg-info/top_level.txt +0 -0
- {hypha_debugger-0.1.4 → hypha_debugger-0.1.6}/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,30 @@ 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
|
+
from hypha_debugger.services.source import get_source, get_skill_md
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger("hypha_debugger")
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
def _build_service_url(server_url: str, service_id: str) -> str:
|
|
23
|
-
"""Build
|
|
24
|
+
"""Build the HTTP service URL from server URL and full service ID.
|
|
24
25
|
|
|
25
26
|
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
27
|
|
|
28
28
|
Args:
|
|
29
29
|
server_url: e.g. "https://hypha.aicell.io"
|
|
30
|
-
service_id: e.g. "ws-xxx/clientId:py-debugger"
|
|
30
|
+
service_id: e.g. "ws-xxx/clientId:py-debugger-abc123"
|
|
31
31
|
|
|
32
32
|
Returns:
|
|
33
|
-
e.g. "https://hypha.aicell.io/ws-xxx/services/py-debugger"
|
|
33
|
+
e.g. "https://hypha.aicell.io/ws-xxx/services/py-debugger-abc123"
|
|
34
34
|
"""
|
|
35
35
|
base = server_url.rstrip("/")
|
|
36
36
|
# service_id format: "workspace/clientId:svcName"
|
|
37
37
|
parts = service_id.split("/", 1)
|
|
38
38
|
if len(parts) == 2:
|
|
39
39
|
workspace, svc_part = parts
|
|
40
|
-
# Strip clientId: "abc123:py-debugger" → "py-debugger"
|
|
40
|
+
# Strip clientId: "abc123:py-debugger-xyz" → "py-debugger-xyz"
|
|
41
41
|
if ":" in svc_part:
|
|
42
42
|
svc_name = svc_part.split(":", 1)[1]
|
|
43
43
|
else:
|
|
@@ -46,76 +46,87 @@ def _build_service_url(server_url: str, service_id: str) -> str:
|
|
|
46
46
|
return f"{base}/services/{service_id}"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def _build_instruction_block(
|
|
50
|
-
service_url: str,
|
|
51
|
-
token: str,
|
|
52
|
-
require_token: bool = True,
|
|
53
|
-
) -> str:
|
|
49
|
+
def _build_instruction_block(service_url: str, token: str = "") -> str:
|
|
54
50
|
"""Build the instruction block with copy-paste commands for remote access.
|
|
55
51
|
|
|
56
|
-
Returns a formatted string
|
|
52
|
+
Returns a formatted string that can be pasted directly into an AI agent chat.
|
|
57
53
|
"""
|
|
58
|
-
|
|
54
|
+
auth = f' -H "Authorization: Bearer $TOKEN"' if token else ""
|
|
59
55
|
lines = [
|
|
60
56
|
"# Hypha Remote Debugger — Python Process",
|
|
61
57
|
"# A debugger is attached to a running Python process.",
|
|
62
|
-
"# You can remotely
|
|
58
|
+
"# You can remotely execute code, read/write files, inspect variables,",
|
|
63
59
|
"# and query process info via the HTTP API below.",
|
|
64
60
|
"#",
|
|
65
|
-
"# Available functions:
|
|
66
|
-
"#
|
|
61
|
+
"# Available functions:",
|
|
62
|
+
"# get_process_info - PID, Python version, CWD, platform, memory",
|
|
63
|
+
"# execute_code - Run arbitrary Python code (with timeout)",
|
|
64
|
+
"# get_variable - Inspect a variable by name",
|
|
65
|
+
"# list_variables - List variables in a namespace",
|
|
66
|
+
"# get_stack_trace - Stack traces of all threads",
|
|
67
|
+
"# list_files - List directory contents",
|
|
68
|
+
"# read_file - Read file content (with offset/limit)",
|
|
69
|
+
"# write_file - Write/append to a file",
|
|
70
|
+
"# get_installed_packages - List pip packages",
|
|
71
|
+
"# get_source - Get debugger source code (for self-inspection)",
|
|
72
|
+
"# get_skill_md - Get full API docs as Markdown",
|
|
67
73
|
"#",
|
|
68
|
-
"#
|
|
74
|
+
"# POST endpoints accept JSON body with parameter names as keys.",
|
|
69
75
|
"",
|
|
70
76
|
f'SERVICE_URL="{service_url}"',
|
|
71
77
|
]
|
|
72
|
-
if
|
|
78
|
+
if token:
|
|
73
79
|
lines.append(f'TOKEN="{token}"')
|
|
74
80
|
lines += [
|
|
75
81
|
"",
|
|
76
82
|
"# Get process info (PID, Python version, CWD, memory):",
|
|
77
|
-
f'curl "$SERVICE_URL/get_process_info
|
|
83
|
+
f'curl "$SERVICE_URL/get_process_info"{auth}',
|
|
78
84
|
"",
|
|
79
|
-
"# Execute Python code
|
|
80
|
-
f
|
|
81
|
-
|
|
82
|
-
" -H \"Content-Type: application/json\""
|
|
85
|
+
"# Execute Python code (timeout default 30s):",
|
|
86
|
+
f'curl -X POST "$SERVICE_URL/execute_code"{auth}'
|
|
87
|
+
' -H "Content-Type: application/json"'
|
|
83
88
|
" -d '{\"code\": \"import sys; sys.version\"}'",
|
|
84
89
|
"",
|
|
85
|
-
"#
|
|
86
|
-
f
|
|
87
|
-
|
|
88
|
-
" -
|
|
89
|
-
" -d '{\"name\": \"my_var\"}'",
|
|
90
|
+
"# Write a file:",
|
|
91
|
+
f'curl -X POST "$SERVICE_URL/write_file"{auth}'
|
|
92
|
+
' -H "Content-Type: application/json"'
|
|
93
|
+
" -d '{\"path\": \"hello.txt\", \"content\": \"Hello, world!\"}'",
|
|
90
94
|
"",
|
|
91
95
|
"# List files in working directory:",
|
|
92
|
-
f'curl "$SERVICE_URL/list_files
|
|
96
|
+
f'curl "$SERVICE_URL/list_files"{auth}',
|
|
97
|
+
"",
|
|
98
|
+
"# Read a file:",
|
|
99
|
+
f'curl -X POST "$SERVICE_URL/read_file"{auth}'
|
|
100
|
+
' -H "Content-Type: application/json"'
|
|
101
|
+
" -d '{\"path\": \"hello.txt\"}'",
|
|
102
|
+
"",
|
|
103
|
+
"# Get full API documentation:",
|
|
104
|
+
f'curl "$SERVICE_URL/get_skill_md"{auth}',
|
|
93
105
|
]
|
|
94
106
|
return "\n".join(lines)
|
|
95
107
|
|
|
96
108
|
|
|
97
109
|
def _print_session_info(
|
|
98
110
|
server_url: str,
|
|
99
|
-
service_id: str,
|
|
100
111
|
service_url: str,
|
|
101
|
-
token: str,
|
|
102
|
-
require_token: bool = True,
|
|
112
|
+
token: str = "",
|
|
103
113
|
) -> None:
|
|
104
114
|
"""Print session information with remote access URLs."""
|
|
105
115
|
print(f"[hypha-debugger] Connected to {server_url}")
|
|
106
|
-
print(f"[hypha-debugger] Service ID: {service_id}")
|
|
107
116
|
print(f"[hypha-debugger] Service URL: {service_url}")
|
|
108
|
-
if
|
|
117
|
+
if token:
|
|
109
118
|
print(f"[hypha-debugger] Token: {token}")
|
|
110
119
|
print()
|
|
111
120
|
sep = "=" * 60
|
|
112
|
-
print(
|
|
113
|
-
print("
|
|
114
|
-
print(
|
|
121
|
+
print(sep)
|
|
122
|
+
print(" WARNING: The URL below grants full access to this Python")
|
|
123
|
+
print(" process (execute code, read/write files). Only share it")
|
|
124
|
+
print(" with trusted agents or people.")
|
|
125
|
+
print(sep)
|
|
115
126
|
print()
|
|
116
|
-
print(_build_instruction_block(service_url, token
|
|
127
|
+
print(_build_instruction_block(service_url, token))
|
|
117
128
|
print()
|
|
118
|
-
print(
|
|
129
|
+
print(sep)
|
|
119
130
|
|
|
120
131
|
|
|
121
132
|
@dataclass
|
|
@@ -135,17 +146,17 @@ class DebugSession:
|
|
|
135
146
|
|
|
136
147
|
def print_instructions(self) -> str:
|
|
137
148
|
"""Print copy-paste instructions for remote access and return them."""
|
|
138
|
-
instructions = _build_instruction_block(
|
|
139
|
-
self.service_url, self.token, bool(self.token)
|
|
140
|
-
)
|
|
149
|
+
instructions = _build_instruction_block(self.service_url, self.token)
|
|
141
150
|
sep = "=" * 60
|
|
142
|
-
print(
|
|
143
|
-
print("
|
|
144
|
-
print(
|
|
151
|
+
print(sep)
|
|
152
|
+
print(" WARNING: The URL below grants full access to this Python")
|
|
153
|
+
print(" process (execute code, read/write files). Only share it")
|
|
154
|
+
print(" with trusted agents or people.")
|
|
155
|
+
print(sep)
|
|
145
156
|
print()
|
|
146
157
|
print(instructions)
|
|
147
158
|
print()
|
|
148
|
-
print(
|
|
159
|
+
print(sep)
|
|
149
160
|
return instructions
|
|
150
161
|
|
|
151
162
|
async def serve_forever(self):
|
|
@@ -181,6 +192,27 @@ class DebugSession:
|
|
|
181
192
|
self._thread.join(timeout=5)
|
|
182
193
|
|
|
183
194
|
|
|
195
|
+
def _resolve_id_and_visibility(
|
|
196
|
+
service_id: str,
|
|
197
|
+
visibility: str,
|
|
198
|
+
require_token: bool,
|
|
199
|
+
) -> tuple:
|
|
200
|
+
"""Resolve effective service ID and visibility.
|
|
201
|
+
|
|
202
|
+
By default (require_token=False), generates a unique random service ID
|
|
203
|
+
and registers as unlisted. The URL itself acts as the secret.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
(effective_id, effective_visibility)
|
|
207
|
+
"""
|
|
208
|
+
effective_id = service_id
|
|
209
|
+
# Always add a random suffix unless user provided a custom ID
|
|
210
|
+
if service_id == "py-debugger":
|
|
211
|
+
effective_id = f"py-debugger-{secrets.token_hex(16)}"
|
|
212
|
+
effective_visibility = visibility or ("protected" if require_token else "unlisted")
|
|
213
|
+
return effective_id, effective_visibility
|
|
214
|
+
|
|
215
|
+
|
|
184
216
|
async def start_debugger(
|
|
185
217
|
server_url: str,
|
|
186
218
|
workspace: str = "",
|
|
@@ -188,35 +220,34 @@ async def start_debugger(
|
|
|
188
220
|
service_id: str = "py-debugger",
|
|
189
221
|
service_name: str = "Python Debugger",
|
|
190
222
|
visibility: str = "",
|
|
191
|
-
require_token: bool =
|
|
223
|
+
require_token: bool = False,
|
|
192
224
|
) -> DebugSession:
|
|
193
225
|
"""Start the Hypha debugger (async).
|
|
194
226
|
|
|
195
227
|
Connects to a Hypha server and registers a debug service that remote
|
|
196
228
|
clients can use to inspect and interact with this Python process.
|
|
197
229
|
|
|
230
|
+
By default, the service is registered as "unlisted" with a unique random
|
|
231
|
+
service ID. The URL itself is unguessable — no token needed. Just keep
|
|
232
|
+
the URL secret.
|
|
233
|
+
|
|
198
234
|
Args:
|
|
199
235
|
server_url: Hypha server URL (required).
|
|
200
236
|
workspace: Workspace name (auto-assigned if empty).
|
|
201
237
|
token: Authentication token for connecting to Hypha.
|
|
202
|
-
service_id: Service ID to register as.
|
|
238
|
+
service_id: Service ID to register as. A random suffix is added by default.
|
|
203
239
|
service_name: Human-readable service name.
|
|
204
240
|
visibility: Service visibility override ("public", "protected", "unlisted").
|
|
205
|
-
|
|
206
|
-
require_token: Whether remote callers must supply a JWT token (default True).
|
|
207
|
-
When False, the service is registered as "unlisted" and the service ID
|
|
208
|
-
gets a random suffix — the URL itself acts as the secret, no token needed.
|
|
241
|
+
require_token: Whether to generate a JWT token for remote callers (default False).
|
|
209
242
|
|
|
210
243
|
Returns:
|
|
211
244
|
A DebugSession with service_id, workspace, server, service_url, and token.
|
|
212
245
|
"""
|
|
213
246
|
from hypha_rpc import connect_to_server
|
|
214
247
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
effective_id = f"py-debugger-{secrets.token_hex(8)}"
|
|
219
|
-
effective_visibility = visibility or ("protected" if require_token else "unlisted")
|
|
248
|
+
effective_id, effective_visibility = _resolve_id_and_visibility(
|
|
249
|
+
service_id, visibility, require_token
|
|
250
|
+
)
|
|
220
251
|
|
|
221
252
|
connect_config = {"server_url": server_url}
|
|
222
253
|
if workspace:
|
|
@@ -246,13 +277,16 @@ async def start_debugger(
|
|
|
246
277
|
"get_stack_trace": get_stack_trace,
|
|
247
278
|
"list_files": list_files,
|
|
248
279
|
"read_file": read_file,
|
|
280
|
+
"write_file": write_file,
|
|
281
|
+
"get_source": get_source,
|
|
282
|
+
"get_skill_md": get_skill_md,
|
|
249
283
|
}
|
|
250
284
|
|
|
251
285
|
svc_info = await server.register_service(service)
|
|
252
286
|
actual_id = svc_info.get("id", effective_id) if isinstance(svc_info, dict) else effective_id
|
|
253
287
|
ws = server.config.get("workspace", workspace) if hasattr(server.config, "get") else getattr(server.config, "workspace", workspace)
|
|
254
288
|
|
|
255
|
-
# Generate a token for remote access (24h expiry)
|
|
289
|
+
# Generate a token for remote access (24h expiry) only if requested.
|
|
256
290
|
session_token = ""
|
|
257
291
|
if require_token:
|
|
258
292
|
session_token = await server.generate_token({"expires_in": 86400})
|
|
@@ -266,7 +300,7 @@ async def start_debugger(
|
|
|
266
300
|
ws,
|
|
267
301
|
actual_id,
|
|
268
302
|
)
|
|
269
|
-
_print_session_info(server_url,
|
|
303
|
+
_print_session_info(server_url, service_url, session_token)
|
|
270
304
|
|
|
271
305
|
return DebugSession(
|
|
272
306
|
service_id=actual_id,
|
|
@@ -285,7 +319,7 @@ def start_debugger_sync(
|
|
|
285
319
|
service_id: str = "py-debugger",
|
|
286
320
|
service_name: str = "Python Debugger",
|
|
287
321
|
visibility: str = "",
|
|
288
|
-
require_token: bool =
|
|
322
|
+
require_token: bool = False,
|
|
289
323
|
) -> DebugSession:
|
|
290
324
|
"""Start the Hypha debugger (sync).
|
|
291
325
|
|
|
@@ -300,11 +334,9 @@ def start_debugger_sync(
|
|
|
300
334
|
"""
|
|
301
335
|
from hypha_rpc.sync import connect_to_server
|
|
302
336
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
effective_id = f"py-debugger-{secrets.token_hex(8)}"
|
|
307
|
-
effective_visibility = visibility or ("protected" if require_token else "unlisted")
|
|
337
|
+
effective_id, effective_visibility = _resolve_id_and_visibility(
|
|
338
|
+
service_id, visibility, require_token
|
|
339
|
+
)
|
|
308
340
|
|
|
309
341
|
server = connect_to_server({"server_url": server_url, **({"workspace": workspace} if workspace else {}), **({"token": token} if token else {})})
|
|
310
342
|
|
|
@@ -328,13 +360,16 @@ def start_debugger_sync(
|
|
|
328
360
|
"get_stack_trace": get_stack_trace,
|
|
329
361
|
"list_files": list_files,
|
|
330
362
|
"read_file": read_file,
|
|
363
|
+
"write_file": write_file,
|
|
364
|
+
"get_source": get_source,
|
|
365
|
+
"get_skill_md": get_skill_md,
|
|
331
366
|
}
|
|
332
367
|
|
|
333
368
|
svc_info = server.register_service(service)
|
|
334
369
|
actual_id = svc_info.get("id", effective_id) if isinstance(svc_info, dict) else effective_id
|
|
335
370
|
ws = server.config.get("workspace", workspace) if hasattr(server.config, "get") else getattr(server.config, "workspace", workspace)
|
|
336
371
|
|
|
337
|
-
# Generate a token for remote access (24h expiry)
|
|
372
|
+
# Generate a token for remote access (24h expiry) only if requested.
|
|
338
373
|
session_token = ""
|
|
339
374
|
if require_token:
|
|
340
375
|
session_token = server.generate_token({"expires_in": 86400})
|
|
@@ -348,7 +383,7 @@ def start_debugger_sync(
|
|
|
348
383
|
ws,
|
|
349
384
|
actual_id,
|
|
350
385
|
)
|
|
351
|
-
_print_session_info(server_url,
|
|
386
|
+
_print_session_info(server_url, service_url, session_token)
|
|
352
387
|
|
|
353
388
|
return DebugSession(
|
|
354
389
|
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,187 @@
|
|
|
1
|
+
"""Source code and skill documentation service."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from hypha_rpc.utils.schema import schema_function
|
|
9
|
+
|
|
10
|
+
@schema_function
|
|
11
|
+
def get_source(
|
|
12
|
+
module: str = Field(
|
|
13
|
+
default="",
|
|
14
|
+
description=(
|
|
15
|
+
'Module path relative to hypha_debugger, e.g. "services.execute". '
|
|
16
|
+
'Empty string returns a list of available modules.'
|
|
17
|
+
),
|
|
18
|
+
),
|
|
19
|
+
) -> dict:
|
|
20
|
+
"""Get the source code of hypha-debugger modules.
|
|
21
|
+
|
|
22
|
+
Use this to understand exactly what functions are available, their
|
|
23
|
+
parameters, and how they work. Pass an empty string to list modules.
|
|
24
|
+
"""
|
|
25
|
+
return _get_source_impl(module)
|
|
26
|
+
|
|
27
|
+
@schema_function
|
|
28
|
+
def get_skill_md() -> str:
|
|
29
|
+
"""Get full API documentation for all debugger service functions.
|
|
30
|
+
|
|
31
|
+
Returns a Markdown document describing every available function,
|
|
32
|
+
its parameters, return values, and curl usage examples. Suitable
|
|
33
|
+
for pasting into an AI agent context.
|
|
34
|
+
"""
|
|
35
|
+
return _get_skill_md_impl()
|
|
36
|
+
|
|
37
|
+
except ImportError:
|
|
38
|
+
|
|
39
|
+
def get_source(module: str = "") -> dict:
|
|
40
|
+
"""Get the source code of hypha-debugger modules."""
|
|
41
|
+
return _get_source_impl(module)
|
|
42
|
+
|
|
43
|
+
def get_skill_md() -> str:
|
|
44
|
+
"""Get full API documentation for all debugger service functions."""
|
|
45
|
+
return _get_skill_md_impl()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_MODULES = {
|
|
49
|
+
"debugger": "hypha_debugger.debugger",
|
|
50
|
+
"services.execute": "hypha_debugger.services.execute",
|
|
51
|
+
"services.filesystem": "hypha_debugger.services.filesystem",
|
|
52
|
+
"services.info": "hypha_debugger.services.info",
|
|
53
|
+
"services.inspect_vars": "hypha_debugger.services.inspect_vars",
|
|
54
|
+
"services.source": "hypha_debugger.services.source",
|
|
55
|
+
"utils.env": "hypha_debugger.utils.env",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _get_source_impl(module: str) -> dict:
|
|
60
|
+
if not module:
|
|
61
|
+
return {
|
|
62
|
+
"modules": list(_MODULES.keys()),
|
|
63
|
+
"hint": 'Pass a module name, e.g. get_source(module="services.execute")',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
full_name = _MODULES.get(module)
|
|
67
|
+
if not full_name:
|
|
68
|
+
return {
|
|
69
|
+
"error": f"Unknown module: {module}",
|
|
70
|
+
"available": list(_MODULES.keys()),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
mod = __import__(full_name, fromlist=["_"])
|
|
75
|
+
source = inspect.getsource(mod)
|
|
76
|
+
return {
|
|
77
|
+
"module": module,
|
|
78
|
+
"full_name": full_name,
|
|
79
|
+
"source": source,
|
|
80
|
+
"lines": source.count("\n") + 1,
|
|
81
|
+
}
|
|
82
|
+
except Exception as e:
|
|
83
|
+
return {"error": f"Failed to get source: {e}"}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _get_skill_md_impl() -> str:
|
|
87
|
+
return """# Hypha Remote Debugger — Python Process API
|
|
88
|
+
|
|
89
|
+
## Overview
|
|
90
|
+
A debugger is attached to a running Python process. You can remotely execute code,
|
|
91
|
+
read/write files, inspect variables, and query process info via the HTTP API.
|
|
92
|
+
|
|
93
|
+
## Functions
|
|
94
|
+
|
|
95
|
+
### get_process_info()
|
|
96
|
+
Get information about the current Python process.
|
|
97
|
+
- **Returns**: `{pid, cwd, python_version, hostname, platform, memory_mb, cpu_count, ...}`
|
|
98
|
+
- **Example**: `curl "$SERVICE_URL/get_process_info"`
|
|
99
|
+
|
|
100
|
+
### execute_code(code, namespace?, timeout?)
|
|
101
|
+
Execute arbitrary Python code in the process.
|
|
102
|
+
- **code** (string, required): Python code to execute.
|
|
103
|
+
- **namespace** (string, default `"__main__"`): Module namespace to execute in.
|
|
104
|
+
- **timeout** (int, default `30`): Timeout in seconds. 0 for no timeout.
|
|
105
|
+
- **Returns**: `{stdout, stderr, result, result_type, error?, timed_out?}`
|
|
106
|
+
- Tries `eval()` first (returns value), falls back to `exec()` (returns None).
|
|
107
|
+
- **Example**:
|
|
108
|
+
```bash
|
|
109
|
+
curl -X POST "$SERVICE_URL/execute_code" \\
|
|
110
|
+
-H "Content-Type: application/json" \\
|
|
111
|
+
-d '{"code": "import sys; sys.version"}'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### get_variable(name, namespace?)
|
|
115
|
+
Inspect a variable by name.
|
|
116
|
+
- **name** (string, required): Variable name.
|
|
117
|
+
- **namespace** (string, default `"__main__"`): Module namespace.
|
|
118
|
+
- **Returns**: `{name, type, repr, length?, shape?, dtype?, keys?}`
|
|
119
|
+
|
|
120
|
+
### list_variables(namespace?, filter?, include_private?)
|
|
121
|
+
List variables in a namespace.
|
|
122
|
+
- **namespace** (string, default `"__main__"`): Module namespace.
|
|
123
|
+
- **filter** (string): Substring filter for names.
|
|
124
|
+
- **include_private** (bool, default `false`): Include `_`-prefixed names.
|
|
125
|
+
- **Returns**: List of `{name, type, repr}`.
|
|
126
|
+
|
|
127
|
+
### get_stack_trace()
|
|
128
|
+
Get stack traces of all threads.
|
|
129
|
+
- **Returns**: List of `{thread_id, thread_name, stack}`.
|
|
130
|
+
- **Example**: `curl "$SERVICE_URL/get_stack_trace"`
|
|
131
|
+
|
|
132
|
+
### list_files(path?, pattern?)
|
|
133
|
+
List files and directories (sandboxed to CWD).
|
|
134
|
+
- **path** (string, default `"."`): Directory path relative to CWD.
|
|
135
|
+
- **pattern** (string): Glob filter, e.g. `"*.py"`.
|
|
136
|
+
- **Returns**: `{path, entries: [{name, type, size?}], total}`
|
|
137
|
+
- **Example**: `curl "$SERVICE_URL/list_files"`
|
|
138
|
+
|
|
139
|
+
### read_file(path, max_lines?, offset?, encoding?)
|
|
140
|
+
Read a file (sandboxed to CWD).
|
|
141
|
+
- **path** (string, required): File path relative to CWD.
|
|
142
|
+
- **max_lines** (int, default `500`): Max lines to read.
|
|
143
|
+
- **offset** (int, default `0`): Lines to skip from beginning.
|
|
144
|
+
- **Returns**: `{path, content, lines_read, offset, truncated}`
|
|
145
|
+
- **Example**:
|
|
146
|
+
```bash
|
|
147
|
+
curl -X POST "$SERVICE_URL/read_file" \\
|
|
148
|
+
-H "Content-Type: application/json" \\
|
|
149
|
+
-d '{"path": "main.py"}'
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### write_file(path, content, mode?, create_dirs?, encoding?)
|
|
153
|
+
Write content to a file (sandboxed to CWD).
|
|
154
|
+
- **path** (string, required): File path relative to CWD.
|
|
155
|
+
- **content** (string, required): Content to write.
|
|
156
|
+
- **mode** (string, default `"w"`): `"w"` to overwrite, `"a"` to append.
|
|
157
|
+
- **create_dirs** (bool, default `true`): Create parent directories.
|
|
158
|
+
- **Returns**: `{path, bytes_written, mode}`
|
|
159
|
+
- **Example**:
|
|
160
|
+
```bash
|
|
161
|
+
curl -X POST "$SERVICE_URL/write_file" \\
|
|
162
|
+
-H "Content-Type: application/json" \\
|
|
163
|
+
-d '{"path": "hello.txt", "content": "Hello!"}'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### get_installed_packages(filter?)
|
|
167
|
+
List installed pip packages.
|
|
168
|
+
- **filter** (string): Substring filter for package names.
|
|
169
|
+
- **Returns**: List of `{name, version}`.
|
|
170
|
+
- **Example**: `curl "$SERVICE_URL/get_installed_packages"`
|
|
171
|
+
|
|
172
|
+
### get_source(module?)
|
|
173
|
+
Get the source code of debugger modules.
|
|
174
|
+
- **module** (string): Module path, e.g. `"services.execute"`. Empty to list modules.
|
|
175
|
+
- **Returns**: `{module, source, lines}` or `{modules, hint}`.
|
|
176
|
+
|
|
177
|
+
### get_skill_md()
|
|
178
|
+
Get this API documentation as Markdown.
|
|
179
|
+
- **Returns**: This document as a string.
|
|
180
|
+
- **Example**: `curl "$SERVICE_URL/get_skill_md"`
|
|
181
|
+
|
|
182
|
+
## Notes
|
|
183
|
+
- All file operations are sandboxed to the process CWD.
|
|
184
|
+
- POST endpoints accept JSON body with parameter names as keys.
|
|
185
|
+
- GET endpoints take no parameters (or use query params).
|
|
186
|
+
- Code execution has a default 30s timeout to prevent hangs.
|
|
187
|
+
"""
|
|
@@ -14,6 +14,7 @@ hypha_debugger/services/execute.py
|
|
|
14
14
|
hypha_debugger/services/filesystem.py
|
|
15
15
|
hypha_debugger/services/info.py
|
|
16
16
|
hypha_debugger/services/inspect_vars.py
|
|
17
|
+
hypha_debugger/services/source.py
|
|
17
18
|
hypha_debugger/utils/__init__.py
|
|
18
19
|
hypha_debugger/utils/env.py
|
|
19
20
|
tests/test_services.py
|
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
# --- source ---
|
|
229
|
+
|
|
230
|
+
def test_get_source_list_modules():
|
|
231
|
+
from hypha_debugger.services.source import get_source
|
|
232
|
+
result = get_source("")
|
|
233
|
+
assert "modules" in result
|
|
234
|
+
assert "services.execute" in result["modules"]
|
|
235
|
+
assert "services.filesystem" in result["modules"]
|
|
236
|
+
assert "debugger" in result["modules"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_get_source_module():
|
|
240
|
+
from hypha_debugger.services.source import get_source
|
|
241
|
+
result = get_source("services.execute")
|
|
242
|
+
assert "source" in result
|
|
243
|
+
assert "execute_code" in result["source"]
|
|
244
|
+
assert result["lines"] > 10
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_get_source_unknown():
|
|
248
|
+
from hypha_debugger.services.source import get_source
|
|
249
|
+
result = get_source("nonexistent")
|
|
250
|
+
assert "error" in result
|
|
251
|
+
assert "available" in result
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_get_skill_md():
|
|
255
|
+
from hypha_debugger.services.source import get_skill_md
|
|
256
|
+
md = get_skill_md()
|
|
257
|
+
assert isinstance(md, str)
|
|
258
|
+
assert "execute_code" in md
|
|
259
|
+
assert "write_file" in md
|
|
260
|
+
assert "get_source" in md
|
|
261
|
+
assert "get_skill_md" in md
|
|
262
|
+
assert "SERVICE_URL" in md
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_instruction_block_includes_new_functions():
|
|
266
|
+
from hypha_debugger.debugger import _build_instruction_block
|
|
267
|
+
block = _build_instruction_block("https://example.com/ws/services/py-debugger-abc")
|
|
268
|
+
assert "get_source" in block
|
|
269
|
+
assert "get_skill_md" in block
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def test_debug_session_print_instructions(capsys):
|
|
273
|
+
from hypha_debugger.debugger import DebugSession
|
|
274
|
+
session = DebugSession(
|
|
275
|
+
service_id="test", workspace="test", server=None,
|
|
276
|
+
service_url="https://example.com/ws/services/py-debugger-abc",
|
|
277
|
+
)
|
|
278
|
+
result = session.print_instructions()
|
|
279
|
+
captured = capsys.readouterr()
|
|
280
|
+
assert "WARNING" in captured.out
|
|
281
|
+
assert "trusted" in captured.out
|
|
282
|
+
assert "SERVICE_URL=" in captured.out
|
|
283
|
+
assert isinstance(result, str)
|
|
284
|
+
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
|