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

Files changed (38) hide show
  1. examples/example_async_context.py +122 -0
  2. examples/example_binary_file_support.py +127 -0
  3. examples/example_grasp_usage.py +82 -0
  4. examples/example_readfile_usage.py +136 -0
  5. examples/grasp_terminal.py +110 -0
  6. examples/grasp_usage.py +111 -0
  7. examples/test_async_context.py +64 -0
  8. examples/test_get_replay_screenshots.py +90 -0
  9. examples/test_grasp_classes.py +80 -0
  10. examples/test_python_script.py +160 -0
  11. examples/test_removed_methods.py +80 -0
  12. examples/test_shutdown_deprecation.py +62 -0
  13. examples/test_terminal_updates.py +196 -0
  14. grasp_sdk/__init__.py +131 -239
  15. grasp_sdk/grasp/__init__.py +26 -0
  16. grasp_sdk/grasp/browser.py +69 -0
  17. grasp_sdk/grasp/index.py +122 -0
  18. grasp_sdk/grasp/server.py +250 -0
  19. grasp_sdk/grasp/session.py +108 -0
  20. grasp_sdk/grasp/terminal.py +32 -0
  21. grasp_sdk/grasp/utils.py +90 -0
  22. grasp_sdk/models/__init__.py +1 -1
  23. grasp_sdk/services/__init__.py +8 -1
  24. grasp_sdk/services/browser.py +92 -63
  25. grasp_sdk/services/filesystem.py +94 -0
  26. grasp_sdk/services/sandbox.py +391 -20
  27. grasp_sdk/services/terminal.py +177 -0
  28. grasp_sdk/utils/auth.py +6 -8
  29. {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/METADATA +2 -3
  30. grasp_sdk-0.2.0a1.dist-info/RECORD +36 -0
  31. {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/top_level.txt +1 -0
  32. grasp_sdk/sandbox/bootstrap-chrome-stable.mjs +0 -69
  33. grasp_sdk/sandbox/chrome-stable.mjs +0 -424
  34. grasp_sdk/sandbox/chromium.mjs +0 -395
  35. grasp_sdk/sandbox/http-proxy.mjs +0 -324
  36. grasp_sdk-0.1.8.dist-info/RECORD +0 -18
  37. {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/WHEEL +0 -0
  38. {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/entry_points.txt +0 -0
@@ -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
@@ -48,6 +49,11 @@ class CommandEventEmitter:
48
49
  self._callbacks[event] = []
49
50
  self._callbacks[event].append(callback)
50
51
 
52
+ def off(self, event: str, callback: Callable) -> None:
53
+ """Remove event listener."""
54
+ if event in self._callbacks and callback in self._callbacks[event]:
55
+ self._callbacks[event].remove(callback)
56
+
51
57
  def emit(self, event: Any, *args) -> None:
52
58
  """Emit event to all registered listeners."""
53
59
  # print(f'🚀 emit ${event}')
@@ -60,10 +66,35 @@ class CommandEventEmitter:
60
66
  pass
61
67
 
62
68
  async def wait(self) -> Any:
63
- """Wait for command to complete."""
69
+ """Wait for command to complete.
70
+
71
+ Returns:
72
+ Command result or exception result if command fails
73
+ """
64
74
  while not self.handle:
65
75
  await asyncio.sleep(0.1)
66
- return await self.handle.wait()
76
+ try:
77
+ return await self.handle.wait()
78
+ except Exception as ex:
79
+ # 对于 CommandExitException,提取有用信息并返回结构化结果
80
+ if hasattr(ex, 'exit_code') or 'CommandExitException' in str(type(ex)):
81
+ return {
82
+ 'error': str(ex),
83
+ 'exit_code': getattr(ex, 'exit_code', -1),
84
+ 'stdout': getattr(ex, 'stdout', ''),
85
+ 'stderr': getattr(ex, 'stderr', str(ex))
86
+ }
87
+ # Return exception result if available, similar to TypeScript version
88
+ if hasattr(ex, 'result'):
89
+ # 尝试获取异常的结果属性,如果不存在则返回None
90
+ return getattr(ex, 'result', None)
91
+ # 对于其他异常,返回结构化错误信息
92
+ return {
93
+ 'error': str(ex),
94
+ 'exit_code': -1,
95
+ 'stdout': '',
96
+ 'stderr': str(ex)
97
+ }
67
98
 
68
99
  async def kill(self) -> None:
69
100
  """Kill the running command."""
@@ -123,6 +154,56 @@ class SandboxService:
123
154
  """Get timeout value."""
124
155
  return self.config['timeout']
125
156
 
157
+ async def connect_sandbox(self, sandbox_id: str) -> None:
158
+ """
159
+ Connects to an existing sandbox.
160
+
161
+ Args:
162
+ sandbox_id: ID of the sandbox to connect to
163
+
164
+ Raises:
165
+ RuntimeError: If sandbox connection fails
166
+ """
167
+ try:
168
+ self.status = SandboxStatus.CREATING
169
+
170
+ self.logger.info(f'Connection Grasp sandbox: {sandbox_id}')
171
+
172
+ # Verify authentication
173
+ res = await verify({'key': self.config['key']})
174
+ if not res['success']:
175
+ raise RuntimeError('Authorization failed.')
176
+
177
+ api_key = res['data']['token']
178
+
179
+ # Connect to existing sandbox
180
+ self.sandbox = await AsyncSandbox.connect(
181
+ sandbox_id=sandbox_id,
182
+ api_key=api_key
183
+ )
184
+
185
+ self.status = SandboxStatus.RUNNING
186
+
187
+ # Read existing config from sandbox
188
+ config_content = await self.sandbox.files.read('/home/user/.grasp-config.json')
189
+ timeout = self.config['timeout']
190
+ self.config = json.loads(config_content)
191
+
192
+ # Update timeout if different
193
+ if timeout > 0 and timeout != self.config['timeout']:
194
+ # Note: e2b Python SDK may not have setTimeout method
195
+ # This is equivalent to the TypeScript version's setTimeout call
196
+ self.sandbox.set_timeout(timeout)
197
+
198
+ self.logger.info('Grasp sandbox connected', {
199
+ 'sandboxId': getattr(self.sandbox, 'sandbox_id', 'unknown'),
200
+ })
201
+
202
+ except Exception as error:
203
+ self.status = SandboxStatus.ERROR
204
+ self.logger.error('Failed to connect Grasp sandbox', str(error))
205
+ raise RuntimeError(f'Failed to connect sandbox: {error}')
206
+
126
207
  async def create_sandbox(self, template_id: str, envs: Optional[Dict[str, str]] = None) -> None:
127
208
  """
128
209
  Creates and starts a new sandbox.
@@ -135,7 +216,7 @@ class SandboxService:
135
216
  self.status = SandboxStatus.CREATING
136
217
 
137
218
  # Verify authentication
138
- res = await verify(self.config)
219
+ res = await verify({'key': self.config['key']})
139
220
  if not res['success']:
140
221
  raise RuntimeError('Authorization failed.')
141
222
 
@@ -152,6 +233,17 @@ class SandboxService:
152
233
  timeout=self.config['timeout'] // 1000, # Convert ms to seconds
153
234
  envs=envs,
154
235
  )
236
+
237
+ # Write grasp config file
238
+ import json
239
+ config_data = {
240
+ 'id': getattr(self.sandbox, 'sandbox_id', 'unknown'),
241
+ **self.config
242
+ }
243
+ await self.sandbox.files.write(
244
+ '/home/user/.grasp-config.json',
245
+ json.dumps(config_data)
246
+ )
155
247
 
156
248
  self.status = SandboxStatus.RUNNING
157
249
  self.logger.info('Grasp sandbox created successfully', {
@@ -271,14 +363,8 @@ class SandboxService:
271
363
  timeout=timeout_ms // 1000,
272
364
  background=True,
273
365
  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]
366
+ on_stdout=lambda data: event_emitter.emit('stdout', data),
367
+ on_stderr=lambda data: event_emitter.emit('stderr', data)
282
368
  )
283
369
 
284
370
  event_emitter.set_handle(handle)
@@ -320,11 +406,11 @@ class SandboxService:
320
406
  options: Optional[IScriptOptions] = None
321
407
  ) -> Union[Any, CommandEventEmitter]:
322
408
  """
323
- Runs JavaScript code in the sandbox.
409
+ Runs JavaScript or Python code in the sandbox.
324
410
 
325
411
  Args:
326
- code: JavaScript code to execute, or file path starting with '/home/user/'
327
- options: Script execution options
412
+ code: JavaScript/Python code to execute, or file path starting with '/home/user/'
413
+ options: Script execution options (type: 'cjs', 'esm', or 'py')
328
414
 
329
415
  Returns:
330
416
  CommandResult for synchronous execution,
@@ -335,7 +421,8 @@ class SandboxService:
335
421
 
336
422
  Note:
337
423
  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.
424
+ Otherwise, it will be treated as code and written to a temporary file.
425
+ For Python scripts, use type='py' and the code will be executed with python3.
339
426
  """
340
427
  if not self.sandbox or self.status != SandboxStatus.RUNNING:
341
428
  raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
@@ -349,13 +436,18 @@ class SandboxService:
349
436
  script_path = code
350
437
 
351
438
  if not code.startswith('/home/user/'):
352
- extension = 'mjs' if options['type'] == 'esm' else 'js'
439
+ if options['type'] == 'py':
440
+ extension = 'py'
441
+ elif options['type'] == 'esm':
442
+ extension = 'mjs'
443
+ else:
444
+ extension = 'js'
353
445
  working_dir = options.get('cwd', self.DEFAULT_WORKING_DIRECTORY)
354
446
  script_path = f'{working_dir}/script_{timestamp}.{extension}'
355
447
  # Write code to temporary file
356
448
  await self.sandbox.files.write(script_path, code)
357
449
 
358
- self.logger.debug('Running JavaScript code in sandbox', {
450
+ self.logger.debug('Running script in sandbox', {
359
451
  'type': options['type'],
360
452
  'scriptPath': script_path,
361
453
  'codeLength': len(code),
@@ -363,7 +455,10 @@ class SandboxService:
363
455
 
364
456
  # Choose execution command based on type
365
457
  pre_command = options.get('preCommand', '')
366
- command = f'{pre_command}node {script_path}'
458
+ if options['type'] == 'py':
459
+ command = f'{pre_command}python3 {script_path}'
460
+ else:
461
+ command = f'{pre_command}node {script_path}'
367
462
 
368
463
  # Set environment variables
369
464
  envs = options.get('envs', {})
@@ -408,6 +503,86 @@ class SandboxService:
408
503
  ext = Path(file_path).suffix.lower()
409
504
  return ext in binary_extensions
410
505
 
506
+ def _encode_content(self, data: bytes, encoding: str) -> Union[str, bytes]:
507
+ """
508
+ Encode content based on specified encoding.
509
+
510
+ Args:
511
+ data: Raw bytes data
512
+ encoding: Encoding type ('utf8', 'base64', or 'binary')
513
+
514
+ Returns:
515
+ Encoded content as string or bytes
516
+ """
517
+ if encoding == 'utf8':
518
+ return data.decode('utf-8')
519
+ elif encoding == 'base64':
520
+ import base64
521
+ return base64.b64encode(data).decode('utf-8')
522
+ else: # binary
523
+ return data
524
+
525
+ async def read_file_from_sandbox(
526
+ self,
527
+ remote_path: str,
528
+ options: Optional[Dict[str, str]] = None
529
+ ) -> Union[str, bytes]:
530
+ """
531
+ Read content from a file in the sandbox.
532
+
533
+ Args:
534
+ remote_path: File path in sandbox to read from
535
+ options: Dictionary with 'encoding' key ('utf8', 'base64', or 'binary')
536
+
537
+ Returns:
538
+ File content as string or bytes depending on encoding
539
+
540
+ Raises:
541
+ RuntimeError: If sandbox is not running or file read fails
542
+ """
543
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
544
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
545
+
546
+ if options is None:
547
+ options = {}
548
+
549
+ # Determine encoding
550
+ encoding = options.get('encoding')
551
+ if not encoding:
552
+ encoding = 'binary' if self._is_binary_file(remote_path) else 'utf8'
553
+
554
+ try:
555
+ self.logger.debug('Reading file from sandbox', {
556
+ 'remotePath': remote_path,
557
+ 'encoding': encoding
558
+ })
559
+
560
+ # Always read as bytes first
561
+ file_content = await self.sandbox.files.read(remote_path, format='bytes')
562
+
563
+ # Encode based on requested format
564
+ return self._encode_content(file_content, encoding)
565
+
566
+ except Exception as error:
567
+ self.logger.error(f'Failed to read file from sandbox: {error}')
568
+ raise RuntimeError(f'Failed to read file: {error}')
569
+
570
+ async def write_file_to_sandbox(self, remote_path: str, content: Union[str, bytes]) -> None:
571
+ """
572
+ Write content directly to a file in the sandbox.
573
+
574
+ Args:
575
+ remote_path: File path in sandbox where content will be written
576
+ content: String or bytes content to write to the file
577
+
578
+ Raises:
579
+ RuntimeError: If sandbox is not running
580
+ """
581
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
582
+ raise RuntimeError('Sandbox is not running. Call create_sandbox() first.')
583
+
584
+ return await self.sandbox.files.write(remote_path, content)
585
+
411
586
  async def copy_file_from_sandbox(self, remote_path: str, local_path: str) -> None:
412
587
  """
413
588
  Copy file from sandbox to local filesystem.
@@ -442,7 +617,7 @@ class SandboxService:
442
617
  with open(local_path, 'w', encoding='utf-8') as f:
443
618
  f.write(file_content)
444
619
 
445
- self.logger.info(f'File copied from sandbox: {remote_path} -> {local_path}')
620
+ self.logger.debug(f'File copied from sandbox: {remote_path} -> {local_path}')
446
621
 
447
622
  except Exception as error:
448
623
  self.logger.error(f'Failed to copy file: {error}')
@@ -529,6 +704,201 @@ class SandboxService:
529
704
  self.logger.error('Failed to list files', str(error))
530
705
  raise RuntimeError(f'Failed to list files: {error}')
531
706
 
707
+ def _validate_safe_path(self, path: str) -> bool:
708
+ """
709
+ Validate if a path is safe for synchronization.
710
+
711
+ Args:
712
+ path: Path to validate
713
+
714
+ Returns:
715
+ True if path is safe, False otherwise
716
+ """
717
+ # Normalize path
718
+ normalized_path = path.replace('//', '/').rstrip('/')
719
+
720
+ # Check if it's an absolute path under /home/user
721
+ if normalized_path.startswith('/'):
722
+ # Absolute path must be under /home/user/ and not /home/user itself
723
+ return (
724
+ normalized_path.startswith('/home/user/') and
725
+ normalized_path != '/home/user'
726
+ )
727
+
728
+ # Relative path checks
729
+ # Don't allow .. to access parent directories
730
+ if '..' in normalized_path:
731
+ return False
732
+
733
+ # Don't allow syncing current directory itself (. or empty string)
734
+ if normalized_path in ('.', '', './'):
735
+ return False
736
+
737
+ # Relative path must be a subdirectory (starts with ./ or doesn't start with /)
738
+ return normalized_path.startswith('./') or not normalized_path.startswith('/')
739
+
740
+ async def _is_directory(self, remote_path: str) -> bool:
741
+ """
742
+ Check if a remote path is a directory.
743
+
744
+ Args:
745
+ remote_path: Remote path to check
746
+
747
+ Returns:
748
+ True if path is a directory, False otherwise
749
+ """
750
+ if not self.sandbox or self.status != SandboxStatus.RUNNING:
751
+ return False
752
+
753
+ try:
754
+ result = await self.run_command(f'test -d "{remote_path}" && echo "true" || echo "false"')
755
+ stdout = getattr(result, 'stdout', '').strip() if result else ''
756
+ return stdout == 'true'
757
+ except Exception as error:
758
+ self.logger.debug('Error checking if path is directory', {
759
+ 'remotePath': remote_path,
760
+ 'error': str(error)
761
+ })
762
+ return False
763
+
764
+ async def _sync_directory_recursive(
765
+ self,
766
+ remote_path: str,
767
+ local_path: str
768
+ ) -> int:
769
+ """
770
+ Recursively sync a directory from sandbox to local filesystem.
771
+
772
+ Args:
773
+ remote_path: Remote directory path
774
+ local_path: Local directory path
775
+
776
+ Returns:
777
+ Number of files synced
778
+ """
779
+ synced_count = 0
780
+
781
+ try:
782
+ # Get all items in the directory
783
+ items = await self.list_files(remote_path)
784
+
785
+ if len(items) == 0:
786
+ self.logger.debug('No items found in directory', {'remotePath': remote_path})
787
+ return 0
788
+
789
+ # Ensure local directory exists
790
+ os.makedirs(local_path, exist_ok=True)
791
+
792
+ # Process each item
793
+ for item in items:
794
+ remote_item_path = f"{remote_path}/{item}"
795
+ local_item_path = os.path.join(local_path, item)
796
+
797
+ # Check if it's a directory
798
+ is_dir = await self._is_directory(remote_item_path)
799
+
800
+ if is_dir:
801
+ self.logger.debug('Syncing subdirectory', {
802
+ 'from': remote_item_path,
803
+ 'to': local_item_path
804
+ })
805
+
806
+ # Recursively sync subdirectory
807
+ sub_synced_count = await self._sync_directory_recursive(
808
+ remote_item_path,
809
+ local_item_path
810
+ )
811
+ synced_count += sub_synced_count
812
+ else:
813
+ self.logger.debug('Syncing file', {
814
+ 'from': remote_item_path,
815
+ 'to': local_item_path
816
+ })
817
+
818
+ # Sync file
819
+ await self.copy_file_from_sandbox(remote_item_path, local_item_path)
820
+ synced_count += 1
821
+
822
+ return synced_count
823
+
824
+ except Exception as error:
825
+ self.logger.error('Error in recursive directory sync', {
826
+ 'remotePath': remote_path,
827
+ 'localPath': local_path,
828
+ 'error': str(error)
829
+ })
830
+ raise
831
+
832
+ async def sync_downloads_directory(
833
+ self,
834
+ dist: str = '/tmp/grasp/downloads',
835
+ src: str = './downloads'
836
+ ) -> str:
837
+ """
838
+ Synchronize sandbox downloads directory to local filesystem (recursive sync of all subdirectories).
839
+
840
+ Args:
841
+ dist: Local target directory path, defaults to '/tmp/grasp/downloads'
842
+ src: Remote source directory path, defaults to './downloads'
843
+
844
+ Returns:
845
+ Local sync directory path
846
+
847
+ Raises:
848
+ RuntimeError: If sandbox is not running or sync fails
849
+ ValueError: If path is unsafe
850
+ """
851
+ # Validate path safety
852
+ if not self._validate_safe_path(src):
853
+ error_msg = f"Unsafe path detected: {src}. Only subdirectories under /home/user are allowed."
854
+ self.logger.error('Path validation failed', {
855
+ 'path': src,
856
+ 'error': error_msg
857
+ })
858
+ raise ValueError(error_msg)
859
+
860
+ remote_path = src
861
+ local_path = os.path.join(dist, str(self.id), str(int(time.time() * 1000)))
862
+
863
+ try:
864
+ self.logger.debug('🗂️ Starting recursive directory sync', {
865
+ 'remotePath': remote_path,
866
+ 'localPath': local_path
867
+ })
868
+
869
+ # Check if remote directory exists
870
+ dir_exists = await self._is_directory(remote_path)
871
+ if not dir_exists:
872
+ # Try to handle as file
873
+ parent_dir = os.path.dirname(remote_path)
874
+ file_name = os.path.basename(remote_path)
875
+
876
+ files = await self.list_files(parent_dir)
877
+
878
+ if file_name not in files:
879
+ self.logger.debug('Remote path does not exist', {'remotePath': remote_path})
880
+ return ''
881
+
882
+ # Recursively sync directory
883
+ synced_count = await self._sync_directory_recursive(
884
+ remote_path,
885
+ local_path
886
+ )
887
+
888
+ if synced_count == 0:
889
+ self.logger.debug('No files found to sync')
890
+ return ''
891
+
892
+ self.logger.info(
893
+ f'🎉 Successfully synced {synced_count} files recursively from {remote_path}'
894
+ )
895
+
896
+ return local_path
897
+
898
+ except Exception as error:
899
+ self.logger.error('Error syncing downloads directory:', str(error))
900
+ raise
901
+
532
902
  def get_status(self) -> SandboxStatus:
533
903
  """Gets the current sandbox status."""
534
904
  return self.status
@@ -572,7 +942,8 @@ class SandboxService:
572
942
  'sandboxId': getattr(self.sandbox, 'sandbox_id', 'unknown'),
573
943
  })
574
944
 
575
- await self.sandbox.kill()
945
+ if await self.sandbox.is_running():
946
+ await self.sandbox.kill()
576
947
  self.sandbox = None
577
948
  self.status = SandboxStatus.STOPPED
578
949
 
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Terminal service for managing terminal command operations.
4
+
5
+ This module provides Python implementation of the terminal service,
6
+ equivalent to the TypeScript version in src/services/terminal.service.ts
7
+ """
8
+
9
+ import asyncio
10
+ import io
11
+ from typing import Optional, Any, Dict, Callable
12
+ from ..models import ICommandOptions
13
+ from ..utils.logger import get_logger, Logger
14
+ from .sandbox import SandboxService, CommandEventEmitter
15
+ from .browser import CDPConnection
16
+
17
+
18
+ class StreamableCommandResult:
19
+ """
20
+ Streamable command result with event-based output streaming and json result.
21
+ """
22
+
23
+ def __init__(self, emitter: CommandEventEmitter, task):
24
+ self.emitter = emitter
25
+ self._task = task
26
+ # For backward compatibility, provide stdout/stderr as StringIO
27
+ self.stdout = io.StringIO()
28
+ self.stderr = io.StringIO()
29
+
30
+ # Set up internal event handlers to populate StringIO streams
31
+ def on_stdout(data: str) -> None:
32
+ self.stdout.write(data)
33
+ self.stdout.flush()
34
+
35
+ def on_stderr(data: str) -> None:
36
+ self.stderr.write(data)
37
+ self.stderr.flush()
38
+
39
+ # Register internal handlers
40
+ self.emitter.on('stdout', on_stdout)
41
+ self.emitter.on('stderr', on_stderr)
42
+
43
+ def on(self, event: str, callback: Callable) -> None:
44
+ """Register event listener for stdout, stderr, or exit events.
45
+
46
+ Args:
47
+ event: Event name ('stdout', 'stderr', 'exit')
48
+ callback: Callback function to handle the event
49
+ """
50
+ self.emitter.on(event, callback)
51
+
52
+ def off(self, event: str, callback: Callable) -> None:
53
+ """Remove event listener.
54
+
55
+ Args:
56
+ event: Event name ('stdout', 'stderr', 'exit')
57
+ callback: Callback function to remove
58
+ """
59
+ self.emitter.off(event, callback)
60
+
61
+ async def end(self) -> None:
62
+ """Wait until the command finishes."""
63
+ try:
64
+ await self._task
65
+ except Exception as e:
66
+ # Log error but don't raise to avoid breaking cleanup
67
+ pass
68
+
69
+ async def kill(self) -> None:
70
+ """Kill the running command and cleanup resources."""
71
+ try:
72
+ await self.emitter.kill()
73
+ # Close the streams
74
+ self.stdout.close()
75
+ self.stderr.close()
76
+ except Exception as e:
77
+ # Log error but don't raise to avoid breaking cleanup
78
+ pass
79
+
80
+ async def json(self) -> Any:
81
+ """Get the final command result as JSON."""
82
+ try:
83
+ return await self._task
84
+ except Exception as e:
85
+ # 如果任务中发生异常,返回异常信息作为结果
86
+ return {
87
+ 'error': str(e),
88
+ 'exit_code': getattr(e, 'exit_code', -1),
89
+ 'stdout': '',
90
+ 'stderr': str(e)
91
+ }
92
+
93
+
94
+ class Commands:
95
+ """
96
+ Command execution operations for terminal service.
97
+ """
98
+
99
+ def __init__(self, sandbox: SandboxService, connection: CDPConnection):
100
+ self.sandbox = sandbox
101
+ self.connection = connection
102
+ self.logger = self._get_default_logger()
103
+
104
+ def _get_default_logger(self) -> Logger:
105
+ """Gets or creates a default logger instance.
106
+
107
+ Returns:
108
+ Logger instance
109
+ """
110
+ try:
111
+ return get_logger().child('TerminalService')
112
+ except Exception:
113
+ # If logger is not initialized, create a default one
114
+ from ..utils.logger import Logger
115
+ default_logger = Logger({
116
+ 'level': 'debug' if self.sandbox.is_debug else 'info',
117
+ 'console': True
118
+ })
119
+ return default_logger.child('TerminalService')
120
+
121
+ async def run_command(
122
+ self, command: str, options: Optional[ICommandOptions] = None
123
+ ) -> StreamableCommandResult:
124
+ """Run command in sandbox with streaming output.
125
+
126
+ Args:
127
+ command: Command to execute
128
+ options: Command execution options
129
+
130
+ Returns:
131
+ StreamableCommandResult with event-based streaming
132
+ """
133
+ ws = None
134
+ try:
135
+ if options is None:
136
+ options = {}
137
+
138
+ # Force background execution for streaming
139
+ bg_options: ICommandOptions = {**options, 'inBackground': True, 'nohup': False}
140
+
141
+ emitter: Optional[CommandEventEmitter] = await self.sandbox.run_command(
142
+ command, bg_options
143
+ )
144
+
145
+ if emitter is None:
146
+ raise RuntimeError(f"Failed to start command: {command}")
147
+
148
+ # Create task for final result
149
+ async def get_result():
150
+ result = await emitter.wait()
151
+ return result
152
+
153
+ task = asyncio.create_task(get_result())
154
+
155
+ return StreamableCommandResult(emitter, task)
156
+ except Exception as error:
157
+ self.logger.error('Run command failed', {'error': str(error)})
158
+ raise error
159
+
160
+
161
+ class TerminalService(Commands):
162
+ """
163
+ Terminal service for managing terminal command operations.
164
+ """
165
+
166
+ def __init__(self, sandbox: SandboxService, connection: CDPConnection):
167
+ """Initialize terminal service.
168
+
169
+ Args:
170
+ sandbox: The sandbox service instance
171
+ connection: The CDP connection for browser integration
172
+ """
173
+ super().__init__(sandbox, connection)
174
+
175
+ def close(self) -> None:
176
+ """Close terminal service and cleanup resources."""
177
+ self.logger.info('Terminal service closed')