The bootstrap from hell: provisioning a Windows guest you cannot SSH into
No SSH, no cloud-init, no config management. Just a .bat file, an SMB share the hypervisor provides, and one Task Scheduler flag that decides whether your screenshots are black.
Last time we got a real Windows 11 VM booting as a Docker service. Stock Windows. No Python, no automation, and no way for anything outside the machine to make it click a button.
Closing that gap took two months and produced the most miserable file in the repository — 300 lines of Windows batch script that I rewrote four times. It is worth writing up in detail, because almost every problem in it is a problem you will hit too, and almost none of them are documented anywhere useful.
The constraint
On Linux you would use cloud-init, or Ansible over SSH, or just bake a better image. On Windows-in-QEMU you have:
- No SSH. Not by default, and enabling it requires… running a command on the machine, which is the problem you are trying to solve.
- No cloud-init. There is
cloudbase-init, which is real, and which we did not use because our entry point was already the unattended answer file. - No package manager you can rely on. winget exists. It is not on
PATHin a fresh automated install. More on that below. - One hook. The
FirstLogonCommandsblock inautounattend.xml, which runs a command the first time a user logs in.
So: one command, one shot, and everything has to descend from it.
The chain
autounattend.xml
└─ FirstLogonCommands
└─ C:\OEM\install.bat ← 300 lines of batch, the misery
├─ robocopy \\host.lan\Data → C:\Data
├─ install Python 3.10 (silent, system-wide)
├─ python C:\Data\init\main.py ← the sane part, in Python
│ ├─ install software from software.json
│ ├─ install winget packages
│ ├─ verify node + npm, install Appium
│ ├─ enable Developer Mode
│ ├─ install Playwright Chromium
│ └─ configure Teams
├─ pip install -r for 7 control servers
├─ 8 × netsh firewall rules
├─ generate 7 × start_*.bat launchers
└─ 8 × schtasks /Create /SC ONLOGON
The design goal was to make install.bat as short as possible and get into
Python as fast as possible — Windows batch is a language where string handling
and error handling are both approximately theoretical. It is still 300 lines,
because a surprising amount has to happen before you have an interpreter.
Problem 1: getting files in
The guest needs our code. It is not in the image (it changes daily) and there is no network path to the host by default.
QEMU’s wrapper solves this by running Samba and exposing the container’s /data
directory to the guest as \\host.lan\Data. So:
set "SOURCE=\\host.lan\Data"
set "DEST=C:\Data"
robocopy "%SOURCE%" "%DEST%" /MIR /Z /NP /NFL /NDL /NJH /NJS /R:3 /W:5 >> "%LOGFILE%" 2>&1
if %ERRORLEVEL% GEQ 8 (
echo File copy failed with error code %ERRORLEVEL%. >> "%LOGFILE%"
exit /b %ERRORLEVEL%
)
Two things here that cost me time.
robocopy exit codes are not exit codes. Success is not 0. Robocopy returns
a bitmask: 0 means nothing copied, 1 means files copied, 2 means extra files
present, 3 means both, and so on. Anything ≥ 8 is an actual error. If you
write the obvious if %ERRORLEVEL% neq 0 you will treat every successful copy as
a failure. This is documented, and it is documented in a place nobody reads until
after they have been bitten.
/MIR mirrors, which means it deletes. It makes the destination identical to
the source, including removing anything in the destination that is not in the
source. That is exactly what we want for idempotency — re-running gives the same
state — and it is a loaded gun pointed at any directory you also write into. We
learned to keep logs at C:\Logs, deliberately outside C:\Data:
REM Setup log directory (outside C:\Data to prevent robocopy overwrite)
set "LOGDIR=C:\Logs"
That comment is a scar.
Problem 2: /IT, or why your screenshots are black
This is the one. If you take nothing else from this post, take this.
The control servers must run automatically when the machine boots. The natural tool is Task Scheduler:
schtasks /Create /TN "StartServer-ComputerControl" /SC ONLOGON ^
/TR "\"%STARTUP_SERVER_COMPUTER_CONTROL_BAT%\"" ^
/RU "%USERNAME%" /RL HIGHEST /IT /F
Four flags, all load-bearing:
| Flag | Meaning | What breaks without it |
|---|---|---|
/SC ONLOGON |
run at user logon | starts before a desktop exists |
/RU %USERNAME% |
run as the desktop user | wrong session, no user profile |
/RL HIGHEST |
elevated | cannot bind ports, cannot touch the registry |
/IT |
interactive | screenshots come back black |
/IT — “interactive token” — is the killer. Without it the task runs in a
non-interactive session. The process starts. It logs happily. It responds to
health checks. And every screenshot it captures is a black rectangle, because it
is not attached to a session with a rendered desktop, so there is no framebuffer
to read.
The failure is silent, the service looks healthy, and the symptom appears three layers away in the agent — which concludes that the screen is blank and starts planning around it. I lost a day and a half to this. The same flag governs video recording, which was the original symptom in a commit charmingly titled “fix ffmpeg issues, fix scheduled tasks permissions.” Two bugs, one root cause.
Problem 3: the console window that got clicked
We generate a small .bat launcher per server:
start /b "ComputerControlServer" "C:\Program Files\Python310\pythonw.exe" "C:\Data\server_computer_control\server.py"
pythonw.exe, not python.exe. python.exe is a console application: launch it
and Windows opens a console window. Seven servers, seven console windows,
stacked on the desktop.
Which the agent then sees. It screenshots the desktop, the parser dutifully detects seven black rectangles as UI elements, and the planner starts reasoning about them. On one memorable run it clicked one, which put a console into mark mode, which paused the process, which broke that server.
pythonw.exe is the windowless variant. The start /b also matters — it starts
without creating a new window and without blocking the parent script.
Problem 4: winget is not on PATH
winget is genuinely good and it is not where you expect it in an automated install. It lives under the user profile:
setx PATH "%PATH%;%LOCALAPPDATA%\Microsoft\WinGet\Links" /M >nul
%LOCALAPPDATA%\Microsoft\WinGet\Links holds the shims. In an interactive
session Windows sets this up for you. In a FirstLogonCommands context it may
not have happened yet, so winget resolves to nothing and every install silently
does not happen.
Note /M — machine-wide. And note that setx writes the persisted environment;
it does not change the environment of the currently running process. If you need
it in the same script, set it twice.
Software itself is declarative, at least, which is one of the few pleasant things in this stack:
{
"Microsoft Teams": {
"mirrors": ["https://aka.ms/teams64bitmsi"],
"alias": "teams"
}
}
Multiple mirrors per package, because vendor download URLs rot and a provisioning run that dies on a 404 at minute six is a bad afternoon.
Problem 5: Windows Firewall silently drops your ports
The control servers listen on 5050–5056, 4723, 9222. Windows Firewall blocks inbound connections to them by default, with no log anywhere the caller can see. From outside, the port simply does not answer.
netsh advfirewall firewall add rule name="SERVER_COMPUTER_CONTROL" dir=in action=allow protocol=TCP localport=5050
netsh advfirewall firewall add rule name="MCP_SERVER_COMPUTER_CONTROL" dir=in action=allow protocol=TCP localport=5055
netsh advfirewall firewall add rule name="SERVER_BROWSER_CONTROL" dir=in action=allow protocol=TCP localport=5051
netsh advfirewall firewall add rule name="SERVER_NETWORK_PROXY" dir=in action=allow protocol=TCP localport=5052
netsh advfirewall firewall add rule name="SERVER_EVALUATOR" dir=in action=allow protocol=TCP localport=5053
netsh advfirewall firewall add rule name="SERVER_TEAMS_CONTROL" dir=in action=allow protocol=TCP localport=5056
netsh advfirewall firewall add rule name="Chrome Remote Debugging Port" dir=in action=allow protocol=TCP localport=9222
netsh advfirewall firewall add rule name="Appium" dir=in action=allow protocol=TCP localport=4723
Tedious, unavoidable, and the first thing to check when a server is “running” but unreachable.
Problem 6: the certificate store that has two of everything
To intercept the guest’s own HTTPS traffic — which we do, for reasons that get their own post — the mitmproxy CA has to be trusted. Windows has per-user and per-machine certificate stores, and installing into the wrong one produces a certificate that is trusted by nothing that matters:
certutil -addstore "Root" "$env:USERPROFILE/.mitmproxy/mitmproxy-ca-cert.cer"
Root is the machine-wide Trusted Root Certification Authorities store, and it
needs elevation. The user store looks identical in the UI and is ignored by
services and by anything running as another user.
Observability, because you cannot attach a debugger
Everything logs to C:\Logs, and each server writes a startup record. There is
also a small PowerShell script that runs on logon and writes the timing data you
need to debug ordering problems:
$bootTime = (Get-CimInstance -ClassName Win32_OperatingSystem).LastBootUpTime
$logonEvent = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4624} -MaxEvents 50 |
Where-Object {
$_.Properties[5].Value -eq $currentUser -and
($_.Properties[8].Value -eq "2" -or $_.Properties[8].Value -eq "10")
} | Sort-Object TimeCreated -Descending | Select-Object -First 1
Boot time, logon time, and when each task fired. Security event 4624 is “an account was successfully logged on”; logon type 2 is interactive and 10 is remote interactive. With those three timestamps you can finally answer “did my server start before the network share was mounted?” — which was the cause of an entire class of intermittent failures.
We also enable Task Scheduler’s operational log, which is off by default:
wevtutil set-log "Microsoft-Windows-TaskScheduler/Operational" /enabled:true
Without it, a task that fails to start leaves no trace at all.
The part that is still manual
The README has a post-installation checklist, and I want to be straight about it:
- Show hidden files in Explorer.
- Verify the mitmproxy cert actually landed in the machine Root store.
- Verify Teams picked up its
configuration.json. - Log in to Teams, and dismiss the “Last Step” dialog.
- Set Notepad to always open in a new window.
Most of these were automatable and some were automated later. One was not: signing in to a Microsoft account. Interactive OAuth with MFA is, by design, resistant to automation. That is the point of it.
So the honest architecture is: automate to the last possible step, then take a snapshot of the disk after a human has signed in once, and clone every subsequent machine from that snapshot. The human step happens once per golden image rather than once per test run.
Which is a neat segue, because that snapshot turned out to be worth 40 minutes per machine — but first, the agent that was going to drive all this.