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,841 @@
1
+ """Unit tests for Docker backend.
2
+
3
+ Tests mock subprocess calls to simulate Docker CLI responses without
4
+ requiring a real Docker daemon.
5
+ """
6
+
7
+ import asyncio
8
+
9
+ import pytest
10
+
11
+ from kilntainers.backends.base import ExecRequest
12
+ from kilntainers.backends.docker import (
13
+ DEFAULT_DESKTOP_IMAGE,
14
+ DockerBackend,
15
+ DockerBackendConfig,
16
+ DockerSandbox,
17
+ _DockerSandboxState,
18
+ )
19
+ from kilntainers.errors import BackendError, SandboxDiedError
20
+
21
+ # --- Mock subprocess utilities ---
22
+
23
+
24
+ class MockStdin:
25
+ """Mock stdin for subprocess."""
26
+
27
+ def __init__(self):
28
+ self.closed = False
29
+
30
+ def write(self, data):
31
+ pass
32
+
33
+ async def drain(self):
34
+ pass
35
+
36
+ def close(self):
37
+ self.closed = True
38
+
39
+
40
+ class MockStreamReader:
41
+ """Mock asyncio.StreamReader for subprocess output."""
42
+
43
+ def __init__(self, data: bytes, delay: float = 0, block_forever: bool = False):
44
+ self._data = data
45
+ self._pos = 0
46
+ self._delay = delay
47
+ self._block_forever = block_forever
48
+
49
+ async def read(self, n: int = -1) -> bytes:
50
+ if self._block_forever:
51
+ # Block forever until cancelled
52
+ await asyncio.Future()
53
+ return b"" # Never reached
54
+ if self._delay > 0:
55
+ await asyncio.sleep(self._delay)
56
+ if n == -1:
57
+ result = self._data[self._pos :]
58
+ self._pos = len(self._data)
59
+ return result
60
+ result = self._data[self._pos : self._pos + n]
61
+ self._pos += len(result)
62
+ return result
63
+
64
+
65
+ class MockProcess:
66
+ """Mock asyncio.subprocess.Process."""
67
+
68
+ def __init__(
69
+ self,
70
+ returncode: int,
71
+ stdout: bytes = b"",
72
+ stderr: bytes = b"",
73
+ stdin_data: bytes | None = None,
74
+ stdout_delay: float = 0,
75
+ stderr_delay: float = 0,
76
+ block_stdout: bool = False,
77
+ block_stderr: bool = False,
78
+ ):
79
+ self.returncode = returncode
80
+ self._stdout = stdout
81
+ self._stderr = stderr
82
+ self._stdin_data = stdin_data
83
+ self._stdout_delay = stdout_delay
84
+ self._stderr_delay = stderr_delay
85
+ self.pid = 12345
86
+ self.stdin = MockStdin() if stdin_data else None
87
+ self.stdout = (
88
+ MockStreamReader(stdout, stdout_delay, block_stdout)
89
+ if (stdout or block_stdout)
90
+ else None
91
+ )
92
+ self.stderr = (
93
+ MockStreamReader(stderr, stderr_delay, block_stderr)
94
+ if (stderr or block_stderr)
95
+ else None
96
+ )
97
+ self._killed = False
98
+
99
+ async def wait(self):
100
+ return self.returncode
101
+
102
+ async def communicate(self, input_data=None):
103
+ return self._stdout, self._stderr
104
+
105
+ def kill(self):
106
+ self._killed = True
107
+
108
+
109
+ @pytest.fixture
110
+ def mock_subprocess(monkeypatch):
111
+ """Mock asyncio.create_subprocess_exec with configurable responses."""
112
+
113
+ responses: list[MockProcess] = []
114
+
115
+ async def create_mock(*args, **kwargs):
116
+ if responses:
117
+ return responses.pop(0)
118
+ return MockProcess(0, b"", b"")
119
+
120
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
121
+
122
+ # Return a function to add responses
123
+ def add_response(*, returncode=0, stdout=b"", stderr=b"", stdin_data=None):
124
+ responses.insert(0, MockProcess(returncode, stdout, stderr, stdin_data))
125
+
126
+ return add_response
127
+
128
+
129
+ @pytest.fixture
130
+ def default_config():
131
+ """Return a default DockerBackendConfig."""
132
+ return DockerBackendConfig()
133
+
134
+
135
+ # --- DockerBackend tests ---
136
+
137
+
138
+ class TestDockerBackendValidation:
139
+ """Tests for DockerBackend._validate method."""
140
+
141
+ @pytest.mark.asyncio
142
+ async def test_validate_success(self, mock_subprocess, default_config):
143
+ """Validation passes when docker info succeeds."""
144
+ mock_subprocess(returncode=0, stdout=b"Docker info output")
145
+
146
+ backend = DockerBackend(default_config)
147
+ await backend.validate()
148
+
149
+ # Second call should be cached (no-op)
150
+ await backend.validate()
151
+
152
+ @pytest.mark.asyncio
153
+ async def test_validate_failure(self, mock_subprocess, default_config):
154
+ """Validation fails when docker info fails."""
155
+ mock_subprocess(returncode=1, stderr=b"Cannot connect to daemon")
156
+
157
+ backend = DockerBackend(default_config)
158
+
159
+ with pytest.raises(BackendError) as exc_info:
160
+ await backend.validate()
161
+
162
+ assert "Cannot connect to" in str(exc_info.value)
163
+ assert "docker" in str(exc_info.value)
164
+
165
+
166
+ class TestDockerBackendImageManagement:
167
+ """Tests for DockerBackend._ensure_image method."""
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_image_exists_locally(self, mock_subprocess, default_config):
171
+ """Image is available locally, no pull needed."""
172
+ mock_subprocess(returncode=0, stdout=b'{"Id": "abc123"}')
173
+
174
+ backend = DockerBackend(default_config)
175
+ await backend._ensure_image()
176
+
177
+ # Should have called image inspect only
178
+ # (mock responses are LIFO, so we can't verify order easily here)
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_stale_bundled_desktop_image_is_rebuilt(self, monkeypatch):
182
+ """A changed bundled image must not be hidden by its stable local tag."""
183
+ calls: list[tuple[str, ...]] = []
184
+
185
+ async def run_docker(*args, **kwargs):
186
+ calls.append(args)
187
+ if "inspect" in args:
188
+ return (
189
+ 0,
190
+ b'[{"Config":{"Labels":{"mcp-virtual-computer.image-version":"1"}}}]',
191
+ b"",
192
+ )
193
+ return 0, b"", b""
194
+
195
+ backend = DockerBackend(DockerBackendConfig(image=DEFAULT_DESKTOP_IMAGE))
196
+ monkeypatch.setattr(backend, "_run_docker", run_docker)
197
+
198
+ await backend._ensure_image()
199
+
200
+ assert any("build" in call for call in calls)
201
+
202
+ @pytest.mark.asyncio
203
+ async def test_image_not_local_pull_success(self, monkeypatch, default_config):
204
+ """Image not local, pull succeeds."""
205
+
206
+ async def create_mock(*args, **kwargs):
207
+ if "inspect" in args:
208
+ return MockProcess(1, stderr=b"Error: No such image")
209
+ elif "pull" in args:
210
+ return MockProcess(0)
211
+ return MockProcess(0)
212
+
213
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
214
+
215
+ backend = DockerBackend(default_config)
216
+ await backend._ensure_image() # Should not raise
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_image_pull_failure(self, monkeypatch, default_config):
220
+ """Image not local, pull fails."""
221
+
222
+ async def create_mock(*args, **kwargs):
223
+ if "inspect" in args:
224
+ return MockProcess(1, stderr=b"Error: No such image")
225
+ elif "pull" in args:
226
+ return MockProcess(1, stderr=b"Error: pull access denied")
227
+ return MockProcess(0)
228
+
229
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
230
+
231
+ backend = DockerBackend(default_config)
232
+
233
+ with pytest.raises(BackendError) as exc_info:
234
+ await backend._ensure_image()
235
+
236
+ assert "Failed to pull image" in str(exc_info.value)
237
+
238
+
239
+ class TestDockerBackendSandboxCreation:
240
+ """Tests for DockerBackend._create_sandbox method."""
241
+
242
+ @pytest.mark.asyncio
243
+ async def test_create_sandbox_success(self, monkeypatch, default_config):
244
+ """Full sandbox creation sequence succeeds."""
245
+
246
+ call_count = [0]
247
+ calls_log = []
248
+
249
+ async def create_mock(*args, **kwargs):
250
+ call_count[0] += 1
251
+ calls_log.append(args)
252
+
253
+ if "inspect" in args:
254
+ return MockProcess(0, stdout=b'{"Id": "abc123"}')
255
+ elif "run" in args:
256
+ return MockProcess(0, stdout=b"a" * 64)
257
+ elif "exec" in args:
258
+ return MockProcess(0, stdout=b"kilntainers-ready\n")
259
+ return MockProcess(0)
260
+
261
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
262
+
263
+ backend = DockerBackend(default_config)
264
+ sandbox = await backend.create_sandbox()
265
+
266
+ assert isinstance(sandbox, DockerSandbox)
267
+ assert sandbox.sandbox_id == "a" * 12, (
268
+ f"Expected 'aaaaaaaaaaaa', got '{sandbox.sandbox_id}'. Calls: {calls_log}"
269
+ )
270
+
271
+ @pytest.mark.asyncio
272
+ async def test_create_sandbox_readiness_failure(self, monkeypatch, default_config):
273
+ """Readiness check fails, container is cleaned up."""
274
+
275
+ call_count = [0]
276
+ stop_called = [False]
277
+
278
+ async def create_mock(*args, **kwargs):
279
+ call_count[0] += 1
280
+
281
+ if call_count[0] == 1: # image inspect
282
+ return MockProcess(0, stdout=b'{"Id": "abc123"}')
283
+ elif call_count[0] == 2: # docker run
284
+ return MockProcess(0, stdout=b"a" * 64)
285
+ elif call_count[0] == 3: # readiness check - wrong output
286
+ return MockProcess(0, stdout=b"wrong output\n")
287
+ elif "stop" in args:
288
+ stop_called[0] = True
289
+ return MockProcess(0)
290
+ return MockProcess(0)
291
+
292
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
293
+
294
+ backend = DockerBackend(default_config)
295
+
296
+ with pytest.raises(BackendError) as exc_info:
297
+ await backend.create_sandbox()
298
+
299
+ assert "readiness check failed" in str(exc_info.value)
300
+ assert stop_called[0], "Container should be stopped after readiness failure"
301
+
302
+
303
+ class TestDockerBackendToolInstructions:
304
+ """Tests for DockerBackend.tool_instructions method."""
305
+
306
+ def test_tool_instructions_default_image(self, default_config):
307
+ """Default image returns tool description."""
308
+ backend = DockerBackend(default_config)
309
+ instructions = backend.tool_instructions()
310
+
311
+ assert instructions is not None
312
+ assert "Debian" in instructions
313
+ assert "bash" in instructions
314
+ assert "120" in instructions # default timeout
315
+
316
+ def test_tool_instructions_custom_image(self):
317
+ """Custom image returns None."""
318
+ config = DockerBackendConfig(image="alpine:latest")
319
+ backend = DockerBackend(config)
320
+ instructions = backend.tool_instructions()
321
+
322
+ assert instructions is None
323
+
324
+
325
+ class TestDockerBackendEnginePrefix:
326
+ """Tests for DockerBackend._engine_prefix property."""
327
+
328
+ def test_engine_prefix_no_host(self, default_config):
329
+ """No host returns engine only."""
330
+ backend = DockerBackend(default_config)
331
+ assert backend._engine_prefix == ["docker"]
332
+
333
+ def test_engine_prefix_with_host(self):
334
+ """Host adds -H flag to prefix."""
335
+ config = DockerBackendConfig(host="tcp://remote:2375")
336
+ backend = DockerBackend(config)
337
+ assert backend._engine_prefix == ["docker", "-H", "tcp://remote:2375"]
338
+
339
+ def test_engine_prefix_with_custom_engine_and_host(self):
340
+ """Custom engine and host both appear in prefix."""
341
+ config = DockerBackendConfig(engine="podman", host="unix:///custom.sock")
342
+ backend = DockerBackend(config)
343
+ assert backend._engine_prefix == ["podman", "-H", "unix:///custom.sock"]
344
+
345
+ @pytest.mark.asyncio
346
+ async def test_run_docker_includes_host(self, monkeypatch):
347
+ """_run_docker passes -H to subprocess when host is configured."""
348
+ captured_args: list[tuple] = []
349
+
350
+ async def create_mock(*args, **kwargs):
351
+ captured_args.append(args)
352
+ return MockProcess(0, stdout=b"ok\n")
353
+
354
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
355
+
356
+ config = DockerBackendConfig(host="tcp://remote:2375")
357
+ backend = DockerBackend(config)
358
+ await backend._run_docker("info")
359
+
360
+ assert len(captured_args) == 1
361
+ cmd = captured_args[0]
362
+ assert cmd[0] == "docker"
363
+ assert cmd[1] == "-H"
364
+ assert cmd[2] == "tcp://remote:2375"
365
+ assert cmd[3] == "info"
366
+
367
+ @pytest.mark.asyncio
368
+ async def test_run_docker_no_host(self, monkeypatch, default_config):
369
+ """_run_docker omits -H when no host is configured."""
370
+ captured_args: list[tuple] = []
371
+
372
+ async def create_mock(*args, **kwargs):
373
+ captured_args.append(args)
374
+ return MockProcess(0, stdout=b"ok\n")
375
+
376
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
377
+
378
+ backend = DockerBackend(default_config)
379
+ await backend._run_docker("info")
380
+
381
+ assert len(captured_args) == 1
382
+ cmd = captured_args[0]
383
+ assert cmd[0] == "docker"
384
+ assert cmd[1] == "info"
385
+ assert "-H" not in cmd
386
+
387
+
388
+ class TestDockerSandboxEnginePrefix:
389
+ """Tests for DockerSandbox._engine_prefix property."""
390
+
391
+ def test_engine_prefix_no_host(self):
392
+ """No host returns engine only."""
393
+ state = _DockerSandboxState(
394
+ engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
395
+ )
396
+ sandbox = DockerSandbox(state)
397
+ assert sandbox._engine_prefix == ["docker"]
398
+
399
+ def test_engine_prefix_with_host(self):
400
+ """Host adds -H flag to prefix."""
401
+ state = _DockerSandboxState(
402
+ engine="docker",
403
+ host="tcp://remote:2375",
404
+ shell="/bin/bash",
405
+ container_id="a" * 64,
406
+ )
407
+ sandbox = DockerSandbox(state)
408
+ assert sandbox._engine_prefix == ["docker", "-H", "tcp://remote:2375"]
409
+
410
+ def test_build_exec_command_includes_host(self):
411
+ """_build_exec_command includes -H when host is configured."""
412
+ state = _DockerSandboxState(
413
+ engine="docker",
414
+ host="tcp://remote:2375",
415
+ shell="/bin/bash",
416
+ container_id="a" * 64,
417
+ )
418
+ sandbox = DockerSandbox(state)
419
+ request = ExecRequest(command="ls", timeout=30, output_limit=2_097_152)
420
+ cmd = sandbox._build_exec_command(request)
421
+
422
+ assert cmd[0] == "docker"
423
+ assert cmd[1] == "-H"
424
+ assert cmd[2] == "tcp://remote:2375"
425
+ assert "exec" in cmd
426
+
427
+ def test_build_exec_command_no_host(self):
428
+ """_build_exec_command omits -H when no host is configured."""
429
+ state = _DockerSandboxState(
430
+ engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
431
+ )
432
+ sandbox = DockerSandbox(state)
433
+ request = ExecRequest(command="ls", timeout=30, output_limit=2_097_152)
434
+ cmd = sandbox._build_exec_command(request)
435
+
436
+ assert cmd[0] == "docker"
437
+ assert cmd[1] == "exec"
438
+ assert "-H" not in cmd
439
+
440
+
441
+ class TestDockerBackendRunCommand:
442
+ """Tests for DockerBackend._build_run_command method."""
443
+
444
+ def test_build_run_command_defaults(self, default_config):
445
+ """Default config produces expected docker run command."""
446
+ backend = DockerBackend(default_config)
447
+ cmd = backend._build_run_command()
448
+
449
+ assert cmd[0] == "run"
450
+ assert "-d" in cmd
451
+ assert "--rm" in cmd
452
+ assert "--label" in cmd
453
+ assert "kilntainers=true" in cmd
454
+ assert "--network" not in cmd
455
+ assert "none" not in cmd
456
+ assert default_config.image in cmd
457
+ assert "tail" in cmd
458
+ assert "-f" in cmd
459
+ assert "/dev/null" in cmd
460
+
461
+ def test_build_run_command_network_enabled(self):
462
+ """Network enabled omits --network none."""
463
+ config = DockerBackendConfig(network_enabled=True)
464
+ backend = DockerBackend(config)
465
+ cmd = backend._build_run_command()
466
+
467
+ assert "--network" not in cmd
468
+ assert "none" not in cmd
469
+
470
+ def test_build_run_command_with_resources(self):
471
+ """CPU and memory limits are added."""
472
+ config = DockerBackendConfig(cpu="1.5", memory="512m")
473
+ backend = DockerBackend(config)
474
+ cmd = backend._build_run_command()
475
+
476
+ assert "--cpus" in cmd
477
+ assert "1.5" in cmd
478
+ assert "--memory" in cmd
479
+ assert "512m" in cmd
480
+
481
+ def test_build_run_command_with_custom_flags(self):
482
+ """Custom flags are appended."""
483
+ config = DockerBackendConfig(
484
+ docker_run_flags=["--read-only", "--pids-limit=256"]
485
+ )
486
+ backend = DockerBackend(config)
487
+ cmd = backend._build_run_command()
488
+
489
+ assert "--read-only" in cmd
490
+ assert "--pids-limit=256" in cmd
491
+
492
+
493
+ # --- DockerSandbox tests ---
494
+
495
+
496
+ class TestDockerSandboxExecCommandConstruction:
497
+ """Tests for DockerSandbox._build_exec_command method."""
498
+
499
+ @pytest.fixture
500
+ def sandbox(self, default_config):
501
+ """Create a test sandbox."""
502
+ state = _DockerSandboxState(
503
+ engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
504
+ )
505
+ return DockerSandbox(state)
506
+
507
+ def test_command_mode(self, sandbox):
508
+ """Command mode wraps in shell."""
509
+ request = ExecRequest(command="ls -la", timeout=30, output_limit=2_097_152)
510
+ cmd = sandbox._build_exec_command(request)
511
+
512
+ assert "docker" in cmd
513
+ assert "exec" in cmd
514
+ assert "a" * 64 in cmd
515
+ assert "/bin/bash" in cmd
516
+ assert "-c" in cmd
517
+ assert "ls -la" in cmd
518
+ assert "-i" not in cmd
519
+
520
+ def test_args_mode(self, sandbox):
521
+ """Args mode passes directly."""
522
+ request = ExecRequest(
523
+ args=["python3", "script.py"], timeout=30, output_limit=2_097_152
524
+ )
525
+ cmd = sandbox._build_exec_command(request)
526
+
527
+ assert "docker" in cmd
528
+ assert "exec" in cmd
529
+ assert "a" * 64 in cmd
530
+ assert "python3" in cmd
531
+ assert "script.py" in cmd
532
+ assert "/bin/bash" not in cmd
533
+ assert "-c" not in cmd
534
+ assert "-i" not in cmd
535
+
536
+ def test_with_stdin(self, sandbox):
537
+ """Stdin adds -i flag."""
538
+ request = ExecRequest(
539
+ command="cat", stdin="hello", timeout=30, output_limit=2_097_152
540
+ )
541
+ cmd = sandbox._build_exec_command(request)
542
+
543
+ assert "-i" in cmd
544
+
545
+ def test_with_working_directory(self, sandbox):
546
+ """Working directory adds -w flag."""
547
+ request = ExecRequest(
548
+ command="pwd", working_directory="/tmp", timeout=30, output_limit=2_097_152
549
+ )
550
+ cmd = sandbox._build_exec_command(request)
551
+
552
+ assert "-w" in cmd
553
+ assert "/tmp" in cmd
554
+
555
+ def test_all_options(self, sandbox):
556
+ """All options combined."""
557
+ request = ExecRequest(
558
+ command="make",
559
+ stdin="",
560
+ working_directory="/app",
561
+ timeout=30,
562
+ output_limit=2_097_152,
563
+ )
564
+ cmd = sandbox._build_exec_command(request)
565
+
566
+ assert "-i" in cmd
567
+ assert "-w" in cmd
568
+ assert "/app" in cmd
569
+ assert "/bin/bash" in cmd
570
+ assert "-c" in cmd
571
+ assert "make" in cmd
572
+
573
+
574
+ class TestDockerSandboxExec:
575
+ """Tests for DockerSandbox.exec method."""
576
+
577
+ @pytest.fixture
578
+ def sandbox(self, default_config):
579
+ """Create a test sandbox."""
580
+ state = _DockerSandboxState(
581
+ engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
582
+ )
583
+ return DockerSandbox(state)
584
+
585
+ @pytest.mark.asyncio
586
+ async def test_exec_success(self, monkeypatch, sandbox):
587
+ """Successful command execution."""
588
+
589
+ async def create_mock(*args, **kwargs):
590
+ return MockProcess(0, stdout=b"hello\n", stderr=b"")
591
+
592
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
593
+
594
+ request = ExecRequest(command="echo hello", timeout=30, output_limit=2_097_152)
595
+ result = await sandbox.exec(request)
596
+
597
+ assert result.stdout == "hello\n"
598
+ assert result.stderr == ""
599
+ assert result.exit_code == 0
600
+ assert result.exec_duration_ms >= 0
601
+
602
+ @pytest.mark.asyncio
603
+ async def test_exec_failure(self, monkeypatch, sandbox):
604
+ """Command fails with non-zero exit."""
605
+
606
+ async def create_mock(*args, **kwargs):
607
+ if "inspect" in args:
608
+ # Container is still running
609
+ return MockProcess(0, stdout=b"true")
610
+ # Command execution fails
611
+ return MockProcess(1, stdout=b"", stderr=b"error: not found\n")
612
+
613
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
614
+
615
+ request = ExecRequest(command="false", timeout=30, output_limit=2_097_152)
616
+ result = await sandbox.exec(request)
617
+
618
+ assert result.exit_code == 1
619
+ assert "error: not found" in result.stderr
620
+
621
+ @pytest.mark.asyncio
622
+ async def test_exec_timeout(self, monkeypatch, sandbox):
623
+ """Command times out."""
624
+
625
+ async def create_mock(*args, **kwargs):
626
+ # Create a process with blocking stream readers
627
+ proc = MockProcess(0, block_stdout=True, block_stderr=True)
628
+ return proc
629
+
630
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
631
+
632
+ request = ExecRequest(command="sleep 60", timeout=1, output_limit=2_097_152)
633
+ result = await sandbox.exec(request)
634
+
635
+ assert result.exit_code == 124
636
+ assert "timed out" in result.stderr
637
+ assert result.stdout == ""
638
+
639
+ @pytest.mark.asyncio
640
+ async def test_exec_output_limit(self, monkeypatch, sandbox):
641
+ """Output limit exceeded."""
642
+
643
+ async def create_mock(*args, **kwargs):
644
+ # Create a process that generates lots of output
645
+ proc = MockProcess(0, stdout=b"x" * 10000, stderr=b"y" * 10000)
646
+ return proc
647
+
648
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
649
+
650
+ request = ExecRequest(command="yes", timeout=30, output_limit=1000)
651
+ result = await sandbox.exec(request)
652
+
653
+ assert result.exit_code == 1
654
+ assert "output limit exceeded" in result.stderr
655
+ assert result.stdout == ""
656
+
657
+ @pytest.mark.asyncio
658
+ async def test_exec_after_stop(self, sandbox):
659
+ """Exec after stop raises SandboxDiedError."""
660
+ sandbox._stopped = True
661
+
662
+ request = ExecRequest(command="echo", timeout=30, output_limit=2_097_152)
663
+
664
+ with pytest.raises(SandboxDiedError) as exc_info:
665
+ await sandbox.exec(request)
666
+
667
+ assert "stopped" in str(exc_info.value)
668
+
669
+ @pytest.mark.asyncio
670
+ async def test_exec_serialization(self, monkeypatch, sandbox):
671
+ """Concurrent exec calls are serialized."""
672
+
673
+ exec_count = [0]
674
+ in_exec = asyncio.Event()
675
+
676
+ async def mock_wait(self):
677
+ exec_count[0] += 1
678
+ in_exec.set()
679
+ # Wait a bit to verify serialization
680
+ await asyncio.sleep(0.1)
681
+ return 0
682
+
683
+ async def create_mock(*args, **kwargs):
684
+ proc = MockProcess(0, stdout=b"done\n", stderr=b"")
685
+ proc.wait = mock_wait.__get__(proc, MockProcess) # type: ignore[method-assign]
686
+ return proc
687
+
688
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
689
+
690
+ request = ExecRequest(command="echo test", timeout=30, output_limit=2_097_152)
691
+
692
+ # Start two exec calls concurrently
693
+ task1 = asyncio.create_task(sandbox.exec(request))
694
+ task2 = asyncio.create_task(sandbox.exec(request))
695
+
696
+ # Wait for first to start
697
+ await in_exec.wait()
698
+
699
+ # Both should complete
700
+ await task1
701
+ await task2
702
+
703
+ # Due to lock, they should have run serially
704
+ assert exec_count[0] == 2
705
+
706
+
707
+ class TestDockerSandboxStop:
708
+ """Tests for DockerSandbox.stop method."""
709
+
710
+ @pytest.fixture
711
+ def sandbox(self, default_config):
712
+ """Create a test sandbox."""
713
+ state = _DockerSandboxState(
714
+ engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
715
+ )
716
+ return DockerSandbox(state)
717
+
718
+ @pytest.mark.asyncio
719
+ async def test_stop(self, monkeypatch, sandbox):
720
+ """Stop calls docker stop."""
721
+ stop_called = [False]
722
+
723
+ async def create_mock(*args, **kwargs):
724
+ cmd = args[1] if len(args) > 1 else ""
725
+ if "stop" in cmd:
726
+ stop_called[0] = True
727
+ return MockProcess(0)
728
+ return MockProcess(0)
729
+
730
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
731
+
732
+ await sandbox.stop()
733
+
734
+ assert sandbox._stopped
735
+ assert sandbox._stop_requested
736
+ assert stop_called[0]
737
+
738
+ @pytest.mark.asyncio
739
+ async def test_stop_idempotent(self, monkeypatch, sandbox):
740
+ """Stop is idempotent."""
741
+ stop_count = [0]
742
+
743
+ async def create_mock(*args, **kwargs):
744
+ cmd = args[1] if len(args) > 1 else ""
745
+ if "stop" in cmd:
746
+ stop_count[0] += 1
747
+ return MockProcess(0)
748
+ return MockProcess(0)
749
+
750
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
751
+
752
+ await sandbox.stop()
753
+ await sandbox.stop()
754
+ await sandbox.stop()
755
+
756
+ assert stop_count[0] == 1 # Only called once
757
+
758
+
759
+ class TestDockerSandboxDeathDetection:
760
+ """Tests for DockerSandbox.wait_for_death method."""
761
+
762
+ @pytest.fixture
763
+ def sandbox(self, default_config):
764
+ """Create a test sandbox."""
765
+ state = _DockerSandboxState(
766
+ engine="docker", host=None, shell="/bin/bash", container_id="a" * 64
767
+ )
768
+ return DockerSandbox(state)
769
+
770
+ @pytest.mark.asyncio
771
+ async def test_wait_for_death_unexpected_exit(self, monkeypatch, sandbox):
772
+ """Container exits unexpectedly."""
773
+
774
+ async def create_mock(*args, **kwargs):
775
+ cmd = args[1] if len(args) > 1 else ""
776
+ if "wait" in cmd:
777
+ # Container exits immediately
778
+ return MockProcess(0)
779
+ return MockProcess(0)
780
+
781
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
782
+
783
+ # Should return when container exits unexpectedly
784
+ await sandbox.wait_for_death()
785
+
786
+ @pytest.mark.asyncio
787
+ async def test_wait_for_death_with_stop_requested(self, monkeypatch, sandbox):
788
+ """Container exits after stop is requested."""
789
+
790
+ async def create_mock(*args, **kwargs):
791
+ cmd = args[1] if len(args) > 1 else ""
792
+ if "wait" in cmd:
793
+ # Container exits
794
+ return MockProcess(0)
795
+ return MockProcess(0)
796
+
797
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
798
+
799
+ sandbox._stop_requested = True
800
+
801
+ # Should block forever (we'll cancel it)
802
+ task = asyncio.create_task(sandbox.wait_for_death())
803
+ await asyncio.sleep(0.1)
804
+
805
+ # Task should still be running
806
+ assert not task.done()
807
+
808
+ # Cancel the task
809
+ task.cancel()
810
+ await task
811
+
812
+ @pytest.mark.asyncio
813
+ async def test_wait_for_death_cancellation(self, monkeypatch, sandbox):
814
+ """Task is cancelled before container exits."""
815
+
816
+ async def create_mock(*args, **kwargs):
817
+ if "wait" in args:
818
+ # Create a process that completes immediately
819
+ return MockProcess(0)
820
+ return MockProcess(0)
821
+
822
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", create_mock)
823
+
824
+ # When container exits immediately, wait_for_death should return
825
+ await sandbox.wait_for_death()
826
+
827
+
828
+ class TestDockerSandboxSandboxId:
829
+ """Tests for DockerSandbox.sandbox_id property."""
830
+
831
+ def test_sandbox_id(self, default_config):
832
+ """sandbox_id returns first 12 chars of container_id."""
833
+ state = _DockerSandboxState(
834
+ engine="docker",
835
+ host=None,
836
+ shell="/bin/bash",
837
+ container_id="abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
838
+ )
839
+ sandbox = DockerSandbox(state)
840
+
841
+ assert sandbox.sandbox_id == "abcd12345678"