Skip to main content

windows

Run a UTM Windows VM and control it via agent-browser over noVNC

Quellinformationen

Repository
Vesely/skills
Letzte Quellaktivität
25. August 2026 um 09:59
Erkannte Sprache von SKILL.md
Englisch
Sterne
28
Forks
4

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

Datei-Explorer
12 Dateien

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
windows
description
Run a UTM Windows VM and control it via agent-browser over noVNC
allowed-tools
["Bash(/Applications/UTM.app/Contents/MacOS/utmctl:*)","Bash(agent-browser:*)","Bash(websockify:*)","Bash(~/.local/bin/websockify:*)","Bash(pipx:*)","Bash(/usr/libexec/PlistBuddy:*)","Bash(vncdo:*)","Bash(ssh:*)","Bash(scp:*)","Bash(curl:*)","Bash(nc:*)","Bash(netstat:*)","Bash(lsof:*)","Bash(pgrep:*)","Bash(kill:*)","Bash(osascript:*)","Bash(sips:*)","Bash(screencapture:*)","Bash(cliclick:*)","Bash(killall:*)","Bash(git:*)","Bash(grep:*)","Bash(head:*)","Bash(od:*)","Bash(mv:*)","Bash(ps:*)","Bash(mktemp:*)","Bash(timeout:*)","Bash(gtimeout:*)","Bash(open:*)","Bash(cp:*)","Bash(ls:*)","[Truncated]"]
when_to_use
Use ONLY when the user explicitly invokes `/windows` (with optional action description). Examples: `/windows`, `/windows open Notepad`, `/windows close` (tear down the browser session), `/windows stop` (shut the VM down properly), `/windows <action> on <target>` to pick a non-default VM from config.json. Do NOT auto-invoke from general phrasing about Windows or UTM — wait for the slash command.
argument-hint
[action | close | stop] [target]
arguments
["action"]
# Windows VM (UTM + noVNC + agent-browser) Start a UTM Windows VM, bring up a noVNC bridge, and drive the guest from a browser session. A fast path skips setup when everything is already running; recipes cache sequences that worked. ## Inputs - `$action`: (optional) what to do, e.g. "open Notepad". Special values: - `close` = tear down the browser session only (leave the VM running). - `stop` / `shutdown` = shut Windows down properly from the inside, then quit UTM. - The action may also name a target from `config.json`, or one of its `aliases`. ## References — read the relevant one *before* you need it | File | Read it when | |---|---| | `references/config.md` | writing or extending `config.json`, or a key is missing | | `references/input.md` | **before your first click or keystroke on a target**, and whenever input stops working | | `references/troubleshooting.md` | anything looks frozen, black, stale or off by a factor | | `references/keyboard-layouts.md` | `guest_keyboard` is not `us`, or typed text comes out wrong | | `references/shared-folder.md` | moving files in or out, or a guest dialog hangs | | `references/guest-channel.md` | noVNC and `vncdo` are both insufficient, or you need real work done inside the guest | | `references/recipes.md` | saving, loading or writing a recipe | ## Configuration **Everything machine-specific lives in `config.json` in this skill's directory.** No hostname, UUID, port or personal path appears in this file, so the skill can be shared as-is; each user brings their own config. Standard install locations (UTM's app bundle and container) do appear, and the ones that genuinely vary — `novnc_path`, `websockify` — are config keys. **If `config.json` is missing, or a key you need is absent — HALT.** Print the setup block below and stop. Never fall back to `config.example.json`, and never guess a value: a wrong UUID starts someone else's VM, and a wrong `ssh_host` connects to a machine that is not yours. > Copy `config.example.json` to `config.json` and fill in `vm_uuid` and `bundle`. > Get the UUID with `/Applications/UTM.app/Contents/MacOS/utmctl list`. Minimum config — two fields, everything else defaults: ```json { "vm_uuid": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "bundle": "Windows.utm" } ``` For several VMs, target selection, and the full key table, see `references/config.md`. ### Loading it ```bash # SKILL_DIR = the directory you loaded this SKILL.md from. This file is not an executed # script, so there is no $0 to derive it from — substitute the literal path (it is # usually ~/.claude/skills/windows, often a symlink to a checkout elsewhere). SKILL_DIR=<directory this SKILL.md was loaded from> CFG="$SKILL_DIR/config.json" TARGET=<chosen target key> # $action alias match → target key → default_target SSH_HOST=<ssh_host or empty> VNCHOST=<vnc_host> VNCPORT=<vnc_port> # port ON THE VM HOST TUNNEL_PORT=<tunnel_local_port or empty> VMDIR=<bundle> VMUUID=<vm_uuid> BRIDGE_PORT=<bridge_port> SESSION=<session_name> # What websockify and vncdo connect to on THIS Mac: if [[ -n "$TUNNEL_PORT" ]]; then BRIDGE_TARGET="localhost:$TUNNEL_PORT" else BRIDGE_TARGET="$VNCHOST:$VNCPORT"; fi # Run a command on whichever machine hosts the VM. # It takes ONE string and runs it through a shell, so redirects, pipes and || work # the same locally and remotely. Quote every call site accordingly. run() { if [[ -n "$SSH_HOST" ]]; then ssh "$SSH_HOST" "$1" else bash -c "$1"; fi } PLIST=~/Library/Containers/com.utmapp.UTM/Data/Documents/$VMDIR/config.plist # Scratch for this run. Fixed /tmp names collide with another session of the same user, # and screenshots here are full captures of someone's desktop. RUNDIR=$(umask 077; mktemp -d "/tmp/windows-skill.XXXXXX") ``` Put every screenshot, log and marker under `$RUNDIR` and delete it when you finish. The one deliberate exception is the readiness marker below, which has to outlive the run. **Validate every value before it reaches a shell command.** `vm_uuid` must match the UUID shape; `vnc_port`, `tunnel_local_port` and `bridge_port` must be integers in range; and `ssh_host`, `bundle`, `vnc_host`, `novnc_path`, `websockify`, `session_name`, `shared_folder`, the target names and their `aliases` must contain no whitespace, quotes, `;`, `|`, `&`, `$`, newlines or backticks — every one of them is substituted into a command line, several of them unquoted. You may not be the person who wrote that file. **`run` must be a function, not a variable.** In zsh an unquoted variable is **not** word-split — `SSHP="ssh host"; $SSHP ls` fails with `command not found: ssh host`. Add `2>/dev/null` and the command vanishes without a trace, looking like it succeeded. **And it must go through a shell on both branches.** A `run() { "$@"; }` local branch execs its arguments directly, so `run 'pgrep -x UTM >/dev/null || open -a UTM'` looks for a program with that entire sentence as its name — which fails on the local target only, while the remote target works. Use one string plus `bash -c`, as above. **Anything you want expanded on the far side must be escaped**: `\$(date …)` inside a double-quoted `run` argument, or the host's own value is substituted before it is sent. `$PLIST` expands **locally**, which is right only if the remote user has the same home path. If not, build it on the far side or keep `~/Library/…` in single quotes. **The bridge and the browser session always run on this Mac**, whatever the target is. Only websockify's upstream address changes. ## Hard rules Rules marked **[universal]** follow from how UTM/QEMU/noVNC/Win32 work. Rules marked **[verify once]** were observed on a specific setup and may differ on yours — check them the first time you use a target and record the answer in that target's `notes`. ### Never run two copies of the same VM at once **[universal]** Two QEMU processes on the **same disk image** corrupt it outright. Two *copies* of one image are less dramatic but still bad: they share a Windows machine SID and often a `vm_uuid`, so they collide on the network and on anything licence- or identity-bound, and afterwards you cannot tell which copy holds the real data. Either way, run one. ```bash pgrep -f qemu >/dev/null && echo "A VM IS RUNNING LOCALLY" # every DISTINCT ssh_host across all targets, not just the one you are about to start for h in $(<every distinct non-null ssh_host in config.json>); do ssh -o ConnectTimeout=5 "$h" 'pgrep -f qemu >/dev/null' 2>/dev/null \ && echo "A VM IS RUNNING ON $h" done ``` If one is up and the user asked for another, **stop and ask**. This is the one place in this skill where asking is mandatory. ### The VM is shared. Claim it atomically before you touch it **[universal]** GUI automation is exclusive to the foreground window: two sessions at once switch each other's windows, clicks land in the wrong place, and both lose their work — or worse, save something in the wrong place. Claim with an operation that cannot interleave. `mkdir` fails if the directory exists, so it is a lock; reading a file and then writing it is not. ```bash # On the VM HOST's own filesystem, not on the shared folder — the share is the least # reliable thing here, and a lock you cannot read is worse than no lock. LOCK=/tmp/vm-owner-$VMUUID.lock if run "mkdir '$LOCK' 2>/dev/null"; then run "echo '<session-name> '\$(date +%FT%T) > '$LOCK/held-by'" # we hold it else run "cat '$LOCK/held-by' 2>/dev/null" # someone else does fi ``` Everyone driving that VM goes through its host, so a lock there is seen by every session, including ones on other machines. Key it by `$VMUUID` so two different VMs on one host do not block each other. - **Refresh `held-by` as you work** (its mtime is the liveness signal). Release it when you are done — including when you abort or hit an error — but **only if it is still yours**; between your last refresh and now someone may legitimately have taken over: ```bash run "grep -q '<session-name>' '$LOCK/held-by' 2>/dev/null && rm -rf '$LOCK'" ``` - **When it is taken, do not start anything** — not even "just one screenshot", which still switches the foreground window. Do not wait blindly either: 1. **Ask the user** who holds it and whether to wait — always available, always correct. 2. If your harness can address other agent sessions by name and the owner string looks like a session name, ask that session directly. An optimisation, not a requirement. Short wait (~10 min) → wait and retry. Long or no answer → tell the user and let them decide. - **A stale lock is a judgement call, not a timer.** An old `held-by` with no other activity is probably a crashed session — but a long-running import looks identical. Confirm the guest is idle (two screenshots 60 s apart, unchanged) *and* say what you are doing before you break someone's lock. Never break one on a timer alone. - **Before releasing, clean up inside the guest.** Close the applications and dialogs you opened, and leave the desktop as you found it. Anything you did not open, leave alone. ### Never send `mouse down` without an immediate `mouse up` **[universal]** `mouse down` and `mouse up` are separate CLI processes. If anything happens between them, the guest is left holding a button — and the whole input path can wedge: the framebuffer keeps updating, but **no input arrives at all, mouse or keyboard, including Escape**. Restarting the session or websockify does not clear it; a bare `mouse up` does. **Do not click in the guest through noVNC.** Use `vncdo` or an in-guest channel for that. Exactly two noVNC pointer uses remain allowed, both emitted as one uninterrupted unit with nothing in between: - `mouse move` alone — a hover, for the tooltip probe. No button involved. - `mouse move` → `down` → `up` **once**, to give the browser canvas focus so keystrokes reach noVNC. That is a focus tap, not a click in the guest, and it is the only place the sequence appears. Full reasoning and the recovery procedure are in `references/input.md`. ### Only one VNC client at a time **[verify once]** Treat the VNC head as exclusive unless a probe proves otherwise on your setup. While noVNC holds it, `vncdo capture` comes back black or hangs — which looks exactly like a dead VM. **Never run `vncdo` while the browser session is connected**: release the session and the bridge first, confirm the upstream socket is gone, then reconnect afterwards. Procedure in `references/input.md`. ### Probe the input channel; never trust a cached answer **[verify once]** Which channel reaches the guest is **not stable**. Observed over months on one VM: the mouse worked, died, worked, worked only via `vncdo`, died again after a restart — with no configuration change. On the same day two applications in the same guest disagreed about whether the keyboard or the pointer was the working one. So `input_preference` in `config.json` is the order to **try**, never a verdict. Probe at the start of a session that will do more than take a screenshot (~20 s), and re-probe whenever a step silently does nothing, after any restart, and when the foreground application changes. If nothing is verified, **stop and say so** — do not "try a click". The probe is in `references/input.md`. ### Bound everything that talks to the VM **[universal]** Every `vncdo`, `ssh`, `nc`, share and capture call gets an explicit timeout. Un-bounded, they do not fail — they hang for tens of minutes and look like a dead guest. In particular, never probe a network share with a bare `Test-Path`; see `references/shared-folder.md`. ### Kill by pid, never by pattern **[universal]** `pkill -f websockify` and friends also kill other sessions' bridges, other agents' work and other people's processes. Resolve the pid first (`lsof -t`, `pgrep -f`), look at what you found, then kill that pid. **One deliberate exception: `killall UTM`.** UTM is a single application instance owning every VM on that host, so there is no per-VM process to target — and an `osascript` quit fails silently over SSH. It is still blunt: it takes down *every* VM on that machine, so check the other targets' locks on that host first, and never reach for it while another session holds one. ### The shared folder is the most fragile link **[universal for UTM's WebDAV sharing]** When it dies, **every guest dialog that touches it hangs for many minutes**, and anything in the guest polling a file there dies with it. Check it is alive before a long series of steps. Recovery order and the safe liveness test: `references/shared-folder.md`. ### A dialog that "ignores the keyboard" usually just lacks focus **[universal]** Send `alt-tab` first, then `esc`/`enter` — one `alt-tab` per stuck dialog. Verified on a pair of windows that had resisted `esc`, `tab`+`enter` and clicking, and which two sessions independently concluded were unreachable by keyboard. They were reachable; the keys were going somewhere else. ### VNC has no authentication here **[universal]** `vnc_display_arg` must keep its `127.0.0.1` prefix. Reach the port from another machine through the SSH tunnel this skill sets up — never by binding QEMU to `0.0.0.0`. ## Fast path (try first, ~0.3 s) **Never call `eval` on the fast path.** Not for cost, but because `session list` plus a port check answers faster, and `eval` needs a live connected canvas — exactly what you are still verifying. ```bash # 1. ports listening — VNC on the target, bridge always local nc -z -w1 ${BRIDGE_TARGET%:*} ${BRIDGE_TARGET##*:} >/dev/null 2>&1 \ && lsof -nP -iTCP:"$BRIDGE_PORT" -sTCP:LISTEN -t >/dev/null || exit 1 # 2. session exists agent-browser session list 2>/dev/null | grep -qE "^[[:space:]]*$SESSION\$" || exit 1 # 3. the browser is really attached — without this a zombie session passes: # window still open, websocket long dead lsof -nP -iTCP:"$BRIDGE_PORT" -sTCP:ESTABLISHED -t >/dev/null 2>&1 || exit 1 # 4. marker from the last successful setup, TTL 30 min AND the same target M=/tmp/windows-skill-ready-$SESSION [[ -f $M ]] && [[ $(($(date +%s) - $(stat -f %m $M))) -lt 1800 ]] \ && [[ "$(cat $M)" == "$TARGET" ]] || exit 1 echo "ALL_READY" ``` `ALL_READY` → jump to **Step 5**. Otherwise walk 1–4, and on success `echo "$TARGET" > /tmp/windows-skill-ready-$SESSION`. **Why the `ESTABLISHED` check.** A session whose websocket dies keeps running and looks alive: `session list` shows it, the bridge port listens. Measured once: such a session held **two cores for 8 hours**, because a disconnected noVNC canvas retries forever. The socket is the only reliable evidence — though it is *necessary, not sufficient* (sockets exist during "Connecting…" too), so the sanity screenshot confirms the handshake completed. **The marker carries the target on purpose.** If it only had to exist, switching targets would sail through and you would be talking to the old bridge and the still-running VM behind it. **Sanity verify before the first action** (not on the fast path): one screenshot, `agent-browser --session "$SESSION" screenshot "$RUNDIR/_vnc.png"`. If it is dominated by a dark background with the noVNC logo, reconnect (Step 4). ## Steps ### 1. Preflight (only if the fast path failed) Local tools, in parallel: `which agent-browser`; `<websockify> --help` (else `pipx install websockify`); `ls <novnc_path>/vnc.html` (else `git clone --depth 1 https://github.com/novnc/noVNC.git <novnc_path>`); `which vncdo` (`pipx install vncdotool`); `which cliclick` only for `start_method: cliclick`. VM configuration, on the target. Read the plist directly rather than through `utmctl`: ```bash run "/usr/libexec/PlistBuddy -c 'Print :QEMU:AdditionalArguments' '$PLIST'" run "/usr/libexec/PlistBuddy -c 'Print :Display:0:Hardware' '$PLIST'" ``` Expected: a **non-GL** display, and the VNC argument matching `vnc_display_arg`. A `-gl` display makes QEMU refuse the VNC console (`The console requires a GL context`). On the setup this was written against the working value was `virtio-ramfb`; the right value is whatever your UTM build offers without `-gl`, so read the current one before changing it. If they match, continue. If not: 1. If the VM is running, **human checkpoint**: shut Windows down from the inside over VNC. Poll until the qemu process is gone. Never `--force`. 2. Quit UTM — over SSH `run "killall UTM"` (an osascript quit fails silently over SSH); locally `osascript -e 'tell application "UTM" to quit'`. Verify with `run "pgrep -x UTM"`.
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen