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.
@@ -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