ms-enclave 0.0.3__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.
Files changed (40) hide show
  1. ms_enclave/__init__.py +3 -0
  2. ms_enclave/cli/__init__.py +1 -0
  3. ms_enclave/cli/base.py +20 -0
  4. ms_enclave/cli/cli.py +27 -0
  5. ms_enclave/cli/start_server.py +84 -0
  6. ms_enclave/sandbox/__init__.py +27 -0
  7. ms_enclave/sandbox/boxes/__init__.py +16 -0
  8. ms_enclave/sandbox/boxes/base.py +274 -0
  9. ms_enclave/sandbox/boxes/docker_notebook.py +224 -0
  10. ms_enclave/sandbox/boxes/docker_sandbox.py +317 -0
  11. ms_enclave/sandbox/manager/__init__.py +11 -0
  12. ms_enclave/sandbox/manager/base.py +155 -0
  13. ms_enclave/sandbox/manager/http_manager.py +405 -0
  14. ms_enclave/sandbox/manager/local_manager.py +295 -0
  15. ms_enclave/sandbox/model/__init__.py +21 -0
  16. ms_enclave/sandbox/model/base.py +75 -0
  17. ms_enclave/sandbox/model/config.py +97 -0
  18. ms_enclave/sandbox/model/requests.py +57 -0
  19. ms_enclave/sandbox/model/responses.py +57 -0
  20. ms_enclave/sandbox/server/__init__.py +0 -0
  21. ms_enclave/sandbox/server/server.py +195 -0
  22. ms_enclave/sandbox/tools/__init__.py +4 -0
  23. ms_enclave/sandbox/tools/base.py +119 -0
  24. ms_enclave/sandbox/tools/sandbox_tool.py +46 -0
  25. ms_enclave/sandbox/tools/sandbox_tools/__init__.py +4 -0
  26. ms_enclave/sandbox/tools/sandbox_tools/file_operation.py +331 -0
  27. ms_enclave/sandbox/tools/sandbox_tools/notebook_executor.py +167 -0
  28. ms_enclave/sandbox/tools/sandbox_tools/python_executor.py +87 -0
  29. ms_enclave/sandbox/tools/sandbox_tools/shell_executor.py +72 -0
  30. ms_enclave/sandbox/tools/tool_info.py +141 -0
  31. ms_enclave/utils/__init__.py +1 -0
  32. ms_enclave/utils/json_schema.py +208 -0
  33. ms_enclave/utils/logger.py +170 -0
  34. ms_enclave/version.py +2 -0
  35. ms_enclave-0.0.3.dist-info/METADATA +370 -0
  36. ms_enclave-0.0.3.dist-info/RECORD +40 -0
  37. ms_enclave-0.0.3.dist-info/WHEEL +5 -0
  38. ms_enclave-0.0.3.dist-info/entry_points.txt +2 -0
  39. ms_enclave-0.0.3.dist-info/licenses/LICENSE +201 -0
  40. ms_enclave-0.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,317 @@
1
+ """Docker-based sandbox implementation."""
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, Dict, List, Optional, Union
6
+
7
+ import docker
8
+ from docker import DockerClient
9
+ from docker.errors import APIError, ContainerError, ImageNotFound, NotFound
10
+ from docker.models.containers import Container
11
+
12
+ from ms_enclave.utils import get_logger
13
+
14
+ from ..model import CommandResult, DockerSandboxConfig, ExecutionStatus, SandboxStatus, SandboxType
15
+ from .base import Sandbox, register_sandbox
16
+
17
+ logger = get_logger()
18
+
19
+
20
+ @register_sandbox(SandboxType.DOCKER)
21
+ class DockerSandbox(Sandbox):
22
+ """Docker-based sandbox implementation."""
23
+
24
+ def __init__(self, config: DockerSandboxConfig, sandbox_id: Optional[str] = None):
25
+ """Initialize Docker sandbox.
26
+
27
+ Args:
28
+ config: Docker sandbox configuration
29
+ sandbox_id: Optional sandbox ID
30
+ """
31
+ super().__init__(config, sandbox_id)
32
+ self.config: DockerSandboxConfig = config
33
+ self.client: Optional[DockerClient] = None
34
+ self.container: Optional[Container] = None
35
+
36
+ @property
37
+ def sandbox_type(self) -> SandboxType:
38
+ """Return sandbox type."""
39
+ return SandboxType.DOCKER
40
+
41
+ async def start(self) -> None:
42
+ """Start the Docker container."""
43
+ try:
44
+ self.update_status(SandboxStatus.INITIALIZING)
45
+
46
+ # Initialize Docker client
47
+ self.client = docker.from_env()
48
+
49
+ # Ensure image exists
50
+ await self._ensure_image_exists()
51
+
52
+ # Create and start container
53
+ await self._create_container()
54
+ await self._start_container()
55
+
56
+ # Initialize tools
57
+ await self.initialize_tools()
58
+
59
+ self.update_status(SandboxStatus.RUNNING)
60
+
61
+ except Exception as e:
62
+ self.update_status(SandboxStatus.ERROR)
63
+ self.metadata['error'] = str(e)
64
+ logger.error(f'Failed to start Docker sandbox: {e}')
65
+ raise RuntimeError(f'Failed to start Docker sandbox: {e}')
66
+
67
+ async def stop(self) -> None:
68
+ """Stop the Docker container without removing it unless configured.
69
+
70
+ When remove_on_exit is False, this method stops the container but keeps
71
+ the container reference so get_execution_context() can return it.
72
+ """
73
+ if not self.container:
74
+ self.update_status(SandboxStatus.STOPPED)
75
+ return
76
+
77
+ try:
78
+ self.update_status(SandboxStatus.STOPPING)
79
+ await self.stop_container()
80
+
81
+ # If configured to remove on exit, perform full cleanup (removes container and closes client)
82
+ if self.config.remove_on_exit:
83
+ await self.cleanup()
84
+
85
+ self.update_status(SandboxStatus.STOPPED)
86
+ except Exception as e:
87
+ logger.error(f'Error stopping container: {e}')
88
+ self.update_status(SandboxStatus.ERROR)
89
+ raise
90
+
91
+ async def cleanup(self) -> None:
92
+ """Clean up Docker resources.
93
+
94
+ - Always stops the container if it is running.
95
+ - Removes the container only when remove_on_exit is True.
96
+ - Preserves container reference and client when remove_on_exit is False.
97
+ """
98
+ if self.container:
99
+ try:
100
+ self.container.remove(force=True)
101
+ logger.debug(f'Container {self.container.id} removed')
102
+ except Exception as e:
103
+ logger.error(f'Error cleaning up container: {e}')
104
+ finally:
105
+ # Only drop the reference when we actually removed it
106
+ self.container = None
107
+
108
+ # Close Docker client only if we dropped the container reference
109
+ if self.client:
110
+ try:
111
+ self.client.close()
112
+ except Exception as e:
113
+ logger.warning(f'Error closing Docker client: {e}')
114
+ finally:
115
+ self.client = None
116
+
117
+ async def stop_container(self) -> None:
118
+ """Stop the container if it is running."""
119
+ if not self.container:
120
+ return
121
+ try:
122
+ self.container.reload()
123
+ if self.container.status == SandboxStatus.RUNNING:
124
+ self.container.stop(timeout=10)
125
+ except NotFound:
126
+ logger.warning('Container not found while stopping')
127
+ except Exception as e:
128
+ logger.error(f'Error stopping container: {e}')
129
+ raise
130
+
131
+ async def get_execution_context(self) -> Any:
132
+ """Return the container for tool execution."""
133
+ return self.container
134
+
135
+ def _run_streaming(self, command: Union[str, List[str]]) -> tuple[int, str, str]:
136
+ """Execute command with streaming logs using low-level API.
137
+
138
+ Returns:
139
+ A tuple of (exit_code, stdout, stderr)
140
+ """
141
+ if not self.client or not self.container:
142
+ raise RuntimeError('Container is not running')
143
+
144
+ # Use low-level API for precise control over streaming and exit code.
145
+ exec_id = self.client.api.exec_create(
146
+ container=self.container.id,
147
+ cmd=command,
148
+ tty=False,
149
+ )['Id']
150
+
151
+ stdout_parts: List[str] = []
152
+ stderr_parts: List[str] = []
153
+
154
+ try:
155
+ for chunk in self.client.api.exec_start(exec_id, stream=True, demux=True):
156
+ if not chunk:
157
+ continue
158
+ out, err = chunk # each is Optional[bytes]
159
+ if out:
160
+ text = out.decode('utf-8', errors='replace')
161
+ stdout_parts.append(text)
162
+ for line in text.splitlines():
163
+ logger.info(f'[📦 {self.id}] {line}')
164
+ if err:
165
+ text = err.decode('utf-8', errors='replace')
166
+ stderr_parts.append(text)
167
+ for line in text.splitlines():
168
+ logger.error(f'[📦 {self.id}] {line}')
169
+ finally:
170
+ inspect = self.client.api.exec_inspect(exec_id)
171
+ exit_code = inspect.get('ExitCode')
172
+ if exit_code is None:
173
+ exit_code = -1
174
+
175
+ return exit_code, ''.join(stdout_parts), ''.join(stderr_parts)
176
+
177
+ def _run_buffered(self, command: Union[str, List[str]]) -> tuple[int, str, str]:
178
+ """Execute command and return buffered output using high-level API.
179
+
180
+ Returns:
181
+ A tuple of (exit_code, stdout, stderr)
182
+ """
183
+ if not self.container:
184
+ raise RuntimeError('Container is not running')
185
+
186
+ res = self.container.exec_run(command, tty=False, stream=False, demux=True)
187
+ out_tuple = res.output
188
+ if isinstance(out_tuple, tuple):
189
+ out_bytes, err_bytes = out_tuple
190
+ else:
191
+ # Fallback: when demux was not honored, treat all as stdout
192
+ out_bytes, err_bytes = out_tuple, b''
193
+
194
+ stdout = out_bytes.decode('utf-8', errors='replace') if out_bytes else ''
195
+ stderr = err_bytes.decode('utf-8', errors='replace') if err_bytes else ''
196
+ return res.exit_code, stdout, stderr
197
+
198
+ async def execute_command(
199
+ self, command: Union[str, List[str]], timeout: Optional[int] = None, stream: bool = True
200
+ ) -> CommandResult:
201
+ """Execute a command in the container.
202
+
203
+ When stream=True (default), logs are printed in real-time through the logger,
204
+ while stdout/stderr are still accumulated and returned in the result.
205
+ When stream=False, the command is executed and buffered, returning the full output at once.
206
+
207
+ Args:
208
+ command: Command to run (str or list)
209
+ timeout: Optional timeout in seconds
210
+ stream: Whether to stream logs in real time
211
+
212
+ Returns:
213
+ CommandResult with status, exit_code, stdout and stderr
214
+ """
215
+ if not self.container or not self.client:
216
+ raise RuntimeError('Container is not running')
217
+
218
+ loop = asyncio.get_running_loop()
219
+
220
+ run_func = self._run_streaming if stream else self._run_buffered
221
+ try:
222
+ exit_code, stdout, stderr = await asyncio.wait_for(
223
+ loop.run_in_executor(None, lambda: run_func(command)), timeout=timeout
224
+ )
225
+ status = ExecutionStatus.SUCCESS if exit_code == 0 else ExecutionStatus.ERROR
226
+ return CommandResult(command=command, status=status, exit_code=exit_code, stdout=stdout, stderr=stderr)
227
+ except asyncio.TimeoutError:
228
+ return CommandResult(
229
+ command=command,
230
+ status=ExecutionStatus.TIMEOUT,
231
+ exit_code=-1,
232
+ stdout='',
233
+ stderr=f'Command timed out after {timeout} seconds'
234
+ )
235
+ except Exception as e:
236
+ return CommandResult(command=command, status=ExecutionStatus.ERROR, exit_code=-1, stdout='', stderr=str(e))
237
+
238
+ async def _ensure_image_exists(self) -> None:
239
+ """Ensure Docker image exists."""
240
+ try:
241
+ self.client.images.get(self.config.image)
242
+ except ImageNotFound:
243
+ # Try to pull the image
244
+ try:
245
+ self.client.images.pull(self.config.image)
246
+ except Exception as e:
247
+ raise RuntimeError(f'Failed to pull image {self.config.image}: {e}')
248
+
249
+ async def _create_container(self) -> None:
250
+ """Create Docker container."""
251
+ try:
252
+ # Prepare container configuration
253
+ container_config = {
254
+ 'image': self.config.image,
255
+ 'name': f'sandbox-{self.id}',
256
+ 'working_dir': self.config.working_dir,
257
+ 'environment': self.config.env_vars,
258
+ 'detach': True,
259
+ 'tty': True,
260
+ 'stdin_open': True,
261
+ }
262
+
263
+ # Add command if specified
264
+ if self.config.command:
265
+ container_config['command'] = self.config.command
266
+
267
+ # Add resource limits
268
+ if self.config.memory_limit:
269
+ container_config['mem_limit'] = self.config.memory_limit
270
+
271
+ if self.config.cpu_limit:
272
+ container_config['cpu_quota'] = int(self.config.cpu_limit * 100000)
273
+ container_config['cpu_period'] = 100000
274
+
275
+ # Add volumes
276
+ if self.config.volumes:
277
+ container_config['volumes'] = self.config.volumes
278
+
279
+ # Add ports
280
+ if self.config.ports:
281
+ container_config['ports'] = self.config.ports
282
+
283
+ # Network configuration
284
+ if not self.config.network_enabled:
285
+ container_config['network_mode'] = 'none'
286
+ elif self.config.network:
287
+ container_config['network'] = self.config.network
288
+
289
+ # Privileged mode
290
+ container_config['privileged'] = self.config.privileged
291
+
292
+ # Create container
293
+ self.container = self.client.containers.create(**container_config)
294
+ self.metadata['container_id'] = self.container.id
295
+
296
+ except Exception as e:
297
+ raise RuntimeError(f'Failed to create container: {e}')
298
+
299
+ async def _start_container(self) -> None:
300
+ """Start Docker container."""
301
+ try:
302
+ self.container.start()
303
+
304
+ # Wait for container to be ready
305
+ timeout = 30
306
+ start_time = time.time()
307
+
308
+ while time.time() - start_time < timeout:
309
+ self.container.reload()
310
+ if self.container.status == 'running':
311
+ break
312
+ await asyncio.sleep(0.5)
313
+ else:
314
+ raise RuntimeError('Container failed to start within timeout')
315
+
316
+ except Exception as e:
317
+ raise RuntimeError(f'Failed to start container: {e}')
@@ -0,0 +1,11 @@
1
+ """Sandbox manager implementations."""
2
+
3
+ from .base import SandboxManager
4
+ from .http_manager import HttpSandboxManager
5
+ from .local_manager import LocalSandboxManager
6
+
7
+ __all__ = [
8
+ 'SandboxManager',
9
+ 'LocalSandboxManager',
10
+ 'HttpSandboxManager',
11
+ ]
@@ -0,0 +1,155 @@
1
+ """Base sandbox manager interface."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
5
+
6
+ from ..model import SandboxConfig, SandboxInfo, SandboxStatus, SandboxType, ToolResult
7
+
8
+ if TYPE_CHECKING:
9
+ from ..boxes import Sandbox
10
+
11
+
12
+ class SandboxManager(ABC):
13
+ """Abstract base class for sandbox managers."""
14
+
15
+ def __init__(self):
16
+ """Initialize the sandbox manager."""
17
+ self._running = False
18
+ self._sandboxes: Dict[str, 'Sandbox'] = {}
19
+
20
+ @abstractmethod
21
+ async def start(self) -> None:
22
+ """Start the sandbox manager."""
23
+ pass
24
+
25
+ @abstractmethod
26
+ async def stop(self) -> None:
27
+ """Stop the sandbox manager."""
28
+ pass
29
+
30
+ @abstractmethod
31
+ async def create_sandbox(
32
+ self,
33
+ sandbox_type: SandboxType,
34
+ config: Optional[Union[SandboxConfig, Dict]] = None,
35
+ sandbox_id: Optional[str] = None
36
+ ) -> str:
37
+ """Create a new sandbox.
38
+
39
+ Args:
40
+ sandbox_type: Type of sandbox to create
41
+ config: Sandbox configuration
42
+ sandbox_id: Optional sandbox ID
43
+
44
+ Returns:
45
+ Sandbox ID
46
+
47
+ Raises:
48
+ ValueError: If sandbox type is not supported
49
+ RuntimeError: If sandbox creation fails
50
+ """
51
+ pass
52
+
53
+ @abstractmethod
54
+ async def get_sandbox_info(self, sandbox_id: str) -> Optional[SandboxInfo]:
55
+ """Get sandbox information.
56
+
57
+ Args:
58
+ sandbox_id: Sandbox ID
59
+
60
+ Returns:
61
+ Sandbox information or None if not found
62
+ """
63
+ pass
64
+
65
+ @abstractmethod
66
+ async def list_sandboxes(self, status_filter: Optional[SandboxStatus] = None) -> List[SandboxInfo]:
67
+ """List all sandboxes.
68
+
69
+ Args:
70
+ status_filter: Optional status filter
71
+
72
+ Returns:
73
+ List of sandbox information
74
+ """
75
+ pass
76
+
77
+ @abstractmethod
78
+ async def stop_sandbox(self, sandbox_id: str) -> bool:
79
+ """Stop a sandbox.
80
+
81
+ Args:
82
+ sandbox_id: Sandbox ID
83
+
84
+ Returns:
85
+ True if stopped successfully, False if not found
86
+ """
87
+ pass
88
+
89
+ @abstractmethod
90
+ async def delete_sandbox(self, sandbox_id: str) -> bool:
91
+ """Delete a sandbox.
92
+
93
+ Args:
94
+ sandbox_id: Sandbox ID
95
+
96
+ Returns:
97
+ True if deleted successfully, False if not found
98
+ """
99
+ pass
100
+
101
+ @abstractmethod
102
+ async def execute_tool(self, sandbox_id: str, tool_name: str, parameters: Dict[str, Any]) -> ToolResult:
103
+ """Execute tool in sandbox.
104
+
105
+ Args:
106
+ sandbox_id: Sandbox ID
107
+ tool_name: Tool name to execute
108
+ parameters: Tool parameters
109
+
110
+ Returns:
111
+ Tool execution result
112
+
113
+ Raises:
114
+ ValueError: If sandbox or tool not found
115
+ """
116
+ pass
117
+
118
+ @abstractmethod
119
+ async def get_sandbox_tools(self, sandbox_id: str) -> Dict[str, Any]:
120
+ """Get available tools for a sandbox.
121
+
122
+ Args:
123
+ sandbox_id: Sandbox ID
124
+
125
+ Returns:
126
+ Dictionary of available tool types, e.g., {"tool_name": tool_schema}
127
+
128
+ Raises:
129
+ ValueError: If sandbox not found
130
+ """
131
+ pass
132
+
133
+ @abstractmethod
134
+ async def get_stats(self) -> Dict[str, Any]:
135
+ """Get manager statistics.
136
+
137
+ Returns:
138
+ Statistics dictionary
139
+ """
140
+ pass
141
+
142
+ @abstractmethod
143
+ async def cleanup_all_sandboxes(self) -> None:
144
+ """Clean up all sandboxes."""
145
+ pass
146
+
147
+ # Context manager support
148
+ async def __aenter__(self):
149
+ """Async context manager entry."""
150
+ await self.start()
151
+ return self
152
+
153
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
154
+ """Async context manager exit."""
155
+ await self.stop()