Deep Review: 20260519-100641-pr-4985
| Date | 2026-05-19 10:06 |
| Repo | lima-vm/lima |
| Round | 1 |
| Author | @ashwat287 |
| PR | #4985 — POC: add Windows guest support via autounattend.xml |
| Commits | f3cd7c9b poc: add Windows guest support via autounattend.xml |
| 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 | Merge 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 time | 53 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 ¶
<?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").
-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:
- PowerShell single-quoted strings are literal, so
rninsideAdd-Content's argument writes four literal characters (backtick, r, backtick, n) instead of CRLF. sshd parses the resulting one-lineMatchblock as malformed and silently ignores it. - The
-replace '#?AuthorizedKeysFile.*'regex is unanchored. Windows OpenSSH shipssshd_configwith a defaultMatch Group administratorsblock whoseAuthorizedKeysFileline 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 ") and anchor the regex with ^. A simpler alternative overwrites sshd_config from a PowerShell array, removing the dependency on the existing file's layout.
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.
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.
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.
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.
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 ¶
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.
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))
<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.
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
}
//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.
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.
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.
}
// 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
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.
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 ¶
- The "template" infrastructure is unused (in-scope) Claude — Neither autounattend.xml contains a Go template directive, yet both are run through
textutil.ExecuteTemplateand called "templates." Either delete the templating step (treat them as plain embedded XML rendered withos.WriteFile) or actually substitute the parts that vary per instance — SSH keys, hostname, username, timezone, image index, EFI partition size, account password. The current state is the worst of both worlds: the dispatcher pretends to render, but every per-instance value is lost. This is the root cause of I1 and several suggestions. - The standalone hack tool tests a different system than
limactl startruns (in-scope) Codex —hack/gen-autounattend-iso.gouses the tested SATA/NVMe/USB device choices in itsprintAmd64Usage/printArm64Usageoutput. Production startup goes through the generic QEMU path (see I3). Moving the Windows device selection into the QEMU driver so the dev tool and production path share the same device graph would make the dev tool's tested commands serve as a contract the driver must match. templateArgsis doing two jobs at once (future) Claude —templateArgsis the Unix cloud-init prep stage and the single entry point for any future "build args for an OS-specific provisioning artifact." Splitting it intotemplateArgsCommon(Name, IID, Hostname, User, SSHPubKeys, TimeZone) andtemplateArgsUnix(everything else) would let the Windows path be self-contained and decouple it from the FreeBSD/9p/MountType branches. The TODO atpkg/cidata/template.go:89already flags this; I7 turns it into a concrete dependency for fixing I1.
Strengths ¶
- The dispatch on
*instConfig.OS == limatype.WINDOWSsits at the natural seam inGenerateISO9660, so the Linux/Darwin/FreeBSD paths are untouched Claude Codex. - Joliet labeling is correct, and the inline comment at
cidata.go:520-522explains why — ISO9660 Level 1's 8.3 truncation would hideautounattend.xmlfrom Windows Setup Claude Codex Gemini Gemma. - Using NVMe on arm64 instead of carrying
virtio-win.isois the right tradeoff: WinPE ARM64 hasstornvme.sysbuilt in, removing an external download and a license question Claude Gemma. - Keeping separate amd64 and arm64 templates is reasonable while the install device layout and image indexes differ. Once the substitution work in I1 lands, a single template with
{{ if eq .Arch "arm64" }}blocks would reduce divergence — but premature deduplication would tie the hands of arch-specific fixes Codex Gemma.
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:
- The arm64 template (zero coverage) — see S1.
- 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. - The
generateWindowsISOfunction itself — no test exercises the end-to-endOS=Windowsbranch throughGenerateISO9660. - The OS+Arch validation combinations from I5 and S2.
- The PowerShell
FirstLogonCommandscontent — 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-replacepatterns are anchored when modifyingsshd_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 ¶
- The PR description's claim that "OpenSSH server is installed and started during OOBE so Lima's host-agent can connect" is not yet true (see I1). Update the description to clarify that the POC writes a placeholder key and that host-agent integration is a follow-up — otherwise a reader of the merged commit will be misled about the state of the integration.
GenerateISO9660's docstring is out of date — see S7.- The
TODOatpkg/cidata/template.go:89("Split into per-OS template args (Unix vs Windows) to avoid carrying irrelevant fields") is appropriate; I7 expands on it. hack/gen-autounattend-iso.gois a useful developer aid but is not mentioned in any README orCONTRIBUTING.md. Once the POC graduates, document how a developer iterates on the answer files using this tool.- Once Windows is more than a POC, a
templates/windows.yamlexample and a docs page underwebsite/are expected by Lima's existing conventions.
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 ¶
- Code TODO —
pkg/cidata/template.go:89: "Split into per-OS template args (Unix vs Windows) to avoid carrying irrelevant fields." The author already flags this; I7 turns it into a concrete dependency for fixing I1. - Code comment —
pkg/cidata/template.go:223: *"Validation is skipped sinceValidateTemplateArgsis Unix-specific."* The comment is accurate today; a future pass should add Windows-specific validation rather than relying on caller discipline (relates to S2 and I5). - PR description — Explicitly tagged POC; testing was done on macOS via QEMU emulation (amd64) and HVF (arm64). Integration with
limactl start(driver wiring, vTPM, ISO acquisition) is out of scope for this PR, which makes I3 and I4 "expected gaps" rather than blocking bugs — but the gaps still need closing before Lima users can start a Windows guest.
Unresolved Feedback ¶
- @AkihiroSuda raised two inline comments on the previous revision warning against publishing product keys and KMS keys in the template. The author dropped the
<ProductKey>block in this revision (replaced with image-index selection), so the substance is addressed. No further action needed. - @AkihiroSuda's "Can we support the ARM version of Windows?" — addressed by the addition of the arm64 template in this revision.
- @unsuman's "Can you share info about your testing environment?" — answered by @ashwat287 in the same thread.
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.7 | Codex GPT 5.5 | Gemini 3.1 Pro | Qwen 3.6 35B | Gemma 31B | |
|---|---|---|---|---|---|
| Duration | 7m 15s | 5m 50s | — | — | — |
| Findings | 4I 9S | 3I 1S | 3I 1S | 1S | 1I |
| Tool calls | 31 (Bash 17, Read 10, Grep 4) | 46 (shell 46) | — | — | — |
| Design observations | 3 | 2 | 1 | 0 | 1 |
| False positives | 0 | 0 | 0 | 0 | 0 |
| Unique insights | 2 | 2 | 1 | 0 | 0 |
| Files reviewed | 9 | 9 | 9 | 9 | 9 |
| Coverage misses | 0 | 2 | 1 | 8 | 4 |
| Totals | 4I 9S | 3I 1S | 3I 1S | 1S | 1I |
| Downgraded | 0 | 0 | 1 (C→I) | 0 | 0 |
| Dropped | 0 | 0 | 0 | 0 | 0 |
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 ¶
- Inject each agent's actual worktree path into the prompt, colocated with the existing "Read only files inside the working tree" rule in
preamble.md. The rule alone is not enough — a model that does not know its own cwd may hallucinate plausible-looking paths under~/.claude/projects/...or similar. Pair the rule with a concrete value, in the same paragraph: "Your working directory is<path>. Read files using paths relative to this directory, or prefix them with this absolute path." When the rule and the value sit in two separate sections, smaller models follow the rule but resolve paths against a hallucinated cwd. Putting both in one sentence forces the model to use the real value. - Add a lint-style review dimension that names "PowerShell, shell, and other escape-sensitive content in static templates" as a thing to flag. The review prompt currently has language about "validation and error handling" and "security and correctness" but no specific cue to check that quoted strings in non-Go scripting languages (PowerShell, bash, batch, awk, sed) actually interpret the escapes the author appears to intend. A static template that looks plausible at a glance can contain a single-quoted PowerShell string with
rninside that won't expand — a category of bug that visual inspection misses unless the reviewer is primed to look for it.
Repo context updates ¶
- [repo] Lima POC PRs in this codebase are often labeled "POC" in the title and explicitly defer integration work to a follow-up. When reviewing a POC PR, separate "what the POC scaffolds" from "what the POC claims to do today" — the PR description's claims are part of the review surface and should be checked against the code. A PR that lands a misleading description is one a future reader will mistrust.