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,310 @@
|
|
|
1
|
+
"""Integration tests for Docker backend.
|
|
2
|
+
|
|
3
|
+
These tests require a running Docker or Podman daemon and are marked with
|
|
4
|
+
@pytest.mark.integration. They execute real containers and verify
|
|
5
|
+
the full integration. They are parameterized by engine ("docker", "podman").
|
|
6
|
+
|
|
7
|
+
Run with: pytest -m integration
|
|
8
|
+
Skip with: pytest -m "not integration"
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from kilntainers.backends.base import ExecRequest
|
|
18
|
+
from kilntainers.backends.docker import (
|
|
19
|
+
DockerBackend,
|
|
20
|
+
DockerBackendConfig,
|
|
21
|
+
DockerSandbox,
|
|
22
|
+
)
|
|
23
|
+
from kilntainers.errors import BackendError, SandboxDiedError
|
|
24
|
+
|
|
25
|
+
# Cache to track if a docker-like CLI is available and the daemon is running.
|
|
26
|
+
_engine_available_cache: dict[str, bool] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_engine_available(engine: str) -> None:
|
|
30
|
+
"""Validate that the engine CLI is installed and the daemon is running, skipping the test if not."""
|
|
31
|
+
available = _engine_available_cache.get(engine, None)
|
|
32
|
+
|
|
33
|
+
if available is None:
|
|
34
|
+
available = _check_engine_available(engine)
|
|
35
|
+
_engine_available_cache[engine] = available
|
|
36
|
+
|
|
37
|
+
if not available:
|
|
38
|
+
pytest.skip(f"{engine} CLI not installed")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _check_engine_available(engine: str) -> bool:
|
|
42
|
+
"""Check if the engine CLI is installed and the daemon is running."""
|
|
43
|
+
command_available = shutil.which(engine) is not None
|
|
44
|
+
if not command_available:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run([engine, "info"], capture_output=True, text=True)
|
|
48
|
+
return result.returncode == 0
|
|
49
|
+
except subprocess.CalledProcessError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def get_docker_backend(engine: str) -> DockerBackend:
|
|
54
|
+
"""Create and validate a Docker/Podman backend.
|
|
55
|
+
|
|
56
|
+
Skips the test if the engine CLI is not installed or the daemon is not running.
|
|
57
|
+
"""
|
|
58
|
+
validate_engine_available(engine)
|
|
59
|
+
|
|
60
|
+
config = DockerBackendConfig(engine=engine)
|
|
61
|
+
backend = DockerBackend(config)
|
|
62
|
+
try:
|
|
63
|
+
await backend.validate()
|
|
64
|
+
except BackendError as e:
|
|
65
|
+
if f"Is the {engine} daemon running?" in str(e):
|
|
66
|
+
pytest.skip(f"{engine} daemon not running")
|
|
67
|
+
raise
|
|
68
|
+
return backend
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.fixture(params=["docker", "podman"])
|
|
72
|
+
async def engine(request):
|
|
73
|
+
"""Parameterize tests over container engine (docker, podman).
|
|
74
|
+
|
|
75
|
+
Automatically skips if the engine CLI is not installed on the system.
|
|
76
|
+
"""
|
|
77
|
+
engine = request.param
|
|
78
|
+
validate_engine_available(engine)
|
|
79
|
+
return engine
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
async def docker_backend(engine):
|
|
84
|
+
"""Create a real Docker/Podman backend instance for the given engine.
|
|
85
|
+
|
|
86
|
+
Automatically skips if the engine daemon is not running.
|
|
87
|
+
"""
|
|
88
|
+
return await get_docker_backend(engine)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture
|
|
92
|
+
async def sandbox(docker_backend):
|
|
93
|
+
"""Create a real Docker sandbox for testing.
|
|
94
|
+
|
|
95
|
+
The sandbox is automatically stopped after the test.
|
|
96
|
+
"""
|
|
97
|
+
sb = await docker_backend.create_sandbox()
|
|
98
|
+
yield sb
|
|
99
|
+
# Cleanup: stop the sandbox
|
|
100
|
+
await sb.stop()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- Connection Tests ---
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.integration
|
|
107
|
+
class TestConnection:
|
|
108
|
+
"""Tests for Docker host connection configuration."""
|
|
109
|
+
|
|
110
|
+
@pytest.mark.asyncio
|
|
111
|
+
async def test_bad_host_fails_validation(self, engine):
|
|
112
|
+
"""A bad --docker-host value causes validation to fail with BackendError."""
|
|
113
|
+
config = DockerBackendConfig(
|
|
114
|
+
engine=engine,
|
|
115
|
+
host="tcp://127.0.0.1:1", # Connection refused is faster than timeout
|
|
116
|
+
)
|
|
117
|
+
backend = DockerBackend(config)
|
|
118
|
+
|
|
119
|
+
with pytest.raises(BackendError) as exc_info:
|
|
120
|
+
await asyncio.wait_for(backend.validate(), timeout=15)
|
|
121
|
+
|
|
122
|
+
assert "Cannot connect to" in str(exc_info.value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# --- Docker Specific Lifecycle Tests ---
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.integration
|
|
129
|
+
class TestDockerLifecycle:
|
|
130
|
+
"""Tests for Docker-specific sandbox lifecycle operations."""
|
|
131
|
+
|
|
132
|
+
@pytest.mark.asyncio
|
|
133
|
+
async def test_create_sandbox_type(self, docker_backend):
|
|
134
|
+
"""Sandbox creation returns correct type and ID format."""
|
|
135
|
+
sb = await docker_backend.create_sandbox()
|
|
136
|
+
assert isinstance(sb, DockerSandbox)
|
|
137
|
+
assert len(sb.sandbox_id) == 12
|
|
138
|
+
await sb.stop()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- Timeout Tests ---
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.integration
|
|
145
|
+
class TestTimeout:
|
|
146
|
+
"""Tests for timeout enforcement."""
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_timeout_exceeded(self, sandbox):
|
|
150
|
+
"""Long-running command is terminated after timeout."""
|
|
151
|
+
request = ExecRequest(command="sleep 60", timeout=1, output_limit=1024)
|
|
152
|
+
result = await sandbox.exec(request)
|
|
153
|
+
assert result.exit_code == 124
|
|
154
|
+
assert "timed out" in result.stderr
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- Network Isolation Tests ---
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.integration
|
|
161
|
+
class TestNetworkIsolation:
|
|
162
|
+
"""Tests for network isolation behavior."""
|
|
163
|
+
|
|
164
|
+
@pytest.mark.asyncio
|
|
165
|
+
async def test_network_disabled_by_default(self, sandbox):
|
|
166
|
+
"""Network access is blocked by default."""
|
|
167
|
+
request = ExecRequest(
|
|
168
|
+
command="curl -s --connect-timeout 2 http://example.com",
|
|
169
|
+
timeout=5,
|
|
170
|
+
output_limit=1024,
|
|
171
|
+
)
|
|
172
|
+
result = await sandbox.exec(request)
|
|
173
|
+
# Should fail because network is disabled
|
|
174
|
+
assert result.exit_code != 0
|
|
175
|
+
|
|
176
|
+
@pytest.mark.asyncio
|
|
177
|
+
async def test_network_enabled_works(self, docker_backend):
|
|
178
|
+
"""Network access works when enabled."""
|
|
179
|
+
config = DockerBackendConfig(network_enabled=True)
|
|
180
|
+
backend = DockerBackend(config)
|
|
181
|
+
sb = await backend.create_sandbox()
|
|
182
|
+
try:
|
|
183
|
+
request = ExecRequest(
|
|
184
|
+
command="curl -s --connect-timeout 2 http://example.com",
|
|
185
|
+
timeout=5,
|
|
186
|
+
output_limit=1024,
|
|
187
|
+
)
|
|
188
|
+
result = await sb.exec(request)
|
|
189
|
+
# Should succeed (or at least reach the server)
|
|
190
|
+
# We just check it doesn't fail immediately
|
|
191
|
+
assert result.exit_code == 0 or "curl" in result.stderr
|
|
192
|
+
finally:
|
|
193
|
+
await sb.stop()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --- Death Detection Tests ---
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@pytest.mark.integration
|
|
200
|
+
class TestDeathDetection:
|
|
201
|
+
"""Tests for unexpected sandbox death detection."""
|
|
202
|
+
|
|
203
|
+
@pytest.mark.asyncio
|
|
204
|
+
async def test_wait_for_death_on_kill(self, sandbox, engine):
|
|
205
|
+
"""wait_for_death returns when container is killed externally."""
|
|
206
|
+
# Start the death detection task
|
|
207
|
+
death_task = asyncio.create_task(sandbox.wait_for_death())
|
|
208
|
+
|
|
209
|
+
# Give it a moment to start
|
|
210
|
+
await asyncio.sleep(0.1)
|
|
211
|
+
|
|
212
|
+
# Kill the container externally
|
|
213
|
+
subprocess.run(
|
|
214
|
+
[engine, "kill", sandbox._container_id],
|
|
215
|
+
capture_output=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# wait_for_death should complete
|
|
219
|
+
await asyncio.wait_for(death_task, timeout=5)
|
|
220
|
+
|
|
221
|
+
@pytest.mark.asyncio
|
|
222
|
+
async def test_exec_after_container_dies(self, sandbox, engine):
|
|
223
|
+
"""Exec raises SandboxDiedError when container dies during exec."""
|
|
224
|
+
# Kill the container externally
|
|
225
|
+
subprocess.run(
|
|
226
|
+
[engine, "kill", sandbox._container_id],
|
|
227
|
+
capture_output=True,
|
|
228
|
+
)
|
|
229
|
+
await asyncio.sleep(0.1) # Give it time to die
|
|
230
|
+
|
|
231
|
+
# Next exec should detect the death
|
|
232
|
+
request = ExecRequest(command="echo test", timeout=5, output_limit=1024)
|
|
233
|
+
|
|
234
|
+
with pytest.raises(SandboxDiedError) as exc_info:
|
|
235
|
+
await sandbox.exec(request)
|
|
236
|
+
assert "died during command execution" in str(exc_info.value)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- Cleanup Tests ---
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@pytest.mark.integration
|
|
243
|
+
class TestCleanup:
|
|
244
|
+
"""Tests for proper resource cleanup."""
|
|
245
|
+
|
|
246
|
+
@pytest.mark.asyncio
|
|
247
|
+
async def test_container_removed_on_stop(self, sandbox, engine):
|
|
248
|
+
"""Container is removed (--rm flag) when stopped."""
|
|
249
|
+
container_id = sandbox._container_id
|
|
250
|
+
|
|
251
|
+
# Verify container exists
|
|
252
|
+
result = subprocess.run(
|
|
253
|
+
[engine, "inspect", "--format", "{{.State.Running}}", container_id],
|
|
254
|
+
capture_output=True,
|
|
255
|
+
text=True,
|
|
256
|
+
)
|
|
257
|
+
assert result.returncode == 0
|
|
258
|
+
|
|
259
|
+
# Stop the sandbox
|
|
260
|
+
await sandbox.stop()
|
|
261
|
+
|
|
262
|
+
# Container should be removed (not just stopped)
|
|
263
|
+
result = subprocess.run(
|
|
264
|
+
[engine, "inspect", container_id],
|
|
265
|
+
capture_output=True,
|
|
266
|
+
)
|
|
267
|
+
assert result.returncode != 0 # Container not found
|
|
268
|
+
|
|
269
|
+
@pytest.mark.asyncio
|
|
270
|
+
async def test_kilntainers_label_present(self, sandbox, engine):
|
|
271
|
+
"""Container has kilntainers=true label."""
|
|
272
|
+
result = subprocess.run(
|
|
273
|
+
[
|
|
274
|
+
engine,
|
|
275
|
+
"inspect",
|
|
276
|
+
"--format",
|
|
277
|
+
'{{index .Config.Labels "kilntainers"}}',
|
|
278
|
+
sandbox._container_id,
|
|
279
|
+
],
|
|
280
|
+
capture_output=True,
|
|
281
|
+
text=True,
|
|
282
|
+
)
|
|
283
|
+
assert result.returncode == 0
|
|
284
|
+
assert "true" in result.stdout
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# --- Tool Instructions Tests ---
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@pytest.mark.integration
|
|
291
|
+
class TestToolInstructions:
|
|
292
|
+
"""Tests for tool_instructions method."""
|
|
293
|
+
|
|
294
|
+
@pytest.mark.asyncio
|
|
295
|
+
async def test_tool_instructions_default_image(self, docker_backend):
|
|
296
|
+
"""Default image returns tool instructions."""
|
|
297
|
+
instructions = docker_backend.tool_instructions()
|
|
298
|
+
assert instructions is not None
|
|
299
|
+
assert "Debian" in instructions
|
|
300
|
+
assert "bash" in instructions
|
|
301
|
+
assert "120" in instructions # default timeout
|
|
302
|
+
|
|
303
|
+
@pytest.mark.asyncio
|
|
304
|
+
async def test_tool_instructions_custom_image(self, engine, docker_backend):
|
|
305
|
+
"""Custom image returns None for tool instructions."""
|
|
306
|
+
config = DockerBackendConfig(engine=engine, image="alpine:latest")
|
|
307
|
+
backend = DockerBackend(config)
|
|
308
|
+
await backend.validate()
|
|
309
|
+
instructions = backend.tool_instructions()
|
|
310
|
+
assert instructions is None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Docker named-computer management tests."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kilntainers.backends.docker import (
|
|
8
|
+
COMPUTER_ID_LABEL,
|
|
9
|
+
IMAGE_LABEL,
|
|
10
|
+
TEMPORARY_LABEL,
|
|
11
|
+
DockerBackend,
|
|
12
|
+
DockerBackendConfig,
|
|
13
|
+
DockerSandbox,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def inspect_row(
|
|
18
|
+
computer_id: str = "dev-box",
|
|
19
|
+
*,
|
|
20
|
+
temporary: bool = False,
|
|
21
|
+
running: bool = True,
|
|
22
|
+
) -> dict[str, object]:
|
|
23
|
+
return {
|
|
24
|
+
"Id": "a" * 64,
|
|
25
|
+
"Created": "2026-08-12T12:00:00Z",
|
|
26
|
+
"Config": {
|
|
27
|
+
"Labels": {
|
|
28
|
+
COMPUTER_ID_LABEL: computer_id,
|
|
29
|
+
TEMPORARY_LABEL: str(temporary).lower(),
|
|
30
|
+
IMAGE_LABEL: "debian:bookworm-slim",
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"State": {"Running": running, "Status": "running" if running else "exited"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_permanent_run_command_has_name_labels_and_no_auto_remove() -> None:
|
|
38
|
+
backend = DockerBackend(DockerBackendConfig())
|
|
39
|
+
command = backend._build_run_command(computer_id="dev-box", temporary=False)
|
|
40
|
+
|
|
41
|
+
assert "--rm" not in command
|
|
42
|
+
assert "kilntainer-dev-box" in command
|
|
43
|
+
assert f"{COMPUTER_ID_LABEL}=dev-box" in command
|
|
44
|
+
assert f"{TEMPORARY_LABEL}=false" in command
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_temporary_run_command_uses_auto_remove() -> None:
|
|
48
|
+
backend = DockerBackend(DockerBackendConfig())
|
|
49
|
+
command = backend._build_run_command(computer_id="scratch", temporary=True)
|
|
50
|
+
assert "--rm" in command
|
|
51
|
+
assert f"{TEMPORARY_LABEL}=true" in command
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_list_computers_reads_provider_labels(monkeypatch) -> None:
|
|
56
|
+
backend = DockerBackend(DockerBackendConfig())
|
|
57
|
+
backend._validated = True
|
|
58
|
+
|
|
59
|
+
async def fake_run(*args, **kwargs):
|
|
60
|
+
if args[:2] == ("ps", "-aq"):
|
|
61
|
+
return 0, b"aaaaaaaaaaaa\n", b""
|
|
62
|
+
if args[:2] == ("container", "inspect"):
|
|
63
|
+
return 0, json.dumps([inspect_row()]).encode(), b""
|
|
64
|
+
raise AssertionError(args)
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr(backend, "_run_docker", fake_run)
|
|
67
|
+
computers = await backend.list_computers()
|
|
68
|
+
|
|
69
|
+
assert len(computers) == 1
|
|
70
|
+
assert computers[0].computer_id == "dev-box"
|
|
71
|
+
assert computers[0].sandbox_id == "a" * 12
|
|
72
|
+
assert computers[0].temporary is False
|
|
73
|
+
assert computers[0].state == "running"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_attach_starts_stopped_permanent_container(monkeypatch) -> None:
|
|
78
|
+
backend = DockerBackend(DockerBackendConfig())
|
|
79
|
+
backend._validated = True
|
|
80
|
+
running = False
|
|
81
|
+
calls: list[tuple[str, ...]] = []
|
|
82
|
+
|
|
83
|
+
async def fake_run(*args, **kwargs):
|
|
84
|
+
nonlocal running
|
|
85
|
+
calls.append(args)
|
|
86
|
+
if args[:2] == ("container", "inspect"):
|
|
87
|
+
return 0, json.dumps([inspect_row(running=running)]).encode(), b""
|
|
88
|
+
if args[0] == "start":
|
|
89
|
+
running = True
|
|
90
|
+
return 0, b"", b""
|
|
91
|
+
raise AssertionError(args)
|
|
92
|
+
|
|
93
|
+
async def ready(self):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(backend, "_run_docker", fake_run)
|
|
97
|
+
monkeypatch.setattr(DockerSandbox, "_verify_readiness", ready)
|
|
98
|
+
sandbox = await backend.attach_sandbox("dev-box")
|
|
99
|
+
|
|
100
|
+
assert sandbox is not None
|
|
101
|
+
assert sandbox.computer_id == "dev-box"
|
|
102
|
+
assert sandbox.temporary is False
|
|
103
|
+
assert any(call[0] == "start" for call in calls)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.mark.asyncio
|
|
107
|
+
async def test_delete_uses_force_remove(monkeypatch) -> None:
|
|
108
|
+
backend = DockerBackend(DockerBackendConfig())
|
|
109
|
+
backend._validated = True
|
|
110
|
+
calls: list[tuple[str, ...]] = []
|
|
111
|
+
|
|
112
|
+
async def fake_run(*args, **kwargs):
|
|
113
|
+
calls.append(args)
|
|
114
|
+
if args[:2] == ("container", "inspect"):
|
|
115
|
+
return 0, json.dumps([inspect_row()]).encode(), b""
|
|
116
|
+
if args[:2] == ("rm", "-f"):
|
|
117
|
+
return 0, b"", b""
|
|
118
|
+
raise AssertionError(args)
|
|
119
|
+
|
|
120
|
+
monkeypatch.setattr(backend, "_run_docker", fake_run)
|
|
121
|
+
assert await backend.delete_computer("dev-box") is True
|
|
122
|
+
assert any(call[:2] == ("rm", "-f") for call in calls)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Mock sandbox and backend for testing higher-level code.
|
|
2
|
+
|
|
3
|
+
These mocks are designed for Phase 4 (MCP server) tests, allowing
|
|
4
|
+
testing of the server layer without requiring a real backend like Docker.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from kilntainers.backends.base import Backend, ExecRequest, ExecResult, Sandbox
|
|
11
|
+
from kilntainers.config import BackendConfig
|
|
12
|
+
from kilntainers.errors import BackendError, SandboxDiedError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MockSandbox(Sandbox):
|
|
16
|
+
"""Sandbox that returns preconfigured ExecResults.
|
|
17
|
+
|
|
18
|
+
Designed for testing the MCP server layer without requiring a real
|
|
19
|
+
backend like Docker.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
sandbox_id: str = "mock-sandbox-001",
|
|
26
|
+
exec_results: list[ExecResult] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize a mock sandbox.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
sandbox_id: The sandbox ID to return.
|
|
32
|
+
exec_results: A queue of results to return from exec(). If None,
|
|
33
|
+
returns a default success result.
|
|
34
|
+
"""
|
|
35
|
+
self._sandbox_id = sandbox_id
|
|
36
|
+
self._stopped = False
|
|
37
|
+
self._death_event = asyncio.Event()
|
|
38
|
+
self._stop_requested = False
|
|
39
|
+
self.exec_results: list[ExecResult] = exec_results or []
|
|
40
|
+
self.exec_calls: list[ExecRequest] = []
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def sandbox_id(self) -> str:
|
|
44
|
+
"""Return the sandbox ID."""
|
|
45
|
+
return self._sandbox_id
|
|
46
|
+
|
|
47
|
+
async def exec(self, request: ExecRequest) -> ExecResult:
|
|
48
|
+
"""Record the call and return a queued result or default."""
|
|
49
|
+
if self._death_event.is_set():
|
|
50
|
+
raise SandboxDiedError(f"mock sandbox {self._sandbox_id} died")
|
|
51
|
+
self.exec_calls.append(request)
|
|
52
|
+
if self.exec_results:
|
|
53
|
+
return self.exec_results.pop(0)
|
|
54
|
+
return ExecResult(
|
|
55
|
+
stdout="",
|
|
56
|
+
stderr="",
|
|
57
|
+
exit_code=0,
|
|
58
|
+
exec_duration_ms=1,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def stop(self) -> None:
|
|
62
|
+
"""Mark the sandbox as stopped."""
|
|
63
|
+
self._stopped = True
|
|
64
|
+
self._stop_requested = True
|
|
65
|
+
self._death_event.set()
|
|
66
|
+
|
|
67
|
+
async def wait_for_death(self) -> None:
|
|
68
|
+
"""Block until the sandbox dies."""
|
|
69
|
+
await self._death_event.wait()
|
|
70
|
+
|
|
71
|
+
def simulate_death(self) -> None:
|
|
72
|
+
"""Test helper: trigger sandbox death."""
|
|
73
|
+
self._death_event.set()
|
|
74
|
+
|
|
75
|
+
def is_stopped(self) -> bool:
|
|
76
|
+
"""Test helper: check if stopped."""
|
|
77
|
+
return self._stopped
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MockBackend(Backend):
|
|
81
|
+
"""Backend that returns MockSandbox instances.
|
|
82
|
+
|
|
83
|
+
Designed for testing the MCP server layer without requiring a real
|
|
84
|
+
backend like Docker.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None:
|
|
89
|
+
"""Mock implementation - does nothing."""
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def config_from_args(cls, args: argparse.Namespace) -> BackendConfig:
|
|
93
|
+
"""Mock implementation - returns default config."""
|
|
94
|
+
return BackendConfig()
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
config: BackendConfig,
|
|
99
|
+
*,
|
|
100
|
+
tool_instructions: str | None = "mock instructions",
|
|
101
|
+
sandbox_id: str = "mock-sandbox-001",
|
|
102
|
+
exec_results: list[ExecResult] | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Initialize a mock backend.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
config: The backend configuration.
|
|
108
|
+
tool_instructions: The tool instructions to return.
|
|
109
|
+
sandbox_id: The sandbox ID to use for created sandboxes.
|
|
110
|
+
exec_results: Results queue for created sandboxes.
|
|
111
|
+
"""
|
|
112
|
+
super().__init__(config)
|
|
113
|
+
self._tool_instructions = tool_instructions
|
|
114
|
+
self._sandbox_id = sandbox_id
|
|
115
|
+
self._exec_results = exec_results
|
|
116
|
+
self._validated = False
|
|
117
|
+
# Test helpers for lazy creation testing
|
|
118
|
+
self.create_count = 0
|
|
119
|
+
self.fail_next_create = False
|
|
120
|
+
|
|
121
|
+
async def _validate(self) -> None:
|
|
122
|
+
"""Mark as validated (no-op for mock)."""
|
|
123
|
+
self._validated = True
|
|
124
|
+
|
|
125
|
+
async def _create_sandbox(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
computer_id: str | None = None,
|
|
129
|
+
temporary: bool = True,
|
|
130
|
+
) -> Sandbox:
|
|
131
|
+
"""Return a MockSandbox.
|
|
132
|
+
|
|
133
|
+
Supports test helpers:
|
|
134
|
+
- create_count: incremented on each sandbox creation
|
|
135
|
+
- fail_next_create: if True, raises BackendError and resets flag
|
|
136
|
+
"""
|
|
137
|
+
if self.fail_next_create:
|
|
138
|
+
self.fail_next_create = False
|
|
139
|
+
raise BackendError("mock creation failure")
|
|
140
|
+
self.create_count += 1
|
|
141
|
+
return MockSandbox(
|
|
142
|
+
sandbox_id=self._sandbox_id,
|
|
143
|
+
exec_results=self._exec_results,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def tool_instructions(self) -> str | None:
|
|
147
|
+
"""Return the configured tool instructions."""
|
|
148
|
+
return self._tool_instructions
|
|
149
|
+
|
|
150
|
+
def is_validated(self) -> bool:
|
|
151
|
+
"""Test helper: check if validate was called."""
|
|
152
|
+
return self._validated
|