ap-sandbox 0.1.2__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.
- ap_sandbox-0.1.2/.gitignore +5 -0
- ap_sandbox-0.1.2/PKG-INFO +15 -0
- ap_sandbox-0.1.2/ap_sandbox/__init__.py +14 -0
- ap_sandbox-0.1.2/ap_sandbox/client.py +180 -0
- ap_sandbox-0.1.2/ap_sandbox/config.py +100 -0
- ap_sandbox-0.1.2/ap_sandbox/errors.py +41 -0
- ap_sandbox-0.1.2/ap_sandbox/manager.py +166 -0
- ap_sandbox-0.1.2/pyproject.toml +35 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ap-sandbox
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A Client for Agent Platform Sandbox.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: e2b-code-interpreter>=2.4.1
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
A Client for Agent Platform Sandbox.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""ap-sandbox — E2B sandbox lifecycle management for ACS environments."""
|
|
2
|
+
|
|
3
|
+
from ap_sandbox.config import SandboxConfig
|
|
4
|
+
from ap_sandbox.errors import SandboxError, SandboxErrorCode
|
|
5
|
+
from ap_sandbox.manager import SandboxManager
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SandboxManager",
|
|
9
|
+
"SandboxConfig",
|
|
10
|
+
"SandboxError",
|
|
11
|
+
"SandboxErrorCode",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""E2B SDK wrapper for ap-sandbox.
|
|
2
|
+
|
|
3
|
+
All SDK interactions are funnelled through this module so that the rest
|
|
4
|
+
of the package never touches ``e2b`` directly. Every public function
|
|
5
|
+
accepts a ``SandboxConfig`` and raises ``SandboxError`` on failure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from e2b_code_interpreter import Sandbox
|
|
15
|
+
|
|
16
|
+
from ap_sandbox.config import SandboxConfig
|
|
17
|
+
from ap_sandbox.errors import SandboxError, SandboxErrorCode
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("ap_sandbox.client")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_request_id(biz_id: str) -> str:
|
|
23
|
+
"""Generate a unique request ID with *biz_id* as prefix.
|
|
24
|
+
|
|
25
|
+
Format: ``{biz_id}-{8-char-hex}``
|
|
26
|
+
"""
|
|
27
|
+
return f"{biz_id}-{uuid.uuid4().hex[:8]}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _classify_and_raise(
|
|
31
|
+
action: str, error: Exception, default_code: SandboxErrorCode
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Inspect *error* and raise a ``SandboxError`` with the best-fit code."""
|
|
34
|
+
message = str(error)
|
|
35
|
+
lowered = message.lower()
|
|
36
|
+
|
|
37
|
+
if ("api" in lowered and "key" in lowered) or "unauthor" in lowered:
|
|
38
|
+
raise SandboxError(
|
|
39
|
+
SandboxErrorCode.AUTH_FAILED,
|
|
40
|
+
f"Failed to {action}: credentials rejected. Check E2B_API_KEY. "
|
|
41
|
+
f"Original: {message}",
|
|
42
|
+
cause=error,
|
|
43
|
+
) from error
|
|
44
|
+
|
|
45
|
+
if "template" in lowered and (
|
|
46
|
+
"not found" in lowered or "unknown" in lowered
|
|
47
|
+
):
|
|
48
|
+
raise SandboxError(
|
|
49
|
+
SandboxErrorCode.TEMPLATE_NOT_FOUND,
|
|
50
|
+
f"Failed to {action}: template not found. Check SANDBOX_TEMPLATE. "
|
|
51
|
+
f"Original: {message}",
|
|
52
|
+
cause=error,
|
|
53
|
+
) from error
|
|
54
|
+
|
|
55
|
+
if "quota" in lowered or "limit" in lowered:
|
|
56
|
+
raise SandboxError(
|
|
57
|
+
SandboxErrorCode.QUOTA_EXCEEDED,
|
|
58
|
+
f"Failed to {action}: quota exceeded. Original: {message}",
|
|
59
|
+
cause=error,
|
|
60
|
+
) from error
|
|
61
|
+
|
|
62
|
+
raise SandboxError(
|
|
63
|
+
default_code,
|
|
64
|
+
f"Failed to {action}: {message}",
|
|
65
|
+
cause=error,
|
|
66
|
+
) from error
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_request_headers(config: SandboxConfig) -> dict[str, str]:
|
|
70
|
+
"""Build X-Request-ID header from config for API-layer calls."""
|
|
71
|
+
if config.ap_sandbox_metadata and "ap-job-id" in config.ap_sandbox_metadata:
|
|
72
|
+
biz_id = config.ap_sandbox_metadata["ap-job-id"]
|
|
73
|
+
else:
|
|
74
|
+
biz_id = config.sandbox_template
|
|
75
|
+
return {"X-Request-ID": make_request_id(biz_id)}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_metadata(config: SandboxConfig) -> dict[str, str]:
|
|
79
|
+
"""Build sandbox metadata by merging defaults with user-supplied extras."""
|
|
80
|
+
metadata = {
|
|
81
|
+
"e2b.agents.kruise.io/skip-init-runtime": "true",
|
|
82
|
+
"e2b.agents.kruise.io/create-on-no-stock": "true",
|
|
83
|
+
"e2b.agents.kruise.io/claim-timeout-seconds": str(
|
|
84
|
+
config.sandbox_create_timeout
|
|
85
|
+
),
|
|
86
|
+
"e2b.agents.kruise.io/wait-ready-timeout-seconds": str(
|
|
87
|
+
config.wait_ready_timeout_sec
|
|
88
|
+
),
|
|
89
|
+
"e2b.agents.kruise.io/reserve-failed-sandbox-for": config.reserve_failed_sandbox_for,
|
|
90
|
+
"e2b.agents.kruise.io/return-sandbox-ip": "true",
|
|
91
|
+
}
|
|
92
|
+
if config.ap_sandbox_metadata:
|
|
93
|
+
metadata.update(config.ap_sandbox_metadata)
|
|
94
|
+
return metadata
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_sandbox(config: SandboxConfig, **kwargs: Any) -> Sandbox:
|
|
98
|
+
"""Create and return an E2B ``Sandbox`` instance.
|
|
99
|
+
|
|
100
|
+
Extra *kwargs* are forwarded directly to ``Sandbox.create()``, allowing
|
|
101
|
+
callers to customise parameters such as ``envs``, ``secure``,
|
|
102
|
+
``network``, ``lifecycle``, etc.
|
|
103
|
+
|
|
104
|
+
The ``metadata`` key is treated specially: caller-supplied metadata is
|
|
105
|
+
**merged** into the built-in metadata (caller values win on conflict).
|
|
106
|
+
All other *kwargs* fully override the corresponding defaults.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
config.validate_required()
|
|
110
|
+
except ValueError as value_error:
|
|
111
|
+
raise SandboxError(
|
|
112
|
+
SandboxErrorCode.CONFIG_MISSING,
|
|
113
|
+
str(value_error),
|
|
114
|
+
cause=value_error,
|
|
115
|
+
) from value_error
|
|
116
|
+
|
|
117
|
+
metadata = build_metadata(config)
|
|
118
|
+
# Merge caller-supplied metadata into built-in metadata (caller wins)
|
|
119
|
+
if "metadata" in kwargs:
|
|
120
|
+
metadata.update(kwargs.pop("metadata"))
|
|
121
|
+
|
|
122
|
+
# Build X-Request-ID header for tracing
|
|
123
|
+
headers = _build_request_headers(config)
|
|
124
|
+
# Merge caller-supplied headers (caller wins)
|
|
125
|
+
if "headers" in kwargs:
|
|
126
|
+
headers.update(kwargs.pop("headers"))
|
|
127
|
+
|
|
128
|
+
create_params: dict[str, Any] = {
|
|
129
|
+
"template": config.sandbox_template,
|
|
130
|
+
"timeout": config.sandbox_timeout_sec,
|
|
131
|
+
"metadata": metadata,
|
|
132
|
+
"domain": config.e2b_domain,
|
|
133
|
+
"api_key": config.e2b_api_key,
|
|
134
|
+
"request_timeout": config.sandbox_create_timeout,
|
|
135
|
+
"headers": headers,
|
|
136
|
+
}
|
|
137
|
+
# Remaining kwargs override defaults
|
|
138
|
+
create_params.update(kwargs)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
return Sandbox.create(**create_params)
|
|
142
|
+
except Exception as error:
|
|
143
|
+
_classify_and_raise("create sandbox", error, SandboxErrorCode.CREATE_FAILED)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def kill_sandbox(sandbox: Any, config: SandboxConfig | None = None) -> None:
|
|
147
|
+
"""Kill a running sandbox instance.
|
|
148
|
+
|
|
149
|
+
If *config* is provided, an ``X-Request-ID`` header is injected for
|
|
150
|
+
API-layer tracing.
|
|
151
|
+
"""
|
|
152
|
+
opts: dict[str, Any] = {}
|
|
153
|
+
if config is not None:
|
|
154
|
+
opts["headers"] = _build_request_headers(config)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
sandbox.kill(**opts)
|
|
158
|
+
except AttributeError as attribute_error:
|
|
159
|
+
raise SandboxError(
|
|
160
|
+
SandboxErrorCode.KILL_FAILED,
|
|
161
|
+
"Sandbox object has no kill() method",
|
|
162
|
+
cause=attribute_error,
|
|
163
|
+
) from attribute_error
|
|
164
|
+
except Exception as error:
|
|
165
|
+
_classify_and_raise("kill sandbox", error, SandboxErrorCode.KILL_FAILED)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_sandbox_ip(sandbox: Any, config: SandboxConfig | None = None) -> str:
|
|
169
|
+
"""Query the sandbox for its VM IP address.
|
|
170
|
+
|
|
171
|
+
If *config* is provided, an ``X-Request-ID`` header is injected for
|
|
172
|
+
API-layer tracing.
|
|
173
|
+
"""
|
|
174
|
+
opts: dict[str, Any] = {}
|
|
175
|
+
if config is not None:
|
|
176
|
+
opts["headers"] = _build_request_headers(config)
|
|
177
|
+
|
|
178
|
+
info = sandbox.get_info(**opts)
|
|
179
|
+
metadata = info.metadata or {}
|
|
180
|
+
return metadata.get("e2b.agents.kruise.io/sandbox-ip", "")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Pydantic-based configuration for ap-sandbox.
|
|
2
|
+
|
|
3
|
+
All sandbox settings are loaded from environment variables with sensible
|
|
4
|
+
defaults. Aliases are supported so that both legacy names
|
|
5
|
+
(``E2B_DOMAIN``) and new names (``AP_SANDBOX_PROVIDER_ENDPOINT``) work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import model_validator
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SandboxConfig(BaseSettings):
|
|
19
|
+
"""Sandbox configuration backed by environment variables."""
|
|
20
|
+
|
|
21
|
+
model_config = SettingsConfigDict(
|
|
22
|
+
env_prefix="",
|
|
23
|
+
case_sensitive=False,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# --- E2B / ACS connection ---
|
|
27
|
+
e2b_domain: str = ""
|
|
28
|
+
e2b_api_key: str = ""
|
|
29
|
+
|
|
30
|
+
# --- Sandbox template & lifecycle ---
|
|
31
|
+
sandbox_template: str = ""
|
|
32
|
+
sandbox_timeout_sec: int = 3600
|
|
33
|
+
wait_ready_timeout_sec: int = 120
|
|
34
|
+
reserve_failed_sandbox_for: str = "never"
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def sandbox_create_timeout(self) -> int:
|
|
38
|
+
"""Request timeout for the sandbox create API call (seconds)."""
|
|
39
|
+
return self.wait_ready_timeout_sec + 15
|
|
40
|
+
|
|
41
|
+
# --- Optional extra metadata (JSON string from env) ---
|
|
42
|
+
ap_sandbox_metadata: dict[str, str] | None = None
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Alias support: legacy env vars mapped to canonical fields
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
@model_validator(mode="before")
|
|
48
|
+
@classmethod
|
|
49
|
+
def apply_aliases(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
"""Map alternative environment variable names to canonical fields."""
|
|
51
|
+
alias_map: dict[str, list[str]] = {
|
|
52
|
+
"e2b_domain": ["AP_SANDBOX_PROVIDER_ENDPOINT"],
|
|
53
|
+
"e2b_api_key": ["AP_SANDBOX_PROVIDER_CREDENTIAL"],
|
|
54
|
+
"sandbox_template": ["SANDBOX_TEMPLATE", "SANDBOX_TEMPLATE_ID"],
|
|
55
|
+
"sandbox_timeout_sec": ["SANDBOX_TIMEOUT_SEC"],
|
|
56
|
+
"wait_ready_timeout_sec": ["SANDBOX_READY_TIMEOUT_SEC"],
|
|
57
|
+
"reserve_failed_sandbox_for": ["SANDBOX_RESERVE_FAILED_FOR"],
|
|
58
|
+
}
|
|
59
|
+
for canonical, aliases in alias_map.items():
|
|
60
|
+
if values.get(canonical):
|
|
61
|
+
continue
|
|
62
|
+
for alias in aliases:
|
|
63
|
+
env_value = os.environ.get(alias, "")
|
|
64
|
+
if env_value:
|
|
65
|
+
values[canonical] = env_value
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
# Parse AP_SANDBOX_METADATA from JSON string
|
|
69
|
+
raw_metadata = values.get("ap_sandbox_metadata") or values.get(
|
|
70
|
+
"AP_SANDBOX_METADATA"
|
|
71
|
+
)
|
|
72
|
+
if isinstance(raw_metadata, str):
|
|
73
|
+
try:
|
|
74
|
+
parsed = json.loads(raw_metadata)
|
|
75
|
+
if isinstance(parsed, dict):
|
|
76
|
+
values["ap_sandbox_metadata"] = parsed
|
|
77
|
+
else:
|
|
78
|
+
values["ap_sandbox_metadata"] = None
|
|
79
|
+
except (json.JSONDecodeError, TypeError):
|
|
80
|
+
values["ap_sandbox_metadata"] = None
|
|
81
|
+
elif raw_metadata is not None and not isinstance(raw_metadata, dict):
|
|
82
|
+
# pydantic-settings may have already parsed JSON into a non-dict type
|
|
83
|
+
values["ap_sandbox_metadata"] = None
|
|
84
|
+
|
|
85
|
+
return values
|
|
86
|
+
|
|
87
|
+
def validate_required(self) -> None:
|
|
88
|
+
"""Raise ``ValueError`` if critical fields are empty."""
|
|
89
|
+
missing: list[str] = []
|
|
90
|
+
if not self.e2b_domain:
|
|
91
|
+
missing.append("E2B_DOMAIN / AP_SANDBOX_PROVIDER_ENDPOINT")
|
|
92
|
+
if not self.e2b_api_key:
|
|
93
|
+
missing.append("E2B_API_KEY / AP_SANDBOX_PROVIDER_CREDENTIAL")
|
|
94
|
+
if not self.sandbox_template:
|
|
95
|
+
missing.append("SANDBOX_TEMPLATE / SANDBOX_TEMPLATE_ID")
|
|
96
|
+
if missing:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Missing required sandbox configuration: {', '.join(missing)}. "
|
|
99
|
+
"Set these environment variables before creating a sandbox."
|
|
100
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Unified error codes and exception hierarchy for ap-sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SandboxErrorCode(IntEnum):
|
|
9
|
+
"""Numeric error codes for programmatic branching."""
|
|
10
|
+
|
|
11
|
+
UNKNOWN = 1000
|
|
12
|
+
CONFIG_MISSING = 1001
|
|
13
|
+
AUTH_FAILED = 1002
|
|
14
|
+
TEMPLATE_NOT_FOUND = 1003
|
|
15
|
+
QUOTA_EXCEEDED = 1004
|
|
16
|
+
CREATE_FAILED = 1005
|
|
17
|
+
CONNECT_FAILED = 1006
|
|
18
|
+
KILL_FAILED = 1007
|
|
19
|
+
IP_QUERY_FAILED = 1008
|
|
20
|
+
TIMEOUT = 1009
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SandboxError(Exception):
|
|
24
|
+
"""Base exception for all sandbox operations.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
code: A ``SandboxErrorCode`` identifying the failure category.
|
|
28
|
+
message: Human-readable description of what went wrong.
|
|
29
|
+
cause: Optional underlying exception that triggered this error.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
code: SandboxErrorCode,
|
|
35
|
+
message: str,
|
|
36
|
+
cause: Exception | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.code = code
|
|
39
|
+
self.message = message
|
|
40
|
+
self.cause = cause
|
|
41
|
+
super().__init__(f"[{code.name}({code.value})] {message}")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""SandboxManager — the primary public interface for ap-sandbox.
|
|
2
|
+
|
|
3
|
+
Lifecycle management with signal handling, context-manager support,
|
|
4
|
+
and ``atexit`` safety net.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import logging
|
|
11
|
+
import signal
|
|
12
|
+
from types import FrameType
|
|
13
|
+
from typing import Any
|
|
14
|
+
from e2b_code_interpreter import Sandbox
|
|
15
|
+
|
|
16
|
+
from ap_sandbox.client import create_sandbox, get_sandbox_ip, kill_sandbox
|
|
17
|
+
from ap_sandbox.config import SandboxConfig
|
|
18
|
+
from ap_sandbox.errors import SandboxError, SandboxErrorCode
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("ap_sandbox.manager")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SandboxManager:
|
|
24
|
+
"""Create, use, and automatically clean up E2B sandboxes.
|
|
25
|
+
|
|
26
|
+
Features:
|
|
27
|
+
- **Signal handling**: ``SIGINT`` / ``SIGTERM`` trigger automatic cleanup.
|
|
28
|
+
- **Context manager**: ``with SandboxManager() as mgr: ...``
|
|
29
|
+
- **atexit**: registered as a last-resort cleanup.
|
|
30
|
+
|
|
31
|
+
Example::
|
|
32
|
+
|
|
33
|
+
from ap_sandbox import SandboxManager
|
|
34
|
+
|
|
35
|
+
with SandboxManager() as mgr:
|
|
36
|
+
mgr.create()
|
|
37
|
+
print(mgr.sandbox_ip)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: SandboxConfig | None = None) -> None:
|
|
41
|
+
self.config = config or SandboxConfig()
|
|
42
|
+
self._sandbox: Sandbox | None = None
|
|
43
|
+
self._sandbox_ip: str | None = None
|
|
44
|
+
|
|
45
|
+
self._prev_sigint: Any = None
|
|
46
|
+
self._prev_sigterm: Any = None
|
|
47
|
+
self._killed = False
|
|
48
|
+
|
|
49
|
+
self._install_signal_handlers()
|
|
50
|
+
atexit.register(self.kill)
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Public API
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def create(self, **kwargs: Any) -> "SandboxManager":
|
|
57
|
+
"""Create a sandbox and query its Sandbox IP. Returns *self* for chaining.
|
|
58
|
+
|
|
59
|
+
Extra *kwargs* are forwarded to ``Sandbox.create()``. This allows
|
|
60
|
+
callers to pass E2B-supported parameters like ``envs``, ``secure``,
|
|
61
|
+
``network``, ``lifecycle``, ``volume_mounts``, etc.
|
|
62
|
+
|
|
63
|
+
Example::
|
|
64
|
+
|
|
65
|
+
mgr.create(envs={"MY_VAR": "value"}, secure=False)
|
|
66
|
+
"""
|
|
67
|
+
self._sandbox = create_sandbox(self.config, **kwargs)
|
|
68
|
+
sandbox_id = self._get_sandbox_id()
|
|
69
|
+
logger.info("Sandbox %s created", sandbox_id)
|
|
70
|
+
|
|
71
|
+
self._sandbox_ip = get_sandbox_ip(self._sandbox, self.config)
|
|
72
|
+
logger.info("Sandbox %s IP: %s", sandbox_id, self._sandbox_ip)
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
def kill(self) -> None:
|
|
76
|
+
"""Idempotently destroy the sandbox and clean up internal state."""
|
|
77
|
+
if self._killed or self._sandbox is None:
|
|
78
|
+
return
|
|
79
|
+
sandbox_id = self._get_sandbox_id()
|
|
80
|
+
try:
|
|
81
|
+
kill_sandbox(self._sandbox, self.config)
|
|
82
|
+
logger.info("Sandbox %s killed", sandbox_id)
|
|
83
|
+
except Exception as error:
|
|
84
|
+
logger.error("Failed to kill sandbox %s: %s", sandbox_id, error)
|
|
85
|
+
finally:
|
|
86
|
+
self._sandbox = None
|
|
87
|
+
self._sandbox_ip = None
|
|
88
|
+
self._killed = True
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def sandbox(self) -> Sandbox | None:
|
|
92
|
+
"""The underlying E2B sandbox object, or ``None`` if not created."""
|
|
93
|
+
return self._sandbox
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def sandbox_id(self) -> str | None:
|
|
97
|
+
"""The sandbox ID string, or ``None`` if not created."""
|
|
98
|
+
if self._sandbox is None:
|
|
99
|
+
return None
|
|
100
|
+
return self._get_sandbox_id()
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def sandbox_ip(self) -> str | None:
|
|
104
|
+
"""The sandbox IP string, or ``None`` if not created."""
|
|
105
|
+
if self._sandbox is None:
|
|
106
|
+
return None
|
|
107
|
+
return self._sandbox_ip
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Context manager
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "SandboxManager":
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *_exc: object) -> None:
|
|
117
|
+
self.kill()
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Signal handling
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _install_signal_handlers(self) -> None:
|
|
124
|
+
"""Register ``SIGINT`` / ``SIGTERM`` handlers that clean up the sandbox."""
|
|
125
|
+
try:
|
|
126
|
+
self._prev_sigint = signal.getsignal(signal.SIGINT)
|
|
127
|
+
self._prev_sigterm = signal.getsignal(signal.SIGTERM)
|
|
128
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
129
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
130
|
+
except (OSError, ValueError):
|
|
131
|
+
# Not on main thread or signals not supported — skip silently.
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
def _signal_handler(self, signum: int, frame: FrameType | None) -> None:
|
|
135
|
+
logger.warning("Signal %s received, cleaning up sandbox", signum)
|
|
136
|
+
self.kill()
|
|
137
|
+
self._restore_signal_handlers()
|
|
138
|
+
|
|
139
|
+
if signum == signal.SIGINT:
|
|
140
|
+
if callable(self._prev_sigint):
|
|
141
|
+
self._prev_sigint(signum, frame)
|
|
142
|
+
raise KeyboardInterrupt
|
|
143
|
+
if callable(self._prev_sigterm):
|
|
144
|
+
self._prev_sigterm(signum, frame)
|
|
145
|
+
raise SystemExit(0)
|
|
146
|
+
|
|
147
|
+
def _restore_signal_handlers(self) -> None:
|
|
148
|
+
try:
|
|
149
|
+
if self._prev_sigint is not None:
|
|
150
|
+
signal.signal(signal.SIGINT, self._prev_sigint)
|
|
151
|
+
if self._prev_sigterm is not None:
|
|
152
|
+
signal.signal(signal.SIGTERM, self._prev_sigterm)
|
|
153
|
+
except (OSError, ValueError):
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
# Internals
|
|
158
|
+
# ------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
def _get_sandbox_id(self) -> str:
|
|
161
|
+
sandbox_id = getattr(self._sandbox, "id", None) or getattr(
|
|
162
|
+
self._sandbox, "sandbox_id", None
|
|
163
|
+
)
|
|
164
|
+
if not sandbox_id:
|
|
165
|
+
return "<unknown>"
|
|
166
|
+
return str(sandbox_id)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling >= 1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ap-sandbox"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "A Client for Agent Platform Sandbox."
|
|
9
|
+
readme = { text = "A Client for Agent Platform Sandbox.", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
dependencies = [
|
|
13
|
+
"e2b-code-interpreter>=2.4.1",
|
|
14
|
+
"pydantic>=2.0",
|
|
15
|
+
"pydantic-settings>=2.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=7.0",
|
|
21
|
+
"mypy>=1.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
addopts = "-v --tb=short"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
only-include = [
|
|
30
|
+
"/ap_sandbox",
|
|
31
|
+
"/pyproject.toml",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["ap_sandbox"]
|