hypha-debugger 0.1.4__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.
Files changed (22) hide show
  1. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/PKG-INFO +1 -1
  2. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/__init__.py +1 -1
  3. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/__main__.py +3 -3
  4. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/debugger.py +92 -67
  5. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/services/execute.py +51 -14
  6. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/services/filesystem.py +75 -7
  7. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/PKG-INFO +1 -1
  8. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/pyproject.toml +1 -1
  9. hypha_debugger-0.1.5/tests/test_services.py +240 -0
  10. hypha_debugger-0.1.4/tests/test_services.py +0 -77
  11. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/README.md +0 -0
  12. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/services/__init__.py +0 -0
  13. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/services/info.py +0 -0
  14. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/services/inspect_vars.py +0 -0
  15. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/utils/__init__.py +0 -0
  16. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger/utils/env.py +0 -0
  17. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/SOURCES.txt +0 -0
  18. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/dependency_links.txt +0 -0
  19. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/entry_points.txt +0 -0
  20. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/requires.txt +0 -0
  21. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/hypha_debugger.egg-info/top_level.txt +0 -0
  22. {hypha_debugger-0.1.4 → hypha_debugger-0.1.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha-debugger
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Injectable debugger for Python processes and AI agents, powered by Hypha RPC
5
5
  Author: Amun AI AB
6
6
  License: MIT
@@ -12,5 +12,5 @@ Usage (sync):
12
12
 
13
13
  from hypha_debugger.debugger import start_debugger, start_debugger_sync, DebugSession
14
14
 
15
- __version__ = "0.1.4"
15
+ __version__ = "0.1.5"
16
16
  __all__ = ["start_debugger", "start_debugger_sync", "DebugSession", "__version__"]
@@ -42,9 +42,9 @@ def main():
42
42
  help="Human-readable service name (default: Python Debugger)",
43
43
  )
44
44
  parser.add_argument(
45
- "--no-token",
45
+ "--require-token",
46
46
  action="store_true",
47
- help="Disable token requirement (URL-secret mode)",
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=not args.no_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 a stable, predictable HTTP service URL.
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,76 +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 similar to the JS overlay instructions.
51
+ Returns a formatted string that can be pasted directly into an AI agent chat.
57
52
  """
58
- auth_header = f' -H "Authorization: Bearer $TOKEN"' if require_token else ""
53
+ auth = f' -H "Authorization: Bearer $TOKEN"' if token else ""
59
54
  lines = [
60
55
  "# Hypha Remote Debugger — Python Process",
61
56
  "# A debugger is attached to a running Python process.",
62
- "# You can remotely inspect variables, execute code, browse files,",
57
+ "# You can remotely execute code, read/write files, inspect variables,",
63
58
  "# and query process info via the HTTP API below.",
64
59
  "#",
65
- "# Available functions: get_process_info, execute_code, get_variable,",
66
- "# list_variables, get_stack_trace, list_files, read_file, get_installed_packages",
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",
67
70
  "#",
68
- "# All endpoints require ?_mode=last suffix. POST endpoints accept JSON body.",
71
+ "# POST endpoints accept JSON body with parameter names as keys.",
69
72
  "",
70
73
  f'SERVICE_URL="{service_url}"',
71
74
  ]
72
- if require_token:
75
+ if token:
73
76
  lines.append(f'TOKEN="{token}"')
74
77
  lines += [
75
78
  "",
76
79
  "# Get process info (PID, Python version, CWD, memory):",
77
- f'curl "$SERVICE_URL/get_process_info?_mode=last"{auth_header}',
80
+ f'curl "$SERVICE_URL/get_process_info"{auth}',
78
81
  "",
79
- "# Execute Python code remotely:",
80
- f"curl -X POST \"$SERVICE_URL/execute_code?_mode=last\""
81
- f"{auth_header}"
82
- " -H \"Content-Type: application/json\""
82
+ "# Execute Python code (timeout default 30s):",
83
+ f'curl -X POST "$SERVICE_URL/execute_code"{auth}'
84
+ ' -H "Content-Type: application/json"'
83
85
  " -d '{\"code\": \"import sys; sys.version\"}'",
84
86
  "",
85
- "# Inspect a variable:",
86
- f"curl -X POST \"$SERVICE_URL/get_variable?_mode=last\""
87
- f"{auth_header}"
88
- " -H \"Content-Type: application/json\""
89
- " -d '{\"name\": \"my_var\"}'",
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!\"}'",
90
91
  "",
91
92
  "# List files in working directory:",
92
- f'curl "$SERVICE_URL/list_files?_mode=last"{auth_header}',
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\"}'",
93
99
  ]
94
100
  return "\n".join(lines)
95
101
 
96
102
 
97
103
  def _print_session_info(
98
104
  server_url: str,
99
- service_id: str,
100
105
  service_url: str,
101
- token: str,
102
- require_token: bool = True,
106
+ token: str = "",
103
107
  ) -> None:
104
108
  """Print session information with remote access URLs."""
105
109
  print(f"[hypha-debugger] Connected to {server_url}")
106
- print(f"[hypha-debugger] Service ID: {service_id}")
107
110
  print(f"[hypha-debugger] Service URL: {service_url}")
108
- if require_token:
111
+ if token:
109
112
  print(f"[hypha-debugger] Token: {token}")
110
113
  print()
111
114
  sep = "=" * 60
112
- print(f"{sep}")
113
- print(" Copy the text below to your AI agent")
114
- print(f"{sep}")
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)
115
120
  print()
116
- print(_build_instruction_block(service_url, token, require_token))
121
+ print(_build_instruction_block(service_url, token))
117
122
  print()
118
- print(f"{sep}")
123
+ print(sep)
119
124
 
120
125
 
121
126
  @dataclass
@@ -135,17 +140,17 @@ class DebugSession:
135
140
 
136
141
  def print_instructions(self) -> str:
137
142
  """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
- )
143
+ instructions = _build_instruction_block(self.service_url, self.token)
141
144
  sep = "=" * 60
142
- print(f"{sep}")
143
- print(" Copy the text below to your AI agent")
144
- print(f"{sep}")
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)
145
150
  print()
146
151
  print(instructions)
147
152
  print()
148
- print(f"{sep}")
153
+ print(sep)
149
154
  return instructions
150
155
 
151
156
  async def serve_forever(self):
@@ -181,6 +186,27 @@ class DebugSession:
181
186
  self._thread.join(timeout=5)
182
187
 
183
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
+
184
210
  async def start_debugger(
185
211
  server_url: str,
186
212
  workspace: str = "",
@@ -188,35 +214,34 @@ async def start_debugger(
188
214
  service_id: str = "py-debugger",
189
215
  service_name: str = "Python Debugger",
190
216
  visibility: str = "",
191
- require_token: bool = True,
217
+ require_token: bool = False,
192
218
  ) -> DebugSession:
193
219
  """Start the Hypha debugger (async).
194
220
 
195
221
  Connects to a Hypha server and registers a debug service that remote
196
222
  clients can use to inspect and interact with this Python process.
197
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
+
198
228
  Args:
199
229
  server_url: Hypha server URL (required).
200
230
  workspace: Workspace name (auto-assigned if empty).
201
231
  token: Authentication token for connecting to Hypha.
202
- service_id: Service ID to register as.
232
+ service_id: Service ID to register as. A random suffix is added by default.
203
233
  service_name: Human-readable service name.
204
234
  visibility: Service visibility override ("public", "protected", "unlisted").
205
- Defaults to "protected" when require_token=True, "unlisted" when False.
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.
235
+ require_token: Whether to generate a JWT token for remote callers (default False).
209
236
 
210
237
  Returns:
211
238
  A DebugSession with service_id, workspace, server, service_url, and token.
212
239
  """
213
240
  from hypha_rpc import connect_to_server
214
241
 
215
- # Resolve effective service_id and visibility based on require_token.
216
- effective_id = service_id
217
- if not require_token and service_id == "py-debugger":
218
- effective_id = f"py-debugger-{secrets.token_hex(8)}"
219
- 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
+ )
220
245
 
221
246
  connect_config = {"server_url": server_url}
222
247
  if workspace:
@@ -246,13 +271,14 @@ async def start_debugger(
246
271
  "get_stack_trace": get_stack_trace,
247
272
  "list_files": list_files,
248
273
  "read_file": read_file,
274
+ "write_file": write_file,
249
275
  }
250
276
 
251
277
  svc_info = await server.register_service(service)
252
278
  actual_id = svc_info.get("id", effective_id) if isinstance(svc_info, dict) else effective_id
253
279
  ws = server.config.get("workspace", workspace) if hasattr(server.config, "get") else getattr(server.config, "workspace", workspace)
254
280
 
255
- # Generate a token for remote access (24h expiry); skip in no-token mode.
281
+ # Generate a token for remote access (24h expiry) only if requested.
256
282
  session_token = ""
257
283
  if require_token:
258
284
  session_token = await server.generate_token({"expires_in": 86400})
@@ -266,7 +292,7 @@ async def start_debugger(
266
292
  ws,
267
293
  actual_id,
268
294
  )
269
- _print_session_info(server_url, actual_id, service_url, session_token, require_token)
295
+ _print_session_info(server_url, service_url, session_token)
270
296
 
271
297
  return DebugSession(
272
298
  service_id=actual_id,
@@ -285,7 +311,7 @@ def start_debugger_sync(
285
311
  service_id: str = "py-debugger",
286
312
  service_name: str = "Python Debugger",
287
313
  visibility: str = "",
288
- require_token: bool = True,
314
+ require_token: bool = False,
289
315
  ) -> DebugSession:
290
316
  """Start the Hypha debugger (sync).
291
317
 
@@ -300,11 +326,9 @@ def start_debugger_sync(
300
326
  """
301
327
  from hypha_rpc.sync import connect_to_server
302
328
 
303
- # Resolve effective service_id and visibility based on require_token.
304
- effective_id = service_id
305
- if not require_token and service_id == "py-debugger":
306
- effective_id = f"py-debugger-{secrets.token_hex(8)}"
307
- 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
+ )
308
332
 
309
333
  server = connect_to_server({"server_url": server_url, **({"workspace": workspace} if workspace else {}), **({"token": token} if token else {})})
310
334
 
@@ -328,13 +352,14 @@ def start_debugger_sync(
328
352
  "get_stack_trace": get_stack_trace,
329
353
  "list_files": list_files,
330
354
  "read_file": read_file,
355
+ "write_file": write_file,
331
356
  }
332
357
 
333
358
  svc_info = server.register_service(service)
334
359
  actual_id = svc_info.get("id", effective_id) if isinstance(svc_info, dict) else effective_id
335
360
  ws = server.config.get("workspace", workspace) if hasattr(server.config, "get") else getattr(server.config, "workspace", workspace)
336
361
 
337
- # Generate a token for remote access (24h expiry); skip in no-token mode.
362
+ # Generate a token for remote access (24h expiry) only if requested.
338
363
  session_token = ""
339
364
  if require_token:
340
365
  session_token = server.generate_token({"expires_in": 86400})
@@ -348,7 +373,7 @@ def start_debugger_sync(
348
373
  ws,
349
374
  actual_id,
350
375
  )
351
- _print_session_info(server_url, actual_id, service_url, session_token, require_token)
376
+ _print_session_info(server_url, service_url, session_token)
352
377
 
353
378
  return DebugSession(
354
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 of the last expression."""
20
- return _execute_impl(code, namespace)
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
- # Try as expression first (to capture return value)
51
- try:
52
- result = eval(code, ns)
53
- except SyntaxError:
54
- # Not an expression, execute as statements
55
- exec(code, ns)
56
- result = None
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
- return _read_file_impl(path, max_lines, encoding)
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 >= max_lines:
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}"}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hypha-debugger
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Injectable debugger for Python processes and AI agents, powered by Hypha RPC
5
5
  Author: Amun AI AB
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hypha-debugger"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "Injectable debugger for Python processes and AI agents, powered by Hypha RPC"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -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