Skip to content

feat: launch enclave from Windows through a WSL2 shim - #30

Draft
planger wants to merge 6 commits into
feat/rolling-release-macos-arm64from
feat/windows-wsl-shim
Draft

feat: launch enclave from Windows through a WSL2 shim#30
planger wants to merge 6 commits into
feat/rolling-release-macos-arm64from
feat/windows-wsl-shim

Conversation

@planger

@planger planger commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Stacked on #28 (feat/rolling-release-macos-arm64) — review that first.

enclave.exe forwards every argument to the Linux enclave binary inside a WSL2 distribution and returns its exit code. It parses no argv of its own, so enclave.exe --tool codex -- -p "…" behaves exactly as on Linux. This is a launcher, not a step towards native Windows support, and the error messages are written so they do not imply otherwise.

The working directory decides the distribution

A distribution can only see its own filesystem, so the directory the code lives in picks it:

Current directory Result
\\wsl.localhost\Ubuntu\home\p\proj (or \\wsl$\…) Ubuntu at /home/p/proj. The happy path.
C:\Users\p\proj Refused. With ENCLAVE_WSL_ALLOW_WINDOWS_PATH=1: /mnt/c/Users/p/proj, distro from ENCLAVE_WSL_DISTRO or the WSL default.
\\server\share\… Refused always.
Z:\ mapping a WSL share Resolved back to the share and treated as the first row.
Z:\ mapping anything else Refused, and the error names what the drive points at.

If ENCLAVE_WSL_DISTRO=B but the directory is inside A, A wins with a warning.

Layout

cmd/enclave/main.go became main_unix.go (//go:build !windows, unchanged content) plus a 6-line main_windows.go. All logic is in internal/wslshim.

The generator now emits //go:build !windows into cmd/enclave/tool_imports.go, so the launcher links neither the tool extensions nor the 14 MB of embedded assets they pull in — it never builds an image. Result: 3.3 MB instead of 14.3 MB (the plan estimated ~2 MB). make cross-build still compiles ./... for windows/amd64 and now windows/arm64, so the portability guard is unaffected.

Argument quoting is the risk

Enclave takes free-form arguments — prompts with spaces and quotes, -- passthrough. Windows passes a single command-line string that wsl.exe re-parses with rules that are not plain CRT rules. The shim therefore builds that string itself and hands it to CreateProcess via SysProcAttr.CmdLine rather than letting os/exec quote.

Two layers verify it:

  1. A golden table (spaces, ", \, \", trailing backslash, %VAR%, $VAR, newline, tab, empty string, emoji, CJK, bare --, and the 32767-char limit including surrogate-pair counting) round-trips through a reimplementation of CommandLineToArgvW in quote_test.go. The reference parser has its own tests, so a pass cannot come from both sides being wrong the same way. This runs in CI on every push.
  2. scripts/wsl-shim-verify.ps1 feeds the launcher's actual command lines to a real wsl.exe and compares the NUL-separated argv the Linux side receives. It reads internal/wslshim/testdata/wsl-quoting-golden.json, which a Go test generates from the escaping code and fails on if stale — so no quoting logic is duplicated in PowerShell.

Honest limitation: layer 2 cannot run in GitHub-hosted CI, because windows-latest runners have no WSL2. It is a documented manual pre-release gate (docs/DEV.md). CI does parse the script with the PowerShell parser so syntax errors cannot land.

Locating the Linux binary

~/.local/bin/enclave is only on PATH via ~/.profile, which a non-login shell does not read. So: resolve, then exec.

  1. wsl.exe [-d D] --cd / -e /bin/sh -lc '<fixed literal probe>' — no user argv, so quoting-safe. Passing --cd here is also how support for it is detected, at no extra cost; if it fails the shim retries without it and falls back to changing directory through a shell that forwards "\$@".
  2. wsl.exe [-d D] --cd <linuxPath> -e <absolutePath> <argv…> — no shell re-parses the user's arguments.

One extra round trip (~100–300 ms cold). No caching in v1. Probe exit 127 is treated as conclusive, so a missing binary is not retried; a failed -d gets the installed-distribution list appended to the error.

Stdio, Ctrl-C, exit codes

Streams are assigned directly, never piped, so console handles are inherited and docker attach inside WSL gets a real TTY. signal.Ignore(os.Interrupt) keeps the shim from dying before its child. Child exit codes propagate verbatim; shim preflight failures exit 125 and every shim message is prefixed enclave (windows launcher):.

Environment

WSL only forwards variables named in WSLENV. The shim auto-forwards ENCLAVE_* — minus its own ENCLAVE_WSL_* controls and ENCLAVE_HOME, whose Windows path the Linux binary cannot use — plus anything in ENCLAVE_WSL_FORWARD_ENV=VAR1,VAR2/p, which overrides those exclusions and accepts WSLENV flag suffixes. An existing WSLENV is preserved and appended to. The shim stays ignorant of tool specs, so nothing couples to extensions/tools/*/spec.yaml. Docs state plainly that the normal path is configuring credentials inside the distribution.

CI and packaging

  • ci.yml: new windows-latest job (build, go vet ./..., go test ./internal/wslshim/..., PowerShell parse). Green from day one.
  • make cross-build: adds windows/arm64, and docs/DEV.md no longer claims the windows target is unpublished.
  • rolling.yml: windows/amd64 + windows/arm64 rows with smoke: false, zipped in the release job, plus a generated Scoop manifest (packaging/scoop/enclave.template.jsonenclave.json) installable straight from the release URL. winget is deferred: it needs a PR into microsoft/winget-pkgs per release.
  • Completions need nothing: enclave completion powershell passes through to Cobra on the Linux side.
  • scripts/check-license-headers.sh now covers *.ps1.

Testing

Everything except GetDriveType is host-independent and runs on Linux: path classification, UTF-16LE distro-list parsing from byte fixtures, the quoting golden table, WSLENV construction, exit-code mapping, and the distro-conflict warning. Orchestration is covered end to end against an injected fake host, including the --cd fallback and the 125-vs-child-exit-code distinction.

Verified: make build, make test, make lint, make cross-build, GOOS=windows go vet ./..., and GOOS=windows go test -c ./internal/wslshim all pass.

Not verified locally: pwsh is unavailable in this environment, so scripts/wsl-shim-verify.ps1 has not been executed or even parsed here — the new CI step is the first thing that will parse it.

Explicitly out of scope

No WSL2/distro/binary installation, no doctor, no binary-resolution cache, no MSI, no native Windows support.

Mapped drive letters

cmd.exe cannot hold a UNC working directory, so pushd \\wsl.localhost\Ubuntu\home\p\proj assigns a free drive letter. The plan called for refusing mapped drives always; on review that was over-broad, so this resolves them:

  • WNetGetConnectionW (bound from mpr.dllx/sys/windows does not wrap it) reports what the letter maps to. A WSL share is treated as the share path it names, with the mapping's own remainder plus the working directory below it appended, so a Z: mapped to /home/p/proj makes Z:\src mean /home/p/proj/src.
  • Anything else is still refused, and the error now names what the drive points at rather than hedging about what it might be.
  • The resolved UNC path must name a distribution explicitly. A test caught that a mapping to the provider root would otherwise promote the first path component to a distribution name and silently bind-mount the wrong directory into a container an agent can write to. That is the failure mode that made refusing defensible in the first place, so it is checked rather than assumed.

GetDriveType and WNetGetConnection are both behind one-method seams, so the whole classifier stays testable on Linux; go vet's unsafeptr check and gosec both pass on the GOOS=windows paths.

Why not collapse the two calls: WNetGetConnection could answer both questions, but a drive can report as remote without being a redirected net use, so GetDriveType stays the classifier.

planger added 3 commits July 29, 2026 09:49
The rolling release now ships enclave-darwin-arm64, enclave-darwin-amd64,
and enclave-linux-arm64 alongside the existing linux-amd64 binary and
amd64 .deb. darwin/amd64 is cross-compiled on the arm64 macOS runner and
therefore not smoke-tested; the other three run the smoke test natively.

No native Windows binary is published: native Windows is unsupported at
runtime, so Windows users are documented onto WSL2 with the Linux artifact.

The smoke test gained macOS support: bash 3.2 has no mapfile, and the Apple
layout ignores the XDG_* overrides the script relied on.
The backend hardcodes qemu-system-x86_64 and its bundle builder pins
--platform linux/amd64 with apk --arch x86_64, so the guest is x86-64
regardless of host. It launches with -machine microvm,accel=kvm:tcg, so
only x86-64 Linux hosts get KVM; arm64 hosts and macOS fall back to TCG.

This was undocumented and becomes misleading now that arm64 and macOS
binaries are published.
enclave.exe forwards every argument to the Linux enclave binary inside a
WSL2 distribution and parses no argv of its own. It is a launcher, not a
step towards native Windows support.

The working directory decides the distribution, because that is where the
code lives: a \\wsl.localhost\<Distro>\... path names its own, a Windows
drive path is refused unless ENCLAVE_WSL_ALLOW_WINDOWS_PATH=1, and network
shares and mapped drives are refused outright.

Argument quoting is the risk. Windows passes one command-line string that
wsl.exe re-parses, so the shim builds that string itself and hands it to
CreateProcess unchanged, driven by a golden table that round-trips through
a reimplementation of CommandLineToArgvW. The empirical half cannot run in
GitHub-hosted CI because windows-latest runners have no WSL2, so it splits
into that unit-test table plus scripts/wsl-shim-verify.ps1 as a documented
manual pre-release gate.

cmd/enclave/tool_imports.go is now generated with //go:build !windows so
the launcher links neither the tool extensions nor the embedded runtime
assets: 3 MB instead of 14 MB.
@github-actions

Copy link
Copy Markdown

planger and others added 3 commits July 29, 2026 12:26
cmd.exe cannot hold a UNC working directory, so `pushd
\\wsl.localhost\Ubuntu\home\p\proj` assigns a free drive letter instead.
Refusing that letter refused an alias for a path the launcher already
accepts, and ENCLAVE_WSL_ALLOW_WINDOWS_PATH did not apply because the
remote-drive check ran first, leaving cmd.exe users with no way through.

WNetGetConnection reports what the letter maps to. A WSL share is now
treated as the share path it names, with the mapping's own remainder and
the working directory below it appended. Everything else is still refused,
and the error now names what the drive points at instead of hedging about
what it might be.

The distribution must be named explicitly in the resolved UNC path: a
mapping to the provider root would otherwise promote the first path
component to a distribution name and silently bind-mount the wrong
directory.

Also fixes the golden-file test on Windows, where core.autocrlf rewrote
the fixture's newlines and made a byte comparison fail for a reason that
had nothing to do with content drift.
The script echoed the received argv with `/usr/bin/printf %s\0`, which reads a
`--` in its first operand slot as an end-of-options marker and drops it. The
bare `--` case therefore failed against a launcher that had passed the argument
correctly and a wsl.exe that had delivered it intact, and it did so only on
coreutils versions with that behavior. A sentinel operand keeps every argument
under test out of that slot; a missing sentinel now fails the case loudly
rather than scoring it against the wrong argv.

$PSScriptRoot is empty in Windows PowerShell for a script run by relative path
from a UNC location, which is how this script is reached from \\wsl.localhost,
so -GoldenFile is resolved in the body with a fallback instead of as a param
default.
@planger
planger force-pushed the feat/rolling-release-macos-arm64 branch from f7f4aaa to 71b25e0 Compare August 7, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants