Close the remaining review findings: injection, isolation, unbounded work
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
This commit is contained in:
parent
f053762c4e
commit
5a2d4703ab
@ -49,7 +49,8 @@ select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
|||||||
# ASYNC109: `timeout` params here are deliberate LLM-facing tool parameters.
|
# ASYNC109: `timeout` params here are deliberate LLM-facing tool parameters.
|
||||||
# ASYNC110: polling an external process's death has no event to await.
|
# ASYNC110: polling an external process's death has no event to await.
|
||||||
# ASYNC240: pathlib use is microsecond stat/exists checks, not bulk I/O.
|
# ASYNC240: pathlib use is microsecond stat/exists checks, not bulk I/O.
|
||||||
ignore = ["ASYNC109", "ASYNC110", "ASYNC240"]
|
# ASYNC230: the one blocking read is a bounded 256KB tail, not bulk I/O.
|
||||||
|
ignore = ["ASYNC109", "ASYNC110", "ASYNC230", "ASYNC240"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from fastmcp.exceptions import ToolError
|
|||||||
from .config import DEFAULT_MACHINE, FIRMWARE_MAP, Config, host_arch, kvm_available
|
from .config import DEFAULT_MACHINE, FIRMWARE_MAP, Config, host_arch, kvm_available
|
||||||
from .models import VMConfig, VMRecord
|
from .models import VMConfig, VMRecord
|
||||||
from .qemu_img import probe_format
|
from .qemu_img import probe_format
|
||||||
|
from .registry import socket_is_live
|
||||||
|
|
||||||
_FWD_RE = re.compile(r"^(\d{1,5}):(\d{1,5})$")
|
_FWD_RE = re.compile(r"^(\d{1,5}):(\d{1,5})$")
|
||||||
|
|
||||||
@ -58,25 +59,6 @@ def pick_free_port() -> int:
|
|||||||
return s.getsockname()[1]
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
def socket_is_live(path: Path) -> bool:
|
|
||||||
"""True if something is still accepting connections on this unix socket.
|
|
||||||
|
|
||||||
A leftover socket *file* is normal after a crash; a socket that still
|
|
||||||
accepts means a process owns this VM's name and must not be trampled.
|
|
||||||
"""
|
|
||||||
if not path.exists():
|
|
||||||
return False
|
|
||||||
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
try:
|
|
||||||
probe.settimeout(0.2)
|
|
||||||
probe.connect(str(path))
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
probe.close()
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_port_forwards(forwards: list[str]) -> list[str]:
|
def resolve_port_forwards(forwards: list[str]) -> list[str]:
|
||||||
"""Turn user forward specs into concrete 'HOSTPORT:GUESTPORT' entries.
|
"""Turn user forward specs into concrete 'HOSTPORT:GUESTPORT' entries.
|
||||||
|
|
||||||
@ -86,6 +68,7 @@ def resolve_port_forwards(forwards: list[str]) -> list[str]:
|
|||||||
instead of a cryptic QEMU launch error.
|
instead of a cryptic QEMU launch error.
|
||||||
"""
|
"""
|
||||||
resolved: list[str] = []
|
resolved: list[str] = []
|
||||||
|
claimed: set[int] = set()
|
||||||
for fwd in forwards:
|
for fwd in forwards:
|
||||||
host, sep, guest = fwd.partition(":")
|
host, sep, guest = fwd.partition(":")
|
||||||
if not sep:
|
if not sep:
|
||||||
@ -97,10 +80,17 @@ def resolve_port_forwards(forwards: list[str]) -> list[str]:
|
|||||||
)
|
)
|
||||||
if host in ("auto", "0"):
|
if host in ("auto", "0"):
|
||||||
port = pick_free_port()
|
port = pick_free_port()
|
||||||
|
while port in claimed: # each bind test closes before the next runs
|
||||||
|
port = pick_free_port()
|
||||||
else:
|
else:
|
||||||
if not host.isdigit() or not 1 <= int(host) <= 65535:
|
if not host.isdigit() or not 1 <= int(host) <= 65535:
|
||||||
raise ToolError(f"Invalid host port in {fwd!r} — use a number or 'auto'.")
|
raise ToolError(f"Invalid host port in {fwd!r} — use a number or 'auto'.")
|
||||||
port = int(host)
|
port = int(host)
|
||||||
|
if port in claimed:
|
||||||
|
raise ToolError(
|
||||||
|
f"Host port {port} is requested twice in port_forwards; QEMU would "
|
||||||
|
"fail to launch. Use 'auto' for one of them."
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with socket.socket() as s:
|
with socket.socket() as s:
|
||||||
s.bind(("", port))
|
s.bind(("", port))
|
||||||
@ -109,10 +99,54 @@ def resolve_port_forwards(forwards: list[str]) -> list[str]:
|
|||||||
f"Host port {port} is already in use ({e.strerror}) — "
|
f"Host port {port} is already in use ({e.strerror}) — "
|
||||||
f"pick another, or use 'auto:{guest}' to grab a free one."
|
f"pick another, or use 'auto:{guest}' to grab a free one."
|
||||||
) from e
|
) from e
|
||||||
|
claimed.add(port)
|
||||||
resolved.append(f"{port}:{guest}")
|
resolved.append(f"{port}:{guest}")
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def qopt(value: str | Path) -> str:
|
||||||
|
"""Escape a value for QEMU's comma-separated option syntax.
|
||||||
|
|
||||||
|
QEMU splits options on commas and reads a doubled comma as a literal one.
|
||||||
|
A path like 'data,readonly=on.qcow2' would otherwise inject an option.
|
||||||
|
"""
|
||||||
|
return str(value).replace(",", ",,")
|
||||||
|
|
||||||
|
|
||||||
|
# Flags that would let a "sandbox" reach the host filesystem, host devices, or
|
||||||
|
# spawn host processes. extra_args is an operator escape hatch, but an agent
|
||||||
|
# acting on untrusted input must not be able to open these doors.
|
||||||
|
_UNSAFE_EXTRA_ARGS = {
|
||||||
|
"-fsdev": "host filesystem passthrough",
|
||||||
|
"-virtfs": "host filesystem passthrough",
|
||||||
|
"-runas": "changes the QEMU process user",
|
||||||
|
"-monitor": "exposes the human monitor",
|
||||||
|
"-qmp": "would collide with the managed QMP socket",
|
||||||
|
"-pidfile": "would collide with the managed pidfile",
|
||||||
|
"-daemonize": "already applied by the launcher",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_extra_args(extra: list[str]) -> None:
|
||||||
|
"""Reject the escape-hatch flags that break the sandbox's promises."""
|
||||||
|
for i, token in enumerate(extra):
|
||||||
|
flag = token.split("=", 1)[0]
|
||||||
|
if flag in _UNSAFE_EXTRA_ARGS:
|
||||||
|
raise ToolError(
|
||||||
|
f"extra_args may not contain {flag!r} ({_UNSAFE_EXTRA_ARGS[flag]}). "
|
||||||
|
"Remove it, or start QEMU yourself and manage it with attach_vm."
|
||||||
|
)
|
||||||
|
if flag == "-drive" or flag == "-blockdev":
|
||||||
|
value = extra[i + 1] if flag == token and i + 1 < len(extra) else token
|
||||||
|
if "/dev/" in value:
|
||||||
|
raise ToolError(
|
||||||
|
"extra_args may not attach host block devices (/dev/...). Use a "
|
||||||
|
"disk image created with image_create."
|
||||||
|
)
|
||||||
|
if flag == "-chardev" and "spawn" in token:
|
||||||
|
raise ToolError("extra_args may not use a spawning chardev (runs host commands).")
|
||||||
|
|
||||||
|
|
||||||
def build_cmdline(
|
def build_cmdline(
|
||||||
cfg: VMConfig,
|
cfg: VMConfig,
|
||||||
paths: VMPaths,
|
paths: VMPaths,
|
||||||
@ -144,19 +178,19 @@ def build_cmdline(
|
|||||||
if fw["mode"] == "pflash":
|
if fw["mode"] == "pflash":
|
||||||
args += [
|
args += [
|
||||||
"-drive",
|
"-drive",
|
||||||
f"if=pflash,format=raw,readonly=on,file={fw['code']}",
|
f"if=pflash,format=raw,readonly=on,file={qopt(fw['code'])}",
|
||||||
"-drive",
|
"-drive",
|
||||||
f"if=pflash,format=raw,file={paths.uefi_vars}",
|
f"if=pflash,format=raw,file={qopt(paths.uefi_vars)}",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
args += ["-bios", fw["code"]]
|
args += ["-bios", fw["code"]]
|
||||||
|
|
||||||
for disk in cfg.disks:
|
for disk in cfg.disks:
|
||||||
fmt = disk_formats[disk]
|
fmt = disk_formats[disk]
|
||||||
args += ["-drive", f"file={disk},if=virtio,format={fmt}"]
|
args += ["-drive", f"file={qopt(disk)},if=virtio,format={fmt}"]
|
||||||
|
|
||||||
if cfg.iso:
|
if cfg.iso:
|
||||||
args += ["-drive", f"file={cfg.iso},media=cdrom,readonly=on"]
|
args += ["-drive", f"file={qopt(cfg.iso)},media=cdrom,readonly=on"]
|
||||||
if cfg.disks:
|
if cfg.disks:
|
||||||
# Boot the installer once; subsequent boots hit the disk.
|
# Boot the installer once; subsequent boots hit the disk.
|
||||||
args += ["-boot", "once=d"]
|
args += ["-boot", "once=d"]
|
||||||
@ -165,6 +199,10 @@ def build_cmdline(
|
|||||||
args += ["-nic", "none"]
|
args += ["-nic", "none"]
|
||||||
else:
|
else:
|
||||||
netdev = "user,id=net0"
|
netdev = "user,id=net0"
|
||||||
|
if cfg.restrict_net:
|
||||||
|
# Guest-initiated traffic is dropped: no internet, and no reaching
|
||||||
|
# host services on 10.0.2.2. Inbound hostfwd still works.
|
||||||
|
netdev += ",restrict=on"
|
||||||
for fwd in cfg.port_forwards:
|
for fwd in cfg.port_forwards:
|
||||||
m = _FWD_RE.match(fwd)
|
m = _FWD_RE.match(fwd)
|
||||||
if not m:
|
if not m:
|
||||||
@ -178,11 +216,11 @@ def build_cmdline(
|
|||||||
"-display",
|
"-display",
|
||||||
"none",
|
"none",
|
||||||
"-serial",
|
"-serial",
|
||||||
f"file:{paths.serial_log}",
|
f"file:{qopt(paths.serial_log)}",
|
||||||
"-qmp",
|
"-qmp",
|
||||||
f"unix:{paths.qmp_socket},server=on,wait=off",
|
f"unix:{qopt(paths.qmp_socket)},server=on,wait=off",
|
||||||
"-chardev",
|
"-chardev",
|
||||||
f"socket,id=qga0,path={paths.qga_socket},server=on,wait=off",
|
f"socket,id=qga0,path={qopt(paths.qga_socket)},server=on,wait=off",
|
||||||
"-device",
|
"-device",
|
||||||
"virtio-serial",
|
"virtio-serial",
|
||||||
"-device",
|
"-device",
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class VMConfig:
|
|||||||
firmware: Literal["bios", "uefi"] = "bios"
|
firmware: Literal["bios", "uefi"] = "bios"
|
||||||
port_forwards: list[str] = field(default_factory=list)
|
port_forwards: list[str] = field(default_factory=list)
|
||||||
no_net: bool = False
|
no_net: bool = False
|
||||||
|
restrict_net: bool = False
|
||||||
extra_args: list[str] = field(default_factory=list)
|
extra_args: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import contextlib
|
|||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable, Iterator
|
from collections.abc import Callable, Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -25,6 +26,45 @@ from .models import VMRecord
|
|||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
def socket_is_live(path: Path) -> bool:
|
||||||
|
"""True if something is still accepting connections on this unix socket.
|
||||||
|
|
||||||
|
A leftover socket *file* is normal after a crash; a socket that still
|
||||||
|
accepts means a process owns it and must not be trampled.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
try:
|
||||||
|
probe.settimeout(0.2)
|
||||||
|
probe.connect(str(path))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
probe.close()
|
||||||
|
|
||||||
|
|
||||||
|
def pid_matches_vm(pid: int | None, name: str) -> bool:
|
||||||
|
"""False only when we can positively prove this PID is a *different* VM.
|
||||||
|
|
||||||
|
Pairs with pid_alive: after a pidfile clobber a record can point at some
|
||||||
|
other QEMU, and acting on it (quit, SIGKILL) would hit the wrong machine.
|
||||||
|
Unreadable cmdline means 'cannot prove otherwise', so we do not contradict
|
||||||
|
the liveness check on that basis alone.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parts = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
if b"-name" not in parts:
|
||||||
|
return True
|
||||||
|
index = parts.index(b"-name")
|
||||||
|
if index + 1 >= len(parts):
|
||||||
|
return True
|
||||||
|
return parts[index + 1].decode(errors="replace") == name
|
||||||
|
|
||||||
|
|
||||||
def pid_alive(pid: int | None) -> bool:
|
def pid_alive(pid: int | None) -> bool:
|
||||||
"""True iff the PID exists AND is actually a qemu-system process.
|
"""True iff the PID exists AND is actually a qemu-system process.
|
||||||
|
|
||||||
@ -207,11 +247,15 @@ class VMRegistry:
|
|||||||
|
|
||||||
def is_process_alive(self, record: VMRecord) -> bool:
|
def is_process_alive(self, record: VMRecord) -> bool:
|
||||||
"""Liveness for spawned VMs via pidfile; attached VMs are judged by
|
"""Liveness for spawned VMs via pidfile; attached VMs are judged by
|
||||||
their QMP socket instead (we may not know their PID)."""
|
their QMP socket instead (we may not know their PID).
|
||||||
|
|
||||||
|
A SIGKILLed QEMU leaves its socket file behind, so for attached VMs we
|
||||||
|
connect rather than just stat — a stale file must not read as alive.
|
||||||
|
"""
|
||||||
if record.source == "attached" and record.pid is None:
|
if record.source == "attached" and record.pid is None:
|
||||||
return Path(record.qmp_socket).exists()
|
return socket_is_live(Path(record.qmp_socket))
|
||||||
self.refresh_pid(record)
|
self.refresh_pid(record)
|
||||||
return pid_alive(record.pid)
|
return pid_alive(record.pid) and pid_matches_vm(record.pid, record.name)
|
||||||
|
|
||||||
def disks_in_use(self) -> dict[str, str]:
|
def disks_in_use(self) -> dict[str, str]:
|
||||||
"""Map of absolute disk path -> VM name, for every live registered VM.
|
"""Map of absolute disk path -> VM name, for every live registered VM.
|
||||||
@ -224,6 +268,11 @@ class VMRegistry:
|
|||||||
for rec in self._vms.values():
|
for rec in self._vms.values():
|
||||||
if not self.is_process_alive(rec):
|
if not self.is_process_alive(rec):
|
||||||
continue
|
continue
|
||||||
for disk in rec.config.get("disks", []):
|
disks = list(rec.config.get("disks", []))
|
||||||
|
# A sandbox's base image is read by the running VM through the
|
||||||
|
# overlay's backing chain, so it is in use too.
|
||||||
|
if rec.config.get("sandbox_base"):
|
||||||
|
disks.append(rec.config["sandbox_base"])
|
||||||
|
for disk in disks:
|
||||||
used[str(Path(disk).expanduser().resolve())] = rec.name
|
used[str(Path(disk).expanduser().resolve())] = rec.name
|
||||||
return used
|
return used
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
"""See & drive: screenshots, keyboard/mouse injection, serial console."""
|
"""See & drive: screenshots, keyboard/mouse injection, serial console."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
@ -16,6 +19,9 @@ from ..qmp import execute, qmp_session
|
|||||||
from ._common import require_vm
|
from ._common import require_vm
|
||||||
|
|
||||||
_ABS_MAX = 32767 # QEMU absolute-pointer coordinate space
|
_ABS_MAX = 32767 # QEMU absolute-pointer coordinate space
|
||||||
|
MAX_TYPE_CHARS = 4096 # each character is a separate QMP round trip
|
||||||
|
MAX_MOUSE_DELTA = 20000 # a full 4K screen traverse and then some
|
||||||
|
SERIAL_TAIL_BYTES = 256 * 1024
|
||||||
|
|
||||||
# PS/2-style relative buttons for HMP mouse_button (bitmask).
|
# PS/2-style relative buttons for HMP mouse_button (bitmask).
|
||||||
_MOUSE_BUTTONS = {"left": 1, "right": 2, "middle": 4}
|
_MOUSE_BUTTONS = {"left": 1, "right": 2, "middle": 4}
|
||||||
@ -31,14 +37,21 @@ def _png_dimensions(data: bytes) -> tuple[int, int]:
|
|||||||
async def _screendump(ctx: Context, name: str) -> bytes:
|
async def _screendump(ctx: Context, name: str) -> bytes:
|
||||||
state, record = require_vm(ctx, name)
|
state, record = require_vm(ctx, name)
|
||||||
config: Config = state.config
|
config: Config = state.config
|
||||||
dest = config.vm_state_dir(name) / "screenshot.png"
|
# Unique per call: a shared filename lets a concurrent screenshot swap the
|
||||||
|
# frame under vm_click between the capture and the size calculation, and
|
||||||
|
# leaves the guest's screen contents on disk afterwards.
|
||||||
|
dest = config.vm_state_dir(name) / f"screenshot-{uuid.uuid4().hex}.png"
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
async with qmp_session(record) as client:
|
|
||||||
await execute(client, "screendump", {"filename": str(dest), "format": "png"})
|
|
||||||
try:
|
try:
|
||||||
return dest.read_bytes()
|
async with qmp_session(record) as client:
|
||||||
except OSError as e:
|
await execute(client, "screendump", {"filename": str(dest), "format": "png"})
|
||||||
raise ToolError(f"screendump reported success but {dest} is unreadable: {e}") from e
|
try:
|
||||||
|
return dest.read_bytes()
|
||||||
|
except OSError as e:
|
||||||
|
raise ToolError(f"screendump reported success but {dest} is unreadable: {e}") from e
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
async def vm_screenshot(name: str, ctx: Context = None) -> Image:
|
async def vm_screenshot(name: str, ctx: Context = None) -> Image:
|
||||||
@ -87,6 +100,12 @@ async def vm_type_text(
|
|||||||
printable ASCII plus tab and newline). Set enter=True to press Enter at
|
printable ASCII plus tab and newline). Set enter=True to press Enter at
|
||||||
the end — handy for shell commands at a console login or terminal."""
|
the end — handy for shell commands at a console login or terminal."""
|
||||||
_, record = require_vm(ctx, name)
|
_, record = require_vm(ctx, name)
|
||||||
|
if len(text) > MAX_TYPE_CHARS:
|
||||||
|
raise ToolError(
|
||||||
|
f"text is {len(text)} characters; the limit is {MAX_TYPE_CHARS} because each "
|
||||||
|
"one is a separate keystroke round trip. Write large content with "
|
||||||
|
"guest_file_write instead."
|
||||||
|
)
|
||||||
presses: list[list[str]] = []
|
presses: list[list[str]] = []
|
||||||
for char in text:
|
for char in text:
|
||||||
try:
|
try:
|
||||||
@ -185,9 +204,15 @@ async def vm_mouse_move(
|
|||||||
raise ToolError(f"Unsupported button {click!r}: use left, right, or middle.")
|
raise ToolError(f"Unsupported button {click!r}: use left, right, or middle.")
|
||||||
if step < 1 or step > 120:
|
if step < 1 or step > 120:
|
||||||
raise ToolError("step must be between 1 and 120 (PS/2 deltas are small signed bytes).")
|
raise ToolError("step must be between 1 and 120 (PS/2 deltas are small signed bytes).")
|
||||||
|
if abs(dx) > MAX_MOUSE_DELTA or abs(dy) > MAX_MOUSE_DELTA:
|
||||||
|
raise ToolError(
|
||||||
|
f"dx/dy must be within +/-{MAX_MOUSE_DELTA}: motion is sent in {step}px "
|
||||||
|
"packets, so a larger delta would issue an unbounded number of commands."
|
||||||
|
)
|
||||||
|
|
||||||
_, record = require_vm(ctx, name)
|
_, record = require_vm(ctx, name)
|
||||||
|
|
||||||
|
sx = sy = 0
|
||||||
homed_steps = 0
|
homed_steps = 0
|
||||||
if home is not None:
|
if home is not None:
|
||||||
# Screen size bounds how far the corner can be; overshoot a little so
|
# Screen size bounds how far the corner can be; overshoot a little so
|
||||||
@ -244,10 +269,17 @@ async def vm_serial_read(name: str, tail_lines: int = 50, ctx: Context = None) -
|
|||||||
path = Path(record.serial_log)
|
path = Path(record.serial_log)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise ToolError(f"Serial log {path} does not exist yet.")
|
raise ToolError(f"Serial log {path} does not exist yet.")
|
||||||
lines = path.read_text(errors="replace").splitlines()
|
# Read a window from the end: a chatty kernel console grows without bound
|
||||||
|
# and slurping the whole file would OOM the server managing every VM.
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
size = handle.seek(0, os.SEEK_END)
|
||||||
|
handle.seek(max(0, size - SERIAL_TAIL_BYTES))
|
||||||
|
window = handle.read().decode(errors="replace")
|
||||||
|
lines = window.splitlines()
|
||||||
return {
|
return {
|
||||||
"name": name,
|
"name": name,
|
||||||
"total_lines": len(lines),
|
"log_bytes": size,
|
||||||
|
"read_from_end_bytes": min(size, SERIAL_TAIL_BYTES),
|
||||||
"tail": "\n".join(lines[-tail_lines:]) if lines else "(serial log is empty)",
|
"tail": "\n".join(lines[-tail_lines:]) if lines else "(serial log is empty)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from fastmcp import Context, FastMCP
|
|||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
|
|
||||||
from ..config import resolve_image_path, valid_vm_name
|
from ..config import resolve_image_path, valid_vm_name
|
||||||
from ..launcher import resolve_port_forwards, spawn_vm
|
from ..launcher import check_extra_args, resolve_port_forwards, spawn_vm
|
||||||
from ..models import VMConfig
|
from ..models import VMConfig
|
||||||
from ..qmp import execute, qmp_session, recent_events, wait_for_event
|
from ..qmp import execute, qmp_session, recent_events, wait_for_event
|
||||||
from ..registry import pid_alive
|
from ..registry import pid_alive
|
||||||
@ -26,6 +26,7 @@ async def launch_vm(
|
|||||||
firmware: Literal["bios", "uefi"] = "bios",
|
firmware: Literal["bios", "uefi"] = "bios",
|
||||||
port_forwards: list[str] | None = None,
|
port_forwards: list[str] | None = None,
|
||||||
no_net: bool = False,
|
no_net: bool = False,
|
||||||
|
restrict_net: bool = False,
|
||||||
extra_args: list[str] | None = None,
|
extra_args: list[str] | None = None,
|
||||||
ctx: Context = None,
|
ctx: Context = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@ -39,12 +40,23 @@ async def launch_vm(
|
|||||||
acceleration is used automatically when the guest arch matches the host.
|
acceleration is used automatically when the guest arch matches the host.
|
||||||
The VM keeps running even if this MCP server restarts; stop it with
|
The VM keeps running even if this MCP server restarts; stop it with
|
||||||
stop_vm.
|
stop_vm.
|
||||||
|
|
||||||
|
restrict_net=True drops all guest-initiated traffic (no internet, and no
|
||||||
|
reaching services on the host) while keeping port_forwards working inbound
|
||||||
|
— use it when running untrusted software. no_net=True removes the NIC
|
||||||
|
entirely.
|
||||||
|
|
||||||
|
extra_args passes raw flags to qemu-system-* and is an operator escape
|
||||||
|
hatch: only use values you wrote yourself, never values derived from
|
||||||
|
untrusted input. Flags that would breach VM isolation (host filesystem
|
||||||
|
passthrough, host block devices, spawning chardevs) are rejected.
|
||||||
"""
|
"""
|
||||||
state = app(ctx)
|
state = app(ctx)
|
||||||
if not valid_vm_name(name):
|
if not valid_vm_name(name):
|
||||||
raise ToolError(
|
raise ToolError(
|
||||||
f"Invalid VM name {name!r}: use letters, digits, '.', '_', '-' (max 48 chars)."
|
f"Invalid VM name {name!r}: use letters, digits, '.', '_', '-' (max 48 chars)."
|
||||||
)
|
)
|
||||||
|
check_extra_args(extra_args or [])
|
||||||
|
|
||||||
# Pick up records written by another instance (or another in-flight call)
|
# Pick up records written by another instance (or another in-flight call)
|
||||||
# before deciding this name is free.
|
# before deciding this name is free.
|
||||||
@ -92,6 +104,7 @@ async def launch_vm(
|
|||||||
firmware=firmware,
|
firmware=firmware,
|
||||||
port_forwards=resolve_port_forwards(port_forwards or []),
|
port_forwards=resolve_port_forwards(port_forwards or []),
|
||||||
no_net=no_net,
|
no_net=no_net,
|
||||||
|
restrict_net=restrict_net,
|
||||||
extra_args=extra_args or [],
|
extra_args=extra_args or [],
|
||||||
)
|
)
|
||||||
record = await spawn_vm(cfg, state.config)
|
record = await spawn_vm(cfg, state.config)
|
||||||
@ -190,13 +203,24 @@ async def attach_vm(
|
|||||||
)
|
)
|
||||||
if state.registry.get(name) is not None:
|
if state.registry.get(name) is not None:
|
||||||
raise ToolError(f"A VM named {name!r} is already registered. Use forget_vm first.")
|
raise ToolError(f"A VM named {name!r} is already registered. Use forget_vm first.")
|
||||||
if not Path(qmp_socket).exists():
|
sock = Path(qmp_socket).expanduser()
|
||||||
raise ToolError(f"No socket at {qmp_socket}. Is the QEMU process running with -qmp?")
|
if not sock.exists():
|
||||||
|
raise ToolError(f"No socket at {sock}. Is the QEMU process running with -qmp?")
|
||||||
|
if not sock.is_socket():
|
||||||
|
raise ToolError(
|
||||||
|
f"{sock} is not a unix socket. Point qmp_socket at the path QEMU was given "
|
||||||
|
"in -qmp unix:<path>,server=on."
|
||||||
|
)
|
||||||
|
|
||||||
from ..models import VMRecord
|
from ..models import VMRecord
|
||||||
|
|
||||||
|
# Store the resolved path: a symlink swapped later must not redirect a quit.
|
||||||
record = VMRecord(
|
record = VMRecord(
|
||||||
name=name, source="attached", qmp_socket=qmp_socket, qga_socket=qga_socket, pid=pid
|
name=name,
|
||||||
|
source="attached",
|
||||||
|
qmp_socket=str(sock.resolve()),
|
||||||
|
qga_socket=str(Path(qga_socket).expanduser().resolve()) if qga_socket else None,
|
||||||
|
pid=pid,
|
||||||
)
|
)
|
||||||
async with qmp_session(record) as client:
|
async with qmp_session(record) as client:
|
||||||
status = await execute(client, "query-status")
|
status = await execute(client, "query-status")
|
||||||
|
|||||||
@ -74,6 +74,7 @@ async def sandbox_vm(
|
|||||||
memory_mb: int = 2048,
|
memory_mb: int = 2048,
|
||||||
cpus: int = 2,
|
cpus: int = 2,
|
||||||
port_forwards: list[str] | None = None,
|
port_forwards: list[str] | None = None,
|
||||||
|
allow_network: bool = False,
|
||||||
wait_agent_s: int = 90,
|
wait_agent_s: int = 90,
|
||||||
ctx: Context = None,
|
ctx: Context = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@ -83,7 +84,17 @@ async def sandbox_vm(
|
|||||||
guest_file_* are immediately usable. By default a free host port is
|
guest_file_* are immediately usable. By default a free host port is
|
||||||
forwarded to guest port 22 (pass port_forwards=[] to disable, or your own
|
forwarded to guest port 22 (pass port_forwards=[] to disable, or your own
|
||||||
list). If no `name` is given, sandbox / sandbox-2 / ... is chosen.
|
list). If no `name` is given, sandbox / sandbox-2 / ... is chosen.
|
||||||
Tear everything down later with sandbox_destroy."""
|
Tear everything down later with sandbox_destroy.
|
||||||
|
|
||||||
|
Outbound networking is OFF by default: the guest cannot reach the internet
|
||||||
|
or any service on the host, which is what makes it a sandbox. Inbound port
|
||||||
|
forwards still work. Pass allow_network=True when the guest legitimately
|
||||||
|
needs to fetch packages.
|
||||||
|
|
||||||
|
Note that the guest agent answers well before the guest finishes booting,
|
||||||
|
so this returns while services like networking are still starting. If a
|
||||||
|
command depends on one, wait for it inside the guest (e.g. poll
|
||||||
|
'systemctl is-active NetworkManager') rather than assuming it is up."""
|
||||||
state = app(ctx)
|
state = app(ctx)
|
||||||
try:
|
try:
|
||||||
base = resolve_image_path(base_image)
|
base = resolve_image_path(base_image)
|
||||||
@ -113,6 +124,7 @@ async def sandbox_vm(
|
|||||||
memory_mb=memory_mb,
|
memory_mb=memory_mb,
|
||||||
cpus=cpus,
|
cpus=cpus,
|
||||||
port_forwards=["auto:22"] if port_forwards is None else port_forwards,
|
port_forwards=["auto:22"] if port_forwards is None else port_forwards,
|
||||||
|
restrict_net=not allow_network,
|
||||||
ctx=ctx,
|
ctx=ctx,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -149,6 +161,7 @@ async def sandbox_vm(
|
|||||||
"sandbox": True,
|
"sandbox": True,
|
||||||
"overlay": str(overlay),
|
"overlay": str(overlay),
|
||||||
"base_image": str(base),
|
"base_image": str(base),
|
||||||
|
"network": "outbound allowed" if allow_network else "outbound blocked",
|
||||||
"guest_agent": agent,
|
"guest_agent": agent,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -117,7 +117,7 @@ async def test_serial_read_tail(dirs, fake_qmp):
|
|||||||
data = result_data(
|
data = result_data(
|
||||||
await client.call_tool("vm_serial_read", {"name": "vm1", "tail_lines": 3})
|
await client.call_tool("vm_serial_read", {"name": "vm1", "tail_lines": 3})
|
||||||
)
|
)
|
||||||
assert data["total_lines"] == 100
|
assert data["log_bytes"] > 0
|
||||||
assert data["tail"] == "line97\nline98\nline99"
|
assert data["tail"] == "line97\nline98\nline99"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
"""Lifecycle tools with mocked spawn / fake QMP."""
|
"""Lifecycle tools with mocked spawn / fake QMP."""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
from fastmcp.exceptions import ToolError
|
from fastmcp.exceptions import ToolError
|
||||||
@ -140,9 +142,19 @@ async def test_attach_requires_existing_socket(client, tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_attach_rejects_a_regular_file(client, tmp_path):
|
||||||
|
"""A path that exists but is not a socket cannot be a QMP endpoint."""
|
||||||
|
plain = tmp_path / "not-a-socket"
|
||||||
|
plain.touch()
|
||||||
|
with pytest.raises(ToolError, match="not a unix socket"):
|
||||||
|
await client.call_tool("attach_vm", {"name": "ext", "qmp_socket": str(plain)})
|
||||||
|
|
||||||
|
|
||||||
async def test_attach_and_forget(dirs, fake_qmp, tmp_path):
|
async def test_attach_and_forget(dirs, fake_qmp, tmp_path):
|
||||||
sock = tmp_path / "ext.sock"
|
sock = tmp_path / "ext.sock"
|
||||||
sock.touch()
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
listener.bind(str(sock))
|
||||||
|
listener.listen(1)
|
||||||
FakeQMPClient.responses["query-status"] = {"status": "running"}
|
FakeQMPClient.responses["query-status"] = {"status": "running"}
|
||||||
async with Client(mcp) as client:
|
async with Client(mcp) as client:
|
||||||
data = result_data(
|
data = result_data(
|
||||||
@ -153,6 +165,7 @@ async def test_attach_and_forget(dirs, fake_qmp, tmp_path):
|
|||||||
assert data["forgotten"] is True
|
assert data["forgotten"] is True
|
||||||
result = result_data(await client.call_tool("list_vms", {}))
|
result = result_data(await client.call_tool("list_vms", {}))
|
||||||
assert result["vms"] == []
|
assert result["vms"] == []
|
||||||
|
listener.close()
|
||||||
|
|
||||||
|
|
||||||
async def test_forget_refuses_running_spawned_vm(dirs, all_pids_alive):
|
async def test_forget_refuses_running_spawned_vm(dirs, all_pids_alive):
|
||||||
|
|||||||
@ -9,6 +9,7 @@ mid-call, two instances clobbering each other's records.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastmcp import Client
|
from fastmcp import Client
|
||||||
@ -259,3 +260,188 @@ def test_record_name_follows_the_registry_key(dirs):
|
|||||||
registry = VMRegistry(config_for(dirs))
|
registry = VMRegistry(config_for(dirs))
|
||||||
registry.load()
|
registry.load()
|
||||||
assert registry.get("truth").name == "truth"
|
assert registry.get("truth").name == "truth"
|
||||||
|
|
||||||
|
|
||||||
|
# --- QEMU command line cannot be injected through paths ---------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_comma_in_disk_path_is_escaped():
|
||||||
|
"""A comma in a path would otherwise start a new QEMU option."""
|
||||||
|
from mcqemu.launcher import build_cmdline
|
||||||
|
from mcqemu.models import VMConfig
|
||||||
|
from test_launcher import paths
|
||||||
|
|
||||||
|
evil = "/vms/data,readonly=on,if=none.qcow2"
|
||||||
|
cfg = VMConfig(name="t", disks=[evil])
|
||||||
|
args = build_cmdline(cfg, paths(), accel="kvm", disk_formats={evil: "qcow2"})
|
||||||
|
drive = next(a for a in args if a.startswith("file=/vms/data"))
|
||||||
|
# Parse it the way QEMU does: split on single commas, un-double the rest.
|
||||||
|
options = dict(opt.split("=", 1) for opt in qemu_split(drive))
|
||||||
|
assert options["file"] == evil, "the path must survive intact"
|
||||||
|
assert options["if"] == "virtio", "the embedded 'if=none' must not become an option"
|
||||||
|
assert set(options) == {"file", "if", "format"}
|
||||||
|
|
||||||
|
|
||||||
|
def qemu_split(option_string: str) -> list[str]:
|
||||||
|
"""QEMU's option parser: ',,' is a literal comma, ',' separates options."""
|
||||||
|
return [part.replace("\0", ",") for part in option_string.replace(",,", "\0").split(",")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[
|
||||||
|
["-fsdev", "local,path=/home,security_model=none,id=h"],
|
||||||
|
["-virtfs", "local,path=/,mount_tag=root"],
|
||||||
|
["-drive", "file=/dev/sda,format=raw"],
|
||||||
|
["-runas", "root"],
|
||||||
|
["-monitor", "stdio"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_extra_args_rejects_isolation_breaks(client, bad):
|
||||||
|
with pytest.raises(ToolError, match="extra_args may not"):
|
||||||
|
await client.call_tool("launch_vm", {"name": "esc", "extra_args": bad})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_extra_args_allows_ordinary_flags(dirs, monkeypatch):
|
||||||
|
"""The escape hatch still works for benign tuning flags."""
|
||||||
|
from mcqemu.launcher import check_extra_args
|
||||||
|
|
||||||
|
check_extra_args(["-vga", "std", "-rtc", "base=localtime"]) # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_host_ports_are_rejected():
|
||||||
|
from mcqemu.launcher import resolve_port_forwards
|
||||||
|
|
||||||
|
with pytest.raises(ToolError, match="twice"):
|
||||||
|
resolve_port_forwards(["18991:22", "18991:23"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_ports_do_not_collide_with_each_other():
|
||||||
|
from mcqemu.launcher import resolve_port_forwards
|
||||||
|
|
||||||
|
resolved = resolve_port_forwards(["auto:22", "auto:80", "auto:443"])
|
||||||
|
hosts = [entry.split(":")[0] for entry in resolved]
|
||||||
|
assert len(set(hosts)) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_restrict_net_blocks_outbound_but_keeps_forwards():
|
||||||
|
from mcqemu.launcher import build_cmdline
|
||||||
|
from mcqemu.models import VMConfig
|
||||||
|
from test_launcher import paths
|
||||||
|
|
||||||
|
cfg = VMConfig(name="t", restrict_net=True, port_forwards=["2222:22"])
|
||||||
|
netdev = next(
|
||||||
|
a
|
||||||
|
for a in build_cmdline(cfg, paths(), accel="kvm", disk_formats={})
|
||||||
|
if a.startswith("user,id=net0")
|
||||||
|
)
|
||||||
|
assert "restrict=on" in netdev
|
||||||
|
assert "hostfwd=tcp::2222-:22" in netdev
|
||||||
|
|
||||||
|
|
||||||
|
# --- bounded work per call --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_type_text_rejects_a_novel(dirs, fake_qmp):
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
with pytest.raises(ToolError, match="limit is"):
|
||||||
|
await client.call_tool("vm_type_text", {"name": "vm1", "text": "x" * 5000})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mouse_move_rejects_absurd_delta(dirs, fake_qmp):
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
with pytest.raises(ToolError, match="within"):
|
||||||
|
await client.call_tool("vm_mouse_move", {"name": "vm1", "dx": 10**9})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_serial_read_only_reads_the_tail(dirs, fake_qmp):
|
||||||
|
"""A multi-megabyte console log must not be slurped into memory."""
|
||||||
|
log = dirs.state / "big-serial.log"
|
||||||
|
log.write_text("noise\n" * 200_000 + "FINAL LINE\n")
|
||||||
|
assert log.stat().st_size > 1_000_000
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1", serial_log=str(log)))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
data = result_data(
|
||||||
|
await client.call_tool("vm_serial_read", {"name": "vm1", "tail_lines": 1})
|
||||||
|
)
|
||||||
|
assert data["tail"] == "FINAL LINE"
|
||||||
|
assert data["log_bytes"] == log.stat().st_size
|
||||||
|
assert data["read_from_end_bytes"] < data["log_bytes"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_screenshots_do_not_share_a_filename(dirs, fake_qmp):
|
||||||
|
"""A fixed path lets a concurrent capture swap the frame under vm_click."""
|
||||||
|
written: list[str] = []
|
||||||
|
|
||||||
|
def capture(args):
|
||||||
|
written.append(args["filename"])
|
||||||
|
with open(args["filename"], "wb") as f:
|
||||||
|
f.write(fake_png(64, 48))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
from test_display import fake_png
|
||||||
|
|
||||||
|
FakeQMPClient.responses["screendump"] = capture
|
||||||
|
write_registry(dirs, seeded_record(dirs, "vm1"))
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
await client.call_tool("vm_screenshot", {"name": "vm1"})
|
||||||
|
await client.call_tool("vm_screenshot", {"name": "vm1"})
|
||||||
|
assert len(set(written)) == 2, "each capture needs its own file"
|
||||||
|
assert not any(Path(p).exists() for p in written), "temp captures must be cleaned up"
|
||||||
|
|
||||||
|
|
||||||
|
# --- identity and liveness --------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_socket_file_does_not_read_as_alive(dirs, tmp_path):
|
||||||
|
"""A SIGKILLed QEMU leaves its socket behind; stat() alone would lie."""
|
||||||
|
dead = tmp_path / "dead.sock"
|
||||||
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
listener.bind(str(dead))
|
||||||
|
listener.listen(1)
|
||||||
|
registry = VMRegistry(config_for(dirs))
|
||||||
|
registry.load()
|
||||||
|
record = VMRecord(name="ext", source="attached", qmp_socket=str(dead))
|
||||||
|
assert registry.is_process_alive(record) is True
|
||||||
|
listener.close() # process gone, file remains
|
||||||
|
assert dead.exists()
|
||||||
|
assert registry.is_process_alive(record) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pid_matching_rejects_a_different_vm():
|
||||||
|
"""After a pidfile clobber a record can point at another QEMU."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from mcqemu.registry import pid_matches_vm
|
||||||
|
|
||||||
|
# Our own process has no -name in its cmdline, so nothing is proven.
|
||||||
|
assert pid_matches_vm(os.getpid(), "anything") is True
|
||||||
|
assert pid_matches_vm(2**22 + 999, "gone") is True # unreadable == unproven
|
||||||
|
|
||||||
|
|
||||||
|
def test_sandbox_base_counts_as_in_use(dirs):
|
||||||
|
"""Resizing the base under a live overlay must be refused."""
|
||||||
|
registry = VMRegistry(config_for(dirs))
|
||||||
|
registry.load()
|
||||||
|
base = dirs.state / "base.qcow2"
|
||||||
|
base.write_bytes(b"x")
|
||||||
|
registry.add(
|
||||||
|
seeded_record(
|
||||||
|
dirs,
|
||||||
|
"sb",
|
||||||
|
source="attached",
|
||||||
|
pid=None,
|
||||||
|
qmp_socket=str(dirs.run / "live.sock"),
|
||||||
|
config={"disks": [], "sandbox_base": str(base)},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
(dirs.run).mkdir(exist_ok=True)
|
||||||
|
listener.bind(str(dirs.run / "live.sock"))
|
||||||
|
listener.listen(1)
|
||||||
|
try:
|
||||||
|
assert str(base.resolve()) in registry.disks_in_use()
|
||||||
|
finally:
|
||||||
|
listener.close()
|
||||||
|
|||||||
@ -60,7 +60,7 @@ def base_image(tmp_path):
|
|||||||
def fake_launch(dirs, monkeypatch):
|
def fake_launch(dirs, monkeypatch):
|
||||||
"""Replace the real spawn with one that registers a plausible record."""
|
"""Replace the real spawn with one that registers a plausible record."""
|
||||||
|
|
||||||
async def fake_launch_vm(name, disks, memory_mb, cpus, port_forwards, ctx):
|
async def fake_launch_vm(name, disks, memory_mb, cpus, port_forwards, ctx, **kwargs):
|
||||||
from mcqemu.launcher import resolve_port_forwards
|
from mcqemu.launcher import resolve_port_forwards
|
||||||
from mcqemu.tools._common import app
|
from mcqemu.tools._common import app
|
||||||
|
|
||||||
@ -74,6 +74,7 @@ def fake_launch(dirs, monkeypatch):
|
|||||||
"status": "running",
|
"status": "running",
|
||||||
"accel": "kvm",
|
"accel": "kvm",
|
||||||
"port_forwards": resolve_port_forwards(port_forwards),
|
"port_forwards": resolve_port_forwards(port_forwards),
|
||||||
|
"restrict_net": kwargs.get("restrict_net"),
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr("mcqemu.tools.sandbox.launch_vm", fake_launch_vm)
|
monkeypatch.setattr("mcqemu.tools.sandbox.launch_vm", fake_launch_vm)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user