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,37 @@
1
+ """Tests for backend registry."""
2
+
3
+ import pytest
4
+
5
+ from kilntainers.backends import (
6
+ get_available_backend_names,
7
+ get_backend_class,
8
+ )
9
+ from kilntainers.backends.docker import DockerBackend
10
+
11
+
12
+ class TestBackendRegistry:
13
+ """Test backend registry and lookup function."""
14
+
15
+ def test_registry_contains_docker(self) -> None:
16
+ """Registry should contain Docker backend."""
17
+ available = get_available_backend_names()
18
+ assert "docker" in available
19
+
20
+ def test_get_backend_class_docker(self) -> None:
21
+ """get_backend_class should return DockerBackend for 'docker'."""
22
+ backend_class = get_backend_class("docker")
23
+ assert backend_class == DockerBackend
24
+
25
+ def test_get_backend_class_unknown_raises(self) -> None:
26
+ """get_backend_class should raise KeyError for unknown backend."""
27
+ with pytest.raises(KeyError, match=r"Unknown backend.*'unknown'"):
28
+ get_backend_class("unknown")
29
+
30
+ def test_get_backend_class_error_message_includes_available(self) -> None:
31
+ """Error message should include available backends."""
32
+ with pytest.raises(KeyError) as exc_info:
33
+ get_backend_class("unknown")
34
+ error_msg = str(exc_info.value)
35
+ assert "Available backends:" in error_msg
36
+ # At least docker should be available
37
+ assert "docker" in error_msg
@@ -0,0 +1,355 @@
1
+ """Tests for backend abstraction layer — ABCs and shared types."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ from dataclasses import FrozenInstanceError
6
+
7
+ import pytest
8
+
9
+ from kilntainers.backends.base import (
10
+ Backend,
11
+ ExecRequest,
12
+ ExecResult,
13
+ Sandbox,
14
+ )
15
+ from kilntainers.config import BackendConfig
16
+ from kilntainers.errors import SandboxDiedError
17
+
18
+ # --- ExecRequest Tests ---
19
+
20
+
21
+ class TestExecRequestValidation:
22
+ """Test ExecRequest.__post_init__ validation."""
23
+
24
+ def test_both_command_and_args_raises(self) -> None:
25
+ """Providing both command and args should raise ValueError."""
26
+ with pytest.raises(ValueError, match="mutually exclusive"):
27
+ ExecRequest(
28
+ command="ls -la",
29
+ args=["ls", "-la"],
30
+ timeout=30,
31
+ output_limit=1024,
32
+ )
33
+
34
+ def test_neither_command_nor_args_raises(self) -> None:
35
+ """Providing neither command nor args should raise ValueError."""
36
+ with pytest.raises(ValueError, match="either command or args"):
37
+ ExecRequest(timeout=30, output_limit=1024)
38
+
39
+ def test_relative_working_directory_raises(self) -> None:
40
+ """Relative working_directory should raise ValueError."""
41
+ with pytest.raises(ValueError, match="absolute path"):
42
+ ExecRequest(
43
+ command="ls",
44
+ working_directory="relative/path",
45
+ timeout=30,
46
+ output_limit=1024,
47
+ )
48
+
49
+ def test_absolute_working_directory_valid(self) -> None:
50
+ """Absolute working_directory should be valid."""
51
+ req = ExecRequest(
52
+ command="ls",
53
+ working_directory="/absolute/path",
54
+ timeout=30,
55
+ output_limit=1024,
56
+ )
57
+ assert req.working_directory == "/absolute/path"
58
+
59
+ def test_timeout_less_than_one_raises(self) -> None:
60
+ """timeout < 1 should raise ValueError."""
61
+ with pytest.raises(ValueError, match="at least 1 second"):
62
+ ExecRequest(command="ls", timeout=0, output_limit=1024)
63
+
64
+ with pytest.raises(ValueError, match="at least 1 second"):
65
+ ExecRequest(command="ls", timeout=-1, output_limit=1024)
66
+
67
+ def test_timeout_one_or_greater_valid(self) -> None:
68
+ """timeout >= 1 should be valid."""
69
+ req = ExecRequest(command="ls", timeout=1, output_limit=1024)
70
+ assert req.timeout == 1
71
+
72
+ req = ExecRequest(command="ls", timeout=300, output_limit=1024)
73
+ assert req.timeout == 300
74
+
75
+ def test_output_limit_less_than_one_raises(self) -> None:
76
+ """output_limit < 1 should raise ValueError."""
77
+ with pytest.raises(ValueError, match="positive"):
78
+ ExecRequest(command="ls", timeout=30, output_limit=0)
79
+
80
+ with pytest.raises(ValueError, match="positive"):
81
+ ExecRequest(command="ls", timeout=30, output_limit=-1)
82
+
83
+ def test_output_limit_positive_valid(self) -> None:
84
+ """output_limit >= 1 should be valid."""
85
+ req = ExecRequest(command="ls", timeout=30, output_limit=1)
86
+ assert req.output_limit == 1
87
+
88
+ req = ExecRequest(command="ls", timeout=30, output_limit=10_000_000)
89
+ assert req.output_limit == 10_000_000
90
+
91
+ def test_command_only_valid(self) -> None:
92
+ """Request with only command should be valid."""
93
+ req = ExecRequest(
94
+ command="ls -la",
95
+ timeout=30,
96
+ output_limit=1024,
97
+ )
98
+ assert req.command == "ls -la"
99
+ assert req.args is None
100
+
101
+ def test_args_only_valid(self) -> None:
102
+ """Request with only args should be valid."""
103
+ req = ExecRequest(
104
+ args=["ls", "-la"],
105
+ timeout=30,
106
+ output_limit=1024,
107
+ )
108
+ assert req.args == ["ls", "-la"]
109
+ assert req.command is None
110
+
111
+ def test_with_stdin_valid(self) -> None:
112
+ """Request with stdin should be valid."""
113
+ req = ExecRequest(
114
+ command="cat",
115
+ stdin="hello world",
116
+ timeout=30,
117
+ output_limit=1024,
118
+ )
119
+ assert req.stdin == "hello world"
120
+
121
+ def test_frozen_immutable(self) -> None:
122
+ """ExecRequest should be frozen (immutable)."""
123
+ req = ExecRequest(
124
+ command="ls",
125
+ timeout=30,
126
+ output_limit=1024,
127
+ )
128
+ with pytest.raises(FrozenInstanceError):
129
+ req.command = "new command" # type: ignore[misc]
130
+
131
+ def test_kw_only_enforced(self) -> None:
132
+ """Positional arguments should not be allowed."""
133
+ with pytest.raises(TypeError):
134
+ ExecRequest("ls", 30, 1024) # type: ignore
135
+
136
+
137
+ # --- ExecResult Tests ---
138
+
139
+
140
+ class TestExecResult:
141
+ """Test ExecResult dataclass."""
142
+
143
+ def test_construction(self) -> None:
144
+ """All fields should be populated correctly."""
145
+ result = ExecResult(
146
+ stdout="output",
147
+ stderr="errors",
148
+ exit_code=0,
149
+ exec_duration_ms=100,
150
+ )
151
+ assert result.stdout == "output"
152
+ assert result.stderr == "errors"
153
+ assert result.exit_code == 0
154
+ assert result.exec_duration_ms == 100
155
+
156
+ def test_frozen_immutable(self) -> None:
157
+ """ExecResult should be frozen (immutable)."""
158
+ result = ExecResult(
159
+ stdout="output",
160
+ stderr="errors",
161
+ exit_code=0,
162
+ exec_duration_ms=100,
163
+ )
164
+ with pytest.raises(FrozenInstanceError):
165
+ result.stdout = "new output" # type: ignore[misc]
166
+
167
+
168
+ # --- Backend ABC Tests ---
169
+
170
+
171
+ class StubBackend(Backend):
172
+ """Minimal Backend implementation for testing ABC behavior."""
173
+
174
+ @classmethod
175
+ def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None:
176
+ """Stub implementation - does nothing."""
177
+
178
+ @classmethod
179
+ def config_from_args(cls, args: argparse.Namespace) -> BackendConfig:
180
+ """Stub implementation - returns default config."""
181
+ return BackendConfig()
182
+
183
+ def __init__(self, config: BackendConfig) -> None:
184
+ super().__init__(config)
185
+ self.validate_called = 0
186
+ self.create_sandbox_called = 0
187
+
188
+ async def _validate(self) -> None:
189
+ """Stub validation."""
190
+ self.validate_called += 1
191
+
192
+ async def _create_sandbox(
193
+ self,
194
+ *,
195
+ computer_id: str | None = None,
196
+ temporary: bool = True,
197
+ ) -> Sandbox:
198
+ """Stub sandbox creation."""
199
+ self.create_sandbox_called += 1
200
+ return StubSandbox()
201
+
202
+ def tool_instructions(self) -> str | None:
203
+ return "stub instructions"
204
+
205
+
206
+ class StubSandbox(Sandbox):
207
+ """Minimal Sandbox implementation for testing."""
208
+
209
+ def __init__(self, sandbox_id: str = "stub-sandbox-001") -> None:
210
+ self._sandbox_id = sandbox_id
211
+ self._stopped = False
212
+ self._death_event = asyncio.Event()
213
+
214
+ @property
215
+ def sandbox_id(self) -> str:
216
+ return self._sandbox_id
217
+
218
+ async def exec(self, request: ExecRequest) -> ExecResult:
219
+ if self._death_event.is_set():
220
+ raise SandboxDiedError("stub sandbox died")
221
+ return ExecResult(
222
+ stdout="",
223
+ stderr="",
224
+ exit_code=0,
225
+ exec_duration_ms=1,
226
+ )
227
+
228
+ async def stop(self) -> None:
229
+ self._stopped = True
230
+ self._death_event.set()
231
+
232
+ async def wait_for_death(self) -> None:
233
+ await self._death_event.wait()
234
+
235
+
236
+ class TestBackendABC:
237
+ """Test Backend ABC concrete methods."""
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_validate_calls_validate_once(self) -> None:
241
+ """First validate() call should call _validate()."""
242
+ backend = StubBackend(BackendConfig())
243
+ await backend.validate()
244
+ assert backend.validate_called == 1
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_validate_caches_result(self) -> None:
248
+ """Subsequent validate() calls should not call _validate()."""
249
+ backend = StubBackend(BackendConfig())
250
+ await backend.validate()
251
+ await backend.validate()
252
+ await backend.validate()
253
+ assert backend.validate_called == 1
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_create_sandbox_auto_validates(self) -> None:
257
+ """create_sandbox() should call validate() if not validated."""
258
+ backend = StubBackend(BackendConfig())
259
+ await backend.create_sandbox()
260
+ assert backend.validate_called == 1
261
+ assert backend.create_sandbox_called == 1
262
+
263
+ @pytest.mark.asyncio
264
+ async def test_create_sandbox_does_not_revalidate(self) -> None:
265
+ """create_sandbox() should not re-validate if already validated."""
266
+ backend = StubBackend(BackendConfig())
267
+ await backend.validate()
268
+ await backend.create_sandbox()
269
+ assert backend.validate_called == 1
270
+ assert backend.create_sandbox_called == 1
271
+
272
+ def test_tool_instructions_returns_value(self) -> None:
273
+ """tool_instructions() should return subclass value."""
274
+ backend = StubBackend(BackendConfig())
275
+ assert backend.tool_instructions() == "stub instructions"
276
+
277
+
278
+ # --- Sandbox ABC Tests ---
279
+
280
+
281
+ class TestSandboxABC:
282
+ """Test Sandbox ABC context manager."""
283
+
284
+ @pytest.mark.asyncio
285
+ async def test_context_manager_calls_stop_on_exit(self) -> None:
286
+ """async with sandbox should call stop() on normal exit."""
287
+ sandbox = StubSandbox()
288
+ async with sandbox:
289
+ pass
290
+ assert sandbox._stopped is True
291
+
292
+ @pytest.mark.asyncio
293
+ async def test_context_manager_calls_stop_on_exception(self) -> None:
294
+ """async with sandbox should call stop() even on exception."""
295
+ sandbox = StubSandbox()
296
+ with pytest.raises(ValueError):
297
+ async with sandbox:
298
+ raise ValueError("test error")
299
+ assert sandbox._stopped is True
300
+
301
+ @pytest.mark.asyncio
302
+ async def test_stop_is_idempotent(self) -> None:
303
+ """Calling stop() multiple times should not error."""
304
+ sandbox = StubSandbox()
305
+ await sandbox.stop()
306
+ await sandbox.stop()
307
+ await sandbox.stop()
308
+ assert sandbox._stopped is True
309
+
310
+ @pytest.mark.asyncio
311
+ async def test_exec_returns_result(self) -> None:
312
+ """exec() should return ExecResult."""
313
+ sandbox = StubSandbox()
314
+ req = ExecRequest(command="test", timeout=30, output_limit=1024)
315
+ result = await sandbox.exec(req)
316
+ assert isinstance(result, ExecResult)
317
+
318
+ @pytest.mark.asyncio
319
+ async def test_exec_on_dead_sandbox_raises(self) -> None:
320
+ """exec() on dead sandbox should raise SandboxDiedError."""
321
+ sandbox = StubSandbox()
322
+ # Kill the sandbox
323
+ sandbox._death_event.set()
324
+ req = ExecRequest(command="test", timeout=30, output_limit=1024)
325
+ with pytest.raises(SandboxDiedError):
326
+ await sandbox.exec(req)
327
+
328
+ @pytest.mark.asyncio
329
+ async def test_sandbox_id_property(self) -> None:
330
+ """sandbox_id property should return the ID."""
331
+ sandbox = StubSandbox(sandbox_id="test-id-123")
332
+ assert sandbox.sandbox_id == "test-id-123"
333
+
334
+ @pytest.mark.asyncio
335
+ async def test_wait_for_death_blocks(self) -> None:
336
+ """wait_for_death() should block until death."""
337
+ sandbox = StubSandbox()
338
+ # Create a task that waits for death
339
+ task = asyncio.create_task(sandbox.wait_for_death())
340
+ # Task should not be done yet
341
+ await asyncio.sleep(0.01)
342
+ assert not task.done()
343
+ # Kill the sandbox
344
+ await sandbox.stop()
345
+ # Task should now complete
346
+ await task
347
+ assert task.done()
348
+
349
+ @pytest.mark.asyncio
350
+ async def test_context_manager_returns_self(self) -> None:
351
+ """__aenter__ should return self."""
352
+ sandbox = StubSandbox()
353
+ async with sandbox as entered:
354
+ assert sandbox is entered
355
+ assert entered is sandbox