Add the mcqemu.warehack.ing docs site
Starlight, diataxis-shaped: a tutorial pair, six how-to guides, a generated tool reference plus a configuration reference, and four explanation pages covering architecture, sandbox isolation, see-and-drive, and the failure handling philosophy. The tool reference is generated from the running MCP server's own schemas, so its 33 tools, parameters and defaults cannot drift from the code; the generator asserts its grouping still covers exactly the live tool set. Infrastructure follows the warehacking cookie-cutter (multi-stage Dockerfile, Caddy serving dist with real 404 status, compose profiles for prod and dev, Makefile with a deploy target pointed at docker-2). Two deviations worth noting: remark-gfm is added to the MDX pipeline because Astro enables GFM for .md but not .mdx, so tables silently rendered as run-together paragraphs; and the card icon palette is pinned to the accent because Starlight's staggered grid rotates through colours including purple. Package URLs now point at the Gitea repo and this site.
This commit is contained in:
parent
b5a1c3583b
commit
f4c2a70bbf
19
docs-site/.dockerignore
Normal file
19
docs-site/.dockerignore
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# don't pull in repo-root artifacts
|
||||||
|
../artifacts/
|
||||||
|
README.md
|
||||||
13
docs-site/.env.example
Normal file
13
docs-site/.env.example
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# mcqemu docs-site — environment template.
|
||||||
|
# Copy to `.env` and adjust before `make prod` / `make dev`.
|
||||||
|
|
||||||
|
# Keeps this stack's containers, networks and volumes distinct from the
|
||||||
|
# sibling docs sites on the same host.
|
||||||
|
COMPOSE_PROJECT_NAME=mcqemu-docs
|
||||||
|
|
||||||
|
# Domain served. caddy-docker-proxy reads this from the labels and obtains
|
||||||
|
# a certificate over ACME DNS-01.
|
||||||
|
#
|
||||||
|
# Production: mcqemu.warehack.ing
|
||||||
|
# Local dev: mcqemu.l.warehack.ing
|
||||||
|
DOMAIN=mcqemu.warehack.ing
|
||||||
8
docs-site/.gitignore
vendored
Normal file
8
docs-site/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
*.log
|
||||||
|
package-lock.json
|
||||||
18
docs-site/Caddyfile
Normal file
18
docs-site/Caddyfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
:80 {
|
||||||
|
root * /srv/docs
|
||||||
|
encode zstd gzip
|
||||||
|
try_files {path} {path}/
|
||||||
|
file_server
|
||||||
|
|
||||||
|
# Serve the 404 page with a REAL 404 status. The older
|
||||||
|
# `try_files ... /404.html` form returned HTTP 200 for every missing
|
||||||
|
# path, which silently broke uptime checks, smoke tests, and Search
|
||||||
|
# Console (they all saw a "live" page).
|
||||||
|
handle_errors {
|
||||||
|
@404 expression {err.status_code} == 404
|
||||||
|
handle @404 {
|
||||||
|
rewrite * /404.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
docs-site/Dockerfile
Normal file
47
docs-site/Dockerfile
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Multi-stage build for the mcqemu docs site.
|
||||||
|
#
|
||||||
|
# Stages:
|
||||||
|
# - base : Node, pnpm/npm tooling, deps installed
|
||||||
|
# - dev : runs `astro dev` with HMR for local development
|
||||||
|
# - builder : produces the static `dist/`
|
||||||
|
# - prod : caddy:alpine that serves `dist/` (no Node at runtime)
|
||||||
|
#
|
||||||
|
# `docker compose --profile dev up` → dev target
|
||||||
|
# `docker compose up` (no profile) → prod target
|
||||||
|
|
||||||
|
# Pinned through the mirror.gcr.io pass-through to dodge intermittent
|
||||||
|
# Docker Hub TLS hiccups during builds. Same content, more reliable
|
||||||
|
# fetch path. The `docker pull` resolves identically against either.
|
||||||
|
FROM mirror.gcr.io/library/node:22-alpine AS base
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
|
||||||
|
# ----- dev: astro dev server with HMR -----
|
||||||
|
FROM base AS dev
|
||||||
|
# Astro's binary is in node_modules/.bin — package.json's `dev` script
|
||||||
|
# already binds to 0.0.0.0 for HMR-behind-Caddy.
|
||||||
|
COPY . .
|
||||||
|
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||||
|
EXPOSE 4321
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
|
|
||||||
|
|
||||||
|
# ----- builder: produce dist/ -----
|
||||||
|
FROM base AS builder
|
||||||
|
COPY . .
|
||||||
|
ENV ASTRO_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
# ----- prod: caddy serves the static build -----
|
||||||
|
FROM mirror.gcr.io/library/caddy:2-alpine AS prod
|
||||||
|
# Caddyfile is intentionally minimal — caddy-docker-proxy on the host
|
||||||
|
# handles TLS, routing, and the public-facing reverse proxy. This
|
||||||
|
# container just serves files locally; the proxy points at it.
|
||||||
|
RUN mkdir -p /srv/docs
|
||||||
|
COPY --from=builder /app/dist /srv/docs
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
|
EXPOSE 80
|
||||||
50
docs-site/Makefile
Normal file
50
docs-site/Makefile
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# mcqemu docs — targets follow the warehacking cookie-cutter.
|
||||||
|
|
||||||
|
SHELL := /usr/bin/env bash
|
||||||
|
.SHELLFLAGS := -eu -o pipefail -c
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Show this help
|
||||||
|
@awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
.PHONY: prod
|
||||||
|
prod: ## Build + run the production docs container (Caddy serves dist/)
|
||||||
|
docker compose up -d --build docs
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev: ## Run the Astro dev server with HMR (--profile dev)
|
||||||
|
docker compose --profile dev up --build docs-dev
|
||||||
|
|
||||||
|
.PHONY: down
|
||||||
|
down: ## Stop and remove the docs containers
|
||||||
|
docker compose --profile dev down
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
.PHONY: logs
|
||||||
|
logs: ## Tail logs (works for whichever profile is up)
|
||||||
|
docker compose logs -f --tail=100
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Build the static site without bringing up Caddy (CI gate)
|
||||||
|
docker compose build docs
|
||||||
|
|
||||||
|
# ---- Production deploy --------------------------------------------------
|
||||||
|
#
|
||||||
|
# Pulls origin/main on the warehack.ing prod host and rebuilds the docs
|
||||||
|
# container. Agent forwarding (-A) lets the remote git pull use the
|
||||||
|
# operator's key for Gitea, so nothing persistent is provisioned there.
|
||||||
|
#
|
||||||
|
# Use docker-2.supportedsystems.net, not warehack.ing: the latter resolves
|
||||||
|
# to an IPv4 whose port 22 is unreachable from some networks.
|
||||||
|
|
||||||
|
DEPLOY_HOST ?= warehack-ing@docker-2.supportedsystems.net
|
||||||
|
DEPLOY_PATH ?= ~/mcqemu
|
||||||
|
|
||||||
|
.PHONY: deploy
|
||||||
|
deploy: ## Pull main + rebuild the docs container on the prod host
|
||||||
|
@echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)"
|
||||||
|
ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod"
|
||||||
|
@echo "==> verifying by content (these sites return 200 for every path)"
|
||||||
|
@curl -sS https://mcqemu.warehack.ing/ | grep -oE "<title>[^<]*</title>" || echo " homepage title not found"
|
||||||
|
@curl -sS https://mcqemu.warehack.ing/definitely-not-a-page-xyz/ | grep -oE "<title>[^<]*</title>" || echo " (bogus path returned no title)"
|
||||||
92
docs-site/astro.config.mjs
Normal file
92
docs-site/astro.config.mjs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// mcqemu docs — Starlight with a diataxis-shaped sidebar.
|
||||||
|
//
|
||||||
|
// Telemetry and devToolbar are off per project convention. The HMR block
|
||||||
|
// matters when the dev server runs behind Caddy: Vite's WebSocket needs an
|
||||||
|
// explicit host, protocol and clientPort or HMR drops every few seconds
|
||||||
|
// with "server connection lost".
|
||||||
|
//
|
||||||
|
// DOMAIN drives both the canonical site URL and the HMR host, so the same
|
||||||
|
// image serves mcqemu.warehack.ing (prod) and mcqemu.l.warehack.ing (dev).
|
||||||
|
|
||||||
|
import mdx from "@astrojs/mdx";
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import starlight from "@astrojs/starlight";
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import starlightLinksValidator from "starlight-links-validator";
|
||||||
|
|
||||||
|
const domain = process.env.DOMAIN ?? "mcqemu.warehack.ing";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: `https://${domain}`,
|
||||||
|
telemetry: false,
|
||||||
|
devToolbar: { enabled: false },
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
hmr: {
|
||||||
|
host: domain,
|
||||||
|
protocol: "wss",
|
||||||
|
clientPort: 443,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
integrations: [
|
||||||
|
starlight({
|
||||||
|
title: "mcqemu",
|
||||||
|
description:
|
||||||
|
"MCP server for QEMU: VM lifecycle, disposable sandboxes, live snapshots, guest-agent access, and screenshot-driven control.",
|
||||||
|
favicon: "/favicon.svg",
|
||||||
|
customCss: ["./src/styles/theme.css"],
|
||||||
|
|
||||||
|
social: [
|
||||||
|
{
|
||||||
|
icon: "seti:git",
|
||||||
|
label: "Source",
|
||||||
|
href: "https://git.supported.systems/warehack.ing/mcqemu",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Diataxis order: orientation, then learning, then doing, then
|
||||||
|
// looking up, then understanding.
|
||||||
|
// Starlight v0.39+ needs {label, items:[{autogenerate}]}; the inline
|
||||||
|
// {label, autogenerate} shorthand was removed.
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
label: "Start here",
|
||||||
|
items: [{ label: "What is mcqemu?", slug: "overview" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Tutorial",
|
||||||
|
items: [{ autogenerate: { directory: "tutorial" } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "How-to",
|
||||||
|
items: [{ autogenerate: { directory: "how-to" } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reference",
|
||||||
|
items: [{ autogenerate: { directory: "reference" } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Explanation",
|
||||||
|
items: [{ autogenerate: { directory: "explanation" } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
// Broken internal links fail the build instead of shipping.
|
||||||
|
starlightLinksValidator({ errorOnRelativeLinks: false }),
|
||||||
|
],
|
||||||
|
|
||||||
|
pagination: true,
|
||||||
|
lastUpdated: true,
|
||||||
|
}),
|
||||||
|
// Astro enables GFM for .md, but the MDX pipeline does not inherit it,
|
||||||
|
// so tables render as run-together paragraphs without this.
|
||||||
|
mdx({ remarkPlugins: [remarkGfm] }),
|
||||||
|
sitemap(),
|
||||||
|
],
|
||||||
|
});
|
||||||
68
docs-site/docker-compose.yml
Normal file
68
docs-site/docker-compose.yml
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# mcqemu docs site — two profiles.
|
||||||
|
#
|
||||||
|
# Default (no --profile flag):
|
||||||
|
# production — Caddy serves the built dist/. This is what runs at
|
||||||
|
# mcqemu.warehack.ing.
|
||||||
|
#
|
||||||
|
# --profile dev:
|
||||||
|
# Astro dev server with HMR, src/ bind-mounted for live reload. The Vite
|
||||||
|
# HMR WebSocket is configured in astro.config.mjs to survive the
|
||||||
|
# TLS-terminating proxy; the caddy.reverse_proxy.* labels below keep the
|
||||||
|
# connection from being closed as idle.
|
||||||
|
#
|
||||||
|
# Both services join the external `caddy` network and advertise themselves
|
||||||
|
# to caddy-docker-proxy through labels. DOMAIN in .env switches between the
|
||||||
|
# production and local-dev tiers.
|
||||||
|
|
||||||
|
services:
|
||||||
|
docs:
|
||||||
|
profiles: ["prod", ""]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: prod
|
||||||
|
image: mcqemu-docs:prod
|
||||||
|
container_name: mcqemu-docs
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
labels:
|
||||||
|
caddy: ${DOMAIN:-mcqemu.warehack.ing}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 80}}"
|
||||||
|
|
||||||
|
docs-dev:
|
||||||
|
profiles: ["dev"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: dev
|
||||||
|
image: mcqemu-docs:dev
|
||||||
|
container_name: mcqemu-docs-dev
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DOMAIN=${DOMAIN:-mcqemu.l.warehack.ing}
|
||||||
|
- ASTRO_TELEMETRY_DISABLED=1
|
||||||
|
volumes:
|
||||||
|
# node_modules stays inside the container so host/platform mismatches
|
||||||
|
# cannot break native deps.
|
||||||
|
- ./astro.config.mjs:/app/astro.config.mjs:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./public:/app/public
|
||||||
|
networks:
|
||||||
|
- caddy
|
||||||
|
labels:
|
||||||
|
caddy: ${DOMAIN:-mcqemu.l.warehack.ing}
|
||||||
|
caddy.reverse_proxy: "{{upstreams 4321}}"
|
||||||
|
# Vite HMR sends no application-level pings, so Caddy's default idle
|
||||||
|
# timeouts would close the socket every 10-15s. Needs Caddy 2.10+.
|
||||||
|
caddy.reverse_proxy.flush_interval: "-1"
|
||||||
|
caddy.reverse_proxy.transport: "http"
|
||||||
|
caddy.reverse_proxy.transport.read_timeout: "0"
|
||||||
|
caddy.reverse_proxy.transport.write_timeout: "0"
|
||||||
|
caddy.reverse_proxy.transport.keepalive: "5m"
|
||||||
|
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
|
||||||
|
caddy.reverse_proxy.stream_timeout: "24h"
|
||||||
|
caddy.reverse_proxy.stream_close_delay: "5s"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy:
|
||||||
|
external: true
|
||||||
21
docs-site/package.json
Normal file
21
docs-site/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "mcqemu-docs",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev --host 0.0.0.0",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview --host 0.0.0.0",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/mdx": "^5.0.4",
|
||||||
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
|
"@astrojs/starlight": "^0.39.2",
|
||||||
|
"astro": "^6.3.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"sharp": "^0.34.0",
|
||||||
|
"starlight-links-validator": "^0.24.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
docs-site/public/favicon.svg
Normal file
7
docs-site/public/favicon.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="mcqemu">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#16170f"/>
|
||||||
|
<rect x="5" y="7" width="22" height="16" rx="2" fill="none" stroke="#b5820f" stroke-width="2"/>
|
||||||
|
<path d="M9 12l3 3-3 3" fill="none" stroke="#f0c674" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M15 18h7" stroke="#f0c674" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M12 26h8" stroke="#b5820f" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 533 B |
8
docs-site/src/content.config.ts
Normal file
8
docs-site/src/content.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Starlight content collection — required for content.config.ts in Astro 6.x.
|
||||||
|
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||||
|
import { docsSchema } from "@astrojs/starlight/schema";
|
||||||
|
import { defineCollection } from "astro:content";
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
};
|
||||||
170
docs-site/src/content/docs/explanation/architecture.mdx
Normal file
170
docs-site/src/content/docs/explanation/architecture.mdx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
title: Architecture
|
||||||
|
description: How the MCP server, the QEMU processes, the QMP and guest-agent sockets, qemu-img and the JSON registry fit together, and why VMs are daemonized and monitor sessions are short-lived.
|
||||||
|
---
|
||||||
|
|
||||||
|
mcqemu is a thin control plane. It does not emulate anything, it does not proxy
|
||||||
|
guest traffic, and it does not keep VMs alive. Everything it does is arranged so
|
||||||
|
that the interesting state lives in the operating system (processes, sockets,
|
||||||
|
files) rather than in the server's memory, because the server is the part most
|
||||||
|
likely to be restarted at an awkward moment.
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
| Piece | What it is | Lifetime |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| The MCP server | A Python process speaking MCP to your client (usually over stdio) | As long as your client keeps it |
|
||||||
|
| A VM | A `qemu-system-*` process, daemonized, with its own pidfile | Until something stops it |
|
||||||
|
| The QMP socket | A unix socket per VM, QEMU's machine monitor | The VM's lifetime |
|
||||||
|
| The guest-agent socket | A second unix socket per VM, backing a virtio-serial port | The VM's lifetime |
|
||||||
|
| `qemu-img` | A subprocess, spawned per image operation | One command |
|
||||||
|
| The registry | A JSON file plus a lock file on disk | Persistent |
|
||||||
|
|
||||||
|
The server owns none of the VMs in any operating-system sense. It is not their
|
||||||
|
parent process, it does not hold their sockets open, and if you kill it nothing
|
||||||
|
happens to them. The registry is a notebook, not an authority. When a tool needs
|
||||||
|
to know whether a VM is running, it does not consult the notebook, it looks at
|
||||||
|
`/proc` and at the socket.
|
||||||
|
|
||||||
|
The two decisions that shape everything else are the process model and the
|
||||||
|
session model.
|
||||||
|
|
||||||
|
## Decision one: VMs outlive the server
|
||||||
|
|
||||||
|
VMs are launched with `-daemonize` and `-pidfile`. QEMU forks, the foreground
|
||||||
|
process exits, and the VM continues as a session leader unattached to anything
|
||||||
|
mcqemu owns. Restarting your editor, upgrading mcqemu, or crashing the server
|
||||||
|
leaves a Debian install halfway through its partitioner exactly where it was.
|
||||||
|
|
||||||
|
This matters more than it might sound. An agent driving a VM will typically be
|
||||||
|
in the middle of a long operation (an OS installation, a package build, a boot
|
||||||
|
that takes two minutes under TCG emulation) and the MCP server is attached to an
|
||||||
|
editor session that gets reloaded for unrelated reasons. A model where the VM
|
||||||
|
dies with its parent would make those operations quietly unreliable in a way
|
||||||
|
that is very hard to attribute.
|
||||||
|
|
||||||
|
`-daemonize` also buys a clean launch verdict. QEMU's foreground process exits
|
||||||
|
zero only after the VM is fully initialized and the QMP socket is listening, so
|
||||||
|
awaiting that process gives a synchronous answer with real stderr on failure,
|
||||||
|
rather than the usual "spawn it and poll for a socket" dance. When QEMU fails
|
||||||
|
because a disk is locked or a machine type does not exist, the launch call
|
||||||
|
returns that message directly. Daemonized QEMU routes later errors to its `-D`
|
||||||
|
log file instead of stderr, so if the exit status is non-zero but stderr is
|
||||||
|
empty, the launcher reads the last line of the log.
|
||||||
|
|
||||||
|
### Liveness, and why the pidfile is not enough
|
||||||
|
|
||||||
|
Reading a PID from a pidfile and calling `kill(pid, 0)` is the obvious liveness
|
||||||
|
check and it is wrong. PIDs are recycled. A pidfile written days ago, on a host
|
||||||
|
that has since rebooted or simply wrapped its PID space, can name a process that
|
||||||
|
is very much alive and has nothing to do with your VM. Acting on that (sending a
|
||||||
|
signal, reporting the VM as running) is a bug with real consequences.
|
||||||
|
|
||||||
|
So liveness is two checks. First, `/proc/<pid>/comm` must read back as something
|
||||||
|
starting with `qemu-system`; a recycled PID belonging to a shell or a browser
|
||||||
|
fails immediately. Second, for VMs mcqemu launched, `/proc/<pid>/cmdline` is
|
||||||
|
parsed for the `-name` argument and compared with the registry key. That second
|
||||||
|
check is deliberately generous: it returns "matches" whenever it cannot prove
|
||||||
|
otherwise (unreadable cmdline, no `-name` present), because its job is to veto a
|
||||||
|
positive identification, not to manufacture a negative one.
|
||||||
|
|
||||||
|
Attached VMs are judged differently. mcqemu did not choose their `-name` and may
|
||||||
|
not know their PID at all, so when no PID was supplied their liveness comes from
|
||||||
|
connecting to the QMP socket. A stat is not enough there either: a SIGKILLed
|
||||||
|
QEMU leaves its socket file on disk, so the check opens a connection and sees
|
||||||
|
whether anything accepts.
|
||||||
|
|
||||||
|
## Decision two: QMP sessions are opened per call
|
||||||
|
|
||||||
|
QEMU's QMP unix socket accepts exactly one client at a time. That single fact
|
||||||
|
drives the session model. If mcqemu connected once at launch and held the
|
||||||
|
connection for the VM's lifetime, it would own the monitor exclusively and
|
||||||
|
everything else would be locked out: no `qmp-shell` for a human looking over the
|
||||||
|
agent's shoulder, no second tool, no debugging. It would also leave connection
|
||||||
|
state to reconcile whenever either side restarted, which for a server whose VMs
|
||||||
|
outlive it is a recurring problem rather than an edge case.
|
||||||
|
|
||||||
|
Instead every tool call opens a session, runs its commands, and disconnects. In
|
||||||
|
between calls the socket is free. You can attach `qmp-shell` to a VM the agent is
|
||||||
|
driving, poke at it, and detach, and the next tool call simply connects.
|
||||||
|
|
||||||
|
The cost is that concurrent calls against the same VM would collide, and they
|
||||||
|
would collide badly: the second connect would sit there until its five second
|
||||||
|
timeout, then report the VM unreachable, which reads exactly like a VM that has
|
||||||
|
exited. So sessions are serialized with a lock keyed by VM name. It is a mutex
|
||||||
|
table over resources rather than shared mutable state, which means two calls
|
||||||
|
against two different VMs still run in parallel, and only same-VM calls queue.
|
||||||
|
|
||||||
|
That lock exists inside one server process. It cannot serialize a second mcqemu
|
||||||
|
instance or your `qmp-shell`, and that is exactly why the connect-failure message
|
||||||
|
is careful (see [Reliability](/explanation/reliability/)): when a connect fails
|
||||||
|
but the process is demonstrably alive, the error says another client may hold the
|
||||||
|
monitor and explicitly tells the caller not to relaunch.
|
||||||
|
|
||||||
|
## The guest agent is a separate channel
|
||||||
|
|
||||||
|
Every launched VM gets a second socket wired to a virtio-serial port named
|
||||||
|
`org.qemu.guest_agent.0`. `guest_exec`, `guest_file_read`, `guest_file_write`,
|
||||||
|
`guest_ping` and `guest_info` all speak to that socket, never to QMP.
|
||||||
|
|
||||||
|
Keeping the channels separate is worth the extra socket. Guest-agent calls depend
|
||||||
|
on software inside the guest and are therefore allowed to hang, time out, or
|
||||||
|
never work at all; monitor calls control the machine from outside and must keep
|
||||||
|
working when the guest is wedged. Sharing one channel would let a frozen guest
|
||||||
|
interfere with the tools you use to deal with a frozen guest.
|
||||||
|
|
||||||
|
The guest agent happens to speak the QMP wire format, so the same client library
|
||||||
|
works with greeting and capability negotiation switched off. The protocol's
|
||||||
|
mandatory `guest-sync` handshake, which echoes a random token, doubles as the
|
||||||
|
probe for "is there actually an agent in there": if the token does not come back
|
||||||
|
within three seconds, the guest does not have a working `qemu-guest-agent` and
|
||||||
|
the tools say so rather than timing out on the real command later. See
|
||||||
|
[Install the guest agent](/how-to/install-the-guest-agent/) for the guest side.
|
||||||
|
|
||||||
|
## Images are `qemu-img` subprocesses
|
||||||
|
|
||||||
|
Image work shells out to `qemu-img` and parses its JSON output. There is no
|
||||||
|
library binding and no attempt to open qcow2 files directly.
|
||||||
|
|
||||||
|
The reason is safety rather than convenience. `qemu-img` participates in QEMU's
|
||||||
|
image locking protocol, so an attempt to convert or resize a disk that a running
|
||||||
|
VM has open fails with a lock error instead of corrupting the image. mcqemu
|
||||||
|
recognises that error and rewrites it into a sentence that names the actual
|
||||||
|
problem. The registry's own view of which disks are busy is advisory only (it
|
||||||
|
does not know an attached VM's disks and does not walk backing chains), so the
|
||||||
|
image lock is treated as the authority and the registry view as a hint.
|
||||||
|
|
||||||
|
## The registry is bookkeeping, not truth
|
||||||
|
|
||||||
|
The registry is a JSON file under the state directory holding one record per VM:
|
||||||
|
socket paths, pidfile, PID, the exact argv used, the launch config, log paths.
|
||||||
|
It exists so that a freshly started server can find VMs that a previous one
|
||||||
|
launched.
|
||||||
|
|
||||||
|
Two properties matter more than speed. It must never take the server down, and
|
||||||
|
it must tolerate more than one writer.
|
||||||
|
|
||||||
|
More than one writer is not hypothetical. A second MCP client, a stray
|
||||||
|
`uvx mcqemu`, or two clients configured against the same state directory all
|
||||||
|
share one file. So every write is a read-modify-write under an exclusive
|
||||||
|
`flock`, and the temp file used for the atomic rename carries the writing
|
||||||
|
process's PID in its name. Without the lock the last writer's snapshot silently
|
||||||
|
erases the other's VMs; with a shared temp name two writers could interleave and
|
||||||
|
publish the mixture.
|
||||||
|
|
||||||
|
Never taking the server down is the other half, and it is covered in
|
||||||
|
[Reliability](/explanation/reliability/): an unreadable registry is quarantined
|
||||||
|
and the server starts empty, reporting the damage through `list_vms` rather than
|
||||||
|
refusing to start.
|
||||||
|
|
||||||
|
## What this means in practice
|
||||||
|
|
||||||
|
VMs survive server restarts, so `list_vms` after a restart shows what is really
|
||||||
|
running rather than an empty list. The monitor socket is available to other
|
||||||
|
tooling between calls. Two agents can share a host without clobbering each
|
||||||
|
other's records, though they cannot serialize each other's monitor access, so
|
||||||
|
same-VM contention shows up as a retryable error rather than a wedge.
|
||||||
|
|
||||||
|
Paths for the state and runtime directories are documented in
|
||||||
|
[Configuration](/reference/configuration/), and every tool named here is listed
|
||||||
|
with its parameters in the [Tool reference](/reference/tools/).
|
||||||
191
docs-site/src/content/docs/explanation/reliability.mdx
Normal file
191
docs-site/src/content/docs/explanation/reliability.mdx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
title: How mcqemu handles failure
|
||||||
|
description: The four rules behind mcqemu's error handling (verify before destroying, degrade instead of dying, never guess an unchecked cause, bound every wait) and what each of them costs.
|
||||||
|
---
|
||||||
|
|
||||||
|
An MCP server that manages VMs is in an unusual position. Its callers are often
|
||||||
|
models, which are good at acting on a plausible-sounding sentence and less good
|
||||||
|
at doubting one. Its subjects are long-lived processes holding gigabytes of state
|
||||||
|
that no one wants to lose. And it works on a host that is doing other things at
|
||||||
|
the same time.
|
||||||
|
|
||||||
|
Four rules follow from that. None of them is clever; all of them are the boring
|
||||||
|
option, chosen deliberately.
|
||||||
|
|
||||||
|
## Never delete without verifying what you waited for
|
||||||
|
|
||||||
|
Waiting for something and then assuming it happened is the standard way to
|
||||||
|
destroy data. `sandbox_destroy` is the only tool in mcqemu that deletes files, so
|
||||||
|
it is where this rule is enforced hardest.
|
||||||
|
|
||||||
|
It sends `quit` over the monitor, then polls until the PID is actually gone,
|
||||||
|
giving it ten seconds. If the process is still there it escalates to `SIGKILL`
|
||||||
|
and polls again for five. If the process is *still* alive after that, it stops
|
||||||
|
and fails, and nothing is deleted. The error names the PID and says the overlay
|
||||||
|
is still open by that process.
|
||||||
|
|
||||||
|
That last branch is the point. Unlinking a disk image that QEMU still has open
|
||||||
|
does not free the space (the kernel keeps the inode alive for the open file
|
||||||
|
descriptor) and it does not stop the guest writing, so you end up with a running
|
||||||
|
VM writing into a file that no longer has a name, consuming disk you cannot
|
||||||
|
account for. Refusing to delete leaves you with a mess you can see, which is
|
||||||
|
strictly better than a mess you cannot.
|
||||||
|
|
||||||
|
The same instinct shows up in the surrounding checks. `sandbox_destroy` refuses
|
||||||
|
outright to touch a VM that was not created by `sandbox_vm`, because `stop_vm`
|
||||||
|
and `forget_vm` never delete disks and users reasonably assume the same of
|
||||||
|
everything else. It re-validates the VM name it was handed rather than trusting
|
||||||
|
the registry key it came from, since a corrupted record is exactly the input that
|
||||||
|
turns a delete into a disaster. It resolves the directories it is about to remove
|
||||||
|
and confirms they are genuinely inside the state and runtime roots, and are not
|
||||||
|
the roots themselves. Cleanup errors are collected rather than swallowed, so the
|
||||||
|
result distinguishes "destroyed" from "mostly destroyed, here is what is left".
|
||||||
|
|
||||||
|
## Degrade instead of dying
|
||||||
|
|
||||||
|
If the registry file is unreadable, mcqemu does not refuse to start. It moves the
|
||||||
|
damaged file aside to a timestamped quarantine name, starts with an empty
|
||||||
|
registry, and records what happened. `list_vms` returns that record in
|
||||||
|
`registry_warnings` alongside the (now empty) VM list. Individual records that
|
||||||
|
fail to parse are skipped one at a time with their own warning, rather than
|
||||||
|
taking the whole file down with them.
|
||||||
|
|
||||||
|
The reasoning is a direct consequence of the process model described in
|
||||||
|
[Architecture](/explanation/architecture/). VMs outlive this server. A server
|
||||||
|
that will not start because its notebook is corrupt is a server that cannot stop
|
||||||
|
the runaway VM eating the host's memory, and the notebook has no bearing on
|
||||||
|
whether that VM exists. Bookkeeping failures must not disarm the controls.
|
||||||
|
|
||||||
|
The warning matters as much as the degradation. A quietly empty list would be
|
||||||
|
read as "no VMs are running", which in this situation is exactly wrong: VMs may
|
||||||
|
well be running untracked, and their QMP sockets are still sitting under the
|
||||||
|
runtime directory where `attach_vm` can pick them up. So the tool's contract makes
|
||||||
|
the warning list impossible to miss, and its own description tells the caller to
|
||||||
|
report it rather than treat the list as complete.
|
||||||
|
|
||||||
|
A registry written by a newer mcqemu is handled the other way: if the schema
|
||||||
|
version is higher than this build understands, it is not loaded and not
|
||||||
|
quarantined, because reading it optimistically and writing it back would damage
|
||||||
|
records the newer build owns. Refusing and saying so is the safe direction.
|
||||||
|
|
||||||
|
The same preference for a usable degraded state appears at launch. If QEMU exits
|
||||||
|
zero (which means the VM is up and its monitor is listening) but the pidfile
|
||||||
|
cannot be read, the launcher does not raise. Raising there would leave a running,
|
||||||
|
unregistered VM that no tool could find or stop, which is the worst outcome
|
||||||
|
available. It returns a record without a PID, and only fails when neither a
|
||||||
|
pidfile nor a live QMP socket can be found, at which point it prints the `pgrep`
|
||||||
|
command to locate the process by hand.
|
||||||
|
|
||||||
|
## Do not guess a cause you have not checked
|
||||||
|
|
||||||
|
The most consequential error message in mcqemu is the one for a failed QMP
|
||||||
|
connect, and it has two forms.
|
||||||
|
|
||||||
|
If the process is not alive, the message says the VM has likely exited and points
|
||||||
|
at the QEMU log. If the process *is* alive, it says something quite different:
|
||||||
|
that QEMU's monitor accepts one client at a time, that another tool call or an
|
||||||
|
external `qmp-shell` may be holding it, and, explicitly, not to relaunch the VM.
|
||||||
|
|
||||||
|
The difference is not politeness. A caller told "the VM has exited" will do the
|
||||||
|
sensible thing and start it again, and if the VM was merely busy that means two
|
||||||
|
QEMU processes fighting over one disk image, or a launch that fails confusingly
|
||||||
|
on a locked image, or in the worst case a second guest writing to a filesystem
|
||||||
|
the first one is already mounting. The wrong diagnosis produces a wrong action
|
||||||
|
that produces real damage. So the liveness check runs before the message is
|
||||||
|
written, and the message only claims what was checked.
|
||||||
|
|
||||||
|
The same principle appears in a few other places:
|
||||||
|
|
||||||
|
Waiting for a shutdown polls the process, not just the event queue. The client
|
||||||
|
library's event queue never wakes on a dropped connection, so a VM that crashes
|
||||||
|
mid-wait would burn the caller's entire timeout and then be reported as a guest
|
||||||
|
that ignores ACPI, sending them to `force=True` for a machine that already died.
|
||||||
|
Polling liveness once a second turns that into "process exited during shutdown",
|
||||||
|
which is what happened.
|
||||||
|
|
||||||
|
The identity check on a PID is deliberately one-sided. It compares the `-name` in
|
||||||
|
`/proc/<pid>/cmdline` against the registry key, and returns "this is not a
|
||||||
|
different VM" whenever it cannot prove otherwise: unreadable cmdline, no `-name`
|
||||||
|
present, a truncated argument list. Its job is to veto a mistaken identification,
|
||||||
|
so an inconclusive read must not be allowed to contradict a positive liveness
|
||||||
|
check.
|
||||||
|
|
||||||
|
Liveness for an attached VM connects to its socket rather than stat-ing it,
|
||||||
|
because a SIGKILLed QEMU leaves the socket file behind and the file's existence
|
||||||
|
proves nothing. The same check protects launch: a leftover socket file under a
|
||||||
|
name you are reusing is deleted, but only after confirming that nothing is
|
||||||
|
listening on it, since an unregistered but running QEMU would otherwise lose its
|
||||||
|
monitor and become permanently unreachable.
|
||||||
|
|
||||||
|
And when a tool cannot tell, it says so. Timeouts report the events actually seen
|
||||||
|
while waiting. Guest-agent failures name the specific thing that is missing
|
||||||
|
(`qemu-guest-agent` installed and running in the guest) rather than reporting a
|
||||||
|
generic connection error, and a guest agent that refuses a command on a
|
||||||
|
RHEL-family guest gets the specific advice about `BLOCK_RPCS` in
|
||||||
|
`/etc/sysconfig/qemu-ga`.
|
||||||
|
|
||||||
|
## Bound every wait
|
||||||
|
|
||||||
|
Every operation that waits has a deadline. An unbounded wait in a server managing
|
||||||
|
multiple VMs does not just hang one call, it holds that VM's session lock and
|
||||||
|
blocks every subsequent call against the same machine, so one wedged guest takes
|
||||||
|
out an entire VM's tooling.
|
||||||
|
|
||||||
|
| Wait | Bound | Why that number |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| QMP connect | 5s | Either the socket is there or it is not |
|
||||||
|
| QMP command | 30s | Ordinary monitor commands are fast; 30s means wedged |
|
||||||
|
| Guest-agent handshake | 3s | Doubles as the "is there an agent?" probe |
|
||||||
|
| Guest-agent call | 10s | The handshake succeeded, so the agent was alive a moment ago |
|
||||||
|
| `guest_exec` | Caller's `timeout`, plus an outer bound | The poll loop checks the clock between awaits, so a single hung call needs its own ceiling |
|
||||||
|
| `stop_vm` (graceful) | 30s, configurable | Long enough for a normal shutdown, short enough to notice ACPI being ignored |
|
||||||
|
| `savevm` / `loadvm` | 900s | Writing or reading all of guest RAM legitimately takes minutes |
|
||||||
|
| `sandbox_vm` agent wait | 90s, configurable | A cold boot plus the agent's own startup |
|
||||||
|
|
||||||
|
`savevm` is the interesting one. Snapshotting a large VM writes its entire RAM
|
||||||
|
into the qcow2 file and can honestly take minutes, so a 30 second bound would
|
||||||
|
turn a working operation into a spurious failure. The response is a much longer
|
||||||
|
bound rather than no bound at all, because "this can take a while" is not the same
|
||||||
|
statement as "this can take forever", and a wedged snapshot still needs to
|
||||||
|
surface eventually.
|
||||||
|
|
||||||
|
`sandbox_vm` shows the other half of bounding a wait well: it does not simply
|
||||||
|
sleep until its deadline. While waiting for the guest agent it also checks
|
||||||
|
whether the VM is still alive, so a guest that dies during boot is reported as
|
||||||
|
having exited, with the tail of the QEMU log, at the moment it happens, instead
|
||||||
|
of being blamed ninety seconds later on a missing guest agent.
|
||||||
|
|
||||||
|
## Concurrency is a failure mode too
|
||||||
|
|
||||||
|
Two mcqemu instances can share one registry file, and nothing prevents it, so
|
||||||
|
every write is a read-modify-write under an exclusive file lock and the atomic
|
||||||
|
rename uses a temp name carrying the writer's PID. Without the lock the second
|
||||||
|
writer's snapshot silently erases the first writer's VMs, and those VMs keep
|
||||||
|
running with nobody tracking them. `list_vms` re-reads the file before answering
|
||||||
|
so records another instance created are visible.
|
||||||
|
|
||||||
|
Within one process, calls against the same VM are serialized by a per-VM lock
|
||||||
|
(the monitor socket takes one client at a time), and a launch reserves its name
|
||||||
|
for the duration, because launching involves awaits between checking that a name
|
||||||
|
is free and registering it, and two concurrent launches would otherwise both pass
|
||||||
|
the check and race over the same sockets and pidfile.
|
||||||
|
|
||||||
|
## What all of this costs
|
||||||
|
|
||||||
|
More calls fail than would otherwise. A tool that refuses to delete, refuses to
|
||||||
|
guess, and gives up after a timeout will return errors in situations where a more
|
||||||
|
optimistic implementation would have carried on and usually been fine. That is
|
||||||
|
the trade being made, and it is made on purpose: the failures it avoids are the
|
||||||
|
ones that lose data or leave two VMs on one disk, and those are not recoverable
|
||||||
|
by retrying.
|
||||||
|
|
||||||
|
A bounded wait can also fire on a slow but perfectly healthy machine, which is
|
||||||
|
why the bounds most likely to be wrong for your host are parameters rather than
|
||||||
|
constants. `stop_vm` takes a `timeout`, `guest_exec` takes a `timeout`, and
|
||||||
|
`sandbox_vm` takes `wait_agent_s`. If you are running a guest under TCG emulation
|
||||||
|
on a busy laptop, raise them rather than fighting the defaults.
|
||||||
|
|
||||||
|
The errors are written to be acted on, which is worth knowing if you are reading
|
||||||
|
them as a model: they usually name the next tool to call, and when the safe move
|
||||||
|
is to do nothing they say that explicitly. Full parameter details for every tool
|
||||||
|
mentioned here are in the [Tool reference](/reference/tools/).
|
||||||
154
docs-site/src/content/docs/explanation/sandboxing.mdx
Normal file
154
docs-site/src/content/docs/explanation/sandboxing.mdx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
title: What sandboxing does and does not give you
|
||||||
|
description: The isolation a mcqemu sandbox actually provides (a separate guest kernel, a copy-on-write overlay, blocked outbound networking) and an honest account of where that boundary ends.
|
||||||
|
---
|
||||||
|
|
||||||
|
A sandbox in mcqemu is a QEMU virtual machine with two defaults changed: its disk
|
||||||
|
is a copy-on-write overlay over a base image, and its outbound networking is
|
||||||
|
switched off. `sandbox_vm` does the overlay, the launch, and the wait for the
|
||||||
|
guest agent in one call; `sandbox_destroy` tears the whole thing down. That is
|
||||||
|
the entire mechanism. Everything below is about what those two defaults buy you
|
||||||
|
and where they stop.
|
||||||
|
|
||||||
|
## A separate kernel is the real boundary
|
||||||
|
|
||||||
|
The isolation that matters here is not something mcqemu implements. It is the
|
||||||
|
fact that a guest runs its own kernel on emulated hardware. Guest processes make
|
||||||
|
syscalls into the guest kernel, which drives virtual devices, which QEMU
|
||||||
|
implements in userspace on the host. Nothing in the guest addresses host memory,
|
||||||
|
host processes, or host files, because there is no path from guest userspace to
|
||||||
|
those things that does not go through the device model.
|
||||||
|
|
||||||
|
That is a much stronger position than a container, where the guest and the host
|
||||||
|
share one kernel and isolation is a matter of namespaces and seccomp filters
|
||||||
|
being configured correctly. If you are running software you have reason to
|
||||||
|
distrust, the kernel boundary is the reason to reach for a VM.
|
||||||
|
|
||||||
|
The cost is that you pay for it. A VM boots a whole operating system, wants its
|
||||||
|
own memory, and (when the guest architecture does not match the host) runs under
|
||||||
|
TCG emulation, which is correct but slow.
|
||||||
|
|
||||||
|
## The overlay: the base image is never written
|
||||||
|
|
||||||
|
`sandbox_vm` creates a qcow2 overlay whose backing file is your base image, and
|
||||||
|
gives the VM only the overlay. Every write the guest makes lands in the overlay.
|
||||||
|
The base image is opened read-only through the backing chain and comes out
|
||||||
|
byte-identical no matter what happens inside.
|
||||||
|
|
||||||
|
This is what makes sandboxes cheap enough to be disposable. Creating one is
|
||||||
|
writing a small file with a pointer to a big one, so a fresh sandbox from a
|
||||||
|
20 GB base costs kilobytes and a second or two. It is also what makes
|
||||||
|
`sandbox_destroy` safe: it deletes the overlay and the VM's state and runtime
|
||||||
|
directories, and reports the base image path back under `base_image_untouched`
|
||||||
|
so the answer is visible rather than assumed.
|
||||||
|
|
||||||
|
Two consequences worth knowing. The overlay is recreated fresh on every
|
||||||
|
`sandbox_vm` call for a given name, so a stale overlay from a dead sandbox never
|
||||||
|
resurrects old state. And because the base is genuinely in use while a sandbox
|
||||||
|
runs, mcqemu counts it among the disks in use, so an attempt to modify the base
|
||||||
|
underneath a running sandbox is refused.
|
||||||
|
|
||||||
|
If you want state to survive, take a snapshot (see [Snapshots](/how-to/snapshots/))
|
||||||
|
or launch a normal VM against a real disk with `launch_vm` instead. Sandboxes
|
||||||
|
are for work you intend to throw away. [Disposable sandboxes](/tutorial/disposable-sandboxes/)
|
||||||
|
walks through the loop.
|
||||||
|
|
||||||
|
## Outbound networking is off by default
|
||||||
|
|
||||||
|
`sandbox_vm` launches with QEMU's user-mode networking (SLIRP) in restricted
|
||||||
|
mode. Guest-initiated traffic is dropped. Inbound port forwards keep working, so
|
||||||
|
the default forward from a free host port to guest port 22 is still how you get
|
||||||
|
in, and `guest_exec` over the agent socket is unaffected because it never touches
|
||||||
|
the network at all.
|
||||||
|
|
||||||
|
The reason this is the default is specific, and it is worth understanding rather
|
||||||
|
than taking on faith. SLIRP synthesizes a small private network for the guest,
|
||||||
|
and in that network the address `10.0.2.2` is the host's loopback interface. A
|
||||||
|
guest with unrestricted user-mode networking can therefore open connections to
|
||||||
|
services listening on the host's `127.0.0.1`: a development database that never
|
||||||
|
bothered with authentication because it only listens locally, an SSH agent
|
||||||
|
forwarding socket, an unauthenticated Ollama or Redis, a `docker` API on a TCP
|
||||||
|
port. The mental model of "it is only on localhost, so it is private" is exactly
|
||||||
|
the model SLIRP breaks. `restrict=on` closes that path along with internet
|
||||||
|
access.
|
||||||
|
|
||||||
|
When a guest legitimately needs to fetch packages, pass `allow_network=True`, and
|
||||||
|
be aware you are re-opening the host loopback path at the same time. There is no
|
||||||
|
setting that gives internet access while blocking `10.0.2.2`, because SLIRP does
|
||||||
|
not offer that distinction. If you need it, give the VM a real bridged or TAP
|
||||||
|
interface and enforce the policy in your host firewall, which means starting
|
||||||
|
QEMU yourself and registering it with
|
||||||
|
[attach_vm](/how-to/attach-existing-vm/).
|
||||||
|
|
||||||
|
`launch_vm` takes the same `restrict_net` flag, plus `no_net=True` to remove the
|
||||||
|
network card entirely. A VM with no NIC has no forwards either, so it is only
|
||||||
|
reachable through the guest agent and the display.
|
||||||
|
|
||||||
|
## Where the boundary ends
|
||||||
|
|
||||||
|
Everything above is real. None of it makes this a hardened security boundary
|
||||||
|
against a determined attacker.
|
||||||
|
|
||||||
|
**QEMU escapes exist.** The device model is a large body of C code parsing input
|
||||||
|
that a hostile guest controls, and it has had exploitable bugs, some of them
|
||||||
|
serious. mcqemu changes nothing about that surface. If your threat model
|
||||||
|
includes an adversary willing to burn a QEMU escape on you, a mcqemu sandbox is
|
||||||
|
not the control you want on its own. Real defense in depth for that case means
|
||||||
|
running QEMU as a dedicated unprivileged user, on a host that holds nothing
|
||||||
|
valuable, behind whatever seccomp and MAC policy your distribution offers, and
|
||||||
|
not on your workstation.
|
||||||
|
|
||||||
|
**The QEMU process runs as you.** It has your file permissions, your network
|
||||||
|
access, and your ability to write to your own home directory. The boundary being
|
||||||
|
defended is "the guest cannot reach the host", and it holds against the guest
|
||||||
|
playing by the rules of the device model. It does not hold against an escape,
|
||||||
|
because the thing that escapes lands with your privileges.
|
||||||
|
|
||||||
|
**`extra_args` can weaken isolation.** `launch_vm` accepts raw QEMU flags,
|
||||||
|
because there will always be a machine type or device you need and no wrapper
|
||||||
|
anticipated. That escape hatch is an operator tool. Values you wrote yourself are
|
||||||
|
fine; values derived from anything untrusted are not, because a few flags turn a
|
||||||
|
VM into a hole straight through to the host. Those are rejected outright:
|
||||||
|
|
||||||
|
| Rejected | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| `-fsdev`, `-virtfs` | Host filesystem passthrough into the guest |
|
||||||
|
| `-drive` / `-blockdev` naming `/dev/...` | Attaches a host block device |
|
||||||
|
| `-chardev` with `spawn` | Runs a host command |
|
||||||
|
| `-runas` | Changes the user the QEMU process runs as |
|
||||||
|
| `-monitor` | Exposes the human monitor outside the managed socket |
|
||||||
|
| `-qmp`, `-pidfile`, `-daemonize` | Collide with the sockets and process model mcqemu manages |
|
||||||
|
|
||||||
|
Read that list as what it is: a guardrail against an agent constructing arguments
|
||||||
|
from untrusted input, not a security perimeter. It is a fixed list of known-bad
|
||||||
|
flags, not a proof that everything else is safe, and QEMU has a very large flag
|
||||||
|
surface. An operator who genuinely wants host passthrough can start QEMU by hand
|
||||||
|
and manage it with `attach_vm`, and nothing stops them, which is correct. The
|
||||||
|
point of the check is that the path to weakening isolation should require a
|
||||||
|
person deciding to do it.
|
||||||
|
|
||||||
|
**The guest agent is root inside the guest, by design.** `guest_exec` runs
|
||||||
|
commands as the agent's user, which is normally root, and `guest_file_write` will
|
||||||
|
write anywhere the agent can reach. That is not a leak, it is the feature: you
|
||||||
|
already control the VM's power button, its disks and its display from the host,
|
||||||
|
so declining to give you a shell would be theatre. What it does mean is that
|
||||||
|
installing the guest agent is a decision about the guest, not a neutral
|
||||||
|
convenience. Do not install it in a VM whose contents you are supposed to be
|
||||||
|
analysing at arm's length, and remember that a sandbox with the agent running has
|
||||||
|
no meaningful defense against the host, only against the guest reaching out.
|
||||||
|
|
||||||
|
## A summary you can act on
|
||||||
|
|
||||||
|
Protected: your host filesystem from guest processes, your host services on
|
||||||
|
loopback (with the default restricted networking), your base images from any
|
||||||
|
guest writes, and your host kernel from guest syscalls.
|
||||||
|
|
||||||
|
Not protected: anything, if QEMU itself is exploited; anything, if you pass
|
||||||
|
isolation-weakening `extra_args`; the guest's own contents from you, since the
|
||||||
|
agent and the monitor give you full control; and the host loopback path, if you
|
||||||
|
pass `allow_network=True`.
|
||||||
|
|
||||||
|
Use a sandbox to run software you have not read, to test an installer, to try a
|
||||||
|
package that wants to touch your whole system, or to give an agent somewhere to
|
||||||
|
make a mess. Do not use one as the only thing standing between you and code
|
||||||
|
written specifically to attack you.
|
||||||
148
docs-site/src/content/docs/explanation/see-and-drive.mdx
Normal file
148
docs-site/src/content/docs/explanation/see-and-drive.mdx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
title: Seeing and driving a VM
|
||||||
|
description: Why screenshots and scancode-level input let an agent operate a machine that has no OS yet, how absolute and relative pointers differ, and why the look-act-look loop beats a blind sequence of keystrokes.
|
||||||
|
---
|
||||||
|
|
||||||
|
Most tooling that automates a computer needs the computer's cooperation. SSH
|
||||||
|
needs a running sshd, and a network. A guest agent needs a package installed and
|
||||||
|
a service started. Configuration management needs an OS that already booted.
|
||||||
|
Every one of those requires that somebody already got the machine into a working
|
||||||
|
state, which is precisely the part that is tedious to do by hand.
|
||||||
|
|
||||||
|
mcqemu can see and drive a VM without any of it. Screenshots come out of QEMU's
|
||||||
|
framebuffer and keystrokes go into QEMU's emulated keyboard controller, so both
|
||||||
|
work at a BIOS setup screen, a GRUB menu, a partitioner, a login prompt, or a
|
||||||
|
kernel panic. That is the capability the display tools exist for.
|
||||||
|
|
||||||
|
## Screenshots come from the framebuffer, not the guest
|
||||||
|
|
||||||
|
`vm_screenshot` issues QMP's `screendump`, which asks QEMU to render whatever its
|
||||||
|
emulated display device currently holds and write it to a file on the host as a
|
||||||
|
PNG. The server reads that file, returns the image, and deletes it. The guest is
|
||||||
|
not asked, not interrupted, and does not need to be capable of anything.
|
||||||
|
|
||||||
|
Each call writes to a filename unique to that call. A shared name would let a
|
||||||
|
concurrent screenshot swap the frame between capture and use (which matters for
|
||||||
|
`vm_click`, described below), and would leave a picture of the guest's screen
|
||||||
|
sitting on disk afterwards.
|
||||||
|
|
||||||
|
Two things follow from "this is the framebuffer, not a rendering of guest state".
|
||||||
|
A blank image is a real answer, not a failure: consoles blank, guests switch to
|
||||||
|
a mode with nothing drawn yet, and a VM sitting at a black screen may be fine.
|
||||||
|
And the resolution is whatever the guest last programmed, so it changes when an
|
||||||
|
installer switches from text mode to a graphical mode, or when a display manager
|
||||||
|
starts. Coordinates from an old screenshot do not survive a mode change.
|
||||||
|
|
||||||
|
For text-mode guests there is a cheaper channel. If the guest writes to its
|
||||||
|
serial port (a kernel booted with `console=ttyS0`, or a text installer),
|
||||||
|
`vm_serial_read` returns the tail of the serial log as text, which is far easier
|
||||||
|
to read than pixels and keeps scrollback that a screenshot cannot. It returns
|
||||||
|
nothing useful for a guest that only draws to the screen.
|
||||||
|
|
||||||
|
## Keyboard input is synthesized at the scancode level
|
||||||
|
|
||||||
|
`vm_send_keys` uses QMP's `send-key`, which injects key events into the emulated
|
||||||
|
keyboard controller as QEMU's `qcode` key identifiers. The guest sees exactly
|
||||||
|
what it would see from a physical keyboard on the emulated hardware. Nothing
|
||||||
|
above the hardware layer is involved, which is why this works before an OS
|
||||||
|
exists.
|
||||||
|
|
||||||
|
Each entry in the `keys` list is one press. A single key is `"ret"`, `"esc"`,
|
||||||
|
`"f2"`, `"a"`; a chord is written with hyphens (`"ctrl-alt-f2"`, `"ctrl-c"`) and
|
||||||
|
its keys are held together. Friendly aliases are accepted, so `"enter"`,
|
||||||
|
`"space"` and `"escape"` resolve to `ret`, `spc` and `esc`. Between entries there
|
||||||
|
is a configurable delay, and each press has a hold time, because firmware menus
|
||||||
|
and some bootloaders sample the keyboard on a timer and simply miss a press that
|
||||||
|
is too brief.
|
||||||
|
|
||||||
|
`vm_type_text` is the same mechanism with a translation layer: each character is
|
||||||
|
mapped through a US layout to a qcode plus an optional shift, and sent as its own
|
||||||
|
press. This has consequences you should expect rather than discover. The layout
|
||||||
|
is fixed, so a guest configured for a non-US keyboard will produce different
|
||||||
|
characters than you typed, most visibly for symbols and for anything involving
|
||||||
|
AltGr. And every character is a separate round trip over the monitor, so typing
|
||||||
|
is slow and is capped at 4096 characters. Writing a config file by typing it into
|
||||||
|
an editor works and is sometimes the only option; when the guest agent is
|
||||||
|
available, `guest_file_write` is the right tool for anything longer than a
|
||||||
|
command line.
|
||||||
|
|
||||||
|
## Pointer input has two modes, and they behave differently
|
||||||
|
|
||||||
|
**Absolute (the default).** VMs launched by mcqemu get a `virtio-tablet-pci`
|
||||||
|
device, which reports positions rather than movements, exactly like a touchscreen
|
||||||
|
or a drawing tablet. `vm_click` takes pixel coordinates that match what
|
||||||
|
`vm_screenshot` showed you. It takes a screenshot first, both to prove a display
|
||||||
|
exists and to read the resolution, checks that your coordinates are inside it,
|
||||||
|
scales them into QEMU's absolute coordinate space (0 to 32767 on each axis), then
|
||||||
|
sends the position and the button events. There is no cursor to chase: the
|
||||||
|
pointer arrives where you said.
|
||||||
|
|
||||||
|
The catch is that the guest needs a driver for that device. Modern Linux, recent
|
||||||
|
Windows with virtio drivers, and many live images have one. A guest from before
|
||||||
|
about 2010, or a minimal install without the driver, does not, and `vm_click`
|
||||||
|
will appear to do nothing at all. That silent non-effect is the symptom to
|
||||||
|
recognise.
|
||||||
|
|
||||||
|
**Relative (the fallback).** `vm_mouse_move` drives the emulated PS/2 mouse
|
||||||
|
through the human monitor, which every guest with any mouse support understands.
|
||||||
|
The trade is that you are no longer specifying a position, you are specifying
|
||||||
|
movement, and the guest decides what to do with it. Pointer acceleration means a
|
||||||
|
50 pixel delta may move the cursor 50 pixels, or 90, depending on the guest's
|
||||||
|
settings. Large deltas also desync some guests outright, which is why motion is
|
||||||
|
sent in small packets (32 pixels by default, never more than 120, because PS/2
|
||||||
|
deltas are small signed values).
|
||||||
|
|
||||||
|
Because you cannot address a position directly, the reliable technique is to
|
||||||
|
manufacture one. Pass `home="bottom-right"` (or any corner) and mcqemu pushes the
|
||||||
|
cursor hard against that corner, overshooting deliberately, which pins it to a
|
||||||
|
known location regardless of where it was. From there you move toward the target
|
||||||
|
in small steps, take a screenshot to see where the cursor actually landed,
|
||||||
|
correct with further small moves, and only then click. The tool's own response
|
||||||
|
says as much, because getting this wrong is the default outcome and a click in
|
||||||
|
the wrong place in an installer is expensive.
|
||||||
|
|
||||||
|
## The look-act-look loop
|
||||||
|
|
||||||
|
The single most useful habit when driving a VM is: take a screenshot, perform one
|
||||||
|
action, take another screenshot. It feels wasteful and it is not.
|
||||||
|
|
||||||
|
Blind sequences of keystrokes go wrong for reasons that have nothing to do with
|
||||||
|
the sequence being incorrect. Guests take unpredictable time to react, and a VM
|
||||||
|
under TCG emulation can be an order of magnitude slower than the same guest with
|
||||||
|
KVM, so timing tuned once does not transfer. Input sent while the guest is
|
||||||
|
probing devices or switching video modes is dropped on the floor with no error
|
||||||
|
anywhere; the keystroke simply never happened. Installers reorder or insert
|
||||||
|
screens depending on what hardware they find, what mirror they reach, and whether
|
||||||
|
a disk already has a partition table. Focus moves on its own when a dialog
|
||||||
|
appears. And the failure mode compounds: once one keystroke lands on the wrong
|
||||||
|
screen, every subsequent keystroke in the sequence is being interpreted by a
|
||||||
|
program you did not intend, and the machine can end up in a state that is worse
|
||||||
|
than where it started (a partitioner that has silently selected the wrong disk is
|
||||||
|
the memorable example).
|
||||||
|
|
||||||
|
None of this is detectable from the return value of a key-send. The keystroke was
|
||||||
|
delivered to the emulated keyboard successfully; that is all the success of that
|
||||||
|
call means. The only evidence about what actually happened is the next
|
||||||
|
screenshot.
|
||||||
|
|
||||||
|
So the loop is: look at where you are, decide one action, take it, look again to
|
||||||
|
confirm it did what you expected. When a step is slow (a package installation, a
|
||||||
|
reboot, a filesystem being written) look repeatedly rather than sleeping for a
|
||||||
|
guess. When something unexpected appears, you find out one action after it
|
||||||
|
happened instead of twenty. [Drive an installer](/how-to/drive-an-installer/)
|
||||||
|
applies this to a concrete case, and [your first sandbox](/tutorial/first-sandbox/)
|
||||||
|
is a gentler place to get a feel for it.
|
||||||
|
|
||||||
|
## What this does not give you
|
||||||
|
|
||||||
|
The model is reading pixels. There is no accessibility tree, no DOM, no list of
|
||||||
|
widgets, and no text extraction: a button is a shape with letters drawn on it, so
|
||||||
|
low-contrast themes, unusual fonts, small text and scaled displays all degrade
|
||||||
|
the read. Coordinates are display-space and go stale on a mode change. There is
|
||||||
|
no way to ask what has focus, so a screenshot has to be interpreted for it.
|
||||||
|
|
||||||
|
The compensation is that all of this works on absolutely any guest, including one
|
||||||
|
whose OS does not exist yet. Once a guest is booted and has the agent installed,
|
||||||
|
`guest_exec` is faster, more precise and much easier to check for success, and it
|
||||||
|
is the right tool for anything reachable that way. Screenshots and scancodes are
|
||||||
|
for getting to that point, and for the times when the guest cannot help you.
|
||||||
149
docs-site/src/content/docs/how-to/attach-existing-vm.mdx
Normal file
149
docs-site/src/content/docs/how-to/attach-existing-vm.mdx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
---
|
||||||
|
title: Manage a VM you started yourself
|
||||||
|
description: Register an externally launched QEMU process with attach_vm, know which tools work on it, and understand what forget_vm does.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
`launch_vm` builds its own QEMU command line, which is convenient until you
|
||||||
|
need something it does not offer: a device model it never adds, a bridged
|
||||||
|
network, a specific machine type, or a VM that libvirt or a script already
|
||||||
|
starts for you.
|
||||||
|
|
||||||
|
`attach_vm` covers that case. You start QEMU however you like, and mcqemu
|
||||||
|
manages it through its QMP control socket. It never rewrites your command line
|
||||||
|
and never launches the process; it only connects to what is already there.
|
||||||
|
|
||||||
|
## Give QEMU the sockets
|
||||||
|
|
||||||
|
The one hard requirement is a QMP unix socket, added with
|
||||||
|
`-qmp unix:/path,server=on,wait=off`. The `server=on` part makes QEMU create
|
||||||
|
and listen on the socket, and `wait=off` stops it from blocking at startup
|
||||||
|
until a client connects.
|
||||||
|
|
||||||
|
Two optional additions are worth including, because retrofitting them means
|
||||||
|
restarting the guest:
|
||||||
|
|
||||||
|
- A virtio-serial channel named `org.qemu.guest_agent.0` for the `guest_*`
|
||||||
|
tools.
|
||||||
|
- `virtio-tablet-pci` for absolute pointer positioning, which is what
|
||||||
|
`vm_click` needs.
|
||||||
|
|
||||||
|
A complete example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-name legacy \
|
||||||
|
-machine q35,accel=kvm -cpu host -m 4096 -smp 4 \
|
||||||
|
-drive file=$HOME/vms/legacy.qcow2,if=virtio,format=qcow2 \
|
||||||
|
-display none \
|
||||||
|
-qmp unix:$XDG_RUNTIME_DIR/legacy-qmp.sock,server=on,wait=off \
|
||||||
|
-chardev socket,id=qga0,path=$XDG_RUNTIME_DIR/legacy-qga.sock,server=on,wait=off \
|
||||||
|
-device virtio-serial \
|
||||||
|
-device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \
|
||||||
|
-device virtio-tablet-pci \
|
||||||
|
-daemonize -pidfile $XDG_RUNTIME_DIR/legacy.pid
|
||||||
|
```
|
||||||
|
|
||||||
|
If the VM is already running without a QMP socket, there is no way to add one
|
||||||
|
without restarting it. QMP is the only channel these tools have.
|
||||||
|
|
||||||
|
## Attach it
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Register the process:
|
||||||
|
|
||||||
|
```json
|
||||||
|
attach_vm(name="legacy",
|
||||||
|
qmp_socket="/run/user/1000/legacy-qmp.sock",
|
||||||
|
qga_socket="/run/user/1000/legacy-qga.sock",
|
||||||
|
pid=48213)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only `name` and `qmp_socket` are required. `qga_socket` enables the
|
||||||
|
`guest_*` tools; `pid` lets liveness checks tell "stopped" apart from
|
||||||
|
"unreachable" without probing the socket.
|
||||||
|
|
||||||
|
2. The call verifies the socket exists, is really a unix socket, and answers a
|
||||||
|
QMP status query before registering anything, then returns the guest's
|
||||||
|
current status. A failure here means QEMU is not listening where you said.
|
||||||
|
|
||||||
|
3. Confirm it shows up alongside everything else:
|
||||||
|
|
||||||
|
```json
|
||||||
|
list_vms()
|
||||||
|
```
|
||||||
|
|
||||||
|
The entry has `source: "attached"`, which is how the other tools know it was
|
||||||
|
not spawned here.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
Names must be unique across the registry. If the name is taken, either pick
|
||||||
|
another or `forget_vm` the old entry first.
|
||||||
|
|
||||||
|
## What works, and what does not
|
||||||
|
|
||||||
|
Once attached, almost everything behaves as it does for a launched VM:
|
||||||
|
|
||||||
|
- `vm_info`, `list_vms`, `pause_vm`, `resume_vm` all work over QMP.
|
||||||
|
- `vm_screenshot`, `vm_send_keys` and `vm_type_text` work if the VM has a
|
||||||
|
display device.
|
||||||
|
- `vm_click` needs the tablet device; without it, use `vm_mouse_move` (see
|
||||||
|
[Drive an installer](/how-to/drive-an-installer/)).
|
||||||
|
- `guest_*` tools work if you passed `qga_socket` and the guest is running
|
||||||
|
`qemu-guest-agent`.
|
||||||
|
- `vm_snapshot_*` works if the VM's writable disks are qcow2.
|
||||||
|
- `stop_vm` sends an ACPI power button press over QMP, and with `force=true`
|
||||||
|
tells QEMU to quit outright.
|
||||||
|
|
||||||
|
The exception is `vm_serial_read`. mcqemu reads the serial log file it set up
|
||||||
|
at launch, and an attached VM's serial output goes wherever your own command
|
||||||
|
line sent it. The tool reports that rather than guessing. Read your own log
|
||||||
|
file directly, or use `vm_screenshot`.
|
||||||
|
|
||||||
|
`sandbox_destroy` also refuses attached VMs, along with any VM that
|
||||||
|
`sandbox_vm` did not create. It deletes disks, and it will only delete overlays
|
||||||
|
it made itself.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
`stop_vm(name="legacy", force=true)` terminates the QEMU process immediately,
|
||||||
|
exactly as if you had pulled the power cord, whoever started it. If the VM
|
||||||
|
belongs to something else that supervises it (libvirt, a systemd unit, a
|
||||||
|
script), stop it through that system instead, or you will race whatever
|
||||||
|
restarts it.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## What forget_vm does
|
||||||
|
|
||||||
|
`forget_vm` removes the registry entry. That is all it does.
|
||||||
|
|
||||||
|
```json
|
||||||
|
forget_vm(name="legacy")
|
||||||
|
```
|
||||||
|
|
||||||
|
It does not stop the VM, does not touch the QEMU process, and never deletes a
|
||||||
|
disk. Afterwards the VM keeps running exactly as before, and mcqemu simply has
|
||||||
|
no record of it. The result reports `process_left_running` so you know whether
|
||||||
|
you have just stopped tracking a live process.
|
||||||
|
|
||||||
|
Use it to hand a VM back to whatever else owns it, to clear a stale entry for a
|
||||||
|
VM that died elsewhere, or to free up a name.
|
||||||
|
|
||||||
|
For a VM that this server launched, `forget_vm` refuses while the process is
|
||||||
|
still alive unless you pass `force=true`. Forgetting a running spawned VM
|
||||||
|
orphans it: the process stays up holding its disks and ports, and no tool can
|
||||||
|
find it any more. The refusal exists so that only happens on purpose. If you do
|
||||||
|
orphan one, find it with `pgrep -af qemu-system` and either kill it or attach
|
||||||
|
it again by its socket path.
|
||||||
|
|
||||||
|
Re-attaching later is just `attach_vm` with the same socket paths. Nothing
|
||||||
|
about the running VM changed while it was unregistered.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Install and connect](/how-to/install-and-connect/) for host requirements.
|
||||||
|
- [Architecture](/explanation/architecture/) for how the registry tracks VMs
|
||||||
|
and why they outlive the server.
|
||||||
|
- [Tool reference](/reference/tools/) for parameters and defaults.
|
||||||
214
docs-site/src/content/docs/how-to/drive-an-installer.mdx
Normal file
214
docs-site/src/content/docs/how-to/drive-an-installer.mdx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
---
|
||||||
|
title: Drive an installer
|
||||||
|
description: Use the screenshot, act, screenshot loop to get through a bootloader, a text installer or a graphical one, including when to use vm_click versus vm_mouse_move.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
Installers run before there is anything inside the guest to talk to. No SSH, no
|
||||||
|
guest agent, no shell. The tools in this page work at the level QEMU emulates
|
||||||
|
hardware: a framebuffer you can photograph, a keyboard that emits scancodes,
|
||||||
|
and a mouse. That means they work in BIOS menus, bootloaders, partitioners and
|
||||||
|
desktop environments alike, on any operating system, with nothing installed in
|
||||||
|
the guest.
|
||||||
|
|
||||||
|
This page assumes a VM is already running (see
|
||||||
|
[Your first virtual machine](/tutorial/first-sandbox/) if not).
|
||||||
|
|
||||||
|
## The loop
|
||||||
|
|
||||||
|
Every interaction is the same three beats: look, act, look again.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. `vm_screenshot(name="vm")` returns the display as a PNG. Read what is
|
||||||
|
actually on screen.
|
||||||
|
|
||||||
|
2. Send exactly one meaningful action: a keystroke, a line of text, a click.
|
||||||
|
|
||||||
|
3. Screenshot again to see what it did.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
The second screenshot is not optional bookkeeping, it is the whole method. A
|
||||||
|
guest takes time to react, and an installer's next question is rarely the one
|
||||||
|
you predicted. Chaining three blind actions and screenshotting at the end
|
||||||
|
usually means finding out that action one landed somewhere unexpected and
|
||||||
|
actions two and three went into a dialog you did not know was there.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Blind sequences are how installers get told to erase the wrong disk. Answer one
|
||||||
|
prompt per screenshot, especially at partitioning steps and any confirmation
|
||||||
|
dialog.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
Guests need time. After pressing Enter on a step that formats a filesystem or
|
||||||
|
copies packages, wait before screenshotting again, and if the screen has not
|
||||||
|
changed, wait longer rather than sending the key again. Repeated keystrokes
|
||||||
|
queue up and fire all at once when the guest catches up.
|
||||||
|
|
||||||
|
## Keyboard
|
||||||
|
|
||||||
|
`vm_send_keys` presses keys and chords. Each entry in `keys` is one press:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_send_keys(name="vm", keys=["down", "down", "ret"])
|
||||||
|
vm_send_keys(name="vm", keys=["ctrl-alt-f2"])
|
||||||
|
vm_send_keys(name="vm", keys=["esc"])
|
||||||
|
```
|
||||||
|
|
||||||
|
A chord is written with hyphens and is pressed together, so `"ctrl-c"` is one
|
||||||
|
entry, not two. Aliases like `enter`, `space` and `escape` work alongside the
|
||||||
|
QEMU names `ret`, `spc`, `esc`. Function keys are `f1` through `f12`.
|
||||||
|
|
||||||
|
Two timing knobs matter when a guest is slow or an installer eats keys during
|
||||||
|
a redraw: `hold_ms` (default 100) is how long each key is held down, and
|
||||||
|
`delay_ms` (default 50) is the gap between entries. Raising `delay_ms` to 200
|
||||||
|
or so is the usual fix for a menu that seems to miss presses.
|
||||||
|
|
||||||
|
`vm_type_text` types a string character by character, which is what you want
|
||||||
|
for hostnames, passwords and shell commands:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_type_text(name="vm", text="setup-alpine", enter=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
`enter=true` presses Enter at the end. The layout is US ASCII: printable
|
||||||
|
characters plus tab and newline. There is a 4096 character limit, because every
|
||||||
|
character is a separate round trip to the guest. If you need to put a large
|
||||||
|
file into a guest, wait until the guest agent is available and use
|
||||||
|
`guest_file_write` instead of typing it.
|
||||||
|
|
||||||
|
## Text installers: read the serial console
|
||||||
|
|
||||||
|
Many text-mode installers and Linux kernels booted with `console=ttyS0` write
|
||||||
|
to the serial port, and mcqemu logs that to a file for every VM it launches:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_serial_read(name="vm", tail_lines=100)
|
||||||
|
```
|
||||||
|
|
||||||
|
Text is much cheaper to read than a screenshot and it carries scrollback, so
|
||||||
|
prefer it when the guest is producing it. Guests that only paint a graphical
|
||||||
|
screen log nothing here, and VMs registered with `attach_vm` manage their own
|
||||||
|
serial output, so `vm_serial_read` has nothing to read for those.
|
||||||
|
|
||||||
|
## Mouse: which tool
|
||||||
|
|
||||||
|
Two tools move the pointer, and picking the wrong one wastes a lot of time
|
||||||
|
because the failure is silent. Nothing errors, the click just does not land.
|
||||||
|
|
||||||
|
**`vm_click` is the one to try first.** It takes pixel coordinates that match
|
||||||
|
what `vm_screenshot` showed you, and drives an absolute-position tablet device
|
||||||
|
that every VM launched by this server has:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_click(name="vm", x=412, y=337)
|
||||||
|
vm_click(name="vm", x=88, y=120, button="right")
|
||||||
|
vm_click(name="vm", x=200, y=150, double=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
Coordinates are validated against the current display size, so a click outside
|
||||||
|
the screen is rejected rather than silently dropped.
|
||||||
|
|
||||||
|
This works when the guest has a driver for the tablet device. Modern Linux,
|
||||||
|
Windows 7 and later, and current BSDs do. Screenshot after the click: if the
|
||||||
|
pointer did not move to where you clicked, the guest has no absolute pointer
|
||||||
|
driver and you need the other tool.
|
||||||
|
|
||||||
|
**`vm_mouse_move` is for guests without tablet drivers**, which in practice
|
||||||
|
means most operating systems older than about 2010, and some installers before
|
||||||
|
their drivers load. It drives the emulated PS/2 mouse, which only understands
|
||||||
|
relative motion: "three pixels left", never "go to 412, 337". The guest may
|
||||||
|
also apply its own pointer acceleration, so a request to move 200 pixels can
|
||||||
|
land 260 pixels away.
|
||||||
|
|
||||||
|
The reliable pattern is corner, step, verify:
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Home the cursor against a corner so you know where it is. Motion is
|
||||||
|
deliberately overshot toward the edge, so the cursor pins there whatever its
|
||||||
|
previous position was.
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_mouse_move(name="vm", home="top-left")
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid corners are `top-left`, `top-right`, `bottom-left`, `bottom-right`.
|
||||||
|
|
||||||
|
2. Move toward the target with `dx` and `dy` relative to that corner:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_mouse_move(name="vm", home="top-left", dx=412, dy=337)
|
||||||
|
```
|
||||||
|
|
||||||
|
Motion is sent in packets no larger than `step` pixels (default 32) because
|
||||||
|
guests commonly desync or over-accelerate on large deltas. Lower `step` if a
|
||||||
|
guest behaves erratically; the cost is more round trips.
|
||||||
|
|
||||||
|
3. Screenshot and find the cursor. Acceleration means it is often not where you
|
||||||
|
asked.
|
||||||
|
|
||||||
|
4. Correct with small relative moves, no `home` this time, until the cursor is
|
||||||
|
on the target:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_mouse_move(name="vm", dx=-14, dy=6)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Screenshot to confirm, then click:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_mouse_move(name="vm", click="left")
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also move and click in one call by passing `click` alongside `dx`
|
||||||
|
and `dy`, but only once you trust that guest's scaling. `double=true`
|
||||||
|
double-clicks.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
Homing costs a screenshot and a burst of motion packets, so for a sequence of
|
||||||
|
clicks in the same area, home once and then work relatively from where you
|
||||||
|
know the cursor is, re-homing whenever you lose track.
|
||||||
|
|
||||||
|
## Worked example: a graphical installer
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Screenshot. The bootloader menu is showing.
|
||||||
|
|
||||||
|
2. `vm_send_keys(name="vm", keys=["ret"])` to take the default entry.
|
||||||
|
|
||||||
|
3. Wait, then screenshot. Repeat until the installer's first screen appears;
|
||||||
|
graphical installers can take a minute to start under emulation.
|
||||||
|
|
||||||
|
4. Screenshot, find the "Next" button, and `vm_click` on its centre.
|
||||||
|
|
||||||
|
5. Screenshot. If the button highlighted or the page advanced, the tablet works
|
||||||
|
and you can use `vm_click` for the rest of the installation. If nothing
|
||||||
|
moved, switch to `vm_mouse_move` with the corner, step, verify pattern.
|
||||||
|
|
||||||
|
6. For text fields, click into the field, screenshot to confirm the caret is
|
||||||
|
there, then `vm_type_text`. Do not assume focus; installers move it around
|
||||||
|
between pages.
|
||||||
|
|
||||||
|
7. At the partitioning step, screenshot before and after every action, and read
|
||||||
|
the confirmation dialog before answering it.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## After the install
|
||||||
|
|
||||||
|
When the guest is up, install `qemu-guest-agent` inside it (see
|
||||||
|
[Install the guest agent](/how-to/install-the-guest-agent/)). Screenshots and
|
||||||
|
keystrokes keep working afterwards, and stay the right tool for boot menus and
|
||||||
|
crash screens, but for anything the guest can do itself, `guest_exec` gives you
|
||||||
|
exit codes and text instead of pixels.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [See and drive](/explanation/see-and-drive/) for how input injection and
|
||||||
|
screenshots work underneath.
|
||||||
|
- [Tool reference](/reference/tools/) for the full parameter list.
|
||||||
172
docs-site/src/content/docs/how-to/install-and-connect.mdx
Normal file
172
docs-site/src/content/docs/how-to/install-and-connect.mdx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
---
|
||||||
|
title: Install and connect
|
||||||
|
description: Install mcqemu with uv, register it with Claude Code at project or user scope, and confirm the host can actually run VMs.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
mcqemu drives local QEMU processes, so it runs where QEMU runs:
|
||||||
|
|
||||||
|
- Linux, with `qemu-system-x86_64` and `qemu-img` on your PATH. Other guest
|
||||||
|
architectures need their own binary (`qemu-system-aarch64` and so on).
|
||||||
|
- Python 3.11 or newer, managed with [uv](https://docs.astral.sh/uv/).
|
||||||
|
- Read and write access to `/dev/kvm` for hardware acceleration. This is
|
||||||
|
optional: without it QEMU falls back to TCG software emulation, which works
|
||||||
|
and is considerably slower.
|
||||||
|
|
||||||
|
Check the host before blaming the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
qemu-system-x86_64 --version
|
||||||
|
qemu-img --version
|
||||||
|
ls -l /dev/kvm && test -w /dev/kvm && echo "kvm writable"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `/dev/kvm` exists but is not writable, add yourself to the group that owns
|
||||||
|
it (usually `kvm`) and log back in.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="From PyPI">
|
||||||
|
|
||||||
|
`uvx` downloads and runs the published package, with no install step to
|
||||||
|
maintain:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx mcqemu
|
||||||
|
```
|
||||||
|
|
||||||
|
It prints a version banner to stderr and then waits for an MCP client on
|
||||||
|
stdin. That is a healthy server; press Ctrl-C.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="From a checkout">
|
||||||
|
|
||||||
|
Working on mcqemu itself, or pinning to a local revision:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.supported.systems/warehack.ing/mcqemu
|
||||||
|
cd mcqemu
|
||||||
|
uv sync
|
||||||
|
uv run mcqemu
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Register with Claude Code
|
||||||
|
|
||||||
|
Pick the scope by asking who should get these tools.
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem label="Project scope">
|
||||||
|
|
||||||
|
Project scope writes `.mcp.json` in the repository, so anyone who checks it out
|
||||||
|
gets the same server. Use this when VMs are part of the project's workflow,
|
||||||
|
such as a repository whose tests need a throwaway guest.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --scope project mcqemu -- uvx mcqemu
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting file, which you can also write by hand:
|
||||||
|
|
||||||
|
```json title=".mcp.json"
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"mcqemu": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "uvx",
|
||||||
|
"args": ["mcqemu"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From a checkout, use `"command": "uv"` with
|
||||||
|
`"args": ["run", "--directory", "/path/to/mcqemu", "mcqemu"]` so the server
|
||||||
|
runs regardless of which directory Claude Code starts in.
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
<TabItem label="User scope">
|
||||||
|
|
||||||
|
User scope makes the tools available in every project you open. Use this when
|
||||||
|
VMs are part of how you work rather than part of one repository.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --scope user mcqemu -- uvx mcqemu
|
||||||
|
```
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Aside>
|
||||||
|
VMs are host-wide, not project-wide. A VM launched from one project is visible
|
||||||
|
and controllable from any other project that has mcqemu connected, because they
|
||||||
|
all read the same registry under `~/.local/share/mcqemu/`. Scope decides who
|
||||||
|
gets the tools, not which VMs they can see.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Confirm it works
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Run `/mcp` in Claude Code. `mcqemu` should be listed as connected. If it is
|
||||||
|
listed as failed, run the exact command from your config in a terminal; the
|
||||||
|
error is usually a missing `uv` on PATH or a wrong `--directory`.
|
||||||
|
|
||||||
|
2. Ask the agent to call `list_vms`. A fresh install answers with an empty list
|
||||||
|
and empty `registry_warnings`.
|
||||||
|
|
||||||
|
3. Launch something disposable to prove QEMU itself works. Any ISO will do, and
|
||||||
|
it does not need to boot anywhere useful:
|
||||||
|
|
||||||
|
```json
|
||||||
|
launch_vm(name="smoketest", iso="~/isos/alpine-virt.iso")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result reports `accel: "kvm"` or `accel: "tcg"`, which tells you whether
|
||||||
|
acceleration is active.
|
||||||
|
|
||||||
|
4. Take a screenshot with `vm_screenshot(name="smoketest")`. If you get a PNG of
|
||||||
|
a bootloader, every layer is working: the server, QEMU, and the QMP control
|
||||||
|
channel.
|
||||||
|
|
||||||
|
5. Clean up: `stop_vm(name="smoketest", force=true)`. Force is appropriate here
|
||||||
|
because no operating system is running to answer a power button press.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## When something fails
|
||||||
|
|
||||||
|
**`qemu-system-x86_64 not found on PATH`.** QEMU is not installed, or not for
|
||||||
|
that architecture. On Arch, `pacman -S qemu-full`; on Debian and Ubuntu,
|
||||||
|
`apt install qemu-system-x86`.
|
||||||
|
|
||||||
|
**The launch result says `accel: "tcg"` and you expected KVM.** Either
|
||||||
|
`/dev/kvm` is not writable by your user, or the guest architecture does not
|
||||||
|
match the host. KVM only applies when the two match; an aarch64 guest on an
|
||||||
|
x86_64 host is always emulated.
|
||||||
|
|
||||||
|
**A VM is running but no tool can see it.** VMs survive restarts of the MCP
|
||||||
|
server because they are daemonized QEMU processes, and the registry on disk is
|
||||||
|
what connects them. If `list_vms` returns a non-empty `registry_warnings`, that
|
||||||
|
bookkeeping was damaged and VMs may be running untracked. Find them with
|
||||||
|
`pgrep -af qemu-system` before launching anything with the same name.
|
||||||
|
|
||||||
|
**You want the state kept somewhere else.** `MCQEMU_STATE_DIR` and
|
||||||
|
`MCQEMU_RUNTIME_DIR` move the registry, logs and sockets;
|
||||||
|
`MCQEMU_LOG_LEVEL=DEBUG` makes the server chattier on stderr. See
|
||||||
|
[Configuration](/reference/configuration/).
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Your first virtual machine](/tutorial/first-sandbox/) for a guided
|
||||||
|
install from an ISO.
|
||||||
|
- [Tool reference](/reference/tools/) for every tool, parameter and default.
|
||||||
|
- [Architecture](/explanation/architecture/) for what the server keeps on disk
|
||||||
|
and why VMs outlive it.
|
||||||
208
docs-site/src/content/docs/how-to/install-the-guest-agent.mdx
Normal file
208
docs-site/src/content/docs/how-to/install-the-guest-agent.mdx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
title: Install the guest agent
|
||||||
|
description: Get qemu-guest-agent into a guest, either from inside a running VM or by injecting it into a stopped disk image with qemu-nbd and chroot.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
`guest_exec`, `guest_file_read` and `guest_file_write` talk to
|
||||||
|
`qemu-guest-agent` running inside the guest operating system. mcqemu wires the
|
||||||
|
host side of that channel (a virtio-serial port named
|
||||||
|
`org.qemu.guest_agent.0`) into every VM it launches, so the only missing piece
|
||||||
|
is the package inside the guest.
|
||||||
|
|
||||||
|
Check what you have:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_ping(name="vm")
|
||||||
|
```
|
||||||
|
|
||||||
|
Success means you are done. Failure means the agent is not installed, not
|
||||||
|
running, or the guest is paused or frozen. `guest_info` then reports the guest
|
||||||
|
OS, the agent version, and which agent commands are enabled.
|
||||||
|
|
||||||
|
## Option 1: from inside a running guest
|
||||||
|
|
||||||
|
Use this when the guest is up and you can reach a console (with
|
||||||
|
`vm_type_text`, see [Drive an installer](/how-to/drive-an-installer/)) or an
|
||||||
|
SSH session through a forwarded port.
|
||||||
|
|
||||||
|
The guest needs package downloads to work, so if this VM was created with
|
||||||
|
`sandbox_vm`, it has outbound networking blocked by default; recreate it with
|
||||||
|
`allow_network=true` for the install.
|
||||||
|
|
||||||
|
Run the pair of commands for the distribution, as root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debian, Ubuntu
|
||||||
|
apt-get update && apt-get install -y qemu-guest-agent
|
||||||
|
systemctl enable --now qemu-guest-agent
|
||||||
|
|
||||||
|
# Fedora, RHEL, CentOS, Rocky, Alma
|
||||||
|
dnf install -y qemu-guest-agent
|
||||||
|
systemctl enable --now qemu-guest-agent
|
||||||
|
|
||||||
|
# Arch
|
||||||
|
pacman -S --noconfirm qemu-guest-agent
|
||||||
|
systemctl enable --now qemu-guest-agent
|
||||||
|
|
||||||
|
# Alpine
|
||||||
|
apk add qemu-guest-agent
|
||||||
|
rc-update add qemu-guest-agent default && rc-service qemu-guest-agent start
|
||||||
|
|
||||||
|
# openSUSE
|
||||||
|
zypper install -y qemu-guest-agent
|
||||||
|
systemctl enable --now qemu-guest-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows guests get the agent from the
|
||||||
|
[virtio-win](https://github.com/virtio-win/virtio-win-pkg-scripts) ISO: attach
|
||||||
|
it with `launch_vm(..., iso="/path/to/virtio-win.iso")` and run
|
||||||
|
`guest-agent\qemu-ga-x86_64.msi` from inside the guest.
|
||||||
|
|
||||||
|
Then confirm from the host:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_ping(name="vm")
|
||||||
|
guest_exec(name="vm", command="uname", args=["-a"])
|
||||||
|
```
|
||||||
|
|
||||||
|
<Aside>
|
||||||
|
On RHEL-family guests the agent ships with several remote procedure calls
|
||||||
|
disabled, and `guest_exec` is one of them. If `guest_ping` succeeds but
|
||||||
|
`guest_exec` is refused, remove the relevant entries from `BLOCK_RPCS` in
|
||||||
|
`/etc/sysconfig/qemu-ga` inside the guest and restart the
|
||||||
|
`qemu-guest-agent` service.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
Enabling the service matters as much as installing the package. An agent that
|
||||||
|
is running now but not enabled at boot will be missing the next time you launch
|
||||||
|
that image, and every sandbox built on it.
|
||||||
|
|
||||||
|
## Option 2: inject it into a stopped disk image
|
||||||
|
|
||||||
|
Use this when you cannot get a console: a downloaded cloud image with no
|
||||||
|
password set, an image whose network never comes up, or when you are preparing
|
||||||
|
a base image and would rather not boot it at all.
|
||||||
|
|
||||||
|
The technique exposes the disk image to the host as a block device with
|
||||||
|
`qemu-nbd`, mounts the guest's root filesystem, and installs the package into
|
||||||
|
it through `chroot`.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The VM must not be running, and nothing else may have the image open. Two
|
||||||
|
writers on one disk image corrupts it. Run `list_vms` and confirm the image is
|
||||||
|
not attached to anything before you connect it.
|
||||||
|
|
||||||
|
Every command below needs root on the host. You are mounting a filesystem you
|
||||||
|
did not create and running its package manager; do this with images you trust.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Load the network block device module and connect the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo modprobe nbd max_part=8
|
||||||
|
sudo qemu-nbd --connect=/dev/nbd0 ~/vms/debian.qcow2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Find the root partition. Do not guess: images vary, and picking a boot or
|
||||||
|
EFI partition wastes time.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsblk /dev/nbd0
|
||||||
|
sudo blkid /dev/nbd0p*
|
||||||
|
```
|
||||||
|
|
||||||
|
The root filesystem is usually the largest ext4, xfs or btrfs partition.
|
||||||
|
If `lsblk` shows LVM physical volumes instead of a plain filesystem, run
|
||||||
|
`sudo vgchange -ay` and mount the resulting device under `/dev/mapper/`.
|
||||||
|
|
||||||
|
3. Mount it, plus the pseudo-filesystems the package manager needs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /mnt/guest
|
||||||
|
sudo mount /dev/nbd0p1 /mnt/guest
|
||||||
|
sudo mount --bind /dev /mnt/guest/dev
|
||||||
|
sudo mount --bind /proc /mnt/guest/proc
|
||||||
|
sudo mount --bind /sys /mnt/guest/sys
|
||||||
|
```
|
||||||
|
|
||||||
|
If the image has a separate `/boot` or EFI partition, mount those inside
|
||||||
|
`/mnt/guest` too. Installing the agent does not need them, but a package
|
||||||
|
manager that decides to regenerate an initramfs will fail without them.
|
||||||
|
|
||||||
|
4. Give the chroot working DNS, keeping the original so you can put it back:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /mnt/guest/etc/resolv.conf /mnt/guest/etc/resolv.conf.orig
|
||||||
|
sudo cp /etc/resolv.conf /mnt/guest/etc/resolv.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Install the agent inside the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chroot /mnt/guest apt-get update
|
||||||
|
sudo chroot /mnt/guest apt-get install -y qemu-guest-agent
|
||||||
|
sudo chroot /mnt/guest systemctl enable qemu-guest-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
`systemctl enable` works in a chroot because it only creates symlinks; it
|
||||||
|
does not need systemd to be running. `systemctl start` would fail, which is
|
||||||
|
fine, because the guest starts the service on its next boot. For dnf-based
|
||||||
|
images substitute `dnf install -y qemu-guest-agent`, and for Alpine
|
||||||
|
`apk add qemu-guest-agent` with `rc-update add qemu-guest-agent default`.
|
||||||
|
|
||||||
|
6. Restore the guest's own resolver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mv /mnt/guest/etc/resolv.conf.orig /mnt/guest/etc/resolv.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Tear down in reverse order, and check each step succeeded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo umount -R /mnt/guest
|
||||||
|
sudo qemu-nbd --disconnect /dev/nbd0
|
||||||
|
```
|
||||||
|
|
||||||
|
If `umount` reports the target is busy, find what is holding it with
|
||||||
|
`sudo lsof +D /mnt/guest` and stop that before retrying. Disconnecting the
|
||||||
|
nbd device while a filesystem is still mounted loses writes.
|
||||||
|
|
||||||
|
8. Boot it and check:
|
||||||
|
|
||||||
|
```json
|
||||||
|
launch_vm(name="debian", disks=["~/vms/debian.qcow2"])
|
||||||
|
guest_ping(name="debian")
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Never skip `qemu-nbd --disconnect`. A still-connected image is held open by the
|
||||||
|
kernel, so a later `launch_vm` on it either fails or, worse, gives you two
|
||||||
|
writers on one disk. `sudo nbd-client -c /dev/nbd0` or a quick `lsblk` tells
|
||||||
|
you whether the device is still attached.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Making it a base image
|
||||||
|
|
||||||
|
Once `guest_ping` answers, that image is ready to be the base for disposable
|
||||||
|
clones. Shut the VM down cleanly with `stop_vm` so the filesystem is
|
||||||
|
consistent, then never launch the base directly again; build sandboxes on it
|
||||||
|
instead, as in [Disposable sandboxes](/tutorial/disposable-sandboxes/).
|
||||||
|
|
||||||
|
While you are in there, two things are worth doing to the image because every
|
||||||
|
future sandbox inherits them: install the packages you always want, and make
|
||||||
|
sure the SSH server is enabled if you plan to use the forwarded port that
|
||||||
|
`sandbox_vm` sets up.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Disposable sandboxes](/tutorial/disposable-sandboxes/) for using the
|
||||||
|
finished base image.
|
||||||
|
- [Snapshots](/how-to/snapshots/) for checkpointing an image before you modify
|
||||||
|
it.
|
||||||
|
- [Tool reference](/reference/tools/) for the `guest_*` tool parameters.
|
||||||
132
docs-site/src/content/docs/how-to/snapshots.mdx
Normal file
132
docs-site/src/content/docs/how-to/snapshots.mdx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
---
|
||||||
|
title: Take and restore snapshots
|
||||||
|
description: Live snapshots of running VMs capture RAM and restore instantly; offline image snapshots work on stopped disks. When to use each.
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
There are two kinds of snapshot, and choosing between them comes down to one
|
||||||
|
question: is the VM running right now?
|
||||||
|
|
||||||
|
| | Live snapshot | Offline image snapshot |
|
||||||
|
|---|---|---|
|
||||||
|
| Tools | `vm_snapshot_create` / `_restore` / `_delete` / `_list` | `image_snapshot_create` / `_apply` / `_delete` / `_list` |
|
||||||
|
| Addressed by | VM name | disk image path |
|
||||||
|
| VM state | must be running | must be stopped |
|
||||||
|
| Captures | RAM, devices and disk | disk contents only |
|
||||||
|
| Restoring gives you | the exact moment you snapshotted, applications still open | the disk as it was, which then boots from cold |
|
||||||
|
| Requires | writable disks in qcow2 format | a qcow2 image |
|
||||||
|
|
||||||
|
Both store their data inside the qcow2 file itself, so snapshots travel with
|
||||||
|
the image when you copy it, and they cost disk space in the same file.
|
||||||
|
|
||||||
|
## Live snapshots of a running VM
|
||||||
|
|
||||||
|
Use these for checkpoints in the middle of work: before applying an update,
|
||||||
|
before a change you expect to have to undo, before letting something untrusted
|
||||||
|
run.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Create the checkpoint. The VM pauses briefly while its RAM is written into
|
||||||
|
the disk image, then continues.
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_snapshot_create(name="dev", tag="before-upgrade")
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags use letters, digits, dots, underscores and hyphens, start with a letter
|
||||||
|
or digit, and are at most 64 characters.
|
||||||
|
|
||||||
|
2. Do the risky thing.
|
||||||
|
|
||||||
|
3. List what you have if you have lost track:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_snapshot_list(name="dev")
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Roll back. RAM, devices and disk all revert together, so the guest comes
|
||||||
|
back mid-sentence rather than booting.
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_snapshot_restore(name="dev", tag="before-upgrade")
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Drop a checkpoint you no longer need. The VM keeps running; only the saved
|
||||||
|
state is removed.
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_snapshot_delete(name="dev", tag="before-upgrade")
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
Restoring throws away everything that happened after the snapshot, with no
|
||||||
|
second chance. If work since the checkpoint matters, copy it out first with
|
||||||
|
`guest_file_read` or through a forwarded port. A useful habit is to snapshot
|
||||||
|
again under a new tag before restoring an old one, which costs seconds and
|
||||||
|
keeps a way back.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
How long a snapshot takes scales with how much RAM the VM has, because that RAM
|
||||||
|
is being written to disk. A 2 GB VM is a few seconds; a 32 GB VM is a coffee
|
||||||
|
break. Restoring is the same work in reverse and is still far quicker than a
|
||||||
|
boot plus getting an application back to the state it was in.
|
||||||
|
|
||||||
|
Live snapshots need every writable disk to be qcow2. If a VM has a raw disk
|
||||||
|
attached, `vm_snapshot_create` fails, and the fix is to convert the disk while
|
||||||
|
the VM is stopped:
|
||||||
|
|
||||||
|
```json
|
||||||
|
image_convert(source="~/vms/data.img", dest="~/vms/data.qcow2", format="qcow2")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Offline snapshots of a stopped image
|
||||||
|
|
||||||
|
Use these when the VM is not running: marking a known-good state of a base
|
||||||
|
image, or checkpointing a disk before you modify it from the host (for example
|
||||||
|
before injecting the guest agent, see
|
||||||
|
[Install the guest agent](/how-to/install-the-guest-agent/)).
|
||||||
|
|
||||||
|
```json
|
||||||
|
image_snapshot_create(path="~/vms/base.qcow2", tag="clean-install")
|
||||||
|
image_snapshot_list(path="~/vms/base.qcow2")
|
||||||
|
image_snapshot_apply(path="~/vms/base.qcow2", tag="clean-install")
|
||||||
|
image_snapshot_delete(path="~/vms/base.qcow2", tag="clean-install")
|
||||||
|
```
|
||||||
|
|
||||||
|
These tools refuse to touch a disk that is attached to a running VM, and say
|
||||||
|
which VM is holding it. Stop that VM first. The refusal is deliberate: editing
|
||||||
|
a disk underneath a live guest corrupts it, and the guest would not notice
|
||||||
|
until much later.
|
||||||
|
|
||||||
|
Only disk contents are stored, so `image_snapshot_apply` gives you an image
|
||||||
|
that boots from cold. That is exactly what you want for a base image, and not
|
||||||
|
what you want mid-session, which is why the live tools exist.
|
||||||
|
|
||||||
|
## Choosing
|
||||||
|
|
||||||
|
**Working inside a VM and about to do something reversible.** Live snapshot.
|
||||||
|
The restore puts you back in the room you were standing in.
|
||||||
|
|
||||||
|
**Preparing a base image, or about to edit a disk from the host.** Offline
|
||||||
|
snapshot, with the VM stopped.
|
||||||
|
|
||||||
|
**You want a clean throwaway VM every time, not a checkpoint.** Neither. Use
|
||||||
|
`sandbox_vm` with an overlay, which is faster to create and cheaper to discard
|
||||||
|
than any snapshot; see [Disposable sandboxes](/tutorial/disposable-sandboxes/).
|
||||||
|
Snapshots are for going back inside one VM's history; overlays are for never
|
||||||
|
changing the original in the first place.
|
||||||
|
|
||||||
|
**You need a copy you can move to another machine or keep for months.**
|
||||||
|
Neither. `image_convert` writes a standalone image, flattening any backing
|
||||||
|
chain, which is easier to reason about than a snapshot living inside a file
|
||||||
|
that also holds other states.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Tool reference](/reference/tools/) for exact parameters.
|
||||||
|
- [Architecture](/explanation/architecture/) for how snapshot state is stored
|
||||||
|
in qcow2.
|
||||||
52
docs-site/src/content/docs/index.mdx
Normal file
52
docs-site/src/content/docs/index.mdx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
title: mcqemu
|
||||||
|
description: An MCP server for QEMU. Launch and drive virtual machines, run disposable sandboxes, take live snapshots, and reach inside guests, all as tools an agent can call.
|
||||||
|
template: splash
|
||||||
|
hero:
|
||||||
|
tagline: Give an agent a hypervisor. Launch VMs, watch their screens, type into them, snapshot them mid-flight, and throw them away.
|
||||||
|
actions:
|
||||||
|
- text: Start the tutorial
|
||||||
|
link: /tutorial/first-sandbox/
|
||||||
|
icon: right-arrow
|
||||||
|
variant: primary
|
||||||
|
- text: Tool reference
|
||||||
|
link: /reference/tools/
|
||||||
|
icon: open-book
|
||||||
|
variant: minimal
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Card, CardGrid } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
<CardGrid stagger>
|
||||||
|
<Card title="Disposable sandboxes" icon="rocket">
|
||||||
|
One call gives you a copy-on-write clone of a base image, booted, with the
|
||||||
|
guest agent answering. One more call destroys it. The base image is never
|
||||||
|
written to, and outbound networking is off unless you ask for it.
|
||||||
|
</Card>
|
||||||
|
<Card title="See and drive" icon="laptop">
|
||||||
|
Screenshots come from the framebuffer, so they work at a BIOS menu or a
|
||||||
|
bootloader with no guest cooperation. Keys are injected as scancodes, which
|
||||||
|
is why they work before an OS exists.
|
||||||
|
</Card>
|
||||||
|
<Card title="Live snapshots" icon="seti:db">
|
||||||
|
Save RAM, devices and disks at a moment in time, keep working, then roll the
|
||||||
|
whole machine back. Restore is instant because the memory came with it.
|
||||||
|
</Card>
|
||||||
|
<Card title="Inside the guest" icon="seti:shell">
|
||||||
|
With qemu-guest-agent installed, run commands and read or write files over
|
||||||
|
virtio-serial. No SSH, no network, no credentials.
|
||||||
|
</Card>
|
||||||
|
</CardGrid>
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx mcqemu # run it
|
||||||
|
claude mcp add mcqemu -- uvx mcqemu # add it to Claude Code
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Linux with QEMU on the PATH. `/dev/kvm` access is optional; without it
|
||||||
|
guests run under software emulation, which works but is much slower.
|
||||||
|
|
||||||
|
New here? [What is mcqemu?](/overview/) explains the shape of it in a couple of
|
||||||
|
minutes, then the [tutorial](/tutorial/first-sandbox/) gets you to a running VM.
|
||||||
59
docs-site/src/content/docs/overview.mdx
Normal file
59
docs-site/src/content/docs/overview.mdx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
title: What is mcqemu?
|
||||||
|
description: "The shape of the project in two minutes: what it manages, what an agent can do with it, and what it deliberately does not try to be."
|
||||||
|
---
|
||||||
|
|
||||||
|
mcqemu is an MCP server that turns QEMU into tools an agent can call. It manages
|
||||||
|
virtual machines on the machine it runs on: creating disks, booting them,
|
||||||
|
watching their screens, typing into them, snapshotting them, running commands
|
||||||
|
inside them, and tearing them down.
|
||||||
|
|
||||||
|
QEMU already speaks a machine-friendly protocol. QMP is JSON over a unix socket,
|
||||||
|
and the guest agent speaks the same framing over a virtio-serial channel. Most of
|
||||||
|
mcqemu is a careful mapping from that surface onto tools with names and
|
||||||
|
descriptions written for the thing calling them.
|
||||||
|
|
||||||
|
## What you can do with it
|
||||||
|
|
||||||
|
Boot an operating system from an installer ISO and drive the installation by
|
||||||
|
looking at screenshots and sending keystrokes, without a display attached.
|
||||||
|
Keep a base image around with the guest agent installed, then spin up disposable
|
||||||
|
clones of it in seconds and destroy them when you are done. Freeze a running
|
||||||
|
machine, try something destructive, and roll back to the exact moment before,
|
||||||
|
memory included. Reach into a guest to run a command or drop in a file when there
|
||||||
|
is no network and no credentials.
|
||||||
|
|
||||||
|
## The pieces
|
||||||
|
|
||||||
|
A VM launched by mcqemu is a daemonized QEMU process with two unix sockets: one
|
||||||
|
for QMP, one for the guest agent. A small JSON registry records what exists.
|
||||||
|
Because the QEMU processes are daemonized, they outlive the MCP server, so
|
||||||
|
restarting your editor does not take down your machines. See
|
||||||
|
[architecture](/explanation/architecture/) for why that shape was chosen and what
|
||||||
|
it costs.
|
||||||
|
|
||||||
|
Disk images are managed through `qemu-img`. Sandboxes are copy-on-write overlays
|
||||||
|
on top of a base image, which is what makes them cheap to create and safe to
|
||||||
|
throw away.
|
||||||
|
|
||||||
|
## What it is not
|
||||||
|
|
||||||
|
It is not a cluster manager. There is no scheduler, no migration, no multi-host
|
||||||
|
awareness; mcqemu manages VMs on one machine and says so.
|
||||||
|
|
||||||
|
It is not a hardened security boundary. A sandbox is a real VM with a separate
|
||||||
|
kernel and no outbound network by default, which is a meaningful barrier against
|
||||||
|
software that misbehaves, but it is not a claim that a determined attacker with a
|
||||||
|
QEMU escape cannot get out. [Sandboxing](/explanation/sandboxing/) is explicit
|
||||||
|
about where the line sits.
|
||||||
|
|
||||||
|
It does not require anything of a guest in order to watch or drive it. Screens
|
||||||
|
and keystrokes work at the firmware level. Only the `guest_*` tools need
|
||||||
|
cooperation, in the form of `qemu-guest-agent` running inside.
|
||||||
|
|
||||||
|
## Where to go next
|
||||||
|
|
||||||
|
The [tutorial](/tutorial/first-sandbox/) starts from nothing and ends with a
|
||||||
|
working VM you can talk to. The [tool reference](/reference/tools/) lists every
|
||||||
|
tool with its parameters. If you would rather understand the machinery first,
|
||||||
|
start with [architecture](/explanation/architecture/).
|
||||||
93
docs-site/src/content/docs/reference/configuration.mdx
Normal file
93
docs-site/src/content/docs/reference/configuration.mdx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
title: Configuration and file layout
|
||||||
|
description: Environment variables, where state and sockets live, launch defaults, and how port forwarding and firmware are chosen.
|
||||||
|
sidebar:
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MCQEMU_STATE_DIR` | `$XDG_DATA_HOME/mcqemu`, else `~/.local/share/mcqemu` | Registry, per-VM logs, sandbox overlays, UEFI variable stores |
|
||||||
|
| `MCQEMU_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/mcqemu`, else `/tmp/mcqemu-$UID` | QMP sockets, guest-agent sockets, pidfiles |
|
||||||
|
| `MCQEMU_LOG_LEVEL` | `INFO` | Log level for the server's own stderr logging |
|
||||||
|
|
||||||
|
Both directory variables are useful for tests and for running more than one
|
||||||
|
independent instance. Sockets live under the runtime directory partly because
|
||||||
|
unix socket paths are limited to about 107 bytes, and a short path leaves room
|
||||||
|
for long VM names.
|
||||||
|
|
||||||
|
## What lives where
|
||||||
|
|
||||||
|
```
|
||||||
|
$MCQEMU_STATE_DIR/
|
||||||
|
vms.json registry (schema-versioned, atomically written)
|
||||||
|
vms.json.lock advisory lock held during read-modify-write
|
||||||
|
vms/<name>/
|
||||||
|
qemu.log QEMU's own log, written after daemonizing
|
||||||
|
serial.log the guest's serial console
|
||||||
|
overlay.qcow2 sandbox overlay disk (sandboxes only)
|
||||||
|
uefi-vars.fd per-VM UEFI variable store (firmware="uefi")
|
||||||
|
|
||||||
|
$MCQEMU_RUNTIME_DIR/<name>/
|
||||||
|
qmp.sock QMP control socket
|
||||||
|
qga.sock guest-agent channel
|
||||||
|
pid pidfile written by QEMU
|
||||||
|
```
|
||||||
|
|
||||||
|
The registry is rewritten under an exclusive lock, re-reading first, so a second
|
||||||
|
mcqemu instance sharing the same state directory merges rather than overwrites.
|
||||||
|
If the file is ever unreadable it is quarantined as `vms.corrupt.<epoch>.json`
|
||||||
|
and the server starts with an empty registry, reporting the damage in the
|
||||||
|
`registry_warnings` field of `list_vms`. Running VMs are unaffected by that; they
|
||||||
|
can be re-registered with `attach_vm` using the sockets above.
|
||||||
|
|
||||||
|
## Launch defaults
|
||||||
|
|
||||||
|
`launch_vm` aims for a sensible modern machine and lets you override any of it.
|
||||||
|
|
||||||
|
| Aspect | Default |
|
||||||
|
| --- | --- |
|
||||||
|
| Machine type | `q35` on x86, `virt` on aarch64 and riscv64, QEMU's own default elsewhere |
|
||||||
|
| Acceleration | KVM with `-cpu host` when the guest architecture matches the host and `/dev/kvm` is writable, otherwise TCG with `-cpu max` |
|
||||||
|
| Memory and CPUs | 2048 MB, 2 vCPUs |
|
||||||
|
| Disks | virtio, with the format probed by `qemu-img` rather than guessed |
|
||||||
|
| Display | none, with a display device still present so screenshots work |
|
||||||
|
| Serial | written to `serial.log` |
|
||||||
|
| Pointer | virtio tablet, so `vm_click` can target exact pixels |
|
||||||
|
| Guest agent | virtio-serial channel wired on every launch |
|
||||||
|
| Networking | user-mode with virtio-net; `restrict=on` when `restrict_net` is set |
|
||||||
|
|
||||||
|
The result of `launch_vm` reports which accelerator was used, so a VM that
|
||||||
|
quietly fell back to software emulation is visible rather than merely slow.
|
||||||
|
|
||||||
|
## Port forwarding
|
||||||
|
|
||||||
|
`port_forwards` accepts three spellings:
|
||||||
|
|
||||||
|
| Form | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `"2222:22"` | Host port 2222 to guest port 22. The host port is bind-tested first, so a collision fails immediately with a clear message. |
|
||||||
|
| `"auto:22"` | A free host port is chosen. The launch result reports which one. |
|
||||||
|
| `"22"` | Shorthand for `auto:22`. |
|
||||||
|
|
||||||
|
Duplicate host ports within one list are rejected rather than left for QEMU to
|
||||||
|
fail on later.
|
||||||
|
|
||||||
|
## Firmware
|
||||||
|
|
||||||
|
`firmware="bios"` is the default. `firmware="uefi"` uses the edk2 images
|
||||||
|
installed on the host: on x86_64 that is a read-only `OVMF_CODE` pflash plus a
|
||||||
|
writable per-VM copy of `OVMF_VARS`, so UEFI variables persist per VM without
|
||||||
|
touching the shared firmware. On aarch64 and riscv64 the firmware is supplied
|
||||||
|
with `-bios` instead.
|
||||||
|
|
||||||
|
## Escape hatch
|
||||||
|
|
||||||
|
`extra_args` appends raw flags to the QEMU command line. It exists for tuning
|
||||||
|
the tool surface does not cover, and it is for values you wrote yourself, never
|
||||||
|
for values derived from untrusted input. Flags that would breach the isolation a
|
||||||
|
VM is supposed to provide are rejected: host filesystem passthrough (`-fsdev`,
|
||||||
|
`-virtfs`), host block devices, spawning chardevs, `-runas`, and flags that would
|
||||||
|
collide with the sockets and pidfile mcqemu manages.
|
||||||
448
docs-site/src/content/docs/reference/tools.mdx
Normal file
448
docs-site/src/content/docs/reference/tools.mdx
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
---
|
||||||
|
title: Tool reference
|
||||||
|
description: "Every mcqemu tool, its parameters and defaults, grouped by what it does."
|
||||||
|
sidebar:
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
The full tool surface, generated from the running server so it matches the code
|
||||||
|
exactly. Descriptions are the text the calling model sees. Parameters marked
|
||||||
|
`required` have no default; everything else may be omitted.
|
||||||
|
|
||||||
|
## Sandboxes
|
||||||
|
|
||||||
|
One call to a working disposable VM, and one to remove every trace of it.
|
||||||
|
|
||||||
|
### `sandbox_vm`
|
||||||
|
|
||||||
|
Spin up a disposable sandbox VM from a base disk image, in one call: creates a copy-on-
|
||||||
|
write overlay (the base image is never modified), launches the VM, and waits for the
|
||||||
|
guest agent to come up so guest_exec / 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 list). If no `name` is given, sandbox / sandbox-2 / ... is chosen. 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.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `base_image` | string | required |
|
||||||
|
| `name` | string | `null` |
|
||||||
|
| `memory_mb` | integer | `2048` |
|
||||||
|
| `cpus` | integer | `2` |
|
||||||
|
| `port_forwards` | array | `null` |
|
||||||
|
| `allow_network` | boolean | `false` |
|
||||||
|
| `wait_agent_s` | integer | `90` |
|
||||||
|
|
||||||
|
### `sandbox_destroy`
|
||||||
|
|
||||||
|
Destroy a sandbox created with sandbox_vm: force-stop the VM, remove it from the
|
||||||
|
registry, and delete its overlay disk and logs. The base image is untouched. Refuses to
|
||||||
|
operate on VMs that were not created by sandbox_vm — use stop_vm / forget_vm for those
|
||||||
|
(they never delete disks). If the VM cannot be killed, nothing is deleted and the call
|
||||||
|
fails.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
Starting, stopping and tracking virtual machines.
|
||||||
|
|
||||||
|
### `launch_vm`
|
||||||
|
|
||||||
|
Launch a new QEMU virtual machine and register it for management. Use `disks` for
|
||||||
|
existing image files (create them first with image_create); use `iso` to boot an
|
||||||
|
installer or live CD. With both, the VM boots the ISO once then the disk afterwards.
|
||||||
|
`port_forwards` maps host ports to guest ports (user-mode networking): "2222:22" is
|
||||||
|
explicit, "auto:22" (or just "22") picks a free host port — the result reports what was
|
||||||
|
chosen. KVM acceleration is used automatically when the guest arch matches the host. The
|
||||||
|
VM keeps running even if this MCP server restarts; stop it with 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.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `arch` | string | `"x86_64"` |
|
||||||
|
| `disks` | array | `null` |
|
||||||
|
| `iso` | string | `null` |
|
||||||
|
| `memory_mb` | integer | `2048` |
|
||||||
|
| `cpus` | integer | `2` |
|
||||||
|
| `machine` | string | `null` |
|
||||||
|
| `firmware` | string | `"bios"` |
|
||||||
|
| `port_forwards` | array | `null` |
|
||||||
|
| `no_net` | boolean | `false` |
|
||||||
|
| `restrict_net` | boolean | `false` |
|
||||||
|
| `extra_args` | array | `null` |
|
||||||
|
|
||||||
|
### `stop_vm`
|
||||||
|
|
||||||
|
Stop a VM. By default sends a graceful ACPI power-button press and waits for the guest
|
||||||
|
to shut down; if the guest ignores it (no OS booted, or OS without ACPI handling), the
|
||||||
|
call fails with advice to retry with force=True, which terminates QEMU immediately (like
|
||||||
|
pulling the power cord).
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `force` | boolean | `false` |
|
||||||
|
| `timeout` | integer | `30` |
|
||||||
|
|
||||||
|
### `pause_vm`
|
||||||
|
|
||||||
|
Pause (freeze) a running VM's virtual CPUs. The VM stays in memory; resume it with
|
||||||
|
resume_vm.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
### `resume_vm`
|
||||||
|
|
||||||
|
Resume a VM previously frozen with pause_vm.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
### `attach_vm`
|
||||||
|
|
||||||
|
Register an externally launched QEMU process so the other tools can manage it. Point
|
||||||
|
qmp_socket at its QMP unix socket (the QEMU process must have been started with e.g.
|
||||||
|
-qmp unix:/path,server=on,wait=off). Optionally provide qga_socket for guest-agent tools
|
||||||
|
and pid for liveness tracking.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `qmp_socket` | string | required |
|
||||||
|
| `qga_socket` | string | `null` |
|
||||||
|
| `pid` | integer | `null` |
|
||||||
|
|
||||||
|
### `forget_vm`
|
||||||
|
|
||||||
|
Remove a VM from the registry WITHOUT stopping it — the QEMU process is left untouched.
|
||||||
|
Refuses to forget a running VM this server spawned unless force=True (to avoid orphaning
|
||||||
|
processes by accident).
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `force` | boolean | `false` |
|
||||||
|
|
||||||
|
## Inspection
|
||||||
|
|
||||||
|
What exists and what state it is in.
|
||||||
|
|
||||||
|
### `list_vms`
|
||||||
|
|
||||||
|
List every registered VM with its live status (running, paused, shutdown, stopped, or
|
||||||
|
unreachable). Includes both VMs launched by this server and externally attached ones.
|
||||||
|
Returns \{"vms": [...], "registry_warnings": [...]\}. A non-empty registry_warnings
|
||||||
|
means bookkeeping was damaged and some VMs may be running but untracked — report it
|
||||||
|
rather than assuming the list is complete.
|
||||||
|
|
||||||
|
### `vm_info`
|
||||||
|
|
||||||
|
Detailed information about one VM: its launch configuration, log file paths, and — when
|
||||||
|
running — live QMP state (status, vCPUs, block devices).
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
## See and drive
|
||||||
|
|
||||||
|
Watching a VM's display and injecting input, with no guest software required.
|
||||||
|
|
||||||
|
### `vm_screenshot`
|
||||||
|
|
||||||
|
Capture the VM's current display as a PNG image. Works on any running VM with a display
|
||||||
|
device (the default for launched x86 VMs) — no guest software needed. Use this to watch
|
||||||
|
installers, read console output, or verify GUI state before sending keys with
|
||||||
|
vm_send_keys / vm_type_text.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
### `vm_send_keys`
|
||||||
|
|
||||||
|
Press keys or key combos in the VM. Each list entry is one press: a single key ("ret",
|
||||||
|
"esc", "f2", "a") or a chord pressed together ("ctrl-alt-f2", "ctrl-c"). Entries are
|
||||||
|
sent in order with delay_ms between them. Aliases like "enter", "space", "escape" work.
|
||||||
|
To type prose, use vm_type_text instead.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `keys` | array | required |
|
||||||
|
| `hold_ms` | integer | `100` |
|
||||||
|
| `delay_ms` | integer | `50` |
|
||||||
|
|
||||||
|
### `vm_type_text`
|
||||||
|
|
||||||
|
Type a string into the VM, character by character (US keyboard layout; 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.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `text` | string | required |
|
||||||
|
| `enter` | boolean | `false` |
|
||||||
|
| `delay_ms` | integer | `30` |
|
||||||
|
|
||||||
|
### `vm_click`
|
||||||
|
|
||||||
|
Click at pixel coordinates (x, y) on the VM display — coordinates match what
|
||||||
|
vm_screenshot shows. Requires the VM's tablet device for absolute positioning (present
|
||||||
|
on VMs launched by this server). button: left, right, or middle.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `x` | integer | required |
|
||||||
|
| `y` | integer | required |
|
||||||
|
| `button` | string | `"left"` |
|
||||||
|
| `double` | boolean | `false` |
|
||||||
|
|
||||||
|
### `vm_mouse_move`
|
||||||
|
|
||||||
|
Relative mouse control for guests WITHOUT absolute-pointer (tablet) drivers — most
|
||||||
|
pre-2010 OSes. Use this when vm_click has no visible effect. Motion goes to the emulated
|
||||||
|
PS/2 mouse in small steps (\<= `step` px per packet) because guests often desync or
|
||||||
|
apply acceleration on large deltas. Recommended pattern: pass home="bottom-right" (or
|
||||||
|
another corner) to pin the cursor to a known position first, then dx/dy toward the
|
||||||
|
target, then vm_screenshot to verify where the cursor actually landed (guest
|
||||||
|
acceleration may scale motion), correct with further small moves, and finally click.
|
||||||
|
`click` presses that button after moving; double=True double-clicks.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `dx` | integer | `0` |
|
||||||
|
| `dy` | integer | `0` |
|
||||||
|
| `home` | string | `null` |
|
||||||
|
| `click` | string | `null` |
|
||||||
|
| `double` | boolean | `false` |
|
||||||
|
| `step` | integer | `32` |
|
||||||
|
|
||||||
|
### `vm_serial_read`
|
||||||
|
|
||||||
|
Read the last lines of the VM's serial console log. Only useful when the guest writes to
|
||||||
|
its serial port (kernel console=ttyS0, or text-mode installers); GUI-only guests log
|
||||||
|
nothing here — use vm_screenshot for those.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `tail_lines` | integer | `50` |
|
||||||
|
|
||||||
|
## Live snapshots
|
||||||
|
|
||||||
|
Point-in-time state of a running VM, including RAM. Requires qcow2 disks.
|
||||||
|
|
||||||
|
### `vm_snapshot_create`
|
||||||
|
|
||||||
|
Save a live internal snapshot (RAM + device + disk state) of a running VM under `tag`.
|
||||||
|
Requires the VM's writable disks to be qcow2. The VM pauses briefly while state is
|
||||||
|
written. Restore later with vm_snapshot_restore.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `tag` | string | required |
|
||||||
|
|
||||||
|
### `vm_snapshot_restore`
|
||||||
|
|
||||||
|
Roll a running VM back to the internal snapshot `tag` (RAM, devices and disks all
|
||||||
|
revert). Anything that happened after the snapshot is lost.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `tag` | string | required |
|
||||||
|
|
||||||
|
### `vm_snapshot_delete`
|
||||||
|
|
||||||
|
Delete the internal snapshot `tag` from a running VM's disks. The VM keeps running; only
|
||||||
|
the saved snapshot is removed.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `tag` | string | required |
|
||||||
|
|
||||||
|
### `vm_snapshot_list`
|
||||||
|
|
||||||
|
List internal snapshots visible to a running VM. For stopped VMs use image_snapshot_list
|
||||||
|
on the disk file instead.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
## Disk images
|
||||||
|
|
||||||
|
qemu-img operations on images that are not currently attached to a running VM.
|
||||||
|
|
||||||
|
### `image_create`
|
||||||
|
|
||||||
|
Create a new disk image. `size` uses qemu-img suffixes, e.g. "20G". qcow2 grows on
|
||||||
|
demand, so a large virtual size costs almost nothing up front. With `backing_file`, the
|
||||||
|
new image becomes a copy-on-write overlay — great for cheap disposable clones of a base
|
||||||
|
image. Refuses to replace an existing file unless overwrite=True.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `size` | string | required |
|
||||||
|
| `format` | string | `"qcow2"` |
|
||||||
|
| `backing_file` | string | `null` |
|
||||||
|
| `overwrite` | boolean | `false` |
|
||||||
|
|
||||||
|
### `image_info`
|
||||||
|
|
||||||
|
Inspect a disk image: format, virtual and on-disk size, internal snapshots, and (with
|
||||||
|
backing_chain=True) the full copy-on-write chain.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `backing_chain` | boolean | `false` |
|
||||||
|
|
||||||
|
### `image_convert`
|
||||||
|
|
||||||
|
Convert a disk image to another format (e.g. raw -> qcow2, vmdk -> qcow2). Conversion
|
||||||
|
flattens any backing chain into a standalone image. compress=True enables qcow2
|
||||||
|
compression (smaller, slower).
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `source` | string | required |
|
||||||
|
| `dest` | string | required |
|
||||||
|
| `format` | string | `"qcow2"` |
|
||||||
|
| `compress` | boolean | `false` |
|
||||||
|
| `overwrite` | boolean | `false` |
|
||||||
|
|
||||||
|
### `image_resize`
|
||||||
|
|
||||||
|
Resize a disk image's virtual size (e.g. size="30G", or "+10G" to grow relatively).
|
||||||
|
Growing is safe; shrinking DESTROYS data beyond the new size and requires shrink=True as
|
||||||
|
explicit confirmation (shrink the guest filesystem first!).
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `size` | string | required |
|
||||||
|
| `shrink` | boolean | `false` |
|
||||||
|
|
||||||
|
### `image_snapshot_create`
|
||||||
|
|
||||||
|
Create an internal disk-only snapshot in an offline qcow2 image. For running VMs use
|
||||||
|
vm_snapshot_create instead (it also captures RAM).
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `tag` | string | required |
|
||||||
|
|
||||||
|
### `image_snapshot_apply`
|
||||||
|
|
||||||
|
Revert an offline qcow2 image to internal snapshot `tag`. Data written after the
|
||||||
|
snapshot is lost.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `tag` | string | required |
|
||||||
|
|
||||||
|
### `image_snapshot_delete`
|
||||||
|
|
||||||
|
Delete internal snapshot `tag` from an offline qcow2 image.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `tag` | string | required |
|
||||||
|
|
||||||
|
### `image_snapshot_list`
|
||||||
|
|
||||||
|
List internal snapshots stored in a (not currently running) qcow2 image.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `path` | string | required |
|
||||||
|
|
||||||
|
## Guest agent
|
||||||
|
|
||||||
|
Reaching inside a guest. Needs qemu-guest-agent installed and running in the guest OS.
|
||||||
|
|
||||||
|
### `guest_ping`
|
||||||
|
|
||||||
|
Check whether the qemu-guest-agent inside the VM is alive and responding. A failure
|
||||||
|
means the guest OS doesn't have the agent installed or running — the other guest_* tools
|
||||||
|
won't work until it does.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
### `guest_info`
|
||||||
|
|
||||||
|
Report the guest OS details (name, version, kernel) and the guest agent's version and
|
||||||
|
supported commands.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
|
||||||
|
### `guest_exec`
|
||||||
|
|
||||||
|
Run a command inside the guest OS and return its stdout, stderr, and exit code.
|
||||||
|
`command` is the executable path or name; pass arguments separately in `args` (this is
|
||||||
|
exec, not a shell — for shell features use command="/bin/sh", args=["-c", "your |
|
||||||
|
pipeline"]). Requires qemu-guest-agent in the guest.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `command` | string | required |
|
||||||
|
| `args` | array | `null` |
|
||||||
|
| `stdin` | string | `null` |
|
||||||
|
| `timeout` | integer | `30` |
|
||||||
|
|
||||||
|
### `guest_file_read`
|
||||||
|
|
||||||
|
Read a text file from inside the guest (up to max_bytes, default 1 MiB, 8 MiB ceiling).
|
||||||
|
Requires qemu-guest-agent in the guest.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `max_bytes` | integer | `1048576` |
|
||||||
|
|
||||||
|
### `guest_file_write`
|
||||||
|
|
||||||
|
Write a text file inside the guest (mode 'w' truncates, append=True appends). Requires
|
||||||
|
qemu-guest-agent in the guest.
|
||||||
|
|
||||||
|
| Parameter | Type | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | required |
|
||||||
|
| `path` | string | required |
|
||||||
|
| `content` | string | required |
|
||||||
|
| `append` | boolean | `false` |
|
||||||
|
|
||||||
206
docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx
Normal file
206
docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
title: Disposable sandboxes
|
||||||
|
description: Turn a base image into throwaway VMs that boot in seconds, run something in one, then delete it without touching the base.
|
||||||
|
sidebar:
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
In [Your first virtual machine](/tutorial/first-sandbox/) we spent twenty
|
||||||
|
minutes installing an operating system. In this tutorial we get a working VM
|
||||||
|
from that same image in a few seconds, run a command in it, deliberately make a
|
||||||
|
mess inside it, and throw it away. Then we do it again and watch the mess be
|
||||||
|
gone.
|
||||||
|
|
||||||
|
This is the loop worth internalising: build a base image once, then treat
|
||||||
|
individual VMs as cheap and disposable.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
The base image from the first tutorial, `~/vms/alpine-agent.qcow2`, with
|
||||||
|
`qemu-guest-agent` installed inside it, and the VM built on it shut down.
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
The base image must not be in use by a running VM. A sandbox reads from the
|
||||||
|
base while the guest writes into an overlay on top; if something else is
|
||||||
|
writing to the base at the same time, both disks are corrupted. Call `list_vms`
|
||||||
|
first and stop anything still running on that image.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Create a sandbox
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Ask the agent for a sandbox:
|
||||||
|
|
||||||
|
```json
|
||||||
|
sandbox_vm(base_image="~/vms/alpine-agent.qcow2")
|
||||||
|
```
|
||||||
|
|
||||||
|
One call does four things: it creates a copy-on-write overlay disk, launches
|
||||||
|
a VM on that overlay, forwards a free host port to guest port 22, and waits
|
||||||
|
for the guest agent to answer before returning.
|
||||||
|
|
||||||
|
2. Read the result closely, because it tells us what we got:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "sandbox",
|
||||||
|
"status": "running",
|
||||||
|
"sandbox": true,
|
||||||
|
"overlay": "~/.local/share/mcqemu/vms/sandbox/overlay.qcow2",
|
||||||
|
"base_image": "/home/you/vms/alpine-agent.qcow2",
|
||||||
|
"network": "outbound blocked",
|
||||||
|
"guest_agent": "responding",
|
||||||
|
"port_forwards": ["39221:22"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We did not pass a name, so it picked `sandbox` (the next one would be
|
||||||
|
`sandbox-2`). `guest_agent: responding` means `guest_exec` will work right
|
||||||
|
now, with no waiting and no login.
|
||||||
|
|
||||||
|
3. Prove the guest is real:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_exec(name="sandbox", command="uname", args=["-a"])
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
<Aside>
|
||||||
|
The guest agent answers well before the guest has finished booting, so
|
||||||
|
`sandbox_vm` can return while services like networking are still starting. If
|
||||||
|
a command depends on one, poll for it inside the guest (for example
|
||||||
|
`guest_exec(name="sandbox", command="/bin/sh", args=["-c", "rc-service networking status"])`)
|
||||||
|
rather than assuming it is up.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## The base image is never modified
|
||||||
|
|
||||||
|
The sandbox writes to an overlay file, and the overlay refers back to the base
|
||||||
|
for anything it has not changed. Let us see it.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Write a file inside the guest:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_file_write(name="sandbox", path="/root/evidence.txt",
|
||||||
|
content="this sandbox was here\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Read it back to confirm it landed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_file_read(name="sandbox", path="/root/evidence.txt")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Now do some real damage, the kind you would never risk on a machine you
|
||||||
|
cared about:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_exec(name="sandbox", command="/bin/sh", args=["-c", "rm -rf /etc/apk"])
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Look at the relationship between the two disks:
|
||||||
|
|
||||||
|
```json
|
||||||
|
image_info(path="~/.local/share/mcqemu/vms/sandbox/overlay.qcow2", backing_chain=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
The chain shows the overlay on top and `~/vms/alpine-agent.qcow2` below it
|
||||||
|
as the backing file. Check the base image's modification time on the host:
|
||||||
|
it has not changed. Every write the guest made went into the overlay.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Outbound networking is off by default
|
||||||
|
|
||||||
|
Try to reach the internet from inside the sandbox:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_exec(name="sandbox", command="/bin/sh", args=["-c", "apk update"])
|
||||||
|
```
|
||||||
|
|
||||||
|
It fails. That is the point of the default: traffic the guest starts is
|
||||||
|
dropped, so it cannot reach the internet or any service listening on your host.
|
||||||
|
Inbound port forwards still work, which is why the forwarded SSH port is
|
||||||
|
useful even with networking restricted.
|
||||||
|
|
||||||
|
When a sandbox legitimately needs to fetch packages, ask for it explicitly with
|
||||||
|
`allow_network=true`, and know that you have opened the door on purpose.
|
||||||
|
|
||||||
|
## Throw it away
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Destroy the sandbox:
|
||||||
|
|
||||||
|
```json
|
||||||
|
sandbox_destroy(name="sandbox")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result confirms `overlay_deleted: true` and names the base image it left
|
||||||
|
alone. The VM is force-stopped, removed from the registry, and its overlay
|
||||||
|
and logs are deleted.
|
||||||
|
|
||||||
|
2. Create a fresh one from the same base:
|
||||||
|
|
||||||
|
```json
|
||||||
|
sandbox_vm(base_image="~/vms/alpine-agent.qcow2")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Look for the damage:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_file_read(name="sandbox", path="/root/evidence.txt")
|
||||||
|
```
|
||||||
|
|
||||||
|
The file does not exist, and `/etc/apk` is back. The new sandbox started
|
||||||
|
from the base image exactly as it was.
|
||||||
|
|
||||||
|
4. Clean up:
|
||||||
|
|
||||||
|
```json
|
||||||
|
sandbox_destroy(name="sandbox")
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
`sandbox_destroy` deletes the overlay disk, and with it everything that
|
||||||
|
happened inside that VM. There is no undo. If a sandbox produced something you
|
||||||
|
want to keep, copy it out first with `guest_file_read`, or through the
|
||||||
|
forwarded port, before destroying it.
|
||||||
|
|
||||||
|
It only ever touches VMs that `sandbox_vm` created. Ask it to destroy a VM you
|
||||||
|
launched with `launch_vm` and it refuses, because those disks are yours and
|
||||||
|
deleting them would be a surprise.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## What we learned
|
||||||
|
|
||||||
|
A base image plus an overlay gives us VMs that cost seconds instead of minutes,
|
||||||
|
because the expensive part (installing an operating system) already happened
|
||||||
|
and is shared by every sandbox built on it.
|
||||||
|
|
||||||
|
Overlays make throwing a VM away the normal outcome rather than a loss.
|
||||||
|
`sandbox_destroy` then `sandbox_vm` is a full reset back to a known state, and
|
||||||
|
the base image is safe from anything the guest does because the guest can only
|
||||||
|
write to the overlay.
|
||||||
|
|
||||||
|
Restricting outbound traffic by default is what makes it a sandbox rather than
|
||||||
|
just a fast VM. Software running inside it cannot phone anywhere or poke at
|
||||||
|
services on your host unless you decide otherwise.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Snapshots](/how-to/snapshots/) if you want checkpoints inside a
|
||||||
|
longer-lived VM instead of a clean slate every time.
|
||||||
|
- [Install the guest agent](/how-to/install-the-guest-agent/) to turn another
|
||||||
|
operating system into a base image, including injecting the agent into a
|
||||||
|
downloaded cloud image offline.
|
||||||
|
- [Sandboxing](/explanation/sandboxing/) for what these isolation boundaries do
|
||||||
|
and do not promise.
|
||||||
283
docs-site/src/content/docs/tutorial/first-sandbox.mdx
Normal file
283
docs-site/src/content/docs/tutorial/first-sandbox.mdx
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
title: Your first virtual machine
|
||||||
|
description: Install mcqemu, create a disk, install an operating system from an ISO, and end with a VM your agent can run commands inside.
|
||||||
|
sidebar:
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
import { Steps, Aside } from '@astrojs/starlight/components';
|
||||||
|
|
||||||
|
In this tutorial we install mcqemu, create a blank disk, install Alpine Linux
|
||||||
|
onto it from an installer ISO while watching the screen, and finish with a VM
|
||||||
|
whose guest agent answers, so the agent can run commands inside it.
|
||||||
|
|
||||||
|
We will drive everything through an LLM agent (the examples assume Claude Code).
|
||||||
|
Each step shows the tool call to ask for, and what the result should look like,
|
||||||
|
so you can tell at a glance whether the step worked.
|
||||||
|
|
||||||
|
Expect this to take around twenty minutes, most of which is the operating
|
||||||
|
system installer doing its own thing.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
A Linux host with QEMU installed (`qemu-system-x86_64` and `qemu-img` on your
|
||||||
|
PATH), [uv](https://docs.astral.sh/uv/), and about 2 GB of free disk space.
|
||||||
|
Access to `/dev/kvm` makes everything much faster, but the tutorial works
|
||||||
|
without it under software emulation.
|
||||||
|
|
||||||
|
Download the Alpine "virt" ISO before starting, from
|
||||||
|
[alpinelinux.org/downloads](https://alpinelinux.org/downloads/). It is around
|
||||||
|
60 MB. We will assume it is at `~/isos/alpine-virt.iso`, and we will keep our
|
||||||
|
VM disk at `~/vms/alpine-agent.qcow2`, so create those directories now:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/isos ~/vms
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install and connect the server
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Check that the server starts. `uvx` fetches mcqemu and runs it; the version
|
||||||
|
banner appears on stderr and then it waits for a client, which is what an
|
||||||
|
MCP server is supposed to do.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx mcqemu
|
||||||
|
```
|
||||||
|
|
||||||
|
Press Ctrl-C to stop it. Seeing `mcqemu v...` is all we needed.
|
||||||
|
|
||||||
|
2. Register it with Claude Code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add mcqemu -- uvx mcqemu
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start Claude Code in any directory and run `/mcp`. The list should include
|
||||||
|
`mcqemu` as connected. If it does not, [Install and connect](/how-to/install-and-connect/)
|
||||||
|
covers the usual causes.
|
||||||
|
|
||||||
|
4. Ask the agent to call `list_vms`. On a fresh install it answers with an
|
||||||
|
empty list:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"vms": [], "registry_warnings": []}
|
||||||
|
```
|
||||||
|
|
||||||
|
An empty `registry_warnings` means the bookkeeping is healthy. We now have a
|
||||||
|
working server with nothing to manage yet.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Create a disk and start the installer
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Ask the agent to create the disk:
|
||||||
|
|
||||||
|
```json
|
||||||
|
image_create(path="~/vms/alpine-agent.qcow2", size="8G")
|
||||||
|
```
|
||||||
|
|
||||||
|
The result reports `format: qcow2` and a virtual size of 8 GB. Check the
|
||||||
|
file on the host and you will see it occupies only a couple of hundred
|
||||||
|
kilobytes. qcow2 images grow as the guest writes, so a generous virtual size
|
||||||
|
costs almost nothing up front.
|
||||||
|
|
||||||
|
2. Launch the VM with the installer ISO attached:
|
||||||
|
|
||||||
|
```json
|
||||||
|
launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"],
|
||||||
|
iso="~/isos/alpine-virt.iso", memory_mb=2048)
|
||||||
|
```
|
||||||
|
|
||||||
|
The result tells us the VM is running, which acceleration it got (`kvm` or
|
||||||
|
`tcg`), the process ID, and paths to its logs. When a VM has both a disk and
|
||||||
|
an ISO, it boots the ISO this once and the disk on every later boot, so we
|
||||||
|
will not have to detach anything by hand.
|
||||||
|
|
||||||
|
3. Give it fifteen seconds, then look at the screen:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_screenshot(name="alpine")
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent gets a PNG of the guest display back. You should see Alpine's
|
||||||
|
boot messages or a `localhost login:` prompt. Nothing is installed inside
|
||||||
|
the guest to make this work; QEMU is handing over the framebuffer directly,
|
||||||
|
which is why screenshots work in bootloaders and BIOS menus too.
|
||||||
|
|
||||||
|
If the screen still shows a bootloader, wait and take another screenshot.
|
||||||
|
Watching, rather than assuming, is the habit this whole workflow is built
|
||||||
|
on.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Install Alpine onto the disk
|
||||||
|
|
||||||
|
The installer is an interactive console session, so we type into it the same
|
||||||
|
way a person at a keyboard would.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Log in as root (Alpine's live image has no root password):
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_type_text(name="alpine", text="root", enter=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
Take a screenshot to confirm we landed at a shell prompt before continuing.
|
||||||
|
|
||||||
|
2. Start the installer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_type_text(name="alpine", text="setup-alpine", enter=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Work through the questions with `vm_type_text`, taking a screenshot after
|
||||||
|
each answer to see what is being asked next. Most answers can be the
|
||||||
|
default (press Enter with `vm_send_keys(name="alpine", keys=["ret"])`).
|
||||||
|
The three that matter:
|
||||||
|
|
||||||
|
- Set a root password when asked, and remember it. We need it to log in later.
|
||||||
|
- When asked which disk to use, answer `vda`. Virtio disks appear under that
|
||||||
|
name inside the guest.
|
||||||
|
- When asked how to use it, answer `sys`. That installs to the disk rather
|
||||||
|
than running from RAM.
|
||||||
|
|
||||||
|
Alpine asks for a final `y` to erase the disk. Nothing else on your machine
|
||||||
|
is at risk; the only disk the VM can see is the image file we created.
|
||||||
|
|
||||||
|
4. When the installer reports it is done, shut the guest down cleanly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
stop_vm(name="alpine")
|
||||||
|
```
|
||||||
|
|
||||||
|
`stop_vm` presses the virtual power button and waits for the guest to
|
||||||
|
shut itself down, so filesystems get flushed properly.
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
<Aside type="caution">
|
||||||
|
If a guest ignores the power button (no operating system booted yet, or one
|
||||||
|
without ACPI support), `stop_vm` fails and suggests `force=true`. That is the
|
||||||
|
equivalent of pulling the power cord out of the wall: instant, and it can leave
|
||||||
|
a half-written filesystem behind. Reach for it when a guest is genuinely stuck,
|
||||||
|
not as a default.
|
||||||
|
</Aside>
|
||||||
|
|
||||||
|
## Boot from disk
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Launch again, this time with no ISO, and forward a host port to the guest's
|
||||||
|
SSH port so we have a way in later:
|
||||||
|
|
||||||
|
```json
|
||||||
|
launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"],
|
||||||
|
port_forwards=["auto:22"])
|
||||||
|
```
|
||||||
|
|
||||||
|
`auto:22` means "pick any free host port"; the result reports which one it
|
||||||
|
chose, for example `port_forwards: ["43617:22"]`. You can also ask for a
|
||||||
|
specific one with `"2222:22"`, and mcqemu checks up front that the port is
|
||||||
|
free instead of letting QEMU fail obscurely.
|
||||||
|
|
||||||
|
2. Screenshot after a few seconds. This time the login prompt comes from the
|
||||||
|
installed system on the disk, not from the ISO.
|
||||||
|
|
||||||
|
3. Log in with `root` and the password you set:
|
||||||
|
|
||||||
|
```json
|
||||||
|
vm_type_text(name="alpine", text="root", enter=true)
|
||||||
|
vm_type_text(name="alpine", text="<your password>", enter=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Install the guest agent
|
||||||
|
|
||||||
|
Everything so far worked through the screen and keyboard, with no cooperation
|
||||||
|
from the guest operating system. The `guest_*` tools are different: they need
|
||||||
|
`qemu-guest-agent` running inside the guest. mcqemu wires up the host side of
|
||||||
|
that channel on every launch, so installing the package is the only step left.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
|
||||||
|
1. Confirm the agent is genuinely missing, so the difference is visible:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_ping(name="alpine")
|
||||||
|
```
|
||||||
|
|
||||||
|
This fails, and the error says the guest does not have the agent installed
|
||||||
|
or running.
|
||||||
|
|
||||||
|
2. Type these three commands into the guest console, one at a time, with
|
||||||
|
`vm_type_text(..., enter=true)`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apk add qemu-guest-agent
|
||||||
|
rc-update add qemu-guest-agent default
|
||||||
|
rc-service qemu-guest-agent start
|
||||||
|
```
|
||||||
|
|
||||||
|
The VM has outbound network access (we did not restrict it), so `apk` can
|
||||||
|
reach Alpine's mirrors. Take a screenshot after the first command to check
|
||||||
|
the download succeeded; if `apk` cannot find the package, run
|
||||||
|
`setup-apkrepos -c -1` to enable the community repository and try again.
|
||||||
|
|
||||||
|
3. Ask again:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_ping(name="alpine")
|
||||||
|
```
|
||||||
|
|
||||||
|
Now it answers `guest_agent: responding`.
|
||||||
|
|
||||||
|
4. Run a command inside the guest without touching the keyboard:
|
||||||
|
|
||||||
|
```json
|
||||||
|
guest_exec(name="alpine", command="uname", args=["-a"])
|
||||||
|
```
|
||||||
|
|
||||||
|
The result carries the guest's stdout, stderr, and exit code. Note that
|
||||||
|
`command` is an executable and `args` are its arguments; this is exec, not a
|
||||||
|
shell. For pipelines, use `command="/bin/sh"` with
|
||||||
|
`args=["-c", "your | pipeline"]`.
|
||||||
|
|
||||||
|
5. Shut the VM down. We want the image quiescent for the next tutorial.
|
||||||
|
|
||||||
|
```json
|
||||||
|
stop_vm(name="alpine")
|
||||||
|
```
|
||||||
|
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## What we built
|
||||||
|
|
||||||
|
`~/vms/alpine-agent.qcow2` is now an installed Alpine system with a working
|
||||||
|
guest agent. Along the way we saw the two ways to interact with a VM:
|
||||||
|
|
||||||
|
- Through the display and keyboard (`vm_screenshot`, `vm_type_text`,
|
||||||
|
`vm_send_keys`), which works on any guest at any stage of boot, including
|
||||||
|
installers and bootloaders, because it operates below the operating system.
|
||||||
|
- Through the guest agent (`guest_ping`, `guest_exec`), which is faster and
|
||||||
|
gives you structured output, but only after you have put the agent inside
|
||||||
|
the guest.
|
||||||
|
|
||||||
|
We also saw that disks and VMs are separate things: `image_create` makes a
|
||||||
|
disk, `launch_vm` runs a VM around it, and the same disk can be booted again
|
||||||
|
later with different settings.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Keep that image. In [Disposable sandboxes](/tutorial/disposable-sandboxes/) we
|
||||||
|
use it as a base to spin up throwaway clones in seconds, run untrusted things
|
||||||
|
in them, and delete them without the base ever changing.
|
||||||
|
|
||||||
|
If you want to install something bigger than Alpine next, read
|
||||||
|
[Drive an installer](/how-to/drive-an-installer/) for the screenshot and input
|
||||||
|
loop in detail, including mouse control for graphical installers.
|
||||||
74
docs-site/src/styles/theme.css
Normal file
74
docs-site/src/styles/theme.css
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/* mcqemu — a restrained terminal palette.
|
||||||
|
*
|
||||||
|
* The accent is the amber of a monitor that has been on too long, against
|
||||||
|
* slate greys. Starlight derives most of its surface colours from these
|
||||||
|
* tokens, so overriding the scale is enough for both themes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sl-color-accent-low: #3a2a08;
|
||||||
|
--sl-color-accent: #b5820f;
|
||||||
|
--sl-color-accent-high: #f0c674;
|
||||||
|
|
||||||
|
--sl-color-white: #f4f4f2;
|
||||||
|
--sl-color-gray-1: #e3e4e1;
|
||||||
|
--sl-color-gray-2: #c0c2be;
|
||||||
|
--sl-color-gray-3: #8b8e88;
|
||||||
|
--sl-color-gray-4: #55584f;
|
||||||
|
--sl-color-gray-5: #34362f;
|
||||||
|
--sl-color-gray-6: #24261f;
|
||||||
|
--sl-color-black: #16170f;
|
||||||
|
|
||||||
|
--sl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--sl-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--sl-color-accent-low: #f5e6c0;
|
||||||
|
--sl-color-accent: #8a610a;
|
||||||
|
--sl-color-accent-high: #4a3405;
|
||||||
|
|
||||||
|
--sl-color-white: #1b1c16;
|
||||||
|
--sl-color-gray-1: #24261f;
|
||||||
|
--sl-color-gray-2: #3f423a;
|
||||||
|
--sl-color-gray-3: #6b6e66;
|
||||||
|
--sl-color-gray-4: #9a9d94;
|
||||||
|
--sl-color-gray-5: #d3d5cf;
|
||||||
|
--sl-color-gray-6: #eceee8;
|
||||||
|
--sl-color-gray-7: #f6f7f3;
|
||||||
|
--sl-color-black: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Starlight's staggered CardGrid rotates card icon backgrounds through a
|
||||||
|
* built-in palette that includes purple and magenta. Hold the whole grid on
|
||||||
|
* the accent so the page reads as one thing. */
|
||||||
|
.card .icon {
|
||||||
|
background-color: var(--sl-color-accent-low) !important;
|
||||||
|
color: var(--sl-color-accent-high) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool names appear constantly in the prose; give inline code enough
|
||||||
|
* contrast to scan without turning every paragraph into a rash. */
|
||||||
|
:not(pre) > code {
|
||||||
|
border: 1px solid var(--sl-color-gray-5);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reference tables are dense and mostly two or three narrow columns. */
|
||||||
|
.sl-markdown-content table {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sl-markdown-content th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The splash hero reads better slightly narrower than the default. */
|
||||||
|
.hero > .stack {
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
5
docs-site/tsconfig.json
Normal file
5
docs-site/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
@ -27,7 +27,8 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Repository = "https://git.supported.systems/MCP/mcqemu"
|
Documentation = "https://mcqemu.warehack.ing"
|
||||||
|
Repository = "https://git.supported.systems/warehack.ing/mcqemu"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mcqemu = "mcqemu.server:main"
|
mcqemu = "mcqemu.server:main"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user