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.
- 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 +111 -0
- examples/grasp_usage.py +111 -0
- examples/test_async_context.py +64 -0
- examples/test_grasp_classes.py +79 -0
- examples/test_python_script.py +160 -0
- examples/test_removed_methods.py +80 -0
- examples/test_terminal_updates.py +196 -0
- grasp_sdk/__init__.py +129 -239
- grasp_sdk/grasp/__init__.py +24 -0
- grasp_sdk/grasp/browser.py +162 -0
- grasp_sdk/grasp/index.py +127 -0
- grasp_sdk/grasp/server.py +250 -0
- grasp_sdk/grasp/session.py +146 -0
- grasp_sdk/grasp/utils.py +90 -0
- grasp_sdk/models/__init__.py +11 -6
- grasp_sdk/services/__init__.py +8 -1
- grasp_sdk/services/browser.py +68 -28
- grasp_sdk/services/filesystem.py +123 -0
- grasp_sdk/services/sandbox.py +404 -26
- grasp_sdk/services/terminal.py +182 -0
- grasp_sdk/utils/__init__.py +1 -2
- grasp_sdk/utils/auth.py +6 -8
- grasp_sdk/utils/config.py +2 -32
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/METADATA +2 -3
- grasp_sdk-0.2.0b1.dist-info/RECORD +33 -0
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/top_level.txt +1 -0
- grasp_sdk-0.1.9.dist-info/RECORD +0 -14
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.dist-info}/WHEEL +0 -0
- {grasp_sdk-0.1.9.dist-info → grasp_sdk-0.2.0b1.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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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('
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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.
|
|
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.
|
|
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
|
|