Skip to content

Windows: support native OpenSSH; no Git for Windows / MSYS2 required#4885

Closed
jandubois wants to merge 21 commits into
lima-vm:masterfrom
jandubois:windows-native-ssh-no-cygpath
Closed

Windows: support native OpenSSH; no Git for Windows / MSYS2 required#4885
jandubois wants to merge 21 commits into
lima-vm:masterfrom
jandubois:windows-native-ssh-no-cygpath

Conversation

@jandubois
Copy link
Copy Markdown
Member

@jandubois jandubois commented Apr 24, 2026

DO NOT MERGE

This PR has been created with Claude Code using Opus 4.7.

It is for discussion and testing purposes only and has not been reviewed yet!

Summary

This branch makes limactl work on Windows hosts that have only the toolchain shipped in a default Windows 10/11 install (native OpenSSH, wsl.exe, tar). Lima previously required Git for Windows or MSYS2 on PATH for cygpath, ssh, ssh-keygen, scp, and gzip. After this branch, none of those external tools are required for the core flow on plain Windows.

The PR is offered as research / discussion. The goal is to surface what does and does not work end-to-end, get CI signal across the existing Windows runners plus two new "plain Windows" runners (one per driver), and decide together how much of this we want to land.

Related: #4819. The originating thread there narrowed to "drop the cygpath.exe dependency"; investigating that turned up the broader story below.

Background — what was actually required, and why

Two coupled root causes drove the historical Git-for-Windows / MSYS2 requirement:

  1. Native Windows OpenSSH does not implement SSH multiplexing (PowerShell/Win32-OpenSSH#1328, still open as of Feb 2026). When Lima needed ControlMaster for the legacy ssh-based dynamic port forwarder, native ssh would not work, so Cygwin-built ssh was required.
  2. Cygwin-built ssh expects Cygwin-style paths (/c/Users/jan/...). Hence cygpath to translate. Hence the cascade of "why are we calling cygpath everywhere?" sites in the codebase.

Three things make the dependency easier to drop than I expected when starting:

  • Lima's default port forwarder has been Go-native since v1.1.0 (pkg/portfwd/forward.go, gRPC-tunnelled via vsock). The legacy ssh-based forwarder is opt-in via LIMA_SSH_PORT_FORWARDER=true. So ControlMaster is not actually needed by the default flow — verified empirically: starting python3 -m http.server in the guest produces a TCP listener owned by limactl.exe, not ssh.
  • Native Windows OpenSSH ships sftp-server.exe (when the OpenSSH Server optional feature is installed) and is auto-detected by sshocker via LookPath("sftp-server"). So reverse-sshfs works natively too once the host-path translation is corrected.
  • Native Windows OpenSSH treats -F /dev/null as an empty config, the same way Cygwin ssh does. Lima's hardcoded -F /dev/null argument did not need to change.

Detection mechanism

A new sshutil.IsSSHCygwin(sshExe) checks whether cygpath.exe lives in the same directory as ssh.exe (the layout used by Git for Windows and MSYS2), after resolving symlinks so chocolatey/scoop shims do not throw the directory check off. Results are cached per-resolved-absolute-path and logged once per path at Debug level. sshutil.PathForSSH(ctx, sshExe, path) then dispatches:

  • Cygwin-based ssh → cygpath for path translation (preserves any custom MSYS2 fstab the user has)
  • Native Windows OpenSSH → filepath.ToSlash (e.g. C:/Users/jan/...), which native ssh, ssh-keygen, scp, and sftp-server accept

ioutilx.WindowsSubsystemPath (used in the few sites that produce a guest mount-point path rather than a host argument) keeps cygpath as the preferred backend but falls back to a native drive-letter conversion (C:\Users\jan/c/Users/jan) when cygpath is unavailable, so the on-disk Lima config remains identical regardless of toolchain.

Per-commit breakdown

ce3a0696 sshutil: skip cygpath on Windows when ssh is native OpenSSH
bc99aca8 sshutil: detect Win32-OpenSSH version, export PathForSSH
6924004a copytool: support native Windows OpenSSH
df59f8ff downloader: decompress gzip in pure Go
8e56f7e7 ioutilx, hostagent: support reverse-sshfs on plain Windows
1f8a5303 CI: stop forcing Git for Windows on PATH; add plain-Windows smoke test
9858c839 docs: reflect that plain Windows is now a supported host
07328ee2 add diagnosability for plain-Windows path
d720cd4e ioutilx: drop unnecessary else after returning if branch
f21ba237 client: silence flaky nolintlint at the grpc.Dial directive (reverted)
3e09daeb golangci: disable nolintlint
7474e1e1 CI: build the Linux guest agent and fail-loudly in windows-plain
6e599d6e remove _LIMA_WINDOWS_EXTRA_PATH
1218a5d1 CI: split windows-plain by driver, add reverse-sshfs round-trip (partially reverted)
bae5d205 CI: install templates for windows-plain-qemu
e147b971 CI: read symlink targets from git object store
f98222d5 CI: re-skip mount-home on Windows, drop reverse-sshfs round-trip
7f760ab6 sshutil, copytool, ioutilx: address PR review
29cb4234 CI: assert template symlink target stays inside templates/
c78cdc34 docs: sftp-server is an optional Windows feature; toolchain-swap caveat

f21ba237 is reverted by 3e09daeb; f98222d5 partially reverts 1218a5d1; in a non-research PR these would be squashed.

Review feedback addressed (round 1)

Commit 7f760ab6 folds in fixes for the two Important issues and several Suggestions from the first review pass:

  • I1copytool.checkRsyncOnGuest now strips the mux options on Windows. The probe previously hit the same ControlMaster failure the rsync.Command/scp.Command paths work around, so a working rsync install on native Windows OpenSSH was rejected before the guest-side command -v rsync ran.
  • I2copytool.parseCopyPaths now uses filepath.IsAbs instead of filepath.VolumeName != "" to decide local vs remote. The previous check classified the drive-relative form C:foo as local, silently shadowing single-letter instance names. A new TestParseCopyPathsWindowsDriveLetter table test locks in C:\foo / C:/foo / C:foo / instance:path.
  • S1 + S3sshutil.IsSSHCygwin drops its sync.Once in favour of a map keyed by the resolved absolute path, with filepath.EvalSymlinks applied before the cygpath.exe sibling check so chocolatey/scoop shims do not mis-detect.
  • S4ParseOpenSSHVersion logs the unparsed banner at Debug when it falls back to 0.0.0, so cipher-selection and scp-URL downgrades are traceable.
  • S6 — the windows-plain-qemu template-install step now asserts the resolved symlink target stays inside templates/ after the path-space chain walk. Defensive only; Lima's current templates don't use ...
  • S7 — WSL2 docs now call out that hostagent recomputes reverse-sshfs LocalPath on every start; users should not swap between native Windows OpenSSH and Cygwin ssh between limactl create and limactl start on a QEMU instance with reverse-sshfs mounts. The follow-up would persist LocalPath at create time.
  • S8ioutilx.WindowsSubsystemPath's native cygpath fallback now inserts the separator explicitly, so a hypothetical drive-relative input C:foo does not collapse to /cfoo.
  • S9 — WSL2 docs distinguish OpenSSH Client (default on Windows 10 1803+) from OpenSSH Server (optional Feature on Demand, provides sftp-server.exe; needed for QEMU + reverse-sshfs).
  • S10parseCopyPaths now documents UNC-path behaviour inline.

Skipped by design:

  • S5 — transitional warning for _LIMA_WINDOWS_EXTRA_PATH still being set. Judged noise over value.

Deferred to follow-ups:

  • S7 persistence of LocalPath at create time (design change).
  • Commit squash of f21ba237+3e09daeb and the similar pairs (only if/when this branch moves toward merge).

Touched call sites

  • pkg/sshutil/sshutil.goDefaultPubKeys (ssh-keygen), identityFileEntry (IdentityFile), SSHOpts (ControlPath); ParseOpenSSHVersion regex extended to match OpenSSH_for_Windows_X.YpZ; IsSSHCygwin cache reshaped per review
  • pkg/copytool/copytool.goparseCopyPaths host-path translation with filepath.IsAbs gate (corrected per review I2)
  • pkg/copytool/scp.go, pkg/copytool/rsync.go — strip ControlMaster/ControlPath/ControlPersist on Windows so the underlying ssh does not try to use a mux socket that is unavailable (native) or unreliable (Cygwin). Includes checkRsyncOnGuest per review I1.
  • pkg/hostagent/mount.go — reverse-sshfs LocalPath translation now matches the toolchain
  • pkg/ioutilx/ioutilx.go — native cygpath fallback, plus debug logging of the conversion, hardened per review S8
  • pkg/downloader/downloader.go — gzip decompression via in-process compress/gzip. Other formats still shell out
  • cmd/limactl/main.go — drop the _LIMA_WINDOWS_EXTRA_PATH PATH-injection hook (no longer required for the core flow)
  • .golangci.yml — disable nolintlint, which was producing flaky cross-platform "directive is unused" failures because golangci-lint's analysis cache invalidation can cause SA1019 to drop in and out of the post-processing pipeline

Tested locally on Windows 11 with QEMU 10.2.0

Hardware: Windows 11 Pro 26100, native OpenSSH OpenSSH_for_Windows_9.5p2, with PATH cleaned of Git for Windows and MSYS2 entries.

WSL2 driver (templates/experimental/wsl2.yaml, finch rootfs)

  • limactl create — generates ed25519 keypair via native ssh-keygen with native paths
  • limactl start — boots, runs requirements ("ssh", "user session is ready for ssh", "Explicitly start ssh ControlMaster" all pass with ControlMaster=no explicitly set on every ssh invocation)
  • limactl shell uname -aLinux 6.6.87.2-microsoft-standard-WSL2 x86_64
  • ✅ Dynamic port forwarding — host Invoke-WebRequest http://127.0.0.1:8888 reaches a python3 -m http.server running in the guest. The TCP listener on the host is limactl.exe itself (PID owns the socket), confirming the gRPC/vsock forwarder is in use, not ssh -O forward
  • limactl copy host → guest and guest → host
  • limactl shell --workdir=/tmp
  • limactl stop, limactl delete --force

QEMU driver with reverse-sshfs (vmType: qemu, mountType: reverse-sshfs)

  • limactl create — default mountPoint computed correctly via the new native cygpath fallback (C:\Users\jan\qemu-share/c/Users/jan/qemu-share)
  • limactl start — sshocker auto-detects C:\Windows\System32\OpenSSH\sftp-server.exe via LookPath("sftp-server"), mount succeeds
  • ✅ Bidirectional read/write across the mount: file written on host visible in guest, file written from guest visible on host with matching content. Verified with both PowerShell Get-Content and bash cat

Note: this local verification used a custom qemu-share mount, not the default template's ~ mount. CI exercises the default template and hits a pre-existing, non-PR-related guest-side fusermount3: mount failed: Permission denied on Ubuntu 25.10. See "Intentionally not addressed" below.

Existing Cygwin-ssh path (Git for Windows on PATH)

  • limactl create + start + shell + copy all still work unchanged when Git for Windows is on PATH. IsSSHCygwin correctly identifies the toolchain, cygpath is invoked, paths come out as /c/Users/.... No regression for users who have Git for Windows installed.

Unit tests

go test ./pkg/sshutil/... ./pkg/copytool/... ./pkg/downloader/... ./pkg/ioutilx/... ./pkg/limayaml/... passes on Windows. Cross-compile of pkg/sshutil for darwin and a full Linux build of ./cmd/limactl both succeed.

Intentionally not addressed in this PR

Reverse-sshfs mount into the default template's ~ fails on GH Windows runners (guest-side, Ubuntu 25.10)

hack/test-templates.sh keeps the pre-existing skip

[ "${OS_HOST}" = "Msys" ] && CHECKS["mount-home"]=

for the default template case. An earlier commit on this branch (1218a5d1) removed that skip on the hypothesis that the new path-translation work covered the original failure. It did not: both windows-qemu (MSYS2 sftp-server) and windows-plain-qemu (native sftp-server) fail with

[hostagent] fusermount3: mount failed: Permission denied

inside the Ubuntu 25.10 guest, after /etc/fuse.conf has been populated with user_allow_other by Lima's cloud-init and the hostagent's pre-mount requirement check has confirmed it. Same symptom on both toolchains, so the failure is below the sftp-server / ssh boundary.

The guest runs with kernel.apparmor_restrict_unprivileged_userns=1 and kernel.apparmor_restrict_unprivileged_unconfined=1 (Ubuntu 23.10+ default, applied by /usr/lib/sysctl.d/10-apparmor.conf). Lima has an AppArmor profile for /usr/local/bin/rootlesskit to work around this restriction for containerd, but no equivalent for fusermount3. The leading (unconfirmed) hypothesis is that libfuse3 uses user namespaces for the mount when invoked by a non-root user with -o allow_other, and AppArmor denies that for the unconfined lima user. Audit is disabled in the kernel (audit: initializing netlink subsys (disabled)) so DENIED lines don't show in dmesg without re-enabling it.

This reproduces on the CI runners on both master and this branch, and is outside the scope of this PR (native Windows OpenSSH support). Tracking for a separate follow-up; the commit message on f98222d5 has the full trail.

limactl shell --sync is left unchanged — postponed to a follow-up

limactl shell --sync DIR currently does an early exec.LookPath("rsync") and exits with rsync is required for --sync but not found when rsync is unavailable. After this branch:

  • Plain Windows: --sync still fails immediately. Was already failing before; nothing about this PR changes that.
  • Git for Windows users: --sync also fails. Despite my (incorrect) earlier claim, Git for Windows does not include rsync in its bundle — only ssh, scp, ssh-keygen, etc. Users have to manually drop rsync into Git\usr\bin\ (there is a well-known how-to for it). I learned this while writing this PR.
  • MSYS2 users: --sync works after pacman -S rsync.
  • macOS / Linux users: unchanged, rsync is universally present.

A natural fix is to fall back to scp when rsync is missing — pkg/copytool already has the auto-fallback machinery, but cmd/limactl/shell.go:223 opts out of it by hardcoding BackendRsync. Switching to BackendAuto is small. The reason it is not in this PR is that the scp fallback is a meaningful behaviour change for --sync:

Feature rsync scp
Bulk copy host ↔ guest yes yes
--delete (propagate file removals) yes no
Itemized stats for the "Accept the changes? (added: X, deleted: Y, modified: Z)" prompt yes (--itemize-changes) no equivalent
Incremental delta transfer on subsequent syncs yes re-copies the whole tree

The most consequential gap is --delete. If the user adds a file on the host, syncs, deletes it on the host, and re-syncs, rsync would propagate the deletion to the guest; scp would leave a phantom file. The diff-view ("View the changed contents") path also degrades: without --itemize-changes the prompt collapses to a generic "Accept all changes? (y/n)" without specifics, unless we re-implement file-tree comparison in Go.

A workable fallback design would be: when rsync is unavailable, switch to BackendAuto, log a one-time warning that --delete is disabled and stats will be coarse, and either skip the itemized-stats prompt or replace it with a native tree walk. None of that is hard, but the UX policy decision (warn-and-degrade vs flag-gated --sync-tool=scp opt-in vs reject) deserves its own discussion. Tracking as a follow-up.

Other deferred items

  • The nolintlint linter is disabled in .golangci.yml. This silences a real but flaky failure mode (analysis-cache nondeterminism causes the SA1019 deprecation to drop in and out of the input to nolintlint, which then flags the //nolint:staticcheck directive at pkg/driver/external/client/client.go:35 as unused). The proper fix is to migrate grpc.Dial to grpc.NewClient and remove the directive entirely; out of scope here.
  • hack/test-templates.sh is unchanged in this PR. It still uses cygpath, bash arrays, netcat, etc., and still runs under MSYS2 bash in the existing windows-wsl2 and windows-qemu jobs. Rewriting it for plain Windows is a separate, larger project.
  • Decompressors other than gzip (bzip2, xz, zstd) still shell out. Lima's default WSL2 image is .tar.gz, so the gzip path is the one that mattered. The others can be migrated similarly when needed.
  • Reverse-sshfs path translation in pkg/hostagent/mount.go uses PathForSSH (toolchain-aware) rather than WindowsSubsystemPath (cygwin-style always). Persisting LocalPath at create time would remove the toolchain-swap hazard described in the new WSL2 docs note (review S7); kept out of scope here.

CI changes

Existing jobs (windows-wsl2, windows-qemu)

  • Removed _LIMA_WINDOWS_EXTRA_PATH = 'C:\Program Files\Git\usr\bin'. After this branch, limactl does not need anything from there. Strict subset of the old environment.
  • Removed the _LIMA_WINDOWS_EXTRA_PATH entry from MSYS2_ENV_CONV_EXCL (it was only there because we were setting the var).
  • hack/test-templates.sh still skips the mount-home check on Msys (see the section above for the reason).

New jobs

Two new jobs, mirroring the two existing Windows drivers but on a plain-Windows host:

windows-plain-wsl2 — builds with go build (no make, no MSYS2 bash needed for the build step), scrubs PATH of MSYS2 and Git for Windows, then runs a PowerShell smoke test (createstartshellcopystopdelete) against the same WSL2 rootfs template the existing windows-wsl2 job uses.

windows-plain-qemu — same shape as windows-plain-wsl2, but installs QEMU 10.2.0 and uses templates/default.yaml (matching windows-qemu). Smoke test covers the same create/start/shell/copy/stop/delete sequence. The reverse-sshfs mount itself is not exercised in this job because it hits the pre-existing Ubuntu-25.10 fusermount3 issue described above.

Both new jobs:

  • Print PATH before and after the scrub.
  • Assert ssh resolves to C:\Windows\System32\OpenSSH\ssh.exe specifically.
  • Run a tool inventory of ~20 binaries Lima might shell out to, classified as required (must resolve to something native), forbidden (cygpath, pacman — must not resolve at all), and optional (logged for context only). Fails with an actionable message if a required tool is missing or a forbidden one is found.
  • Pass --debug to every limactl invocation so the workflow log captures the toolchain-detection result, ssh args, path-translation decisions, etc.
  • if: failure() step dumps ha.stderr.log, ha.stdout.log, serial.log, lima.yaml, ssh.config from the instance directory.

What this PR is hoping CI will confirm

  1. windows-plain-wsl2 passes end-to-end. Already confirmed in a previous run on this branch.
  2. windows-plain-qemu passes end-to-end for the non-mount path (create / start / shell / copy / stop / delete via native OpenSSH + native sftp-server autodetection).
  3. windows-wsl2 still passes with _LIMA_WINDOWS_EXTRA_PATH removed. Strict subset of the old environment, but the only way to confirm there is no hidden dependency is to run it.
  4. Logging at --debug produces enough trail to diagnose any failure without local repro. The new debug logs (toolchain detection, in-process gzip, mux-stripping, reverse-sshfs LocalPath, native cygpath fallback, ParseOpenSSHVersion fallback) plus the failure-mode hostagent log dump are designed to make any CI failure self-diagnosable.

If items 1–3 fail in interesting ways, those failures themselves are the data we want from this PR. The branch is structured as small focused commits to make per-commit revert easy.

Lima calls cygpath to translate key/socket paths before invoking
ssh-keygen and ssh on Windows. This assumes a Cygwin-based ssh
(Git for Windows, MSYS2). When only native Windows OpenSSH is
installed, cygpath is unavailable and limactl create fails immediately
with:

  failed to convert path to mingw, maybe not using Git ssh?
  exec: "cygpath": executable file not found in %PATH%

Detect the ssh flavor by checking whether cygpath.exe lives alongside
ssh.exe (the layout used by Git for Windows and MSYS2). For native
Windows OpenSSH, pass paths with forward slashes (C:/Users/...), which
native ssh-keygen, ssh, and sshd accept. For Cygwin-based ssh the
existing cygpath-based behavior is preserved, so users with Git for
Windows see no change.

This unblocks limactl create on plain Windows. Full end-to-end use of
native Windows OpenSSH still requires a non-ControlMaster path for
dynamic port forwarding (hostagent uses ssh -O forward/cancel), since
Win32-OpenSSH does not implement SSH multiplexing
(PowerShell/Win32-OpenSSH#1328, still open as of Feb 2026). That is a
separate change.

Related: lima-vm#4819
Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Two related changes used together by callers that talk to the ssh
family of binaries on Windows:

ParseOpenSSHVersion: also match "OpenSSH_for_Windows_X.YpZ" (the version
banner emitted by native Windows OpenSSH). Previously the regex required
a digit immediately after "OpenSSH_", so Win32-OpenSSH was misdetected
as version 0.0.0 and Lima then treated it as pre-8.0 legacy ssh in code
paths that branch on the version (e.g. scp URL form).

PathForSSH: rename from the previously unexported pathForSSH and export
it. copytool.parseCopyPaths needs the same path-translation logic, and
duplicating the cygpath-vs-native decision in two packages would invite
drift.

Add a test for the Win32-OpenSSH version banner.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Three changes that together let limactl copy work on Windows when only
native Windows OpenSSH is installed (no Git for Windows, no MSYS2).

parseCopyPaths: route absolute Windows paths (e.g. C:\Users\jan\file)
through sshutil.PathForSSH so that for native ssh the path becomes
C:/Users/jan/file (forward slashes) instead of failing on a missing
cygpath. Detect Windows drive-letter paths via filepath.VolumeName
*before* splitting on ":" so the drive letter is not mistaken for an
instance name in the "instance:path" form.

scp.go, rsync.go: strip ControlMaster / ControlPath / ControlPersist
from the ssh options on Windows. Native Windows OpenSSH does not
implement SSH multiplexing, so leaving these options in caused scp to
fail with "getsockname failed: Not a socket" before transferring any
bytes. Cygwin-based ssh has known reliability issues with sftp over a
mux socket, so stripping unconditionally on Windows is consistent with
how hostagent and limactl shell already handle this.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Use compress/gzip directly instead of shelling out to a gzip binary.
On Windows, gzip is not part of the base system, so the previous
behaviour required Git for Windows or MSYS2 to be on PATH just to
unpack a .tar.gz image during limactl create / start.

Other formats (bzip2, xz, zstd) still go through the exec path, since
they are less common in Lima image URLs and would need extra
dependencies for in-process decompression. They can be migrated
similarly in follow-ups if needed.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
ioutilx.WindowsSubsystemPath: keep cygpath as the preferred backend
(it respects any custom fstab the user has configured for MSYS2 / Git
for Windows), but add a native fallback for the common drive-letter
case (C:\Users\jan -> /c/Users/jan). Without the fallback, plain
Windows installs that have neither Git for Windows nor MSYS2 hit a
fatal error during fillDefault when computing the default mountPoint
for a host mount. After this change, the default mountPoint is
computed correctly without external tooling.

hostagent.setupMount: switch the host-path translation from
ioutilx.WindowsSubsystemPath to sshutil.PathForSSH. The path is
consumed by the sftp-server that sshocker spawns, and the format that
binary expects depends on toolchain: Cygwin sftp-server (Git for
Windows / MSYS2) wants Cygwin paths, native Windows sftp-server wants
native forward-slash paths. PathForSSH already encodes that decision.

Verified end-to-end on Windows 11 with QEMU 10.2.0 and only native
Windows OpenSSH on PATH (no Git for Windows, no MSYS2): reverse-sshfs
mounts a host directory into the guest, both sides see the same files,
read and write both work, and the host-side sftp-server is the
ssh-built-in C:\Windows\System32\OpenSSH\sftp-server.exe (auto-detected
by sshocker via exec.LookPath).

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Three changes, intended as a research signal rather than a finished
re-engineering of the Windows test matrix.

windows-wsl2 and windows-qemu: drop the _LIMA_WINDOWS_EXTRA_PATH
setting (and the corresponding entry in MSYS2_ENV_CONV_EXCL). After
the prior commits in this branch, limactl no longer needs anything
from C:\Program Files\Git\usr\bin for its core flow on Windows. The
test scripts (hack/test-templates.sh) still run under MSYS2 bash and
still rely on cygpath / awk / netcat from C:\msys64\usr\bin, so that
PATH entry stays. Expected: existing tests pass unchanged, since the
removed entry was strictly additive for limactl.

windows-plain: new job that builds with `go build` and runs a minimal
PowerShell smoke test (create / start / shell / copy / stop / delete)
with PATH scrubbed of MSYS2 and Git for Windows. The PATH scrub is
deliberately aggressive: if any new step starts requiring something
from those toolchains, this job will fail and we will know about it.
Verified locally on a Windows 11 host with only native OpenSSH;
running on the GitHub Windows runner is the actual signal.

Behaviour changes intended to break loudly, not silently. If the new
job fails, the failure mode itself is the data we want.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
wsl2.md: drop the "Windows doesn't ship with ssh.exe, gzip.exe, etc."
bullet, which has been incorrect since Windows 10 build 1803 (2018) for
ssh and is now incorrect for gzip too (Lima decompresses gzip in pure
Go since the prior commits in this branch). Replace with a section
that describes the current behaviour: native OpenSSH is used when
available; Git for Windows / MSYS2 is detected and used when present
(so users with custom MSYS2 fstab entries see the existing cygpath
behaviour); plain Windows works without either.

environment-variables.md: add a historical-context note to
_LIMA_WINDOWS_EXTRA_PATH explaining that it was originally needed to
make Git-for-Windows binaries reachable to limactl, and is no longer
required for the core flow. Keep the variable documented since it can
still be useful for niche scenarios and the implementation in
cmd/limactl/main.go remains.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Logging additions: surface the decisions Lima makes that previously
went unmarked, so a `--debug` run on a failing host shows enough to
diagnose without source-diving.

  sshutil.IsSSHCygwin: log the toolchain detection result once per
  process at Debug level, including the full ssh.exe path and whether
  cygpath.exe was found alongside. Caches the result in a sync.Once,
  so the log fires exactly once even when many call sites consult it.

  ioutilx.WindowsSubsystemPath: when the native fallback is taken
  (cygpath unavailable), log the input -> output mapping at Debug, so
  unexpected drive-letter conversions are visible in the trace.

  downloader.decompressLocal: change "decompressing X with gzip" to
  either "with in-process gzip" or "with external <cmd>" depending on
  which path was taken. The previous message was misleading after the
  in-process gzip change because it still said "with gzip" for both.

  copytool.scp / rsync: log Debug when ControlMaster options are
  stripped on Windows, so a copy-failure trace makes the mux decision
  visible without reading the source.

  hostagent.setupMount: log the resolved sftp-server LocalPath at
  Debug, so reverse-sshfs failures surface what was actually passed.

CI: the windows-plain job now produces a tool inventory so failures
have actionable context.

  Print PATH before and after the Cygwin/MSYS2/Git-for-Windows scrub.

  Enumerate every external binary Lima might shell out to on Windows,
  classified into required (must resolve and must come from
  C:\Windows\...), forbidden (must not resolve at all -- cygpath,
  pacman), and optional (logged for context, e.g. rsync, gzip,
  qemu-img). Fail with an actionable message if a required tool is
  missing or a forbidden one is found, so the CI failure points at the
  scrub regex rather than the smoke test that follows.

  Pass --debug to every limactl invocation in the smoke test, and add
  an "if: failure()" step that dumps ha.stderr.log, ha.stdout.log,
  serial.log, lima.yaml, and ssh.config from the instance directory.

  Persist the scrubbed PATH into $GITHUB_ENV so the smoke test and
  the failure-dump step run against the same environment as the
  verification step.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
revive's indent-error-flow check (in CI) flagged the if/else where the
if branch returns. The else block only existed to give the cygpath
err a name visible after the conditional; lifting the assignment out
of the if-init achieves the same thing without the linter complaint
and is also a touch easier to read.

No behaviour change.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
The //nolint:staticcheck directive at client.go:35 has been intermittently
flagged as unused by nolintlint when running on the windows-2025 lint
job, even though the underlying code, the grpc dependency, and the
directive itself are unchanged. The behaviour is sensitive to
golangci-lint's analysis cache state: master has been passing this
check, but small changes elsewhere in the module can shift the
analyzer scheduling enough that staticcheck's SA1019 deprecation
finding disappears from the input to nolintlint, which then reports
the directive as unused.

Add nolintlint to the directive so it self-suppresses, and document
the reason inline so the next person who looks at this knows why.
The grpc.Dial -> grpc.NewClient migration is a separate concern that
would remove the need for the directive entirely; out of scope here.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
nolintlint flags //nolint directives whose target lint check did not
report an issue at the same line, on the assumption that the directive
is now stale. In practice the underlying linters' findings are
sensitive to golangci-lint's analysis cache and analyzer scheduling,
which can vary across platforms and across PRs even when the relevant
source has not changed. The result is occasional spurious lint
failures on CI for code that nobody touched.

Disable the linter and revert the workaround that the previous commit
applied to pkg/driver/external/client/client.go.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
The first run of windows-plain reported success in 2 minutes, which
turned out to be entirely fictitious: the smoke test never actually
exercised any Lima command end-to-end. Two compounding bugs:

Build step only built limactl.exe, not the per-arch guest agent that
limactl looks up at start time. Result: limactl create exits 1 with
"guest agent binary could not be found for Linux-x86_64" before
touching the VM. Add a second go build invocation that produces
_output/share/lima/lima-guestagent.Linux-x86_64 with the same env
(CGO_ENABLED=0 GOOS=linux GOARCH=amd64) the Makefile uses.

Smoke step relied on $ErrorActionPreference = 'Stop' to abort on
errors, but PowerShell's Stop preference does not catch non-zero
exits from external commands, only PowerShell's own terminating
errors. So when limactl create failed, the script kept going through
start, shell, copy, stop, delete; each of those also failed but the
job exited 0. Wrap every limactl invocation in an Invoke-Limactl
helper that throws on $LASTEXITCODE != 0, so the run is honest about
which step actually broke.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
This experimental Windows-only env var prepended a user-supplied
directory to PATH inside limactl. It existed to inject Git for
Windows or MSYS2 binaries without altering the user shell's PATH,
back when Lima required a Cygwin-style toolchain for ssh, scp,
ssh-keygen, and cygpath.

After this branch's earlier commits, limactl works directly with
native Windows OpenSSH and no longer needs anything from those
toolchains. The variable served no purpose for the core flow and
its leading underscore signalled that no compatibility was promised.

Drop the implementation in cmd/limactl/main.go and the corresponding
docs entry. The CI invocations were already removed earlier in this
branch.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Three changes that together verify reverse-sshfs works on Windows in
both supported toolchain configurations.

windows-plain becomes windows-plain-wsl2 (rename only). Instance name
and LIMA_HOME path follow.

Add windows-plain-qemu mirroring the WSL2 sibling but with the QEMU
driver. Same PATH-scrub-and-tool-inventory shape, same fail-loudly
PowerShell wrapper around limactl. qemu-img and qemu-system-x86_64
move from optional to required in the inventory; the scrub keeps the
\Program Files\QEMU directory after the Cygwin/MSYS2 entries are
removed. Smoke test creates from templates/default.yaml (the same
template windows-qemu uses), runs uname, and does a reverse-sshfs
round-trip equivalent to hack/test-mount-home.sh: write a random
string to a file in $USERPROFILE/lima-test-tmp on the host, read it
via the mount in the guest, compare.

hack/test-templates.sh: drop the line that disabled the mount-home
check on Windows/Msys. The skip's comment cites a "failed to confirm
whether /c/Users/runneradmin [remote] is successfully mounted" CI
failure that pre-dates the path-translation and toolchain-detection
work in this branch. Re-enabling exercises the Cygwin/MSYS2 sftp-server
path of windows-qemu, the parallel of windows-plain-qemu's native
sftp-server path, so we get coverage on both sides of the fork.

If either side fails, that failure is the data we want.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
The first windows-plain-qemu run failed at create time with:

  fatal: template "_images/ubuntu.yaml" not found

templates/default.yaml uses `base: template:_images/ubuntu` and
`base: template:_default/mounts`, which Lima resolves via
<prefix>/share/lima/templates/. Existing windows-qemu populates that
directory by running `make`, whose TEMPLATES target does `cp -aL` of
templates/ into _output/share/lima/templates/. windows-plain-qemu
deliberately avoids `make` (so the build does not require MSYS2 make
or bash), so the destination directory was empty and Lima could not
find the base templates.

Replicate the install in PowerShell. Recursive Copy-Item handles the
plain files. The wrinkle is that templates/_images/<distro>.yaml and
templates/<distro>.yaml are tracked as git symlinks (mode 120000),
which Windows checks out as 17-byte plaintext stubs containing the
target filename because core.symlinks defaults to false. We detect
them via `git ls-tree`, follow the chain (opensuse.yaml ->
opensuse-leap.yaml -> opensuse-leap-16.yaml is the deepest current
chain), and overwrite the stub with the resolved file's contents.

windows-plain-wsl2 does not need this; it uses
templates/experimental/wsl2.yaml which has a self-contained images:
section and no base: references.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
The windows-plain-qemu "Install templates" step assumed the working
tree represented git symlinks as 17-byte plaintext stubs (the
core.symlinks=false case). The GitHub Windows runner actually
preserves them as real NTFS symlinks, so Get-Content on the stub path
returned the target file's YAML content and the resolver threw:

  Stub templates\ubuntu-lts.yaml points at minimumLimaVersion: 2.0.0
  base: - template:_images/ubuntu-24.04 - template:_default/mounts
  which does not exist at templates\minimumLimaVersion: 2.0.0 ...

Read symlink targets directly from the git object store via
`git cat-file blob <sha>` instead of probing the working tree. The
resolution is now independent of whether core.symlinks is true
(runner) or false (plain Windows checkout).

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
1218a5d removed the "mount-home skipped on Msys" line from
hack/test-templates.sh and added an inline reverse-sshfs round-trip
to windows-plain-qemu, on the hypothesis that the path-translation
work in this branch would address the pre-existing failure the skip
was there for. It does not: both jobs fail with

  [hostagent] fusermount3: mount failed: Permission denied

inside the Ubuntu 25.10 guest, with /etc/fuse.conf already containing
user_allow_other (the hostagent's pre-mount requirement check passes
before the mount is attempted). Same symptom on MSYS2 sftp-server and
on native Windows sftp-server, so the failure is below the toolchain
boundary — something in ubuntu-25.10 + fuse3 + AppArmor's
unprivileged-userns restriction. Tracked for a separate follow-up.

Restore the Msys skip and drop the inline round-trip block. The
remaining windows-plain-qemu smoke (create / start / shell / copy /
stop / delete via native OpenSSH with sftp-server autodetection on
PATH) still covers the native-Windows-OpenSSH work that is the
subject of this branch.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Grouped fixes from the review of PR lima-vm#4885 that all live in the
Windows-SSH code paths.

copytool.parseCopyPaths (review I2): the Windows local-abs detection
used filepath.VolumeName, which returns "C:" for the drive-relative
path "C:foo" as well. That shadowed single-letter instance names:
"limactl copy C:foo ." on an instance named "C" was silently
mis-routed through PathForSSH instead of the colon-split. Switch to
filepath.IsAbs, which correctly returns false for "C:foo" and keeps
absolute forms (C:\foo, C:/foo, UNC) classified as local. Add a table
test pinning the three cases plus an explicit instance:path form.

copytool.checkRsyncOnGuest (review I1): the probe built sshOpts and
ran ssh without stripping ControlMaster/ControlPath/ControlPersist on
Windows, which both rsyncTool.Command and scpTool.Command already do.
The probe therefore hit the same mux-socket failure the Command path
works around, so a working rsync install on native Windows OpenSSH
was rejected before "command -v rsync" ran on the guest. Mirror the
mux-strip on Windows.

sshutil.IsSSHCygwin (review S1, S3): the sync.Once cache keyed the
answer to the first caller's sshExe, so a future caller that wants to
re-detect after an SSH swap (e.g. a test exercising both branches)
cannot. Replace with a map keyed by the resolved absolute path, and
filepath.EvalSymlinks the path before the directory check so a
chocolatey/scoop shim does not throw off the cygpath.exe sibling
probe.

sshutil.ParseOpenSSHVersion (review S4): the 0.0.0 fallback silently
downgrades version-gated behaviour (cipher selection, scp URL form).
Log the unparsed banner at Debug so the cause is traceable.

ioutilx.WindowsSubsystemPath (review S8): the native cygpath fallback
assumed orig[2:] begins with a separator, which is true for today's
callers (all absolute). Harden it so a future caller passing the
drive-relative "C:foo" does not get "/cfoo" back.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Review S6. The path-space chain walker in the "Install templates" step
silently swallows '..' underflow: a symlink chain with enough '..'
segments would resolve to a path outside templates/ and Copy-Item
would cheerfully copy whichever file is there. Lima's current
templates don't have any such symlinks, so this is defensive only —
but the loop is easy to trip over on a future template edit.

Add a post-resolution check that the resolved path still starts with
the source root, and throw otherwise.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Review S9: the previous wording said native Windows OpenSSH includes
ssh, scp, ssh-keygen, and sftp-server and ships on Windows 10 build
1803+. Correct only for the client components. sftp-server is part
of OpenSSH Server, which is a separate Feature on Demand and not
installed by default. Lima's QEMU + reverse-sshfs path needs it;
WSL2 does not. Spell the distinction out and link the MS install
doc.

Review S7: hostagent/mount.go recomputes the reverse-sshfs LocalPath
via PathForSSH on every start, while defaults.go resolves the default
MountPoint once at create via WindowsSubsystemPath. A user who
creates with Git for Windows on PATH and then starts without it
(or vice versa) would see LocalPath change shape between restarts
without warning. Document the constraint: pick one ssh toolchain
and stick with it for an instance's lifetime. Persisting LocalPath
at create time would fix this at the code layer but is out of scope
here.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
revive's redefines-builtin-id flagged 'real' as shadowing the Go
built-in (the complex-number function). Caught by the Lint Go job
on all three platforms after 7f760ab added the EvalSymlinks call.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
@jandubois
Copy link
Copy Markdown
Member Author

For the record, the round 1 review mentioned in the summary is https://jandubois.github.io/lima/20260424-190657-pr-4885.html

@liketosweep
Copy link
Copy Markdown

Thanks for the solid foundation here, @jandubois! I’ll be testing and reviewing this locally on Windows to help provide a status signal for the research.

I’m also volunteering to pick up the 'pre-existing limitations' for #4819 as follow-up PRs. Specifically, I'm exploring a pure-Go rsync implementation; this would not only resolve the binary dependency but also naturally solve the requirements in #4887 as a direct subset.

Excited to help move this toward the finish line!

@liketosweep
Copy link
Copy Markdown

@jandubois - I just tested it locally and here is the report .

Verification Report: Windows Native Path Translation


Test Environment:

Host OS: Windows (Native)

Isolation: Isolated $env:PATH (Git , MSYS2 , and Mingw references removed).

Dependency Check: ssh verified as native OpenSSH; cygpath confirmed unavailable via Get-Command.


Methodology:
Compiled limactl from the windows-native-ssh-no-cygpath branch and executed a "clean-room" start of a WSL2 instance using .\limactl-test.exe start --debug .


Key Findings:
The native path translation logic is verified. The sshutil package successfully caught the missing cygpath binary and triggered the pure-Go fallback logic.


Captured Log Evidence:

level=debug msg="cygpath unavailable for \"C:\\\\Users\\\\lts\", attempting native conversion" error="exec: \"cygpath\": executable file not found in %PATH%"

level=debug msg="native cygpath fallback: \"C:\\\\Users\\\\lts\" -> \"/c/Users/lts\""

Conclusion:
The host-side CLI logic successfully abstracts away the dependency on external Windows path-translation tools. This confirms the architectural viability for a standalone Windows experience.

@jandubois
Copy link
Copy Markdown
Member Author

Thanks for the feedback @liketosweep!

I would like to hear from other maintainers, and from @arixmkii if this PR looks sensible in principle, and if I should spend the effort on cleaning it up and creating one or more actual mergeable PRs from it. Probably won't have time for it for a little bit, but once I know we want to do this, I can work on it when time opens up.

@liketosweep
Copy link
Copy Markdown

Thank you, @jandubois. I completely agree with holding off until there is consensus from the other maintainers and @arixmkii on the architectural direction. I will monitor the discussion here and remain available if further local testing is needed once a decision is reached.

@unsuman
Copy link
Copy Markdown
Member

unsuman commented Apr 29, 2026

I tested the PR on my Windows Machine(Windows 11 Home 25H2). No MSYS2, no Cygwin, no Git for Windows:

PS C:\Windows\system32> Get-Command ssh.exe

CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Application     ssh.exe                                            9.5.5.1    C:\Windows\System32\OpenSSH\ssh.exe


PS C:\Windows\system32> Get-Command ssh-keygen.exe

CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Application     ssh-keygen.exe                                     9.5.5.1    C:\Windows\System32\OpenSSH\ssh-keygen.exe

Here are some key observations(We'll have to see if they are universal):

  • Source build fails to parse YAML even if when explicit template is given.
  PS> C:\Users\anshu\OneDrive\Documents\lima\_output\bin\limactl.exe start --vm-type qemu C:\Users\anshu\OneDrive\Documents\lima\_output\share\lima\templates\default.yaml
  
time="..." level=fatal msg="failed to unmarshal YAML (template:_images/ubuntu): [1:1] string was used where mapping is expected\n>  1 | ubuntu-25.10.yaml\n       ^\n"

The same binary works perfectly when placed into C:\Program Files\Lima\bin (overwriting the official lima-2.1.1-Windows-AMD64 installation) and run from there.

  • boot.sh execution failure on the guest side which was causing Lima to get stuck after user session is ready for ssh:
    Used AI and it told me to add a .gitattributes entry which forces LF line endings for these files and it worked:
    pkg/cidata/cidata.TEMPLATE.d/** text eol=lf
    

Once these are out of the way, I can finally run a QEMU instance:

QEMU log

PS C:\Windows\system32> limactl start --vm-type qemu
? Creating an instance "default" Proceed with the current configuration
time="2026-04-29T16:30:19+05:30" level=warning msg="local username \"UNSUMAN\\\\anshu\" is not a valid Linux username (must match \"^[a-z_][a-z0-9_-]*$\"); using \"lima\" instead"
time="2026-04-29T16:30:19+05:30" level=warning msg="local uid \"S-1-5-21-2734909792-918164565-3955868323-1001\" is not a valid Linux uid (must be integer); using 1000 uid instead"
time="2026-04-29T16:30:19+05:30" level=warning msg="local gid \"S-1-5-21-2734909792-918164565-3955868323-1001\" is not a valid Linux gid (must be integer); using 1000 gid instead"
time="2026-04-29T16:30:19+05:30" level=info msg="Starting the instance \"default\" with internal VM driver \"qemu\""
time="2026-04-29T16:30:19+05:30" level=info msg="Attempting to download the image" arch=x86_64 digest="sha256:6d91bd33bee09ea9907914136a9ac73c789611059c3ccb8cec894ea6e2d4b245" location="https://cloud-images.ubuntu.com/releases/questing/release-20260320/ubuntu-25.10-server-cloudimg-amd64.img"
time="2026-04-29T16:30:20+05:30" level=info msg="Using cache \"C:\\\\Users\\\\anshu\\\\AppData\\\\Local\\\\lima\\\\download\\\\by-url-sha256\\\\dfc8a98829aa2a5e8524e44fa3208eeed3cfad82f87187dc67b735876f515501\\\\data\""
time="2026-04-29T16:30:20+05:30" level=info msg="Resize instance default's disk from 3.5GiB to 100GiB"
time="2026-04-29T16:30:20+05:30" level=info msg="Attempting to download the nerdctl archive" arch=x86_64 digest="sha256:8a477f35533c6cc1120c19558d8142967c74f25a4b952b481f48104e030de914" location="https://github.com/containerd/nerdctl/releases/download/v2.2.2/nerdctl-full-2.2.2-linux-amd64.tar.gz"
time="2026-04-29T16:30:20+05:30" level=info msg="Using cache \"C:\\\\Users\\\\anshu\\\\AppData\\\\Local\\\\lima\\\\download\\\\by-url-sha256\\\\072a3470f807a0089a48b29fff33dc8b5d3800fc7761ae82e8b55a6b3ce2ace1\\\\data\""
time="2026-04-29T16:30:21+05:30" level=info msg="[hostagent] hostagent socket created at C:\\Users\\anshu\\.lima\\default\\ha.sock"
time="2026-04-29T16:30:23+05:30" level=info msg="[hostagent] Using system firmware (\"C:\\\\Program Files\\\\qemu\\\\share\\\\edk2-x86_64-code.fd\")"
time="2026-04-29T16:30:23+05:30" level=info msg="[hostagent] Using system firmware vars (\"C:\\\\Program Files\\\\qemu\\\\share\\\\edk2-i386-vars.fd\")"
time="2026-04-29T16:30:23+05:30" level=info msg="[hostagent] Starting QEMU (hint: to watch the boot progress, see \"C:\\\\Users\\\\anshu\\\\.lima\\\\default\\\\serial*.log\")"
time="2026-04-29T16:30:23+05:30" level=info msg="SSH Local Port: 5706"
time="2026-04-29T16:30:23+05:30" level=info msg="[hostagent] Waiting for the essential requirement 1 of 4: \"ssh\""
time="2026-04-29T16:30:42+05:30" level=info msg="[hostagent] The essential requirement 1 of 4 is satisfied"
time="2026-04-29T16:30:42+05:30" level=info msg="[hostagent] Waiting for the essential requirement 2 of 4: \"user session is ready for ssh\""
time="2026-04-29T16:30:42+05:30" level=info msg="[hostagent] The essential requirement 2 of 4 is satisfied"
time="2026-04-29T16:30:42+05:30" level=info msg="[hostagent] Waiting for the essential requirement 3 of 4: \"sshfs binary to be installed\""
time="2026-04-29T16:30:51+05:30" level=info msg="[hostagent] The essential requirement 3 of 4 is satisfied"
time="2026-04-29T16:30:51+05:30" level=info msg="[hostagent] Waiting for the essential requirement 4 of 4: \"fuse to \\\"allow_other\\\" as user\""
time="2026-04-29T16:30:51+05:30" level=info msg="[hostagent] The essential requirement 4 of 4 is satisfied"
time="2026-04-29T16:30:51+05:30" level=info msg="[hostagent] Mounting \"C:\\\\Users\\\\anshu\" on \"/c/Users/anshu\""
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] fusermount3: mount failed: Permission denied"
time="2026-04-29T16:31:23+05:30" level=warning msg="[hostagent] failed to confirm whether /c/Users/anshu [remote] is successfully mounted" error="failed to execute script \"wait-for-remote-ready\": stdout=\"\", stderr=\"sshfs does not seem to be mounted on /c/Users/anshu\\n\": exit status 1"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Waiting for the optional requirement 1 of 2: \"systemd must be available\""
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Guest agent is running"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Time sync: guest agent is alive, starting time synchronization"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding TCP 0.0.0.0:22"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding TCP 127.0.0.53:53"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding TCP 127.0.0.54:53"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Forwarding TCP from 127.0.0.1:35961 to 127.0.0.1:35961"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding TCP [::]:22"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding UDP 127.0.0.54:53"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding UDP 127.0.0.53:53"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Not forwarding UDP 192.168.5.15:68"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Forwarding UDP from 127.0.0.1:323 to 127.0.0.1:323"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Forwarding UDP from 0.0.0.0:5353 to 127.0.0.1:5353"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Forwarding UDP from [::1]:323 to 127.0.0.1:323"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Forwarding UDP from [::]:5353 to 127.0.0.1:5353"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] The optional requirement 1 of 2 is satisfied"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Waiting for the optional requirement 2 of 2: \"containerd binaries to be installed\""
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] The optional requirement 2 of 2 is satisfied"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Waiting for the guest agent to be running"
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] Waiting for the final requirement 1 of 1: \"boot scripts must have finished\""
time="2026-04-29T16:31:23+05:30" level=info msg="[hostagent] The final requirement 1 of 1 is satisfied"
time="2026-04-29T16:31:23+05:30" level=info msg="READY. Run `lima` to open the shell."

For wsl2, using the experimental/wsl2.yaml, I get this error:
{"level":"debug","msg":"[wsl.exe -d lima-wsl2 bash -c /mnt/c/Users/anshu/AppData/Local/Temp/lima-wsl2-boot-171555286.sh]: \"bash: line 1: /mnt/c/Users/anshu/AppData/Local/Temp/lima-wsl2-boot-171555286.sh: cannot execute: required file not found\\n\""}

$env:GOARCH = 'amd64'
go build -o _output\share\lima\lima-guestagent.Linux-x86_64 .\cmd\lima-guestagent
if ($LASTEXITCODE -ne 0) { throw "lima-guestagent build failed: $LASTEXITCODE" }
- name: Scrub PATH and verify only native Windows toolchain is reachable
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just uninstall Cygwin/MSYS2/Git-for-Windows from the CI env?

@jandubois jandubois closed this May 17, 2026
@jandubois jandubois deleted the windows-native-ssh-no-cygpath branch May 17, 2026 18:39
@jandubois jandubois restored the windows-native-ssh-no-cygpath branch May 17, 2026 18:42
@jandubois jandubois reopened this May 17, 2026
jandubois added a commit to jandubois/lima that referenced this pull request May 17, 2026
Grouped fixes from the review of PR lima-vm#4885 that all live in the
Windows-SSH code paths.

copytool.parseCopyPaths (review I2): the Windows local-abs detection
used filepath.VolumeName, which returns "C:" for the drive-relative
path "C:foo" as well. That shadowed single-letter instance names:
"limactl copy C:foo ." on an instance named "C" was silently
mis-routed through PathForSSH instead of the colon-split. Switch to
filepath.IsAbs, which correctly returns false for "C:foo" and keeps
absolute forms (C:\foo, C:/foo, UNC) classified as local. Add a table
test pinning the three cases plus an explicit instance:path form.

copytool.checkRsyncOnGuest (review I1): the probe built sshOpts and
ran ssh without stripping ControlMaster/ControlPath/ControlPersist on
Windows, which both rsyncTool.Command and scpTool.Command already do.
The probe therefore hit the same mux-socket failure the Command path
works around, so a working rsync install on native Windows OpenSSH
was rejected before "command -v rsync" ran on the guest. Mirror the
mux-strip on Windows.

sshutil.IsSSHCygwin (review S1, S3): the sync.Once cache keyed the
answer to the first caller's sshExe, so a future caller that wants to
re-detect after an SSH swap (e.g. a test exercising both branches)
cannot. Replace with a map keyed by the resolved absolute path, and
filepath.EvalSymlinks the path before the directory check so a
chocolatey/scoop shim does not throw off the cygpath.exe sibling
probe.

sshutil.ParseOpenSSHVersion (review S4): the 0.0.0 fallback silently
downgrades version-gated behaviour (cipher selection, scp URL form).
Log the unparsed banner at Debug so the cause is traceable.

ioutilx.WindowsSubsystemPath (review S8): the native cygpath fallback
assumed orig[2:] begins with a separator, which is true for today's
callers (all absolute). Harden it so a future caller passing the
drive-relative "C:foo" does not get "/cfoo" back.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
jandubois added a commit to jandubois/lima that referenced this pull request May 17, 2026
Grouped fixes from the review of PR lima-vm#4885 that all live in the
Windows-SSH code paths.

copytool.parseCopyPaths (review I2): the Windows local-abs detection
used filepath.VolumeName, which returns "C:" for the drive-relative
path "C:foo" as well. That shadowed single-letter instance names: on
an instance named "C", "limactl copy C:foo ." silently went to
PathForSSH instead of the colon-split. Switch to filepath.IsAbs,
which correctly returns false for "C:foo" and keeps absolute forms
(C:\foo, C:/foo, UNC) classified as local. Add a table test pinning
the three cases plus an explicit instance:path form.

copytool.checkRsyncOnGuest (review I1): the probe built sshOpts and
ran ssh without stripping ControlMaster/ControlPath/ControlPersist on
Windows, which both rsyncTool.Command and scpTool.Command already do.
The probe therefore hit the same mux-socket failure the Command path
works around, so checkRsyncOnGuest rejected a working rsync install
on native Windows OpenSSH before "command -v rsync" ran on the guest.
Mirror the mux-strip on Windows.

sshutil.IsSSHCygwin (review S1, S3): the sync.Once cache keyed the
answer to the first caller's sshExe, so a future caller that wants to
re-detect after an SSH swap (e.g. a test exercising both branches)
cannot. Replace with a map whose key is the resolved absolute path;
EvalSymlinks the path before the directory check so a chocolatey or
scoop shim does not throw off the cygpath.exe sibling probe.

sshutil.ParseOpenSSHVersion (review S4): the 0.0.0 fallback silently
downgrades version-gated behaviour (cipher selection, scp URL form).
Log the unparsed banner at Debug to expose the cause.

ioutilx.WindowsSubsystemPath (review S8): the native cygpath fallback
assumed orig[2:] begins with a separator, which is true for today's
callers (all absolute). Harden it so a future caller passing the
drive-relative "C:foo" does not get "/cfoo" back.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
@jandubois
Copy link
Copy Markdown
Member Author

Superseded by #4998

@jandubois jandubois closed this May 17, 2026
jandubois added a commit to jandubois/lima that referenced this pull request May 18, 2026
Grouped fixes from the review of PR lima-vm#4885 that all live in the
Windows-SSH code paths.

copytool.parseCopyPaths (review I2): the Windows local-abs detection
used filepath.VolumeName, which returns "C:" for the drive-relative
path "C:foo" as well. That shadowed single-letter instance names: on
an instance named "C", "limactl copy C:foo ." silently went to
PathForSSH instead of the colon-split. Switch to filepath.IsAbs,
which correctly returns false for "C:foo" and keeps absolute forms
(C:\foo, C:/foo, UNC) classified as local. Add a table test pinning
the three cases plus an explicit instance:path form.

copytool.checkRsyncOnGuest (review I1): the probe built sshOpts and
ran ssh without stripping ControlMaster/ControlPath/ControlPersist on
Windows, which both rsyncTool.Command and scpTool.Command already do.
The probe therefore hit the same mux-socket failure the Command path
works around, so checkRsyncOnGuest rejected a working rsync install
on native Windows OpenSSH before "command -v rsync" ran on the guest.
Mirror the mux-strip on Windows.

sshutil.IsSSHCygwin (review S1, S3): the sync.Once cache keyed the
answer to the first caller's sshExe, so a future caller that wants to
re-detect after an SSH swap (e.g. a test exercising both branches)
cannot. Replace with a map whose key is the resolved absolute path;
EvalSymlinks the path before the directory check so a chocolatey or
scoop shim does not throw off the cygpath.exe sibling probe.

sshutil.ParseOpenSSHVersion (review S4): the 0.0.0 fallback silently
downgrades version-gated behaviour (cipher selection, scp URL form).
Log the unparsed banner at Debug to expose the cause.

ioutilx.WindowsSubsystemPath (review S8): the native cygpath fallback
assumed orig[2:] begins with a separator, which is true for today's
callers (all absolute). Harden it so a future caller passing the
drive-relative "C:foo" does not get "/cfoo" back.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
jandubois added a commit to jandubois/lima that referenced this pull request May 18, 2026
Grouped fixes from the review of PR lima-vm#4885 that all live in the
Windows-SSH code paths.

copytool.parseCopyPaths (review I2): the Windows local-abs detection
used filepath.VolumeName, which returns "C:" for the drive-relative
path "C:foo" as well. That shadowed single-letter instance names: on
an instance named "C", "limactl copy C:foo ." silently went to
PathForSSH instead of the colon-split. Switch to filepath.IsAbs,
which correctly returns false for "C:foo" and keeps absolute forms
(C:\foo, C:/foo, UNC) classified as local. Add a table test pinning
the three cases plus an explicit instance:path form.

copytool.checkRsyncOnGuest (review I1): the probe built sshOpts and
ran ssh without stripping ControlMaster/ControlPath/ControlPersist on
Windows, which both rsyncTool.Command and scpTool.Command already do.
The probe therefore hit the same mux-socket failure the Command path
works around, so checkRsyncOnGuest rejected a working rsync install
on native Windows OpenSSH before "command -v rsync" ran on the guest.
Mirror the mux-strip on Windows.

sshutil.IsSSHCygwin (review S1, S3): the sync.Once cache keyed the
answer to the first caller's sshExe, so a future caller that wants to
re-detect after an SSH swap (e.g. a test exercising both branches)
cannot. Replace with a map whose key is the resolved absolute path;
EvalSymlinks the path before the directory check so a chocolatey or
scoop shim does not throw off the cygpath.exe sibling probe.

sshutil.ParseOpenSSHVersion (review S4): the 0.0.0 fallback silently
downgrades version-gated behaviour (cipher selection, scp URL form).
Log the unparsed banner at Debug to expose the cause.

ioutilx.WindowsSubsystemPath (review S8): the native cygpath fallback
assumed orig[2:] begins with a separator, which is true for today's
callers (all absolute). Harden it so a future caller passing the
drive-relative "C:foo" does not get "/cfoo" back.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
jandubois added a commit to jandubois/lima that referenced this pull request May 18, 2026
Grouped fixes from the review of PR lima-vm#4885 that all live in the
Windows-SSH code paths.

copytool.parseCopyPaths (review I2): the Windows local-abs detection
used filepath.VolumeName, which returns "C:" for the drive-relative
path "C:foo" as well. That shadowed single-letter instance names: on
an instance named "C", "limactl copy C:foo ." silently went to
PathForSSH instead of the colon-split. Switch to filepath.IsAbs,
which correctly returns false for "C:foo" and keeps absolute forms
(C:\foo, C:/foo, UNC) classified as local. Add a table test pinning
the three cases plus an explicit instance:path form.

copytool.checkRsyncOnGuest (review I1): the probe built sshOpts and
ran ssh without stripping ControlMaster/ControlPath/ControlPersist on
Windows, which both rsyncTool.Command and scpTool.Command already do.
The probe therefore hit the same mux-socket failure the Command path
works around, so checkRsyncOnGuest rejected a working rsync install
on native Windows OpenSSH before "command -v rsync" ran on the guest.
Mirror the mux-strip on Windows.

sshutil.IsSSHCygwin (review S1, S3): the sync.Once cache keyed the
answer to the first caller's sshExe, so a future caller that wants to
re-detect after an SSH swap (e.g. a test exercising both branches)
cannot. Replace with a map whose key is the resolved absolute path;
EvalSymlinks the path before the directory check so a chocolatey or
scoop shim does not throw off the cygpath.exe sibling probe.

sshutil.ParseOpenSSHVersion (review S4): the 0.0.0 fallback silently
downgrades version-gated behaviour (cipher selection, scp URL form).
Log the unparsed banner at Debug to expose the cause.

ioutilx.WindowsSubsystemPath (review S8): the native cygpath fallback
assumed orig[2:] begins with a separator, which is true for today's
callers (all absolute). Harden it so a future caller passing the
drive-relative "C:foo" does not get "/cfoo" back.

Signed-off-by: Jan Dubois <jan.dubois@suse.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants