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/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
+ )