mcp-virtual-computer 0.2.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 (48) hide show
  1. kilntainers/__init__.py +3 -0
  2. kilntainers/__main__.py +6 -0
  3. kilntainers/auth.py +34 -0
  4. kilntainers/backends/__init__.py +17 -0
  5. kilntainers/backends/base.py +380 -0
  6. kilntainers/backends/docker.py +1498 -0
  7. kilntainers/backends/test___init__.py +37 -0
  8. kilntainers/backends/test_base.py +355 -0
  9. kilntainers/backends/test_docker.py +841 -0
  10. kilntainers/backends/test_docker_integration.py +310 -0
  11. kilntainers/backends/test_docker_management.py +122 -0
  12. kilntainers/backends/test_utils.py +152 -0
  13. kilntainers/backends/test_virtual_docker.py +387 -0
  14. kilntainers/cli.py +375 -0
  15. kilntainers/computers.py +284 -0
  16. kilntainers/config.py +71 -0
  17. kilntainers/dashboard.html +4263 -0
  18. kilntainers/dashboard.py +23 -0
  19. kilntainers/desktop.py +47 -0
  20. kilntainers/desktop_control.py +105 -0
  21. kilntainers/desktop_image/Dockerfile +67 -0
  22. kilntainers/desktop_image/desktop-control.py +661 -0
  23. kilntainers/desktop_image/start-desktop.sh +206 -0
  24. kilntainers/desktop_image/visual-action.py +185 -0
  25. kilntainers/desktop_image/wsproxy.py +218 -0
  26. kilntainers/errors.py +36 -0
  27. kilntainers/file_tools.py +320 -0
  28. kilntainers/server.py +1760 -0
  29. kilntainers/test_cli.py +704 -0
  30. kilntainers/test_cli_integration.py +213 -0
  31. kilntainers/test_computers.py +122 -0
  32. kilntainers/test_config.py +135 -0
  33. kilntainers/test_dashboard.py +113 -0
  34. kilntainers/test_desktop_control.py +109 -0
  35. kilntainers/test_e2e_mcp.py +310 -0
  36. kilntainers/test_errors.py +62 -0
  37. kilntainers/test_file_tools.py +175 -0
  38. kilntainers/test_http_lifecycle.py +353 -0
  39. kilntainers/test_lifecycle_integration.py +389 -0
  40. kilntainers/test_server.py +849 -0
  41. kilntainers/test_windows_docker.py +300 -0
  42. kilntainers/windows_docker.py +565 -0
  43. mcp_virtual_computer-0.2.3.dist-info/METADATA +179 -0
  44. mcp_virtual_computer-0.2.3.dist-info/RECORD +48 -0
  45. mcp_virtual_computer-0.2.3.dist-info/WHEEL +5 -0
  46. mcp_virtual_computer-0.2.3.dist-info/entry_points.txt +5 -0
  47. mcp_virtual_computer-0.2.3.dist-info/licenses/LICENSE +7 -0
  48. mcp_virtual_computer-0.2.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """Kilntainers: Secure Agent Sandboxes MCP server."""
2
+
3
+ __version__ = "0.2.3"
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m kilntainers`."""
2
+
3
+ from kilntainers.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
kilntainers/auth.py ADDED
@@ -0,0 +1,34 @@
1
+ """Minimal static bearer-token protection for remote Streamable HTTP."""
2
+
3
+ import hmac
4
+
5
+ from starlette.datastructures import Headers
6
+ from starlette.responses import JSONResponse
7
+ from starlette.types import ASGIApp, Receive, Scope, Send
8
+
9
+
10
+ class BearerTokenMiddleware:
11
+ """Require a configured bearer token on the MCP protocol route."""
12
+
13
+ def __init__(self, app: ASGIApp, *, token: str) -> None:
14
+ self.app = app
15
+ self.token = token
16
+
17
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
18
+ if scope["type"] != "http" or not scope.get("path", "").startswith("/mcp"):
19
+ await self.app(scope, receive, send)
20
+ return
21
+ authorization = Headers(scope=scope).get("authorization", "")
22
+ scheme, _, supplied = authorization.partition(" ")
23
+ if scheme.lower() != "bearer" or not hmac.compare_digest(
24
+ supplied,
25
+ self.token,
26
+ ):
27
+ response = JSONResponse(
28
+ {"error": "unauthorized"},
29
+ status_code=401,
30
+ headers={"WWW-Authenticate": "Bearer"},
31
+ )
32
+ await response(scope, receive, send)
33
+ return
34
+ await self.app(scope, receive, send)
@@ -0,0 +1,17 @@
1
+ """Docker-only backend registry for the first virtual-computer slice."""
2
+
3
+ from kilntainers.backends.base import Backend
4
+
5
+
6
+ def get_backend_class(name: str) -> type[Backend]:
7
+ """Return the only supported backend."""
8
+ if name != "docker":
9
+ raise KeyError(f"Unknown backend {name!r}. Available backends: docker")
10
+ from kilntainers.backends.docker import DockerBackend
11
+
12
+ return DockerBackend
13
+
14
+
15
+ def get_available_backend_names() -> list[str]:
16
+ """Return the deliberately narrow backend surface."""
17
+ return ["docker"]
@@ -0,0 +1,380 @@
1
+ """Backend abstraction layer — ABCs and shared types."""
2
+
3
+ import argparse
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import asdict, dataclass
7
+ from inspect import signature
8
+ from types import TracebackType
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from kilntainers.config import BackendConfig
13
+ from kilntainers.windows_docker import DockerRuntimeProgress
14
+
15
+ RuntimeProgressReporter = Callable[["DockerRuntimeProgress"], Awaitable[None]]
16
+
17
+ # --- Shared types ---
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ExecResult:
22
+ """Result of a command execution.
23
+
24
+ The return type from every exec call. Immutable, with no optional
25
+ fields — every execution produces all four values.
26
+
27
+ Maps directly to the MCP response schema (Functional spec §2.2).
28
+ The MCP layer serializes this to JSON for the tool response.
29
+ """
30
+
31
+ stdout: str
32
+ stderr: str
33
+ exit_code: int
34
+ exec_duration_ms: int
35
+
36
+
37
+ @dataclass(frozen=True, slots=True, kw_only=True)
38
+ class ComputerInfo:
39
+ """Provider-neutral description of a managed sandbox computer."""
40
+
41
+ computer_id: str
42
+ sandbox_id: str
43
+ backend: str
44
+ state: str
45
+ temporary: bool
46
+ image: str | None = None
47
+ created_at: str | None = None
48
+
49
+ def to_dict(self) -> dict[str, str | bool | None]:
50
+ """Return a JSON-serializable representation for MCP responses."""
51
+ return asdict(self)
52
+
53
+
54
+ @dataclass(frozen=True, slots=True, kw_only=True)
55
+ class ExecRequest:
56
+ """Validated parameters for a command execution.
57
+
58
+ Constructed by the MCP layer after input validation. The MCP layer
59
+ resolves defaults (effective timeout, configured output limit) so
60
+ the backend always receives concrete values.
61
+
62
+ kw_only=True forces callers to use keyword arguments, which is
63
+ clearer for a dataclass with many optional fields.
64
+ """
65
+
66
+ # Exactly one of command or args must be provided
67
+ command: str | None = None
68
+ args: list[str] | None = None
69
+
70
+ # Optional parameters
71
+ stdin: str | None = None
72
+ working_directory: str | None = None
73
+
74
+ # Always provided by MCP layer (defaults resolved before reaching backend)
75
+ timeout: int # seconds
76
+ output_limit: int # bytes
77
+
78
+ def __post_init__(self) -> None:
79
+ # Validate mutual exclusivity of command/args
80
+ if self.command is not None and self.args is not None:
81
+ raise ValueError("command and args are mutually exclusive")
82
+ if self.command is None and self.args is None:
83
+ raise ValueError("either command or args must be provided")
84
+
85
+ # Validate working_directory is absolute
86
+ if self.working_directory is not None and not self.working_directory.startswith(
87
+ "/"
88
+ ):
89
+ raise ValueError("working_directory must be an absolute path")
90
+
91
+ # Validate timeout
92
+ if self.timeout < 1:
93
+ raise ValueError("timeout must be at least 1 second")
94
+
95
+ # Validate output_limit
96
+ if self.output_limit < 1:
97
+ raise ValueError("output_limit must be positive")
98
+
99
+
100
+ # --- ABCs ---
101
+
102
+
103
+ class Sandbox(ABC):
104
+ """An active, isolated sandbox for executing commands.
105
+
106
+ Created by Backend.create_sandbox(). Each Sandbox is independent —
107
+ no shared state between Sandbox instances. Supports async context
108
+ manager for automatic cleanup.
109
+ """
110
+
111
+ @property
112
+ @abstractmethod
113
+ def sandbox_id(self) -> str:
114
+ """Unique identifier for this sandbox.
115
+
116
+ Used for logging, debugging, and session-to-sandbox mapping in
117
+ HTTP mode. For Docker, this is the container ID (short form).
118
+ """
119
+ ...
120
+
121
+ @property
122
+ def computer_id(self) -> str:
123
+ """Stable human-facing ID used to reconnect to this computer.
124
+
125
+ Legacy backends do not expose a separate name, so their provider ID is
126
+ also used as the computer ID. Managed backends override this property.
127
+ """
128
+ return getattr(self, "_managed_computer_id", self.sandbox_id)
129
+
130
+ @property
131
+ def temporary(self) -> bool:
132
+ """Whether this computer should be removed when its MCP owner exits."""
133
+ return getattr(self, "_managed_temporary", True)
134
+
135
+ @property
136
+ def desktop_url(self) -> str | None:
137
+ """Browser-reachable noVNC websocket URL, when a desktop is enabled."""
138
+ return None
139
+
140
+ @property
141
+ def desktop_environment(self) -> bool:
142
+ """Whether this sandbox is backed by a real desktop environment."""
143
+ return self.desktop_url is not None
144
+
145
+ @property
146
+ def network_access(self) -> bool:
147
+ """Whether this sandbox currently permits outbound network traffic."""
148
+ return True
149
+
150
+ @abstractmethod
151
+ async def exec(self, request: ExecRequest) -> ExecResult:
152
+ """Execute a command in the sandbox.
153
+
154
+ Returns an ExecResult for all normal outcomes including timeout
155
+ and output-limit conditions. Raises SandboxDiedError if the
156
+ sandbox has died (before or during execution).
157
+ """
158
+ ...
159
+
160
+ @abstractmethod
161
+ async def stop(self) -> None:
162
+ """Stop the sandbox and release all resources.
163
+
164
+ Idempotent — safe to call on an already-stopped sandbox.
165
+ """
166
+ ...
167
+
168
+ @abstractmethod
169
+ async def wait_for_death(self) -> None:
170
+ """Block until the sandbox dies unexpectedly.
171
+
172
+ Resolves when the sandbox terminates for reasons other than
173
+ stop() being called (OOM, external kill, daemon crash, etc.).
174
+
175
+ Must NOT resolve when stop() is called. Implementations track
176
+ whether stop was requested and suppress the signal in that case.
177
+
178
+ The MCP layer runs this as a background task to detect sandbox
179
+ death between exec calls. On normal shutdown, the MCP layer
180
+ cancels this task before calling stop().
181
+ """
182
+ ...
183
+
184
+ # --- Context manager support (concrete, not abstract) ---
185
+
186
+ async def __aenter__(self) -> "Sandbox":
187
+ return self
188
+
189
+ async def __aexit__(
190
+ self,
191
+ exc_type: type[BaseException] | None,
192
+ exc_val: BaseException | None,
193
+ exc_tb: TracebackType | None,
194
+ ) -> None:
195
+ await self.stop()
196
+
197
+
198
+ class Backend(ABC):
199
+ """Factory for creating sandboxes.
200
+
201
+ Configured at startup from CLI arguments. One instance per server
202
+ process. Creates independent Sandbox objects on demand.
203
+ """
204
+
205
+ def __init__(self, config: "BackendConfig") -> None:
206
+ """Initialize the backend with configuration.
207
+
208
+ Args:
209
+ config: The backend configuration.
210
+ """
211
+ self._validated: bool = False
212
+ self._config = config
213
+
214
+ @classmethod
215
+ def prepare_runtime(cls) -> None:
216
+ """Prepare process-wide state before the server event loop is created.
217
+
218
+ Most backends need no preparation. Backends with SDKs that configure
219
+ global runtime state can override this hook so those changes apply only
220
+ when that backend is selected.
221
+ """
222
+
223
+ def start_runtime_preparation(self) -> None:
224
+ """Start optional lazy host-runtime preparation without blocking."""
225
+
226
+ async def ensure_runtime(
227
+ self,
228
+ progress: RuntimeProgressReporter | None = None,
229
+ ) -> None:
230
+ """Wait for optional host-runtime preparation to finish."""
231
+
232
+ def runtime_status(self) -> dict[str, object]:
233
+ """Return optional host-runtime preparation state for an MCP App."""
234
+ return {
235
+ "runtime_state": "ready",
236
+ "runtime_phase": "ready",
237
+ "runtime_message": "Container runtime bootstrap is not required.",
238
+ "runtime_progress": 4.0,
239
+ "runtime_total": 4.0,
240
+ "downloaded_bytes": None,
241
+ "download_total_bytes": None,
242
+ "runtime_error": None,
243
+ }
244
+
245
+ @classmethod
246
+ @abstractmethod
247
+ def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None:
248
+ """Register backend-specific CLI arguments on the given argparse group.
249
+
250
+ Args:
251
+ group: An argparse argument group to add arguments to.
252
+ """
253
+ ...
254
+
255
+ @classmethod
256
+ @abstractmethod
257
+ def config_from_args(cls, args: argparse.Namespace) -> "BackendConfig":
258
+ """Build backend config from parsed CLI arguments.
259
+
260
+ Args:
261
+ args: Parsed command-line arguments from argparse.
262
+
263
+ Returns:
264
+ A BackendConfig subclass instance with this backend's configuration.
265
+ """
266
+ ...
267
+
268
+ async def validate(self) -> None:
269
+ """Check all prerequisites. Raises BackendError on failure.
270
+
271
+ Results are cached — subsequent calls are no-ops after the
272
+ first successful validation.
273
+ """
274
+ if self._validated:
275
+ return
276
+ await self._validate()
277
+ self._validated = True
278
+
279
+ @abstractmethod
280
+ async def _validate(self) -> None:
281
+ """Implementation-specific validation. Override this method.
282
+
283
+ Check that the backend's prerequisites are met (e.g., Docker
284
+ daemon is reachable, configured image is valid). Raise
285
+ BackendError with an actionable message on failure.
286
+ """
287
+ ...
288
+
289
+ async def create_sandbox(
290
+ self,
291
+ *,
292
+ computer_id: str | None = None,
293
+ temporary: bool = True,
294
+ ) -> Sandbox:
295
+ """Create a new sandbox and return it ready to use.
296
+
297
+ Auto-validates if validate() has not been called. Returns a
298
+ ready-to-use Sandbox (readiness check already passed).
299
+
300
+ Managed backends use ``computer_id`` as a stable provider name and
301
+ ``temporary`` to select cleanup behavior. Legacy backends may ignore
302
+ both values while still participating in the in-process registry.
303
+ """
304
+ await self.validate()
305
+ parameters = signature(self._create_sandbox).parameters
306
+ if "computer_id" not in parameters:
307
+ # Compatibility for third-party backends built against the original
308
+ # no-argument _create_sandbox contract.
309
+ return await self._create_sandbox()
310
+ return await self._create_sandbox(
311
+ computer_id=computer_id,
312
+ temporary=temporary,
313
+ )
314
+
315
+ @abstractmethod
316
+ async def _create_sandbox(
317
+ self,
318
+ *,
319
+ computer_id: str | None = None,
320
+ temporary: bool = True,
321
+ ) -> Sandbox:
322
+ """Implementation-specific sandbox creation. Override this method.
323
+
324
+ Must perform the full startup sequence:
325
+ 1. Create the sandbox (e.g., docker run)
326
+ 2. Verify readiness (e.g., trivial exec)
327
+ 3. Return the ready Sandbox object
328
+
329
+ Raise BackendError if startup fails at any step.
330
+ """
331
+ ...
332
+
333
+ async def attach_sandbox(self, computer_id: str) -> Sandbox | None:
334
+ """Attach to a provider computer created by an earlier process.
335
+
336
+ Backends with provider-side discovery should override this. Returning
337
+ ``None`` means no matching computer exists.
338
+ """
339
+ return None
340
+
341
+ async def list_computers(self) -> list[ComputerInfo]:
342
+ """List provider-side managed computers visible to this backend."""
343
+ return []
344
+
345
+ async def restart_computer(self, computer_id: str) -> Sandbox | None:
346
+ """Restart and reattach a managed computer, if supported."""
347
+ return None
348
+
349
+ async def delete_computer(self, computer_id: str) -> bool:
350
+ """Permanently delete a provider-side computer, if supported."""
351
+ return False
352
+
353
+ async def factory_reset_computer(self, computer_id: str) -> Sandbox | None:
354
+ """Recreate a managed computer from its configured base image."""
355
+ return None
356
+
357
+ async def set_network_access(
358
+ self, computer_id: str, enabled: bool
359
+ ) -> Sandbox | None:
360
+ """Change outbound network access for a managed computer, if supported."""
361
+ return None
362
+
363
+ async def switch_desktop_environment(
364
+ self, computer_id: str, enabled: bool
365
+ ) -> Sandbox | None:
366
+ """Switch a managed computer's UI mode without losing its state."""
367
+ return None
368
+
369
+ @abstractmethod
370
+ def tool_instructions(self) -> str | None:
371
+ """Return tool description text for the terminal_execute tool.
372
+
373
+ Returns a string describing this backend's sandbox capabilities,
374
+ or None if the backend cannot provide a description (e.g.,
375
+ custom Docker image where the baked-in description doesn't apply).
376
+
377
+ The MCP layer uses this in tool description assembly. When None,
378
+ the server requires --tool-instruction-override.
379
+ """
380
+ ...