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