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,679 @@
1
+ """Async Sandbox SDK Client for interacting with the Sandbox service.
2
+
3
+ This module provides an asynchronous version of SandboxClient using grpc.aio.
4
+ """
5
+
6
+ from datetime import timedelta
7
+ from functools import wraps
8
+ from typing import AsyncIterator, Callable, Dict, List, Optional, TypeVar, Union
9
+
10
+ import grpc
11
+ import grpc.aio
12
+ from google.protobuf.duration_pb2 import Duration
13
+
14
+ from .._gen import sandbox_pb2, sandbox_pb2_grpc
15
+ from .._gen import codebase_pb2, codebase_pb2_grpc
16
+ from .._gen import common_pb2
17
+ from ..types import (
18
+ Codebase,
19
+ ExecResult,
20
+ FileInfo,
21
+ Permission,
22
+ PatternType,
23
+ PermissionRule,
24
+ ResourceLimits,
25
+ RuntimeType,
26
+ Sandbox,
27
+ SandboxStatus,
28
+ Session,
29
+ SessionStatus,
30
+ UploadResult,
31
+ )
32
+ from ..exceptions import (
33
+ SandboxError,
34
+ SandboxNotFoundError,
35
+ CodebaseNotFoundError,
36
+ CommandTimeoutError,
37
+ ConnectionError,
38
+ PermissionDeniedError,
39
+ SessionNotFoundError,
40
+ from_grpc_error,
41
+ )
42
+ from .._shared import (
43
+ permission_to_proto,
44
+ pattern_type_to_proto,
45
+ runtime_type_to_proto,
46
+ resource_limits_to_proto,
47
+ duration_to_timedelta,
48
+ proto_to_sandbox,
49
+ proto_to_codebase,
50
+ proto_to_file_info,
51
+ proto_to_session,
52
+ )
53
+
54
+ # Type variable for decorator
55
+ T = TypeVar("T")
56
+
57
+
58
+ def _handle_grpc_errors_async(context: str = "") -> Callable:
59
+ """Decorator to convert gRPC errors to SDK exceptions (async version).
60
+
61
+ Args:
62
+ context: Description of the operation for error messages.
63
+ """
64
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
65
+ @wraps(func)
66
+ async def wrapper(*args, **kwargs) -> T:
67
+ try:
68
+ return await func(*args, **kwargs)
69
+ except grpc.aio.AioRpcError as e:
70
+ raise from_grpc_error(e, context)
71
+ return wrapper
72
+ return decorator
73
+
74
+
75
+ class AsyncSessionWrapper:
76
+ """Async wrapper for a shell session with context manager support.
77
+
78
+ A session maintains a persistent shell process that preserves
79
+ working directory, environment variables, and background processes.
80
+
81
+ Example:
82
+ >>> async with sandbox.session() as session:
83
+ ... await session.exec("cd /workspace")
84
+ ... await session.exec("npm install")
85
+ ... result = await session.exec("npm test")
86
+ """
87
+
88
+ def __init__(self, client: "AsyncSandboxClient", session: Session):
89
+ """Initialize the AsyncSessionWrapper.
90
+
91
+ Args:
92
+ client: The AsyncSandboxClient instance.
93
+ session: The Session object.
94
+ """
95
+ self._client = client
96
+ self._session = session
97
+
98
+ @property
99
+ def id(self) -> str:
100
+ """Get the session ID."""
101
+ return self._session.id
102
+
103
+ @property
104
+ def sandbox_id(self) -> str:
105
+ """Get the sandbox ID."""
106
+ return self._session.sandbox_id
107
+
108
+ @property
109
+ def status(self) -> SessionStatus:
110
+ """Get the session status."""
111
+ return self._session.status
112
+
113
+ @property
114
+ def shell(self) -> str:
115
+ """Get the shell binary path."""
116
+ return self._session.shell
117
+
118
+ async def exec(
119
+ self,
120
+ command: str,
121
+ timeout: Optional[timedelta] = None,
122
+ ) -> ExecResult:
123
+ """Execute a command in the session.
124
+
125
+ The command runs in the context of the persistent shell,
126
+ so working directory and environment changes persist.
127
+
128
+ Args:
129
+ command: The command to execute.
130
+ timeout: Optional timeout duration.
131
+
132
+ Returns:
133
+ The ExecResult with stdout, stderr, and exit code.
134
+ """
135
+ return await self._client.session_exec(self.id, command, timeout)
136
+
137
+ async def close(self):
138
+ """Close the session and clean up all child processes."""
139
+ await self._client.destroy_session(self.id)
140
+
141
+ async def __aenter__(self) -> "AsyncSessionWrapper":
142
+ """Enter async context manager."""
143
+ return self
144
+
145
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
146
+ """Exit async context manager, closing the session."""
147
+ await self.close()
148
+
149
+
150
+ class AsyncSandboxClient:
151
+ """Async client for interacting with the Sandbox service.
152
+
153
+ Example:
154
+ >>> client = AsyncSandboxClient(endpoint="localhost:9000")
155
+ >>> sandbox = await client.create_sandbox(
156
+ ... codebase_id="cb_123",
157
+ ... permissions=[
158
+ ... {"pattern": "/docs/**", "permission": "write"},
159
+ ... {"pattern": "**/*.py", "permission": "read"},
160
+ ... ]
161
+ ... )
162
+ >>> await client.start_sandbox(sandbox.id)
163
+ >>> result = await client.exec(sandbox.id, command="ls -la /workspace")
164
+ >>> print(result.stdout)
165
+ """
166
+
167
+ def __init__(self, endpoint: str = "localhost:9000", secure: bool = False):
168
+ """Initialize the AsyncSandboxClient.
169
+
170
+ Args:
171
+ endpoint: The gRPC server endpoint (host:port).
172
+ secure: Whether to use TLS for the connection.
173
+ """
174
+ if secure:
175
+ self._channel = grpc.aio.secure_channel(endpoint, grpc.ssl_channel_credentials())
176
+ else:
177
+ self._channel = grpc.aio.insecure_channel(endpoint)
178
+
179
+ self._sandbox_stub = sandbox_pb2_grpc.SandboxServiceStub(self._channel)
180
+ self._codebase_stub = codebase_pb2_grpc.CodebaseServiceStub(self._channel)
181
+
182
+ async def close(self):
183
+ """Close the gRPC channel."""
184
+ await self._channel.close()
185
+
186
+ async def __aenter__(self):
187
+ return self
188
+
189
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
190
+ await self.close()
191
+
192
+ # ============================================
193
+ # Sandbox Operations
194
+ # ============================================
195
+
196
+ @_handle_grpc_errors_async("create sandbox")
197
+ async def create_sandbox(
198
+ self,
199
+ codebase_id: str,
200
+ permissions: Optional[List[Union[PermissionRule, Dict]]] = None,
201
+ labels: Optional[Dict[str, str]] = None,
202
+ expires_in: Optional[timedelta] = None,
203
+ runtime: RuntimeType = RuntimeType.BWRAP,
204
+ image: Optional[str] = None,
205
+ resources: Optional[ResourceLimits] = None,
206
+ ) -> Sandbox:
207
+ """Create a new sandbox.
208
+
209
+ Args:
210
+ codebase_id: The ID of the codebase to use.
211
+ permissions: List of permission rules (PermissionRule or dict).
212
+ labels: Optional labels for the sandbox.
213
+ expires_in: Optional expiration duration.
214
+ runtime: Runtime type (bwrap or docker). Default is bwrap.
215
+ image: Docker image to use (required for docker runtime).
216
+ resources: Resource limits (memory, CPU, processes).
217
+
218
+ Returns:
219
+ The created Sandbox object.
220
+ """
221
+ # Convert permissions to protobuf
222
+ pb_permissions = []
223
+ if permissions:
224
+ for p in permissions:
225
+ if isinstance(p, PermissionRule):
226
+ pb_permissions.append(common_pb2.PermissionRule(
227
+ pattern=p.pattern,
228
+ permission=permission_to_proto(p.permission),
229
+ type=pattern_type_to_proto(p.type),
230
+ priority=p.priority,
231
+ ))
232
+ elif isinstance(p, dict):
233
+ perm = Permission(p.get("permission", "read"))
234
+ ptype = PatternType(p.get("type", "glob"))
235
+ pb_permissions.append(common_pb2.PermissionRule(
236
+ pattern=p["pattern"],
237
+ permission=permission_to_proto(perm),
238
+ type=pattern_type_to_proto(ptype),
239
+ priority=int(p.get("priority", 0)),
240
+ ))
241
+
242
+ # Build request
243
+ request = sandbox_pb2.CreateSandboxRequest(
244
+ codebase_id=codebase_id,
245
+ permissions=pb_permissions,
246
+ labels=labels or {},
247
+ runtime=runtime_type_to_proto(runtime),
248
+ image=image or "",
249
+ )
250
+
251
+ if expires_in:
252
+ request.expires_in.CopyFrom(Duration(
253
+ seconds=int(expires_in.total_seconds()),
254
+ nanos=int((expires_in.total_seconds() % 1) * 1e9),
255
+ ))
256
+
257
+ if resources:
258
+ pb_resources = resource_limits_to_proto(resources)
259
+ if pb_resources:
260
+ request.resources.CopyFrom(pb_resources)
261
+
262
+ response = await self._sandbox_stub.CreateSandbox(request)
263
+ return proto_to_sandbox(response)
264
+
265
+ @_handle_grpc_errors_async("get sandbox")
266
+ async def get_sandbox(self, sandbox_id: str) -> Sandbox:
267
+ """Get information about a sandbox.
268
+
269
+ Args:
270
+ sandbox_id: The ID of the sandbox.
271
+
272
+ Returns:
273
+ The Sandbox object.
274
+ """
275
+ request = sandbox_pb2.GetSandboxRequest(sandbox_id=sandbox_id)
276
+ response = await self._sandbox_stub.GetSandbox(request)
277
+ return proto_to_sandbox(response)
278
+
279
+ @_handle_grpc_errors_async("list sandboxes")
280
+ async def list_sandboxes(self, codebase_id: Optional[str] = None) -> List[Sandbox]:
281
+ """List all sandboxes.
282
+
283
+ Args:
284
+ codebase_id: Optional filter by codebase ID.
285
+
286
+ Returns:
287
+ List of Sandbox objects.
288
+ """
289
+ request = sandbox_pb2.ListSandboxesRequest(codebase_id=codebase_id or "")
290
+ response = await self._sandbox_stub.ListSandboxes(request)
291
+ return [proto_to_sandbox(sb) for sb in response.sandboxes]
292
+
293
+ @_handle_grpc_errors_async("start sandbox")
294
+ async def start_sandbox(self, sandbox_id: str) -> Sandbox:
295
+ """Start a pending sandbox.
296
+
297
+ Args:
298
+ sandbox_id: The ID of the sandbox to start.
299
+
300
+ Returns:
301
+ The updated Sandbox object.
302
+ """
303
+ request = sandbox_pb2.StartSandboxRequest(sandbox_id=sandbox_id)
304
+ response = await self._sandbox_stub.StartSandbox(request)
305
+ return proto_to_sandbox(response)
306
+
307
+ @_handle_grpc_errors_async("stop sandbox")
308
+ async def stop_sandbox(self, sandbox_id: str) -> Sandbox:
309
+ """Stop a running sandbox.
310
+
311
+ Args:
312
+ sandbox_id: The ID of the sandbox to stop.
313
+
314
+ Returns:
315
+ The updated Sandbox object.
316
+ """
317
+ request = sandbox_pb2.StopSandboxRequest(sandbox_id=sandbox_id)
318
+ response = await self._sandbox_stub.StopSandbox(request)
319
+ return proto_to_sandbox(response)
320
+
321
+ @_handle_grpc_errors_async("destroy sandbox")
322
+ async def destroy_sandbox(self, sandbox_id: str) -> None:
323
+ """Destroy a sandbox and release resources.
324
+
325
+ Args:
326
+ sandbox_id: The ID of the sandbox to destroy.
327
+ """
328
+ request = sandbox_pb2.DestroySandboxRequest(sandbox_id=sandbox_id)
329
+ await self._sandbox_stub.DestroySandbox(request)
330
+
331
+ @_handle_grpc_errors_async("execute command")
332
+ async def exec(
333
+ self,
334
+ sandbox_id: str,
335
+ command: str,
336
+ stdin: Optional[str] = None,
337
+ env: Optional[Dict[str, str]] = None,
338
+ workdir: Optional[str] = None,
339
+ timeout: Optional[timedelta] = None,
340
+ ) -> ExecResult:
341
+ """Execute a command in a sandbox.
342
+
343
+ Args:
344
+ sandbox_id: The ID of the sandbox.
345
+ command: The command to execute.
346
+ stdin: Optional stdin input.
347
+ env: Optional environment variables.
348
+ workdir: Optional working directory.
349
+ timeout: Optional timeout duration.
350
+
351
+ Returns:
352
+ The ExecResult with stdout, stderr, and exit code.
353
+ """
354
+ request = sandbox_pb2.ExecRequest(
355
+ sandbox_id=sandbox_id,
356
+ command=command,
357
+ stdin=stdin or "",
358
+ env=env or {},
359
+ workdir=workdir or "",
360
+ )
361
+
362
+ if timeout:
363
+ request.timeout.CopyFrom(Duration(
364
+ seconds=int(timeout.total_seconds()),
365
+ nanos=int((timeout.total_seconds() % 1) * 1e9),
366
+ ))
367
+
368
+ response = await self._sandbox_stub.Exec(request)
369
+ return ExecResult(
370
+ stdout=response.stdout,
371
+ stderr=response.stderr,
372
+ exit_code=response.exit_code,
373
+ duration=duration_to_timedelta(response.duration),
374
+ command=command,
375
+ )
376
+
377
+ async def exec_stream(
378
+ self,
379
+ sandbox_id: str,
380
+ command: str,
381
+ stdin: Optional[str] = None,
382
+ env: Optional[Dict[str, str]] = None,
383
+ workdir: Optional[str] = None,
384
+ timeout: Optional[timedelta] = None,
385
+ ) -> AsyncIterator[bytes]:
386
+ """Execute a command and stream the output.
387
+
388
+ Args:
389
+ sandbox_id: The ID of the sandbox.
390
+ command: The command to execute.
391
+ stdin: Optional stdin input.
392
+ env: Optional environment variables.
393
+ workdir: Optional working directory.
394
+ timeout: Optional timeout duration.
395
+
396
+ Yields:
397
+ Chunks of output data.
398
+ """
399
+ request = sandbox_pb2.ExecRequest(
400
+ sandbox_id=sandbox_id,
401
+ command=command,
402
+ stdin=stdin or "",
403
+ env=env or {},
404
+ workdir=workdir or "",
405
+ )
406
+
407
+ if timeout:
408
+ request.timeout.CopyFrom(Duration(
409
+ seconds=int(timeout.total_seconds()),
410
+ nanos=int((timeout.total_seconds() % 1) * 1e9),
411
+ ))
412
+
413
+ try:
414
+ async for response in self._sandbox_stub.ExecStream(request):
415
+ yield response.data
416
+ except grpc.aio.AioRpcError as e:
417
+ raise from_grpc_error(e, "execute command (stream)")
418
+
419
+ # ============================================
420
+ # Session Operations
421
+ # ============================================
422
+
423
+ @_handle_grpc_errors_async("create session")
424
+ async def create_session(
425
+ self,
426
+ sandbox_id: str,
427
+ shell: str = "/bin/sh",
428
+ env: Optional[Dict[str, str]] = None,
429
+ ) -> AsyncSessionWrapper:
430
+ """Create a new shell session within a sandbox.
431
+
432
+ Args:
433
+ sandbox_id: The ID of the sandbox.
434
+ shell: The shell binary to use (default: /bin/sh).
435
+ env: Optional initial environment variables.
436
+
437
+ Returns:
438
+ An AsyncSessionWrapper object for the new session.
439
+ """
440
+ request = sandbox_pb2.CreateSessionRequest(
441
+ sandbox_id=sandbox_id,
442
+ shell=shell,
443
+ env=env or {},
444
+ )
445
+ response = await self._sandbox_stub.CreateSession(request)
446
+ session = proto_to_session(response)
447
+ return AsyncSessionWrapper(self, session)
448
+
449
+ async def session(
450
+ self,
451
+ sandbox_id: str,
452
+ shell: str = "/bin/sh",
453
+ env: Optional[Dict[str, str]] = None,
454
+ ) -> AsyncSessionWrapper:
455
+ """Create a session with context manager support (alias for create_session).
456
+
457
+ Example:
458
+ >>> async with client.session(sandbox_id) as session:
459
+ ... await session.exec("cd /workspace")
460
+ ... await session.exec("npm install")
461
+ """
462
+ return await self.create_session(sandbox_id, shell, env)
463
+
464
+ @_handle_grpc_errors_async("get session")
465
+ async def get_session(self, session_id: str) -> Session:
466
+ """Get information about a session.
467
+
468
+ Args:
469
+ session_id: The ID of the session.
470
+
471
+ Returns:
472
+ The Session object.
473
+ """
474
+ request = sandbox_pb2.GetSessionRequest(session_id=session_id)
475
+ response = await self._sandbox_stub.GetSession(request)
476
+ return proto_to_session(response)
477
+
478
+ @_handle_grpc_errors_async("list sessions")
479
+ async def list_sessions(self, sandbox_id: str) -> List[Session]:
480
+ """List all sessions for a sandbox.
481
+
482
+ Args:
483
+ sandbox_id: The ID of the sandbox.
484
+
485
+ Returns:
486
+ List of Session objects.
487
+ """
488
+ request = sandbox_pb2.ListSessionsRequest(sandbox_id=sandbox_id)
489
+ response = await self._sandbox_stub.ListSessions(request)
490
+ return [proto_to_session(s) for s in response.sessions]
491
+
492
+ @_handle_grpc_errors_async("destroy session")
493
+ async def destroy_session(self, session_id: str) -> None:
494
+ """Destroy a session and clean up all child processes.
495
+
496
+ Args:
497
+ session_id: The ID of the session to destroy.
498
+ """
499
+ request = sandbox_pb2.DestroySessionRequest(session_id=session_id)
500
+ await self._sandbox_stub.DestroySession(request)
501
+
502
+ @_handle_grpc_errors_async("session exec")
503
+ async def session_exec(
504
+ self,
505
+ session_id: str,
506
+ command: str,
507
+ timeout: Optional[timedelta] = None,
508
+ ) -> ExecResult:
509
+ """Execute a command within a session (stateful).
510
+
511
+ Args:
512
+ session_id: The ID of the session.
513
+ command: The command to execute.
514
+ timeout: Optional timeout duration.
515
+
516
+ Returns:
517
+ The ExecResult with stdout, stderr, and exit code.
518
+ """
519
+ request = sandbox_pb2.SessionExecRequest(
520
+ session_id=session_id,
521
+ command=command,
522
+ )
523
+
524
+ if timeout:
525
+ request.timeout.CopyFrom(Duration(
526
+ seconds=int(timeout.total_seconds()),
527
+ nanos=int((timeout.total_seconds() % 1) * 1e9),
528
+ ))
529
+
530
+ response = await self._sandbox_stub.SessionExec(request)
531
+ return ExecResult(
532
+ stdout=response.stdout,
533
+ stderr=response.stderr,
534
+ exit_code=response.exit_code,
535
+ duration=duration_to_timedelta(response.duration),
536
+ command=command,
537
+ )
538
+
539
+ # ============================================
540
+ # Codebase Operations
541
+ # ============================================
542
+
543
+ @_handle_grpc_errors_async("create codebase")
544
+ async def create_codebase(self, name: str, owner_id: str) -> Codebase:
545
+ """Create a new codebase.
546
+
547
+ Args:
548
+ name: The name of the codebase.
549
+ owner_id: The ID of the owner.
550
+
551
+ Returns:
552
+ The created Codebase object.
553
+ """
554
+ request = codebase_pb2.CreateCodebaseRequest(name=name, owner_id=owner_id)
555
+ response = await self._codebase_stub.CreateCodebase(request)
556
+ return proto_to_codebase(response)
557
+
558
+ @_handle_grpc_errors_async("get codebase")
559
+ async def get_codebase(self, codebase_id: str) -> Codebase:
560
+ """Get information about a codebase.
561
+
562
+ Args:
563
+ codebase_id: The ID of the codebase.
564
+
565
+ Returns:
566
+ The Codebase object.
567
+ """
568
+ request = codebase_pb2.GetCodebaseRequest(codebase_id=codebase_id)
569
+ response = await self._codebase_stub.GetCodebase(request)
570
+ return proto_to_codebase(response)
571
+
572
+ @_handle_grpc_errors_async("list codebases")
573
+ async def list_codebases(self, owner_id: Optional[str] = None) -> List[Codebase]:
574
+ """List all codebases.
575
+
576
+ Args:
577
+ owner_id: Optional filter by owner ID.
578
+
579
+ Returns:
580
+ List of Codebase objects.
581
+ """
582
+ request = codebase_pb2.ListCodebasesRequest(owner_id=owner_id or "")
583
+ response = await self._codebase_stub.ListCodebases(request)
584
+ return [proto_to_codebase(cb) for cb in response.codebases]
585
+
586
+ @_handle_grpc_errors_async("delete codebase")
587
+ async def delete_codebase(self, codebase_id: str) -> None:
588
+ """Delete a codebase.
589
+
590
+ Args:
591
+ codebase_id: The ID of the codebase to delete.
592
+ """
593
+ request = codebase_pb2.DeleteCodebaseRequest(codebase_id=codebase_id)
594
+ await self._codebase_stub.DeleteCodebase(request)
595
+
596
+ @_handle_grpc_errors_async("upload file")
597
+ async def upload_file(
598
+ self,
599
+ codebase_id: str,
600
+ file_path: str,
601
+ content: bytes,
602
+ chunk_size: int = 64 * 1024,
603
+ ) -> UploadResult:
604
+ """Upload a file to a codebase.
605
+
606
+ Args:
607
+ codebase_id: The ID of the codebase.
608
+ file_path: The path where the file should be stored.
609
+ content: The file content as bytes.
610
+ chunk_size: Size of upload chunks (default 64KB).
611
+
612
+ Returns:
613
+ The UploadResult with file info.
614
+ """
615
+ async def generate_chunks():
616
+ # First send metadata
617
+ yield codebase_pb2.UploadChunk(
618
+ metadata=codebase_pb2.UploadChunk.Metadata(
619
+ codebase_id=codebase_id,
620
+ file_path=file_path,
621
+ total_size=len(content),
622
+ )
623
+ )
624
+ # Then send data chunks
625
+ for i in range(0, len(content), chunk_size):
626
+ yield codebase_pb2.UploadChunk(data=content[i:i + chunk_size])
627
+
628
+ response = await self._codebase_stub.UploadFiles(generate_chunks())
629
+ return UploadResult(
630
+ codebase_id=response.codebase_id,
631
+ file_path=response.file_path,
632
+ size=response.size,
633
+ checksum=response.checksum,
634
+ )
635
+
636
+ @_handle_grpc_errors_async("download file")
637
+ async def download_file(self, codebase_id: str, file_path: str) -> bytes:
638
+ """Download a file from a codebase.
639
+
640
+ Args:
641
+ codebase_id: The ID of the codebase.
642
+ file_path: The path of the file to download.
643
+
644
+ Returns:
645
+ The file content as bytes.
646
+ """
647
+ request = codebase_pb2.DownloadFileRequest(
648
+ codebase_id=codebase_id,
649
+ file_path=file_path,
650
+ )
651
+ chunks = []
652
+ async for response in self._codebase_stub.DownloadFile(request):
653
+ chunks.append(response.data)
654
+ return b"".join(chunks)
655
+
656
+ @_handle_grpc_errors_async("list files")
657
+ async def list_files(
658
+ self,
659
+ codebase_id: str,
660
+ path: str = "",
661
+ recursive: bool = False,
662
+ ) -> List[FileInfo]:
663
+ """List files in a codebase directory.
664
+
665
+ Args:
666
+ codebase_id: The ID of the codebase.
667
+ path: The directory path (empty for root).
668
+ recursive: Whether to list recursively.
669
+
670
+ Returns:
671
+ List of FileInfo objects.
672
+ """
673
+ request = codebase_pb2.ListFilesRequest(
674
+ codebase_id=codebase_id,
675
+ path=path,
676
+ recursive=recursive,
677
+ )
678
+ response = await self._codebase_stub.ListFiles(request)
679
+ return [proto_to_file_info(f) for f in response.files]