Deep Review: 20260727-195856-pr-5341
| Date | 2026-07-27 19:58 |
| Repo | lima-vm/lima |
| Round | 3 (of target) |
| Author | @jandubois |
| PR | #5341 — docs: document the Windows host SSH toolchain |
| Branch | docs-plain-windows-wsl2 |
| Commits | df1997c1 docs: document the Windows host SSH toolchain |
| Review SHA | df1997c15eac7201fe06af79caf30586fda9349b |
| Reviewers | Claude Opus 5 (effort: xhigh), Codex GPT 5.6 Sol (effort: xhigh), Gemini 3.1 Pro (effort: default), Gemini 3.5 Flash (effort: default) |
| Verdict | Merge as-is — (corrected). I1 and I2 are Suggestions, not Important; merge after #5299 and #5300. See Resolution. |
| Wall-clock time | 26 min 48 s |
Executive Summary ¶
Round 3 audits the nine round-2 fixes. Seven hold under execution: the shell-tokenisation rules, the PowerShell example, the sftpDriver pin, the builtin exemption, the QEMU mount-type claim, the installation bullet, and all four cross-reference anchors. Two defects remain, both in rewritten wsl2.md paragraphs. The fstab note keeps a remedy its new mechanism cannot produce, and the SSH/PATH warning promises a fast failure where the code stalls ten minutes.
All four reviewers produced output. Claude carried the round with both Important findings and three suggestions; Codex independently reached I1 and re-raised a claim round 2 had already refuted; Gemini 3.1 Pro returned clean; Flash returned one phrasing note. I ran the parser probe, built the site, traced every claim through pkg/sshutil, pkg/hostagent, and sshocker v0.3.11, and rejected two agent findings that verification did not support.
Critical Issues ¶
None.
Important Issues ¶
fstab note keeps a remedy its rewritten mechanism cannot produce Claude Opus 5 Codex GPT 5.6 Sol¶
message at start and falls back to `sshocker`'s own detection; a missing
OpenSSH.Server install on a native-only host typically shows up there.
Note: Lima converts the host path with the selected toolchain on every
start, and `sshocker` maps it back to a drive letter from the leading
`/x/` segment without consulting any `fstab`. A custom MSYS2 `fstab` that
remaps drive prefixes (rare) therefore survives the round trip only by
coincidence, so avoid changing toolchains between starts of an existing
QEMU instance with a reverse-sshfs mount on such a host. A mount pinned to
`sftpDriver: builtin` is exempt, because `sshocker` serves it from the
native path.
Separately, a mount without an explicit `mountPoint` takes its guest path
from whichever `cygpath` `PATH` resolves, re-derived on every load rather
The round-2 rewrite replaced the mechanism and kept the conclusion. Nothing about the conversion persists across starts: pkg/instance/create.go:78 writes the raw config bytes, and mountPathAndSftpServer re-runs PathForSSH on every start. Changing toolchains is therefore not the trigger.
if !createSucceeded {
_ = os.RemoveAll(instDir)
}
}()
if err := os.WriteFile(filePath, instConfig, 0o644); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(instDir, filenames.LimaVersion), []byte(version.Version), 0o444); err != nil {
return nil, err
}
"Only by coincidence" overstates the hazard. convertMSYS2Path rewrites a path only when byte 2 is /, so /cygdrive/c/… and /mnt/c/… pass through untouched and the paired Cygwin sftp-server resolves them correctly. Only a remap onto a different single-letter prefix misroutes, and that one breaks on every start, not just after a toolchain change.
Fix: name the single-letter-prefix condition, say it fails on every start, and drop the cross-start advice.
SSH/PATH mismatch stalls for ten minutes, and mounts are still attempted Claude Opus 5 Gemini 3.5 Flash¶
whatever `ssh` `PATH` yields, complete or not.
The `SSH` environment variable overrides that search, but it does not
replace it. The hostagent still runs the `ssh` that `PATH` resolves, while
the key and socket paths it is handed take the form of whichever toolchain
`SSH` names, so the two must name the same install. Naming a Cygwin
toolchain in `SSH` while native OpenSSH leads `PATH` fails `limactl start`
at its readiness check, before any mount. Other helpers convert
paths with the `cygpath` that `PATH` resolves, regardless of `SSH`. Fixing
`PATH` is the reliable route.
Lima detects which ssh toolchain is in use on each `limactl start` and
takes both the path form and the `sftp-server` binary from that
The ssh essential requirement sets noMaster but never fatal (pkg/hostagent/requirements.go:216-226), so waitForRequirements retries it 200 times at three seconds, about ten minutes, logging nothing per attempt. limactl start's own deadline is also ten minutes (DefaultWatchHostAgentEventsTimeout). The user sees one "Waiting for the essential requirement" line and then silence, not a readiness error.
}
func (a *HostAgent) essentialRequirements() []requirement {
req := make([]requirement, 0)
req = append(req,
requirement{
description: "ssh",
script: `#!/bin/sh
true
`,
debugHint: `Failed to SSH into the guest.
Make sure that the YAML field "ssh.localPort" is not used by other processes on the host.
If any private key under ~/.ssh is protected with a passphrase, you need to have ssh-agent to be running.
`,
noMaster: true,
})
startControlMasterReq := requirement{
description: "Explicitly start ssh ControlMaster",
script: `#!/bin/sh
true
`,
"Before any mount" is wrong too. pkg/hostagent/hostagent.go:591 appends the error and falls through, so setupMounts still runs at line 619 and adds its own failures.
essentialRequirements := a.essentialRequirements()
if *a.instConfig.OS == limatype.WINDOWS {
essentialRequirements = a.essentialWinRequirements()
}
if err := a.waitForRequirements("essential", essentialRequirements); err != nil {
errs = append(errs, err)
}
// Windows guest needs a marker file which shows all installation is done.
// In the next boot, Lima will not mount OS installer ISO file to prevent unexpected re-installation.
Fix: say limactl start stalls at the readiness check for about ten minutes and then fails alongside mount errors. Recast "…leads PATH fails…" as "causes limactl start to fail", which reads without a garden path.
Suggestions ¶
SSH entry's Default line contradicts the selection order its sibling page documents Claude Opus 5¶
on its scp backend, so a path with no arguments is the reliable form.
Tokenization also reads `\` as an escape and splits on spaces. A Windows
path therefore needs quotes, and its backslashes survive only inside single
quotes; unquoted, `C:\Program Files\Git\usr\bin\ssh.exe` arrives as
`C:Program`.
- **Default**: unset. Lima then searches `$PATH`, and on Windows falls back to
`%SystemRoot%\System32\OpenSSH`.
- **Usage**:
```sh
export SSH=/opt/homebrew/bin/ssh
```
```powershell
On Windows Lima does not simply search $PATH for ssh. pickCompleteSSHOnWindows walks PATH for a directory holding ssh.exe, scp.exe, and ssh-keygen.exe together and skips partial installs such as MinGit; only then does it try %SystemRoot%\System32\OpenSSH, and only when that also fails does NewSSHExe fall through to exec.LookPath("ssh"). A reader whose partial install is first on PATH would expect it to win.
wsl2.md:107-110 states the same order correctly, so the two pages disagree.
`PATH` ahead of any other directory holding an `ssh.exe`. The Git
installer's default setting adds only `cmd\`, which holds none of those
binaries, and `usr\bin` also carries `find.exe` and `sort.exe`, which
shadow the Windows commands of those names.
Order matters because Lima takes the first directory on `PATH` that holds
`ssh.exe`, `scp.exe`, and `ssh-keygen.exe` together. When no directory
qualifies it tries `%SystemRoot%\System32\OpenSSH`, and failing that
whatever `ssh` `PATH` yields, complete or not.
The `SSH` environment variable overrides that search, but it does not
replace it. The hostagent still runs the `ssh` that `PATH` resolves, while
the key and socket paths it is handed take the form of whichever toolchain
`SSH` names, so the two must name the same install. Naming a Cygwin
Fix: give the three-step order here, or replace the sentence with a pointer to the toolchain section that already has it.
Recommended QEMU version:
- v8.2.1 or later (macOS)
- v6.2.0 or later (Linux)
On a Windows host, "qemu" needs an OpenSSH installation for its own `ssh`,
`scp`, and `ssh-keygen`. Mounts there must be reverse-sshfs, because QEMU
for Windows has no 9p support, and reverse-sshfs is the only thing in Lima
that uses `sftp-server`. See
[Windows toolchain]({{< ref "/docs/config/vmtype/wsl2#windows-toolchain" >}})
for which binaries to install and how Lima chooses between them.
"its own" has no referent, since none of the three binaries belong to QEMU. The requirement is not driver-specific either: ssh-keygen runs from sshutil.DefaultPubKeys on every limactl start, and scp.exe gates toolchain selection for every command through pickCompleteSSHOnWindows.
wsl2.md:84-85 says the same three binaries "cover the WSL2 driver", so a reader comparing the pages meets a contradiction. The 9p half of the sentence is correct.
### Windows toolchain
Lima uses an OpenSSH installation on the host. On a default Windows
install that is the native binaries in `C:\Windows\System32\OpenSSH\`:
- **OpenSSH Client** (`ssh.exe`, `scp.exe`, `ssh-keygen.exe`) ships by default
on Windows 10 and Windows 11, and covers the WSL2 driver.
- **`sftp-server.exe`** is part of OpenSSH Server, an [optional Feature on Demand](https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_install_firstuse).
Only the QEMU driver's reverse-sshfs mounts use it, and they treat it as a
preference: with no `sftp-server` on the host, `sshocker` serves the mount
in-process instead. Pinning `sftpDriver: openssh-sftp-server` drops that
in-process fallback, so the mount fails if the search below turns up
Fix: attribute ssh, scp, and ssh-keygen to Lima, and keep only sftp-server as the QEMU-specific addition.
$env:SSH = "'C:\Program Files\Git\usr\bin\ssh.exe'"
```
- **Note**: On Windows this overrides the toolchain detection described under
[Windows toolchain]({{< ref "/docs/config/vmtype/wsl2#windows-toolchain" >}}).
Lima pairs the path form and the `sftp-server` binary with whichever `ssh`
this names, but some paths still follow `PATH`, among them the guest mount
point and `limactl shell`'s working directory. Point `SSH` at the same
install that comes first on `PATH` to keep them consistent.
`limactl guest-install` ignores this variable altogether.
The advice is right, but "to keep them consistent" prices the failure as a cosmetic mismatch of mount point and working directory. Per I2 the same mismatch stalls limactl start for ten minutes and then fails, because the hostagent runs a PATH-resolved ssh against key paths formatted for the SSH-named toolchain. A reader who meets SSH on this page alone gets no warning that start breaks.
Keep "among them": limactl copy is a third PATH-following consumer on the #5300 branch (pkg/copytool/copytool.go:127), so the list is genuinely partial.
for _, path := range paths {
cp := &Path{}
if runtime.GOOS == "windows" {
if filepath.IsAbs(path) {
var err error
path, err = fsutil.WindowsSubsystemPath(ctx, path)
if err != nil {
return nil, err
}
} else {
path = filepath.ToSlash(path)
Fix: state the consequence, and link #windows-toolchain for the mechanism.
### Known Issues
- "wsl2" currently doesn't support many of Lima's options. See [this file](https://github.com/lima-vm/lima/blob/master/pkg/wsl2/wsl_driver_windows.go#L19) for the latest supported options.
- 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. Standard VM disk images (like `.qcow2`, `.raw`, etc.) or `.squashfs` images cannot be natively imported by WSL2.
- Lima unpacks a `.tar.gz` rootfs on its own, but a `.tar.xz`, `.tar.bz2`, or `.tar.zst` one needs the matching `xz`, `bzip2`, or `zstd` binary on your `PATH`, and Windows ships none of them.
### Rootfs Image Requirements & Building Custom Images
WSL2 does not run a standard virtual machine disk image directly. Instead, `wsl.exe` imports a guest root filesystem from a `.tar` or `.tar.gz` archive.
The claim is correct: pkg/downloader falls back to an in-process decoder for gzip alone and errors for the rest. But the bullet this replaced told the reader what to install, and the replacement ends at the problem. Only custom rootfs images reach it, since the shipped templates/experimental/wsl2.yaml uses .tar.gz.
Fix: name where the three binaries come from. Do not point at #windows-toolchain without checking it first: that section covers the ssh toolchain, and whether Git for Windows usr\bin ships xz.exe and zstd.exe is unverified here.
Design Observations ¶
Concerns ¶
- A 76-line, largely QEMU-oriented section is now the canonical Windows toolchain reference, and it lives on the WSL2 driver page.
qemu.md,environment-variables.md, and the newinstallation/_index.mdbullet all link intowsl2.md#windows-toolchain, yet only the OpenSSH Client bullet is WSL2-specific; thesftp-serverfeature,sftpDriverpinning, path forms, the sshocker round trip, and the guest mount-point derivation all describe reverse-sshfs, which the WSL2 driver never uses. A Windows host page would let each of the three links land on a title matching the reader's task. Rounds 1 and 2 raised this and the author took the cheaper route deliberately; a third round agreeing does not make it urgent. (future) Claude Opus 5
Strengths ¶
- The
SSH-versus-PATHwarning is correctly diagnosed even where I2 finds its symptom description wrong: sshocker'sSSHConfig.Binary()is a hardcoded"ssh"whileSSHOptsformats key paths for the selected toolchain, so the two genuinely must agree. (in-scope) Claude Opus 5 - Separating the host-path round trip from the guest mount-point derivation captures a distinction that is easy to conflate, and the second paragraph is accurate end to end, including "re-derived on every load rather than stored". (in-scope) Claude Opus 5
- The
sftpDriver: builtinexemption survived a deliberate attempt to refute it. sshocker convertsLocalPathbefore the driver switch, which suggests both drivers share the misconversion, but #5300'smountPathAndSftpServerreturnsfilepath.ToSlash(location)for the builtin driver, so sshocker never sees a POSIX path. (in-scope) [Codex GPT 5.6 Sol, orchestrator] - The PowerShell example is right on both layers: PowerShell escapes with a backtick rather than a backslash, so the literal survives to reach
shellwords.Parseas the single-quoted form. (in-scope) Gemini 3.1 Pro Claude Opus 5
Testing Assessment ¶
Documentation-only, so no automated coverage is expected. What I executed on the review SHA:
mattn/go-shellwordsv1.0.14, the versiongo.mod:41pins, against the three documentedSSHforms. Single-quoted yieldsC:\Program Files\Git\usr\bin\ssh.exeintact; unquoted yieldsC:Programas the binary, exactly asenvironment-variables.md:227claims; double-quoted keeps the space and strips every backslash.make docsy,npm --prefix website ci, thennpx hugo --cleanDestinationDir. The site builds. All three#windows-toolchainlinks emithref="/docs/config/vmtype/wsl2/#windows-toolchain"against a heading carryingid="windows-toolchain", andwsl2.md:95emitshref="/docs/config/mount/#reverse-sshfs"againstid="reverse-sshfs". Hugo never checks a fragment, so this needed the render.- Every behavioural claim re-read on
origin/copytool-native-windows-opensshandorigin/hostagent-reverse-sshfs-plain-windows, and sshocker v0.3.11 from the module cache.
Not executed: no Windows host was available, so the ten-minute stall in I2 is traced from retries = 200 and sleepDuration = 3 * time.Second rather than observed. Codex and Claude both reported building the site independently, and Gemini 3.1 Pro and Flash reported the same; I did not take those on trust and ran the build myself.
No CI job builds this site. test.yml carries paths-ignore: website/**, and the lychee step in lint-quick.yml checks README.md alone, so the two external links in the new text are never validated.
Documentation Assessment ¶
The section reads well and is pitched at a user. The two Important findings are both in text the round-2 fixes introduced, which is the same shape round 2 reported against round 1: each round's fixes are the next round's highest-risk surface. The fstab note has now been rewritten twice and is wrong in a different way each time, which argues for cutting it to the one condition that actually breaks rather than rewriting it again.
One claim in the diff has neither in-tree support nor a way to check it here: wsl2.md:100 says MinGit omits cygpath.exe. Round 2 verified this against the shipped MinGit zip, so it is confirmed, but nothing in the repository records that.
Commit Structure ¶
One commit, correctly scoped, DCO-signed. The message covers the toolchain documentation and the decompressor bullet. It does not mention the net-new installation/_index.md prerequisite bullet, which the generic "Document what to install" clause covers only loosely.
Acknowledged Limitations ¶
- The branch sits 8 commits behind
upstream/master, unchanged from round 2.
Declined in prior rounds ¶
- Documented sftp-server pairing has no implementation on master — declined Round 1: the prose describes the post-merge state by design, and the merge-order dependency lives in the PR description. I judged every behavioural claim against #5299 and #5300 accordingly, and the round-3 text describes nothing absent from those branches.
integration-windows-plaincarries a stale copy of the log line — declined Round 2: branch hygiene on a branch that is not this PR. The info message atwsl2.md:135-137matchespkg/hostagent/mount.goon the #5300 branch verbatim, so the rationale holds.- Document
_LIMA_WINDOWS_EXTRA_PATHin the toolchain section — declined Round 2: the variable survives #5299 and #5300 but is removed onintegration-windows-plain, so documenting it would need undoing. Re-raised this round and dropped again: the variable is still present atcmd/limactl/main.go:42and still absent fromintegration-windows-plain, so nothing the refutation rested on has changed.
Unresolved Feedback ¶
None. The PR carries no review comments from other reviewers.
Agent Performance Retro ¶
Claude Opus 5 ¶
Carried the round. Both Important findings are its own tracing, and I1 is the sharper one: it saw that the round-2 rewrite swapped the mechanism while keeping the conclusion, then went past the defect to bound it, showing that only a single-letter prefix misroutes while /cygdrive/ and /mnt/ pass through untouched. It was also the only agent to audit text outside the round-3 delta, which is where three of its four suggestions came from. Against that, its claim that two call sites exhaust the PATH-following consumers was wrong, and repeating its proposed edit would have made the doc incorrect. It also re-raised the _LIMA_WINDOWS_EXTRA_PATH suggestion round 2 declined, apparently without reading the decline it was given.
Codex GPT 5.6 Sol ¶
The only other agent to find I1, and it arrived by a different route: it constructed the /x/c/Users/lima case and ran it against the pinned sshocker, where Claude reasoned from the guard condition. Two independent paths to one defect is the strongest signal the round produced. Its I1 repeated round 2's OpenSSH Client claim under better citations than last time, and the troubleshooting page it found genuinely does name both features and genuinely does exclude Windows 10 and 11. The manufacturing doc that separates Client from Server still outranks it. The same model producing the same false positive on the same sentence two rounds running is a pattern worth naming.
Gemini 3.1 Pro ¶
Returned clean on all four files after doing real work: it traced the readiness-check failure through the identity-file path and derived the ten-minute retry loop that half of I2 now rests on. Then it filed that derivation under Documentation Assessment as evidence the page is correct, reasoning that the doc says the check fails and the check does fail. The gap it stepped over is between "the mechanism works as described" and "the description tells the user what they will see". This is the second round running where it built something real and reported nothing from it.
Gemini 3.5 Flash ¶
Returned APPROVE with one phrasing note, its first finding across three rounds, and the note is fair: the sentence puts "leads PATH fails" adjacent with no punctuation, and its fix folded into I2. Its Documentation Assessment then claims the prose "perfectly aligns" with implementation files including pkg/fsutil — a real package, but one with no bearing on the sentences it was assessing. It also reported "no broken links" from a Hugo build that validates neither fragments nor external URLs.
Summary ¶
| Claude Opus 5 | Codex GPT 5.6 Sol | Gemini 3.1 Pro | Gemini 3.5 Flash | |
|---|---|---|---|---|
| Duration | 17m 27s | 10m 10s | 6m 39s | 14m 58s |
| Findings | 2I 3S | 1I | none | 1I |
| Tool calls | 72 (Bash 65, Read 7) | 44 (shell 33, run 5, plan 3) | 60 (runshellcommand 58, write_file 2) | 68 (grepsearch 22, readfile 21, web_fetch 9) |
| Design observations | 1 | 2 | 0 | 2 |
| False positives | 1 | 1 | 0 | 0 |
| Unique insights | 4 | 1 | 1 | 1 |
| Files reviewed | 4 | 4 | 4 | 4 |
| Coverage misses | 0 | 1 | 1 | 1 |
| Totals | 2I 3S | 1I | none | 1I |
| Downgraded | 0 | 0 | 0 | 0 |
| Dropped | 2 | 0 | 0 | 0 |
Both Gemini models again concluded the documentation is correct, one silently and one with a grammar note. They share a model family, so count that as one opinion rather than two, and note it is the third round running that Flash has returned APPROVE against text carrying real defects.
Claude gave the most value; Codex gave the round's best-evidenced single finding and its only repeat false positive. The two Important findings both came from auditing round-2 fixes rather than from the round-3 delta, which is what the prior-round context was injected to produce.
Reconciliation. Codex's S1 (fstab remedy) was promoted to I1 and merged with Claude's I1, which covered the same defect with a tighter bound on when it triggers. Flash's S1 (phrasing) was merged into I2 as part of its fix rather than kept separate, since one edit resolves both. Codex's I1 (OpenSSH Client not installed by default) was dropped as a false positive for the second consecutive round, after I read all three Microsoft pages in play. Claude's S3 (_LIMA_WINDOWS_EXTRA_PATH) was dropped under the prior-round decline, verified still valid. Claude's recommendation to make the PATH-following list exhaustive was dropped as factually wrong. S3 came from consolidation rather than any agent.
Review Process Notes ¶
Skill improvements ¶
- When documentation states that a misconfiguration fails, verify how the failure reaches the user, not only that it occurs. A retry loop, an error accumulated into a slice rather than returned, or a deferred watchdog turns a described error into a silent stall, and a reader following the documentation will not recognise the symptom they actually see. Check the retry count, the sleep interval, and whether the erroring branch returns before flagging such prose as correct.
- When a prior round's fix rewrote the mechanism behind a warning, check that the remedy attached to it still follows from the new mechanism. Replacing a cause while keeping the original conclusion is a common shape for a second-round fix, and it is hard to see because both halves are new text and the pairing reads as freshly reasoned.
- Treat a proposal to replace a hedged list with an exhaustive one as a claim requiring enumeration, not an editorial improvement. A list qualified with "among them" or "such as" is correct by construction; converting it to a complete list makes it falsifiable, so verify every member on every branch the prose describes before repeating the recommendation.
- When a vendor's own documentation contradicts itself on a factual claim, prefer the source whose scope matches the claim's granularity over the one that is merely more recent or more specific-sounding. A page that separates the components a claim distinguishes outranks a page that names them together while addressing a different question.
Repo context updates ¶
- [repo] A failed essential requirement does not abort the hostagent's startup.
waitForRequirementsretries each requirement 200 times at 3-second intervals, the essentialsshrequirement is not markedfatal, and the error is appended to an error slice rather than returned, sosetupMountsstill runs afterwards. An ssh misconfiguration therefore presents as a roughly ten-minute silent stall followed by both a readiness failure and mount failures, not as a prompt error.limactl start's own watch timeout is also ten minutes. Weigh any documentation or comment claiming that a startup misconfiguration "fails" against how it presents. - [repo] sshocker's
convertMSYS2Pathrewrites a POSIX path to a drive letter only when the third byte is/, so it transforms/c/...but leaves/cygdrive/c/...and any multi-character prefix untouched. Untouched paths still resolve, because Lima pairs the path form with the same toolchain'ssftp-server. Only a prefix that is itself a single character misroutes, and it does so on every start. Check any claim about customfstaborcygdriveprefixes against that guard rather than against the general idea that sshocker ignoresfstab. - [repo]
SSHConfig.Binary()in sshocker returns a hardcoded"ssh", and bothssh.ExecuteScriptand Lima's ownwaitForWinRequirementcall it, so every hostagent requirement check runs thesshthatPATHresolves regardless of the toolchain Lima selected. Key and socket paths handed to that binary are formatted for the selected toolchain, soSSHandPATHnaming different installs breaks authentication at startup. Flag any documentation presentingSSHas a self-contained override.