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.
- kilntainers/__init__.py +3 -0
- kilntainers/__main__.py +6 -0
- kilntainers/auth.py +34 -0
- kilntainers/backends/__init__.py +17 -0
- kilntainers/backends/base.py +380 -0
- kilntainers/backends/docker.py +1498 -0
- kilntainers/backends/test___init__.py +37 -0
- kilntainers/backends/test_base.py +355 -0
- kilntainers/backends/test_docker.py +841 -0
- kilntainers/backends/test_docker_integration.py +310 -0
- kilntainers/backends/test_docker_management.py +122 -0
- kilntainers/backends/test_utils.py +152 -0
- kilntainers/backends/test_virtual_docker.py +387 -0
- kilntainers/cli.py +375 -0
- kilntainers/computers.py +284 -0
- kilntainers/config.py +71 -0
- kilntainers/dashboard.html +4263 -0
- kilntainers/dashboard.py +23 -0
- kilntainers/desktop.py +47 -0
- kilntainers/desktop_control.py +105 -0
- kilntainers/desktop_image/Dockerfile +67 -0
- kilntainers/desktop_image/desktop-control.py +661 -0
- kilntainers/desktop_image/start-desktop.sh +206 -0
- kilntainers/desktop_image/visual-action.py +185 -0
- kilntainers/desktop_image/wsproxy.py +218 -0
- kilntainers/errors.py +36 -0
- kilntainers/file_tools.py +320 -0
- kilntainers/server.py +1760 -0
- kilntainers/test_cli.py +704 -0
- kilntainers/test_cli_integration.py +213 -0
- kilntainers/test_computers.py +122 -0
- kilntainers/test_config.py +135 -0
- kilntainers/test_dashboard.py +113 -0
- kilntainers/test_desktop_control.py +109 -0
- kilntainers/test_e2e_mcp.py +310 -0
- kilntainers/test_errors.py +62 -0
- kilntainers/test_file_tools.py +175 -0
- kilntainers/test_http_lifecycle.py +353 -0
- kilntainers/test_lifecycle_integration.py +389 -0
- kilntainers/test_server.py +849 -0
- kilntainers/test_windows_docker.py +300 -0
- kilntainers/windows_docker.py +565 -0
- mcp_virtual_computer-0.2.3.dist-info/METADATA +179 -0
- mcp_virtual_computer-0.2.3.dist-info/RECORD +48 -0
- mcp_virtual_computer-0.2.3.dist-info/WHEEL +5 -0
- mcp_virtual_computer-0.2.3.dist-info/entry_points.txt +5 -0
- mcp_virtual_computer-0.2.3.dist-info/licenses/LICENSE +7 -0
- 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.
|