Deep Review: 20260519-100641-pr-4985

Date2026-05-19 10:06
Repolima-vm/lima
Round1
Author@ashwat287
PR#4985 — POC: add Windows guest support via autounattend.xml
Commitsf3cd7c9b poc: add Windows guest support via autounattend.xml
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)
VerdictMerge with fixes — POC scaffolding is the right shape (Joliet ISO, OS-type seam, arch-specific templates), but the answer files contain zero Go template directives so every per-instance value (SSH keys, hostname, username, timezone) is lost; a PowerShell backtick bug and an unanchored sshd_config regex make admin-key auth fail even if substitution is wired up; and Validate now accepts Windows while Prepare will error out looking for a non-existent lima-guestagent.Windows-* binary.
Wall-clock time53 min 43 s


Executive Summary

PR #4985 adds a Windows OS type, two arch-specific autounattend.xml templates, a developer ISO tool, and a generateWindowsISO() branch. Scaffolding is sound — Joliet labeling, dispatch at the cidata seam, NVMe on arm64 — but the answer files contain zero Go template directives, so every per-instance value (SSH keys, hostname, username, timezone) is lost. Three integration gaps surface once limactl start drives the path: Prepare fails looking for a non-existent lima-guestagent.Windows-*; the QEMU driver exposes virtio devices while templates assume SATA/NVMe; and arch silently falls back to amd64 for any value other than aarch64. 0 critical, 7 important, 10 suggestions.


Critical Issues

None.


Important Issues

I1. Templates contain no Go template directives — every per-instance value is lost Claude Codex Gemini
<?xml version="1.0" encoding="utf-8"?>
<!-- Windows unattended answer file for Lima guest VMs (amd64).
     Ref: https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/update-windows-settings-and-scripts-create-your-own-answer-file-sxs -->
<unattend xmlns="urn:schemas-microsoft-com:unattend">

  <!-- windowsPE: locale, disk layout, image selection, hw bypass -->

Neither autounattend.xml contains a {{ directive, yet both pass through textutil.ExecuteTemplate in ExecuteTemplateAutounattend (pkg/cidata/template.go:241). The rendered output equals the file on disk byte for byte. TemplateArgs.SSHPubKeys, User, Hostname, and TimeZone are populated but never reach the guest. The PR's claim that "OpenSSH server is installed and started during OOBE so Lima's host-agent can connect" therefore fails — ssh-rsa PLACEHOLDER lands in administrators_authorized_keys and the local account is hardcoded to lima.

	xmlTemplate, err := templateFS.ReadFile(path.Join(root, "autounattend.xml"))
	if err != nil {
		return nil, err
	}

	return textutil.ExecuteTemplate(string(xmlTemplate), args)
}

Fix: render the SSH keys via a PowerShell array literal inside the existing <CommandLine> (careful quoting needed across the XML and PowerShell layers), and use {{ .Hostname | printf "%.15s" }} for <ComputerName> (NetBIOS limit), {{ .User }} for <Name>/<Username>, and {{ .TimeZone }} for <TimeZone> with an IANA→Windows mapping table (Windows expects "Pacific Standard Time", not "America/Los_Angeles").

I2. PowerShell backtick escapes inside a single-quoted string land as literal characters; unanchored -replace regex breaks the admin Match block Claude
          <CommandLine>powershell.exe -NoProfile -Command "Set-Content -Path 'C:\ProgramData\ssh\administrators_authorized_keys' -Value 'ssh-rsa PLACEHOLDER'; icacls 'C:\ProgramData\ssh\administrators_authorized_keys' /inheritance:r /grant 'SYSTEM:(F)' /grant 'Administrators:(F)'"</CommandLine>
          <Description>Write authorized_keys with correct ACLs</Description>
        </SynchronousCommand>
        <SynchronousCommand wcm:action="add">
          <Order>5</Order>
          <CommandLine>powershell.exe -NoProfile -Command "$cfg = 'C:\ProgramData\ssh\sshd_config'; (Get-Content $cfg) -replace '#?AuthorizedKeysFile.*', 'AuthorizedKeysFile .ssh/authorized_keys' | Set-Content $cfg; Add-Content $cfg 'Match Group administrators`r`n  AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys'; Restart-Service sshd"</CommandLine>
          <Description>Configure sshd for admin key auth</Description>
        </SynchronousCommand>
        <SynchronousCommand wcm:action="add">
          <Order>6</Order>
          <CommandLine>reg.exe add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /t REG_SZ /d 0 /f</CommandLine>

Two compounding problems:

  1. PowerShell single-quoted strings are literal, so rn inside Add-Content's argument writes four literal characters (backtick, r, backtick, n) instead of CRLF. sshd parses the resulting one-line Match block as malformed and silently ignores it.
  2. The -replace '#?AuthorizedKeysFile.*' regex is unanchored. Windows OpenSSH ships sshd_config with a default Match Group administrators block whose AuthorizedKeysFile line points at __PROGRAMDATA__/ssh/administrators_authorized_keys. The replacement rewrites that line too, so even with problem (1) fixed, sshd no longer consults the path the script claims to populate.

Combined with I1, both halves of the SSH-key plumbing are broken.

Fix: use a double-quoted string for the Add-Content argument (escape XML quotes with &quot;) and anchor the regex with ^. A simpler alternative overwrites sshd_config from a PowerShell array, removing the dependency on the existing file's layout.

I3. QEMU driver still exposes virtio devices while answer files assume SATA/AHCI (amd64) and NVMe (arm64) Codex
	args, err := templateArgs(ctx, true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort, noCloudInit, rosettaEnabled, rosettaBinFmt)
	if err != nil {
		return "", err
	}

	// Windows guests use autounattend.xml instead of cloud-init.
	if *instConfig.OS == limatype.WINDOWS {
		var winArch string
		if instConfig.Arch != nil && *instConfig.Arch == limatype.AARCH64 {
			winArch = "arm64"
		} else {
			winArch = "amd64"
		}
		return generateWindowsISO(args, instDir, winArch)
	}

	if err := ValidateTemplateArgs(args); err != nil {
		return "", err
	}

The Windows branch in cidata.go only changes the cidata payload. The QEMU driver still attaches the disk as virtio, the cidata ISO via virtio-scsi, and the NIC as virtio-net-pci, while the amd64 template's own comment says the disk is SATA/AHCI and the arm64 template uses NVMe. Without inbox virtio drivers in WinPE, the installer cannot see the disk during windowsPE, so installation fails before reaching oobeSystem. This is why the PR's testing section invokes QEMU directly with hand-tuned device arguments rather than limactl start.

Fix: add a Windows path in the QEMU driver matching the templates — SATA/AHCI on amd64, NVMe on arm64, usb-storage for both ISOs, USB keyboard/tablet, and ramfb on arm64. The dev tool's command lines in hack/gen-autounattend-iso.go are the right shape to copy. The alternative (embed virtio-win.iso plus a <DriverPaths> block) carries a license question that NVMe avoids.

I4. Validate accepts os: Windows but Prepare falls through to a non-existent guest-agent lookup Codex
	GuestAgent          string
	NerdctlArchiveCache string
}

// Prepare ensures the disk, the nerdctl archive, etc.
func Prepare(ctx context.Context, inst *limatype.Instance, guestAgent string) (*Prepared, error) {
	var needsGuestAgent bool
	switch *inst.Config.OS {
	case limatype.DARWIN:
		// macOS guests always need the guest agent for running fake-cloud-init
		needsGuestAgent = true
	case limatype.FREEBSD:
		// guest agent is not implemented for FreeBSD yet
		needsGuestAgent = false
	default:
		needsGuestAgent = !*inst.Config.Plain
	}
	if needsGuestAgent && guestAgent == "" {
		var err error
		guestAgent, err = usrlocal.GuestAgentBinary(*inst.Config.OS, *inst.Config.Arch)
		if err != nil {
			return nil, err
		}
	}
	limaDriver, err := driverutil.CreateConfiguredDriver(inst, 0)
	if err != nil {

Validate now accepts Windows, but the Prepare switch has no Windows case; the default branch sets needsGuestAgent = true (because Plain is false). Prepare then calls usrlocal.GuestAgentBinary("Windows", arch), which searches for lima-guestagent.Windows-x86_64 (or -aarch64). The Makefile only builds Linux and Darwin guest agents — no lima-guestagent.Windows-* exists — so limactl start --os Windows fails inside Prepare with a confusing guest agent binary could not be found error before the driver ever launches.

Fix:

 case limatype.FREEBSD:
     needsGuestAgent = false
+case limatype.WINDOWS:
+    needsGuestAgent = false
 default:
     needsGuestAgent = !*inst.Config.Plain

A more complete fix gates the whole Windows path behind a "not yet startable via limactl" validation error until I3 lands.

I5. arch for Windows defaults to amd64 for any value other than aarch64 Claude Codex Gemini Gemma Qwen
		return "", err
	}

	// Windows guests use autounattend.xml instead of cloud-init.
	if *instConfig.OS == limatype.WINDOWS {
		var winArch string
		if instConfig.Arch != nil && *instConfig.Arch == limatype.AARCH64 {
			winArch = "arm64"
		} else {
			winArch = "amd64"
		}
		return generateWindowsISO(args, instDir, winArch)
	}

	if err := ValidateTemplateArgs(args); err != nil {
		return "", err
	}

limatype.ArchTypes includes x86_64, aarch64, armv7l, ppc64le, riscv64, s390x. The PR's validation change accepts any of them for os: Windows. A user writing os: Windows, arch: riscv64 passes validation and silently gets an amd64 answer file; the failure surfaces deep in the install process when Windows refuses to boot the wrong-arch image.

Fix: reject the invalid combination at validation, catching the error before any ISO is generated:

 case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD, limatype.WINDOWS:
+    if *y.OS == limatype.WINDOWS && *y.Arch != limatype.X8664 && *y.Arch != limatype.AARCH64 {
+        errs = errors.Join(errs, fmt.Errorf("field `arch` for `os: Windows` must be %q or %q; got %q", limatype.X8664, limatype.AARCH64, *y.Arch))
+    }

Make ExecuteTemplateAutounattend's arch switch exhaustive afterward so a future caller cannot reintroduce the silent fallthrough.

I6. Windows path silently drops mounts, provision, bootScripts rather than rejecting them at validation Gemini
	args, err := templateArgs(ctx, true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort, noCloudInit, rosettaEnabled, rosettaBinFmt)
	if err != nil {
		return "", err
	}

	// Windows guests use autounattend.xml instead of cloud-init.
	if *instConfig.OS == limatype.WINDOWS {
		var winArch string
		if instConfig.Arch != nil && *instConfig.Arch == limatype.AARCH64 {
			winArch = "arm64"
		} else {
			winArch = "amd64"
		}
		return generateWindowsISO(args, instDir, winArch)
	}

	if err := ValidateTemplateArgs(args); err != nil {
		return "", err
	}

The Windows branch returns from GenerateISO9660 immediately. It does not run drv.BootScripts(), does not loop over instConfig.Provision, and does not include any Mount entries. A user copying their existing Linux lima.yaml (with mounts:, provision:, or driver-supplied boot scripts) gets no warning that those blocks are ignored. The trap matters most for provision, where users read their scripts in the YAML and expect them to run.

Fix: reject the unsupported blocks at validation. Name the unsupported field in the error, not the OS, so the user knows what to remove:

+if *y.OS == limatype.WINDOWS {
+    if len(y.Provision) > 0 {
+        errs = errors.Join(errs, errors.New("field `provision` is not yet supported for Windows guests"))
+    }
+    if len(y.Mounts) > 0 {
+        errs = errors.Join(errs, errors.New("field `mounts` is not yet supported for Windows guests"))
+    }
+}

Lift restrictions one at a time as I3's integration work lands.

I7. generateWindowsISO runs the full Unix templateArgs pipeline whose output is then thrown away Claude
}

// GenerateISO9660 generates the cidata ISO9660 image (or directory, for noCloudInit)
// in instDir. It returns the instance ID, which changes on every boot.
func GenerateISO9660(ctx context.Context, drv driver.Driver, instDir, name string, instConfig *limatype.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort int, guestAgentBinary, nerdctlArchive string, vsockPort int, virtioPort string, noCloudInit, rosettaEnabled, rosettaBinFmt bool) (string, error) {
	args, err := templateArgs(ctx, true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort, noCloudInit, rosettaEnabled, rosettaBinFmt)
	if err != nil {
		return "", err
	}

	// Windows guests use autounattend.xml instead of cloud-init.
	if *instConfig.OS == limatype.WINDOWS {
		var winArch string
		if instConfig.Arch != nil && *instConfig.Arch == limatype.AARCH64 {
			winArch = "arm64"
		} else {
			winArch = "amd64"
		}
		return generateWindowsISO(args, instDir, winArch)
	}

	if err := ValidateTemplateArgs(args); err != nil {
		return "", err
	}

templateArgs() dereferences Linux-specific fields before Windows can peel off: *instConfig.MountType, User.Home/Shell/UID, GuestInstallPrefix, HostResolver.Enabled, the 9p msize parser, sshutil.DefaultPubKeys, osutil.DNSAddresses. None reaches the autounattend ISO; the Windows path uses only Name, Hostname, User, SSHPubKeys, TimeZone, and IID.

Two consequences: a host with no SSH key, an unreadable resolv.conf, or a 9p mount with an unparseable msize aborts Windows ISO generation with an unrelated Linux-pipeline error; and Windows depends on the Unix pipeline staying happy, the opposite of the intended decoupling. The TODO: Split into per-OS template args (Unix vs Windows) at pkg/cidata/template.go:89 already flags this; I7 promotes it from paper TODO to concrete blocker for I1.

	FSType string
	FSArgs []string
}

// TemplateArgs holds all data needed to render provisioning templates.
// TODO: Split into per-OS template args (Unix vs Windows) to avoid carrying irrelevant fields.
type TemplateArgs struct {
	Debug                           bool
	OS                              limatype.OS
	Name                            string // instance name
	Hostname                        string // instance hostname

Fix: short-circuit the OS check before templateArgs, and build a Windows-only windowsTemplateArgs that supplies only the fields the Windows path uses.


Suggestions

S1. TestAutounattend only renders amd64 Codex Claude
			assert.Assert(t, strings.Contains(string(b), "mounts:"))
		}
	}
}

func TestAutounattend(t *testing.T) {
	args := &TemplateArgs{
		Name: "default",
	}
	output, err := ExecuteTemplateAutounattend(args, "amd64")
	assert.NilError(t, err)
	t.Log(string(output))

	// Verify the output is well-formed XML
	assert.Assert(t, xml.Unmarshal(output, new(any)) == nil, "output is not valid XML")

The arm64 template adds 239 lines, none of them covered by the test. A syntax or placeholder regression in cidata.TEMPLATE.d.Windows.arm64/autounattend.xml would not be caught. The fix is a small refactor to a table-driven test:

-    output, err := ExecuteTemplateAutounattend(args, "amd64")
-    assert.NilError(t, err)
+    for _, arch := range []string{"amd64", "arm64"} {
+        t.Run(arch, func(t *testing.T) {
+            output, err := ExecuteTemplateAutounattend(args, arch)
+            assert.NilError(t, err)
+            assert.Assert(t, xml.Unmarshal(output, new(any)) == nil)
+            assert.Assert(t, strings.Contains(string(output), "processorArchitecture=\""+arch+"\""))
+        })
+    }

Once I1 is fixed, extend the test to assert that a non-placeholder key appears in the rendered output, so the placeholder cannot silently return as a regression.

S2. No validation that the configured vmType supports Windows Claude

The PR adds WINDOWS to the accepted OS list but does not constrain VMType. VZ has no vTPM (issue #4852 calls this out explicitly) and WSL2 cannot host a Windows guest. A user can write os: Windows, vmType: vz and validation accepts it; the failure surfaces deep in driver startup. Reject the combination up front:

			errs = errors.Join(errs, fmt.Errorf("template requires Lima version %q; this is only %q", *y.MinimumLimaVersion, version.Version))
		}
	}

	switch *y.OS {
	case limatype.LINUX, limatype.DARWIN, limatype.FREEBSD, limatype.WINDOWS:
	default:
		errs = errors.Join(errs, fmt.Errorf("field `os` must be one of %q; got %q", limatype.OSTypes, *y.OS))
	}
	if !slices.Contains(limatype.ArchTypes, *y.Arch) {
		errs = errors.Join(errs, fmt.Errorf("field `arch` must be one of %v; got %q", limatype.ArchTypes, *y.Arch))
S3. Empty password for lima account + OpenSSH's PermitEmptyPasswords no default Gemini
        <LocalAccounts>
          <LocalAccount wcm:action="add">
            <Name>lima</Name>
            <DisplayName>lima</DisplayName>
            <Group>Administrators</Group>
            <Password>
              <Value></Value>
              <PlainText>true</PlainText>
            </Password>
          </LocalAccount>
        </LocalAccounts>
      </UserAccounts>
      <AutoLogon>
        <Enabled>true</Enabled>

The local account is created with an empty password so AutoLogon can run. Once I1 is fixed and authentication uses SSH keys, the empty password is acceptable. But corporate Group Policy frequently restricts empty-password accounts even for network logons that authenticate via keys; document the choice in the template's header comment and consider whether to generate a random password the user never needs to know.

S4. arch parameter to ExecuteTemplateAutounattend is typed as string instead of limatype.Arch Claude
	return layout, nil
}

// ExecuteTemplateAutounattend renders the Windows autounattend.xml template.
// arch should be "amd64" or "arm64". Validation is skipped since ValidateTemplateArgs is Unix-specific.
func ExecuteTemplateAutounattend(args *TemplateArgs, arch string) ([]byte, error) {
	var templateFS embed.FS
	var root string
	switch arch {
	case "arm64":
		templateFS = windowsArm64TemplateFS

The caller in cidata.go:395-401 maps limatype.AARCH64 to the string "arm64"; the dev tool in hack/gen-autounattend-iso.go accepts a -arch flag whose values are also "amd64"/"arm64". Push the dispatch inside ExecuteTemplateAutounattend and accept limatype.Arch directly. This centralizes the lookup, removes the duplicate switch, and lets the function return a typed error for unsupported architectures (see I5).

		return "", err
	}

	// Windows guests use autounattend.xml instead of cloud-init.
	if *instConfig.OS == limatype.WINDOWS {
		var winArch string
		if instConfig.Arch != nil && *instConfig.Arch == limatype.AARCH64 {
			winArch = "arm64"
		} else {
			winArch = "amd64"
		}
		return generateWindowsISO(args, instDir, winArch)
	}

	if err := ValidateTemplateArgs(args); err != nil {
		return "", err
	}
S5. Two arch directories invert the existing embed-naming convention Claude
//go:embed cidata.TEMPLATE.d
var templateFS embed.FS

const templateFSRoot = "cidata.TEMPLATE.d"

//go:embed cidata.TEMPLATE.d.Windows.amd64
var windowsAmd64TemplateFS embed.FS

const windowsAmd64TemplateFSRoot = "cidata.TEMPLATE.d.Windows.amd64"

//go:embed cidata.TEMPLATE.d.Windows.arm64
var windowsArm64TemplateFS embed.FS

const windowsArm64TemplateFSRoot = "cidata.TEMPLATE.d.Windows.arm64"

type CACerts struct {
	RemoveDefaults *bool
	Trusted        []Cert
}

The existing root is cidata.TEMPLATE.d/, with subdirectories like util.FreeBSD and boot.Linux. The new directories split on . at the top level. Nest the new directories under the existing root (cidata.TEMPLATE.d/autounattend.Windows.amd64/) or under a single Windows root (cidata.TEMPLATE.d.Windows/amd64/) to keep the embed pattern consistent and remove one of the two embed.FS declarations.

S6. args.IID is generated but never reaches the Windows ISO Claude

templateArgs() sets args.IID = fmt.Sprintf("iid-%d", time.Now().Unix()) on every call. The Linux path uses the IID to invalidate per-boot network config. The Windows path returns args.IID from generateWindowsISO, but the value is not embedded anywhere — it serves no purpose. Once I7 is fixed and Windows builds its own args, the IID can be generated locally or omitted; for now this is a symptom of the shared pipeline.

S7. GenerateISO9660's docstring no longer matches behavior Claude

	os.RemoveAll(filepath.Join(instDir, filenames.CloudConfig)) // delete existing
	return os.WriteFile(filepath.Join(instDir, filenames.CloudConfig), config, 0o444)
}

// GenerateISO9660 generates the cidata ISO9660 image (or directory, for noCloudInit)
// in instDir. It returns the instance ID, which changes on every boot.
func GenerateISO9660(ctx context.Context, drv driver.Driver, instDir, name string, instConfig *limatype.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort int, guestAgentBinary, nerdctlArchive string, vsockPort int, virtioPort string, noCloudInit, rosettaEnabled, rosettaBinFmt bool) (string, error) {
	args, err := templateArgs(ctx, true, instDir, name, instConfig, udpDNSLocalPort, tcpDNSLocalPort, vsockPort, virtioPort, noCloudInit, rosettaEnabled, rosettaBinFmt)
	if err != nil {
		return "", err
	}

The function now also produces an autounattend ISO (volume label autounattend, not cidata) for Windows. Update the comment so a reader does not have to inspect the body to discover the OS branch:

-// GenerateISO9660 generates the cidata ISO9660 image (or directory, for noCloudInit)
-// in instDir. It returns the instance ID, which changes on every boot.
+// GenerateISO9660 generates the cidata ISO9660 image (or directory, for noCloudInit)
+// in instDir, or an autounattend ISO for Windows guests. It returns the
+// instance ID, which changes on every boot.
S8. Local variable templateFS shadows the package-level templateFS Claude
}

// ExecuteTemplateAutounattend renders the Windows autounattend.xml template.
// arch should be "amd64" or "arm64". Validation is skipped since ValidateTemplateArgs is Unix-specific.
func ExecuteTemplateAutounattend(args *TemplateArgs, arch string) ([]byte, error) {
	var templateFS embed.FS
	var root string
	switch arch {
	case "arm64":
		templateFS = windowsArm64TemplateFS
		root = windowsArm64TemplateFSRoot

A package-level templateFS exists at line 21. Rename the local to fsys or similar so a reader does not mistake which templateFS is in scope.

	"github.com/lima-vm/lima/v2/pkg/limatype"
	"github.com/lima-vm/lima/v2/pkg/textutil"
)

//go:embed cidata.TEMPLATE.d
var templateFS embed.FS

const templateFSRoot = "cidata.TEMPLATE.d"

//go:embed cidata.TEMPLATE.d.Windows.amd64
var windowsAmd64TemplateFS embed.FS
S9. Inconsistent image-index expression between arches Claude

amd64 uses the short <OSImageIndex>6</OSImageIndex> directly under <OSImage>. arm64 uses the verbose <InstallFrom><MetaData><Key>/IMAGE/INDEX</Key> form. Both are valid; pick one for consistency. Also, hardcoding Pro forces the user to edit the template if they have a single-edition or Enterprise ISO. Surface this via TemplateArgs or a lima.yaml knob once the POC graduates.

S10. Unchecked os.Stat error in the dev tool Claude
	if err := iso9660util.Write(*outPath, "autounattend", layout, iso9660util.WithJoliet()); err != nil {
		fmt.Fprintf(os.Stderr, "error writing ISO: %v\n", err)
		os.Exit(1)
	}

	fi, _ := os.Stat(*outPath)
	fmt.Fprintf(os.Stdout, "Wrote %s (%d bytes)\n", *outPath, fi.Size())

	if *arch == "amd64" {
		printAmd64Usage(*outPath)
	} else {

If os.Stat returns an error, fi is nil and fi.Size() panics. The failure is unlikely on the happy path, but easy to remove: use len(...) over the encoded payload, or check the error. Minor — this is a dev tool, not production code.


Design Observations

Concerns

Strengths


Testing Assessment

The single new unit test (TestAutounattend) verifies that the rendered XML parses with xml.Unmarshal and contains a handful of fixed substrings. It does not exercise:

  1. The arm64 template (zero coverage) — see S1.
  2. Substitution of SSHPubKeys, Hostname, User, TimeZone — the test passes today because nothing is substituted. Once I1 is fixed, assertions on the rendered values would prevent regression back to placeholder strings.
  3. The generateWindowsISO function itself — no test exercises the end-to-end OS=Windows branch through GenerateISO9660.
  4. The OS+Arch validation combinations from I5 and S2.
  5. The PowerShell FirstLogonCommands content — a unit test cannot execute PowerShell on Linux/macOS CI, but a textual lint that rejects backtick escapes inside single-quoted strings would have caught I2 at CI time. The same lint could enforce that -replace patterns are anchored when modifying sshd_config.

xml.Unmarshal(output, new(any)) is a weak validator — it accepts any well-formed XML and does not validate against the unattend schema, so structural errors (mistyped element names, wrong namespaces, missing required children) pass silently. If practical, run the rendered XML through xsdvalidate against unattend.xsd from the Windows ADK, or parse the rendered output and assert on specific elements rather than substring-matching.

No test covers limactl create or limactl start for os: Windows end to end. I3 and I4 mean any such test would fail today — until the driver work lands, integration coverage stays at zero.


Documentation Assessment


Commit Structure

A single commit bundling the OS-type registration, the dispatcher seam, the two answer files, the dev tool, the unit test, and the validation changes. For a POC commit, bundling is appropriate — splitting into atomic commits adds review surface without aiding history. Once the POC graduates, the OS-type registration, the dispatcher, the templates, and the driver-device work are natural seams along which to split.


Acknowledged Limitations


Unresolved Feedback


Agent Performance Retro

Claude

Claude carried the review. It produced the largest set of well-grounded findings (1 critical-class issue at root-cause level — the templates-aren't-templates observation — and three further important issues), the unique PowerShell-escape catch (I2), and the only finding that traced the dependency between the broken templateArgs pipeline and the missing substitution (I7). Its severity calibration was the most honest of the cloud trio: it labeled the placeholder key as important rather than critical because the PR is explicitly a POC. Its prose included a faintly visible "Let me check a few more things before writing" preamble at the top of the file — a small leak from its planning into the final output that the prompt should address.

Codex

Codex was the only agent that traced the change across packages. Its two unique findings (I3, I4) — the QEMU device-graph mismatch and the Prepare/guest-agent gap — required reading pkg/instance/start.go, pkg/usrlocal/usrlocal.go, the Makefile, and the QEMU driver, none of which appear in the PR diff. Without Codex this review would have flagged the answer files but missed why limactl start --os Windows will fail in Prepare before any ISO is even read. Its severity calls were consistently sober; it produced no critical-class findings and only one suggestion, leaving room for the others' broader sweeps.

Gemini

Gemini caught the high-visibility placeholder-key issue and labeled it critical (the only critical-class call in the review). Consolidation downgraded the label to important to match the POC framing, but the call was useful: it forced the executive summary to lead with the issue rather than bury it. Gemini also flagged the silent dropping of mounts and provision (I6) and produced a clean fix diff for the arch-validation gap (I5). It missed the cross-component integration concerns that Codex caught and the PowerShell-escape bug that Claude caught; consistent with prior reviews, it did not use git blame (daily quota) to distinguish regression from pre-existing context.

Qwen

Qwen produced no usable output on either attempt. The first attempt hallucinated file paths under /Users/jan/.claude/projects/lima-review/... instead of using its actual worktree at /var/folders/.../review-qwen-pass-1-*; opencode auto-rejected the reads and the review file landed empty. The second attempt (manual retry) produced a non-conforming "stream of consciousness" doc with no Critical/Important/Suggestions structure, with seven half-formed findings (most marked as "No issue" by the agent itself) and one finding (arch fallback) that overlaps I5. No findings from Qwen survived consolidation.

Gemma

Gemma produced a short, technically clean review — one important and one suggestion — but with very limited surface coverage. It marked both autounattend.xml files as "Reviewed, no issues" while every cloud agent flagged at least one critical-class issue inside them. Its sole important finding ("missing validation for Windows guest arguments") is a true gap but a broad framing that misses the specific failures the cloud trio caught (placeholder keys, hardcoded user, PowerShell bug). Its arch-fallback suggestion duplicates I5.

Summary

Claude Opus 4.7Codex GPT 5.5Gemini 3.1 ProQwen 3.6 35BGemma 31B
Duration7m 15s5m 50s
Findings4I 9S3I 1S3I 1S1S1I
Tool calls31 (Bash 17, Read 10, Grep 4)46 (shell 46)
Design observations32101
False positives00000
Unique insights22100
Files reviewed99999
Coverage misses02184
Totals4I 9S3I 1S3I 1S1S1I
Downgraded001 (C→I)00
Dropped00000

Reconciliation: Gemini's C1 was downgraded to I1 (important) — the placeholder key is a real bug, but the PR's explicit POC framing and the author's stated "host-agent integration is follow-up" position make "critical" the wrong severity for the scope as defined; the substantive content of the finding remains identical, and it consolidates with the same observations from Claude and Codex.

The cloud trio carried this review. Claude provided the deepest single-file analysis (templates-aren't-templates, PowerShell escape, templateArgs pipeline coupling) and the highest finding density. Codex provided the irreplaceable cross-package integration view (Prepare switch, QEMU device graph) that no other agent reached. Gemini provided the calibration anchor (severity, scope) and the cleanest fix diffs. The local trio added nothing the cloud trio had not already raised; both failed at basic file navigation in their respective sandbox models, and both produced reviews that would have masked rather than highlighted the consequential issues in this PR. On the next deep review of a non-trivial PR, the local agents should probably stay off the pool until the worktree-anchoring issue (see Review Process Notes) is fixed.


Review Process Notes

Skill improvements

Repo context updates