Deep Review: 20260519-084740-pr-5013
| Date | 2026-05-19 08:47 |
| Repo | lima-vm/lima |
| Round | 1 |
| Author | @liketosweep |
| PR | #5013 — PoC: Windows Guest Support via autounattend.xml (Issue #4852) |
| Commits | 1bfac33a feat : PoC for automated Windows guest via autounattend.xml Implements issue #4852. |
| Reviewers | Claude Opus 4.7 (effort: xhigh), Codex GPT 5.5 (effort: xhigh), Gemini 3.1 Pro (effort: default), Qwen 3.6 35B (effort: default), Gemma 31B (effort: default) |
| Verdict | Rework — two silent-failure bugs in the exported pkg/windows/autounattend API (template arity, UTF-16LE encoder) plus a committed plaintext-password answer file that contradicts the PR's security claim; foundation needs another iteration before integration. |
| Wall-clock time | 49 min 38 s |
Executive Summary ¶
This PoC introduces pkg/windows/autounattend (a typed answer-file generator), pkg/windows/cidata (an ISO builder), and a manual poc-windows-guest/ test harness. Architectural direction is sound — small, testable Go surface plus a shell-driven proof — and the test suite includes a PasswordNeverPlaintext security assertion. But the exported API hides two silent-failure bugs the current tests do not exercise: a Go-template arity error in the ExtraCommands path that fails at execute time, and a byte-index-as-rune-index error in the UTF-16LE password encoder that produces invalid output for non-ASCII passwords. The committed PoC answer file undoes the package's security guarantee by storing the password in plaintext. The new ISO builder duplicates pkg/iso9660util with flags the detected tools reject. 2 critical, 14 important, 11 suggestions.
Critical Issues ¶
<RequiresUserInput>false</RequiresUserInput>
<CommandLine>powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "F:\first_logon.ps1" -SSHKey "{{joinLines .SSHPublicKeys}}"</CommandLine>
</SynchronousCommand>
{{range $i, $cmd := .ExtraCommands}}
<SynchronousCommand wcm:action="add">
<Order>{{inc $i 1 | inc}}</Order>
<RequiresUserInput>false</RequiresUserInput>
<CommandLine>{{$cmd}}</CommandLine>
</SynchronousCommand>
{{end}}
</FirstLogonCommands>
inc is registered as func(n int) int in autounattend.go:20. The expression inc $i 1 invokes inc with two arguments. Go's text/template checks function arity at execute time, not parse time, so the template parses but the first caller with a non-empty ExtraCommands slice gets wrong number of args for inc: want 1 got 2 from Generate(cfg). The current tests never populate ExtraCommands, so CI sees no failure. The bug is silent until someone uses the exported field.
var tmpl = template.Must(
template.New("autounattend").
Funcs(template.FuncMap{
"encodePassword": encodeUTF16LE,
"joinLines": func(ss []string) string { return strings.Join(ss, "\n") },
"inc": func(n int) int { return n + 1 },
}).
Parse(rawTemplate),
)
type Config struct {
Fix: drop the stray 1 and let the pipeline produce i+2. Add a test case that exercises ExtraCommands with at least one entry.
- <Order>{{inc $i 1 | inc}}</Order>
+ <Order>{{inc $i | inc}}</Order>
Codex graded this Important; Claude graded it Critical. Kept Critical because the bug sits on the exported API surface and surfaces only in production.
return buf.Bytes(), nil
}
// encodeUTF16LE implements the Microsoft answer-file password encoding:
// base64( UTF-16LE( plaintext + "Password" ) )
func encodeUTF16LE(plaintext string) string {
s := plaintext + "Password"
b := make([]byte, len(s)*2)
for i, r := range s {
b[i*2] = byte(r)
b[i*2+1] = byte(r >> 8)
}
return base64.StdEncoding.EncodeToString(b)
}
// SanitiseHostname converts an arbitrary string into a valid Windows
// NetBIOS computer name (<=15 chars, alphanumeric + hyphen, no leading/trailing hyphen).
func SanitiseHostname(name string) string {
out := make([]byte, 0, len(name))
range s over a Go string yields i as the byte offset of each rune, not the rune index. For ASCII passwords the two agree, so TestGenerate_PasswordNeverPlaintext passes with "T3stP@ssw0rd!". As soon as s contains a multi-byte rune, the destination index b[i*2] skips ahead by the rune's UTF-8 length times two, leaving zero bytes between writes. Claude verified "abç" produces 61006200e7000000… instead of 61006200e700…. Runes above U+FFFF need surrogate-pair encoding (4 bytes); the current code never emits surrogates. Windows decodes the blob as UTF-16LE for both LocalAccount and AutoLogon, so first boot stalls at login with mismatched passwords.
Fix: use unicode/utf16.Encode over a rune slice, then write each uint16 little-endian via encoding/binary. Add a unit test that feeds a non-ASCII password and decodes the base64 back to UTF-16LE.
Important Issues ¶
<LocalAccount wcm:action="add">
<Name>limauser</Name>
<DisplayName>limauser</DisplayName>
<Group>Administrators</Group>
<Password>
<Value>limauser</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<Username>limauser</Username>
<LogonCount>1</LogonCount>
<Password>
<Value>limauser</Value>
<PlainText>true</PlainText>
</Password>
</AutoLogon>
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideLocalAccountScreen>true</HideLocalAccountScreen>
The Go package encodes passwords (template.xml:138-141, PlainText>false</PlainText>) and the test suite contains an explicit guard (TestGenerate_PasswordNeverPlaintext). The committed manual PoC undoes that guarantee twice: LocalAccount and AutoLogon both carry <Value>limauser</Value> with <PlainText>true</PlainText>. Running the PoC creates an Administrators-group account whose password equals the username, then forwards SSH to 127.0.0.1:60022 via run.sh:110.
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>{{.Username}}</Name>
<DisplayName>{{.Username}}</DisplayName>
<Group>Administrators</Group>
<Password>
<Value>{{encodePassword .Password}}</Value>
<PlainText>false</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
<AutoLogon>
-blockdev driver=raw,file=cdrom1-storage,node-name=cdrom1,read-only=true \
-device ide-cd,bus=ide.2,drive=cdrom1 \
-blockdev driver=file,filename="$CIDATA_ISO",node-name=cdrom2-storage,read-only=true \
-blockdev driver=raw,file=cdrom2-storage,node-name=cdrom2,read-only=true \
-device ide-cd,bus=ide.3,drive=cdrom2 \
-netdev user,id=net0,net=192.168.10.0/24,dhcpstart=192.168.10.15,hostfwd=tcp:127.0.0.1:60022-:22 \
-device virtio-net-pci,netdev=net0 \
-chardev socket,id=chr-vu-fs0,path="$SCRIPT_DIR/virtiofsd.sock" \
-device vhost-user-fs-pci,chardev=chr-vu-fs0,tag=lima \
-device virtio-rng-pci
}
Fix: generate the PoC answer file from pkg/windows/autounattend at run time instead of maintaining two divergent copies. run.sh can invoke a small Go binary that calls autounattend.Generate before staging the ISO.
pkg/windows/cidata reimplements ISO writing and uses flags the detected tools reject Codex Claude Gemini Gemma Qwen¶
return fmt.Errorf("cidata: finalising ISO: %w", err)
}
return nil
}
func detectISOTool() (string, error) {
for _, candidate := range []string{"genisoimage", "mkisofs", "xorriso"} {
if p, err := exec.LookPath(candidate); err == nil {
return p, nil
}
}
return "", fmt.Errorf(
"cidata: no ISO tool found; install one of: genisoimage, mkisofs, xorriso\n" +
" Ubuntu/Debian: sudo apt install genisoimage\n" +
" Fedora/RHEL: sudo dnf install genisoimage",
)
}
func runISOTool(toolPath, stagingDir, outputPath string) error {
cmd := exec.Command(toolPath,
"-output", outputPath,
"-joliet",
"-rock",
"-volid", "cidata",
stagingDir,
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("cidata: %s failed: %w\noutput: %s", filepath.Base(toolPath), err, out)
}
return nil
}
Two compounding problems:
- The detection set names
xorriso, but the args use themkisofsemulation dialect thatxorrisoaccepts only with-as mkisofsprepended. Without that,xorrisoparses-outputas its own command and fails. - Long-form
-output,-joliet,-rock,-volidare not standard forgenisoimageormkisofs— those use-o,-J,-r,-V. The advertised fallback only works onxorrisofs(the mkisofs-emulation binary), which the code does not detect.
The existing helper at pkg/iso9660util/joliet.go:57-67 already handles macOS (hdiutil makehybrid), the correct binary set (xorrisofs, not xorriso), and short flags all three tools accept.
return fmt.Errorf("failed to run %v: %w (output=%q)", cmd.Args, err, string(b))
}
return nil
}
func writeJolietCommand(isoPath, label, workDir string) ([]string, error) {
if runtime.GOOS == "darwin" {
return []string{"hdiutil", "makehybrid", "-o", isoPath, "-iso", "-joliet", "-default-volume-name", label, workDir}, nil
}
candidates := []string{"xorrisofs", "genisoimage", "mkisofs"}
for _, cmd := range candidates {
if cmdAbs, err := exec.LookPath(cmd); err == nil {
return []string{cmdAbs, "-o", isoPath, "--norock", "-J", "-V", label, workDir}, nil
}
}
return nil, fmt.Errorf("none of %v is available", candidates)
}
Fix: call pkg/iso9660util.Write(..., iso9660util.WithJoliet()) from BuildISO. Drop detectISOTool and runISOTool. Extend the existing helper rather than duplicate it.
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<RequiresUserInput>false</RequiresUserInput>
<CommandLine>powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "F:\first_logon.ps1" -SSHKey "{{joinLines .SSHPublicKeys}}"</CommandLine>
</SynchronousCommand>
{{range $i, $cmd := .ExtraCommands}}
<SynchronousCommand wcm:action="add">
<Order>{{inc $i 1 | inc}}</Order>
<RequiresUserInput>false</RequiresUserInput>
Three failure modes:
joinLinesjoins multiple keys with\n. XML preserves the literal newline; Windows Setup forwards it tocmd.exe, which truncates or rejects multi-line command strings. The single-key test inautounattend_test.go:15hides the problem.- An SSH key comment containing
", backtick, or$()survives XML escaping and is interpreted by PowerShell. A malicious or unusual comment can break argument parsing or trigger expression evaluation as Administrator. - In
run.sh,sedsubstitutes the key through a|delimiter. A key containing|,\, or&corrupts the XML before boot.
func baseConfig() autounattend.Config {
return autounattend.Config{
Hostname: "lima-win",
Username: "limauser",
Password: "T3stP@ssw0rd!",
SSHPublicKeys: []string{"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA user@host"},
WindowsEditionIndex: 1,
}
}
func TestGenerate_ContainsRequiredPasses(t *testing.T) {
Gemini graded this Critical; Codex and Claude graded it Important. Kept Important because the PR controls both ends; severity rises once external SSH keys are accepted from limayaml.
Fix: stop passing the key on the command line. Stage an authorized_keys file on the cidata ISO. Have first_logon.ps1 read it from $PSScriptRoot and copy it to C:\ProgramData\ssh\administrators_authorized_keys. Removes the quoting hazard, the newline issue, and the sed substitution risk in one change.
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
out = append(out, byte(r))
default:
out = append(out, '-')
}
}
s := strings.Trim(string(out), "-")
if len(s) > 15 {
s = s[:15]
}
if s == "" {
return "lima-windows"
}
return s
}
Trim runs once, before truncation. If the 15-byte truncation lands inside a hyphen run, the result has a trailing -. Concrete repro: "abcdefghijklmn-xyz" filters and trims to "abcdefghijklmn-xyz", then [:15] is "abcdefghijklmn-". Windows NetBIOS rejects names ending in -, and Sysprep fails on first boot with the answer file's <ComputerName> rejected.
The test table at autounattend_test.go:91-97 has "this-is-way-too-long-for-netbios" — that input truncates cleanly because the 15th byte is o, not -. The boundary case is untested.
})
}
}
func TestSanitiseHostname(t *testing.T) {
cases := []struct{ in, want string }{
{"my-vm", "my-vm"},
{"my_special_vm!", "my-special-vm"},
{"this-is-way-too-long-for-netbios", "this-is-way-too"},
{"---", "lima-windows"},
{"", "lima-windows"},
}
for _, tc := range cases {
got := autounattend.SanitiseHostname(tc.in)
if got != tc.want {
t.Errorf("SanitiseHostname(%q) = %q, want %q", tc.in, got, tc.want)
}
Fix: re-trim after truncation.
s := strings.Trim(string(out), "-")
if len(s) > 15 {
- s = s[:15]
+ s = strings.TrimRight(s[:15], "-")
}
Add {"abcdefghijklmn-xyz", "abcdefghijklmn"} to the test table.
$keyFile = Join-Path $sshDir 'administrators_authorized_keys'
if (-not (Test-Path $sshDir)) {
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
}
Set-Content -Path $keyFile -Value $SSHKey -Encoding UTF8 -Force
icacls $keyFile /inheritance:r | Out-Null
icacls $keyFile /grant 'SYSTEM:(F)' | Out-Null
icacls $keyFile /grant 'Administrators:(F)' | Out-Null
}
Invoke-Step 'Set default SSH shell to PowerShell' {
$regPath = 'HKLM:\SOFTWARE\OpenSSH'
if (-not (Test-Path $regPath)) {
$ErrorActionPreference = 'Stop' does not promote native-executable non-zero exit codes to PowerShell exceptions on Windows PowerShell 5.1 (the default shell on Server 2025). If icacls fails to grant SYSTEM:(F) or choco install winfsp fails (transient network error, package signing change), the enclosing Invoke-Step still prints OK. The script then continues to the next step, register the VirtIO-FS service against a missing WinFSP install. The provisioning log claims success while the guest comes up broken.
Fix: wrap native commands in a helper that checks $LASTEXITCODE immediately.
function Invoke-Native {
param([string]$FilePath, [string[]]$Arguments)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath exited with $LASTEXITCODE"
}
}
New-ItemProperty -Path $regPath -Name DefaultShell `
-Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' `
-PropertyType String -Force | Out-Null
}
Invoke-Step 'Install WinFSP (required for VirtIO-FS)' {
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$chocoExe = 'C:\ProgramData\chocolatey\bin\choco.exe'
if (-not (Test-Path $chocoExe)) {
Set-ExecutionPolicy Bypass -Scope Process -Force
$installScript = (New-Object Net.WebClient).DownloadString(
'https://community.chocolatey.org/install.ps1'
)
Invoke-Expression $installScript
}
& $chocoExe install winfsp -y --pre --no-progress
}
Invoke-Step 'Register and start VirtIO-FS service' {
$virtiofsBin = 'E:\viofs\2k25\amd64\virtiofs.exe'
if (-not (Get-Service -Name VirtioFsSvc -ErrorAction SilentlyContinue)) {
New-Service -Name VirtioFsSvc `
The provisioning script downloads install.ps1 from Chocolatey and runs it with Invoke-Expression, with no checksum, signature, or pinned version. It then installs a pre-release WinFSP package without pinning a specific version. A compromised upstream — or a TLS-interception path during first boot — yields Administrator code execution inside the guest. The PR is the PoC today, but the same script becomes the first-boot path for every Windows guest once pkg/cidata calls into it.
Fix:
- Bundle the WinFSP MSI onto the cidata ISO. Install with
msiexec /i ... /quiet. - If bundling is impossible, download a pinned WinFSP release URL and verify its SHA-256 against a hash compiled into the generator before invoking
msiexec. Drop--pre.
import (
_ "embed"
"bytes"
"encoding/base64"
"fmt"
"html/template"
"strings"
)
//go:embed template.xml
var rawTemplate string
html/template applies HTML-context-aware escaping driven by a state machine that does not match XML. For ASCII input the output is correct enough. As soon as a hostname, username, or extra command contains a quote, ampersand, or angle bracket, the escaping drifts — " for ", ' for ', HTML comment handling rather than XML — in ways that downstream XSD validators on some Windows builds reject.
The fields that reach element text (Username, Hostname, ExtraCommands, SSHPublicKeys) are already constrained by validation upstream, so the practical risk today is small. The structural defect is that the package depends on html/template's contextual rules to produce a non-HTML format.
Fix: switch to text/template. Escape XML element text and attribute values explicitly with encoding/xml.EscapeString for the free-form fields.
-"html/template"
+"text/template"
E: and F: couple the answer file to QEMU CD-ROM ordering Claude Gemini¶
publicKeyToken="31bf3856ad364e35"
language="neutral" versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<DriverPaths>
<PathAndCredentials wcm:action="add" wcm:keyValue="1">
<Path>E:\viostor\2k25\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="2">
<Path>E:\NetKVM\2k25\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="3">
<Path>E:\viofs\2k25\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="4">
<Path>E:\Balloon\2k25\amd64</Path>
</PathAndCredentials>
<PathAndCredentials wcm:action="add" wcm:keyValue="5">
<Path>E:\viorng\2k25\amd64</Path>
</PathAndCredentials>
</DriverPaths>
</component>
<component name="Microsoft-Windows-International-Core-WinPE"
processorArchitecture="amd64"
-device ide-cd,bus=ide.1,drive=cdrom0 \ # Windows ISO
-device ide-cd,bus=ide.2,drive=cdrom1 \ # virtio ISO → guest assigns E:
-device ide-cd,bus=ide.3,drive=cdrom2 \ # cidata ISO → guest assigns F:
The answer file assumes the virtio driver ISO lands on E: and cidata on F:. Windows assigns drive letters in attach order, but the install ISO often takes the first letter, and any additional disks shift the assignments. The PoC works because run.sh controls attach order exactly. Wiring this into pkg/qemu/qemu.go (the PR's "Next Steps") and adding a user's disk.additionalDisks shifts the letters; installation then hangs at winload.exe because the driver paths no longer resolve.
Fix:
- In
windowsPE, replace hard-codedE:\...paths with%CDROM%\...or scan all CD-ROMs via thewcm:keyValuemechanism. - Set a known volume label (
cidata) inBuildISOand resolve the script location at first logon viawmic logicaldisk where VolumeName='cidata' get DeviceID.
$sshDir = 'C:\ProgramData\ssh'
$keyFile = Join-Path $sshDir 'administrators_authorized_keys'
if (-not (Test-Path $sshDir)) {
New-Item -ItemType Directory -Path $sshDir -Force | Out-Null
}
Set-Content -Path $keyFile -Value $SSHKey -Encoding UTF8 -Force
icacls $keyFile /inheritance:r | Out-Null
icacls $keyFile /grant 'SYSTEM:(F)' | Out-Null
icacls $keyFile /grant 'Administrators:(F)' | Out-Null
}
On Windows PowerShell 5.1, -Encoding UTF8 writes a UTF-8 BOM. The Windows OpenSSH authorized_keys parser treats the BOM as part of the first key's algorithm string, so the key never matches and SSH authentication silently fails. The PoC reports "all steps completed successfully" because the Set-Content itself succeeds, but ssh from the host hangs at password prompt.
Fix: SSH keys are pure ASCII, so encode as ASCII (or UTF8NoBOM on PS 7+, or [IO.File]::WriteAllText with a no-BOM encoding).
-Set-Content -Path $keyFile -Value $SSHKey -Encoding UTF8 -Force
+Set-Content -Path $keyFile -Value $SSHKey -Encoding Ascii -Force
Note: if I3's recommended fix lands (drop the -SSHKey parameter and copy authorized_keys from the cidata ISO), apply the same no-BOM rule to whichever code emits the file.
poc-windows-guest/run.sh uses Ubuntu-specific QEMU machine type and a Linux-only accelerator Gemini¶
run_qemu() {
info "Launching QEMU — Windows installation will begin automatically"
warn "First boot (OS install) takes 15-30 min depending on your machine"
qemu-system-x86_64 \
-name guest=win2k25,debug-threads=on \
-machine pc-q35-noble,usb=off,vmport=off,dump-guest-core=off,memory-backend=pc.ram,hpet=off,acpi=on \
-accel kvm \
-cpu host,migratable=on,hv-time=on,hv-relaxed=on,hv-vapic=on,hv-spinlocks=0x1fff \
-m size=4096 \
-object memory-backend-memfd,id=pc.ram,share=true,size=4096M \
-overcommit mem-lock=off \
-smp 4,sockets=2,cores=2,threads=1 \
pc-q35-noble is an Ubuntu Noble downstream alias; upstream QEMU on Fedora/Arch/macOS does not register it. -accel kvm is Linux-only. Anyone running the PoC on macOS or a non-Ubuntu Linux distribution hits a QEMU startup error before the install begins. The PR description claims macOS testing is in scope for the production wiring, so the PoC harness should at least start there.
Fix: use the upstream q35 machine type and allow the accelerator to fall back.
--machine pc-q35-noble,usb=off,vmport=off,dump-guest-core=off,memory-backend=pc.ram,hpet=off,acpi=on \
--accel kvm \
+-machine q35,usb=off,vmport=off,dump-guest-core=off,memory-backend=pc.ram,hpet=off,acpi=on \
+-accel kvm -accel hvf -accel tcg \
func (c *Config) validate() error {
switch {
case c.Hostname == "":
return fmt.Errorf("autounattend: Hostname is required")
case len(c.Hostname) > 15:
return fmt.Errorf("autounattend: Hostname %q exceeds 15-char NetBIOS limit", c.Hostname)
case c.Password == "":
return fmt.Errorf("autounattend: Password is required")
case len(c.SSHPublicKeys) == 0:
return fmt.Errorf("autounattend: at least one SSH public key is required")
case c.WindowsEditionIndex < 1:
len() on a Go string returns the byte count. A caller that bypasses SanitiseHostname and passes "日本サーバ" (15 UTF-8 bytes, 5 runes) passes validation. The template embeds those bytes into <ComputerName>, and Sysprep rejects the name at first boot — NetBIOS names are ASCII-only.
Fix: reject any character outside [A-Za-z0-9-] in validate(), or document that Hostname must be the output of SanitiseHostname and remove the byte-length check from validate so it is the single source of truth.
sed "s|REPLACE_WITH_YOUR_SSH_PUBLIC_KEY|${ssh_key}|g" \
"$SCRIPT_DIR/autounattend.xml" > "$TMP_DIR/autounattend.xml"
cp "$SCRIPT_DIR/first_logon.ps1" "$TMP_DIR/first_logon.ps1"
"$ISO_TOOL" -output "$CIDATA_ISO" -joliet -rock -volid cidata "$TMP_DIR" \
2>&1 | grep -v '^$' || true
[[ -s "$CIDATA_ISO" ]] || fatal "ISO tool produced an empty file"
info "win-cidata.iso ready ($(du -sh "$CIDATA_ISO" | cut -f1))"
}
create_disk() {
info "Creating 40 GB qcow2 disk"
The || true after the pipe suppresses every non-zero exit, including a real failure from the ISO tool. The size check only catches an empty output; a tool that exits 1 after writing a partial non-empty file passes the check, and the script proceeds into a confusing Windows install failure 15 minutes later.
Fix: capture the output and check the exit code separately.
local iso_output
if ! iso_output=$("$ISO_TOOL" -output "$CIDATA_ISO" -joliet -rock -volid cidata "$TMP_DIR" 2>&1); then
printf '%s\n' "$iso_output" | grep -v '^$' || true
rm -f "$CIDATA_ISO"
fatal "ISO tool failed"
fi
qemu-img create -f qcow2 "$QCOW2" 40G -q
}
start_virtiofsd() {
info "Starting virtiofsd"
virtiofsd \
--shared-dir="$SCRIPT_DIR" \
--socket-path="$SCRIPT_DIR/virtiofsd.sock" \
--sandbox none &
echo $! > "$VIRTIOFSD_PID_FILE"
sleep 1
[[ -S "$SCRIPT_DIR/virtiofsd.sock" ]] || fatal "virtiofsd socket not created"
}
run_qemu() {
info "Launching QEMU — Windows installation will begin automatically"
The EXIT trap at run.sh:30 clears the socket on normal exit, but kill -9 or a host crash bypasses traps. The next run finds virtiofsd.sock on disk, virtiofsd refuses to bind, and the script aborts with no path to recovery short of manual cleanup.
kill "$(cat "$VIRTIOFSD_PID_FILE")" 2>/dev/null || true
rm -f "$VIRTIOFSD_PID_FILE"
fi
rm -f "$SCRIPT_DIR/virtiofsd.sock"
}
trap cleanup EXIT
check_prereqs() {
local missing=()
for cmd in qemu-system-x86_64 qemu-img virtiofsd; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
Fix: delete the socket file immediately before starting the daemon.
start_virtiofsd() {
info "Starting virtiofsd"
+ rm -f "$SCRIPT_DIR/virtiofsd.sock"
virtiofsd \
switch {
case c.Hostname == "":
return fmt.Errorf("autounattend: Hostname is required")
case len(c.Hostname) > 15:
return fmt.Errorf("autounattend: Hostname %q exceeds 15-char NetBIOS limit", c.Hostname)
case c.Password == "":
return fmt.Errorf("autounattend: Password is required")
case len(c.SSHPublicKeys) == 0:
return fmt.Errorf("autounattend: at least one SSH public key is required")
case c.WindowsEditionIndex < 1:
return fmt.Errorf("autounattend: WindowsEditionIndex must be >= 1")
}
Windows Server 2025 enforces the default local-account password policy: length ≥ 8, three of four character classes (lower/upper/digit/symbol), and no username substring. A caller that supplies "abc" passes validation here, the XML renders, and Setup fails after the user-creation step. The install drops into OOBE with no message back to the Lima host; the diagnosis is buried in Windows Setup logs.
Fix: validate against the default policy in validate() so the caller gets a clear error from Generate instead of a stalled install.
case !meetsWindowsComplexity(c.Password):
return fmt.Errorf("autounattend: Password does not meet Windows complexity requirements (>=8 chars, three of: lower/upper/digit/symbol)")
Suggestions ¶
macOS ships hdiutil (hdiutil makehybrid -iso -joliet); Windows users install ISO tools via choco install xorriso. The current error message names only apt and dnf. Subsumed by I2 if pkg/iso9660util is reused.
Two parallel callers with the same OutputPath collide on the .tmp filename. Use os.CreateTemp(filepath.Dir(cfg.OutputPath), "win-cidata-*.iso.tmp") to obtain a unique path before renaming. Theoretical today (one caller), invited by the package API.
If os.Rename fails (cross-filesystem, parent removed), the current code unconditionally removes the temp file, leaving the user nothing to debug. Drop the os.Remove on rename failure and include the temp path in the error message.
The three highest-risk code paths (C1, C2, I3) are precisely the ones not exercised. A table-driven test varying ExtraCommands count (0, 1, 2), SSHPublicKeys count (1, 2), and Password character set (ASCII, non-ASCII BMP, supplementary plane) would have caught all three in CI.
Once C1 is fixed, {{add $i 2}} reads better than {{inc $i | inc}}.
var tmpl = template.Must(
template.New("autounattend").
Funcs(template.FuncMap{
"encodePassword": encodeUTF16LE,
"joinLines": func(ss []string) string { return strings.Join(ss, "\n") },
"inc": func(n int) int { return n + 1 },
}).
Parse(rawTemplate),
)
type Config struct {
The PR's "Next Steps" remove this scaffolding in favor of integration in pkg/cidata and pkg/qemu. After that lands, poc-windows-guest/autounattend.xml diverges from template.xml, first_logon.ps1 lives independently of any code that consumes it, and run.sh references a path the production driver may stop using. Either delete the directory in the integration PR, or move it to hack/win-guest-poc/ with a README labelling it throwaway scaffolding.
A user with LC_ALL=fr_FR.UTF-8 gets translated genisoimage diagnostics that issue trackers and search engines do not match. Set cmd.Env = append(os.Environ(), "LC_ALL=C") before CombinedOutput.
A cmd.exe discovery loop avoids the I8 problem for the script path:
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<RequiresUserInput>false</RequiresUserInput>
<CommandLine>powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "F:\first_logon.ps1" -SSHKey "{{joinLines .SSHPublicKeys}}"</CommandLine>
</SynchronousCommand>
{{range $i, $cmd := .ExtraCommands}}
<SynchronousCommand wcm:action="add">
<Order>{{inc $i 1 | inc}}</Order>
<RequiresUserInput>false</RequiresUserInput>
Subsumed by the broader I8 fix.
The fixed XMLBytes/ScriptBytes fields force an API change for every new file (e.g., the authorized_keys file from I3). Replace with Files map[string][]byte and let callers supply what they need.
rm -f "$QCOW2" runs before create_disk, so any in-progress install lost to ^C is wiped on retry. Add a --keep-disk flag or skip the unlink when the disk exists and a LIMA_POC_REUSE_DISK=1 env var is set.
Config, Generate, SanitiseHostname, ISOConfig, and BuildISO lack godoc comments. Add the leading-symbol-name comments before another package imports either of these.
Design Observations ¶
Concerns ¶
D1. The hand-rolled XML template duplicates a complex Microsoft schema (future) Claude
autounattend.xml is a complex Microsoft schema with strict component-naming rules, pass ordering, and per-component XSDs. The current 185-line template already drops corner cases (LocalAccount vs DomainAccount password encoding, UnsecureJoin flags, FIDO/PIN policy elements). Hand-rolled templating works for the PoC but accumulates subtle bugs as the supported locale/edition matrix grows. Two alternatives before the production wiring lands:
- Define a typed model (
Unattend,Settings,Component, ...) and marshal to XML viaencoding/xml. This eliminates escape-context bugs (I7), provides field-level validation, and lets tests compare struct values rather than string contents. - Survey existing Go libraries that produce answer files from structs and decide whether one is worth adopting.
The current Generate surface is small enough that a later refactor is not blocked by this decision; pinning the direction before pkg/cidata imports the package saves a rewrite.
D2. pkg/windows/cidata overlaps with pkg/cidata in name and concept (in-scope) Claude
Lima already has a pkg/cidata (cloud-init seed builder for Linux guests). The new package is at pkg/windows/cidata with the same cidata leaf name. Importers must alias to disambiguate. Suggest renaming to pkg/windows/answerfileiso or merging the Windows-specific behavior into pkg/cidata behind a guest-OS branch.
D3. Reimplementation of ISO writing instead of reuse (in-scope) Codex
This is the design observation behind I2: pkg/windows/cidata introduces a parallel implementation of staging and tool dispatch even though pkg/iso9660util already owns ISO writing and Joliet command selection. Reusing the existing helper would remove the I2 flag-compatibility issue, centralize future fixes, and keep pkg/windows/cidata focused on the Windows-specific file layout. If pkg/iso9660util is missing a feature, extend it rather than fork.
Strengths ¶
- The Go package surface is small and testable: a single
Generateand a singleBuildISO. TestGenerate_PasswordNeverPlaintextasserts a security property over the output rather than the implementation, which is the right shape. The test would catch a future regression that re-introducesPlainText>true.BuildISOwrites to<path>.tmpand renames — the correct atomic-write pattern for a host-visible artifact.first_logon.ps1wraps each provisioning phase inInvoke-StepunderSet-StrictModewith$ErrorActionPreference = 'Stop'. The pattern is correct; I5 is about closing the native-command gap, not about the structure.
Testing Assessment ¶
Untested scenarios, highest-risk first:
ExtraCommandswith one or more entries (triggers C1).- Non-ASCII or supplementary-plane passwords (triggers C2).
- Multiple SSH public keys (triggers I3).
- Hostnames that result in a trailing
-after the 15-byte truncation (triggers I4 — e.g."abcdefghijklmn-xyz"). Generatecalled with a non-ASCII multi-byteHostnamethat bypassesSanitiseHostname(triggers I11).pkg/windows/cidatahas no Go tests at all. Tool detection,ISOConfigvalidation, and the empty-output error path are all easily testable without invoking a real ISO tool.
The cloud agents ran go test ./pkg/windows/... and confirmed all current tests pass.
Documentation Assessment ¶
The new exported symbols (Config, Generate, SanitiseHostname, ISOConfig, BuildISO) lack godoc comments. The encodeUTF16LE comment names the encoding but does not link to the LocalAccounts Password schema reference; the magic "Password" suffix is hard to defend without that link.
poc-windows-guest/run.sh wipes $IMAGES_DIR/windows.qcow2 on every run (run.sh:119) but does not announce that. A user who sees "OS install takes 15-30 min" and ^Cs mid-install discovers the wipe only on retry.
Commit Structure ¶
Single feature commit 1bfac33a "feat : PoC for automated Windows guest via autounattend.xml Implements issue #4852.". Minor: stray space before the colon, two spaces before "Implements". A body paragraph noting the split between pkg/windows/... and poc-windows-guest/... and the planned removal of the latter would help future archaeology.
Acknowledged Limitations ¶
The PR explicitly defers full Lima integration (wiring pkg/windows/autounattend and pkg/windows/cidata into pkg/cidata, adding guestOS: windows to limayaml, Windows-specific QEMU args, an example template, integration tests) to follow-up PRs. The findings here treat that deferred integration as out of scope. Items that affect the current PR — the API surface, the manual PoC, and the supply chain of the first-boot script — are flagged regardless.
The PR description discloses "Assistance taken from Claude opus for the documentation of this Pr." That is disclosure, not a defect, but combined with the issue's caution against "blindly submitting an untested AI slop" it argues for closer review of the code paths the test suite does not exercise (C1, C2, I3).
Unresolved Feedback ¶
No inline review comments on PR #5013 at the time of review.
Agent Performance Retro ¶
Claude ¶
Claude produced the most comprehensive review and the only one that empirically verified both Critical findings. C2's encoding analysis includes a hex dump from running the buggy encoder against "abç" and comparing against unicode/utf16.Encode — that level of evidence is the right shape for a silent-failure bug whose test passes by accident. Claude was the only agent to catch the html/template vs XML mismatch (I7), the NetBIOS UTF-8 byte length issue (I11), the lack of a password complexity check (I14), and the design concern about pkg/windows/cidata overlapping with the existing pkg/cidata (D2). Severity calibration was slightly hot — I7 and I14 are real but the practical risk today is bounded by current callers — but the underlying analysis was sound in every case I verified.
Codex ¶
Codex caught the same two top bugs Claude did, plus the cross-file finding that mattered most for this PR: the new pkg/windows/cidata ignores the existing pkg/iso9660util/joliet.go. The pointer to xorrisofs (not xorriso), -o/-J/-V short flags, and the darwin/hdiutil branch in the existing helper is concrete and immediately actionable; my I2 is essentially Codex's I5 with the cross-reference verified. Codex also caught the || true exit-code masking in run.sh (I12) that no other agent flagged. Severity calibration ran the other way from Claude — no Criticals at all, even for C1's arity bug. The bug is real and Codex described it accurately; the severity disagreement is a style difference rather than an error.
Gemini ¶
Gemini produced three useful findings the cloud peers missed — the UTF-8 BOM in Set-Content -Encoding UTF8 (I9), the Ubuntu-only pc-q35-noble machine type plus Linux-only -accel kvm (I10), and the stale-socket lockout in run.sh (I13). It missed C1 entirely (the template arity bug). The stderr log showed a single Error stating path from misinterpreting diff text as a file path, but the agent recovered and produced structured findings. Severity was inflated: it marked I3 and I5 as Critical where the other two cloud agents marked them Important. The miss on C1 plus the severity inflation moved the consensus toward the Codex/Claude framing for the consolidated severities.
Qwen ¶
Qwen failed the format check — the output has no Critical Issues/Important Issues/Suggestions sections and no Coverage Summary. More damaging, it found the {{inc $i 1 | inc}} template bug, then talked itself out of it on second pass ("I was reading this wrong. No bug."). The analysis that follows is incorrect: inc $i 1 does not mean "increment $i by 1" — inc takes one argument, so the two-argument call is the bug. The agent rejected its own correct first read. The Chocolatey supply-chain finding and the plaintext-password observation are useful but already covered by Claude and Codex. Net: lower confidence than the cloud trio, and the self-rebuttal on C1 is the kind of failure mode that argues against giving Qwen weight in close calls.
Gemma ¶
Gemma produced a short structured review that caught C2 (UTF-16LE encoder), the xorriso flag mismatch (I2), and the SanitiseHostname trailing-hyphen issue (I4). It missed everything else: C1, the plaintext password in the PoC autounattend.xml, the SSH key quoting issue, Chocolatey, BOM, drive letters, macOS QEMU args, virtiofsd socket, native command failures. The output also contained escaped quotes (\"-output\") leaked from JSON serialization, suggesting an opencode wrapper layer that needs a polish pass. Useful as a sanity check on the highest-confidence findings but cannot stand alone.
Summary ¶
| Claude Opus 4.7 | Codex GPT 5.5 | Gemini 3.1 Pro | Qwen 3.6 35B | Gemma 31B | |
|---|---|---|---|---|---|
| Duration | 6m 54s | 6m 26s | — | — | — |
| Findings | 2C 8I 7S | 9I | 3C 5I 2S | none | 1C 2I |
| Tool calls | 12 (Read 7, Bash 5) | 37 (shell 37) | — | — | — |
| Design observations | 3 | 1 | 1 | 0 | 0 |
| False positives | 0 | 0 | 0 | 1 | 0 |
| Unique insights | 4 | 2 | 3 | 0 | 0 |
| Files reviewed | 8 | 8 | 8 | 7 | 8 |
| Coverage misses | 0 | 0 | 4 | 5 | 4 |
| Totals | 2C 8I 7S | 9I | 3C 5I 2S | none | 1C 2I |
| Downgraded | 0 | 0 | 2 (I→S) | 0 | 0 |
| Dropped | 0 | 0 | 0 | 1 | 0 |
Cloud agents carried the review. Claude and Codex independently caught every Critical and most Important findings; their disagreements were severity-level rather than presence/absence. Gemini added three Important findings the others missed but missed C1. Among the local agents, Gemma added nothing the cloud trio didn't already have, and Qwen actively talked itself out of a real bug. For PRs of this size and complexity, Claude+Codex is the most reliable two-of-three baseline; Gemini is a useful third reader for shell/PowerShell-heavy diffs; the local agents are not yet competitive enough to influence consolidation.
Review Process Notes ¶
Skill improvements ¶
- Add a dimension: validate template-engine choice against output format. When a template renders XML, JSON, SQL, YAML, or any non-HTML format, the use of
html/templateis a defect, not an optimization. Flag any import ofhtml/templatewhoseParseinput has no<html>root or is fed by//go:embed *.xml/*.json/*.yaml. The principle: HTML-context-aware escaping rules misfit other markup, often silently. Reviewers should treat the template-engine/output-format mismatch as a first-class structural smell. - Add a dimension: function-arity bugs in Go templates surface at execute time, not parse time. When a template uses
template.FuncMapfunctions, check that every call site's argument count matches the function's declared arity, and that the test suite executes every conditional branch ({{if}},{{range}},{{with}}) at least once. Atemplate.Must(...).Parse(...)that passes does not guarantee runtime correctness: aninc $i 1against a one-argincparses fine and only errors when the body executes. Flag any template path with conditional or range blocks whose body is not exercised by at least one test case. - Add a dimension:
rangeover a Go string yields byte offset, not rune index. A loop of the shapefor i, r := range s { dst[i*k] = ... }is buggy wheneversmay contain non-ASCII text. The variableiis the byte offset of the rune in the UTF-8 source. Using it to index a destination indexed by rune position skips slots for multi-byte runes. The test suite catches this only when the test data contains non-ASCII input, so flag any such loop where the test fixtures are ASCII-only.
Repo context updates ¶
- Lima reuses ISO writing through
pkg/iso9660util. Any new package that needs to produce a Joliet ISO should callpkg/iso9660util.Writerather than implement its own tool detection and exec dispatch. The helper handlesdarwin/hdiutil makehybrid, detectsxorrisofs(notxorriso), and uses short-form flags (-o,-J,-V,--norock) that are accepted by all three Linux tools (xorrisofs,genisoimage,mkisofs). New code that reimplements this should be flagged as duplicated infrastructure. - Lima already has a
pkg/cidata(Linux cloud-init seed builder). A new package whose path or leaf name collides withpkg/cidataforces aliasing at every import site. Flag any newpkg/.../cidatapackage and ask whether the new behavior belongs inside the existingpkg/cidata(perhaps behind a guest-OS branch) before creating a sibling.