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,387 @@
|
|
|
1
|
+
"""Docker-only virtual computer configuration and desktop integration tests."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from kilntainers.backends.base import ExecRequest
|
|
10
|
+
from kilntainers.backends.docker import (
|
|
11
|
+
DEFAULT_DESKTOP_IMAGE,
|
|
12
|
+
DESKTOP_IMAGE_VERSION,
|
|
13
|
+
DESKTOP_IMAGE_VERSION_LABEL,
|
|
14
|
+
DESKTOP_LABEL,
|
|
15
|
+
IMAGE_LABEL,
|
|
16
|
+
WORKSPACE_LABEL,
|
|
17
|
+
DockerBackend,
|
|
18
|
+
DockerBackendConfig,
|
|
19
|
+
)
|
|
20
|
+
from kilntainers.desktop import animate_file_operation
|
|
21
|
+
from kilntainers.desktop_control import (
|
|
22
|
+
accessibility_snapshot,
|
|
23
|
+
capture_screen,
|
|
24
|
+
desktop_action,
|
|
25
|
+
visible_terminal_execute,
|
|
26
|
+
)
|
|
27
|
+
from kilntainers.errors import BackendError
|
|
28
|
+
from kilntainers.file_tools import list_directory, read_text_file, write_text_file
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_desktop_image_owns_complete_user_local_prefix() -> None:
|
|
32
|
+
"""User-scoped installers must be able to create commands below ~/.local."""
|
|
33
|
+
image_files = files("kilntainers").joinpath("desktop_image")
|
|
34
|
+
dockerfile = image_files.joinpath("Dockerfile").read_text(encoding="utf-8")
|
|
35
|
+
entrypoint = image_files.joinpath("start-desktop.sh").read_text(encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
assert "computer -g computer /home/computer/.local \\" in dockerfile
|
|
38
|
+
assert "computer -g computer /home/computer/.local/bin \\" in dockerfile
|
|
39
|
+
assert "/home/computer/.local/bin \\" in entrypoint
|
|
40
|
+
assert (
|
|
41
|
+
f'LABEL {DESKTOP_IMAGE_VERSION_LABEL}="{DESKTOP_IMAGE_VERSION}"' in dockerfile
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_headless_computer_is_persistent_and_uses_workspace() -> None:
|
|
46
|
+
backend = DockerBackend(DockerBackendConfig(network_enabled=True))
|
|
47
|
+
command = backend._build_run_command(computer_id="desk", temporary=False)
|
|
48
|
+
|
|
49
|
+
assert "--rm" not in command
|
|
50
|
+
assert command[-4:] == ["debian:bookworm-slim", "tail", "-f", "/dev/null"]
|
|
51
|
+
assert command[command.index("--workdir") + 1] == "/workspace"
|
|
52
|
+
assert "127.0.0.1::6080" not in command
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_desktop_capable_computer_can_start_with_xfce_disabled() -> None:
|
|
56
|
+
backend = DockerBackend(
|
|
57
|
+
DockerBackendConfig(
|
|
58
|
+
image=DEFAULT_DESKTOP_IMAGE,
|
|
59
|
+
desktop_environment=False,
|
|
60
|
+
network_enabled=True,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
command = backend._build_run_command(computer_id="desk", temporary=False)
|
|
64
|
+
|
|
65
|
+
assert command[-1] == DEFAULT_DESKTOP_IMAGE
|
|
66
|
+
assert "tail" not in command
|
|
67
|
+
assert "--init" in command
|
|
68
|
+
assert command[command.index("--publish") + 1] == "127.0.0.1::6080"
|
|
69
|
+
assert "DESKTOP_ENVIRONMENT=false" in command
|
|
70
|
+
assert f"{DESKTOP_LABEL}=false" in command
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_desktop_computer_publishes_only_loopback_websocket() -> None:
|
|
74
|
+
backend = DockerBackend(
|
|
75
|
+
DockerBackendConfig(
|
|
76
|
+
image=DEFAULT_DESKTOP_IMAGE,
|
|
77
|
+
desktop_environment=True,
|
|
78
|
+
network_enabled=True,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
command = backend._build_run_command(computer_id="desk", temporary=False)
|
|
82
|
+
|
|
83
|
+
assert command[-1] == DEFAULT_DESKTOP_IMAGE
|
|
84
|
+
assert "tail" not in command
|
|
85
|
+
assert command[command.index("--publish") + 1] == "127.0.0.1::6080"
|
|
86
|
+
assert command[command.index("--shm-size") + 1] == "256m"
|
|
87
|
+
assert "DESKTOP_ENVIRONMENT=true" in command
|
|
88
|
+
assert f"{DESKTOP_LABEL}=true" in command
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_desktop_no_network_keeps_local_screen_and_requests_firewall() -> None:
|
|
92
|
+
backend = DockerBackend(
|
|
93
|
+
DockerBackendConfig(
|
|
94
|
+
image=DEFAULT_DESKTOP_IMAGE,
|
|
95
|
+
desktop_environment=True,
|
|
96
|
+
network_enabled=False,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
backend._validated = True
|
|
101
|
+
command = backend._build_run_command(computer_id="desk", temporary=False)
|
|
102
|
+
|
|
103
|
+
assert "--network" not in command
|
|
104
|
+
assert command[command.index("--cap-add") + 1] == "NET_ADMIN"
|
|
105
|
+
assert "NETWORK_ACCESS=false" in command
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_xfce_disabled_no_network_keeps_local_transport_for_runtime_switch() -> None:
|
|
109
|
+
backend = DockerBackend(
|
|
110
|
+
DockerBackendConfig(
|
|
111
|
+
image=DEFAULT_DESKTOP_IMAGE,
|
|
112
|
+
desktop_environment=False,
|
|
113
|
+
network_enabled=False,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
command = backend._build_run_command(computer_id="desk", temporary=False)
|
|
117
|
+
|
|
118
|
+
assert "--network" not in command
|
|
119
|
+
assert "127.0.0.1::6080" in command
|
|
120
|
+
assert "NETWORK_ACCESS=false" in command
|
|
121
|
+
assert "DESKTOP_ENVIRONMENT=false" in command
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_attached_desktop_exposes_browser_websocket_url() -> None:
|
|
125
|
+
backend = DockerBackend(DockerBackendConfig(desktop_environment=True))
|
|
126
|
+
sandbox = backend._sandbox_from_inspect(
|
|
127
|
+
{
|
|
128
|
+
"Id": "a" * 64,
|
|
129
|
+
"Config": {
|
|
130
|
+
"Labels": {
|
|
131
|
+
"kilntainers.computer-id": "desk",
|
|
132
|
+
"kilntainers.temporary": "false",
|
|
133
|
+
IMAGE_LABEL: DEFAULT_DESKTOP_IMAGE,
|
|
134
|
+
DESKTOP_LABEL: "true",
|
|
135
|
+
WORKSPACE_LABEL: "/workspace",
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"NetworkSettings": {
|
|
139
|
+
"Ports": {"6080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "49153"}]}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
assert sandbox.computer_id == "desk"
|
|
145
|
+
assert sandbox.temporary is False
|
|
146
|
+
assert sandbox.desktop_url == "ws://127.0.0.1:49153/websockify"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_legacy_headless_computer_is_not_replaced_to_enable_xfce() -> None:
|
|
150
|
+
backend = DockerBackend(DockerBackendConfig())
|
|
151
|
+
sandbox = backend._sandbox_from_inspect(
|
|
152
|
+
{
|
|
153
|
+
"Id": "b" * 64,
|
|
154
|
+
"Config": {
|
|
155
|
+
"Labels": {
|
|
156
|
+
"kilntainers.computer-id": "legacy",
|
|
157
|
+
"kilntainers.temporary": "false",
|
|
158
|
+
IMAGE_LABEL: "debian:bookworm-slim",
|
|
159
|
+
DESKTOP_LABEL: "false",
|
|
160
|
+
WORKSPACE_LABEL: "/workspace",
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"NetworkSettings": {"Ports": {}, "Networks": {"bridge": {}}},
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
with pytest.raises(BackendError, match="data was left untouched"):
|
|
168
|
+
await backend._set_desktop_mode(sandbox, True)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.integration
|
|
172
|
+
async def test_real_xfce_desktop_replays_and_preserves_file_write() -> None:
|
|
173
|
+
"""Build/run the live desktop and verify real UI replay plus reattachment."""
|
|
174
|
+
computer_id = f"virtual-computer-e2e-{os.getpid()}"
|
|
175
|
+
backend = DockerBackend(
|
|
176
|
+
DockerBackendConfig(
|
|
177
|
+
image=DEFAULT_DESKTOP_IMAGE,
|
|
178
|
+
desktop_environment=True,
|
|
179
|
+
network_enabled=True,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
sandbox = await backend.create_sandbox(
|
|
183
|
+
computer_id=computer_id,
|
|
184
|
+
temporary=False,
|
|
185
|
+
)
|
|
186
|
+
try:
|
|
187
|
+
assert sandbox.desktop_url is not None
|
|
188
|
+
screen = await capture_screen(sandbox)
|
|
189
|
+
assert screen.startswith(b"\x89PNG\r\n\x1a\n")
|
|
190
|
+
snapshot = await accessibility_snapshot(sandbox)
|
|
191
|
+
assert snapshot["format"] == "at-spi-snapshot-v1"
|
|
192
|
+
assert snapshot["elements"]
|
|
193
|
+
terminal = await visible_terminal_execute(
|
|
194
|
+
sandbox,
|
|
195
|
+
command="printf 'visible terminal e2e\\n'",
|
|
196
|
+
args=None,
|
|
197
|
+
stdin=None,
|
|
198
|
+
working_directory="/workspace",
|
|
199
|
+
timeout=30,
|
|
200
|
+
output_limit=131_072,
|
|
201
|
+
)
|
|
202
|
+
assert terminal.stdout == "visible terminal e2e\n"
|
|
203
|
+
assert terminal.exit_code == 0
|
|
204
|
+
windows = await desktop_action(sandbox, "list_windows")
|
|
205
|
+
assert any("MCP Terminal" in window["title"] for window in windows["windows"])
|
|
206
|
+
saved = await write_text_file(
|
|
207
|
+
sandbox,
|
|
208
|
+
"desktop-e2e.txt",
|
|
209
|
+
"written through the real desktop\n",
|
|
210
|
+
workspace_directory="/workspace",
|
|
211
|
+
timeout=30,
|
|
212
|
+
output_limit=131_072,
|
|
213
|
+
text_limit=131_072,
|
|
214
|
+
)
|
|
215
|
+
warning = await animate_file_operation(
|
|
216
|
+
sandbox,
|
|
217
|
+
operation="write_file",
|
|
218
|
+
path=saved.path,
|
|
219
|
+
content=saved.content,
|
|
220
|
+
original_content=None,
|
|
221
|
+
old_text=None,
|
|
222
|
+
new_text=None,
|
|
223
|
+
replace_all=False,
|
|
224
|
+
terminal_was_last=False,
|
|
225
|
+
workspace_directory="/workspace",
|
|
226
|
+
)
|
|
227
|
+
assert warning is None
|
|
228
|
+
actual = await read_text_file(
|
|
229
|
+
sandbox,
|
|
230
|
+
saved.path,
|
|
231
|
+
workspace_directory="/workspace",
|
|
232
|
+
timeout=30,
|
|
233
|
+
output_limit=131_072,
|
|
234
|
+
text_limit=131_072,
|
|
235
|
+
)
|
|
236
|
+
assert actual.content == saved.content
|
|
237
|
+
listing = await list_directory(
|
|
238
|
+
sandbox,
|
|
239
|
+
"/workspace",
|
|
240
|
+
workspace_directory="/workspace",
|
|
241
|
+
timeout=30,
|
|
242
|
+
output_limit=131_072,
|
|
243
|
+
)
|
|
244
|
+
assert any(
|
|
245
|
+
entry.name == "desktop-e2e.txt" and entry.kind == "file"
|
|
246
|
+
for entry in listing.entries
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
attached = await backend.attach_sandbox(computer_id)
|
|
250
|
+
assert attached is not None
|
|
251
|
+
assert attached.computer_id == computer_id
|
|
252
|
+
assert attached.desktop_url == sandbox.desktop_url
|
|
253
|
+
finally:
|
|
254
|
+
await backend.delete_computer(computer_id)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@pytest.mark.integration
|
|
258
|
+
async def test_runtime_switches_preserve_the_full_container() -> None:
|
|
259
|
+
"""Exercise dashboard toggles without replacing the Docker computer."""
|
|
260
|
+
computer_id = f"virtual-computer-toggle-e2e-{os.getpid()}"
|
|
261
|
+
backend = DockerBackend(
|
|
262
|
+
DockerBackendConfig(
|
|
263
|
+
image=DEFAULT_DESKTOP_IMAGE,
|
|
264
|
+
desktop_environment=False,
|
|
265
|
+
network_enabled=True,
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
sandbox = await backend.create_sandbox(computer_id=computer_id, temporary=False)
|
|
269
|
+
try:
|
|
270
|
+
original_container_id = sandbox.sandbox_id
|
|
271
|
+
assert sandbox.desktop_environment is False
|
|
272
|
+
assert sandbox.desktop_url is None
|
|
273
|
+
await write_text_file(
|
|
274
|
+
sandbox,
|
|
275
|
+
"toggle-preserved.txt",
|
|
276
|
+
"workspace survives both mug faces\n",
|
|
277
|
+
workspace_directory="/workspace",
|
|
278
|
+
timeout=30,
|
|
279
|
+
output_limit=131_072,
|
|
280
|
+
text_limit=131_072,
|
|
281
|
+
)
|
|
282
|
+
outside_workspace = await sandbox.exec(
|
|
283
|
+
ExecRequest(
|
|
284
|
+
command="mkdir -p /opt/mcp-test && printf persistent > /opt/mcp-test/mode",
|
|
285
|
+
timeout=10,
|
|
286
|
+
output_limit=131_072,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
assert outside_workspace.exit_code == 0
|
|
290
|
+
|
|
291
|
+
desktop = await backend.switch_desktop_environment(computer_id, True)
|
|
292
|
+
assert desktop is not None
|
|
293
|
+
assert desktop.sandbox_id == original_container_id
|
|
294
|
+
assert desktop.desktop_environment is True
|
|
295
|
+
assert desktop.desktop_url is not None
|
|
296
|
+
assert (await capture_screen(desktop)).startswith(b"\x89PNG\r\n\x1a\n")
|
|
297
|
+
|
|
298
|
+
isolated = await backend.set_network_access(computer_id, False)
|
|
299
|
+
assert isolated is not None
|
|
300
|
+
assert isolated.network_access is False
|
|
301
|
+
assert isolated.desktop_url is not None
|
|
302
|
+
blocked = await isolated.exec(
|
|
303
|
+
ExecRequest(
|
|
304
|
+
command=(
|
|
305
|
+
'python3 -c "import socket; '
|
|
306
|
+
"socket.getaddrinfo('deb.debian.org', 443); print('online')\""
|
|
307
|
+
),
|
|
308
|
+
timeout=10,
|
|
309
|
+
output_limit=131_072,
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
assert blocked.exit_code != 0
|
|
313
|
+
|
|
314
|
+
online = await backend.set_network_access(computer_id, True)
|
|
315
|
+
assert online is not None
|
|
316
|
+
reachable = await online.exec(
|
|
317
|
+
ExecRequest(
|
|
318
|
+
command=(
|
|
319
|
+
'python3 -c "import socket; '
|
|
320
|
+
"socket.getaddrinfo('deb.debian.org', 443); print('online')\""
|
|
321
|
+
),
|
|
322
|
+
timeout=10,
|
|
323
|
+
output_limit=131_072,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
assert reachable.exit_code == 0
|
|
327
|
+
assert reachable.stdout == "online\n"
|
|
328
|
+
|
|
329
|
+
headless = await backend.switch_desktop_environment(computer_id, False)
|
|
330
|
+
assert headless is not None
|
|
331
|
+
assert headless.sandbox_id == original_container_id
|
|
332
|
+
assert headless.desktop_environment is False
|
|
333
|
+
assert headless.desktop_url is None
|
|
334
|
+
preserved = await read_text_file(
|
|
335
|
+
headless,
|
|
336
|
+
"toggle-preserved.txt",
|
|
337
|
+
workspace_directory="/workspace",
|
|
338
|
+
timeout=30,
|
|
339
|
+
output_limit=131_072,
|
|
340
|
+
text_limit=131_072,
|
|
341
|
+
)
|
|
342
|
+
assert preserved.content == "workspace survives both mug faces\n"
|
|
343
|
+
persistent = await headless.exec(
|
|
344
|
+
ExecRequest(
|
|
345
|
+
command="cat /opt/mcp-test/mode",
|
|
346
|
+
timeout=10,
|
|
347
|
+
output_limit=131_072,
|
|
348
|
+
)
|
|
349
|
+
)
|
|
350
|
+
assert persistent.exit_code == 0
|
|
351
|
+
assert persistent.stdout == "persistent"
|
|
352
|
+
|
|
353
|
+
disconnected = await backend.set_network_access(computer_id, False)
|
|
354
|
+
assert disconnected is not None
|
|
355
|
+
assert disconnected.network_access is False
|
|
356
|
+
inspect = await backend._inspect_computer(computer_id)
|
|
357
|
+
assert inspect is not None
|
|
358
|
+
network_settings = inspect["NetworkSettings"]
|
|
359
|
+
assert isinstance(network_settings, dict)
|
|
360
|
+
network_settings = cast("dict[str, object]", network_settings)
|
|
361
|
+
networks = network_settings.get("Networks")
|
|
362
|
+
assert isinstance(networks, dict)
|
|
363
|
+
assert "none" not in networks
|
|
364
|
+
|
|
365
|
+
restored = await backend.switch_desktop_environment(computer_id, True)
|
|
366
|
+
assert restored is not None
|
|
367
|
+
assert restored.sandbox_id == original_container_id
|
|
368
|
+
assert restored.desktop_url is not None
|
|
369
|
+
healed_vnc = await restored.exec(
|
|
370
|
+
ExecRequest(
|
|
371
|
+
command=(
|
|
372
|
+
"old=$(pgrep -o x11vnc) || exit 1; kill \"$old\"; i=0; "
|
|
373
|
+
"while [ $i -lt 50 ]; do i=$((i + 1)); "
|
|
374
|
+
"new=$(pgrep -o x11vnc 2>/dev/null || true); "
|
|
375
|
+
"if [ -n \"$new\" ] && [ \"$new\" != \"$old\" ]; then "
|
|
376
|
+
"printf '%s->%s\\n' \"$old\" \"$new\"; exit 0; fi; "
|
|
377
|
+
"sleep 0.2; done; exit 1"
|
|
378
|
+
),
|
|
379
|
+
timeout=15,
|
|
380
|
+
output_limit=131_072,
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
assert healed_vnc.exit_code == 0
|
|
384
|
+
assert "->" in healed_vnc.stdout
|
|
385
|
+
assert (await capture_screen(restored)).startswith(b"\x89PNG\r\n\x1a\n")
|
|
386
|
+
finally:
|
|
387
|
+
await backend.delete_computer(computer_id)
|