Deep Review: 20260424-190657-pr-4885
| Date | 2026-04-24 19:06 |
| Repo | lima-vm/lima |
| Round | 1 |
| Author | @jandubois |
| PR | #4885 — Windows: support native OpenSSH; no Git for Windows / MSYS2 required |
| Branch | windows-native-ssh-no-cygpath |
| Commits | e147b971 CI: read symlink targets from git object storebae5d205 CI: install templates for windows-plain-qemu1218a5d1 CI: split windows-plain by driver, add reverse-sshfs round-trip6e599d6e remove LIMAWINDOWSEXTRAPATH7474e1e1 CI: build the Linux guest agent and fail-loudly in windows-plain3e09daeb golangci: disable nolintlintf21ba237 client: silence flaky nolintlint at the grpc.Dial directive (reverted)d720cd4e ioutilx: drop unnecessary else after returning if branch07328ee2 add diagnosability for plain-Windows path9858c839 docs: reflect that plain Windows is now a supported host1f8a5303 CI: stop forcing Git for Windows on PATH; add plain-Windows smoke test8e56f7e7 ioutilx, hostagent: support reverse-sshfs on plain Windowsdf59f8ff downloader: decompress gzip in pure Go6924004a copytool: support native Windows OpenSSHbc99aca8 sshutil: detect Win32-OpenSSH version, export PathForSSHce3a0696 sshutil: skip cygpath on Windows when ssh is native OpenSSH |
| Reviewers | Claude Opus 4.7 (effort: xhigh), Codex GPT 5.5 (effort: xhigh), Gemini 3.1 Pro (effort: default) |
| Verdict | Merge with fixes — two real gaps in the new Windows-SSH logic (rsync probe and parseCopyPaths drive-letter detection); otherwise the design and testing are solid for a research PR. |
| Wall-clock time | 19 min 30 s |
Executive Summary ¶
This PR makes limactl usable on plain Windows 10/11 hosts by teaching sshutil to dispatch between native OpenSSH and Cygwin-based OpenSSH, stripping SSH mux options on Windows, decompressing gzip in-process, and adding two "plain Windows" CI jobs (one per driver). The refactor is scoped well and the research-PR framing is honest about what it does and does not cover.
Two real issues surfaced. The new rsync/scp mux-stripping is applied in rsync.Command and scp.Command but was omitted from the checkRsyncOnGuest probe, so an installed-but-gated rsync can be rejected on native Windows OpenSSH (I1). The drive-letter check in parseCopyPaths uses filepath.VolumeName instead of filepath.IsAbs, which silently breaks limactl copy for users whose instance is named with a single letter like C (I2) — a regression from the pre-PR code that used filepath.IsAbs.
The remaining items are documentation, logging, and defensive-coding suggestions. The author's DO NOT MERGE framing is appropriate for a research PR; the issues above are small enough to address in a follow-up before merging for real.
Structure: 0 Critical / 2 Important / 10 Suggestions.
Critical Issues ¶
None.
Important Issues ¶
}
return true
}
func checkRsyncOnGuest(ctx context.Context, inst *limatype.Instance) bool {
sshExe, err := sshutil.NewSSHExe()
if err != nil {
logrus.Debugf("failed to create SSH executable: %v", err)
return false
}
sshOpts, err := sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false)
if err != nil {
logrus.Debugf("failed to get SSH options for rsync check: %v", err)
return false
}
sshArgs := append([]string{}, sshExe.Args...)
sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...)
checkCmd := exec.CommandContext(ctx, sshExe.Exe, sshArgs...)
checkCmd.Args = append(checkCmd.Args,
"-p", fmt.Sprintf("%d", inst.SSHLocalPort),
*inst.Config.User.Name+"@"+inst.SSHAddress,
"command -v rsync >/dev/null 2>&1",
)
err = checkCmd.Run()
return err == nil
}
func (t *rsyncTool) Command(ctx context.Context, paths []string, opts *Options) (*exec.Cmd, error) {
copyPaths, err := parseCopyPaths(ctx, paths)
if err != nil {
return nil, err
rsyncTool.Command at pkg/copytool/rsync.go:131-138 and scpTool.Command at pkg/copytool/scp.go:125-134 both call sshutil.SSHOptsRemovingControlPath on Windows. The probe at pkg/copytool/rsync.go:67 builds the same sshOpts but skips that step, so the probe inherits ControlMaster=auto, ControlPath=…, and ControlPersist=yes from SSHOpts.
}
sshOpts, err := sshutil.SSHOpts(ctx, sshExe, cp.Instance.Dir, *cp.Instance.Config.User.Name, false, false, false, false)
if err != nil {
return nil, err
}
if runtime.GOOS == "windows" {
// See the equivalent comment in scp.go. Strip mux options
// so rsync's ssh transport does not try to use a
// ControlMaster socket that is unavailable or unreliable
// on Windows.
logrus.Debug("rsync: stripping ControlMaster/ControlPath/ControlPersist (Windows)")
sshOpts = sshutil.SSHOptsRemovingControlPath(sshOpts)
}
sshArgs := []string{sshExe.Exe}
sshArgs = append(sshArgs, sshExe.Args...)
sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...)
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", cp.Instance.SSHLocalPort))
sshOpts, err = sshutil.CommonOpts(ctx, sshExe, false)
if err != nil {
return nil, err
}
}
if runtime.GOOS == "windows" {
// Strip ControlMaster/ControlPath/ControlPersist so that scp invokes
// ssh without trying to use SSH connection multiplexing. Native
// Windows OpenSSH does not support multiplexing (Unix-domain mux
// socket), and Cygwin-based ssh has known reliability issues with
// sftp over a mux socket. See also the equivalent handling in
// hostagent and limactl shell.
logrus.Debug("scp: stripping ControlMaster/ControlPath/ControlPersist (Windows)")
sshOpts = sshutil.SSHOptsRemovingControlPath(sshOpts)
}
sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
return exec.CommandContext(ctx, t.toolPath, append(sshArgs, scpArgs...)...), nil
}
Native Windows OpenSSH does not support Unix-domain mux sockets (PR description). Cygwin-based ssh has documented reliability issues over a mux socket (pkg/copytool/scp.go:126-131). The probe therefore hits the same failure mode the Command method works around. When a user installs rsync on a plain-Windows host (e.g., via winget), the probe fails before running command -v rsync on the guest, and BackendAuto falls back to scp while BackendRsync reports "rsync not available on guest(s)". go test ./pkg/copytool/... does not cover this path because CI does not install rsync on the Windows runners.
if err != nil {
return nil, err
}
}
if runtime.GOOS == "windows" {
// Strip ControlMaster/ControlPath/ControlPersist so that scp invokes
// ssh without trying to use SSH connection multiplexing. Native
// Windows OpenSSH does not support multiplexing (Unix-domain mux
// socket), and Cygwin-based ssh has known reliability issues with
// sftp over a mux socket. See also the equivalent handling in
// hostagent and limactl shell.
logrus.Debug("scp: stripping ControlMaster/ControlPath/ControlPersist (Windows)")
sshOpts = sshutil.SSHOptsRemovingControlPath(sshOpts)
}
sshArgs := sshutil.SSHArgsFromOpts(sshOpts)
Fix: apply the same mux-stripping in checkRsyncOnGuest:
sshOpts, err := sshutil.SSHOpts(ctx, sshExe, inst.Dir, *inst.Config.User.Name, false, false, false, false)
if err != nil {
logrus.Debugf("failed to get SSH options for rsync check: %v", err)
return false
}
+if runtime.GOOS == "windows" {
+ sshOpts = sshutil.SSHOptsRemovingControlPath(sshOpts)
+}
sshArgs := append([]string{}, sshExe.Args...)
sshArgs = append(sshArgs, sshutil.SSHArgsFromOpts(sshOpts)...)
cp := &Path{}
// On Windows, detect local drive-letter paths (e.g. C:\...) before
// splitting on ":" so the drive letter is not mistaken for an instance
// name. filepath.VolumeName returns "C:" for local drive paths and ""
// for instance:path forms like "nat:/tmp/file".
isLocalAbs := runtime.GOOS == "windows" && filepath.VolumeName(path) != ""
if isLocalAbs {
sshExe, err := sshutil.NewSSHExe()
if err != nil {
return nil, err
}
On Windows filepath.VolumeName("C:foo.txt") returns "C:" (drive-relative paths still carry a volume). The pre-PR code at the merge-base used filepath.IsAbs(path), which correctly returns false for "C:foo.txt" and routes the string into the colon-split where it is recognised as a remote path on instance C. After this change, limactl copy C:foo.txt . on an instance named C is mis-parsed as a local Windows path and runs through PathForSSH instead of being routed to the guest.
Single-letter instance names are rare but valid, and the failure is silent (cygpath will happily translate the bogus path). The fix restores pre-PR behaviour for the instance-name case while keeping the intended bypass for C:\foo and C:/foo, which filepath.IsAbs accepts on Windows. UNC paths also pass filepath.IsAbs and therefore remain routed through PathForSSH, matching the pre-PR behaviour.
Fix:
-isLocalAbs := runtime.GOOS == "windows" && filepath.VolumeName(path) != ""
+isLocalAbs := runtime.GOOS == "windows" && filepath.IsAbs(path)
A unit test on parseCopyPaths with C:\foo, C:/foo, C:foo, and instance:path would lock the intended branching in place.
Suggestions ¶
sshExe.Exe = executable
return sshExe, nil
}
var cygwinDetect struct {
sync.Once
isCygwin bool
}
// IsSSHCygwin reports whether sshExe is a Cygwin-based build of OpenSSH
// (e.g. Git for Windows, MSYS2, or Cygwin itself), as opposed to native
// Windows OpenSSH. The detection is based on whether cygpath.exe lives in
// the same directory as ssh.exe, which is the layout used by Git for Windows
// and MSYS2. Always returns false on non-Windows.
//
// The first call's result is cached and logged at Debug level. Subsequent
// calls return the same answer regardless of the sshExe passed; in practice
// limactl uses a single ssh binary throughout a process, so this is fine
// and avoids spamming the log when toolchain-aware code paths run repeatedly.
func IsSSHCygwin(sshExe SSHExe) bool {
if runtime.GOOS != "windows" || sshExe.Exe == "" {
return false
}
cygwinDetect.Do(func() {
path := sshExe.Exe
if !filepath.IsAbs(path) {
resolved, err := exec.LookPath(path)
if err != nil {
logrus.WithError(err).Debugf("IsSSHCygwin: cannot resolve %q via PATH; assuming native", sshExe.Exe)
return
}
path = resolved
}
cygpathPath := filepath.Join(filepath.Dir(path), "cygpath.exe")
if _, err := os.Stat(cygpathPath); err == nil {
cygwinDetect.isCygwin = true
logrus.Debugf("ssh at %q detected as Cygwin-based (found %q alongside)", path, cygpathPath)
} else {
logrus.Debugf("ssh at %q detected as native Windows OpenSSH (no cygpath.exe alongside)", path)
}
})
return cygwinDetect.isCygwin
}
// PathForSSH converts orig to the path form expected by the ssh family of
// binaries (ssh, ssh-keygen, scp, sftp, rsync-over-ssh) that Lima will invoke.
// On non-Windows the path is returned unchanged. On Windows:
// - For Cygwin-based ssh (Git for Windows, MSYS2): runs cygpath to produce a
The docstring documents this: "Subsequent calls return the same answer regardless of the sshExe passed". The concern is blast radius, not correctness in the current flow. A unit test that wants to exercise both the Cygwin and native branches of PathForSSH in one binary cannot do so, and any future caller that resets SSH mid-process would silently keep the old detection result.
The cache in openSSHInfos at pkg/sshutil/sshutil.go:479-481 already uses map[sshExecutable]*openSSHInfo guarded by an sync.RWMutex. Mirror that pattern for cygwin detection: key by the resolved absolute ssh path and drop sync.Once. The change is ~10 lines and removes the "first call wins" subtlety.
ModTime time.Time
}
var (
// openSSHInfos caches the parsed version and supported options of each ssh executable, if it is needed again.
openSSHInfos = map[sshExecutable]*openSSHInfo{}
openSSHInfosRW sync.RWMutex
)
func detectOpenSSHInfo(ctx context.Context, sshExe SSHExe) openSSHInfo {
var (
info openSSHInfo
exe sshExecutable
case "$NAME" in
"default")
- # CI failure:
- # "[hostagent] failed to confirm whether /c/Users/runneradmin [remote] is successfully mounted"
- [ "${OS_HOST}" = "Msys" ] && CHECKS["mount-home"]=
[ "${OS_HOST}" = "Darwin" ] && CHECKS["ssh-over-vsock"]="1"
The PR description frames re-enabling this check as a probe to see whether the path-translation fixes also resolve the pre-existing Msys-side failure. Removing the explanatory comment along with the skip means a future reader who hits the same failure has to reconstruct the history via git blame. Keep the comment as # Was previously skipped on Msys; see PR #4885 so the context survives the next regression.
// and avoids spamming the log when toolchain-aware code paths run repeatedly.
func IsSSHCygwin(sshExe SSHExe) bool {
if runtime.GOOS != "windows" || sshExe.Exe == "" {
return false
}
cygwinDetect.Do(func() {
path := sshExe.Exe
if !filepath.IsAbs(path) {
resolved, err := exec.LookPath(path)
if err != nil {
logrus.WithError(err).Debugf("IsSSHCygwin: cannot resolve %q via PATH; assuming native", sshExe.Exe)
return
}
path = resolved
}
cygpathPath := filepath.Join(filepath.Dir(path), "cygpath.exe")
if _, err := os.Stat(cygpathPath); err == nil {
cygwinDetect.isCygwin = true
logrus.Debugf("ssh at %q detected as Cygwin-based (found %q alongside)", path, cygpathPath)
} else {
logrus.Debugf("ssh at %q detected as native Windows OpenSSH (no cygpath.exe alongside)", path)
}
})
return cygwinDetect.isCygwin
}
// PathForSSH converts orig to the path form expected by the ssh family of
// binaries (ssh, ssh-keygen, scp, sftp, rsync-over-ssh) that Lima will invoke.
exec.LookPath returns the path as it appears in PATH. If that entry is a symlink or a shim (chocolatey, scoop, user shortcut), filepath.Dir points at the shim's directory, not the real binary's neighbourhood, and the Cygwin detection returns the wrong answer. A filepath.EvalSymlinks(path) before filepath.Dir aligns the check with the actual binary location.
return slices.DeleteFunc(copiedOpts, func(s string) bool {
return strings.HasPrefix(s, "ControlMaster") || strings.HasPrefix(s, "ControlPath") || strings.HasPrefix(s, "ControlPersist")
})
}
func ParseOpenSSHVersion(version []byte) *semver.Version {
// Matches "OpenSSH_8.4p1 ..." (upstream) and "OpenSSH_for_Windows_9.5p2 ..."
// (Win32-OpenSSH). The optional "_for_Windows" prefix is recognized so native
// Windows OpenSSH is not misdetected as version 0.0.0.
regex := regexp.MustCompile(`(?m)^OpenSSH(?:_for_Windows)?_(\d+\.\d+)(?:p(\d+))?\b`)
matches := regex.FindSubmatch(version)
if len(matches) == 3 {
if len(matches[2]) == 0 {
matches[2] = []byte("0")
}
return semver.New(fmt.Sprintf("%s.%s", matches[1], matches[2]))
}
return &semver.Version{}
}
func parseOpenSSHGSSAPISupported(version string) bool {
return !strings.Contains(version, `Unsupported option "gssapiauthentication"`)
}
The returned 0.0.0 gates cipher selection and scp URL form. An unrecognised banner (a future fork, a HPN-SSH build, a wrapper that rewrites ssh -V output) silently downgrades behaviour without any signal in --debug logs. One logrus.Debugf line in the fallback branch makes the cause traceable.
if len(matches) == 3 {
...
return semver.New(fmt.Sprintf("%s.%s", matches[1], matches[2]))
}
+ logrus.Debugf("ParseOpenSSHVersion: no match in %q; returning 0.0.0", string(version))
return &semver.Version{}
The variable is removed from both code and docs. Users who set it in a shell profile get no signal that it stopped doing anything. One release of a transitional warning would flag the mismatch:
DefaultInstanceName = "default"
basicCommand = "basic"
advancedCommand = "advanced"
)
func main() {
if os.Geteuid() == 0 && (len(os.Args) < 2 || os.Args[1] != "generate-doc") {
fmt.Fprint(os.Stderr, "limactl: must not run as the root user\n")
os.Exit(1)
}
yq.MaybeRunYQ()
err := newApp().Execute()
server.StopAllExternalDrivers()
osutil.HandleExitError(err)
if err != nil {
logrus.Fatal(err)
}
}
func processGlobalFlags(rootCmd *cobra.Command) error {
// --log-level will override --debug, but will not reset debugutil.Debug
if debug, _ := rootCmd.Flags().GetBool("debug"); debug {
logrus.SetLevel(logrus.DebugLevel)
$resolved = $posixPath
$depth = 0
while ($linkTargets.ContainsKey($resolved)) {
if ($depth++ -gt 16) { throw "Symlink chain too deep starting from $posixPath" }
$target = $linkTargets[$resolved]
$slash = $resolved.LastIndexOf('/')
$resolved = if ($slash -ge 0) { "$($resolved.Substring(0, $slash))/$target" } else { $target }
$segs = New-Object System.Collections.ArrayList
foreach ($s in ($resolved -split '/')) {
if ($s -eq '' -or $s -eq '.') { continue }
if ($s -eq '..') {
if ($segs.Count -gt 0) { $segs.RemoveAt($segs.Count - 1) | Out-Null }
continue
}
$segs.Add($s) | Out-Null
}
$resolved = $segs -join '/'
}
$srcFile = $resolved -replace '/', '\'
$dstFile = Join-Path '_output\share\lima' ($posixPath -replace '/', '\')
Write-Host "Resolving symlink: $posixPath -> $resolved"
Copy-Item -Force -Path $srcFile -Destination $dstFile
}
- name: Scrub PATH and verify only native Windows toolchain is reachable
run: |
$ErrorActionPreference = 'Stop'
Write-Host "PATH before scrub:"
$env:PATH -split ';' | ForEach-Object { Write-Host " $_" }
The loop silently swallows .. underflow. A symlink chain with enough .. segments produces a path relative to the repo root; the Copy-Item on line 552 then copies whichever file is there into _output\share\lima\templates\…. Lima's templates do not use such symlinks today, so this is defensive only, but a single post-loop check (if ($resolved -notlike "$srcRoot/*") { throw ... }) closes the loop on future template edits.
sshfsOptions += ",follow_symlinks"
}
logrus.Infof("Mounting %q on %q", m.Location, *m.MountPoint)
resolvedLocation := m.Location
if runtime.GOOS == "windows" {
// Convert the host path to the form the sftp-server invoked by sshocker
// expects. PathForSSH tracks the toolchain Lima is using: Cygwin-style
// (e.g. /c/Users/jan) when ssh comes from Git for Windows / MSYS2 (where
// sftp-server is also Cygwin-based), and native forward-slash form
// (e.g. C:/Users/jan) when ssh is native Windows OpenSSH.
sshExe, err := sshutil.NewSSHExe()
if err != nil {
return nil, err
}
resolvedLocation, err = sshutil.PathForSSH(ctx, sshExe, m.Location)
if err != nil {
return nil, err
}
logrus.Debugf("reverse-sshfs: host path %q resolved to %q for sftp-server", m.Location, resolvedLocation)
}
sshAddress, sshPort := a.sshAddressPort()
// Create a copy of sshConfig to avoid
// modifying HostAgent's sshConfig in case of Windows
sshConfig := *a.sshConfig
The PR description already flags this: mount.go resolves LocalPath with PathForSSH (toolchain-aware) on every start, while pkg/limayaml/defaults.go computes the default MountPoint once at create time via WindowsSubsystemPath (Cygwin-style, no fallback variation). A user who creates an instance with Git for Windows on PATH and then removes it would see LocalPath change between restarts. Either document "do not swap Windows SSH toolchain between create and start" in the WSL2/QEMU docs, or persist the resolved LocalPath at create time so the restart path does not recompute it.
out, err := exec.CommandContext(ctx, "cygpath", filepath.ToSlash(orig)).CombinedOutput()
if err == nil {
return strings.TrimSpace(string(out)), nil
}
logrus.WithError(err).Debugf("cygpath unavailable for %q, attempting native conversion", orig)
if vol := filepath.VolumeName(orig); len(vol) == 2 && vol[1] == ':' {
converted := "/" + strings.ToLower(vol[:1]) + filepath.ToSlash(orig[2:])
logrus.Debugf("native cygpath fallback: %q -> %q", orig, converted)
return converted, nil
}
return "", fmt.Errorf("cannot convert %q to a Cygwin-style path: cygpath unavailable and input is not a drive-letter path", orig)
}
func WindowsSubsystemPathForLinux(ctx context.Context, orig, distro string) (string, error) {
out, err := exec.CommandContext(ctx, "wsl", "-d", distro, "--exec", "wslpath", filepath.ToSlash(orig)).CombinedOutput()
orig[2:] is expected to begin with a separator. For orig = "C:foo", the result is "/cfoo" — not a valid Cygwin-style path. All current callers pass absolute paths, but the API contract is fragile for future callers. Insert the slash explicitly:
-converted := "/" + strings.ToLower(vol[:1]) + filepath.ToSlash(orig[2:])
+converted := "/" + strings.ToLower(vol[:1]) + "/" + strings.TrimPrefix(filepath.ToSlash(orig[2:]), "/")
- When running lima using "wsl2", `${LIMA_HOME}/<INSTANCE>/serial.log` will not contain kernel boot logs
- WSL2 requires a `tar` formatted rootfs archive instead of a VM image
### External tools
Lima uses native Windows OpenSSH (`C:\Windows\System32\OpenSSH\`, included in
Windows 10 build 1803 and later) for `ssh`, `scp`, `ssh-keygen`, and `sftp-server`.
No additional Cygwin-style toolchain is required.
Installing [Git for Windows](https://gitforwindows.org/) (`winget install -e --id Git.MinGit`)
remains supported. Lima detects when ssh is a Cygwin-based build and uses
`cygpath` for path translation in that case, which respects any custom MSYS2
fstab the user has configured. On a vanilla Windows install with neither Git
OpenSSH Client (ssh, scp, ssh-keygen) ships by default on Windows 10 build 1803+, but OpenSSH Server — which provides sftp-server.exe — is an optional Feature on Demand (https://learn.microsoft.com/en-us/windows-server/administration/OpenSSH/openssh-overview). The new CI at .github/workflows/test.yml:357 and :584 treats sftp-server as required, so the reverse-sshfs path assumes the server component is installed. Users without it will see a mismatch between the docs and runtime behaviour. Soften to: Lima works with native Windows OpenSSH when the required components are installed; QEMU with reverse-sshfs additionally requires sftp-server.exe from the OpenSSH Server optional feature.
# required-native: must come from C:\Windows or be a built-in like wsl.
# forbidden: must NOT resolve at all on a "plain Windows" host.
# optional: may or may not be present; logged for context. Lima has
# graceful fallbacks (id, rsync) or only needs them for
# non-default code paths (qemu-img, decompressors).
$required = @('ssh','scp','ssh-keygen','sftp-server','wsl','tar')
$forbidden = @{
'cygpath' = 'cygpath comes from MSYS2/Git-for-Windows/Cygwin only'
'pacman' = 'pacman comes from MSYS2 only'
}
# bash.exe is interesting but ambiguous: Windows 10/11 ship a WSL
cp := &Path{}
// On Windows, detect local drive-letter paths (e.g. C:\...) before
// splitting on ":" so the drive letter is not mistaken for an instance
// name. filepath.VolumeName returns "C:" for local drive paths and ""
// for instance:path forms like "nat:/tmp/file".
isLocalAbs := runtime.GOOS == "windows" && filepath.VolumeName(path) != ""
if isLocalAbs {
sshExe, err := sshutil.NewSSHExe()
if err != nil {
return nil, err
}
path, err = sshutil.PathForSSH(ctx, sshExe, path)
if err != nil {
return nil, err
}
cp.Path = path
cp.IsRemote = false
copyPaths = append(copyPaths, cp)
continue
}
if runtime.GOOS == "windows" {
path = filepath.ToSlash(path)
}
parts := strings.SplitN(path, ":", 2)
filepath.VolumeName(\\server\share\foo) returns \\server\share on Windows, so UNC paths hit this branch. On native OpenSSH they pass through filepath.ToSlash as //server/share/foo, which scp may or may not accept. On the Cygwin branch ioutilx.WindowsSubsystemPath falls back to the drive-letter handler (len(vol) == 2) and returns the new explicit error. UNC support is neither exercised in tests nor mentioned in the comment. Either document the intent (// UNC paths flow through PathForSSH; native ssh accepts //server/share/foo) or reject UNC early with a clean error. The fix for I2 (switching to filepath.IsAbs) preserves this branching because UNC paths are absolute, so UNC behaviour is unchanged either way — but the contract remains worth documenting.
Design Observations ¶
Concerns
- Toolchain detection is implicit (in-scope) Claude.
IsSSHCygwinequates "iscygpath.exenext tossh.exe?" with "is this ssh a Cygwin build?". The heuristic works for the two real toolchains (Git for Windows, MSYS2) and is documented, but a user who dropscygpath.exeintoC:\Windows\System32\OpenSSH\for unrelated reasons would flip native OpenSSH to be mis-detected.detectOpenSSHInfoalready runsssh -V; the banner (OpenSSH_for_Windows_…vs.OpenSSH_…p… Cygwin|MSYS) is a more direct signal. - Mux-stripping duplicated across call sites (future) Claude.
scp.Command,rsync.Command,hostagent/mount.go, and the probe incheckRsyncOnGuesteach decide whether to stripControlMaster/ControlPath/ControlPersiston Windows using two slightly different helpers (SSHOptsRemovingControlPath,DisableControlMasterOptsFromSSHArgs). A single choke point —SSHOptstaking awithControlMaster bool, or a dedicated "give me Windows-safe SSH options" constructor — would prevent the inconsistency that surfaced as I1 and make future additions uniform.
Strengths
- The
PathForSSH/IsSSHCygwinpair cleanly separates "which path form should ssh consume?" from "is the toolchain Cygwin-based?", and either function is reusable independently Codex Claude. - Moving gzip decompression in-process via
bar.NewProxyReader(in)removes a high-frequency external dependency without changing the rest of the decompressor dispatch or the progress-bar UX Codex Gemini. - The new
TestParseOpenSSHVersioncase forOpenSSH_for_Windows_9.5p2guards against the exact silent-0.0.0 regression the regex extension was meant to fix Claude. - The two plain-Windows CI jobs print
PATHbefore and after scrubbing, inventory required/forbidden/optional binaries, assertsshresolves toC:\Windows\System32\OpenSSH\ssh.exe, and dumpha.stderr.log/ha.stdout.log/serial.logon failure — so CI failures are self-diagnosable without a local Windows repro Claude.
Testing Assessment ¶
New test coverage is narrow but targeted:
TestParseOpenSSHVersionadds the Win32-OpenSSH banner as a positive case.- The two new CI jobs (
windows-plain-wsl2,windows-plain-qemu) exercisecreate → start → shell → copy → stop → deleteend-to-end, plus a reverse-sshfs round-trip in the QEMU variant.
Gaps, in order of risk:
checkRsyncOnGueston native Windows OpenSSH with rsync present is not exercised anywhere; I1 would not be caught by any current test.parseCopyPathshas no unit tests for the new Windows branching; a table test overC:\foo,C:/foo,C:foo,instance:path, and\\server\share\foowould lock I2 in place and clarify UNC behaviour (S10).IsSSHCygwincache behaviour across repeated calls with differentsshExearguments is documented but not asserted — a future refactor that expects per-call detection would regress silently.- The native
WindowsSubsystemPathfallback has no unit test; S8's relative-drive edge case would surface immediately under a simple table test.
The review agent ran go test ./pkg/sshutil ./pkg/copytool ./pkg/downloader ./pkg/ioutilx ./pkg/limayaml and it passed.
Documentation Assessment ¶
website/content/en/docs/config/vmtype/wsl2.mdis updated to reflect the new external-tools story. S9 softens the "included" wording so thesftp-serverrequirement is explicit.website/content/en/docs/config/environment-variables.mddrops the_LIMA_WINDOWS_EXTRA_PATHentry, consistent with the code removal.- No documentation covers the QEMU-on-Windows story separately from WSL2. The reverse-sshfs fallback and mux-stripping behaviour are only discoverable by reading the code. A short "Windows hosts" note in
config/vmtype/_index.mdwould close that gap — deferrable to the non-research follow-up. website/content/en/docs/installation/_index.mdstill says "Windows (untested)" (pre-existing). Since this PR promotes plain Windows to a tested host via the new CI jobs, consider revising that line in the same branch.
Commit Structure ¶
f21ba237(client: silence flaky nolintlint at the grpc.Dial directive) and3e09daeb(golangci: disable nolintlint) form a revert pair; the PR description acknowledges this. Squash before the real merge.d720cd4e(ioutilx: drop unnecessary else after returning if branch) and07328ee2(add diagnosability for plain-Windows path) tweak code introduced in the same branch; folding them into the originating commit keeps bisect clean.- Otherwise the per-commit layout is clean and each commit stands alone. The branch is easy to bisect on a per-driver basis.
Acknowledged Limitations ¶
limactl shell --syncon plain Windows continues to fail hard atcmd/limactl/shell.go:223. The PR description spells out theBackendAuto/ scp fallback plan and why it is deliberately out of scope here (--deleteand--itemize-changessemantics).nolintlintis disabled in.golangci.ymlto work around golangci-lint cache nondeterminism. The PR identifies thegrpc.Dial → grpc.NewClientmigration as the proper fix.hack/test-templates.shremains MSYS2-only for its own execution (bash arrays, netcat, cygpath); rewriting it for plain Windows is a separate project.- The fstab-aware divergence in
hostagent/mount.go'sLocalPathbetween Cygwin and native toolchains is flagged in the PR body; S7 extends this with a concrete toolchain-swap scenario. - Decompressors other than gzip (bzip2, xz, zstd) still shell out; the PR explicitly notes gzip was the one that mattered for the default Windows flow.
Unresolved Feedback ¶
No PR review comments have been posted. Prior feedback from #4819:
- @AkihiroSuda's question about eliminating the Git for Windows dependency is directly answered by this PR.
- @arixmkii's custom-MSYS2-fstab concern is addressed by the toolchain-aware dispatch: Cygwin ssh still routes through
cygpath, preserving custom fstabs.
Agent Performance Retro ¶
Claude ¶
Claude produced the broadest review: one strong Important (I1, co-raised with Codex) and seven unique suggestions spanning symlink resolution, logging gaps, CI hardening, and documentation follow-ups. The I2 "gzip Close() swallows errors" finding was a false positive — gzip.Reader.Close just re-returns z.err, and the CRC/length error is surfaced from the last Read() call during io.Copy, so the code correctly returns it. I3 (IsSSHCygwin sync.Once) was downgraded to S1: the docstring documents the tradeoff, so "important" overstates it. Everything else was sound and well-anchored to line numbers.
Codex ¶
Codex produced a tight, focused review: one Important (I1) that overlapped with Claude's, and one Suggestion (S9) about the docs misrepresenting bundled OpenSSH components. Codex ran go test ./pkg/{sshutil,copytool,downloader,ioutilx,limayaml} and reported the results. The narrower scope reflects a pragmatic severity bar — nothing here was padding.
Gemini ¶
Gemini's Important (I2) was the single best find of the review: a subtle regression where switching from filepath.IsAbs to filepath.VolumeName breaks single-letter instance names. Gemini traced the merge-base behaviour, called out the silent failure mode, and proposed a one-line fix. The accompanying S8 (relative-drive edge case in the native cygpath fallback) is the same class of issue in a different file. Gemini did not run git blame — expected, given its quota constraints.
Summary ¶
| Claude Opus 4.7 | Codex GPT 5.5 | Gemini 3.1 Pro | |
|---|---|---|---|
| Duration | 12m 52s | 5m 25s | 7m 46s |
| Findings | 1I 8S | 1I 1S | 1I 1S |
| Tool calls | 50 (Bash 25, Read 21, Grep 3) | 41 (shell 41) | 11 (readfile 7, grepsearch 4) |
| Design observations | 6 | 2 | 2 |
| False positives | 1 | 0 | 0 |
| Unique insights | 7 | 1 | 2 |
| Files reviewed | 14 | 14 | 14 |
| Coverage misses | 0 | 0 | 0 |
| Totals | 1I 8S | 1I 1S | 1I 1S |
| Downgraded | 1 (I→S) | 0 | 0 |
| Dropped | 1 | 0 | 0 |
Reconciliation:
- Claude I2 "gzip Close() swallows errors" → dropped as false positive.
gzip.Reader.Closereturnsz.err, which is already set by the finalRead()that checks the trailer;io.Copyreturns that error. - Claude I3 "IsSSHCygwin
sync.Once" → downgraded to S1. The docstring documents the tradeoff explicitly, and no current caller trips it.
Claude delivered the most total value through breadth and CI-side findings, but Gemini's I2 is the single most impactful catch of the review — a silent correctness regression that neither of the others identified. Codex was efficient and its I1 converged with Claude's, confirming the rsync-probe gap with independent analysis.
Review Process Notes ¶
Skill improvements
- When reviewing a change that adds a helper function applied at multiple call sites (e.g., mux-stripping, path translation, error wrapping), enumerate every call site in the touched packages that uses the same precondition and check whether the helper is applied consistently. Helpers introduced to fix a narrow symptom are easy to leave off one path when the symptom did not manifest there during local testing. Grep for the helper's name and for the
runtime.GOOSbranch it guards, and cross-check the list against all functions that build equivalent arguments — because an inconsistency between the "do X" call site and the "check whether X works" call site is a silent regression that local runs rarely reveal. - When a diff replaces a
filepath.IsAbscheck with a different absoluteness test (e.g.,filepath.VolumeName != ""), verify that the replacement agrees with the original on drive-relative paths, UNC paths, and bare drive letters. Go'sfilepath.VolumeNameaccepts drive-relative inputs likeC:foothatfilepath.IsAbsrejects, so the switch can silently change how paths split downstream.
Repo context updates
- [repo] When reviewing Windows-related code paths in Lima, note that the Windows SSH landscape has two valid toolchains (native OpenSSH, Cygwin-based OpenSSH via Git for Windows or MSYS2) with different path conventions and mux-socket semantics. Code paths that shell out to
ssh,scp,ssh-keygen, orsftp-servermust handle both and any probe that builds SSH options must stripControlMaster/ControlPath/ControlPersiston Windows, because native OpenSSH does not support Unix-domain mux sockets and Cygwin-based ssh has known reliability issues over one. The helpers aresshutil.PathForSSHfor path translation andsshutil.SSHOptsRemovingControlPath/sshutil.DisableControlMasterOptsFromSSHArgsfor mux stripping.