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,1498 @@
1
+ """Docker backend implementation."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass, field
9
+ from importlib.resources import files
10
+ from typing import cast
11
+
12
+ from kilntainers.backends.base import (
13
+ Backend,
14
+ ComputerInfo,
15
+ ExecRequest,
16
+ ExecResult,
17
+ Sandbox,
18
+ )
19
+ from kilntainers.computers import random_computer_id
20
+ from kilntainers.config import BackendConfig, env_flag
21
+ from kilntainers.errors import BackendError, SandboxDiedError
22
+ from kilntainers.windows_docker import (
23
+ DockerRuntimeProgress,
24
+ initial_docker_runtime_status,
25
+ prepare_windows_docker_runtime,
26
+ )
27
+
28
+ DEFAULT_IMAGE = "debian:bookworm-slim"
29
+ DEFAULT_DESKTOP_IMAGE = "mcp-virtual-computer-desktop:bookworm"
30
+ COMPUTER_NAME_PREFIX = "kilntainer-"
31
+ COMPUTER_ID_LABEL = "kilntainers.computer-id"
32
+ TEMPORARY_LABEL = "kilntainers.temporary"
33
+ IMAGE_LABEL = "kilntainers.image"
34
+ DESKTOP_LABEL = "mcp-virtual-computer.desktop"
35
+ WORKSPACE_LABEL = "mcp-virtual-computer.workspace"
36
+ DESKTOP_MODE_FILE = "/var/lib/mcp-virtual-computer/desktop-enabled"
37
+ DESKTOP_IMAGE_VERSION_LABEL = "mcp-virtual-computer.image-version"
38
+ DESKTOP_IMAGE_VERSION = "2"
39
+
40
+
41
+ @dataclass(frozen=True, slots=True, kw_only=True)
42
+ class DockerBackendConfig(BackendConfig):
43
+ """Configuration for the Docker backend.
44
+
45
+ Populated from CLI args by DockerBackend.config_from_args().
46
+ Consumed by DockerBackend.
47
+ """
48
+
49
+ engine: str = "docker"
50
+ host: str | None = None
51
+ image: str = "debian:bookworm-slim"
52
+ shell: str = "/bin/bash"
53
+ network_enabled: bool = True
54
+ cpu: str | None = None
55
+ memory: str | None = None
56
+ docker_run_flags: list[str] = field(default_factory=list)
57
+ desktop_environment: bool = False
58
+ workspace_directory: str = "/workspace"
59
+
60
+
61
+ class _OutputLimitExceeded(Exception):
62
+ """Internal signal: combined output exceeded the configured limit."""
63
+
64
+ pass
65
+
66
+
67
+ @dataclass(frozen=True, slots=True, kw_only=True)
68
+ class _DockerSandboxState:
69
+ """State shared between DockerBackend and DockerSandbox.
70
+
71
+ The sandbox receives these values at construction instead of the full
72
+ config to keep the interface minimal.
73
+ """
74
+
75
+ engine: str
76
+ host: str | None
77
+ shell: str
78
+ container_id: str
79
+ computer_id: str | None = None
80
+ temporary: bool = True
81
+ image: str | None = None
82
+ desktop_environment: bool = False
83
+ network_access: bool = True
84
+ workspace_directory: str = "/workspace"
85
+ desktop_host_port: int | None = None
86
+
87
+
88
+ class DockerBackend(Backend):
89
+ """Docker backend implementation.
90
+
91
+ Manages Docker container lifecycle and command execution through
92
+ subprocess calls to the Docker CLI (or compatible engine like podman).
93
+ """
94
+
95
+ @classmethod
96
+ def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None:
97
+ """Register Docker-specific CLI arguments."""
98
+ group.add_argument(
99
+ "--engine",
100
+ default="docker",
101
+ help="Container CLI binary (default: docker). Supports podman.",
102
+ )
103
+ group.add_argument(
104
+ "--docker-host",
105
+ default=None,
106
+ dest="docker_host",
107
+ help=(
108
+ "Docker daemon socket/address, passed as -H to the Docker CLI "
109
+ '(e.g., "ssh://user@remote-host", "tcp://host:2375")'
110
+ ),
111
+ )
112
+ group.add_argument(
113
+ "--image",
114
+ default=DEFAULT_DESKTOP_IMAGE,
115
+ help=f"Docker image (default: {DEFAULT_DESKTOP_IMAGE})",
116
+ )
117
+ group.add_argument(
118
+ "--cpu",
119
+ default=None,
120
+ help='Docker CPU limit (e.g., "1.5")',
121
+ )
122
+ group.add_argument(
123
+ "--memory",
124
+ default=None,
125
+ help='Docker memory limit (e.g., "512m")',
126
+ )
127
+ group.add_argument(
128
+ "--docker-run-flag",
129
+ action="append",
130
+ default=None,
131
+ dest="docker_run_flags",
132
+ help=(
133
+ "Additional flag passed to docker run. Repeatable. "
134
+ '(e.g., --docker-run-flag "--pids-limit=256")'
135
+ ),
136
+ )
137
+
138
+ @classmethod
139
+ def config_from_args(cls, args: argparse.Namespace) -> BackendConfig:
140
+ """Build DockerBackendConfig from parsed CLI arguments."""
141
+ # Use core --shell with a backend-specific default
142
+ shell = args.shell if args.shell is not None else "/bin/bash"
143
+ desktop_environment = env_flag("DESKTOP_ENVIRONMENT", default=False)
144
+ image = args.image
145
+ network_access = env_flag("NETWORK_ACCESS", default=args.network)
146
+ return DockerBackendConfig(
147
+ engine=args.engine,
148
+ host=args.docker_host,
149
+ image=image,
150
+ shell=shell,
151
+ network_enabled=network_access,
152
+ cpu=args.cpu,
153
+ memory=args.memory,
154
+ docker_run_flags=args.docker_run_flags or [],
155
+ default_timeout=args.timeout,
156
+ desktop_environment=desktop_environment,
157
+ workspace_directory="/workspace",
158
+ )
159
+
160
+ def __init__(self, config: DockerBackendConfig) -> None:
161
+ super().__init__(config)
162
+ # Override parent's _config with more specific type for type checker
163
+ self._config: DockerBackendConfig = config
164
+ self._runtime_task: asyncio.Task[None] | None = None
165
+ self._runtime_error: BackendError | None = None
166
+ self._runtime_progress = initial_docker_runtime_status(config.engine)
167
+ self._runtime_reporters: dict[
168
+ Callable[[DockerRuntimeProgress], Awaitable[None]], float
169
+ ] = {}
170
+ self._runtime_notification_tasks: set[asyncio.Task[None]] = set()
171
+
172
+ async def _notify_runtime_reporter(
173
+ self,
174
+ reporter: Callable[[DockerRuntimeProgress], Awaitable[None]],
175
+ update: DockerRuntimeProgress,
176
+ ) -> None:
177
+ try:
178
+ await reporter(update)
179
+ except Exception:
180
+ # Progress is best-effort and must never break runtime preparation.
181
+ pass
182
+
183
+ def _accept_runtime_progress(self, update: DockerRuntimeProgress) -> None:
184
+ self._runtime_progress = update
185
+ for reporter, previous in tuple(self._runtime_reporters.items()):
186
+ if update.progress <= previous:
187
+ continue
188
+ self._runtime_reporters[reporter] = update.progress
189
+ task = asyncio.create_task(self._notify_runtime_reporter(reporter, update))
190
+ self._runtime_notification_tasks.add(task)
191
+ task.add_done_callback(self._runtime_notification_tasks.discard)
192
+
193
+ async def _prepare_windows_runtime(self) -> None:
194
+ loop = asyncio.get_running_loop()
195
+ latest = self._runtime_progress
196
+
197
+ def report(update: DockerRuntimeProgress) -> None:
198
+ nonlocal latest
199
+ latest = update
200
+ loop.call_soon_threadsafe(self._accept_runtime_progress, update)
201
+
202
+ try:
203
+ await asyncio.to_thread(
204
+ prepare_windows_docker_runtime,
205
+ engine=self._config.engine,
206
+ host=self._config.host,
207
+ report=report,
208
+ )
209
+ self._accept_runtime_progress(latest)
210
+ except BackendError as error:
211
+ self._runtime_error = error
212
+ self._accept_runtime_progress(
213
+ DockerRuntimeProgress(
214
+ state="failed",
215
+ phase="failed",
216
+ message="Docker setup failed.",
217
+ progress=self._runtime_progress.progress,
218
+ error=str(error),
219
+ )
220
+ )
221
+ except Exception as error:
222
+ wrapped = BackendError(f"Unexpected Docker setup failure: {error}")
223
+ self._runtime_error = wrapped
224
+ self._accept_runtime_progress(
225
+ DockerRuntimeProgress(
226
+ state="failed",
227
+ phase="failed",
228
+ message="Docker setup failed.",
229
+ progress=self._runtime_progress.progress,
230
+ error=str(wrapped),
231
+ )
232
+ )
233
+
234
+ def start_runtime_preparation(self) -> None:
235
+ """Start the Windows Docker check/install task on first use."""
236
+ if self._runtime_progress.state == "ready":
237
+ return
238
+ if self._runtime_task is not None and not self._runtime_task.done():
239
+ return
240
+ if self._runtime_error is not None:
241
+ # A new user invocation is an explicit retry after external repair.
242
+ self._runtime_error = None
243
+ self._runtime_progress = initial_docker_runtime_status(self._config.engine)
244
+ self._runtime_task = asyncio.create_task(self._prepare_windows_runtime())
245
+
246
+ async def ensure_runtime(
247
+ self,
248
+ progress: Callable[[DockerRuntimeProgress], Awaitable[None]] | None = None,
249
+ ) -> None:
250
+ """Wait for lazy Docker preparation while preserving it on cancellation."""
251
+ if self._runtime_progress.state == "ready":
252
+ return
253
+ self.start_runtime_preparation()
254
+ if progress is not None:
255
+ self._runtime_reporters[progress] = -1.0
256
+ if self._runtime_progress.state != "pending":
257
+ self._runtime_reporters[progress] = self._runtime_progress.progress
258
+ await self._notify_runtime_reporter(progress, self._runtime_progress)
259
+ try:
260
+ assert self._runtime_task is not None
261
+ await asyncio.shield(self._runtime_task)
262
+ finally:
263
+ if progress is not None:
264
+ self._runtime_reporters.pop(progress, None)
265
+ if self._runtime_error is not None:
266
+ raise self._runtime_error
267
+
268
+ def runtime_status(self) -> dict[str, object]:
269
+ """Expose genuine Windows Docker setup state to the MCP App."""
270
+ return self._runtime_progress.to_dict()
271
+
272
+ @property
273
+ def _engine_prefix(self) -> list[str]:
274
+ """Return the base engine command, including -H if a host is configured."""
275
+ prefix = [self._config.engine]
276
+ if self._config.host is not None:
277
+ prefix.extend(["-H", self._config.host])
278
+ return prefix
279
+
280
+ async def _run_docker(
281
+ self,
282
+ *args: str,
283
+ stdin_data: bytes | None = None,
284
+ check: bool = True,
285
+ timeout: float = 30,
286
+ ) -> tuple[int, bytes, bytes]:
287
+ """Run a Docker CLI command and return (returncode, stdout, stderr).
288
+
289
+ Args:
290
+ args: Command arguments after the engine name (e.g., "info", "run", "-d").
291
+ stdin_data: Bytes to pipe to the command's stdin.
292
+ check: If True, raise BackendError on non-zero exit.
293
+ timeout: Seconds to wait before killing the subprocess.
294
+
295
+ Returns:
296
+ Tuple of (return_code, stdout_bytes, stderr_bytes).
297
+
298
+ Raises:
299
+ BackendError: If check=True and the command exits non-zero,
300
+ or if the command times out.
301
+ """
302
+ cmd = [*self._engine_prefix, *args]
303
+ proc = await asyncio.create_subprocess_exec(
304
+ *cmd,
305
+ stdin=asyncio.subprocess.PIPE if stdin_data else asyncio.subprocess.DEVNULL,
306
+ stdout=asyncio.subprocess.PIPE,
307
+ stderr=asyncio.subprocess.PIPE,
308
+ )
309
+ try:
310
+ stdout, stderr = await asyncio.wait_for(
311
+ proc.communicate(stdin_data),
312
+ timeout=timeout,
313
+ )
314
+ except asyncio.TimeoutError:
315
+ proc.kill()
316
+ await proc.wait()
317
+ raise BackendError(
318
+ f"Docker command timed out after {timeout}s: {' '.join(cmd)}"
319
+ )
320
+
321
+ if check and proc.returncode is not None and proc.returncode != 0:
322
+ stderr_text = stderr.decode("utf-8", errors="replace").strip()
323
+ raise BackendError(
324
+ f"Docker command failed (exit {proc.returncode}): "
325
+ f"{' '.join(cmd)}\n{stderr_text}"
326
+ )
327
+ assert proc.returncode is not None
328
+ return proc.returncode, stdout, stderr
329
+
330
+ async def _validate(self) -> None:
331
+ """Validate Docker prerequisites.
332
+
333
+ Checks that the Docker engine is reachable and responsive.
334
+ """
335
+ await self.ensure_runtime()
336
+ try:
337
+ await self._run_docker("info", timeout=10)
338
+ except BackendError:
339
+ raise BackendError(
340
+ f"Cannot connect to {self._config.engine}. "
341
+ f"Is the {self._config.engine} daemon running?"
342
+ )
343
+
344
+ async def _ensure_image(
345
+ self,
346
+ image: str | None = None,
347
+ *,
348
+ desktop_environment: bool | None = None,
349
+ ) -> None:
350
+ """Pull the configured image if not available locally."""
351
+ image = image or self._config.image
352
+ desktop_environment = (
353
+ self._config.desktop_environment
354
+ if desktop_environment is None
355
+ else desktop_environment
356
+ )
357
+ # Check if image exists locally
358
+ returncode, stdout, _ = await self._run_docker(
359
+ "image",
360
+ "inspect",
361
+ image,
362
+ check=False,
363
+ timeout=10,
364
+ )
365
+ if returncode == 0:
366
+ if image != DEFAULT_DESKTOP_IMAGE:
367
+ return
368
+ try:
369
+ image_data = json.loads(stdout)
370
+ labels = image_data[0]["Config"]["Labels"]
371
+ if (
372
+ isinstance(labels, dict)
373
+ and labels.get(DESKTOP_IMAGE_VERSION_LABEL)
374
+ == DESKTOP_IMAGE_VERSION
375
+ ):
376
+ return
377
+ except (IndexError, KeyError, TypeError, json.JSONDecodeError):
378
+ pass
379
+ # Rebuild a stale bundled image under the same local tag. Running
380
+ # persistent containers keep their writable layer and are untouched.
381
+
382
+ if image == DEFAULT_DESKTOP_IMAGE:
383
+ context = files("kilntainers").joinpath("desktop_image")
384
+ try:
385
+ await self._run_docker(
386
+ "build",
387
+ "--tag",
388
+ image,
389
+ str(context),
390
+ timeout=900,
391
+ )
392
+ except BackendError as error:
393
+ raise BackendError(
394
+ "Failed to build the bundled Xfce desktop image. "
395
+ f"Docker reported: {error}"
396
+ )
397
+ return
398
+
399
+ # Pull with progress output to stderr
400
+ # Don't use _run_docker because we want stderr to pass through
401
+ # to the parent process (for progress display to the user)
402
+ proc = await asyncio.create_subprocess_exec(
403
+ *self._engine_prefix,
404
+ "pull",
405
+ image,
406
+ stdout=asyncio.subprocess.DEVNULL,
407
+ stderr=None, # inherit parent stderr — shows pull progress
408
+ )
409
+ await proc.wait()
410
+ if proc.returncode != 0:
411
+ raise BackendError(
412
+ f"Failed to pull image '{image}'. "
413
+ f"Check that the image name is correct and the registry is reachable."
414
+ )
415
+
416
+ def _build_run_command(
417
+ self,
418
+ *,
419
+ computer_id: str | None = None,
420
+ temporary: bool = True,
421
+ desktop_environment: bool | None = None,
422
+ network_access: bool | None = None,
423
+ image: str | None = None,
424
+ ) -> list[str]:
425
+ """Build the docker run argument list."""
426
+ computer_id = computer_id or random_computer_id()
427
+ desktop_environment = (
428
+ self._config.desktop_environment
429
+ if desktop_environment is None
430
+ else desktop_environment
431
+ )
432
+ network_access = (
433
+ self._config.network_enabled if network_access is None else network_access
434
+ )
435
+ image = image or self._config.image
436
+ desktop_capable = desktop_environment or image == DEFAULT_DESKTOP_IMAGE
437
+ cmd = [
438
+ "run",
439
+ "-d", # detached mode
440
+ "--label",
441
+ "kilntainers=true", # identification label
442
+ "--label",
443
+ f"{COMPUTER_ID_LABEL}={computer_id}",
444
+ "--label",
445
+ f"{TEMPORARY_LABEL}={str(temporary).lower()}",
446
+ "--label",
447
+ f"{IMAGE_LABEL}={image}",
448
+ "--label",
449
+ f"{DESKTOP_LABEL}={str(desktop_environment).lower()}",
450
+ "--label",
451
+ f"{WORKSPACE_LABEL}={self._config.workspace_directory}",
452
+ "--name",
453
+ f"{COMPUTER_NAME_PREFIX}{computer_id}",
454
+ "--workdir",
455
+ self._config.workspace_directory,
456
+ ]
457
+
458
+ if temporary:
459
+ # Temporary computers disappear when stopped by session cleanup.
460
+ cmd.append("--rm")
461
+
462
+ # Plain headless images can use Docker's complete network namespace
463
+ # isolation. Desktop-capable computers keep the bridge so their local
464
+ # noVNC transport stays available while Xfce is toggled off; their
465
+ # bundled entrypoint applies an OUTPUT firewall instead.
466
+ if not network_access and not desktop_capable:
467
+ cmd.extend(["--network", "none"])
468
+
469
+ # Resource limits
470
+ if self._config.cpu is not None:
471
+ cmd.extend(["--cpus", self._config.cpu])
472
+ if self._config.memory is not None:
473
+ cmd.extend(["--memory", self._config.memory])
474
+
475
+ if desktop_capable:
476
+ # Bind the browser transport to loopback on an ephemeral host port.
477
+ cmd.extend(
478
+ [
479
+ "--init",
480
+ "--publish",
481
+ "127.0.0.1::6080",
482
+ "--shm-size",
483
+ "256m",
484
+ "--cap-add",
485
+ "NET_ADMIN",
486
+ "--env",
487
+ f"NETWORK_ACCESS={str(network_access).lower()}",
488
+ "--env",
489
+ f"DESKTOP_ENVIRONMENT={str(desktop_environment).lower()}",
490
+ ]
491
+ )
492
+
493
+ # User-provided extra flags (escape hatch)
494
+ for flag in self._config.docker_run_flags:
495
+ cmd.append(flag)
496
+
497
+ # Image and keep-alive command
498
+ cmd.append(image)
499
+ if not desktop_capable:
500
+ cmd.extend(["tail", "-f", "/dev/null"])
501
+
502
+ return cmd
503
+
504
+ async def _create_sandbox(
505
+ self,
506
+ *,
507
+ computer_id: str | None = None,
508
+ temporary: bool = True,
509
+ desktop_environment: bool | None = None,
510
+ network_access: bool | None = None,
511
+ image: str | None = None,
512
+ ) -> "DockerSandbox":
513
+ """Create a Docker sandbox.
514
+
515
+ Performs the full startup sequence:
516
+ 1. Ensure image is available (pull if needed)
517
+ 2. Build and run docker run command
518
+ 3. Create sandbox object
519
+ 4. Verify readiness (cleanup if fails)
520
+ """
521
+ computer_id = computer_id or random_computer_id()
522
+ desktop_environment = (
523
+ self._config.desktop_environment
524
+ if desktop_environment is None
525
+ else desktop_environment
526
+ )
527
+ network_access = (
528
+ self._config.network_enabled if network_access is None else network_access
529
+ )
530
+ image = image or self._config.image
531
+ desktop_capable = desktop_environment or image == DEFAULT_DESKTOP_IMAGE
532
+
533
+ # 1. Ensure image is available (pull if needed)
534
+ await self._ensure_image(image, desktop_environment=desktop_environment)
535
+
536
+ # 2. Build docker run command
537
+ cmd = self._build_run_command(
538
+ computer_id=computer_id,
539
+ temporary=temporary,
540
+ desktop_environment=desktop_environment,
541
+ network_access=network_access,
542
+ image=image,
543
+ )
544
+
545
+ # 3. Create and start container
546
+ _, stdout, _ = await self._run_docker(*cmd, timeout=30)
547
+ container_id = stdout.decode().strip()
548
+ try:
549
+ desktop_host_port = (
550
+ await self._published_desktop_port(container_id)
551
+ if desktop_capable
552
+ else None
553
+ )
554
+ except Exception:
555
+ await self._run_docker(
556
+ "rm",
557
+ "-f",
558
+ container_id,
559
+ check=False,
560
+ timeout=20,
561
+ )
562
+ raise
563
+
564
+ # 4. Create sandbox state
565
+ state = _DockerSandboxState(
566
+ engine=self._config.engine,
567
+ host=self._config.host,
568
+ shell=self._config.shell,
569
+ container_id=container_id,
570
+ computer_id=computer_id,
571
+ temporary=temporary,
572
+ image=image,
573
+ desktop_environment=desktop_environment,
574
+ network_access=network_access,
575
+ workspace_directory=self._config.workspace_directory,
576
+ desktop_host_port=desktop_host_port,
577
+ )
578
+
579
+ # 5. Create sandbox object
580
+ sandbox = DockerSandbox(state)
581
+
582
+ # 6. Verify readiness
583
+ try:
584
+ await sandbox._verify_readiness()
585
+ except Exception:
586
+ # Clean up the container if readiness check fails
587
+ await sandbox.stop()
588
+ raise
589
+
590
+ return sandbox
591
+
592
+ async def _published_desktop_port(self, container_id: str) -> int:
593
+ """Return Docker's ephemeral loopback port for the noVNC websocket."""
594
+ for _ in range(20):
595
+ returncode, stdout, _ = await self._run_docker(
596
+ "port",
597
+ container_id,
598
+ "6080/tcp",
599
+ check=False,
600
+ timeout=5,
601
+ )
602
+ if returncode == 0:
603
+ address = stdout.decode("utf-8", errors="replace").strip().splitlines()
604
+ if address:
605
+ try:
606
+ return int(address[0].rsplit(":", 1)[1])
607
+ except (IndexError, ValueError):
608
+ pass
609
+ await asyncio.sleep(0.1)
610
+ raise BackendError("Docker did not publish the desktop websocket port.")
611
+
612
+ @staticmethod
613
+ def _desktop_port_from_inspect(data: dict[str, object]) -> int | None:
614
+ network = data.get("NetworkSettings", {})
615
+ network = (
616
+ cast("dict[str, object]", network) if isinstance(network, dict) else {}
617
+ )
618
+ ports = network.get("Ports", {})
619
+ ports = cast("dict[str, object]", ports) if isinstance(ports, dict) else {}
620
+ bindings = ports.get("6080/tcp")
621
+ if not isinstance(bindings, list) or not bindings:
622
+ return None
623
+ binding = bindings[0]
624
+ if not isinstance(binding, dict):
625
+ return None
626
+ binding = cast("dict[str, object]", binding)
627
+ try:
628
+ return int(str(binding.get("HostPort", "")))
629
+ except ValueError:
630
+ return None
631
+
632
+ async def _inspect_computer(self, computer_id: str) -> dict[str, object] | None:
633
+ """Return Docker inspect data for an owned named computer."""
634
+ returncode, stdout, _ = await self._run_docker(
635
+ "container",
636
+ "inspect",
637
+ f"{COMPUTER_NAME_PREFIX}{computer_id}",
638
+ check=False,
639
+ timeout=10,
640
+ )
641
+ if returncode != 0:
642
+ return None
643
+ try:
644
+ items = json.loads(stdout.decode("utf-8"))
645
+ data = items[0]
646
+ except (json.JSONDecodeError, IndexError, TypeError):
647
+ raise BackendError(
648
+ f"Docker returned invalid inspect data for computer '{computer_id}'."
649
+ )
650
+ if not isinstance(data, dict):
651
+ raise BackendError(
652
+ f"Docker returned invalid inspect data for computer '{computer_id}'."
653
+ )
654
+ labels = data.get("Config", {})
655
+ labels = labels.get("Labels", {}) if isinstance(labels, dict) else {}
656
+ if not isinstance(labels, dict) or labels.get(COMPUTER_ID_LABEL) != computer_id:
657
+ raise BackendError(
658
+ f"Docker name '{COMPUTER_NAME_PREFIX}{computer_id}' is already in "
659
+ "use by a container not owned by this server."
660
+ )
661
+ return data
662
+
663
+ def _sandbox_from_inspect(self, data: dict[str, object]) -> "DockerSandbox":
664
+ """Build a live sandbox handle from Docker inspect JSON."""
665
+ config = data.get("Config", {})
666
+ config = cast("dict[str, object]", config) if isinstance(config, dict) else {}
667
+ labels = config.get("Labels", {})
668
+ labels = cast("dict[str, object]", labels) if isinstance(labels, dict) else {}
669
+ computer_id = str(labels.get(COMPUTER_ID_LABEL, ""))
670
+ temporary = str(labels.get(TEMPORARY_LABEL, "true")).lower() == "true"
671
+ image = str(labels.get(IMAGE_LABEL) or self._config.image)
672
+ desktop_environment = str(labels.get(DESKTOP_LABEL, "false")).lower() == "true"
673
+ workspace_directory = str(
674
+ labels.get(WORKSPACE_LABEL) or self._config.workspace_directory
675
+ )
676
+ network_data = data.get("NetworkSettings", {})
677
+ network_data = (
678
+ cast("dict[str, object]", network_data)
679
+ if isinstance(network_data, dict)
680
+ else {}
681
+ )
682
+ networks = network_data.get("Networks", {})
683
+ networks = (
684
+ cast("dict[str, object]", networks) if isinstance(networks, dict) else {}
685
+ )
686
+ desktop_host_port = self._desktop_port_from_inspect(data)
687
+ desktop_capable = desktop_host_port is not None
688
+ if desktop_capable:
689
+ raw_environment = config.get("Env", [])
690
+ environment = (
691
+ cast("list[object]", raw_environment)
692
+ if isinstance(raw_environment, list)
693
+ else []
694
+ )
695
+ configured_network = next(
696
+ (
697
+ str(value).split("=", 1)[1]
698
+ for value in environment
699
+ if isinstance(value, str) and value.startswith("NETWORK_ACCESS=")
700
+ ),
701
+ "true",
702
+ )
703
+ network_access = configured_network.casefold() == "true"
704
+ else:
705
+ network_access = (
706
+ any(name != "none" for name in networks)
707
+ if networks
708
+ else self._config.network_enabled
709
+ )
710
+ return DockerSandbox(
711
+ _DockerSandboxState(
712
+ engine=self._config.engine,
713
+ host=self._config.host,
714
+ shell=self._config.shell,
715
+ container_id=str(data.get("Id", "")),
716
+ computer_id=computer_id,
717
+ temporary=temporary,
718
+ image=image,
719
+ desktop_environment=desktop_environment,
720
+ network_access=network_access,
721
+ workspace_directory=workspace_directory,
722
+ desktop_host_port=desktop_host_port,
723
+ )
724
+ )
725
+
726
+ async def _read_desktop_mode(self, sandbox: "DockerSandbox") -> bool:
727
+ """Read the live usable mode, falling back to the old label."""
728
+ if sandbox._desktop_host_port is None:
729
+ sandbox._desktop_environment = False
730
+ return False
731
+ returncode, stdout, _ = await self._run_docker(
732
+ "exec",
733
+ sandbox._container_id,
734
+ "sh",
735
+ "-c",
736
+ f"cat {DESKTOP_MODE_FILE}",
737
+ check=False,
738
+ timeout=5,
739
+ )
740
+ if returncode == 0:
741
+ value = stdout.decode("utf-8", errors="replace").strip().casefold()
742
+ if value in {"true", "false"}:
743
+ sandbox._desktop_environment = value == "true"
744
+ if sandbox._desktop_environment:
745
+ # The state file records intent. Do not advertise Xfce unless both
746
+ # the desktop session and VNC backend are genuinely available.
747
+ returncode, _, _ = await self._run_docker(
748
+ "exec",
749
+ sandbox._container_id,
750
+ "sh",
751
+ "-c",
752
+ self._desktop_ready_check(),
753
+ check=False,
754
+ timeout=5,
755
+ )
756
+ sandbox._desktop_environment = returncode == 0
757
+ return sandbox.desktop_environment
758
+
759
+ @staticmethod
760
+ def _desktop_ready_check() -> str:
761
+ """Shell predicate for a usable Xfce session and its VNC transport."""
762
+ return (
763
+ "ps -C xfce4-session -o stat= 2>/dev/null "
764
+ "| grep -qv '^[[:space:]]*Z' && "
765
+ "ps -C x11vnc -o stat= 2>/dev/null "
766
+ "| grep -qv '^[[:space:]]*Z' && "
767
+ "pgrep -f '[p]ython3 /usr/local/bin/wsproxy' >/dev/null"
768
+ )
769
+
770
+ async def _set_desktop_mode(
771
+ self,
772
+ sandbox: "DockerSandbox",
773
+ enabled: bool,
774
+ ) -> "DockerSandbox":
775
+ """Start or stop Xfce inside one desktop-capable container."""
776
+ if sandbox._desktop_host_port is None or sandbox.image != DEFAULT_DESKTOP_IMAGE:
777
+ if enabled:
778
+ raise BackendError(
779
+ "This existing computer was created from a headless or custom "
780
+ "image and cannot start Xfce in place. Its data was left "
781
+ "untouched; use factory_reset_computer only if replacing it "
782
+ "is intentional."
783
+ )
784
+ sandbox._desktop_environment = False
785
+ return sandbox
786
+
787
+ desired = str(enabled).lower()
788
+ script = (
789
+ "install -d -o computer -g computer "
790
+ f"$(dirname {DESKTOP_MODE_FILE}); "
791
+ f"printf '%s\\n' {desired} > {DESKTOP_MODE_FILE}; "
792
+ f"chown computer:computer {DESKTOP_MODE_FILE}"
793
+ )
794
+ await self._run_docker(
795
+ "exec",
796
+ "-u",
797
+ "0",
798
+ sandbox._container_id,
799
+ "sh",
800
+ "-c",
801
+ script,
802
+ timeout=10,
803
+ )
804
+
805
+ # The supervisor checks the marker five times per second. Wait for the
806
+ # actual session state so MCP callers never receive a fictional mode.
807
+ state_check = (
808
+ self._desktop_ready_check()
809
+ if enabled
810
+ else (
811
+ "! ps -C xfce4-session -o stat= 2>/dev/null | grep -qv '^[[:space:]]*Z'"
812
+ )
813
+ )
814
+ for _ in range(75):
815
+ returncode, _, _ = await self._run_docker(
816
+ "exec",
817
+ sandbox._container_id,
818
+ "sh",
819
+ "-c",
820
+ state_check,
821
+ check=False,
822
+ timeout=5,
823
+ )
824
+ if returncode == 0:
825
+ sandbox._desktop_environment = enabled
826
+ return sandbox
827
+ await asyncio.sleep(0.2)
828
+ raise BackendError(
829
+ f"Xfce did not {'start' if enabled else 'stop'} within 15 seconds. "
830
+ "The computer itself is still running and was not replaced."
831
+ )
832
+
833
+ async def attach_sandbox(self, computer_id: str) -> "DockerSandbox | None":
834
+ """Attach to a named container, starting it first when necessary."""
835
+ await self.validate()
836
+ data = await self._inspect_computer(computer_id)
837
+ if data is None:
838
+ return None
839
+ state = data.get("State", {})
840
+ state = cast("dict[str, object]", state) if isinstance(state, dict) else {}
841
+ running = bool(state.get("Running"))
842
+ if not running:
843
+ await self._run_docker("start", str(data.get("Id", "")), timeout=30)
844
+ data = await self._inspect_computer(computer_id)
845
+ if data is None: # pragma: no cover - provider race
846
+ raise BackendError(
847
+ f"Computer '{computer_id}' disappeared while it was starting."
848
+ )
849
+ sandbox = self._sandbox_from_inspect(data)
850
+ await sandbox._verify_readiness()
851
+ await self._read_desktop_mode(sandbox)
852
+ if sandbox.desktop_environment != self._config.desktop_environment:
853
+ await self._set_desktop_mode(
854
+ sandbox,
855
+ self._config.desktop_environment,
856
+ )
857
+ await self._set_network_access_on_sandbox(
858
+ sandbox,
859
+ self._config.network_enabled,
860
+ )
861
+ return sandbox
862
+
863
+ async def list_computers(self) -> list[ComputerInfo]:
864
+ """List all Docker computers owned by this server."""
865
+ await self.validate()
866
+ _, stdout, _ = await self._run_docker(
867
+ "ps",
868
+ "-aq",
869
+ "--filter",
870
+ f"label={COMPUTER_ID_LABEL}",
871
+ timeout=10,
872
+ )
873
+ container_ids = stdout.decode("utf-8").split()
874
+ if not container_ids:
875
+ return []
876
+ _, inspect_stdout, _ = await self._run_docker(
877
+ "container",
878
+ "inspect",
879
+ *container_ids,
880
+ timeout=15,
881
+ )
882
+ try:
883
+ rows = json.loads(inspect_stdout.decode("utf-8"))
884
+ except json.JSONDecodeError:
885
+ raise BackendError("Docker returned invalid computer inventory data.")
886
+
887
+ computers: list[ComputerInfo] = []
888
+ for data in rows if isinstance(rows, list) else []:
889
+ if not isinstance(data, dict):
890
+ continue
891
+ config = data.get("Config", {})
892
+ labels = config.get("Labels", {}) if isinstance(config, dict) else {}
893
+ if not isinstance(labels, dict):
894
+ continue
895
+ computer_id = labels.get(COMPUTER_ID_LABEL)
896
+ if not isinstance(computer_id, str) or not computer_id:
897
+ continue
898
+ state_data = data.get("State", {})
899
+ state = (
900
+ str(state_data.get("Status", "unknown"))
901
+ if isinstance(state_data, dict)
902
+ else "unknown"
903
+ )
904
+ computers.append(
905
+ ComputerInfo(
906
+ computer_id=computer_id,
907
+ sandbox_id=str(data.get("Id", ""))[:12],
908
+ backend="docker",
909
+ state=state,
910
+ temporary=(
911
+ str(labels.get(TEMPORARY_LABEL, "true")).lower() == "true"
912
+ ),
913
+ image=str(labels.get(IMAGE_LABEL) or self._config.image),
914
+ created_at=(
915
+ str(data.get("Created")) if data.get("Created") else None
916
+ ),
917
+ )
918
+ )
919
+ return computers
920
+
921
+ async def restart_computer(self, computer_id: str) -> "DockerSandbox | None":
922
+ """Restart a Docker computer without replacing its writable layer."""
923
+ data = await self._inspect_computer(computer_id)
924
+ if data is None:
925
+ return None
926
+ await self._run_docker("restart", "-t", "5", str(data.get("Id", "")))
927
+ refreshed = await self._inspect_computer(computer_id)
928
+ if refreshed is None: # pragma: no cover - provider race
929
+ raise BackendError(f"Computer '{computer_id}' disappeared after restart.")
930
+ sandbox = self._sandbox_from_inspect(refreshed)
931
+ await sandbox._verify_readiness()
932
+ await self._read_desktop_mode(sandbox)
933
+ return sandbox
934
+
935
+ async def delete_computer(self, computer_id: str) -> bool:
936
+ """Force-remove a Docker computer and all writable state."""
937
+ data = await self._inspect_computer(computer_id)
938
+ if data is None:
939
+ return False
940
+ await self._run_docker("rm", "-f", str(data.get("Id", "")), timeout=20)
941
+ return True
942
+
943
+ async def factory_reset_computer(self, computer_id: str) -> "DockerSandbox | None":
944
+ """Recreate a named computer from its original image."""
945
+ data = await self._inspect_computer(computer_id)
946
+ if data is None:
947
+ return None
948
+ config = data.get("Config", {})
949
+ config = cast("dict[str, object]", config) if isinstance(config, dict) else {}
950
+ labels = config.get("Labels", {})
951
+ labels = cast("dict[str, object]", labels) if isinstance(labels, dict) else {}
952
+ temporary = str(labels.get(TEMPORARY_LABEL, "true")).lower() == "true"
953
+ current = self._sandbox_from_inspect(data)
954
+ await self._read_desktop_mode(current)
955
+ await self.delete_computer(computer_id)
956
+ return await self._create_sandbox(
957
+ computer_id=computer_id,
958
+ temporary=temporary,
959
+ desktop_environment=current.desktop_environment,
960
+ network_access=current.network_access,
961
+ image=current.image,
962
+ )
963
+
964
+ async def _set_desktop_firewall(
965
+ self,
966
+ sandbox: "DockerSandbox",
967
+ enabled: bool,
968
+ ) -> None:
969
+ """Apply the desktop image's outbound-only firewall."""
970
+ if enabled:
971
+ script = (
972
+ "command -v iptables >/dev/null 2>&1 || exit 0; "
973
+ "iptables -D OUTPUT -j MCP_NO_NETWORK 2>/dev/null || true; "
974
+ "iptables -F MCP_NO_NETWORK 2>/dev/null || true; "
975
+ "iptables -X MCP_NO_NETWORK 2>/dev/null || true; "
976
+ "rm -f /run/mcp-network-disabled"
977
+ )
978
+ else:
979
+ script = (
980
+ "command -v iptables >/dev/null 2>&1 || { "
981
+ "echo 'desktop image has no iptables support' >&2; exit 45; }; "
982
+ "iptables -N MCP_NO_NETWORK 2>/dev/null || true; "
983
+ "iptables -F MCP_NO_NETWORK; "
984
+ "iptables -A MCP_NO_NETWORK -o lo -j ACCEPT; "
985
+ "iptables -A MCP_NO_NETWORK -m conntrack "
986
+ "--ctstate ESTABLISHED,RELATED -j ACCEPT; "
987
+ "iptables -A MCP_NO_NETWORK -j REJECT; "
988
+ "iptables -C OUTPUT -j MCP_NO_NETWORK 2>/dev/null || "
989
+ "iptables -I OUTPUT 1 -j MCP_NO_NETWORK; "
990
+ "touch /run/mcp-network-disabled"
991
+ )
992
+ try:
993
+ await self._run_docker(
994
+ "exec",
995
+ "-u",
996
+ "0",
997
+ sandbox._container_id,
998
+ "sh",
999
+ "-c",
1000
+ script,
1001
+ timeout=15,
1002
+ )
1003
+ except BackendError as error:
1004
+ raise BackendError(
1005
+ "Could not change desktop network access. Rebuild the bundled "
1006
+ f"desktop image and retry. Docker reported: {error}"
1007
+ )
1008
+
1009
+ async def _set_headless_network(
1010
+ self,
1011
+ sandbox: "DockerSandbox",
1012
+ enabled: bool,
1013
+ ) -> None:
1014
+ """Attach a headless computer to bridge or Docker's none network."""
1015
+ data = await self._inspect_computer(sandbox.computer_id)
1016
+ if data is None:
1017
+ raise BackendError(f"Computer '{sandbox.computer_id}' was not found.")
1018
+ network_data = data.get("NetworkSettings", {})
1019
+ network_data = (
1020
+ cast("dict[str, object]", network_data)
1021
+ if isinstance(network_data, dict)
1022
+ else {}
1023
+ )
1024
+ raw_networks = network_data.get("Networks", {})
1025
+ networks = (
1026
+ list(cast("dict[str, object]", raw_networks))
1027
+ if isinstance(raw_networks, dict)
1028
+ else []
1029
+ )
1030
+ if not networks:
1031
+ # A real Docker inspect always includes a network entry. Keeping an
1032
+ # empty synthetic inspect unchanged makes provider test doubles and
1033
+ # third-party Docker-compatible engines safe to attach.
1034
+ return
1035
+ container_id = sandbox._container_id
1036
+ if enabled:
1037
+ if any(name != "none" for name in networks):
1038
+ return
1039
+ if "none" in networks:
1040
+ await self._run_docker("network", "disconnect", "none", container_id)
1041
+ try:
1042
+ await self._run_docker("network", "connect", "bridge", container_id)
1043
+ except Exception:
1044
+ await self._run_docker(
1045
+ "network", "connect", "none", container_id, check=False
1046
+ )
1047
+ raise
1048
+ return
1049
+
1050
+ if networks == ["none"]:
1051
+ return
1052
+ detached: list[str] = []
1053
+ try:
1054
+ for network in networks:
1055
+ if network == "none":
1056
+ continue
1057
+ await self._run_docker("network", "disconnect", network, container_id)
1058
+ detached.append(network)
1059
+ await self._run_docker("network", "connect", "none", container_id)
1060
+ except Exception:
1061
+ for network in detached:
1062
+ await self._run_docker(
1063
+ "network", "connect", network, container_id, check=False
1064
+ )
1065
+ raise
1066
+
1067
+ async def _set_network_access_on_sandbox(
1068
+ self,
1069
+ sandbox: "DockerSandbox",
1070
+ enabled: bool,
1071
+ ) -> "DockerSandbox":
1072
+ if sandbox._desktop_host_port is not None:
1073
+ await self._set_desktop_firewall(sandbox, enabled)
1074
+ else:
1075
+ await self._set_headless_network(sandbox, enabled)
1076
+ sandbox._network_access = enabled
1077
+ return sandbox
1078
+
1079
+ async def set_network_access(
1080
+ self,
1081
+ computer_id: str,
1082
+ enabled: bool,
1083
+ ) -> "DockerSandbox | None":
1084
+ """Really enable or isolate outbound traffic for a live computer."""
1085
+ data = await self._inspect_computer(computer_id)
1086
+ if data is None:
1087
+ return None
1088
+ sandbox = self._sandbox_from_inspect(data)
1089
+ await sandbox._verify_readiness()
1090
+ await self._read_desktop_mode(sandbox)
1091
+ return await self._set_network_access_on_sandbox(sandbox, enabled)
1092
+
1093
+ async def switch_desktop_environment(
1094
+ self,
1095
+ computer_id: str,
1096
+ enabled: bool,
1097
+ *,
1098
+ network_access: bool | None = None,
1099
+ ) -> "DockerSandbox | None":
1100
+ """Start or stop Xfce without replacing the Docker computer."""
1101
+ data = await self._inspect_computer(computer_id)
1102
+ if data is None:
1103
+ return None
1104
+ current = self._sandbox_from_inspect(data)
1105
+ await current._verify_readiness()
1106
+ await self._read_desktop_mode(current)
1107
+ network_access = (
1108
+ current.network_access if network_access is None else network_access
1109
+ )
1110
+ if current.desktop_environment != enabled:
1111
+ await self._set_desktop_mode(current, enabled)
1112
+ return await self._set_network_access_on_sandbox(current, network_access)
1113
+
1114
+ def tool_instructions(self) -> str | None:
1115
+ """Return tool description for Docker backend.
1116
+
1117
+ Returns None if using a custom image (baked-in description only
1118
+ applies to default Debian image).
1119
+ """
1120
+ if self._config.image not in {DEFAULT_IMAGE, DEFAULT_DESKTOP_IMAGE}:
1121
+ return None
1122
+
1123
+ shell_name = self._config.shell.rsplit("/", 1)[-1] # basename
1124
+ timeout = self._config.default_timeout
1125
+
1126
+ return (
1127
+ f"Execute a shell command in one persistent Debian Docker computer. "
1128
+ f"Commands run in {shell_name}. Each call is independent — "
1129
+ f"no state (shell variables, working directory) persists between calls (however filesystem does persist). Use the working_directory "
1130
+ f"parameter or chain commands with && to control execution context. "
1131
+ f"\n\n"
1132
+ f"To write files or pass data without shell escaping, use the "
1133
+ f'stdin parameter (e.g., command="cat > file.txt" with content '
1134
+ f"in stdin). Commands time out after {timeout} seconds by default "
1135
+ f"(override with the timeout parameter for long-running operations)."
1136
+ )
1137
+
1138
+
1139
+ class DockerSandbox(Sandbox):
1140
+ """Docker sandbox implementation.
1141
+
1142
+ Wraps a running Docker container. Handles command execution with
1143
+ timeout and output-limit enforcement, stop, and death detection.
1144
+ """
1145
+
1146
+ def __init__(self, state: _DockerSandboxState) -> None:
1147
+ self._engine = state.engine
1148
+ self._host = state.host
1149
+ self._shell = state.shell
1150
+ self._container_id = state.container_id
1151
+ self._computer_id = state.computer_id or state.container_id[:12]
1152
+ self._temporary = state.temporary
1153
+ self._image = state.image
1154
+ self._desktop_environment = state.desktop_environment
1155
+ self._network_access = state.network_access
1156
+ self._workspace_directory = state.workspace_directory
1157
+ self._desktop_host_port = state.desktop_host_port
1158
+ self._stopped = False
1159
+ self._stop_requested = False
1160
+ self._exec_lock = asyncio.Lock()
1161
+
1162
+ @property
1163
+ def _engine_prefix(self) -> list[str]:
1164
+ """Return the base engine command, including -H if a host is configured."""
1165
+ prefix = [self._engine]
1166
+ if self._host is not None:
1167
+ prefix.extend(["-H", self._host])
1168
+ return prefix
1169
+
1170
+ @property
1171
+ def sandbox_id(self) -> str:
1172
+ """Return the short form (first 12 chars) of the container ID."""
1173
+ return self._container_id[:12]
1174
+
1175
+ @property
1176
+ def computer_id(self) -> str:
1177
+ """Return the stable computer slug rather than the container hash."""
1178
+ return self._computer_id
1179
+
1180
+ @property
1181
+ def temporary(self) -> bool:
1182
+ """Return the provider-side cleanup mode recorded in Docker labels."""
1183
+ return self._temporary
1184
+
1185
+ @property
1186
+ def desktop_url(self) -> str | None:
1187
+ if not self._desktop_environment or self._desktop_host_port is None:
1188
+ return None
1189
+ return f"ws://127.0.0.1:{self._desktop_host_port}/websockify"
1190
+
1191
+ @property
1192
+ def desktop_environment(self) -> bool:
1193
+ return self._desktop_environment
1194
+
1195
+ @property
1196
+ def network_access(self) -> bool:
1197
+ return self._network_access
1198
+
1199
+ @property
1200
+ def image(self) -> str | None:
1201
+ return self._image
1202
+
1203
+ async def _run_docker(
1204
+ self,
1205
+ *args: str,
1206
+ stdin_data: bytes | None = None,
1207
+ check: bool = True,
1208
+ timeout: float = 30,
1209
+ ) -> tuple[int, bytes, bytes]:
1210
+ """Run a Docker CLI command and return (returncode, stdout, stderr).
1211
+
1212
+ Shared helper method for Docker CLI calls within the sandbox.
1213
+ """
1214
+ cmd = [*self._engine_prefix, *args]
1215
+ proc = await asyncio.create_subprocess_exec(
1216
+ *cmd,
1217
+ stdin=asyncio.subprocess.PIPE if stdin_data else asyncio.subprocess.DEVNULL,
1218
+ stdout=asyncio.subprocess.PIPE,
1219
+ stderr=asyncio.subprocess.PIPE,
1220
+ )
1221
+ try:
1222
+ stdout, stderr = await asyncio.wait_for(
1223
+ proc.communicate(stdin_data),
1224
+ timeout=timeout,
1225
+ )
1226
+ except asyncio.TimeoutError:
1227
+ proc.kill()
1228
+ await proc.wait()
1229
+ raise BackendError(
1230
+ f"Docker command timed out after {timeout}s: {' '.join(cmd)}"
1231
+ )
1232
+
1233
+ if check and proc.returncode is not None and proc.returncode != 0:
1234
+ stderr_text = stderr.decode("utf-8", errors="replace").strip()
1235
+ raise BackendError(
1236
+ f"Docker command failed (exit {proc.returncode}): "
1237
+ f"{' '.join(cmd)}\n{stderr_text}"
1238
+ )
1239
+ assert proc.returncode is not None
1240
+ return proc.returncode, stdout, stderr
1241
+
1242
+ async def _verify_readiness(self) -> None:
1243
+ """Verify the sandbox accepts exec calls and the shell works."""
1244
+ _, stdout, _ = await self._run_docker(
1245
+ "exec",
1246
+ self._container_id,
1247
+ self._shell,
1248
+ "-c",
1249
+ "echo kilntainers-ready",
1250
+ timeout=15,
1251
+ )
1252
+ if b"kilntainers-ready" not in stdout:
1253
+ raise BackendError(
1254
+ f"Container {self.sandbox_id} started but "
1255
+ f"readiness check failed (unexpected output)"
1256
+ )
1257
+
1258
+ def _build_exec_command(self, request: ExecRequest) -> list[str]:
1259
+ """Build the docker exec argument list."""
1260
+ cmd = [*self._engine_prefix, "exec"]
1261
+
1262
+ # -i keeps stdin open (needed when piping stdin data)
1263
+ if request.stdin is not None:
1264
+ cmd.append("-i")
1265
+
1266
+ # -w sets the working directory inside the container
1267
+ if request.working_directory is not None:
1268
+ cmd.extend(["-w", request.working_directory])
1269
+
1270
+ cmd.append(self._container_id)
1271
+
1272
+ if request.command is not None:
1273
+ # Command mode: wrap in shell
1274
+ cmd.extend([self._shell, "-c", request.command])
1275
+ else:
1276
+ # Args mode: pass directly, no shell
1277
+ assert request.args is not None # guaranteed by ExecRequest validation
1278
+ cmd.extend(request.args)
1279
+
1280
+ return cmd
1281
+
1282
+ async def _communicate_with_limit(
1283
+ self,
1284
+ proc: asyncio.subprocess.Process,
1285
+ stdin_data: bytes | None,
1286
+ output_limit: int,
1287
+ ) -> tuple[bytes, bytes, int]:
1288
+ """Read process output with combined size enforcement.
1289
+
1290
+ Similar to proc.communicate() but monitors combined stdout+stderr
1291
+ byte count and raises _OutputLimitExceeded if the limit is breached.
1292
+
1293
+ Returns (stdout_bytes, stderr_bytes, returncode).
1294
+ """
1295
+ # Write stdin if provided
1296
+ if stdin_data is not None and proc.stdin is not None:
1297
+ proc.stdin.write(stdin_data)
1298
+ await proc.stdin.drain()
1299
+ proc.stdin.close()
1300
+
1301
+ total_bytes = 0
1302
+ stdout_chunks: list[bytes] = []
1303
+ stderr_chunks: list[bytes] = []
1304
+
1305
+ async def read_stream(
1306
+ stream: asyncio.StreamReader | None, chunks: list[bytes]
1307
+ ) -> None:
1308
+ nonlocal total_bytes
1309
+ if stream is None:
1310
+ return
1311
+ while True:
1312
+ chunk = await stream.read(8192)
1313
+ if not chunk:
1314
+ break # EOF
1315
+ total_bytes += len(chunk)
1316
+ if total_bytes > output_limit:
1317
+ raise _OutputLimitExceeded()
1318
+ chunks.append(chunk)
1319
+
1320
+ # Read both streams concurrently. TaskGroup cancels the other
1321
+ # reader if one raises _OutputLimitExceeded.
1322
+ try:
1323
+ async with asyncio.TaskGroup() as tg:
1324
+ tg.create_task(read_stream(proc.stdout, stdout_chunks))
1325
+ tg.create_task(read_stream(proc.stderr, stderr_chunks))
1326
+ except* _OutputLimitExceeded:
1327
+ # Re-raise to signal output limit exceeded
1328
+ raise
1329
+
1330
+ await proc.wait()
1331
+
1332
+ assert proc.returncode is not None
1333
+ return (
1334
+ b"".join(stdout_chunks),
1335
+ b"".join(stderr_chunks),
1336
+ proc.returncode,
1337
+ )
1338
+
1339
+ async def _kill_subprocess(self, proc: asyncio.subprocess.Process) -> None:
1340
+ """Kill a subprocess and wait for it to exit."""
1341
+ try:
1342
+ proc.kill() # SIGKILL
1343
+ except ProcessLookupError:
1344
+ pass # Already exited
1345
+ await proc.wait()
1346
+
1347
+ async def _is_container_running(self) -> bool:
1348
+ """Check if the container is still running."""
1349
+ returncode, stdout, _ = await self._run_docker(
1350
+ "inspect",
1351
+ "--format",
1352
+ "{{.State.Running}}",
1353
+ self._container_id,
1354
+ check=False,
1355
+ timeout=5,
1356
+ )
1357
+ if returncode != 0:
1358
+ return False
1359
+ return b"true" in stdout
1360
+
1361
+ async def _do_exec(self, request: ExecRequest) -> ExecResult:
1362
+ """Core exec implementation."""
1363
+ cmd = self._build_exec_command(request)
1364
+ stdin_data = request.stdin.encode("utf-8") if request.stdin else None
1365
+
1366
+ start_time = time.monotonic()
1367
+
1368
+ proc = await asyncio.create_subprocess_exec(
1369
+ *cmd,
1370
+ stdin=asyncio.subprocess.PIPE if stdin_data else asyncio.subprocess.DEVNULL,
1371
+ stdout=asyncio.subprocess.PIPE,
1372
+ stderr=asyncio.subprocess.PIPE,
1373
+ )
1374
+
1375
+ try:
1376
+ stdout_bytes, stderr_bytes, returncode = await asyncio.wait_for(
1377
+ self._communicate_with_limit(proc, stdin_data, request.output_limit),
1378
+ timeout=request.timeout,
1379
+ )
1380
+ elapsed_ms = int((time.monotonic() - start_time) * 1000)
1381
+
1382
+ # Check if container died during exec
1383
+ if returncode != 0 and not await self._is_container_running():
1384
+ if not self._stop_requested:
1385
+ raise SandboxDiedError(
1386
+ f"Sandbox {self.sandbox_id} died during command execution"
1387
+ )
1388
+
1389
+ return ExecResult(
1390
+ stdout=stdout_bytes.decode("utf-8", errors="replace"),
1391
+ stderr=stderr_bytes.decode("utf-8", errors="replace"),
1392
+ exit_code=returncode,
1393
+ exec_duration_ms=elapsed_ms,
1394
+ )
1395
+
1396
+ except asyncio.TimeoutError:
1397
+ await self._kill_subprocess(proc)
1398
+ elapsed_ms = int((time.monotonic() - start_time) * 1000)
1399
+ return ExecResult(
1400
+ stdout="",
1401
+ stderr=f"[kilntainers: command timed out after {request.timeout}s]",
1402
+ exit_code=124,
1403
+ exec_duration_ms=elapsed_ms,
1404
+ )
1405
+ except ExceptionGroup as eg:
1406
+ # Check if the ExceptionGroup contains _OutputLimitExceeded
1407
+ for exc in eg.exceptions:
1408
+ if isinstance(exc, _OutputLimitExceeded):
1409
+ await self._kill_subprocess(proc)
1410
+ elapsed_ms = int((time.monotonic() - start_time) * 1000)
1411
+ return ExecResult(
1412
+ stdout="",
1413
+ stderr=(
1414
+ f"[kilntainers: output limit exceeded "
1415
+ f"({request.output_limit} bytes). Command terminated. "
1416
+ f"No output returned. Re-run with head, tail, or grep "
1417
+ f"to manage output size.]"
1418
+ ),
1419
+ exit_code=1,
1420
+ exec_duration_ms=elapsed_ms,
1421
+ )
1422
+ # If not, re-raise the ExceptionGroup
1423
+ raise
1424
+
1425
+ async def exec(self, request: ExecRequest) -> ExecResult:
1426
+ """Execute a command in the sandbox.
1427
+
1428
+ Uses a lock to serialize exec calls within this sandbox.
1429
+ """
1430
+ if self._stopped:
1431
+ raise SandboxDiedError("Sandbox has been stopped")
1432
+
1433
+ async with self._exec_lock:
1434
+ return await self._do_exec(request)
1435
+
1436
+ async def stop(self) -> None:
1437
+ """Stop the sandbox and release all resources.
1438
+
1439
+ Idempotent — safe to call on an already-stopped sandbox.
1440
+ """
1441
+ if self._stopped:
1442
+ return
1443
+ self._stopped = True
1444
+ self._stop_requested = True
1445
+
1446
+ try:
1447
+ # docker stop sends SIGTERM, waits grace period, then SIGKILL
1448
+ proc = await asyncio.create_subprocess_exec(
1449
+ *self._engine_prefix,
1450
+ "stop",
1451
+ "-t",
1452
+ "5",
1453
+ self._container_id,
1454
+ stdout=asyncio.subprocess.DEVNULL,
1455
+ stderr=asyncio.subprocess.DEVNULL,
1456
+ )
1457
+ try:
1458
+ # 5s Docker grace + 5s buffer for Docker overhead
1459
+ await asyncio.wait_for(proc.wait(), timeout=10)
1460
+ except asyncio.TimeoutError:
1461
+ proc.kill()
1462
+ await proc.wait()
1463
+ except Exception:
1464
+ # Best-effort cleanup — don't propagate errors from stop
1465
+ pass
1466
+
1467
+ async def wait_for_death(self) -> None:
1468
+ """Block until the sandbox dies unexpectedly.
1469
+
1470
+ Returns when the container exits for reasons other than stop()
1471
+ being called. Does not return when stop() is called — in that
1472
+ case, blocks until cancelled by the MCP layer.
1473
+ """
1474
+ proc = await asyncio.create_subprocess_exec(
1475
+ *self._engine_prefix,
1476
+ "wait",
1477
+ self._container_id,
1478
+ stdout=asyncio.subprocess.DEVNULL,
1479
+ stderr=asyncio.subprocess.DEVNULL,
1480
+ )
1481
+ try:
1482
+ await proc.wait()
1483
+ except asyncio.CancelledError:
1484
+ # Normal shutdown — MCP layer cancelled this task before stop()
1485
+ proc.kill()
1486
+ await proc.wait()
1487
+ raise
1488
+
1489
+ if self._stop_requested:
1490
+ # Container exited because stop() was called — this is expected.
1491
+ # Block forever; the MCP layer will cancel this task.
1492
+ try:
1493
+ await asyncio.Future() # never completes
1494
+ except asyncio.CancelledError:
1495
+ return
1496
+
1497
+ # Container exited unexpectedly (OOM, external kill, daemon crash).
1498
+ # Returning signals death to the MCP layer.