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