Parallels VM system-test control plane for macOS hosts. Test real installers, trust stores, network devices, and upgrade paths on pristine end-user guest VMs (Windows / Linux / macOS), with the operating discipline that makes that reliable:
- Golden-snapshot ladder —
golden(powered-off, human-made, pristine) →ready(booted once) →built(provisioned); every test starts from a revert, never from leftover state. - Internal-disk-only boot policy — archive copies on an external drive are clone sources, never boot targets; CI hard-fails rather than silently booting off slow storage.
- Guarded guest execution — host-side timeouts, guest straggler cleanup, revert-collapse recovery, one-VM-at-a-time.
- Cache-first provisioning — downloads happen once into an offline cache; the test path only reverts snapshots (metered-link safe).
- Encoded guest quirks — SYSTEM/UNC on Windows, tar.gz-push (+ keepalive) transport on macOS (shared folder is TCC-blocked under headless exec), headless-OS capability limits (docs/CAPABILITIES.md).
The division of labor is strict and documented: a human installs the OS + Parallels Guest Tools and takes one golden snapshot per VM (docs/HUMAN-SETUP.md); vmkit owns everything after. Every failure mode this harness has hit is cataloged with its guard in docs/FAILURES.md.
vmkit is internal and not distributed through Homebrew — it is not part of the Port Zero product and must not appear in the product's public tap. Clone and install:
git clone [email protected]:PortZeroNetwork/vmkit.git && cd vmkit
just install # -> /usr/local/bin/vmkit (override: just install ~/.local)
vmkit init-host # then edit ~/.config/vmkit/host.conf
vmkit doctor # validate the machine against the contractTo update: git pull && just install.
cd my-repo
vmkit init # ./vmkit.conf + vmtest/scripts/{lib,smoke.sh,smoke.ps1}
vmkit check-scripts # parse-check the flavor scripts — no VM, ~2s
vmkit test linux smoke # reset to "built", run one flavor on one platform
vmkit series lifecycle # every configured platform in series + summaryvmkit init scaffolds the script layout as well as the config, because the
layout is not arbitrary: only the flavor script's own directory is pushed
into the guest. The helpers therefore live in vmtest/scripts/lib/, inside
it — a sibling ../lib resolves on the host and is absent in the guest.
Flavor scripts live in your repo, run inside the guests, and speak a tiny
greppable protocol: PHASE=<name> ok=true|false|SKIP lines as they go, and a
final RESULT=PASS|FAIL|SKIP.
vmkit reads both that line and the guest's exit status, and reports one of:
| Outcome | Means | First thing to look at |
|---|---|---|
PASS / SKIP |
verdict printed, guest exited 0 | — |
FAIL |
RESULT=FAIL, or a non-zero exit |
the PHASE= lines |
NO-RESULT |
the script never reached its verdict | where its output stops — usually a helper it could not source |
NO-OUTPUT |
not one byte printed | a crashed interpreter (still exits 0 through prlctl exec) |
SETUP-FAILED |
the guest never reached the script | the reset/boot above it |
TIMEOUT |
VMKIT_FLAVOR_<NAME>_TIMEOUT elapsed |
the last phase printed |
A missing RESULT= line is a failure, never a pass, and RESULT=PASS
followed by a non-zero exit is a failure too: everything after the last
PHASE= line did not happen. VMKIT_REQUIRE_RESULT=0 opts out of the first
half for a script that predates the protocol; the exit status is not
negotiable. (docs/FAILURES.md #22 — this used to report PASS (40s) for a leg
in which every assertion was command not found.)
Some guest config must survive the per-test reset — a toolchain, Homebrew,
a Windows Defender exclusion for unsigned/network-sourced installers. It can't
live in a flavor script (every test reverts to built first); it has to be
baked into the checkpoint itself. vmkit provision is that
reset → run-a-guest-script → re-checkpoint dance as one reusable primitive,
with "preserve the pristine baseline once" built in:
vmkit provision windows vmtest/scripts/windows-add-defender-exclusions.ps1 \
--checkpoint built --label defender # bakes the exclusion into "built"See docs/PROVISIONING.md for the full model (anchors, preservation, failure handling).
ensure_only enforces one-VM-at-a-time by stopping every other running VM.
That is right for a queue of harness runs and destructive for anything else on
the machine: a CI job landing mid-session powers off the guest a human is
working in, and two CI jobs landing together stop each other's guests mid-test.
A GitHub concurrency: group serializes one workflow in one repository and
knows nothing about your local session, a second self-hosted runner on the same
host, or another organization's jobs.
Every VM-mutating command now claims the host for its own duration —
test, series, run, reset, up, provision — and releases on exit and
on signal. One VM at a time is a property of the host, not of one invocation.
The TTL is derived from the flavor timeout plus a margin for the sibling stop,
the memory settle and the boot (capped at 4h, so a killed run cannot wedge the
host), which is information vmkit has and a caller would have to guess at.
VMKIT_NO_SELF_HOLD=1 restores the old behaviour.
vmkit hold is the same lock, taken by hand for an interactive session:
export VMKIT_HOLD_TOKEN=$(vmkit hold --print-token "debugging the installer" --vm windows)
vmkit hold # who has it, until when
vmkit unhold # releaseAuthorization is by token, never by VM name. A live hold blocks every
VM-stopping path for everybody; only a caller carrying VMKIT_HOLD_TOKEN from
the record gets through. Export it, or your own vmkit reset is refused by your
own hold. --vm is documentation of what the host is being used for, nothing
more.
That distinction is the whole point. An earlier version keyed on the VM name — holding a VM permitted work on that VM, so the holder could reset their own guest. It protected every VM except the one actually in use: a CI job targeting the same guest matched and reverted it mid-provision. Everyone on this host runs as the same unix user, so pid/user/VM-name cannot tell a session from a CI job; a token can.
Holds always expire (--ttl, default 4h) — a forgotten hold that wedged CI
until someone noticed would be worse than the failure this prevents.
VMKIT_IGNORE_HOLD=1 overrides, vmkit doctor surfaces an active one, and
vmkit hold --steal takes over.
For a long session on a machine that is also a CI runner, stop the runner service too: a hold makes the job fail with the reason, whereas an offline runner makes it queue until you're done.
Drive a running guest's interactive desktop from the host (no guest agent):
vmkit screenshot windows # PNG path printed on stdout
vmkit key windows win+r # combos: ctrl+c, alt+tab, enter, …
vmkit type windows "notepad"
vmkit key windows enter
vmkit mouse windows click # buttons + relative nudge onlyThis is a vision loop substrate (screenshot → decide → key/type). Absolute click-at-(x,y) and accessibility trees need a guest agent (planned next).
Run vmkit init-agents to generate .instructions/vmkit.md (VM inventory,
snapshot ladder, test flavors, archive drive) and wire it the same way
agent-toolbox instruction modules
do: one @.instructions/vmkit.md line in AGENTS.md, and CLAUDE.md as a
thin @AGENTS.md pointer. No marker blocks in the root agent files.
vmkit adopt # new/cleaned machine: clone archive VMs -> internal disk
vmkit sync macos # mirror an internal VM bundle out to its archive copy
vmkit list # VMs + snapshot laddersRun the self-hosted runner as a service (never interactive run.sh), stage
CI-built artifacts where VMKIT_ARTIFACT_* points, and call
vmkit series <flavor>. Split triggers so PRs run a fast smoke and pushes run
the full pristine-machine suite.
A concurrency group that queues (never cancels) VM jobs is still worth having
— queueing beats a refusal — but it is no longer what makes one-VM-at-a-time
true: vmkit claims the host itself, so a second runner, another repository, an
agent session or a person is serialized too. A leg that arrives while the host
is busy fails with the reason rather than going green having tested nothing.
vmkit's own CI needs neither a Mac nor Parallels: just test runs the verdict
gate, the script lint and the hold against a fake prlctl in about 30 seconds.
- SPEC.md — v1 specification: runtime primitives + the Vmkitfile, written as acceptance criteria for the Rust port
- HUMAN-SETUP.md — the human/machine contract, new-machine bootstrap (including Apple Silicon arch migration)
- CAPABILITIES.md — what guest scripts can/can't do per OS
- FAILURES.md — failure catalog: every known failure class → its guard
- PROVISIONING.md — bake one-time guest OS config into a checkpoint (
vmkit provision)