Deep Review: 20260519-084740-pr-5013

Date2026-05-19 08:47
Repolima-vm/lima
Round1
Author@liketosweep
PR#5013 — PoC: Windows Guest Support via autounattend.xml (Issue #4852)
Commits1bfac33a feat : PoC for automated Windows guest via autounattend.xml Implements issue #4852.
ReviewersClaude 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)
VerdictRework — 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 time49 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

C1. Template arity error in ExtraCommands Order computation Claude Codex
          <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.

C2. UTF-16LE encoder uses UTF-8 byte index as destination index Claude Codex Gemini Gemma Qwen
	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

I1. Committed PoC answer file stores plaintext admin credentials Claude Codex Qwen
          <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.

I2. 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 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.

I3. SSH public keys passed through a PowerShell double-quoted command-line argument Claude Codex Gemini

      <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:

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.

I4. SanitiseHostname can return a name ending in - Claude Codex Gemini Gemma
		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.

I5. Native command exit codes are not caught by Invoke-Step Codex Gemini
    $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"
    }
}
I6. Chocolatey bootstrap downloads and evaluates an unpinned install script as Administrator Claude Codex Qwen
    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:

I7. pkg/windows/autounattend uses html/template for an XML document Claude
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 — &#34; for ", &#39; 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"
I8. Hard-coded drive letters 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:

I9. Set-Content -Encoding UTF8 writes a BOM that OpenSSH refuses to parse Gemini
    $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.

I10. 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 \
I11. validate() accepts UTF-8 multi-byte hostnames that NetBIOS rejects Claude

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.

I12. run.sh masks ISO tool failures with || true Codex
    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
I13. Stale virtiofsd.sock prevents the next run from starting Gemini
    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 \
I14. validate() rejects empty passwords but accepts passwords Windows will reject Claude
	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

S1. macOS and Windows host detection in cidata.go Claude Gemini Qwen

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.

S2. BuildISO race on <OutputPath>.tmp Claude

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.

S3. Preserve the .tmp file on rename failure Claude

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.

S4. Tests do not cover ExtraCommands, multi-key SSH, or non-ASCII passwords Claude Gemini Codex

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.

S5. Replace inc | inc with an add function Claude

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 {
S6. poc-windows-guest/ will drift once pkg/windows/... is wired in Claude

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.

S7. Pin ISO-tool diagnostics to LC_ALL=C Claude

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.

S8. Drive letter discovery loop instead of hard-coded F: Gemini

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.

S9. Generic Files map[string][]byte in ISOConfig Gemini

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.

S10. run.sh wipes the qcow2 disk on every run with no opt-out Qwen

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.

S11. Godoc on exported symbols Claude Gemini Gemma

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:

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


Testing Assessment

Untested scenarios, highest-risk first:

  1. ExtraCommands with one or more entries (triggers C1).
  2. Non-ASCII or supplementary-plane passwords (triggers C2).
  3. Multiple SSH public keys (triggers I3).
  4. Hostnames that result in a trailing - after the 15-byte truncation (triggers I4 — e.g. "abcdefghijklmn-xyz").
  5. Generate called with a non-ASCII multi-byte Hostname that bypasses SanitiseHostname (triggers I11).
  6. pkg/windows/cidata has no Go tests at all. Tool detection, ISOConfig validation, 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.7Codex GPT 5.5Gemini 3.1 ProQwen 3.6 35BGemma 31B
Duration6m 54s6m 26s
Findings2C 8I 7S9I3C 5I 2Snone1C 2I
Tool calls12 (Read 7, Bash 5)37 (shell 37)
Design observations31100
False positives00010
Unique insights42300
Files reviewed88878
Coverage misses00454
Totals2C 8I 7S9I3C 5I 2Snone1C 2I
Downgraded002 (I→S)00
Dropped00010

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

Repo context updates