"""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"})