Skip to content

Add VM lifecycle v2, an image library and golden image builds to Hyper-V Control - #9

Merged
JerryLinLinLin merged 28 commits into
mainfrom
claude/hyperv-plugin-vm-management-e74fbe
Oct 14, 2026
Merged

JerryLinLinLin merged 28 commits into
mainfrom
claude/hyperv-plugin-vm-management-e74fbe

Conversation

@JerryLinLinLin

@JerryLinLinLin JerryLinLinLin commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Implements the approved plan for Hyper-V Control: a focused refactor of the VM-management layer, VM lifecycle v2, an image catalog and library with background jobs, golden image builds, and runtime 0.2.0. Three follow-ups from the architecture review are included: UAC on the first tool call, Quick Create's Windows 11 dev environment as the default image, and a compressed bundle.

Refactor (no contract change)

  • HyperVScript: one hardened module bootstrap shared with the typed HyperVService, in-script VM resolution by exact name or GUID (one PowerShell process per tool call instead of two), state assertions, typed parameter rendering with literal quoting, and one failure envelope { ok:false, error, errorType, errorId, category } classified by error ID, never by localized text.
  • Inline scripts moved out of the MCP tool layer into VmLifecycleService; tool methods parse, validate and call a service.
  • A recording mock of the Hyper-V module (HYPERV_CONTROL_MODULE_PATH) lets the unit phase assert on every cmdlet and parameter without a Hyper-V host.
  • The elevation bridge forwards HYPERV_CONTROL_*, HYPERV_GUEST_* and _NT_SYMBOL_PATH to the UAC worker; a UAC launch otherwise drops the client's process environment, so every documented override only worked at user level.

Lifecycle v2

  • hyperv_vm_create sources: image (default), new disk, ISO, existing disk, linked (differencing), none; defaults from the host; Secure Boot template, vTPM, static memory, Standard checkpoints, Default Switch by ID; duplicate names, existing disks and free space checked before any change; rollback on failure.
  • VmStateRules: a verified setting × state table drives configure, network and disk; Running VMs get what Hyper-V allows and a precise refusal that names what needs Off.
  • hyperv_vm_remove with dryRun and guarded deleteDisks (checkpoints removed and merges awaited first; shared disks, differencing parents and ISOs kept and reported).
  • hyperv_firmware (template, Secure Boot, vTPM, first boot device), new hyperv_vhd and hyperv_vm_clone (linked in seconds; full as a job), adapter add/remove/set and NAT switches, richer hyperv_vm_info and hyperv_capabilities.

Images and jobs

  • hyperv_image: Microsoft's Quick Create gallery and custom catalogs (lenient JSON), a local library, resumable fetch with SHA-256 and magic-byte checks as a hyperv_jobs job, register/remove with in-use guards, and deploy through the one create path.
  • Default image: hyperv_vm_create name=X deploys a linked clone of the default image, Microsoft's Windows 11 dev environment (or HYPERV_CONTROL_DEFAULT_IMAGE). When the library lacks it, one "quick create" job fetches it and creates the VM; the name, switch and target disk are checked before the download, a running fetch is joined, and parameters of other sources are refused rather than ignored. The date-based "expired" heuristic for Windows evaluation images is gone; they carry an evaluation warning instead.
  • hyperv_image_build: a Windows ISO or evaluation media applied offline to a GPT VHDX with an unattend (local admin, RDP, no device encryption, optional kernel-debug BCD settings), or an Ubuntu cloud image with a NoCloud seed.
  • hyperv_jobs for anything that outlives a call; export, import, full clone and quick create run on it.

Elevation on the first tool call

With --elevate and no administrator rights, the server answers initialize, tools/list and ping itself, so starting Claude Code never prompts. The first tool call shows one UAC prompt and starts a same-user elevated worker over the existing private pipe; an MCP client inside the server forwards every tool call to it through the SDK's call-tool filter until the session ends. Concurrent first calls share the prompt. Declining returns ElevationDeclined and the next call prompts again; a worker that exits is reported once as ElevatedWorkerExited and replaced. HYPERV_CONTROL_WORKER=always starts the worker without UAC for tests; it then holds no rights the server lacks.

Bundle size

EnableCompressionInSingleFile takes the executable from 121 MB to 53 MB for about 50 ms more per process start. ReadyToRun (146 MB, no gain once compressed) and Native AOT (refused: Windows Forms needs trimming off; WMI, the RDP ActiveX control and reflection JSON add 89 IL2026/IL3050 warnings) were measured and rejected. The CI comparison inflates bundled entries before masking build identifiers and ignores the stored header offset.

Verification

  • Unit phase: 215 checks. Stdio smoke: 41 tools plus the elevation proxy through the seam. Launcher, release-isolation, marketplace checker and runtime packaging pass; the CI comparison was proven on two compressed builds that differ only in build identifiers.
  • Live on a Windows 11 26200 host with throwaway VMs: lifecycle 28/28, images (2.5 GB Ubuntu fetch, linked-clone deploy, boot on the console), image build (synthetic WIM pipeline), and a 19-step integration run. Through the real launcher, unelevated: initialize, 41 tools and ping with no worker process.
  • CI's small runner volume exposed that a linked clone or a differencing hyperv_vhd demanded the parent's full virtual size free; a differencing child now needs 1 GB and the result warns when the volume could not hold the parent's size.
  • Two Hyper-V behaviors found and fixed live: Export-VM fails on read-only differencing parents (now cleared for the export and restored), and New-VM defaults to dynamic memory and network-first boot.
  • Not exercised: the real UAC prompt (seam only) and the Windows 11 dev environment through the plugin's own fetch, deploy and boot (23.3 GB; not downloaded). Details and limits: docs/validation/hyper-v-control/runtime-v0.2.0/report.md.

Release

runtime.json pins hyper-v-control-v0.2.0; the asset is not published yet, so CI's comparison step reports a notice. After merge: tag hyper-v-control-v0.2.0, publish the four files from build/hyperv-control/runtime-release built with SDK 10.0.401, then verify the pinned hash.

🤖 Generated with Claude Code

Add HyperVScript, a builder that every host operation now uses: one hardened
module bootstrap shared with the typed HyperVService, in-script resolution of
a VM by exact name or GUID (one PowerShell process per tool call instead of
two), state assertions, typed parameter rendering with literal quoting, and a
uniform failure envelope { ok:false, error, errorType, errorId, category }.

Move the inline create/remove/configure/export/import/network/disk scripts out
of the MCP tool layer into VmLifecycleService, so tool methods only parse,
validate and call a service. Typed power, checkpoint and firmware results use
the same failure envelope. The version string is read from the assembly.

Add a recording mock of the Hyper-V module selected through
HYPERV_CONTROL_MODULE_PATH, a MockHyperV test helper, and lifecycle tests that
assert on the cmdlets and parameters each operation emits. Scaffold the job
runner, image services, tool partials and validation phases for the lifecycle
v2 and image work that follows.
Implement phase 3a of the Hyper-V Control plan: hyperv_image (list, fetch,
register, remove, catalogs, deploy) and hyperv_jobs (list, status, wait,
cancel) over a rewritten ImageCatalogService.

Catalogs use Quick Create's gallery schema and are parsed as leniently as
VMCreate does: UTF-16 or UTF-8 with or without a BOM, trailing commas,
comments, case-insensitive names, string-or-bool secureBoot, any hash prefix
treated as SHA-256, and the min/max host version filter. Each image carries
warnings: the expired WinDev2407Eval evaluation (2024-10-23), other likely
expired Windows evaluations, images older than three years, Ubuntu releases
out of standard support, missing or invalid hashes and unsupported disk
types. Listing never touches a disk URL, and recommends the newest supported
image per OS family. catalogs.json starts with Microsoft's feed; custom
catalogs may be URLs, files or UNC paths.

The library lives under <VirtualHardDiskPath>\HyperVControl\Images (read once
from Get-VMHost and cached) or HYPERV_CONTROL_IMAGE_ROOT. library.json and
catalogs.json are updated under an in-process gate and a cross-process lock
file, and replaced atomically.

A fetch is a job. One suffix-range GET resolves redirects, the size and the
zip's central directory (Zip64 aware), so free space for the download plus
the extracted entry is checked before downloading. The download resumes a
partial file with an HTTP Range request, hashes as it writes, and keeps the
partial file when the connection drops or stalls; a SHA-256 mismatch deletes
it. The archiveRelativePath entry is extracted by streaming, with magic bytes
checked before and after, the entry is recorded and the archive deleted
unless keepArchive. Cancelling deletes the partial download and any
extraction output.

Register adopts a local .vhdx or .iso in place; templates become read-only.
Remove refuses while a VM attaches the file (disk or DVD) or any disk chain,
including checkpoint disks and recorded clones, has it as a differencing
parent. Deploy creates a differencing child (or a full copy) under the VM's
folder, creates the VM through VmLifecycleService.CreateAsync, then applies
the lab defaults in one script: static memory, automatic checkpoints off,
Standard checkpoints, Secure Boot by OS family or off when the image says
so, HvSocket for Linux, and a local key protector plus vTPM for Windows
after the template. A failed deploy removes the half-created VM and disk.

Unit tests drive the saved live feed, a UTF-8 custom catalog, a hand-built
Zip64 archive and a loopback HTTP server (redirects, ranges, a dropped and a
stalled download) against the recording mock module, which gains
state-driven Get-VMHost, Get-VHD parent chains, New-VHD and checkpoint disks
in images.functions.ps1. ImageLiveTests covers the images-live phase.
Add references/images.md for agents: the list, fetch, deploy and boot
workflow, catalog rules shared with Quick Create (encodings, trailing
commas, hash prefix, disk types, host version filter, custom galleries),
what each warning means (including the Windows 11 dev environment that
expired on 2024-10-23), the library layout and its in-use guard, fetch
semantics for interruption, cancellation and hash mismatches, disk-space
expectations, the deploy lab defaults and the error types. SKILL.md does
not link it yet.

Split ImageCatalogService into partial files: the core (root, list,
catalogs, recommendation), fetch, library (register, remove and the in-use
guard) and deploy, so the deploy path lives in one file. The move changes
no code.

Free-space queries now work for a library root at a drive root, which used
to produce a doubled separator.
A UAC launch starts the worker with the user's profile environment, so every
HYPERV_CONTROL_* override set only in the MCP client's process environment
was silently lost. The parent now writes an allowlisted set of variables
(HYPERV_CONTROL_*, HYPERV_GUEST_*, _NT_SYMBOL_PATH) as a length-prefixed JSON
header over the private pipe before relaying MCP traffic, and the worker
applies them before starting. Verified live: the worker reports the image
root set in the client environment.
Add VmStateRules, the setting x VM state table from the verified Hyper-V
matrix. Configure, firmware, network and disk calls render one in-script
gate that refuses the whole request before any change and lists every
setting that needs the VM Off; Running conditions cover static memory
resize, dynamic memory bounds that may only grow or shrink, Generation 2
adapter hot add/remove, SCSI data disks and online VHDX resize.

hyperv_vm_create gains sources new_disk, iso, existing_vhd, linked and
none, host default paths, Default Switch by ID, OS-family Secure Boot and
vTPM defaults (template before key protector before Enable-VMTPM), a
duplicate-name guard, a free-space preflight and rollback of a half-made
VM. hyperv_vm_remove takes a name or ID, never powers off, and with
deleteDisks removes checkpoints, waits for their merge, removes the VM and
deletes only files no other VM or differencing child uses; dryRun returns
the plan. hyperv_vm_configure, hyperv_firmware, hyperv_network and
hyperv_disk gain the planned settings and actions; hyperv_vhd and
hyperv_vm_clone (linked and full) are new; hyperv_vm_info and
hyperv_capabilities report more state.

The recording mock gains lifecycle.functions.ps1 with disk files, chains,
checkpoint merges, adapters, firmware, TPM and NAT, and LifecycleV2Tests
cover the table, refusals without mutation, cmdlet order and plans.
LifecycleTests follow the new create and remove signatures.
Windows: apply an ISO, an install.wim/esd or a downloaded Microsoft
evaluation ISO (eval, eval-ltsc, eval-iot; resumable and cached) offline to
a GPT VHDX (300 MB ESP formatted FAT32 then retyped, 16 MB MSR, NTFS),
make it bootable with bcdboot /s (plus /offline where supported), and
write a specialize/oobeSystem unattend.xml: local administrator, RDP and
its firewall group, autologon, device-encryption prevention, BypassNRO,
and first-logon deletion of the answer file. Optional offline kernel-debug
BCD settings and a C:\Tools copy. Where the host's bcdboot copies the boot
files but cannot create the store on another disk's ESP (BFSVC c0000035),
the store is completed with bcdedit from the image's own BCD-Template.

Linux: download an Ubuntu cloud image checked against its SHA256SUMS,
convert it with qemu-img (HYPERV_CONTROL_QEMU_IMG, PATH or Program
Files), grow it, and build a NoCloud CIDATA seed ISO with inbox IMAPI2.

Every build runs as a job, writes <name>.image.json beside the VHDX for
the image library, refuses existing outputs, detaches whatever it attached
and deletes its outputs on failure. A generated password is returned once
in the start response, never in the job result or the sidecar.

Unit tests cover validation, unattend and cloud-init content, script
rendering and ordering of both pipelines against the recording mock,
cleanup on failure, resumable downloads over loopback HTTP and a real
IMAPI2 seed ISO.
Refuse a download up front when the cache volume cannot hold the rest of
the file, instead of failing when the disk fills. Copy toolsDirectory into
an existing C:\Tools of a custom image, but never overwrite a file the
image already has. Clarify the failed-build hint and the seed-ISO sharing
note in nextSteps.
The image-build-live phase drives the published EXE without downloading
Windows media or creating VMs. A synthetic WIM (placeholder hives, the
host's boot files, BCD template and bootstr.dll, and a marker) in an
IMAPI2 ISO runs the whole Windows pipeline into a 4 GB VHDX under
D:\HyperVControl-validation\imagebuild, which is then mounted read-only
to check the GPT layout, applied files, Panther unattend, C:\Tools and a
copy of the offline BCD store (boot manager, winload, debug on, KDNET).
A WIM without boot files must fail with bcdboot's reason and leave no
outputs. For Linux, the missing-qemu-img refusal is checked with an
unreachable URL, and a .vhdx source is copied, grown with Resize-VHD and
paired with a seed ISO that Windows mounts as CIDATA. Elevated helpers
prepare, verify and clean up; the run directory is detached and deleted
in every outcome, and secrets are redacted from the evidence.

references/image-build.md tells agents when to build, which parameters
exist, what the Windows image contains, the one-time password rule, the
Ubuntu seed workflow, the qemu-img dependency and evaluation expiry.
…ugin-vm-management-e74fbe

# Conflicts:
#	tests/HyperVControl.Tests/PhaseStubs.cs
Live runs on Windows 11 26200 showed that New-VM enables dynamic memory
and boots the network adapter before the new disk. Create and linked
clone now set the memory mode explicitly (static by default) and put the
disk first; an ISO source still boots from the DVD first. The mock New-VM
mirrors that host behavior so unit tests guard both fixes.

COM port pipe mapping (serial_host) now also works while Running, which
the live run confirmed; the result notes that a Generation 2 guest sees a
newly mapped port only after a full stop and start, and the kernel
configure description and error text say so.

The lifecycle-live phase now covers the iso source (with a generated data
ISO), existing_vhd and linked sources, and a full clone, and records the
static memory resize outcome: without a booted guest OS Hyper-V refuses
it and memory stays unchanged.
Rewrite the VM operations reference for lifecycle v2: what each VM state allows and the all-or-nothing refusals, create sources and defaults (including the explicit memory mode and disk-first boot order), firmware and vTPM order, media, network and NAT, virtual disk safety, linked and full clones, and what removal deletes or keeps.
…ugin-vm-management-e74fbe

# Conflicts:
#	tests/HyperVControl.Tests/PhaseStubs.cs
Route hyperv_image deploy and hyperv_vm_create source=image through the one
VM create path, so linked clones from library images get the same defaults,
guards and rollback as every other VM. Run full clones, exports and imports as
hyperv_jobs jobs and report a failed job as a tool error.

Export-VM fails with "Access is denied" when a differencing parent in the
chain carries the read-only attribute, which the plugin sets on linked
parents; exports and full clones now clear it for the duration of the export
and restore it afterwards (verified live on Windows 11 26200). Removal plans
report chain parents the VM does not own instead of staying silent.

Bump the runtime and plugin to 0.2.0, regenerate the client manifests, pin
the 0.2.0 asset in runtime.json, and route the new lifecycle, image and job
workflows in the skill and README.
EnableCompressionInSingleFile shrinks the self-contained executable from
121 MB to 53 MB for about 50 ms more at process start. ReadyToRun was
measured too (146 MB uncompressed, no gain once compressed) and Native AOT
is closed by Windows Forms, WMI and the RDP ActiveX control, so neither is
used. The CI comparison now inflates each bundled entry before masking the
app assembly's build identifiers, identifies the file list by inflated
size, kind and path, and blanks the header offset the apphost stores.
With --elevate and no administrator rights, the server no longer starts
the elevated worker at launch. It serves the MCP session itself
(initialize, tools/list and ping never prompt) and a tools/call filter
(McpServerOptions.Filters.Request.CallToolFilters) forwards every call
for a listed tool to a same-user worker that the first call starts
through UAC. Concurrent first calls share that start, so a session shows
one prompt; the worker answers every later call until the session ends.

ElevationProxy starts the worker as the bridge did (runas, hidden, a
CurrentUserOnly pipe, process IDs verified both ways, the environment
header) and talks to it with an MCP client over the pipe, returning the
worker's results unchanged. The link uses the initialize handshake so no
per-request metadata reaches the client. A declined prompt returns an
ElevationDeclined tool error and the next call prompts again; a worker
that exits is reported once as ElevatedWorkerExited with its exit code,
and the next call starts a new one. Client EOF, the end of the session
or the parent's exit closes the pipe; the worker is killed after 5 s.

HYPERV_CONTROL_WORKER=always is a test seam: the worker starts with the
parent's own token, without UAC and without the forwarded variables, so
they still arrive only through the header. The worker accepts a
non-elevated token only when launched that way. hyperv_capabilities
reports which process answered (process.id, elevated, mode).

Unit checks cover the start info of both paths, a declined prompt and
its retry, one prompt for concurrent calls, cancellation while the
prompt is open and a worker that exits before connecting. The smoke
phase drives the published EXE through the seam.
The permission model, the plugin README and the skill now describe the
UAC prompt on the first tool call instead of at server start: one prompt
per MCP session, a worker that lasts until the session ends, a declined
prompt that fails only that call and prompts again on the next one, the
ElevatedWorkerExited error, the process block of hyperv_capabilities,
and the HYPERV_CONTROL_WORKER=always test seam. The skill tells agents
to ask the user before retrying after ElevationDeclined, since each new
call shows another prompt.
…g guidance

The elevation bridge forwards HYPERV_CONTROL_* from the client's
environment, so HYPERV_CONTROL_QEMU_IMG no longer has to be a persistent
user or machine variable.
The Quick Create gallery's "Windows 11 dev environment" was flagged expired
by a date heuristic (built + 90 days), and the docs claimed it boots to a
black desktop and shuts down hourly. Neither was tested; on 2026-10-14 the
user verified that Hyper-V Quick Create still downloads it and the VM works.

Feed and recommendation:
- Drop both date-based expiry heuristics and the Expired flag (catalog
  images and library entries; an old library.json "expired" is ignored).
- Windows evaluations (WinDev name/URI or evaluation wording) carry one
  informational "Evaluation edition" warning with the slmgr /xpr advice.
  Stale (3 years) and Ubuntu end of life are unchanged.
- recommended.windows is now the newest fetchable Windows image, so
  Microsoft's Windows 11 dev environment; the hint drops the expiry clause.

Default image:
- HYPERV_CONTROL_DEFAULT_IMAGE names a library entry, else a catalog image
  (exact name, case-insensitive); otherwise the recommended Windows catalog
  image, or the newest Windows image fetched into the library when no
  catalog offers one. A catalog image already held (same SHA-256, else same
  name) resolves to the library entry.
- hyperv_image list adds defaultImage {name, catalog, inLibrary, source}.
- hyperv_image deploy without imageName uses it once it is in the library.

hyperv_vm_create:
- source defaults to image; imageName may name a catalog image; new
  parameters catalog and waitSeconds (0-3600, default 60).
- A library image deploys in-call. A catalog image runs one job,
  "quick create <vm>", that fetches it (joining a running fetch of the same
  image instead of downloading twice) and then deploys it; the job's result
  is the VM, and a still-running job comes back with next.
- The VM name rules, target disk, duplicate name and switch are checked
  before any download (VmLifecycleService.CheckCreateAsync shares create's
  guards). Parameters only other sources use are refused under
  source=image; allowDuplicateName now reaches the image path.
- Fix image-build next steps that called hyperv_vm_create with vhdPath but
  no source.

Tests: feed assertions, default image resolution with and without the
variable, the quick-create job end to end against a loopback HTTP fixture
and the mock module, catalog imageName, a joined fetch (one download),
refusals before any job, CheckCreateAsync, and smoke checks for the new
default. Live phases pass source=new_disk explicitly.
images.md: replace the untested "expired evaluation" row and the "no
current Windows image" sentence with the evaluation-edition guidance, add a
Default image section (HYPERV_CONTROL_DEFAULT_IMAGE, the recommended
Windows image, the library fallback, defaultImage, the quick-create job,
joined fetches, sizes and the confirm-before-download rule).

SKILL.md routes Windows lab VM creation to hyperv_vm_create without source
and tells the agent to confirm before the first multi-GB download;
operations.md describes source=image as the create default; the plugin
README mentions one-call Windows VMs without versions or dates.
…ugin-vm-management-e74fbe

# Conflicts:
#	tests/HyperVControl.Tests/Program.cs
… the compressed bundle

The executable now prompts for UAC on the first tool call, creates VMs
from Microsoft's Windows 11 dev environment by default and ships as a
53 MB compressed bundle, so the unpublished 0.2.0 pin moves to the new
hash. The validation report gains a follow-up section with what was
measured and what the seam cannot prove: the real UAC prompt and the
Windows image through the plugin's own fetch and deploy.
A linked clone and a differencing hyperv_vhd demanded the parent's whole
virtual size free on the target volume. A differencing child starts near
empty and grows as the guest writes, so the rule refused clones on small
volumes: CI's runner (about 14 GB free against the mock's 64 GB parent),
and it would have refused the 44.8 GB Windows 11 dev environment on most
lab volumes. Both paths now need 1 GB and warn when the volume could not
hold the parent's size. Runtime 0.2.0 is re-pinned to the new build.
@JerryLinLinLin
JerryLinLinLin merged commit 94d49ab into main Oct 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant