hopx-ai 0.1.10__tar.gz → 0.1.16__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.
Potentially problematic release.
This version of hopx-ai might be problematic. Click here for more details.
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/PKG-INFO +10 -8
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/README.md +9 -7
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/__init__.py +2 -2
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_agent_client.py +18 -0
- hopx_ai-0.1.16/hopx_ai/_async_agent_client.py +223 -0
- hopx_ai-0.1.16/hopx_ai/_async_cache.py +38 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_async_client.py +1 -1
- hopx_ai-0.1.16/hopx_ai/_async_commands.py +58 -0
- hopx_ai-0.1.16/hopx_ai/_async_env_vars.py +151 -0
- hopx_ai-0.1.16/hopx_ai/_async_files.py +81 -0
- hopx_ai-0.1.16/hopx_ai/_async_terminal.py +184 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_client.py +1 -1
- hopx_ai-0.1.16/hopx_ai/_temp_async_token.py +14 -0
- hopx_ai-0.1.16/hopx_ai/_test_env_fix.py +30 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/async_sandbox.py +336 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/env_vars.py +23 -21
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/errors.py +2 -2
- hopx_ai-0.1.16/hopx_ai/files.py +489 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/models.py +1 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/sandbox.py +17 -9
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/pyproject.toml +1 -1
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/.gitignore +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/CHANGELOG.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/COOKBOOK_TEST_RESULTS.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/DESKTOP_AUTOMATION.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/FIXES_APPLIED_v0.1.9.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/INVESTIGATION_RESULTS.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/agent_test_report.json +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/bunnyshell/_async_client.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/bunnyshell/_client.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/example_dx_showcase.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_code_execution.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_commands.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_complete_workflow.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_files.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_v3_1_1_error_codes.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/agent_v3_1_features.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/async_iterator.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/async_quick_start.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/context_manager.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/debug_logging.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_automation.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_complete_workflow.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_screenshot_recording.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_vnc.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/desktop_windows.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/lazy_iterator.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/lifecycle.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/list_sandboxes.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/quick_start.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/rotate_api_keys.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/template_build.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/template_nodejs.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/examples/templates.py +0 -0
- /hopx_ai-0.1.10/hopx_ai/files.py → /hopx_ai-0.1.16/hopx_ai/_async_files_clean.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_generated/__init__.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_generated/models.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_utils.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/_ws_client.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/cache.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/commands.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/desktop.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/models_updated.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/__init__.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/build_flow.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/builder.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/file_hasher.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/ready_checks.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/tar_creator.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/template/types.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/hopx_ai/terminal.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_agent_comprehensive.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_agent_v3_1_1.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_agent_v3_1_improvements.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_all_features.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_api_endpoints.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_async_live.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_env_vars.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_hybrid_complete.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_hybrid_live.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_improvements.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_jwt_api.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_jwt_auth.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_jwt_simple.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_live.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_logs_polling.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_logs_simple.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_qa_comprehensive.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_qa_simple.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_sdk_e2e_template.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_comprehensive.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_id.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_id_final.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_template_name.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_websocket_features.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_with_real_key.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/test_working_features.py +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/vm_agent_openapi.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/vm_agent_openapi_script.md +0 -0
- {hopx_ai-0.1.10 → hopx_ai-0.1.16}/vm_api_agent_readme.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hopx-ai
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.16
|
|
4
4
|
Summary: Official Python SDK for HOPX.AI Sandboxes
|
|
5
5
|
Project-URL: Homepage, https://hopx.ai
|
|
6
6
|
Project-URL: Documentation, https://docs.hopx.ai
|
|
@@ -329,18 +329,20 @@ asyncio.run(terminal_session())
|
|
|
329
329
|
|
|
330
330
|
```python
|
|
331
331
|
sandbox = Sandbox.create(
|
|
332
|
-
template='code-interpreter',
|
|
332
|
+
template='code-interpreter', # Template defines CPU/RAM/Disk resources
|
|
333
333
|
api_key='your-api-key',
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
timeout=600, # 10 minute timeout
|
|
339
|
-
env_vars={ # Pre-set environment variables
|
|
334
|
+
region='us-west-2', # Preferred region (optional)
|
|
335
|
+
timeout_seconds=600, # Auto-kill after 10 minutes (optional)
|
|
336
|
+
internet_access=True, # Enable internet access (optional, default: True)
|
|
337
|
+
env_vars={ # Pre-set environment variables (optional)
|
|
340
338
|
'DATABASE_URL': 'postgres://...',
|
|
341
339
|
'API_KEY': 'sk-...'
|
|
342
340
|
}
|
|
343
341
|
)
|
|
342
|
+
|
|
343
|
+
# Note: Resources (vCPU, RAM, Disk) come from the template.
|
|
344
|
+
# To customize resources, create a custom template with Template.build()
|
|
345
|
+
# See: https://docs.hopx.ai/templates
|
|
344
346
|
```
|
|
345
347
|
|
|
346
348
|
## Error Handling
|
|
@@ -296,18 +296,20 @@ asyncio.run(terminal_session())
|
|
|
296
296
|
|
|
297
297
|
```python
|
|
298
298
|
sandbox = Sandbox.create(
|
|
299
|
-
template='code-interpreter',
|
|
299
|
+
template='code-interpreter', # Template defines CPU/RAM/Disk resources
|
|
300
300
|
api_key='your-api-key',
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
timeout=600, # 10 minute timeout
|
|
306
|
-
env_vars={ # Pre-set environment variables
|
|
301
|
+
region='us-west-2', # Preferred region (optional)
|
|
302
|
+
timeout_seconds=600, # Auto-kill after 10 minutes (optional)
|
|
303
|
+
internet_access=True, # Enable internet access (optional, default: True)
|
|
304
|
+
env_vars={ # Pre-set environment variables (optional)
|
|
307
305
|
'DATABASE_URL': 'postgres://...',
|
|
308
306
|
'API_KEY': 'sk-...'
|
|
309
307
|
}
|
|
310
308
|
)
|
|
309
|
+
|
|
310
|
+
# Note: Resources (vCPU, RAM, Disk) come from the template.
|
|
311
|
+
# To customize resources, create a custom template with Template.build()
|
|
312
|
+
# See: https://docs.hopx.ai/templates
|
|
311
313
|
```
|
|
312
314
|
|
|
313
315
|
## Error Handling
|
|
@@ -39,7 +39,7 @@ from .models import (
|
|
|
39
39
|
DisplayInfo,
|
|
40
40
|
)
|
|
41
41
|
from .errors import (
|
|
42
|
-
|
|
42
|
+
HopxError,
|
|
43
43
|
APIError,
|
|
44
44
|
AuthenticationError,
|
|
45
45
|
NotFoundError,
|
|
@@ -85,7 +85,7 @@ __all__ = [
|
|
|
85
85
|
"RecordingInfo",
|
|
86
86
|
"DisplayInfo",
|
|
87
87
|
# Errors
|
|
88
|
-
"
|
|
88
|
+
"HopxError",
|
|
89
89
|
"APIError",
|
|
90
90
|
"AuthenticationError",
|
|
91
91
|
"NotFoundError",
|
|
@@ -36,6 +36,7 @@ class AgentHTTPClient:
|
|
|
36
36
|
jwt_token: Optional[str] = None,
|
|
37
37
|
timeout: int = 30,
|
|
38
38
|
max_retries: int = 3,
|
|
39
|
+
token_refresh_callback: Optional[callable] = None,
|
|
39
40
|
):
|
|
40
41
|
"""
|
|
41
42
|
Initialize agent HTTP client.
|
|
@@ -45,11 +46,13 @@ class AgentHTTPClient:
|
|
|
45
46
|
jwt_token: JWT token for agent authentication
|
|
46
47
|
timeout: Default timeout in seconds
|
|
47
48
|
max_retries: Maximum retry attempts
|
|
49
|
+
token_refresh_callback: Callback to refresh token on 401 (returns new token)
|
|
48
50
|
"""
|
|
49
51
|
self._agent_url = agent_url.rstrip('/')
|
|
50
52
|
self._jwt_token = jwt_token
|
|
51
53
|
self._timeout = timeout
|
|
52
54
|
self._max_retries = max_retries
|
|
55
|
+
self._token_refresh_callback = token_refresh_callback
|
|
53
56
|
|
|
54
57
|
# Build headers
|
|
55
58
|
headers = {}
|
|
@@ -298,6 +301,21 @@ class AgentHTTPClient:
|
|
|
298
301
|
return response
|
|
299
302
|
|
|
300
303
|
except httpx.HTTPStatusError as e:
|
|
304
|
+
# Handle 401 Unauthorized - try to refresh token
|
|
305
|
+
if e.response.status_code == 401 and self._token_refresh_callback and attempt == 0:
|
|
306
|
+
logger.info(f"{operation} got 401 Unauthorized, attempting token refresh...")
|
|
307
|
+
try:
|
|
308
|
+
# Call refresh callback to get new token
|
|
309
|
+
new_token = self._token_refresh_callback()
|
|
310
|
+
if new_token:
|
|
311
|
+
# Update token and retry request
|
|
312
|
+
self.update_jwt_token(new_token)
|
|
313
|
+
logger.info(f"{operation} token refreshed, retrying request...")
|
|
314
|
+
continue
|
|
315
|
+
except Exception as refresh_error:
|
|
316
|
+
logger.error(f"Token refresh failed: {refresh_error}")
|
|
317
|
+
# Fall through to raise original error
|
|
318
|
+
|
|
301
319
|
# Check if should retry
|
|
302
320
|
if attempt < self._max_retries - 1 and self._should_retry(e.response.status_code):
|
|
303
321
|
delay = self._get_retry_delay(attempt)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Async HTTP client for agent operations with retry logic."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, Dict, Any
|
|
6
|
+
import httpx
|
|
7
|
+
from .errors import (
|
|
8
|
+
FileNotFoundError,
|
|
9
|
+
FileOperationError,
|
|
10
|
+
CodeExecutionError,
|
|
11
|
+
CommandExecutionError,
|
|
12
|
+
DesktopNotAvailableError,
|
|
13
|
+
AgentError,
|
|
14
|
+
NetworkError,
|
|
15
|
+
TimeoutError as HopxTimeoutError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncAgentHTTPClient:
|
|
22
|
+
"""
|
|
23
|
+
Async HTTP client for agent operations with retry logic and error handling.
|
|
24
|
+
|
|
25
|
+
Features:
|
|
26
|
+
- Connection pooling (reuses TCP connections)
|
|
27
|
+
- Automatic retries with exponential backoff
|
|
28
|
+
- Proper error wrapping to HOPX exceptions
|
|
29
|
+
- Configurable timeouts
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
agent_url: str,
|
|
35
|
+
*,
|
|
36
|
+
jwt_token: Optional[str] = None,
|
|
37
|
+
timeout: int = 30,
|
|
38
|
+
max_retries: int = 3,
|
|
39
|
+
token_refresh_callback: Optional[callable] = None,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Initialize async agent HTTP client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
agent_url: Agent base URL (e.g., https://7777-{id}.domain)
|
|
46
|
+
jwt_token: JWT token for agent authentication
|
|
47
|
+
timeout: Default timeout in seconds
|
|
48
|
+
max_retries: Maximum retry attempts
|
|
49
|
+
token_refresh_callback: Async callback to refresh token on 401
|
|
50
|
+
"""
|
|
51
|
+
self._agent_url = agent_url.rstrip('/')
|
|
52
|
+
self._jwt_token = jwt_token
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._max_retries = max_retries
|
|
55
|
+
self._token_refresh_callback = token_refresh_callback
|
|
56
|
+
|
|
57
|
+
# Build headers
|
|
58
|
+
headers = {}
|
|
59
|
+
if self._jwt_token:
|
|
60
|
+
headers['Authorization'] = f'Bearer {self._jwt_token}'
|
|
61
|
+
|
|
62
|
+
# Create reusable async HTTP client with connection pooling
|
|
63
|
+
self._client = httpx.AsyncClient(
|
|
64
|
+
timeout=httpx.Timeout(timeout),
|
|
65
|
+
limits=httpx.Limits(max_connections=10, max_keepalive_connections=5),
|
|
66
|
+
headers=headers,
|
|
67
|
+
transport=httpx.AsyncHTTPTransport(
|
|
68
|
+
local_address="0.0.0.0", # Force IPv4
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
logger.debug(f"Async agent client initialized: {self._agent_url}")
|
|
73
|
+
|
|
74
|
+
async def close(self) -> None:
|
|
75
|
+
"""Close the HTTP client."""
|
|
76
|
+
await self._client.aclose()
|
|
77
|
+
|
|
78
|
+
def update_jwt_token(self, token: str) -> None:
|
|
79
|
+
"""Update JWT token for agent authentication."""
|
|
80
|
+
self._jwt_token = token
|
|
81
|
+
self._client.headers['Authorization'] = f'Bearer {token}'
|
|
82
|
+
|
|
83
|
+
def _should_retry(self, status_code: int) -> bool:
|
|
84
|
+
"""Check if request should be retried."""
|
|
85
|
+
return status_code in {429, 500, 502, 503, 504}
|
|
86
|
+
|
|
87
|
+
def _get_retry_delay(self, attempt: int) -> float:
|
|
88
|
+
"""Calculate retry delay with exponential backoff."""
|
|
89
|
+
return min(2 ** attempt, 10) # Max 10 seconds
|
|
90
|
+
|
|
91
|
+
def _wrap_error(
|
|
92
|
+
self,
|
|
93
|
+
error: Exception,
|
|
94
|
+
operation: str,
|
|
95
|
+
context: Optional[Dict[str, Any]] = None
|
|
96
|
+
) -> AgentError:
|
|
97
|
+
"""Wrap httpx errors into HOPX exceptions."""
|
|
98
|
+
context = context or {}
|
|
99
|
+
|
|
100
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
101
|
+
status_code = error.response.status_code
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
error_data = error.response.json()
|
|
105
|
+
error_code = error_data.get('code', '').upper()
|
|
106
|
+
error_message = error_data.get('message', str(error))
|
|
107
|
+
except:
|
|
108
|
+
error_code = None
|
|
109
|
+
error_message = str(error)
|
|
110
|
+
|
|
111
|
+
# Map error codes to specific exceptions
|
|
112
|
+
if error_code == 'FILE_NOT_FOUND':
|
|
113
|
+
return FileNotFoundError(
|
|
114
|
+
error_message,
|
|
115
|
+
path=context.get('path'),
|
|
116
|
+
code='file_not_found'
|
|
117
|
+
)
|
|
118
|
+
elif error_code in ('PATH_NOT_ALLOWED', 'PERMISSION_DENIED'):
|
|
119
|
+
return FileOperationError(
|
|
120
|
+
error_message,
|
|
121
|
+
path=context.get('path'),
|
|
122
|
+
operation=operation,
|
|
123
|
+
code='file_operation_failed'
|
|
124
|
+
)
|
|
125
|
+
elif error_code in ('EXECUTION_FAILED', 'EXECUTION_TIMEOUT', 'INVALID_TOKEN'):
|
|
126
|
+
return CodeExecutionError(
|
|
127
|
+
error_message,
|
|
128
|
+
exit_code=error_data.get('exit_code'),
|
|
129
|
+
code='execution_failed'
|
|
130
|
+
)
|
|
131
|
+
elif error_code == 'COMMAND_FAILED':
|
|
132
|
+
return CommandExecutionError(
|
|
133
|
+
error_message,
|
|
134
|
+
exit_code=error_data.get('exit_code'),
|
|
135
|
+
command=context.get('command'),
|
|
136
|
+
code='command_failed'
|
|
137
|
+
)
|
|
138
|
+
elif error_code == 'DESKTOP_NOT_AVAILABLE':
|
|
139
|
+
return DesktopNotAvailableError(
|
|
140
|
+
error_message,
|
|
141
|
+
missing_dependencies=error_data.get('missing_dependencies', []),
|
|
142
|
+
code='desktop_not_available'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
elif isinstance(error, httpx.TimeoutException):
|
|
146
|
+
return HopxTimeoutError(f"{operation} timed out after {self._timeout}s")
|
|
147
|
+
elif isinstance(error, httpx.NetworkError):
|
|
148
|
+
return NetworkError(f"{operation} failed: {error}")
|
|
149
|
+
|
|
150
|
+
return AgentError(f"{operation} failed: {error}")
|
|
151
|
+
|
|
152
|
+
async def _request(
|
|
153
|
+
self,
|
|
154
|
+
method: str,
|
|
155
|
+
endpoint: str,
|
|
156
|
+
operation: str = "request",
|
|
157
|
+
context: Optional[Dict[str, Any]] = None,
|
|
158
|
+
**kwargs
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""Make HTTP request with retry logic."""
|
|
161
|
+
url = f"{self._agent_url}{endpoint}"
|
|
162
|
+
|
|
163
|
+
for attempt in range(self._max_retries):
|
|
164
|
+
try:
|
|
165
|
+
response = await self._client.request(method, url, **kwargs)
|
|
166
|
+
response.raise_for_status()
|
|
167
|
+
|
|
168
|
+
# Handle empty responses (204 No Content)
|
|
169
|
+
if response.status_code == 204 or not response.content:
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
if response.headers.get('content-type', '').startswith('application/json'):
|
|
173
|
+
return response.json()
|
|
174
|
+
return {"content": response.content}
|
|
175
|
+
|
|
176
|
+
except httpx.HTTPStatusError as e:
|
|
177
|
+
# Handle 401 Unauthorized - try to refresh token
|
|
178
|
+
if e.response.status_code == 401 and self._token_refresh_callback and attempt == 0:
|
|
179
|
+
logger.info(f"{operation} got 401 Unauthorized, attempting token refresh...")
|
|
180
|
+
try:
|
|
181
|
+
# Call async refresh callback to get new token
|
|
182
|
+
new_token = await self._token_refresh_callback()
|
|
183
|
+
if new_token:
|
|
184
|
+
# Update token and retry request
|
|
185
|
+
self.update_jwt_token(new_token)
|
|
186
|
+
logger.info(f"{operation} token refreshed, retrying request...")
|
|
187
|
+
continue
|
|
188
|
+
except Exception as refresh_error:
|
|
189
|
+
logger.error(f"Token refresh failed: {refresh_error}")
|
|
190
|
+
# Fall through to raise original error
|
|
191
|
+
|
|
192
|
+
if not self._should_retry(e.response.status_code) or attempt == self._max_retries - 1:
|
|
193
|
+
raise self._wrap_error(e, operation, context)
|
|
194
|
+
|
|
195
|
+
await asyncio.sleep(self._get_retry_delay(attempt))
|
|
196
|
+
|
|
197
|
+
except (httpx.TimeoutException, httpx.NetworkError) as e:
|
|
198
|
+
if attempt == self._max_retries - 1:
|
|
199
|
+
raise self._wrap_error(e, operation, context)
|
|
200
|
+
|
|
201
|
+
await asyncio.sleep(self._get_retry_delay(attempt))
|
|
202
|
+
|
|
203
|
+
raise AgentError(f"{operation} failed after {self._max_retries} retries")
|
|
204
|
+
|
|
205
|
+
async def get(self, endpoint: str, operation: str = "GET request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
206
|
+
"""Make GET request."""
|
|
207
|
+
return await self._request("GET", endpoint, operation=operation, context=context, **kwargs)
|
|
208
|
+
|
|
209
|
+
async def post(self, endpoint: str, operation: str = "POST request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
210
|
+
"""Make POST request."""
|
|
211
|
+
return await self._request("POST", endpoint, operation=operation, context=context, **kwargs)
|
|
212
|
+
|
|
213
|
+
async def put(self, endpoint: str, operation: str = "PUT request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
214
|
+
"""Make PUT request."""
|
|
215
|
+
return await self._request("PUT", endpoint, operation=operation, context=context, **kwargs)
|
|
216
|
+
|
|
217
|
+
async def delete(self, endpoint: str, operation: str = "DELETE request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
218
|
+
"""Make DELETE request."""
|
|
219
|
+
return await self._request("DELETE", endpoint, operation=operation, context=context, **kwargs)
|
|
220
|
+
|
|
221
|
+
async def patch(self, endpoint: str, operation: str = "PATCH request", context: Optional[Dict[str, Any]] = None, **kwargs) -> Dict[str, Any]:
|
|
222
|
+
"""Make PATCH request."""
|
|
223
|
+
return await self._request("PATCH", endpoint, operation=operation, context=context, **kwargs)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Async cache operations for sandboxes."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
import logging
|
|
5
|
+
from ._async_agent_client import AsyncAgentHTTPClient
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncCache:
|
|
11
|
+
"""Async cache operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, sandbox):
|
|
14
|
+
"""Initialize with sandbox reference."""
|
|
15
|
+
self._sandbox = sandbox
|
|
16
|
+
logger.debug("AsyncCache initialized")
|
|
17
|
+
|
|
18
|
+
async def _get_client(self) -> AsyncAgentHTTPClient:
|
|
19
|
+
"""Get agent client from sandbox."""
|
|
20
|
+
await self._sandbox._ensure_agent_client()
|
|
21
|
+
return self._sandbox._agent_client
|
|
22
|
+
|
|
23
|
+
async def stats(self) -> Dict[str, Any]:
|
|
24
|
+
"""Get cache statistics."""
|
|
25
|
+
client = await self._get_client()
|
|
26
|
+
response = await client.get(
|
|
27
|
+
"/cache/stats",
|
|
28
|
+
operation="get cache stats"
|
|
29
|
+
)
|
|
30
|
+
return response
|
|
31
|
+
|
|
32
|
+
async def clear(self) -> None:
|
|
33
|
+
"""Clear cache."""
|
|
34
|
+
client = await self._get_client()
|
|
35
|
+
await client.post(
|
|
36
|
+
"/cache/clear",
|
|
37
|
+
operation="clear cache"
|
|
38
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Async command execution for sandboxes."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Dict, Any
|
|
4
|
+
import logging
|
|
5
|
+
from ._async_agent_client import AsyncAgentHTTPClient
|
|
6
|
+
from .models import ExecutionResult
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncCommands:
|
|
12
|
+
"""Async command execution for sandboxes."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, sandbox):
|
|
15
|
+
"""Initialize with sandbox reference."""
|
|
16
|
+
self._sandbox = sandbox
|
|
17
|
+
logger.debug("AsyncCommands initialized")
|
|
18
|
+
|
|
19
|
+
async def _get_client(self) -> AsyncAgentHTTPClient:
|
|
20
|
+
"""Get agent client from sandbox."""
|
|
21
|
+
await self._sandbox._ensure_agent_client()
|
|
22
|
+
return self._sandbox._agent_client
|
|
23
|
+
|
|
24
|
+
async def run(
|
|
25
|
+
self,
|
|
26
|
+
command: str,
|
|
27
|
+
*,
|
|
28
|
+
timeout_seconds: int = 60,
|
|
29
|
+
env: Optional[Dict[str, str]] = None,
|
|
30
|
+
working_dir: str = "/workspace"
|
|
31
|
+
) -> ExecutionResult:
|
|
32
|
+
"""Run shell command."""
|
|
33
|
+
client = await self._get_client()
|
|
34
|
+
|
|
35
|
+
payload = {
|
|
36
|
+
"command": command,
|
|
37
|
+
"timeout": timeout_seconds,
|
|
38
|
+
"working_dir": working_dir
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if env:
|
|
42
|
+
payload["env"] = env
|
|
43
|
+
|
|
44
|
+
response = await client.post(
|
|
45
|
+
"/commands/run",
|
|
46
|
+
json=payload,
|
|
47
|
+
operation="run command",
|
|
48
|
+
context={"command": command}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return ExecutionResult(
|
|
52
|
+
success=response.get("success", True),
|
|
53
|
+
stdout=response.get("stdout", ""),
|
|
54
|
+
stderr=response.get("stderr", ""),
|
|
55
|
+
exit_code=response.get("exit_code", 0),
|
|
56
|
+
execution_time=response.get("execution_time", 0.0),
|
|
57
|
+
rich_outputs=[]
|
|
58
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Async environment variables for sandboxes."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
import logging
|
|
5
|
+
from ._async_agent_client import AsyncAgentHTTPClient
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncEnvironmentVariables:
|
|
11
|
+
"""
|
|
12
|
+
Async environment variable operations.
|
|
13
|
+
|
|
14
|
+
Provides methods for managing environment variables inside the sandbox at runtime.
|
|
15
|
+
|
|
16
|
+
Features:
|
|
17
|
+
- Get all environment variables
|
|
18
|
+
- Set/replace all environment variables
|
|
19
|
+
- Update specific environment variables (merge)
|
|
20
|
+
- Delete individual environment variables
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, sandbox):
|
|
24
|
+
"""Initialize with sandbox reference."""
|
|
25
|
+
self._sandbox = sandbox
|
|
26
|
+
logger.debug("AsyncEnvironmentVariables initialized")
|
|
27
|
+
|
|
28
|
+
async def _get_client(self) -> AsyncAgentHTTPClient:
|
|
29
|
+
"""Get agent client from sandbox."""
|
|
30
|
+
await self._sandbox._ensure_agent_client()
|
|
31
|
+
return self._sandbox._agent_client
|
|
32
|
+
|
|
33
|
+
async def get_all(self, *, timeout: Optional[int] = None) -> Dict[str, str]:
|
|
34
|
+
"""
|
|
35
|
+
Get all environment variables.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
timeout: Request timeout in seconds (overrides default)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Dictionary of environment variables
|
|
42
|
+
"""
|
|
43
|
+
logger.debug("Getting all environment variables")
|
|
44
|
+
|
|
45
|
+
client = await self._get_client()
|
|
46
|
+
response = await client.get(
|
|
47
|
+
"/env",
|
|
48
|
+
operation="get environment variables"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return response.get("env_vars", {})
|
|
52
|
+
|
|
53
|
+
async def set_all(
|
|
54
|
+
self,
|
|
55
|
+
env_vars: Dict[str, str],
|
|
56
|
+
*,
|
|
57
|
+
timeout: Optional[int] = None
|
|
58
|
+
) -> Dict[str, str]:
|
|
59
|
+
"""
|
|
60
|
+
Set/replace all environment variables.
|
|
61
|
+
|
|
62
|
+
This replaces ALL existing environment variables with the provided ones.
|
|
63
|
+
Use update() if you want to merge instead.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
env_vars: Dictionary of environment variables to set
|
|
67
|
+
timeout: Request timeout in seconds
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Updated dictionary of all environment variables
|
|
71
|
+
"""
|
|
72
|
+
logger.debug(f"Setting {len(env_vars)} environment variables (replace all)")
|
|
73
|
+
|
|
74
|
+
client = await self._get_client()
|
|
75
|
+
response = await client.put(
|
|
76
|
+
"/env",
|
|
77
|
+
json={"env_vars": env_vars},
|
|
78
|
+
operation="set environment variables"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return response.get("env_vars", env_vars)
|
|
82
|
+
|
|
83
|
+
async def update(
|
|
84
|
+
self,
|
|
85
|
+
env_vars: Dict[str, str],
|
|
86
|
+
*,
|
|
87
|
+
timeout: Optional[int] = None
|
|
88
|
+
) -> Dict[str, str]:
|
|
89
|
+
"""
|
|
90
|
+
Update specific environment variables (merge).
|
|
91
|
+
|
|
92
|
+
This merges the provided variables with existing ones.
|
|
93
|
+
Existing variables not specified are preserved.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
env_vars: Dictionary of environment variables to update/add
|
|
97
|
+
timeout: Request timeout in seconds
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
Updated dictionary of all environment variables
|
|
101
|
+
"""
|
|
102
|
+
logger.debug(f"Updating {len(env_vars)} environment variables (merge)")
|
|
103
|
+
|
|
104
|
+
client = await self._get_client()
|
|
105
|
+
response = await client.patch(
|
|
106
|
+
"/env",
|
|
107
|
+
json={"env_vars": env_vars, "merge": True}, # ✅ FIXED: add merge flag
|
|
108
|
+
operation="update environment variables"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Agent returns 204 No Content - get updated vars
|
|
112
|
+
if not response or not response.get("env_vars"):
|
|
113
|
+
return await self.get_all()
|
|
114
|
+
|
|
115
|
+
return response.get("env_vars", {})
|
|
116
|
+
|
|
117
|
+
async def delete(self, name: str, *, timeout: Optional[int] = None) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Delete a specific environment variable.
|
|
120
|
+
|
|
121
|
+
Note: Agent's DELETE /env clears ALL custom variables.
|
|
122
|
+
We work around this by re-setting all vars except the one to delete.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
name: Variable name to delete
|
|
126
|
+
timeout: Request timeout in seconds
|
|
127
|
+
"""
|
|
128
|
+
logger.debug(f"Deleting environment variable: {name}")
|
|
129
|
+
|
|
130
|
+
# Get all current env vars
|
|
131
|
+
current_vars = await self.get_all()
|
|
132
|
+
|
|
133
|
+
# Remove the specified variable
|
|
134
|
+
if name in current_vars:
|
|
135
|
+
del current_vars[name]
|
|
136
|
+
# Re-set all env vars without the deleted one
|
|
137
|
+
await self.set_all(current_vars)
|
|
138
|
+
logger.debug(f"Environment variable {name} deleted")
|
|
139
|
+
else:
|
|
140
|
+
logger.debug(f"Environment variable {name} not found (already deleted)")
|
|
141
|
+
|
|
142
|
+
# Convenience methods (aliases)
|
|
143
|
+
|
|
144
|
+
async def get(self, name: str) -> Optional[str]:
|
|
145
|
+
"""Get a single environment variable value."""
|
|
146
|
+
all_vars = await self.get_all()
|
|
147
|
+
return all_vars.get(name)
|
|
148
|
+
|
|
149
|
+
async def set(self, name: str, value: str) -> None:
|
|
150
|
+
"""Set a single environment variable (convenience method)."""
|
|
151
|
+
await self.update({name: value})
|