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.
- examples/example_async_context.py +122 -0
- examples/example_binary_file_support.py +127 -0
- examples/example_grasp_usage.py +82 -0
- examples/example_readfile_usage.py +136 -0
- examples/grasp_terminal.py +110 -0
- examples/grasp_usage.py +111 -0
- examples/test_async_context.py +64 -0
- examples/test_get_replay_screenshots.py +90 -0
- examples/test_grasp_classes.py +80 -0
- examples/test_python_script.py +160 -0
- examples/test_removed_methods.py +80 -0
- examples/test_shutdown_deprecation.py +62 -0
- examples/test_terminal_updates.py +196 -0
- grasp_sdk/__init__.py +131 -239
- grasp_sdk/grasp/__init__.py +26 -0
- grasp_sdk/grasp/browser.py +69 -0
- grasp_sdk/grasp/index.py +122 -0
- grasp_sdk/grasp/server.py +250 -0
- grasp_sdk/grasp/session.py +108 -0
- grasp_sdk/grasp/terminal.py +32 -0
- grasp_sdk/grasp/utils.py +90 -0
- grasp_sdk/models/__init__.py +1 -1
- grasp_sdk/services/__init__.py +8 -1
- grasp_sdk/services/browser.py +92 -63
- grasp_sdk/services/filesystem.py +94 -0
- grasp_sdk/services/sandbox.py +391 -20
- grasp_sdk/services/terminal.py +177 -0
- grasp_sdk/utils/auth.py +6 -8
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/METADATA +2 -3
- grasp_sdk-0.2.0a1.dist-info/RECORD +36 -0
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/top_level.txt +1 -0
- grasp_sdk/sandbox/bootstrap-chrome-stable.mjs +0 -69
- grasp_sdk/sandbox/chrome-stable.mjs +0 -424
- grasp_sdk/sandbox/chromium.mjs +0 -395
- grasp_sdk/sandbox/http-proxy.mjs +0 -324
- grasp_sdk-0.1.8.dist-info/RECORD +0 -18
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/WHEEL +0 -0
- {grasp_sdk-0.1.8.dist-info → grasp_sdk-0.2.0a1.dist-info}/entry_points.txt +0 -0
grasp_sdk/services/sandbox.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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')
|