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,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