grasp-sdk 0.1.9__py3-none-any.whl → 0.2.0b1__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.

Potentially problematic release.


This version of grasp-sdk might be problematic. Click here for more details.

@@ -7,6 +7,7 @@ equivalent to the TypeScript version in src/services/sandbox.service.ts
7
7
  """
8
8
 
9
9
  import asyncio
10
+ import json
10
11
  import os
11
12
  import tempfile
12
13
  import time
@@ -41,6 +42,8 @@ class CommandEventEmitter:
41
42
  def set_handle(self, handle: Any) -> None:
42
43
  """Set the command handle (used internally)."""
43
44
  self.handle = handle
45
+ if self.is_killed:
46
+ self.handle.kill()
44
47
 
45
48
  def on(self, event: str, callback: Callable) -> None:
46
49
  """Register event listener."""
@@ -48,6 +51,11 @@ class CommandEventEmitter:
48
51
  self._callbacks[event] = []
49
52
  self._callbacks[event].append(callback)
50
53
 
54
+ def off(self, event: str, callback: Callable) -> None:
55
+ """Remove event listener."""
56
+ if event in self._callbacks and callback in self._callbacks[event]:
57
+ self._callbacks[event].remove(callback)
58
+
51
59
  def emit(self, event: Any, *args) -> None:
52
60
  """Emit event to all registered listeners."""
53
61
  # print(f'🚀 emit ${event}')
@@ -60,16 +68,41 @@ class CommandEventEmitter:
60
68
  pass
61
69
 
62
70
  async def wait(self) -> Any:
63
- """Wait for command to complete."""
71
+ """Wait for command to complete.
72
+
73
+ Returns:
74
+ Command result or exception result if command fails
75
+ """
64
76
  while not self.handle:
65
77
  await asyncio.sleep(0.1)
66
- return await self.handle.wait()
78
+ try:
79
+ return await self.handle.wait()
80
+ except Exception as ex:
81
+ # 对于 CommandExitException,提取有用信息并返回结构化结果
82
+ if hasattr(ex, 'exit_code') or 'CommandExitException' in str(type(ex)):
83
+ return {
84
+ 'error': str(ex),
85
+ 'exit_code': getattr(ex, 'exit_code', -1),
86
+ 'stdout': getattr(ex, 'stdout', ''),
87
+ 'stderr': getattr(ex, 'stderr', str(ex))
88
+ }
89
+ # Return exception result if available, similar to TypeScript version
90
+ if hasattr(ex, 'result'):
91
+ # 尝试获取异常的结果属性,如果不存在则返回None
92
+ return getattr(ex, 'result', None)
93
+ # 对于其他异常,返回结构化错误信息
94
+ return {
95
+ 'error': str(ex),
96
+ 'exit_code': -1,
97
+ 'stdout': '',
98
+ 'stderr': str(ex)
99
+ }
67
100
 
68
101
  async def kill(self) -> None:
69
102
  """Kill the running command."""
70
- if not self.handle:
71
- raise RuntimeError('Command handle not set')
72
- await self.handle.kill()
103
+ self.is_killed = True
104
+ if self.handle:
105
+ await self.handle.kill()
73
106
 
74
107
  def get_handle(self) -> Optional[Any]:
75
108
  """Get the original command handle."""
@@ -123,6 +156,56 @@ class SandboxService:
123
156
  """Get timeout value."""
124
157
  return self.config['timeout']
125
158
 
159
+ async def connect_sandbox(self, sandbox_id: str) -> None:
160
+ """
161
+ Connects to an existing sandbox.
162
+
163
+ Args:
164
+ sandbox_id: ID of the sandbox to connect to
165
+
166
+ Raises:
167
+ RuntimeError: If sandbox connection fails
168
+ """
169
+ try:
170
+ self.status = SandboxStatus.CREATING
171
+
172
+ self.logger.info(f'Connection Grasp sandbox: {sandbox_id}')
173
+
174
+ # Verify authentication
175
+ res = await verify({'key': self.config['key']})
176
+ if not res['success']:
177
+ raise RuntimeError('Authorization failed.')
178
+
179
+ api_key = res['data']['token']
180
+
181
+ # Connect to existing sandbox
182
+ self.sandbox = await AsyncSandbox.connect(
183
+ sandbox_id=sandbox_id,
184
+ api_key=api_key
185
+ )
186
+
187
+ self.status = SandboxStatus.RUNNING
188
+
189
+ # Read existing config from sandbox
190
+ config_content = await self.sandbox.files.read('/home/user/.grasp-config.json')
191
+ timeout = self.config['timeout']
192
+ self.config = json.loads(config_content)
193
+
194
+ # Update timeout if different
195
+ if timeout > 0 and timeout != self.config['timeout']:
196
+ # Note: e2b Python SDK may not have setTimeout method
197
+ # This is equivalent to the TypeScript version's setTimeout call
198
+ self.sandbox.set_timeout(timeout)
199
+
200
+ self.logger.info('Grasp sandbox connected', {
201
+ 'sandboxId': getattr(self.sandbox, 'sandbox_id', 'unknown'),
202
+ })
203
+
204
+ except Exception as error:
205
+ self.status = SandboxStatus.ERROR
206
+ self.logger.error('Failed to connect Grasp sandbox', str(error))
207
+ raise RuntimeError(f'Failed to connect sandbox: {error}')
208
+
126
209
  async def create_sandbox(self, template_id: str, envs: Optional[Dict[str, str]] = None) -> None:
127
210
  """
128
211
  Creates and starts a new sandbox.
@@ -135,7 +218,7 @@ class SandboxService:
135
218
  self.status = SandboxStatus.CREATING
136
219
 
137
220
  # Verify authentication
138
- res = await verify(self.config)
221
+ res = await verify({'key': self.config['key']})
139
222
  if not res['success']:
140
223
  raise RuntimeError('Authorization failed.')
141
224
 
@@ -152,6 +235,17 @@ class SandboxService:
152
235
  timeout=self.config['timeout'] // 1000, # Convert ms to seconds
153
236
  envs=envs,
154
237
  )
238
+
239
+ # Write grasp config file
240
+ import json
241
+ config_data = {
242
+ 'id': getattr(self.sandbox, 'sandbox_id', 'unknown'),
243
+ **self.config
244
+ }
245
+ await self.sandbox.files.write(
246
+ '/home/user/.grasp-config.json',
247
+ json.dumps(config_data)
248
+ )
155
249
 
156
250
  self.status = SandboxStatus.RUNNING
157
251
  self.logger.info('Grasp sandbox created successfully', {
@@ -192,10 +286,11 @@ class SandboxService:
192
286
  options = {}
193
287
 
194
288
  cwd = options.get('cwd', self.DEFAULT_WORKING_DIRECTORY)
195
- timeout_ms = options.get('timeout', self.config['timeout'])
289
+ timeout_ms = options.get('timeout_ms', 0)
196
290
  use_nohup = options.get('nohup', False)
197
291
  in_background = options.get('inBackground', False)
198
292
  envs = options.get('envs', {})
293
+ user = options.get('user', 'user')
199
294
 
200
295
  # print(f'command: {command}')
201
296
  # print(f'🚀 options {options}')
@@ -224,9 +319,10 @@ class SandboxService:
224
319
  result = await self.sandbox.commands.run(
225
320
  final_command,
226
321
  cwd=cwd,
227
- timeout=timeout_ms,
322
+ timeout=timeout_ms // 1000,
228
323
  envs=envs,
229
324
  background=in_background,
325
+ user=user,
230
326
  )
231
327
  else:
232
328
  raise RuntimeError('Sandbox commands interface not available')
@@ -252,6 +348,7 @@ class SandboxService:
252
348
  timeout=timeout_ms // 1000,
253
349
  envs=envs,
254
350
  background=in_background,
351
+ user=user,
255
352
  )
256
353
  return None
257
354
 
@@ -271,14 +368,9 @@ class SandboxService:
271
368
  timeout=timeout_ms // 1000,
272
369
  background=True,
273
370
  envs=envs,
274
- on_stdout=lambda data: (
275
- self.logger.debug('Command stdout', {'data': data}),
276
- event_emitter.emit('stdout', data)
277
- )[1],
278
- on_stderr=lambda data: (
279
- self.logger.debug('Command stderr', {'data': data}),
280
- event_emitter.emit('stderr', data)
281
- )[1]
371
+ user=user,
372
+ on_stdout=lambda data: event_emitter.emit('stdout', data),
373
+ on_stderr=lambda data: event_emitter.emit('stderr', data)
282
374
  )
283
375
 
284
376
  event_emitter.set_handle(handle)
@@ -320,11 +412,11 @@ class SandboxService:
320
412
  options: Optional[IScriptOptions] = None
321
413
  ) -> Union[Any, CommandEventEmitter]:
322
414
  """
323
- Runs JavaScript code in the sandbox.
415
+ Runs JavaScript or Python code in the sandbox.
324
416
 
325
417
  Args:
326
- code: JavaScript code to execute, or file path starting with '/home/user/'
327
- options: Script execution options
418
+ code: JavaScript/Python code to execute, or file path starting with '/home/user/'
419
+ options: Script execution options (type: 'cjs', 'esm', or 'py')
328
420
 
329
421
  Returns:
330
422
  CommandResult for synchronous execution,
@@ -335,7 +427,8 @@ class SandboxService:
335
427
 
336
428
  Note:
337
429
  If code starts with '/home/user/', it will be treated as a file path.
338
- Otherwise, it will be treated as JavaScript code and written to a temporary file.
430
+ Otherwise, it will be treated as code and written to a temporary file.
431
+ For Python scripts, use type='py' and the code will be executed with python3.
339
432
  """
340
433
  if not self.sandbox or self.status != SandboxStatus.RUNNING:
341
434
  raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
@@ -349,13 +442,18 @@ class SandboxService:
349
442
  script_path = code
350
443
 
351
444
  if not code.startswith('/home/user/'):
352
- extension = 'mjs' if options['type'] == 'esm' else 'js'
445
+ if options['type'] == 'py':
446
+ extension = 'py'
447
+ elif options['type'] == 'esm':
448
+ extension = 'mjs'
449
+ else:
450
+ extension = 'js'
353
451
  working_dir = options.get('cwd', self.DEFAULT_WORKING_DIRECTORY)
354
452
  script_path = f'{working_dir}/script_{timestamp}.{extension}'
355
453
  # Write code to temporary file
356
454
  await self.sandbox.files.write(script_path, code)
357
455
 
358
- self.logger.debug('Running JavaScript code in sandbox', {
456
+ self.logger.debug('Running script in sandbox', {
359
457
  'type': options['type'],
360
458
  'scriptPath': script_path,
361
459
  'codeLength': len(code),
@@ -363,7 +461,10 @@ class SandboxService:
363
461
 
364
462
  # Choose execution command based on type
365
463
  pre_command = options.get('preCommand', '')
366
- command = f'{pre_command}node {script_path}'
464
+ if options['type'] == 'py':
465
+ command = f'{pre_command}python3 {script_path}'
466
+ else:
467
+ command = f'{pre_command}node {script_path}'
367
468
 
368
469
  # Set environment variables
369
470
  envs = options.get('envs', {})
@@ -372,10 +473,11 @@ class SandboxService:
372
473
  # Prepare command options
373
474
  cmd_options: Optional[Any] = {
374
475
  'cwd': options.get('cwd'),
375
- 'timeout': options.get('timeoutMs'),
476
+ 'timeout_ms': options.get('timeout_ms', 0),
376
477
  'inBackground': options.get('background', False),
377
478
  'nohup': options.get('nohup', False),
378
479
  'envs': envs,
480
+ 'user': options.get('user', 'user'),
379
481
  }
380
482
 
381
483
  # Execute the script
@@ -408,6 +510,86 @@ class SandboxService:
408
510
  ext = Path(file_path).suffix.lower()
409
511
  return ext in binary_extensions
410
512
 
513
+ def _encode_content(self, data: bytes, encoding: str) -> Union[str, bytes]:
514
+ """
515
+ Encode content based on specified encoding.
516
+
517
+ Args:
518
+ data: Raw bytes data
519
+ encoding: Encoding type ('utf8', 'base64', or 'binary')
520
+
521
+ Returns:
522
+ Encoded content as string or bytes
523
+ """
524
+ if encoding == 'utf8':
525
+ return data.decode('utf-8')
526
+ elif encoding == 'base64':
527
+ import base64
528
+ return base64.b64encode(data).decode('utf-8')
529
+ else: # binary
530
+ return data
531
+
532
+ async def read_file_from_sandbox(
533
+ self,
534
+ remote_path: str,
535
+ options: Optional[Dict[str, str]] = None
536
+ ) -> Union[str, bytes]:
537
+ """
538
+ Read content from a file in the sandbox.
539
+
540
+ Args:
541
+ remote_path: File path in sandbox to read from
542
+ options: Dictionary with 'encoding' key ('utf8', 'base64', or 'binary')
543
+
544
+ Returns:
545
+ File content as string or bytes depending on encoding
546
+
547
+ Raises:
548
+ RuntimeError: If sandbox is not running or file read fails
549
+ """
550
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
551
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
552
+
553
+ if options is None:
554
+ options = {}
555
+
556
+ # Determine encoding
557
+ encoding = options.get('encoding')
558
+ if not encoding:
559
+ encoding = 'binary' if self._is_binary_file(remote_path) else 'utf8'
560
+
561
+ try:
562
+ self.logger.debug('Reading file from sandbox', {
563
+ 'remotePath': remote_path,
564
+ 'encoding': encoding
565
+ })
566
+
567
+ # Always read as bytes first
568
+ file_content = await self.sandbox.files.read(remote_path, format='bytes')
569
+
570
+ # Encode based on requested format
571
+ return self._encode_content(file_content, encoding)
572
+
573
+ except Exception as error:
574
+ self.logger.error(f'Failed to read file from sandbox: {error}')
575
+ raise RuntimeError(f'Failed to read file: {error}')
576
+
577
+ async def write_file_to_sandbox(self, remote_path: str, content: Union[str, bytes]) -> None:
578
+ """
579
+ Write content directly to a file in the sandbox.
580
+
581
+ Args:
582
+ remote_path: File path in sandbox where content will be written
583
+ content: String or bytes content to write to the file
584
+
585
+ Raises:
586
+ RuntimeError: If sandbox is not running
587
+ """
588
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
589
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
590
+
591
+ return await self.sandbox.files.write(remote_path, content)
592
+
411
593
  async def copy_file_from_sandbox(self, remote_path: str, local_path: str) -> None:
412
594
  """
413
595
  Copy file from sandbox to local filesystem.
@@ -442,7 +624,7 @@ class SandboxService:
442
624
  with open(local_path, 'w', encoding='utf-8') as f:
443
625
  f.write(file_content)
444
626
 
445
- self.logger.info(f'File copied from sandbox: {remote_path} -> {local_path}')
627
+ self.logger.debug(f'File copied from sandbox: {remote_path} -> {local_path}')
446
628
 
447
629
  except Exception as error:
448
630
  self.logger.error(f'Failed to copy file: {error}')
@@ -529,6 +711,201 @@ class SandboxService:
529
711
  self.logger.error('Failed to list files', str(error))
530
712
  raise RuntimeError(f'Failed to list files: {error}')
531
713
 
714
+ def _validate_safe_path(self, path: str) -> bool:
715
+ """
716
+ Validate if a path is safe for synchronization.
717
+
718
+ Args:
719
+ path: Path to validate
720
+
721
+ Returns:
722
+ True if path is safe, False otherwise
723
+ """
724
+ # Normalize path
725
+ normalized_path = path.replace('//', '/').rstrip('/')
726
+
727
+ # Check if it's an absolute path under /home/user
728
+ if normalized_path.startswith('/'):
729
+ # Absolute path must be under /home/user/ and not /home/user itself
730
+ return (
731
+ normalized_path.startswith('/home/user/') and
732
+ normalized_path != '/home/user'
733
+ )
734
+
735
+ # Relative path checks
736
+ # Don't allow .. to access parent directories
737
+ if '..' in normalized_path:
738
+ return False
739
+
740
+ # Don't allow syncing current directory itself (. or empty string)
741
+ if normalized_path in ('.', '', './'):
742
+ return False
743
+
744
+ # Relative path must be a subdirectory (starts with ./ or doesn't start with /)
745
+ return normalized_path.startswith('./') or not normalized_path.startswith('/')
746
+
747
+ async def _is_directory(self, remote_path: str) -> bool:
748
+ """
749
+ Check if a remote path is a directory.
750
+
751
+ Args:
752
+ remote_path: Remote path to check
753
+
754
+ Returns:
755
+ True if path is a directory, False otherwise
756
+ """
757
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
758
+ return False
759
+
760
+ try:
761
+ result = await self.run_command(f'test -d "{remote_path}" && echo "true" || echo "false"')
762
+ stdout = getattr(result, 'stdout', '').strip() if result else ''
763
+ return stdout == 'true'
764
+ except Exception as error:
765
+ self.logger.debug('Error checking if path is directory', {
766
+ 'remotePath': remote_path,
767
+ 'error': str(error)
768
+ })
769
+ return False
770
+
771
+ async def _sync_directory_recursive(
772
+ self,
773
+ remote_path: str,
774
+ local_path: str
775
+ ) -> int:
776
+ """
777
+ Recursively sync a directory from sandbox to local filesystem.
778
+
779
+ Args:
780
+ remote_path: Remote directory path
781
+ local_path: Local directory path
782
+
783
+ Returns:
784
+ Number of files synced
785
+ """
786
+ synced_count = 0
787
+
788
+ try:
789
+ # Get all items in the directory
790
+ items = await self.list_files(remote_path)
791
+
792
+ if len(items) == 0:
793
+ self.logger.debug('No items found in directory', {'remotePath': remote_path})
794
+ return 0
795
+
796
+ # Ensure local directory exists
797
+ os.makedirs(local_path, exist_ok=True)
798
+
799
+ # Process each item
800
+ for item in items:
801
+ remote_item_path = f"{remote_path}/{item}"
802
+ local_item_path = os.path.join(local_path, item)
803
+
804
+ # Check if it's a directory
805
+ is_dir = await self._is_directory(remote_item_path)
806
+
807
+ if is_dir:
808
+ self.logger.debug('Syncing subdirectory', {
809
+ 'from': remote_item_path,
810
+ 'to': local_item_path
811
+ })
812
+
813
+ # Recursively sync subdirectory
814
+ sub_synced_count = await self._sync_directory_recursive(
815
+ remote_item_path,
816
+ local_item_path
817
+ )
818
+ synced_count += sub_synced_count
819
+ else:
820
+ self.logger.debug('Syncing file', {
821
+ 'from': remote_item_path,
822
+ 'to': local_item_path
823
+ })
824
+
825
+ # Sync file
826
+ await self.copy_file_from_sandbox(remote_item_path, local_item_path)
827
+ synced_count += 1
828
+
829
+ return synced_count
830
+
831
+ except Exception as error:
832
+ self.logger.error('Error in recursive directory sync', {
833
+ 'remotePath': remote_path,
834
+ 'localPath': local_path,
835
+ 'error': str(error)
836
+ })
837
+ raise
838
+
839
+ async def sync_downloads_directory(
840
+ self,
841
+ dist: str = '/tmp/grasp/downloads',
842
+ src: str = './downloads'
843
+ ) -> str:
844
+ """
845
+ Synchronize sandbox downloads directory to local filesystem (recursive sync of all subdirectories).
846
+
847
+ Args:
848
+ dist: Local target directory path, defaults to '/tmp/grasp/downloads'
849
+ src: Remote source directory path, defaults to './downloads'
850
+
851
+ Returns:
852
+ Local sync directory path
853
+
854
+ Raises:
855
+ RuntimeError: If sandbox is not running or sync fails
856
+ ValueError: If path is unsafe
857
+ """
858
+ # Validate path safety
859
+ if not self._validate_safe_path(src):
860
+ error_msg = f"Unsafe path detected: {src}. Only subdirectories under /home/user are allowed."
861
+ self.logger.error('Path validation failed', {
862
+ 'path': src,
863
+ 'error': error_msg
864
+ })
865
+ raise ValueError(error_msg)
866
+
867
+ remote_path = src
868
+ local_path = dist
869
+
870
+ try:
871
+ self.logger.debug('🗂️ Starting recursive directory sync', {
872
+ 'remotePath': remote_path,
873
+ 'localPath': local_path
874
+ })
875
+
876
+ # Check if remote directory exists
877
+ dir_exists = await self._is_directory(remote_path)
878
+ if not dir_exists:
879
+ # Try to handle as file
880
+ parent_dir = os.path.dirname(remote_path)
881
+ file_name = os.path.basename(remote_path)
882
+
883
+ files = await self.list_files(parent_dir)
884
+
885
+ if file_name not in files:
886
+ self.logger.debug('Remote path does not exist', {'remotePath': remote_path})
887
+ return ''
888
+
889
+ # Recursively sync directory
890
+ synced_count = await self._sync_directory_recursive(
891
+ remote_path,
892
+ local_path
893
+ )
894
+
895
+ if synced_count == 0:
896
+ self.logger.debug('No files found to sync')
897
+ return ''
898
+
899
+ self.logger.info(
900
+ f'🎉 Successfully synced {synced_count} files recursively from {remote_path}'
901
+ )
902
+
903
+ return local_path
904
+
905
+ except Exception as error:
906
+ self.logger.error('Error syncing downloads directory:', str(error))
907
+ raise
908
+
532
909
  def get_status(self) -> SandboxStatus:
533
910
  """Gets the current sandbox status."""
534
911
  return self.status
@@ -572,7 +949,8 @@ class SandboxService:
572
949
  'sandboxId': getattr(self.sandbox, 'sandbox_id', 'unknown'),
573
950
  })
574
951
 
575
- await self.sandbox.kill()
952
+ if await self.sandbox.is_running():
953
+ await self.sandbox.kill()
576
954
  self.sandbox = None
577
955
  self.status = SandboxStatus.STOPPED
578
956