hopx-ai 0.1.15__py3-none-any.whl
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.
Potentially problematic release.
This version of hopx-ai might be problematic. Click here for more details.
- hopx_ai/__init__.py +114 -0
- hopx_ai/_agent_client.py +391 -0
- hopx_ai/_async_agent_client.py +223 -0
- hopx_ai/_async_cache.py +38 -0
- hopx_ai/_async_client.py +230 -0
- hopx_ai/_async_commands.py +58 -0
- hopx_ai/_async_env_vars.py +151 -0
- hopx_ai/_async_files.py +81 -0
- hopx_ai/_async_files_clean.py +489 -0
- hopx_ai/_async_terminal.py +184 -0
- hopx_ai/_client.py +230 -0
- hopx_ai/_generated/__init__.py +22 -0
- hopx_ai/_generated/models.py +502 -0
- hopx_ai/_temp_async_token.py +14 -0
- hopx_ai/_test_env_fix.py +30 -0
- hopx_ai/_utils.py +9 -0
- hopx_ai/_ws_client.py +141 -0
- hopx_ai/async_sandbox.py +763 -0
- hopx_ai/cache.py +97 -0
- hopx_ai/commands.py +174 -0
- hopx_ai/desktop.py +1227 -0
- hopx_ai/env_vars.py +244 -0
- hopx_ai/errors.py +249 -0
- hopx_ai/files.py +489 -0
- hopx_ai/models.py +274 -0
- hopx_ai/models_updated.py +270 -0
- hopx_ai/sandbox.py +1447 -0
- hopx_ai/template/__init__.py +47 -0
- hopx_ai/template/build_flow.py +540 -0
- hopx_ai/template/builder.py +300 -0
- hopx_ai/template/file_hasher.py +81 -0
- hopx_ai/template/ready_checks.py +106 -0
- hopx_ai/template/tar_creator.py +122 -0
- hopx_ai/template/types.py +199 -0
- hopx_ai/terminal.py +164 -0
- hopx_ai-0.1.15.dist-info/METADATA +462 -0
- hopx_ai-0.1.15.dist-info/RECORD +38 -0
- hopx_ai-0.1.15.dist-info/WHEEL +4 -0
hopx_ai/sandbox.py
ADDED
|
@@ -0,0 +1,1447 @@
|
|
|
1
|
+
"""Main Sandbox class - E2B inspired pattern."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, List, Iterator, Dict, Any
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
# Public API models (enhanced with generated models + convenience)
|
|
9
|
+
from .models import (
|
|
10
|
+
SandboxInfo,
|
|
11
|
+
Template,
|
|
12
|
+
ExecutionResult, # ExecuteResponse + convenience methods
|
|
13
|
+
RichOutput,
|
|
14
|
+
MetricsSnapshot,
|
|
15
|
+
Language,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ._client import HTTPClient
|
|
19
|
+
from ._agent_client import AgentHTTPClient
|
|
20
|
+
from ._utils import remove_none_values
|
|
21
|
+
from .files import Files
|
|
22
|
+
from .commands import Commands
|
|
23
|
+
from .desktop import Desktop
|
|
24
|
+
from .env_vars import EnvironmentVariables
|
|
25
|
+
from .cache import Cache
|
|
26
|
+
from ._ws_client import WebSocketClient
|
|
27
|
+
from .terminal import Terminal
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TokenData:
|
|
34
|
+
"""JWT token storage."""
|
|
35
|
+
token: str
|
|
36
|
+
expires_at: datetime
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Global token cache (shared across all Sandbox instances)
|
|
40
|
+
_token_cache: Dict[str, TokenData] = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Sandbox:
|
|
44
|
+
"""
|
|
45
|
+
Bunnyshell Sandbox - lightweight VM management.
|
|
46
|
+
|
|
47
|
+
Create and manage sandboxes (microVMs) with a simple, intuitive API.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> from bunnyshell import Sandbox
|
|
51
|
+
>>>
|
|
52
|
+
>>> # Create sandbox
|
|
53
|
+
>>> sandbox = Sandbox.create(template="code-interpreter")
|
|
54
|
+
>>> print(sandbox.get_info().public_host)
|
|
55
|
+
>>>
|
|
56
|
+
>>> # Use and cleanup
|
|
57
|
+
>>> sandbox.kill()
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
sandbox_id: str,
|
|
63
|
+
*,
|
|
64
|
+
api_key: Optional[str] = None,
|
|
65
|
+
base_url: str = "https://api.hopx.dev",
|
|
66
|
+
timeout: int = 60,
|
|
67
|
+
max_retries: int = 3,
|
|
68
|
+
):
|
|
69
|
+
"""
|
|
70
|
+
Initialize Sandbox instance.
|
|
71
|
+
|
|
72
|
+
Note: Prefer using Sandbox.create() or Sandbox.connect() instead of direct instantiation.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
sandbox_id: Sandbox ID
|
|
76
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
77
|
+
base_url: API base URL
|
|
78
|
+
timeout: Request timeout in seconds
|
|
79
|
+
max_retries: Maximum number of retries
|
|
80
|
+
"""
|
|
81
|
+
self.sandbox_id = sandbox_id
|
|
82
|
+
self._client = HTTPClient(
|
|
83
|
+
api_key=api_key,
|
|
84
|
+
base_url=base_url,
|
|
85
|
+
timeout=timeout,
|
|
86
|
+
max_retries=max_retries,
|
|
87
|
+
)
|
|
88
|
+
self._agent_client: Optional[AgentHTTPClient] = None
|
|
89
|
+
self._ws_client: Optional[WebSocketClient] = None
|
|
90
|
+
self._files: Optional[Files] = None
|
|
91
|
+
self._commands: Optional[Commands] = None
|
|
92
|
+
self._desktop: Optional[Desktop] = None
|
|
93
|
+
self._env: Optional[EnvironmentVariables] = None
|
|
94
|
+
self._cache: Optional[Cache] = None
|
|
95
|
+
self._terminal: Optional[Terminal] = None
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def files(self) -> Files:
|
|
99
|
+
"""
|
|
100
|
+
File operations resource.
|
|
101
|
+
|
|
102
|
+
Lazy initialization - gets agent URL on first access.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Files resource instance
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
>>> sandbox = Sandbox.create(template="code-interpreter")
|
|
109
|
+
>>> content = sandbox.files.read('/workspace/data.txt')
|
|
110
|
+
"""
|
|
111
|
+
if self._files is None:
|
|
112
|
+
self._ensure_agent_client()
|
|
113
|
+
# WS client is lazy-loaded in Files.watch() - not needed for basic operations
|
|
114
|
+
self._files = Files(self._agent_client, self)
|
|
115
|
+
return self._files
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def commands(self) -> Commands:
|
|
119
|
+
"""
|
|
120
|
+
Command execution resource.
|
|
121
|
+
|
|
122
|
+
Lazy initialization - gets agent URL on first access.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Commands resource instance
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
>>> sandbox = Sandbox.create(template="nodejs")
|
|
129
|
+
>>> result = sandbox.commands.run('npm install')
|
|
130
|
+
"""
|
|
131
|
+
if self._commands is None:
|
|
132
|
+
self._ensure_agent_client()
|
|
133
|
+
self._commands = Commands(self._agent_client)
|
|
134
|
+
return self._commands
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def desktop(self) -> Desktop:
|
|
138
|
+
"""
|
|
139
|
+
Desktop automation resource.
|
|
140
|
+
|
|
141
|
+
Lazy initialization - checks desktop availability on first access.
|
|
142
|
+
|
|
143
|
+
Provides methods for:
|
|
144
|
+
- VNC server management
|
|
145
|
+
- Mouse and keyboard control
|
|
146
|
+
- Screenshot capture
|
|
147
|
+
- Screen recording
|
|
148
|
+
- Window management
|
|
149
|
+
- Display configuration
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Desktop resource instance
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
DesktopNotAvailableError: If template doesn't support desktop automation
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
>>> sandbox = Sandbox.create(template="desktop")
|
|
159
|
+
>>>
|
|
160
|
+
>>> # Start VNC
|
|
161
|
+
>>> vnc_info = sandbox.desktop.start_vnc()
|
|
162
|
+
>>> print(f"VNC at: {vnc_info.url}")
|
|
163
|
+
>>>
|
|
164
|
+
>>> # Mouse control
|
|
165
|
+
>>> sandbox.desktop.click(100, 100)
|
|
166
|
+
>>> sandbox.desktop.type("Hello World")
|
|
167
|
+
>>>
|
|
168
|
+
>>> # Screenshot
|
|
169
|
+
>>> img = sandbox.desktop.screenshot()
|
|
170
|
+
>>> with open('screen.png', 'wb') as f:
|
|
171
|
+
... f.write(img)
|
|
172
|
+
>>>
|
|
173
|
+
>>> # If desktop not available:
|
|
174
|
+
>>> try:
|
|
175
|
+
... sandbox.desktop.click(100, 100)
|
|
176
|
+
... except DesktopNotAvailableError as e:
|
|
177
|
+
... print(e.message)
|
|
178
|
+
... print(e.install_command)
|
|
179
|
+
"""
|
|
180
|
+
if self._desktop is None:
|
|
181
|
+
self._ensure_agent_client()
|
|
182
|
+
self._desktop = Desktop(self._agent_client)
|
|
183
|
+
return self._desktop
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def env(self) -> EnvironmentVariables:
|
|
187
|
+
"""
|
|
188
|
+
Environment variables resource.
|
|
189
|
+
|
|
190
|
+
Lazy initialization - gets agent URL on first access.
|
|
191
|
+
|
|
192
|
+
Provides methods for:
|
|
193
|
+
- Get all environment variables
|
|
194
|
+
- Set/replace all environment variables
|
|
195
|
+
- Update specific environment variables (merge)
|
|
196
|
+
- Delete environment variables
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
EnvironmentVariables resource instance
|
|
200
|
+
|
|
201
|
+
Example:
|
|
202
|
+
>>> sandbox = Sandbox.create(template="code-interpreter")
|
|
203
|
+
>>>
|
|
204
|
+
>>> # Get all environment variables
|
|
205
|
+
>>> env = sandbox.env.get_all()
|
|
206
|
+
>>> print(env.get("PATH"))
|
|
207
|
+
>>>
|
|
208
|
+
>>> # Set a single variable
|
|
209
|
+
>>> sandbox.env.set("API_KEY", "sk-prod-xyz")
|
|
210
|
+
>>>
|
|
211
|
+
>>> # Update multiple variables (merge)
|
|
212
|
+
>>> sandbox.env.update({
|
|
213
|
+
... "NODE_ENV": "production",
|
|
214
|
+
... "DEBUG": "false"
|
|
215
|
+
... })
|
|
216
|
+
>>>
|
|
217
|
+
>>> # Get a specific variable
|
|
218
|
+
>>> api_key = sandbox.env.get("API_KEY")
|
|
219
|
+
>>>
|
|
220
|
+
>>> # Delete a variable
|
|
221
|
+
>>> sandbox.env.delete("DEBUG")
|
|
222
|
+
"""
|
|
223
|
+
if self._env is None:
|
|
224
|
+
self._ensure_agent_client()
|
|
225
|
+
self._env = EnvironmentVariables(self._agent_client)
|
|
226
|
+
return self._env
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def cache(self) -> Cache:
|
|
230
|
+
"""
|
|
231
|
+
Cache management resource.
|
|
232
|
+
|
|
233
|
+
Lazy initialization - gets agent URL on first access.
|
|
234
|
+
|
|
235
|
+
Provides methods for:
|
|
236
|
+
- Get cache statistics
|
|
237
|
+
- Clear cache
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Cache resource instance
|
|
241
|
+
|
|
242
|
+
Example:
|
|
243
|
+
>>> sandbox = Sandbox.create(template="code-interpreter")
|
|
244
|
+
>>>
|
|
245
|
+
>>> # Get cache stats
|
|
246
|
+
>>> stats = sandbox.cache.stats()
|
|
247
|
+
>>> print(f"Cache hits: {stats['hits']}")
|
|
248
|
+
>>> print(f"Cache size: {stats['size']} MB")
|
|
249
|
+
>>>
|
|
250
|
+
>>> # Clear cache
|
|
251
|
+
>>> sandbox.cache.clear()
|
|
252
|
+
"""
|
|
253
|
+
if self._cache is None:
|
|
254
|
+
self._ensure_agent_client()
|
|
255
|
+
self._cache = Cache(self._agent_client)
|
|
256
|
+
return self._cache
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def terminal(self) -> Terminal:
|
|
260
|
+
"""
|
|
261
|
+
Interactive terminal resource via WebSocket.
|
|
262
|
+
|
|
263
|
+
Lazy initialization - gets agent URL and WebSocket client on first access.
|
|
264
|
+
|
|
265
|
+
Provides methods for:
|
|
266
|
+
- Connect to interactive terminal
|
|
267
|
+
- Send input to terminal
|
|
268
|
+
- Resize terminal
|
|
269
|
+
- Receive output stream
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Terminal resource instance
|
|
273
|
+
|
|
274
|
+
Note:
|
|
275
|
+
Requires websockets library: pip install websockets
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
>>> import asyncio
|
|
279
|
+
>>>
|
|
280
|
+
>>> async def run_terminal():
|
|
281
|
+
... sandbox = Sandbox.create(template="code-interpreter")
|
|
282
|
+
...
|
|
283
|
+
... # Connect to terminal
|
|
284
|
+
... async with await sandbox.terminal.connect() as ws:
|
|
285
|
+
... # Send command
|
|
286
|
+
... await sandbox.terminal.send_input(ws, "ls -la\\n")
|
|
287
|
+
...
|
|
288
|
+
... # Receive output
|
|
289
|
+
... async for message in sandbox.terminal.iter_output(ws):
|
|
290
|
+
... if message['type'] == 'output':
|
|
291
|
+
... print(message['data'], end='')
|
|
292
|
+
... elif message['type'] == 'exit':
|
|
293
|
+
... break
|
|
294
|
+
>>>
|
|
295
|
+
>>> asyncio.run(run_terminal())
|
|
296
|
+
"""
|
|
297
|
+
if self._terminal is None:
|
|
298
|
+
self._ensure_ws_client()
|
|
299
|
+
self._terminal = Terminal(self._ws_client)
|
|
300
|
+
return self._terminal
|
|
301
|
+
|
|
302
|
+
def _ensure_agent_client(self) -> None:
|
|
303
|
+
"""Ensure agent HTTP client is initialized."""
|
|
304
|
+
if self._agent_client is None:
|
|
305
|
+
info = self.get_info()
|
|
306
|
+
agent_url = info.public_host.rstrip('/')
|
|
307
|
+
|
|
308
|
+
# Ensure JWT token is valid
|
|
309
|
+
self._ensure_valid_token()
|
|
310
|
+
|
|
311
|
+
# Get JWT token for agent authentication
|
|
312
|
+
jwt_token = _token_cache.get(self.sandbox_id)
|
|
313
|
+
jwt_token_str = jwt_token.token if jwt_token else None
|
|
314
|
+
|
|
315
|
+
# Create agent client with token refresh callback
|
|
316
|
+
def refresh_token_callback():
|
|
317
|
+
"""Callback to refresh token when agent returns 401."""
|
|
318
|
+
self.refresh_token()
|
|
319
|
+
token_data = _token_cache.get(self.sandbox_id)
|
|
320
|
+
return token_data.token if token_data else None
|
|
321
|
+
|
|
322
|
+
self._agent_client = AgentHTTPClient(
|
|
323
|
+
agent_url=agent_url,
|
|
324
|
+
jwt_token=jwt_token_str,
|
|
325
|
+
timeout=60, # Default 60s for agent operations
|
|
326
|
+
max_retries=3,
|
|
327
|
+
token_refresh_callback=refresh_token_callback
|
|
328
|
+
)
|
|
329
|
+
logger.debug(f"Agent client initialized: {agent_url}")
|
|
330
|
+
|
|
331
|
+
# Wait for agent to be ready on first access
|
|
332
|
+
# Agent might need a moment after sandbox creation
|
|
333
|
+
import time
|
|
334
|
+
max_wait = 30 # seconds (increased for reliability)
|
|
335
|
+
retry_delay = 1.5 # seconds between retries
|
|
336
|
+
|
|
337
|
+
for attempt in range(max_wait):
|
|
338
|
+
try:
|
|
339
|
+
# Quick health check with short timeout
|
|
340
|
+
health = self._agent_client.get("/health", operation="agent health check", timeout=5)
|
|
341
|
+
if health.json().get("status") == "healthy":
|
|
342
|
+
logger.debug(f"Agent ready after {attempt * retry_delay:.1f}s")
|
|
343
|
+
break
|
|
344
|
+
except Exception as e:
|
|
345
|
+
if attempt < max_wait - 1:
|
|
346
|
+
time.sleep(retry_delay)
|
|
347
|
+
continue
|
|
348
|
+
# Don't log warning - agent will usually work anyway
|
|
349
|
+
logger.debug(f"Agent health check timeout after {max_wait * retry_delay:.1f}s: {e}")
|
|
350
|
+
|
|
351
|
+
def _ensure_ws_client(self) -> None:
|
|
352
|
+
"""Ensure WebSocket client is initialized and agent is ready."""
|
|
353
|
+
if self._ws_client is None:
|
|
354
|
+
# First ensure agent HTTP client is ready (which waits for agent)
|
|
355
|
+
self._ensure_agent_client()
|
|
356
|
+
|
|
357
|
+
info = self.get_info()
|
|
358
|
+
agent_url = info.public_host.rstrip('/')
|
|
359
|
+
self._ws_client = WebSocketClient(agent_url)
|
|
360
|
+
logger.debug(f"WebSocket client initialized: {agent_url}")
|
|
361
|
+
|
|
362
|
+
def refresh_token(self) -> None:
|
|
363
|
+
"""
|
|
364
|
+
Refresh JWT token for agent authentication.
|
|
365
|
+
Called automatically when token is about to expire (<1 hour left).
|
|
366
|
+
"""
|
|
367
|
+
response = self._client.post(f"/v1/sandboxes/{self.sandbox_id}/token/refresh")
|
|
368
|
+
|
|
369
|
+
if "auth_token" in response and "token_expires_at" in response:
|
|
370
|
+
_token_cache[self.sandbox_id] = TokenData(
|
|
371
|
+
token=response["auth_token"],
|
|
372
|
+
expires_at=datetime.fromisoformat(response["token_expires_at"].replace("Z", "+00:00"))
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Update agent client's JWT token if already initialized
|
|
376
|
+
if self._agent_client is not None:
|
|
377
|
+
self._agent_client.update_jwt_token(response["auth_token"])
|
|
378
|
+
|
|
379
|
+
def _ensure_valid_token(self) -> None:
|
|
380
|
+
"""
|
|
381
|
+
Ensure JWT token is valid (not expired or expiring soon).
|
|
382
|
+
Auto-refreshes if less than 1 hour remaining.
|
|
383
|
+
"""
|
|
384
|
+
token_data = _token_cache.get(self.sandbox_id)
|
|
385
|
+
|
|
386
|
+
if token_data is None:
|
|
387
|
+
# No token yet, try to refresh
|
|
388
|
+
try:
|
|
389
|
+
self.refresh_token()
|
|
390
|
+
except Exception:
|
|
391
|
+
# Token might not be available yet (e.g., old sandbox)
|
|
392
|
+
pass
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
# Check if token expires soon (< 1 hour)
|
|
396
|
+
now = datetime.now(token_data.expires_at.tzinfo)
|
|
397
|
+
hours_left = (token_data.expires_at - now).total_seconds() / 3600
|
|
398
|
+
|
|
399
|
+
if hours_left < 1:
|
|
400
|
+
# Refresh token
|
|
401
|
+
self.refresh_token()
|
|
402
|
+
|
|
403
|
+
def get_token(self) -> str:
|
|
404
|
+
"""
|
|
405
|
+
Get current JWT token (for advanced use cases).
|
|
406
|
+
Automatically refreshes if needed.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
JWT token string
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
HopxError: If no token available
|
|
413
|
+
"""
|
|
414
|
+
self._ensure_valid_token()
|
|
415
|
+
|
|
416
|
+
token_data = _token_cache.get(self.sandbox_id)
|
|
417
|
+
if token_data is None:
|
|
418
|
+
from .errors import HopxError
|
|
419
|
+
raise HopxError('No JWT token available for sandbox')
|
|
420
|
+
|
|
421
|
+
return token_data.token
|
|
422
|
+
|
|
423
|
+
# =============================================================================
|
|
424
|
+
# CLASS METHODS (Static - for creating/listing sandboxes)
|
|
425
|
+
# =============================================================================
|
|
426
|
+
|
|
427
|
+
@classmethod
|
|
428
|
+
def create(
|
|
429
|
+
cls,
|
|
430
|
+
template: Optional[str] = None,
|
|
431
|
+
*,
|
|
432
|
+
template_id: Optional[str] = None,
|
|
433
|
+
region: Optional[str] = None,
|
|
434
|
+
timeout_seconds: Optional[int] = None,
|
|
435
|
+
internet_access: Optional[bool] = None,
|
|
436
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
437
|
+
api_key: Optional[str] = None,
|
|
438
|
+
base_url: str = "https://api.hopx.dev",
|
|
439
|
+
) -> "Sandbox":
|
|
440
|
+
"""
|
|
441
|
+
Create a new sandbox from a template.
|
|
442
|
+
|
|
443
|
+
Resources (vcpu, memory, disk) are ALWAYS loaded from the template.
|
|
444
|
+
You cannot specify custom resources - create a template first with desired resources.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
template: Template name (e.g., "my-python-template")
|
|
448
|
+
template_id: Template ID (alternative to template name)
|
|
449
|
+
region: Preferred region (optional, auto-selected if not specified)
|
|
450
|
+
timeout_seconds: Auto-kill timeout in seconds (optional, default: no timeout)
|
|
451
|
+
internet_access: Enable internet access (optional, default: True)
|
|
452
|
+
env_vars: Environment variables to set in the sandbox (optional)
|
|
453
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
454
|
+
base_url: API base URL (default: production)
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Sandbox instance
|
|
458
|
+
|
|
459
|
+
Raises:
|
|
460
|
+
ValidationError: Invalid parameters
|
|
461
|
+
ResourceLimitError: Insufficient resources
|
|
462
|
+
APIError: API request failed
|
|
463
|
+
|
|
464
|
+
Examples:
|
|
465
|
+
>>> # Create from template ID with timeout
|
|
466
|
+
>>> sandbox = Sandbox.create(
|
|
467
|
+
... template_id="291",
|
|
468
|
+
... timeout_seconds=300,
|
|
469
|
+
... internet_access=True
|
|
470
|
+
... )
|
|
471
|
+
>>> print(sandbox.get_info().public_host)
|
|
472
|
+
|
|
473
|
+
>>> # Create from template name without internet
|
|
474
|
+
>>> sandbox = Sandbox.create(
|
|
475
|
+
... template="my-python-template",
|
|
476
|
+
... env_vars={"DEBUG": "true"},
|
|
477
|
+
... internet_access=False
|
|
478
|
+
... )
|
|
479
|
+
"""
|
|
480
|
+
# Create HTTP client
|
|
481
|
+
client = HTTPClient(api_key=api_key, base_url=base_url)
|
|
482
|
+
|
|
483
|
+
# Validate parameters
|
|
484
|
+
if template_id:
|
|
485
|
+
# Create from template ID (resources from template)
|
|
486
|
+
# Convert template_id to string if it's an int (API may return int from build)
|
|
487
|
+
data = remove_none_values({
|
|
488
|
+
"template_id": str(template_id),
|
|
489
|
+
"region": region,
|
|
490
|
+
"timeout_seconds": timeout_seconds,
|
|
491
|
+
"internet_access": internet_access,
|
|
492
|
+
"env_vars": env_vars,
|
|
493
|
+
})
|
|
494
|
+
elif template:
|
|
495
|
+
# Create from template name (resources from template)
|
|
496
|
+
data = remove_none_values({
|
|
497
|
+
"template_name": template,
|
|
498
|
+
"region": region,
|
|
499
|
+
"timeout_seconds": timeout_seconds,
|
|
500
|
+
"internet_access": internet_access,
|
|
501
|
+
"env_vars": env_vars,
|
|
502
|
+
})
|
|
503
|
+
else:
|
|
504
|
+
raise ValueError("Either 'template' or 'template_id' must be provided")
|
|
505
|
+
|
|
506
|
+
# Create sandbox via API
|
|
507
|
+
response = client.post("/v1/sandboxes", json=data)
|
|
508
|
+
sandbox_id = response["id"]
|
|
509
|
+
|
|
510
|
+
# ⚠️ NEW: Store JWT token from create response
|
|
511
|
+
if "auth_token" in response and "token_expires_at" in response:
|
|
512
|
+
_token_cache[sandbox_id] = TokenData(
|
|
513
|
+
token=response["auth_token"],
|
|
514
|
+
expires_at=datetime.fromisoformat(response["token_expires_at"].replace("Z", "+00:00"))
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Return Sandbox instance
|
|
518
|
+
return cls(
|
|
519
|
+
sandbox_id=sandbox_id,
|
|
520
|
+
api_key=api_key,
|
|
521
|
+
base_url=base_url,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
@classmethod
|
|
525
|
+
def connect(
|
|
526
|
+
cls,
|
|
527
|
+
sandbox_id: str,
|
|
528
|
+
*,
|
|
529
|
+
api_key: Optional[str] = None,
|
|
530
|
+
base_url: str = "https://api.hopx.dev",
|
|
531
|
+
) -> "Sandbox":
|
|
532
|
+
"""
|
|
533
|
+
Connect to an existing sandbox.
|
|
534
|
+
|
|
535
|
+
NEW JWT Behavior:
|
|
536
|
+
- If VM is paused → resumes it and refreshes JWT token
|
|
537
|
+
- If VM is stopped → raises error (cannot connect to stopped VM)
|
|
538
|
+
- If VM is running/active → refreshes JWT token
|
|
539
|
+
- Stores JWT token for agent authentication
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
sandbox_id: Sandbox ID
|
|
543
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
544
|
+
base_url: API base URL
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Sandbox instance
|
|
548
|
+
|
|
549
|
+
Raises:
|
|
550
|
+
NotFoundError: Sandbox not found
|
|
551
|
+
HopxError: If sandbox is stopped or in invalid state
|
|
552
|
+
|
|
553
|
+
Example:
|
|
554
|
+
>>> sandbox = Sandbox.connect("1761048129dsaqav4n")
|
|
555
|
+
>>> info = sandbox.get_info()
|
|
556
|
+
>>> print(info.status)
|
|
557
|
+
"""
|
|
558
|
+
# Create instance
|
|
559
|
+
instance = cls(
|
|
560
|
+
sandbox_id=sandbox_id,
|
|
561
|
+
api_key=api_key,
|
|
562
|
+
base_url=base_url,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Get current VM status
|
|
566
|
+
info = instance.get_info()
|
|
567
|
+
|
|
568
|
+
# Handle different VM states
|
|
569
|
+
if info.status == "stopped":
|
|
570
|
+
from .errors import HopxError
|
|
571
|
+
raise HopxError(
|
|
572
|
+
f"Cannot connect to stopped sandbox {sandbox_id}. "
|
|
573
|
+
"Use sandbox.start() to start it first, or create a new sandbox."
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if info.status == "paused":
|
|
577
|
+
# Resume paused VM
|
|
578
|
+
instance.resume()
|
|
579
|
+
|
|
580
|
+
if info.status not in ("running", "paused"):
|
|
581
|
+
from .errors import HopxError
|
|
582
|
+
raise HopxError(
|
|
583
|
+
f"Cannot connect to sandbox {sandbox_id} with status '{info.status}'. "
|
|
584
|
+
"Expected 'running' or 'paused'."
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
# Refresh JWT token for agent authentication
|
|
588
|
+
instance.refresh_token()
|
|
589
|
+
|
|
590
|
+
return instance
|
|
591
|
+
|
|
592
|
+
@classmethod
|
|
593
|
+
def iter(
|
|
594
|
+
cls,
|
|
595
|
+
*,
|
|
596
|
+
status: Optional[str] = None,
|
|
597
|
+
region: Optional[str] = None,
|
|
598
|
+
api_key: Optional[str] = None,
|
|
599
|
+
base_url: str = "https://api.hopx.dev",
|
|
600
|
+
) -> Iterator["Sandbox"]:
|
|
601
|
+
"""
|
|
602
|
+
Lazy iterator for sandboxes.
|
|
603
|
+
|
|
604
|
+
Yields sandboxes one by one, fetching pages as needed.
|
|
605
|
+
Doesn't load all sandboxes into memory at once.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
status: Filter by status (running, stopped, paused, creating)
|
|
609
|
+
region: Filter by region
|
|
610
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
611
|
+
base_url: API base URL
|
|
612
|
+
|
|
613
|
+
Yields:
|
|
614
|
+
Sandbox instances
|
|
615
|
+
|
|
616
|
+
Example:
|
|
617
|
+
>>> # Lazy loading - fetches pages as needed
|
|
618
|
+
>>> for sandbox in Sandbox.iter(status="running"):
|
|
619
|
+
... print(f"{sandbox.sandbox_id}")
|
|
620
|
+
... if found:
|
|
621
|
+
... break # Doesn't fetch remaining pages!
|
|
622
|
+
"""
|
|
623
|
+
client = HTTPClient(api_key=api_key, base_url=base_url)
|
|
624
|
+
limit = 100
|
|
625
|
+
has_more = True
|
|
626
|
+
cursor = None
|
|
627
|
+
|
|
628
|
+
while has_more:
|
|
629
|
+
params = {"limit": limit}
|
|
630
|
+
if status:
|
|
631
|
+
params["status"] = status
|
|
632
|
+
if region:
|
|
633
|
+
params["region"] = region
|
|
634
|
+
if cursor:
|
|
635
|
+
params["cursor"] = cursor
|
|
636
|
+
|
|
637
|
+
logger.debug(f"Fetching sandboxes page (cursor: {cursor})")
|
|
638
|
+
response = client.get("/v1/sandboxes", params=params)
|
|
639
|
+
|
|
640
|
+
for item in response.get("data", []):
|
|
641
|
+
yield cls(
|
|
642
|
+
sandbox_id=item["id"],
|
|
643
|
+
api_key=api_key,
|
|
644
|
+
base_url=base_url,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
has_more = response.get("has_more", False)
|
|
648
|
+
cursor = response.get("next_cursor")
|
|
649
|
+
|
|
650
|
+
if has_more:
|
|
651
|
+
logger.debug(f"More results available, next cursor: {cursor}")
|
|
652
|
+
|
|
653
|
+
@classmethod
|
|
654
|
+
def list(
|
|
655
|
+
cls,
|
|
656
|
+
*,
|
|
657
|
+
status: Optional[str] = None,
|
|
658
|
+
region: Optional[str] = None,
|
|
659
|
+
limit: int = 100,
|
|
660
|
+
api_key: Optional[str] = None,
|
|
661
|
+
base_url: str = "https://api.hopx.dev",
|
|
662
|
+
) -> List["Sandbox"]:
|
|
663
|
+
"""
|
|
664
|
+
List all sandboxes (loads all into memory).
|
|
665
|
+
|
|
666
|
+
For lazy loading (better memory usage), use Sandbox.iter() instead.
|
|
667
|
+
|
|
668
|
+
Args:
|
|
669
|
+
status: Filter by status (running, stopped, paused, creating)
|
|
670
|
+
region: Filter by region
|
|
671
|
+
limit: Maximum number of results (default: 100)
|
|
672
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
673
|
+
base_url: API base URL
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
List of Sandbox instances (all loaded into memory)
|
|
677
|
+
|
|
678
|
+
Example:
|
|
679
|
+
>>> # List all running sandboxes (loads all into memory)
|
|
680
|
+
>>> sandboxes = Sandbox.list(status="running")
|
|
681
|
+
>>> for sb in sandboxes:
|
|
682
|
+
... print(f"{sb.sandbox_id}")
|
|
683
|
+
|
|
684
|
+
>>> # For better memory usage, use iter():
|
|
685
|
+
>>> for sb in Sandbox.iter(status="running"):
|
|
686
|
+
... print(f"{sb.sandbox_id}")
|
|
687
|
+
"""
|
|
688
|
+
client = HTTPClient(api_key=api_key, base_url=base_url)
|
|
689
|
+
|
|
690
|
+
params = remove_none_values({
|
|
691
|
+
"status": status,
|
|
692
|
+
"region": region,
|
|
693
|
+
"limit": limit,
|
|
694
|
+
})
|
|
695
|
+
|
|
696
|
+
response = client.get("/v1/sandboxes", params=params)
|
|
697
|
+
sandboxes_data = response.get("data", [])
|
|
698
|
+
|
|
699
|
+
# Create Sandbox instances
|
|
700
|
+
return [
|
|
701
|
+
cls(
|
|
702
|
+
sandbox_id=sb["id"],
|
|
703
|
+
api_key=api_key,
|
|
704
|
+
base_url=base_url,
|
|
705
|
+
)
|
|
706
|
+
for sb in sandboxes_data
|
|
707
|
+
]
|
|
708
|
+
|
|
709
|
+
@classmethod
|
|
710
|
+
def list_templates(
|
|
711
|
+
cls,
|
|
712
|
+
*,
|
|
713
|
+
category: Optional[str] = None,
|
|
714
|
+
language: Optional[str] = None,
|
|
715
|
+
api_key: Optional[str] = None,
|
|
716
|
+
base_url: str = "https://api.hopx.dev",
|
|
717
|
+
) -> List[Template]:
|
|
718
|
+
"""
|
|
719
|
+
List available templates.
|
|
720
|
+
|
|
721
|
+
Args:
|
|
722
|
+
category: Filter by category (development, infrastructure, operating-system)
|
|
723
|
+
language: Filter by language (python, nodejs, etc.)
|
|
724
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
725
|
+
base_url: API base URL
|
|
726
|
+
|
|
727
|
+
Returns:
|
|
728
|
+
List of Template objects
|
|
729
|
+
|
|
730
|
+
Example:
|
|
731
|
+
>>> templates = Sandbox.list_templates()
|
|
732
|
+
>>> for t in templates:
|
|
733
|
+
... print(f"{t.name}: {t.display_name}")
|
|
734
|
+
|
|
735
|
+
>>> # Filter by category
|
|
736
|
+
>>> dev_templates = Sandbox.list_templates(category="development")
|
|
737
|
+
"""
|
|
738
|
+
client = HTTPClient(api_key=api_key, base_url=base_url)
|
|
739
|
+
|
|
740
|
+
params = remove_none_values({
|
|
741
|
+
"category": category,
|
|
742
|
+
"language": language,
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
response = client.get("/v1/templates", params=params)
|
|
746
|
+
templates_data = response.get("data", [])
|
|
747
|
+
|
|
748
|
+
return [Template(**t) for t in templates_data]
|
|
749
|
+
|
|
750
|
+
@classmethod
|
|
751
|
+
def get_template(
|
|
752
|
+
cls,
|
|
753
|
+
name: str,
|
|
754
|
+
*,
|
|
755
|
+
api_key: Optional[str] = None,
|
|
756
|
+
base_url: str = "https://api.hopx.dev",
|
|
757
|
+
) -> Template:
|
|
758
|
+
"""
|
|
759
|
+
Get template details.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
name: Template name
|
|
763
|
+
api_key: API key (or use HOPX_API_KEY env var)
|
|
764
|
+
base_url: API base URL
|
|
765
|
+
|
|
766
|
+
Returns:
|
|
767
|
+
Template object
|
|
768
|
+
|
|
769
|
+
Raises:
|
|
770
|
+
NotFoundError: Template not found
|
|
771
|
+
|
|
772
|
+
Example:
|
|
773
|
+
>>> template = Sandbox.get_template("code-interpreter")
|
|
774
|
+
>>> print(template.description)
|
|
775
|
+
>>> print(f"Default: {template.default_resources.vcpu} vCPU")
|
|
776
|
+
"""
|
|
777
|
+
client = HTTPClient(api_key=api_key, base_url=base_url)
|
|
778
|
+
response = client.get(f"/v1/templates/{name}")
|
|
779
|
+
return Template(**response)
|
|
780
|
+
|
|
781
|
+
# =============================================================================
|
|
782
|
+
# INSTANCE METHODS (for managing individual sandbox)
|
|
783
|
+
# =============================================================================
|
|
784
|
+
|
|
785
|
+
def get_info(self) -> SandboxInfo:
|
|
786
|
+
"""
|
|
787
|
+
Get current sandbox information.
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
SandboxInfo with current state
|
|
791
|
+
|
|
792
|
+
Raises:
|
|
793
|
+
NotFoundError: Sandbox not found
|
|
794
|
+
|
|
795
|
+
Example:
|
|
796
|
+
>>> sandbox = Sandbox.create(template="nodejs")
|
|
797
|
+
>>> info = sandbox.get_info()
|
|
798
|
+
>>> print(f"Status: {info.status}")
|
|
799
|
+
>>> print(f"URL: {info.public_host}")
|
|
800
|
+
>>> print(f"Ends at: {info.end_at}")
|
|
801
|
+
"""
|
|
802
|
+
response = self._client.get(f"/v1/sandboxes/{self.sandbox_id}")
|
|
803
|
+
|
|
804
|
+
# Parse resources if present
|
|
805
|
+
resources = None
|
|
806
|
+
if response.get("resources"):
|
|
807
|
+
from .models import Resources
|
|
808
|
+
resources = Resources(
|
|
809
|
+
vcpu=response["resources"]["vcpu"],
|
|
810
|
+
memory_mb=response["resources"]["memory_mb"],
|
|
811
|
+
disk_mb=response["resources"]["disk_mb"]
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
return SandboxInfo(
|
|
815
|
+
sandbox_id=response["id"],
|
|
816
|
+
template_id=response.get("template_id"),
|
|
817
|
+
template_name=response.get("template_name"),
|
|
818
|
+
organization_id=response.get("organization_id", ""),
|
|
819
|
+
node_id=response.get("node_id"),
|
|
820
|
+
region=response.get("region"),
|
|
821
|
+
status=response["status"],
|
|
822
|
+
public_host=response.get("public_host") or response.get("direct_url", ""),
|
|
823
|
+
resources=resources,
|
|
824
|
+
created_at=response.get("created_at"),
|
|
825
|
+
started_at=None, # TODO: Add when API provides it
|
|
826
|
+
end_at=None, # TODO: Add when API provides it
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
def get_agent_metrics(self) -> Dict[str, Any]:
|
|
830
|
+
"""
|
|
831
|
+
Get real-time agent metrics.
|
|
832
|
+
|
|
833
|
+
Returns agent performance and health metrics including uptime,
|
|
834
|
+
request counts, error counts, and performance statistics.
|
|
835
|
+
|
|
836
|
+
Returns:
|
|
837
|
+
Dict with metrics including:
|
|
838
|
+
- uptime_seconds: Agent uptime
|
|
839
|
+
- total_requests: Total requests count
|
|
840
|
+
- total_errors: Total errors count
|
|
841
|
+
- requests_total: Per-endpoint request counts
|
|
842
|
+
- avg_duration_ms: Average request duration by endpoint
|
|
843
|
+
|
|
844
|
+
Example:
|
|
845
|
+
>>> metrics = sandbox.get_agent_metrics()
|
|
846
|
+
>>> print(f"Uptime: {metrics['uptime_seconds']}s")
|
|
847
|
+
>>> print(f"Total requests: {metrics.get('total_requests', 0)}")
|
|
848
|
+
>>> print(f"Errors: {metrics.get('total_errors', 0)}")
|
|
849
|
+
|
|
850
|
+
Note:
|
|
851
|
+
Requires Agent v3.1.0+
|
|
852
|
+
"""
|
|
853
|
+
self._ensure_agent_client()
|
|
854
|
+
|
|
855
|
+
logger.debug("Getting agent metrics")
|
|
856
|
+
|
|
857
|
+
response = self._agent_client.get(
|
|
858
|
+
"/metrics/snapshot",
|
|
859
|
+
operation="get agent metrics"
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
return response.json()
|
|
863
|
+
|
|
864
|
+
def run_code(
|
|
865
|
+
self,
|
|
866
|
+
code: str,
|
|
867
|
+
*,
|
|
868
|
+
language: str = "python",
|
|
869
|
+
timeout: int = 60,
|
|
870
|
+
env: Optional[Dict[str, str]] = None,
|
|
871
|
+
working_dir: str = "/workspace",
|
|
872
|
+
) -> ExecutionResult:
|
|
873
|
+
"""
|
|
874
|
+
Execute code with rich output capture (plots, DataFrames, etc.).
|
|
875
|
+
|
|
876
|
+
This method automatically captures visual outputs like matplotlib plots,
|
|
877
|
+
pandas DataFrames, and plotly charts.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
code: Code to execute
|
|
881
|
+
language: Language (python, javascript, bash, go)
|
|
882
|
+
timeout: Execution timeout in seconds (default: 60)
|
|
883
|
+
env: Optional environment variables for this execution only.
|
|
884
|
+
Priority: Request env > Global env > Agent env
|
|
885
|
+
working_dir: Working directory for execution (default: /workspace)
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
ExecutionResult with stdout, stderr, rich_outputs
|
|
889
|
+
|
|
890
|
+
Raises:
|
|
891
|
+
CodeExecutionError: If execution fails
|
|
892
|
+
TimeoutError: If execution times out
|
|
893
|
+
|
|
894
|
+
Example:
|
|
895
|
+
>>> # Simple code execution
|
|
896
|
+
>>> result = sandbox.run_code('print("Hello, World!")')
|
|
897
|
+
>>> print(result.stdout) # "Hello, World!\n"
|
|
898
|
+
>>>
|
|
899
|
+
>>> # With environment variables
|
|
900
|
+
>>> result = sandbox.run_code(
|
|
901
|
+
... 'import os; print(os.environ["API_KEY"])',
|
|
902
|
+
... env={"API_KEY": "sk-test-123", "DEBUG": "true"}
|
|
903
|
+
... )
|
|
904
|
+
>>>
|
|
905
|
+
>>> # Execute with matplotlib plot
|
|
906
|
+
>>> code = '''
|
|
907
|
+
... import matplotlib.pyplot as plt
|
|
908
|
+
... plt.plot([1, 2, 3, 4])
|
|
909
|
+
... plt.savefig('/workspace/plot.png')
|
|
910
|
+
... '''
|
|
911
|
+
>>> result = sandbox.run_code(code)
|
|
912
|
+
>>> print(f"Generated {result.rich_count} outputs")
|
|
913
|
+
>>>
|
|
914
|
+
>>> # Check for errors
|
|
915
|
+
>>> result = sandbox.run_code('print(undefined_var)')
|
|
916
|
+
>>> if not result.success:
|
|
917
|
+
... print(f"Error: {result.stderr}")
|
|
918
|
+
>>>
|
|
919
|
+
>>> # With custom timeout for long-running code
|
|
920
|
+
>>> result = sandbox.run_code(long_code, timeout=300)
|
|
921
|
+
"""
|
|
922
|
+
self._ensure_agent_client()
|
|
923
|
+
|
|
924
|
+
logger.debug(f"Executing {language} code ({len(code)} chars)")
|
|
925
|
+
|
|
926
|
+
# Build request payload
|
|
927
|
+
payload = {
|
|
928
|
+
"language": language,
|
|
929
|
+
"code": code,
|
|
930
|
+
"working_dir": working_dir,
|
|
931
|
+
"timeout": timeout
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
# Add optional environment variables
|
|
935
|
+
if env:
|
|
936
|
+
payload["env"] = env
|
|
937
|
+
|
|
938
|
+
# Use /execute endpoint for code execution
|
|
939
|
+
response = self._agent_client.post(
|
|
940
|
+
"/execute",
|
|
941
|
+
json=payload,
|
|
942
|
+
operation="execute code",
|
|
943
|
+
context={"language": language},
|
|
944
|
+
timeout=timeout + 5 # Add buffer to HTTP timeout
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
data = response.json() if response.content else {}
|
|
948
|
+
|
|
949
|
+
# Parse rich outputs
|
|
950
|
+
rich_outputs = []
|
|
951
|
+
if data and isinstance(data, dict):
|
|
952
|
+
rich_outputs_data = data.get("rich_outputs") or []
|
|
953
|
+
for output in rich_outputs_data:
|
|
954
|
+
if output:
|
|
955
|
+
rich_outputs.append(RichOutput(
|
|
956
|
+
type=output.get("type", ""),
|
|
957
|
+
data=output.get("data", {}),
|
|
958
|
+
metadata=output.get("metadata"),
|
|
959
|
+
timestamp=output.get("timestamp")
|
|
960
|
+
))
|
|
961
|
+
|
|
962
|
+
# Create result
|
|
963
|
+
result = ExecutionResult(
|
|
964
|
+
success=data.get("success", True) if data else False,
|
|
965
|
+
stdout=data.get("stdout", "") if data else "",
|
|
966
|
+
stderr=data.get("stderr", "") if data else "",
|
|
967
|
+
exit_code=data.get("exit_code", 0) if data else 1,
|
|
968
|
+
execution_time=data.get("execution_time", 0.0) if data else 0.0,
|
|
969
|
+
rich_outputs=rich_outputs
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
return result
|
|
973
|
+
|
|
974
|
+
def run_code_async(
|
|
975
|
+
self,
|
|
976
|
+
code: str,
|
|
977
|
+
callback_url: str,
|
|
978
|
+
*,
|
|
979
|
+
language: str = "python",
|
|
980
|
+
timeout: int = 1800,
|
|
981
|
+
env: Optional[Dict[str, str]] = None,
|
|
982
|
+
working_dir: str = "/workspace",
|
|
983
|
+
callback_headers: Optional[Dict[str, str]] = None,
|
|
984
|
+
callback_signature_secret: Optional[str] = None,
|
|
985
|
+
) -> Dict[str, Any]:
|
|
986
|
+
"""
|
|
987
|
+
Execute code asynchronously with webhook callback.
|
|
988
|
+
|
|
989
|
+
For long-running code (>5 minutes). Agent will POST results to callback_url when complete.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
code: Code to execute
|
|
993
|
+
callback_url: URL to POST results to when execution completes
|
|
994
|
+
language: Language (python, javascript, bash, go)
|
|
995
|
+
timeout: Execution timeout in seconds (default: 1800 = 30 min)
|
|
996
|
+
env: Optional environment variables
|
|
997
|
+
working_dir: Working directory (default: /workspace)
|
|
998
|
+
callback_headers: Custom headers to include in callback request
|
|
999
|
+
callback_signature_secret: Secret to sign callback payload (HMAC-SHA256)
|
|
1000
|
+
|
|
1001
|
+
Returns:
|
|
1002
|
+
Dict with execution_id, status, callback_url
|
|
1003
|
+
|
|
1004
|
+
Example:
|
|
1005
|
+
>>> # Start async execution
|
|
1006
|
+
>>> response = sandbox.run_code_async(
|
|
1007
|
+
... code='import time; time.sleep(600); print("Done!")',
|
|
1008
|
+
... callback_url='https://app.com/webhooks/ml/training',
|
|
1009
|
+
... callback_headers={'Authorization': 'Bearer secret'},
|
|
1010
|
+
... callback_signature_secret='webhook-secret-123'
|
|
1011
|
+
... )
|
|
1012
|
+
>>> print(f"Execution ID: {response['execution_id']}")
|
|
1013
|
+
>>>
|
|
1014
|
+
>>> # Agent will POST to callback_url when done:
|
|
1015
|
+
>>> # POST https://app.com/webhooks/ml/training
|
|
1016
|
+
>>> # X-HOPX-Signature: sha256=...
|
|
1017
|
+
>>> # X-HOPX-Timestamp: 1698765432
|
|
1018
|
+
>>> # Authorization: Bearer secret
|
|
1019
|
+
>>> # {
|
|
1020
|
+
>>> # "execution_id": "abc123",
|
|
1021
|
+
>>> # "status": "completed",
|
|
1022
|
+
>>> # "stdout": "Done!",
|
|
1023
|
+
>>> # "stderr": "",
|
|
1024
|
+
>>> # "exit_code": 0,
|
|
1025
|
+
>>> # "execution_time": 600.123
|
|
1026
|
+
>>> # }
|
|
1027
|
+
"""
|
|
1028
|
+
self._ensure_agent_client()
|
|
1029
|
+
|
|
1030
|
+
logger.debug(f"Starting async {language} execution ({len(code)} chars)")
|
|
1031
|
+
|
|
1032
|
+
# Build request payload
|
|
1033
|
+
payload = {
|
|
1034
|
+
"code": code,
|
|
1035
|
+
"language": language,
|
|
1036
|
+
"timeout": timeout,
|
|
1037
|
+
"working_dir": working_dir,
|
|
1038
|
+
"callback_url": callback_url,
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if env:
|
|
1042
|
+
payload["env"] = env
|
|
1043
|
+
if callback_headers:
|
|
1044
|
+
payload["callback_headers"] = callback_headers
|
|
1045
|
+
if callback_signature_secret:
|
|
1046
|
+
payload["callback_signature_secret"] = callback_signature_secret
|
|
1047
|
+
|
|
1048
|
+
response = self._agent_client.post(
|
|
1049
|
+
"/execute/async",
|
|
1050
|
+
json=payload,
|
|
1051
|
+
operation="async execute code",
|
|
1052
|
+
context={"language": language},
|
|
1053
|
+
timeout=10 # Quick response
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
return response.json()
|
|
1057
|
+
|
|
1058
|
+
def run_code_background(
|
|
1059
|
+
self,
|
|
1060
|
+
code: str,
|
|
1061
|
+
*,
|
|
1062
|
+
language: str = "python",
|
|
1063
|
+
timeout: int = 300,
|
|
1064
|
+
env: Optional[Dict[str, str]] = None,
|
|
1065
|
+
working_dir: str = "/workspace",
|
|
1066
|
+
name: Optional[str] = None,
|
|
1067
|
+
) -> Dict[str, Any]:
|
|
1068
|
+
"""
|
|
1069
|
+
Execute code in background and return immediately.
|
|
1070
|
+
|
|
1071
|
+
Use list_processes() to check status and kill_process() to terminate.
|
|
1072
|
+
|
|
1073
|
+
Args:
|
|
1074
|
+
code: Code to execute
|
|
1075
|
+
language: Language (python, javascript, bash, go)
|
|
1076
|
+
timeout: Execution timeout in seconds (default: 300 = 5 min)
|
|
1077
|
+
env: Optional environment variables
|
|
1078
|
+
working_dir: Working directory (default: /workspace)
|
|
1079
|
+
name: Optional process name for identification
|
|
1080
|
+
|
|
1081
|
+
Returns:
|
|
1082
|
+
Dict with process_id, execution_id, status
|
|
1083
|
+
|
|
1084
|
+
Example:
|
|
1085
|
+
>>> # Start background execution
|
|
1086
|
+
>>> result = sandbox.run_code_background(
|
|
1087
|
+
... code='long_running_task()',
|
|
1088
|
+
... name='ml-training',
|
|
1089
|
+
... env={"GPU": "enabled"}
|
|
1090
|
+
... )
|
|
1091
|
+
>>> process_id = result['process_id']
|
|
1092
|
+
>>>
|
|
1093
|
+
>>> # Check status
|
|
1094
|
+
>>> processes = sandbox.list_processes()
|
|
1095
|
+
>>> for p in processes:
|
|
1096
|
+
... if p['process_id'] == process_id:
|
|
1097
|
+
... print(f"Status: {p['status']}")
|
|
1098
|
+
>>>
|
|
1099
|
+
>>> # Kill if needed
|
|
1100
|
+
>>> sandbox.kill_process(process_id)
|
|
1101
|
+
"""
|
|
1102
|
+
self._ensure_agent_client()
|
|
1103
|
+
|
|
1104
|
+
logger.debug(f"Starting background {language} execution ({len(code)} chars)")
|
|
1105
|
+
|
|
1106
|
+
# Build request payload
|
|
1107
|
+
payload = {
|
|
1108
|
+
"code": code,
|
|
1109
|
+
"language": language,
|
|
1110
|
+
"timeout": timeout,
|
|
1111
|
+
"working_dir": working_dir,
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if env:
|
|
1115
|
+
payload["env"] = env
|
|
1116
|
+
if name:
|
|
1117
|
+
payload["name"] = name
|
|
1118
|
+
|
|
1119
|
+
response = self._agent_client.post(
|
|
1120
|
+
"/execute/background",
|
|
1121
|
+
json=payload,
|
|
1122
|
+
operation="background execute code",
|
|
1123
|
+
context={"language": language},
|
|
1124
|
+
timeout=10 # Quick response
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
return response.json()
|
|
1128
|
+
|
|
1129
|
+
def list_processes(self) -> List[Dict[str, Any]]:
|
|
1130
|
+
"""
|
|
1131
|
+
List all background execution processes.
|
|
1132
|
+
|
|
1133
|
+
Returns:
|
|
1134
|
+
List of process dictionaries with status
|
|
1135
|
+
|
|
1136
|
+
Example:
|
|
1137
|
+
>>> processes = sandbox.list_processes()
|
|
1138
|
+
>>> for p in processes:
|
|
1139
|
+
... print(f"{p['name']}: {p['status']} (PID: {p['process_id']})")
|
|
1140
|
+
"""
|
|
1141
|
+
self._ensure_agent_client()
|
|
1142
|
+
|
|
1143
|
+
response = self._agent_client.get(
|
|
1144
|
+
"/execute/processes",
|
|
1145
|
+
operation="list processes"
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
data = response.json()
|
|
1149
|
+
return data.get("processes", [])
|
|
1150
|
+
|
|
1151
|
+
def kill_process(self, process_id: str) -> Dict[str, Any]:
|
|
1152
|
+
"""
|
|
1153
|
+
Kill a background execution process.
|
|
1154
|
+
|
|
1155
|
+
Args:
|
|
1156
|
+
process_id: Process ID to kill
|
|
1157
|
+
|
|
1158
|
+
Returns:
|
|
1159
|
+
Dict with confirmation message
|
|
1160
|
+
|
|
1161
|
+
Example:
|
|
1162
|
+
>>> sandbox.kill_process("proc_abc123")
|
|
1163
|
+
"""
|
|
1164
|
+
self._ensure_agent_client()
|
|
1165
|
+
|
|
1166
|
+
response = self._agent_client.post(
|
|
1167
|
+
f"/execute/kill/{process_id}",
|
|
1168
|
+
operation="kill process",
|
|
1169
|
+
context={"process_id": process_id}
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
return response.json()
|
|
1173
|
+
|
|
1174
|
+
def get_metrics_snapshot(self) -> Dict[str, Any]:
|
|
1175
|
+
"""
|
|
1176
|
+
Get current system metrics snapshot.
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
Dict with system metrics (CPU, memory, disk), process metrics, cache stats
|
|
1180
|
+
|
|
1181
|
+
Example:
|
|
1182
|
+
>>> metrics = sandbox.get_metrics_snapshot()
|
|
1183
|
+
>>> print(f"CPU: {metrics['system']['cpu']['usage_percent']}%")
|
|
1184
|
+
>>> print(f"Memory: {metrics['system']['memory']['usage_percent']}%")
|
|
1185
|
+
>>> print(f"Processes: {metrics['process']['count']}")
|
|
1186
|
+
>>> print(f"Cache size: {metrics['cache']['size']}")
|
|
1187
|
+
"""
|
|
1188
|
+
self._ensure_agent_client()
|
|
1189
|
+
|
|
1190
|
+
response = self._agent_client.get(
|
|
1191
|
+
"/metrics/snapshot",
|
|
1192
|
+
operation="get metrics snapshot"
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
return response.json()
|
|
1196
|
+
|
|
1197
|
+
def run_ipython(
|
|
1198
|
+
self,
|
|
1199
|
+
code: str,
|
|
1200
|
+
*,
|
|
1201
|
+
timeout: int = 60,
|
|
1202
|
+
env: Optional[Dict[str, str]] = None,
|
|
1203
|
+
) -> ExecutionResult:
|
|
1204
|
+
"""
|
|
1205
|
+
Execute code in persistent IPython kernel.
|
|
1206
|
+
|
|
1207
|
+
Variables and state persist across executions.
|
|
1208
|
+
Supports magic commands and interactive features.
|
|
1209
|
+
|
|
1210
|
+
Args:
|
|
1211
|
+
code: Code to execute in IPython
|
|
1212
|
+
timeout: Execution timeout in seconds (default: 60)
|
|
1213
|
+
env: Optional environment variables
|
|
1214
|
+
|
|
1215
|
+
Returns:
|
|
1216
|
+
ExecutionResult with stdout, stderr
|
|
1217
|
+
|
|
1218
|
+
Example:
|
|
1219
|
+
>>> # First execution - define variable
|
|
1220
|
+
>>> sandbox.run_ipython("x = 10")
|
|
1221
|
+
>>>
|
|
1222
|
+
>>> # Second execution - x persists!
|
|
1223
|
+
>>> result = sandbox.run_ipython("print(x)")
|
|
1224
|
+
>>> print(result.stdout) # "10"
|
|
1225
|
+
>>>
|
|
1226
|
+
>>> # Magic commands work
|
|
1227
|
+
>>> sandbox.run_ipython("%timeit sum(range(100))")
|
|
1228
|
+
"""
|
|
1229
|
+
self._ensure_agent_client()
|
|
1230
|
+
|
|
1231
|
+
logger.debug(f"Executing IPython code ({len(code)} chars)")
|
|
1232
|
+
|
|
1233
|
+
# Build request payload
|
|
1234
|
+
payload = {
|
|
1235
|
+
"code": code,
|
|
1236
|
+
"timeout": timeout,
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if env:
|
|
1240
|
+
payload["env"] = env
|
|
1241
|
+
|
|
1242
|
+
response = self._agent_client.post(
|
|
1243
|
+
"/execute/ipython",
|
|
1244
|
+
json=payload,
|
|
1245
|
+
operation="execute ipython",
|
|
1246
|
+
timeout=timeout + 5
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
data = response.json()
|
|
1250
|
+
|
|
1251
|
+
return ExecutionResult(
|
|
1252
|
+
success=data.get("success", True),
|
|
1253
|
+
stdout=data.get("stdout", ""),
|
|
1254
|
+
stderr=data.get("stderr", ""),
|
|
1255
|
+
exit_code=data.get("exit_code", 0),
|
|
1256
|
+
execution_time=data.get("execution_time", 0.0)
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
async def run_code_stream(
|
|
1260
|
+
self,
|
|
1261
|
+
code: str,
|
|
1262
|
+
*,
|
|
1263
|
+
language: str = "python",
|
|
1264
|
+
timeout: int = 60,
|
|
1265
|
+
env: Optional[Dict[str, str]] = None,
|
|
1266
|
+
working_dir: str = "/workspace"
|
|
1267
|
+
):
|
|
1268
|
+
"""
|
|
1269
|
+
Execute code with real-time output streaming via WebSocket.
|
|
1270
|
+
|
|
1271
|
+
Stream stdout/stderr as it's generated (async generator).
|
|
1272
|
+
|
|
1273
|
+
Args:
|
|
1274
|
+
code: Code to execute
|
|
1275
|
+
language: Language (python, javascript, bash, go)
|
|
1276
|
+
timeout: Execution timeout in seconds
|
|
1277
|
+
env: Optional environment variables
|
|
1278
|
+
working_dir: Working directory
|
|
1279
|
+
|
|
1280
|
+
Yields:
|
|
1281
|
+
Message dictionaries:
|
|
1282
|
+
- {"type": "stdout", "data": "...", "timestamp": "..."}
|
|
1283
|
+
- {"type": "stderr", "data": "...", "timestamp": "..."}
|
|
1284
|
+
- {"type": "result", "exit_code": 0, "execution_time": 1.23}
|
|
1285
|
+
- {"type": "complete", "success": True}
|
|
1286
|
+
|
|
1287
|
+
Note:
|
|
1288
|
+
Requires websockets library: pip install websockets
|
|
1289
|
+
|
|
1290
|
+
Example:
|
|
1291
|
+
>>> import asyncio
|
|
1292
|
+
>>>
|
|
1293
|
+
>>> async def stream_execution():
|
|
1294
|
+
... sandbox = Sandbox.create(template="code-interpreter")
|
|
1295
|
+
...
|
|
1296
|
+
... code = '''
|
|
1297
|
+
... import time
|
|
1298
|
+
... for i in range(5):
|
|
1299
|
+
... print(f"Step {i+1}/5")
|
|
1300
|
+
... time.sleep(1)
|
|
1301
|
+
... '''
|
|
1302
|
+
...
|
|
1303
|
+
... async for message in sandbox.run_code_stream(code):
|
|
1304
|
+
... if message['type'] == 'stdout':
|
|
1305
|
+
... print(message['data'], end='')
|
|
1306
|
+
... elif message['type'] == 'result':
|
|
1307
|
+
... print(f"\\nExit code: {message['exit_code']}")
|
|
1308
|
+
>>>
|
|
1309
|
+
>>> asyncio.run(stream_execution())
|
|
1310
|
+
"""
|
|
1311
|
+
self._ensure_ws_client()
|
|
1312
|
+
|
|
1313
|
+
# Connect to streaming endpoint
|
|
1314
|
+
async with await self._ws_client.connect("/execute/stream") as ws:
|
|
1315
|
+
# Send execution request
|
|
1316
|
+
request = {
|
|
1317
|
+
"type": "execute",
|
|
1318
|
+
"code": code,
|
|
1319
|
+
"language": language,
|
|
1320
|
+
"timeout": timeout,
|
|
1321
|
+
"working_dir": working_dir
|
|
1322
|
+
}
|
|
1323
|
+
if env:
|
|
1324
|
+
request["env"] = env
|
|
1325
|
+
|
|
1326
|
+
await self._ws_client.send_message(ws, request)
|
|
1327
|
+
|
|
1328
|
+
# Stream messages
|
|
1329
|
+
async for message in self._ws_client.iter_messages(ws):
|
|
1330
|
+
yield message
|
|
1331
|
+
|
|
1332
|
+
# Stop on complete
|
|
1333
|
+
if message.get('type') == 'complete':
|
|
1334
|
+
break
|
|
1335
|
+
|
|
1336
|
+
def set_timeout(self, seconds: int) -> None:
|
|
1337
|
+
"""
|
|
1338
|
+
Extend sandbox timeout.
|
|
1339
|
+
|
|
1340
|
+
The new timeout will be 'seconds' from now.
|
|
1341
|
+
|
|
1342
|
+
Args:
|
|
1343
|
+
seconds: New timeout duration in seconds from now
|
|
1344
|
+
|
|
1345
|
+
Example:
|
|
1346
|
+
>>> sandbox = Sandbox.create(template="nodejs", timeout=300)
|
|
1347
|
+
>>> # Extend to 10 minutes from now
|
|
1348
|
+
>>> sandbox.set_timeout(600)
|
|
1349
|
+
|
|
1350
|
+
Note:
|
|
1351
|
+
This feature may not be available in all plans.
|
|
1352
|
+
"""
|
|
1353
|
+
# TODO: Implement when API supports it
|
|
1354
|
+
# For now, this is a placeholder matching E2B's API
|
|
1355
|
+
raise NotImplementedError(
|
|
1356
|
+
"set_timeout() will be available soon. "
|
|
1357
|
+
"For now, create sandbox with desired timeout: "
|
|
1358
|
+
"Sandbox.create(template='...', timeout=600)"
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
def stop(self) -> None:
|
|
1362
|
+
"""
|
|
1363
|
+
Stop the sandbox.
|
|
1364
|
+
|
|
1365
|
+
A stopped sandbox can be started again with start().
|
|
1366
|
+
|
|
1367
|
+
Example:
|
|
1368
|
+
>>> sandbox.stop()
|
|
1369
|
+
>>> # ... do something else ...
|
|
1370
|
+
>>> sandbox.start()
|
|
1371
|
+
"""
|
|
1372
|
+
self._client.post(f"/v1/sandboxes/{self.sandbox_id}/stop")
|
|
1373
|
+
|
|
1374
|
+
def start(self) -> None:
|
|
1375
|
+
"""
|
|
1376
|
+
Start a stopped sandbox.
|
|
1377
|
+
|
|
1378
|
+
Example:
|
|
1379
|
+
>>> sandbox.start()
|
|
1380
|
+
"""
|
|
1381
|
+
self._client.post(f"/v1/sandboxes/{self.sandbox_id}/start")
|
|
1382
|
+
|
|
1383
|
+
def pause(self) -> None:
|
|
1384
|
+
"""
|
|
1385
|
+
Pause the sandbox.
|
|
1386
|
+
|
|
1387
|
+
A paused sandbox can be resumed with resume().
|
|
1388
|
+
|
|
1389
|
+
Example:
|
|
1390
|
+
>>> sandbox.pause()
|
|
1391
|
+
>>> # ... do something else ...
|
|
1392
|
+
>>> sandbox.resume()
|
|
1393
|
+
"""
|
|
1394
|
+
self._client.post(f"/v1/sandboxes/{self.sandbox_id}/pause")
|
|
1395
|
+
|
|
1396
|
+
def resume(self) -> None:
|
|
1397
|
+
"""
|
|
1398
|
+
Resume a paused sandbox.
|
|
1399
|
+
|
|
1400
|
+
Example:
|
|
1401
|
+
>>> sandbox.resume()
|
|
1402
|
+
"""
|
|
1403
|
+
self._client.post(f"/v1/sandboxes/{self.sandbox_id}/resume")
|
|
1404
|
+
|
|
1405
|
+
def kill(self) -> None:
|
|
1406
|
+
"""
|
|
1407
|
+
Destroy the sandbox immediately.
|
|
1408
|
+
|
|
1409
|
+
This action is irreversible. All data in the sandbox will be lost.
|
|
1410
|
+
|
|
1411
|
+
Example:
|
|
1412
|
+
>>> sandbox = Sandbox.create(template="nodejs")
|
|
1413
|
+
>>> # ... use sandbox ...
|
|
1414
|
+
>>> sandbox.kill() # Clean up
|
|
1415
|
+
"""
|
|
1416
|
+
self._client.delete(f"/v1/sandboxes/{self.sandbox_id}")
|
|
1417
|
+
|
|
1418
|
+
# =============================================================================
|
|
1419
|
+
# CONTEXT MANAGER (auto-cleanup)
|
|
1420
|
+
# =============================================================================
|
|
1421
|
+
|
|
1422
|
+
def __enter__(self) -> "Sandbox":
|
|
1423
|
+
"""Context manager entry."""
|
|
1424
|
+
return self
|
|
1425
|
+
|
|
1426
|
+
def __exit__(self, *args) -> None:
|
|
1427
|
+
"""Context manager exit - auto cleanup."""
|
|
1428
|
+
try:
|
|
1429
|
+
self.kill()
|
|
1430
|
+
except Exception:
|
|
1431
|
+
# Ignore errors on cleanup
|
|
1432
|
+
pass
|
|
1433
|
+
|
|
1434
|
+
# =============================================================================
|
|
1435
|
+
# UTILITY METHODS
|
|
1436
|
+
# =============================================================================
|
|
1437
|
+
|
|
1438
|
+
def __repr__(self) -> str:
|
|
1439
|
+
return f"<Sandbox {self.sandbox_id}>"
|
|
1440
|
+
|
|
1441
|
+
def __str__(self) -> str:
|
|
1442
|
+
try:
|
|
1443
|
+
info = self.get_info()
|
|
1444
|
+
return f"Sandbox(id={self.sandbox_id}, status={info.status}, url={info.public_host})"
|
|
1445
|
+
except Exception:
|
|
1446
|
+
return f"Sandbox(id={self.sandbox_id})"
|
|
1447
|
+
|