QEMU command line: - escape commas in every interpolated path (qopt); a path like 'data,readonly=on.qcow2' previously injected a drive option - reject extra_args flags that breach VM isolation (host filesystem passthrough, host block devices, spawning chardevs, -runas) and document the parameter as operator-only - detect duplicate host ports across port_forwards instead of failing at QEMU launch; auto ports no longer collide with each other Sandbox isolation: - sandbox_vm now blocks guest-initiated traffic by default (restrict=on), with allow_network=True to opt in. Verified end to end: with identical guest network state, a default sandbox reaches neither a host loopback service nor the internet, while allow_network=True reaches both - note in the docstring that the guest agent answers before the guest has finished booting Bounded work per call: - vm_serial_read seeks a 256KB window from the end instead of reading a console log that grows without bound into memory - cap vm_type_text length and vm_mouse_move deltas - screenshots get a unique filename and are cleaned up, so a concurrent capture cannot swap the frame under vm_click Identity and liveness: - attach_vm requires an actual unix socket and stores the resolved path - attached VMs are judged by connecting, not by a stat that a stale socket file would pass - refuse to act on a PID whose cmdline proves it is a different VM - a sandbox's base image counts as in use while its overlay is live - fix a latent NameError in vm_mouse_move's homing branch
158 lines
5.1 KiB
Python
158 lines
5.1 KiB
Python
"""Port-forward resolution and one-shot sandbox tools."""
|
|
|
|
import socket
|
|
|
|
import pytest
|
|
from fastmcp import Client
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
from conftest import result_data, write_registry
|
|
from mcqemu.launcher import resolve_port_forwards
|
|
from mcqemu.server import mcp
|
|
from test_lifecycle import seeded_record
|
|
|
|
|
|
def test_resolve_explicit_free_port():
|
|
assert resolve_port_forwards(["18765:22"]) == ["18765:22"]
|
|
|
|
|
|
def test_resolve_auto_picks_free_port():
|
|
(entry,) = resolve_port_forwards(["auto:22"])
|
|
host, guest = entry.split(":")
|
|
assert guest == "22"
|
|
assert 1 <= int(host) <= 65535
|
|
|
|
|
|
def test_resolve_bare_guest_port_means_auto():
|
|
(entry,) = resolve_port_forwards(["80"])
|
|
assert entry.endswith(":80")
|
|
|
|
|
|
def test_resolve_busy_port_fails_fast():
|
|
with socket.socket() as blocker:
|
|
blocker.bind(("", 0))
|
|
busy = blocker.getsockname()[1]
|
|
with pytest.raises(ToolError, match="already in use"):
|
|
resolve_port_forwards([f"{busy}:22"])
|
|
|
|
|
|
def test_resolve_rejects_garbage():
|
|
for bad in ["22->2222", "x:y", "auto:notaport", "0:0"]:
|
|
with pytest.raises(ToolError):
|
|
resolve_port_forwards([bad])
|
|
|
|
|
|
@pytest.fixture
|
|
def base_image(tmp_path):
|
|
"""A real minimal qcow2 to serve as sandbox base."""
|
|
import subprocess
|
|
|
|
path = tmp_path / "base.qcow2"
|
|
subprocess.run(
|
|
["qemu-img", "create", "-f", "qcow2", str(path), "256M"],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_launch(dirs, monkeypatch):
|
|
"""Replace the real spawn with one that registers a plausible record."""
|
|
|
|
async def fake_launch_vm(name, disks, memory_mb, cpus, port_forwards, ctx, **kwargs):
|
|
from mcqemu.launcher import resolve_port_forwards
|
|
from mcqemu.tools._common import app
|
|
|
|
state = app(ctx)
|
|
record = seeded_record(
|
|
dirs, name, accel="kvm", config={"disks": disks, "port_forwards": port_forwards}
|
|
)
|
|
state.registry.add(record)
|
|
return {
|
|
"name": name,
|
|
"status": "running",
|
|
"accel": "kvm",
|
|
"port_forwards": resolve_port_forwards(port_forwards),
|
|
"restrict_net": kwargs.get("restrict_net"),
|
|
}
|
|
|
|
monkeypatch.setattr("mcqemu.tools.sandbox.launch_vm", fake_launch_vm)
|
|
|
|
|
|
@pytest.fixture
|
|
def agent_up(monkeypatch):
|
|
async def responding(record):
|
|
return True
|
|
|
|
monkeypatch.setattr("mcqemu.tools.sandbox._agent_responding", responding)
|
|
|
|
|
|
async def test_sandbox_vm_creates_overlay_and_waits(dirs, base_image, fake_launch, agent_up):
|
|
async with Client(mcp) as client:
|
|
data = result_data(await client.call_tool("sandbox_vm", {"base_image": str(base_image)}))
|
|
assert data["name"] == "sandbox"
|
|
assert data["sandbox"] is True
|
|
assert data["guest_agent"] == "responding"
|
|
assert data["port_forwards"][0].endswith(":22")
|
|
overlay = dirs.state / "vms" / "sandbox" / "overlay.qcow2"
|
|
assert overlay.exists()
|
|
assert data["overlay"] == str(overlay)
|
|
|
|
|
|
async def test_sandbox_auto_names_avoid_live_collisions(
|
|
dirs, base_image, fake_launch, agent_up, all_pids_alive
|
|
):
|
|
write_registry(dirs, seeded_record(dirs, "sandbox"))
|
|
async with Client(mcp) as client:
|
|
data = result_data(await client.call_tool("sandbox_vm", {"base_image": str(base_image)}))
|
|
assert data["name"] == "sandbox-2"
|
|
|
|
|
|
async def test_sandbox_auto_name_reuses_dead(
|
|
dirs, base_image, fake_launch, agent_up, all_pids_dead
|
|
):
|
|
write_registry(dirs, seeded_record(dirs, "sandbox"))
|
|
async with Client(mcp) as client:
|
|
data = result_data(await client.call_tool("sandbox_vm", {"base_image": str(base_image)}))
|
|
assert data["name"] == "sandbox"
|
|
|
|
|
|
async def test_sandbox_agent_timeout_reports_hint(dirs, base_image, fake_launch, monkeypatch):
|
|
async def never(record):
|
|
return False
|
|
|
|
monkeypatch.setattr("mcqemu.tools.sandbox._agent_responding", never)
|
|
async with Client(mcp) as client:
|
|
data = result_data(
|
|
await client.call_tool("sandbox_vm", {"base_image": str(base_image), "wait_agent_s": 0})
|
|
)
|
|
assert data["guest_agent"] == "unavailable"
|
|
assert "qemu-guest-agent" in data["agent_hint"]
|
|
|
|
|
|
async def test_sandbox_destroy_removes_overlay(dirs, all_pids_dead):
|
|
overlay = dirs.state / "vms" / "sb" / "overlay.qcow2"
|
|
overlay.parent.mkdir(parents=True)
|
|
overlay.write_bytes(b"fake")
|
|
write_registry(
|
|
dirs,
|
|
seeded_record(
|
|
dirs, "sb", config={"disks": [str(overlay)], "sandbox_overlay": str(overlay)}
|
|
),
|
|
)
|
|
async with Client(mcp) as client:
|
|
data = result_data(await client.call_tool("sandbox_destroy", {"name": "sb"}))
|
|
assert data["destroyed"] is True
|
|
assert data["overlay_deleted"] is True
|
|
assert not overlay.exists()
|
|
result = result_data(await client.call_tool("list_vms", {}))
|
|
assert result["vms"] == []
|
|
|
|
|
|
async def test_sandbox_destroy_refuses_non_sandbox(dirs, all_pids_dead):
|
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
|
async with Client(mcp) as client:
|
|
with pytest.raises(ToolError, match="not created by sandbox_vm"):
|
|
await client.call_tool("sandbox_destroy", {"name": "vm1"})
|