agentfense 0.2.1__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.
- agentfense/__init__.py +191 -0
- agentfense/_async/__init__.py +21 -0
- agentfense/_async/client.py +679 -0
- agentfense/_async/sandbox.py +667 -0
- agentfense/_gen/__init__.py +0 -0
- agentfense/_gen/codebase_pb2.py +78 -0
- agentfense/_gen/codebase_pb2.pyi +141 -0
- agentfense/_gen/codebase_pb2_grpc.py +366 -0
- agentfense/_gen/common_pb2.py +47 -0
- agentfense/_gen/common_pb2.pyi +68 -0
- agentfense/_gen/common_pb2_grpc.py +24 -0
- agentfense/_gen/sandbox_pb2.py +123 -0
- agentfense/_gen/sandbox_pb2.pyi +255 -0
- agentfense/_gen/sandbox_pb2_grpc.py +678 -0
- agentfense/_shared.py +238 -0
- agentfense/client.py +751 -0
- agentfense/exceptions.py +333 -0
- agentfense/presets.py +192 -0
- agentfense/sandbox.py +672 -0
- agentfense/types.py +256 -0
- agentfense/utils.py +286 -0
- agentfense-0.2.1.dist-info/METADATA +378 -0
- agentfense-0.2.1.dist-info/RECORD +25 -0
- agentfense-0.2.1.dist-info/WHEEL +5 -0
- agentfense-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Async high-level Sandbox API for easy sandbox management.
|
|
2
|
+
|
|
3
|
+
This module provides an asynchronous user-friendly interface for creating and
|
|
4
|
+
managing sandboxes with minimal boilerplate.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncIterator, Dict, List, Optional, Union
|
|
10
|
+
|
|
11
|
+
from .client import AsyncSandboxClient, AsyncSessionWrapper
|
|
12
|
+
from ..exceptions import SandboxError, SandboxNotRunningError
|
|
13
|
+
from ..presets import get_preset, get_preset_dicts
|
|
14
|
+
from ..types import (
|
|
15
|
+
Codebase,
|
|
16
|
+
ExecResult,
|
|
17
|
+
Permission,
|
|
18
|
+
PermissionRule,
|
|
19
|
+
ResourceLimits,
|
|
20
|
+
RuntimeType,
|
|
21
|
+
Sandbox as SandboxInfo,
|
|
22
|
+
SandboxStatus,
|
|
23
|
+
)
|
|
24
|
+
from ..utils import (
|
|
25
|
+
generate_codebase_name,
|
|
26
|
+
generate_owner_id,
|
|
27
|
+
human_readable_size,
|
|
28
|
+
walk_directory,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncSandbox:
|
|
33
|
+
"""Async high-level sandbox interface with context manager support.
|
|
34
|
+
|
|
35
|
+
This class provides an asynchronous simplified API for working with sandboxes,
|
|
36
|
+
handling the complexity of codebase creation, file upload, and
|
|
37
|
+
sandbox lifecycle management automatically.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> # One-liner to create a sandbox from local directory
|
|
41
|
+
>>> async with await AsyncSandbox.from_local("./my-project") as sandbox:
|
|
42
|
+
... result = await sandbox.run("python main.py")
|
|
43
|
+
... print(result.stdout)
|
|
44
|
+
|
|
45
|
+
>>> # With custom configuration
|
|
46
|
+
>>> async with await AsyncSandbox.from_local(
|
|
47
|
+
... "./my-project",
|
|
48
|
+
... preset="agent-safe",
|
|
49
|
+
... runtime=RuntimeType.DOCKER,
|
|
50
|
+
... image="python:3.11-slim",
|
|
51
|
+
... ) as sandbox:
|
|
52
|
+
... async with sandbox.session() as session:
|
|
53
|
+
... await session.exec("pip install -r requirements.txt")
|
|
54
|
+
... result = await session.exec("pytest")
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
client: AsyncSandboxClient,
|
|
60
|
+
sandbox_info: SandboxInfo,
|
|
61
|
+
codebase: Codebase,
|
|
62
|
+
owns_client: bool = False,
|
|
63
|
+
owns_codebase: bool = False,
|
|
64
|
+
):
|
|
65
|
+
"""Initialize an AsyncSandbox instance.
|
|
66
|
+
|
|
67
|
+
Note: Use the class methods (from_local, from_codebase) instead
|
|
68
|
+
of calling this constructor directly.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
client: The AsyncSandboxClient instance.
|
|
72
|
+
sandbox_info: The Sandbox info object.
|
|
73
|
+
codebase: The associated Codebase.
|
|
74
|
+
owns_client: Whether this Sandbox owns the client (should close it).
|
|
75
|
+
owns_codebase: Whether this Sandbox owns the codebase (should delete it).
|
|
76
|
+
"""
|
|
77
|
+
self._client = client
|
|
78
|
+
self._sandbox_info = sandbox_info
|
|
79
|
+
self._codebase = codebase
|
|
80
|
+
self._owns_client = owns_client
|
|
81
|
+
self._owns_codebase = owns_codebase
|
|
82
|
+
self._destroyed = False
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
async def from_local(
|
|
86
|
+
cls,
|
|
87
|
+
path: str,
|
|
88
|
+
preset: Optional[str] = "view-only",
|
|
89
|
+
permissions: Optional[List[Union[PermissionRule, Dict]]] = None,
|
|
90
|
+
runtime: RuntimeType = RuntimeType.BWRAP,
|
|
91
|
+
image: Optional[str] = None,
|
|
92
|
+
resources: Optional[ResourceLimits] = None,
|
|
93
|
+
endpoint: str = "localhost:9000",
|
|
94
|
+
secure: bool = False,
|
|
95
|
+
owner_id: Optional[str] = None,
|
|
96
|
+
codebase_name: Optional[str] = None,
|
|
97
|
+
ignore_patterns: Optional[List[str]] = None,
|
|
98
|
+
labels: Optional[Dict[str, str]] = None,
|
|
99
|
+
auto_start: bool = True,
|
|
100
|
+
) -> "AsyncSandbox":
|
|
101
|
+
"""Create a sandbox from a local directory.
|
|
102
|
+
|
|
103
|
+
This is the recommended way to create a sandbox. It automatically:
|
|
104
|
+
1. Creates a codebase
|
|
105
|
+
2. Uploads all files from the directory
|
|
106
|
+
3. Creates a sandbox with the specified permissions
|
|
107
|
+
4. Starts the sandbox (if auto_start is True)
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
path: Path to the local directory.
|
|
111
|
+
preset: Permission preset name ("view-only", "agent-safe", "read-only", "full-access").
|
|
112
|
+
permissions: Additional permission rules (added to preset).
|
|
113
|
+
runtime: Runtime type (bwrap or docker).
|
|
114
|
+
image: Docker image name (required for docker runtime).
|
|
115
|
+
resources: Resource limits (memory, CPU, etc.).
|
|
116
|
+
endpoint: Sandbox service endpoint.
|
|
117
|
+
secure: Whether to use TLS.
|
|
118
|
+
owner_id: Owner ID for the codebase (auto-generated if not provided).
|
|
119
|
+
codebase_name: Name for the codebase (derived from path if not provided).
|
|
120
|
+
ignore_patterns: Additional file patterns to ignore during upload.
|
|
121
|
+
labels: Labels to attach to the sandbox.
|
|
122
|
+
auto_start: Whether to automatically start the sandbox.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
An AsyncSandbox instance ready for use.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
ValueError: If the path doesn't exist or isn't a directory.
|
|
129
|
+
SandboxError: If sandbox creation fails.
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
>>> sandbox = await AsyncSandbox.from_local("./my-project")
|
|
133
|
+
>>> result = await sandbox.run("ls -la")
|
|
134
|
+
>>> print(result.stdout)
|
|
135
|
+
>>> await sandbox.destroy()
|
|
136
|
+
"""
|
|
137
|
+
# Validate path
|
|
138
|
+
dir_path = Path(path).resolve()
|
|
139
|
+
if not dir_path.exists():
|
|
140
|
+
raise ValueError(f"Path does not exist: {path}")
|
|
141
|
+
if not dir_path.is_dir():
|
|
142
|
+
raise ValueError(f"Path is not a directory: {path}")
|
|
143
|
+
|
|
144
|
+
# Normalize preset: treat None as default
|
|
145
|
+
if preset is None:
|
|
146
|
+
preset = "view-only"
|
|
147
|
+
|
|
148
|
+
# Create client
|
|
149
|
+
client = AsyncSandboxClient(endpoint=endpoint, secure=secure)
|
|
150
|
+
|
|
151
|
+
codebase = None
|
|
152
|
+
sandbox_info = None
|
|
153
|
+
try:
|
|
154
|
+
# Generate defaults
|
|
155
|
+
if owner_id is None:
|
|
156
|
+
owner_id = generate_owner_id()
|
|
157
|
+
if codebase_name is None:
|
|
158
|
+
codebase_name = generate_codebase_name(path)
|
|
159
|
+
|
|
160
|
+
# Create codebase
|
|
161
|
+
codebase = await client.create_codebase(name=codebase_name, owner_id=owner_id)
|
|
162
|
+
|
|
163
|
+
# Upload files
|
|
164
|
+
file_count = 0
|
|
165
|
+
total_size = 0
|
|
166
|
+
for rel_path, content in walk_directory(str(dir_path), ignore_patterns):
|
|
167
|
+
await client.upload_file(codebase.id, rel_path, content)
|
|
168
|
+
file_count += 1
|
|
169
|
+
total_size += len(content)
|
|
170
|
+
|
|
171
|
+
# Build permissions: preset + custom
|
|
172
|
+
perm_rules: List[Union[PermissionRule, Dict]] = []
|
|
173
|
+
perm_rules.extend(get_preset_dicts(preset))
|
|
174
|
+
|
|
175
|
+
if permissions:
|
|
176
|
+
perm_rules.extend(permissions)
|
|
177
|
+
|
|
178
|
+
# Create sandbox
|
|
179
|
+
sandbox_info = await client.create_sandbox(
|
|
180
|
+
codebase_id=codebase.id,
|
|
181
|
+
permissions=perm_rules,
|
|
182
|
+
runtime=runtime,
|
|
183
|
+
image=image,
|
|
184
|
+
resources=resources,
|
|
185
|
+
labels=labels,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Start sandbox if requested
|
|
189
|
+
if auto_start:
|
|
190
|
+
sandbox_info = await client.start_sandbox(sandbox_info.id)
|
|
191
|
+
|
|
192
|
+
return cls(
|
|
193
|
+
client=client,
|
|
194
|
+
sandbox_info=sandbox_info,
|
|
195
|
+
codebase=codebase,
|
|
196
|
+
owns_client=True,
|
|
197
|
+
owns_codebase=True,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
except Exception:
|
|
201
|
+
# Clean up sandbox if it was created
|
|
202
|
+
if sandbox_info is not None:
|
|
203
|
+
try:
|
|
204
|
+
await client.destroy_sandbox(sandbox_info.id)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass # Best effort cleanup
|
|
207
|
+
# Clean up codebase if it was created
|
|
208
|
+
if codebase is not None:
|
|
209
|
+
try:
|
|
210
|
+
await client.delete_codebase(codebase.id)
|
|
211
|
+
except Exception:
|
|
212
|
+
pass # Best effort cleanup
|
|
213
|
+
await client.close()
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
async def from_codebase(
|
|
218
|
+
cls,
|
|
219
|
+
codebase_id: str,
|
|
220
|
+
preset: Optional[str] = "view-only",
|
|
221
|
+
permissions: Optional[List[Union[PermissionRule, Dict]]] = None,
|
|
222
|
+
runtime: RuntimeType = RuntimeType.BWRAP,
|
|
223
|
+
image: Optional[str] = None,
|
|
224
|
+
resources: Optional[ResourceLimits] = None,
|
|
225
|
+
endpoint: str = "localhost:9000",
|
|
226
|
+
secure: bool = False,
|
|
227
|
+
labels: Optional[Dict[str, str]] = None,
|
|
228
|
+
auto_start: bool = True,
|
|
229
|
+
) -> "AsyncSandbox":
|
|
230
|
+
"""Create a sandbox from an existing codebase.
|
|
231
|
+
|
|
232
|
+
Use this when you want to create multiple sandboxes from the same
|
|
233
|
+
codebase, or when the codebase already exists.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
codebase_id: ID of the existing codebase.
|
|
237
|
+
preset: Permission preset name.
|
|
238
|
+
permissions: Additional permission rules.
|
|
239
|
+
runtime: Runtime type (bwrap or docker).
|
|
240
|
+
image: Docker image name (for docker runtime).
|
|
241
|
+
resources: Resource limits.
|
|
242
|
+
endpoint: Sandbox service endpoint.
|
|
243
|
+
secure: Whether to use TLS.
|
|
244
|
+
labels: Labels to attach to the sandbox.
|
|
245
|
+
auto_start: Whether to automatically start the sandbox.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
An AsyncSandbox instance ready for use.
|
|
249
|
+
"""
|
|
250
|
+
client = AsyncSandboxClient(endpoint=endpoint, secure=secure)
|
|
251
|
+
|
|
252
|
+
# Normalize preset: treat None as default
|
|
253
|
+
if preset is None:
|
|
254
|
+
preset = "view-only"
|
|
255
|
+
|
|
256
|
+
sandbox_info = None
|
|
257
|
+
try:
|
|
258
|
+
# Get codebase info
|
|
259
|
+
codebase = await client.get_codebase(codebase_id)
|
|
260
|
+
|
|
261
|
+
# Build permissions
|
|
262
|
+
perm_rules: List[Union[PermissionRule, Dict]] = []
|
|
263
|
+
perm_rules.extend(get_preset_dicts(preset))
|
|
264
|
+
if permissions:
|
|
265
|
+
perm_rules.extend(permissions)
|
|
266
|
+
|
|
267
|
+
# Create sandbox
|
|
268
|
+
sandbox_info = await client.create_sandbox(
|
|
269
|
+
codebase_id=codebase_id,
|
|
270
|
+
permissions=perm_rules,
|
|
271
|
+
runtime=runtime,
|
|
272
|
+
image=image,
|
|
273
|
+
resources=resources,
|
|
274
|
+
labels=labels,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if auto_start:
|
|
278
|
+
sandbox_info = await client.start_sandbox(sandbox_info.id)
|
|
279
|
+
|
|
280
|
+
return cls(
|
|
281
|
+
client=client,
|
|
282
|
+
sandbox_info=sandbox_info,
|
|
283
|
+
codebase=codebase,
|
|
284
|
+
owns_client=True,
|
|
285
|
+
owns_codebase=False, # Don't delete existing codebase
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
except Exception:
|
|
289
|
+
# Clean up sandbox if it was created
|
|
290
|
+
if sandbox_info is not None:
|
|
291
|
+
try:
|
|
292
|
+
await client.destroy_sandbox(sandbox_info.id)
|
|
293
|
+
except Exception:
|
|
294
|
+
pass # Best effort cleanup
|
|
295
|
+
await client.close()
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
@classmethod
|
|
299
|
+
async def connect(
|
|
300
|
+
cls,
|
|
301
|
+
sandbox_id: str,
|
|
302
|
+
endpoint: str = "localhost:9000",
|
|
303
|
+
secure: bool = False,
|
|
304
|
+
) -> "AsyncSandbox":
|
|
305
|
+
"""Connect to an existing sandbox.
|
|
306
|
+
|
|
307
|
+
Use this to reconnect to a sandbox that was created earlier.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
sandbox_id: ID of the existing sandbox.
|
|
311
|
+
endpoint: Sandbox service endpoint.
|
|
312
|
+
secure: Whether to use TLS.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
An AsyncSandbox instance connected to the existing sandbox.
|
|
316
|
+
"""
|
|
317
|
+
client = AsyncSandboxClient(endpoint=endpoint, secure=secure)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
# Get sandbox info
|
|
321
|
+
sandbox_info = await client.get_sandbox(sandbox_id)
|
|
322
|
+
|
|
323
|
+
# Get codebase info
|
|
324
|
+
codebase = await client.get_codebase(sandbox_info.codebase_id)
|
|
325
|
+
|
|
326
|
+
return cls(
|
|
327
|
+
client=client,
|
|
328
|
+
sandbox_info=sandbox_info,
|
|
329
|
+
codebase=codebase,
|
|
330
|
+
owns_client=True,
|
|
331
|
+
owns_codebase=False,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
await client.close()
|
|
336
|
+
raise
|
|
337
|
+
|
|
338
|
+
# ============================================
|
|
339
|
+
# Properties
|
|
340
|
+
# ============================================
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def id(self) -> str:
|
|
344
|
+
"""Get the sandbox ID."""
|
|
345
|
+
return self._sandbox_info.id
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def codebase_id(self) -> str:
|
|
349
|
+
"""Get the associated codebase ID."""
|
|
350
|
+
return self._codebase.id
|
|
351
|
+
|
|
352
|
+
@property
|
|
353
|
+
def status(self) -> SandboxStatus:
|
|
354
|
+
"""Get the current sandbox status."""
|
|
355
|
+
return self._sandbox_info.status
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def runtime(self) -> RuntimeType:
|
|
359
|
+
"""Get the runtime type."""
|
|
360
|
+
return self._sandbox_info.runtime
|
|
361
|
+
|
|
362
|
+
@property
|
|
363
|
+
def info(self) -> SandboxInfo:
|
|
364
|
+
"""Get the full sandbox info object."""
|
|
365
|
+
return self._sandbox_info
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def codebase(self) -> Codebase:
|
|
369
|
+
"""Get the associated codebase."""
|
|
370
|
+
return self._codebase
|
|
371
|
+
|
|
372
|
+
# ============================================
|
|
373
|
+
# Lifecycle Methods
|
|
374
|
+
# ============================================
|
|
375
|
+
|
|
376
|
+
async def start(self) -> "AsyncSandbox":
|
|
377
|
+
"""Start the sandbox if it's not already running.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Self for method chaining.
|
|
381
|
+
"""
|
|
382
|
+
if self._sandbox_info.status != SandboxStatus.RUNNING:
|
|
383
|
+
self._sandbox_info = await self._client.start_sandbox(self.id)
|
|
384
|
+
return self
|
|
385
|
+
|
|
386
|
+
async def stop(self) -> "AsyncSandbox":
|
|
387
|
+
"""Stop the sandbox.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Self for method chaining.
|
|
391
|
+
"""
|
|
392
|
+
if self._sandbox_info.status == SandboxStatus.RUNNING:
|
|
393
|
+
self._sandbox_info = await self._client.stop_sandbox(self.id)
|
|
394
|
+
return self
|
|
395
|
+
|
|
396
|
+
async def refresh(self) -> "AsyncSandbox":
|
|
397
|
+
"""Refresh sandbox info from the server.
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Self for method chaining.
|
|
401
|
+
"""
|
|
402
|
+
self._sandbox_info = await self._client.get_sandbox(self.id)
|
|
403
|
+
return self
|
|
404
|
+
|
|
405
|
+
async def destroy(self, delete_codebase: Optional[bool] = None) -> None:
|
|
406
|
+
"""Destroy the sandbox and optionally the codebase.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
delete_codebase: Whether to delete the codebase.
|
|
410
|
+
If None, deletes only if this Sandbox created it.
|
|
411
|
+
"""
|
|
412
|
+
if self._destroyed:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
try:
|
|
416
|
+
# Destroy sandbox
|
|
417
|
+
await self._client.destroy_sandbox(self.id)
|
|
418
|
+
|
|
419
|
+
# Delete codebase if we own it
|
|
420
|
+
should_delete = delete_codebase if delete_codebase is not None else self._owns_codebase
|
|
421
|
+
if should_delete:
|
|
422
|
+
await self._client.delete_codebase(self.codebase_id)
|
|
423
|
+
|
|
424
|
+
finally:
|
|
425
|
+
# Close client if we own it
|
|
426
|
+
if self._owns_client:
|
|
427
|
+
await self._client.close()
|
|
428
|
+
|
|
429
|
+
self._destroyed = True
|
|
430
|
+
|
|
431
|
+
# ============================================
|
|
432
|
+
# Execution Methods
|
|
433
|
+
# ============================================
|
|
434
|
+
|
|
435
|
+
async def run(
|
|
436
|
+
self,
|
|
437
|
+
command: str,
|
|
438
|
+
timeout: int = 60,
|
|
439
|
+
env: Optional[Dict[str, str]] = None,
|
|
440
|
+
workdir: Optional[str] = None,
|
|
441
|
+
raise_on_error: bool = False,
|
|
442
|
+
) -> ExecResult:
|
|
443
|
+
"""Execute a command in the sandbox.
|
|
444
|
+
|
|
445
|
+
This is a simplified wrapper around exec() with sensible defaults.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
command: The command to execute.
|
|
449
|
+
timeout: Timeout in seconds (default: 60).
|
|
450
|
+
env: Environment variables.
|
|
451
|
+
workdir: Working directory.
|
|
452
|
+
raise_on_error: Whether to raise an exception on non-zero exit.
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
The ExecResult with stdout, stderr, and exit code.
|
|
456
|
+
|
|
457
|
+
Raises:
|
|
458
|
+
SandboxNotRunningError: If the sandbox isn't running.
|
|
459
|
+
CommandExecutionError: If raise_on_error is True and command fails.
|
|
460
|
+
CommandTimeoutError: If the command times out.
|
|
461
|
+
|
|
462
|
+
Example:
|
|
463
|
+
>>> result = await sandbox.run("python --version")
|
|
464
|
+
>>> print(result.stdout)
|
|
465
|
+
Python 3.11.0
|
|
466
|
+
"""
|
|
467
|
+
from ..exceptions import CommandExecutionError
|
|
468
|
+
|
|
469
|
+
result = await self._client.exec(
|
|
470
|
+
sandbox_id=self.id,
|
|
471
|
+
command=command,
|
|
472
|
+
timeout=timedelta(seconds=timeout),
|
|
473
|
+
env=env,
|
|
474
|
+
workdir=workdir,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if raise_on_error and result.exit_code != 0:
|
|
478
|
+
raise CommandExecutionError(
|
|
479
|
+
command=command,
|
|
480
|
+
exit_code=result.exit_code,
|
|
481
|
+
stdout=result.stdout,
|
|
482
|
+
stderr=result.stderr,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
return result
|
|
486
|
+
|
|
487
|
+
async def exec(
|
|
488
|
+
self,
|
|
489
|
+
command: str,
|
|
490
|
+
stdin: Optional[str] = None,
|
|
491
|
+
env: Optional[Dict[str, str]] = None,
|
|
492
|
+
workdir: Optional[str] = None,
|
|
493
|
+
timeout: Optional[timedelta] = None,
|
|
494
|
+
) -> ExecResult:
|
|
495
|
+
"""Execute a command in the sandbox (full API).
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
command: The command to execute.
|
|
499
|
+
stdin: Optional stdin input.
|
|
500
|
+
env: Environment variables.
|
|
501
|
+
workdir: Working directory.
|
|
502
|
+
timeout: Timeout duration.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
The ExecResult with stdout, stderr, and exit code.
|
|
506
|
+
"""
|
|
507
|
+
return await self._client.exec(
|
|
508
|
+
sandbox_id=self.id,
|
|
509
|
+
command=command,
|
|
510
|
+
stdin=stdin,
|
|
511
|
+
env=env,
|
|
512
|
+
workdir=workdir,
|
|
513
|
+
timeout=timeout,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
async def exec_stream(
|
|
517
|
+
self,
|
|
518
|
+
command: str,
|
|
519
|
+
stdin: Optional[str] = None,
|
|
520
|
+
env: Optional[Dict[str, str]] = None,
|
|
521
|
+
workdir: Optional[str] = None,
|
|
522
|
+
timeout: Optional[timedelta] = None,
|
|
523
|
+
) -> AsyncIterator[bytes]:
|
|
524
|
+
"""Execute a command and stream the output.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
command: The command to execute.
|
|
528
|
+
stdin: Optional stdin input.
|
|
529
|
+
env: Environment variables.
|
|
530
|
+
workdir: Working directory.
|
|
531
|
+
timeout: Timeout duration.
|
|
532
|
+
|
|
533
|
+
Yields:
|
|
534
|
+
Chunks of output data.
|
|
535
|
+
"""
|
|
536
|
+
async for chunk in self._client.exec_stream(
|
|
537
|
+
sandbox_id=self.id,
|
|
538
|
+
command=command,
|
|
539
|
+
stdin=stdin,
|
|
540
|
+
env=env,
|
|
541
|
+
workdir=workdir,
|
|
542
|
+
timeout=timeout,
|
|
543
|
+
):
|
|
544
|
+
yield chunk
|
|
545
|
+
|
|
546
|
+
# ============================================
|
|
547
|
+
# Session Methods
|
|
548
|
+
# ============================================
|
|
549
|
+
|
|
550
|
+
async def session(
|
|
551
|
+
self,
|
|
552
|
+
shell: str = "/bin/sh",
|
|
553
|
+
env: Optional[Dict[str, str]] = None,
|
|
554
|
+
) -> AsyncSessionWrapper:
|
|
555
|
+
"""Create a new shell session.
|
|
556
|
+
|
|
557
|
+
A session maintains a persistent shell process that preserves
|
|
558
|
+
working directory, environment variables, and background processes.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
shell: The shell binary to use.
|
|
562
|
+
env: Initial environment variables.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
An AsyncSessionWrapper for the new session.
|
|
566
|
+
|
|
567
|
+
Example:
|
|
568
|
+
>>> async with sandbox.session() as session:
|
|
569
|
+
... await session.exec("cd /workspace")
|
|
570
|
+
... await session.exec("source venv/bin/activate")
|
|
571
|
+
... result = await session.exec("python main.py")
|
|
572
|
+
"""
|
|
573
|
+
return await self._client.create_session(
|
|
574
|
+
sandbox_id=self.id,
|
|
575
|
+
shell=shell,
|
|
576
|
+
env=env,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# ============================================
|
|
580
|
+
# File Operations
|
|
581
|
+
# ============================================
|
|
582
|
+
|
|
583
|
+
@staticmethod
|
|
584
|
+
def _to_codebase_path(path: str) -> str:
|
|
585
|
+
"""Map a sandbox path (usually under /workspace) to a codebase path."""
|
|
586
|
+
if path == "/workspace":
|
|
587
|
+
return ""
|
|
588
|
+
if path.startswith("/workspace/"):
|
|
589
|
+
return path[len("/workspace/") :]
|
|
590
|
+
if path.startswith("/"):
|
|
591
|
+
return path[1:]
|
|
592
|
+
return path
|
|
593
|
+
|
|
594
|
+
async def read_file(self, path: str) -> str:
|
|
595
|
+
"""Read a file from the sandbox.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
path: Path to the file in the sandbox.
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
The file content as a string.
|
|
602
|
+
|
|
603
|
+
Example:
|
|
604
|
+
>>> content = await sandbox.read_file("/workspace/output.txt")
|
|
605
|
+
"""
|
|
606
|
+
content = await self._client.download_file(self.codebase_id, self._to_codebase_path(path))
|
|
607
|
+
return content.decode("utf-8")
|
|
608
|
+
|
|
609
|
+
async def read_file_bytes(self, path: str) -> bytes:
|
|
610
|
+
"""Read a file as bytes from the sandbox.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
path: Path to the file in the sandbox.
|
|
614
|
+
|
|
615
|
+
Returns:
|
|
616
|
+
The file content as bytes.
|
|
617
|
+
"""
|
|
618
|
+
return await self._client.download_file(self.codebase_id, self._to_codebase_path(path))
|
|
619
|
+
|
|
620
|
+
async def write_file(self, path: str, content: Union[str, bytes]) -> None:
|
|
621
|
+
"""Write a file to the sandbox.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
path: Path where the file should be stored.
|
|
625
|
+
content: The file content (string or bytes).
|
|
626
|
+
|
|
627
|
+
Example:
|
|
628
|
+
>>> await sandbox.write_file("/workspace/config.json", '{"debug": true}')
|
|
629
|
+
"""
|
|
630
|
+
if isinstance(content, str):
|
|
631
|
+
content = content.encode("utf-8")
|
|
632
|
+
await self._client.upload_file(self.codebase_id, self._to_codebase_path(path), content)
|
|
633
|
+
|
|
634
|
+
async def list_files(self, path: str = "", recursive: bool = False) -> List[str]:
|
|
635
|
+
"""List files in the sandbox.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
path: Directory path (empty for root).
|
|
639
|
+
recursive: Whether to list recursively.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
List of file paths.
|
|
643
|
+
"""
|
|
644
|
+
files = await self._client.list_files(
|
|
645
|
+
codebase_id=self.codebase_id,
|
|
646
|
+
path=self._to_codebase_path(path),
|
|
647
|
+
recursive=recursive,
|
|
648
|
+
)
|
|
649
|
+
return [f.path for f in files]
|
|
650
|
+
|
|
651
|
+
# ============================================
|
|
652
|
+
# Context Manager
|
|
653
|
+
# ============================================
|
|
654
|
+
|
|
655
|
+
async def __aenter__(self) -> "AsyncSandbox":
|
|
656
|
+
"""Enter async context manager."""
|
|
657
|
+
return self
|
|
658
|
+
|
|
659
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
660
|
+
"""Exit async context manager, destroying the sandbox."""
|
|
661
|
+
await self.destroy()
|
|
662
|
+
|
|
663
|
+
def __repr__(self) -> str:
|
|
664
|
+
return (
|
|
665
|
+
f"AsyncSandbox(id={self.id!r}, status={self.status.value!r}, "
|
|
666
|
+
f"runtime={self.runtime.value!r})"
|
|
667
|
+
)
|
|
File without changes
|