Skip to content

feat(app): install WSL from the create-sandbox dialogs - #264

Merged
quickbeard merged 3 commits into
mainfrom
feat/wsl-auto-install
Aug 18, 2026
Merged

quickbeard merged 3 commits into
mainfrom
feat/wsl-auto-install

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 18, 2026 •

Copy link
Copy Markdown
Owner

A sandbox on Windows runs inside WSL 2. On a machine without it, both create-sandbox dialogs were a dead end: the message "WSL 2 is not installed. Install it to use the sandbox, then try again", a Cancel button, and nothing else — while the app already ships a WSL installer for the Code side's WSL servers. "Try again" could not work either: sandbox availability is answered once per app run with staleTime: Infinity and nothing ever invalidated it, so installing WSL by hand still left Work reporting the machine it found at launch.

Both dialogs now offer Install WSL and do it.

What it does

Fetches the installer manifest, stages the MSI for this machine's architecture, and installs it behind the existing single UAC prompt — one elevated script that enables VirtualMachinePlatform via offline dism and runs msiexec. Falls back to Microsoft's wsl --install when the storage host cannot be reached, which is the point on the whitelist-only fleet.

ARM64 is covered for the first time — the bundled wsl.zip is x64-only, so ARM64 machines previously went straight to the Microsoft fallback.

Reuse, not duplication

The elevated script, the 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) handling, and the "acquisition failures fall back, completed-but-failed installs do not" policy are the existing installWslRuntimeDualPath code. Only the MSI's source changed — so the Code side inherits ARM64 support and a ~250MB download in place of 643MB for free.

Acquisition goes through stageAsset, the resumable, content-addressed, sha256-verified downloader the guest images already use. A connection dropped at 90% resumes with a Range request instead of restarting the quarter-gigabyte, and an installer that does not match its digest is rejected before it reaches msiexec. Previously it was a plain fetch to disk with no digest and no resume — on links observed at ~35 KB/s that is roughly two hours to lose.

A sandbox needs only the runtime — it imports its own rootfs and mounts its own server — so the distro and CoDev-in-distro steps are skipped entirely.

Why a standalone manifest

The sandbox image manifest already has a wslInstaller slot (version, recommendedMinBuild, per-arch assets). It is parsed and tested but has no consumer, and it is not the right home: build-image.ts regenerates that whole document with wslInstaller: null hardcoded, so anything recorded there is erased the next time the guest image is rebuilt. Separate documents keep the two release cadences independent.

The shape is shared, not copied — parseWslInstaller and its asset validation are reused, and the unused slot keeps a comment saying which document is live.

/codev-storage/codev/installers/wsl/manifest.json     ← the pointer clients read
/codev-storage/codev/installers/wsl/2.7.11/wsl.2.7.11.0.x64.msi
/codev-storage/codev/installers/wsl/2.7.11/wsl.2.7.11.0.arm64.msi

The version pin moves out of the code and into the manifest, so bumping WSL is an upload, not an app release.

wsl.zip is no longer asked for its x64 MSI member — nothing reads it now, so extracting it cost 250MB of disk for a file with no reader. The zip keeps serving the Ubuntu distro image for WSL servers.

UI

One component (app/src/sandbox/wsl-install.tsx) rendered by both dialogs in place of their form: the reason, an Install WSL button, a progress bar with MB counts, a named "approve the Windows prompt" step, a terminal restart-required state, and retry on failure. Cancel appears only during the download — the elevated step is msiexec's and Windows offers nothing to cancel.

Install state lives in the main process, not the dialog: hundreds of megabytes on slow links, and closing the dialog must not lose track of it. Single-flight, since both dialogs reach the same installer and two elevated installs would stack two UAC prompts.

After a clean install the availability probe runs again — the script maps 3010 to a clean exit, so "exited 0" says nothing about whether WSL can run yet, and only the probe separates installed from needs a reboot. Its cached verdict is invalidated on completion, so the dialog flows straight on to creating the sandbox.

Testing

bun test green in desktop (128), app (702) and codev-sandbox (152); bun typecheck clean across all 33 workspace packages. New tests cover the install state machine (progress → elevated step → installed, restart-required, failure with and without installer output, acquisition error, cancel, single-flight, non-Windows refusal), manifest-driven acquisition (arch selection, no re-download when staged, resume via Range after a mid-body drop, digest rejection, progress totals, uncovered arch, unreachable manifest), and the manifest parser.

Not tested on real hardware — no Windows machine available here, so the elevated path and arch selection are covered by unit tests and typechecking only.

Before merging

Publish the installers with the new script, which computes the digests, writes the manifest, and uploads artifacts before the pointer (the way publish-image.ts does — a hand-computed digest would fail verification only after a full download):

# put wsl.2.7.11.0.x64.msi and wsl.2.7.11.0.arm64.msi in dist-wsl/
# from https://github.com/microsoft/WSL/releases/tag/2.7.11
cd packages/codev-sandbox
bun script/publish-wsl-installer.ts --version 2.7.11 --in dist-wsl

Without CODEV_STORAGE_TARGET it prints the exact mc cp commands instead of uploading. Until the files are up, the primary path fails and every machine takes the Microsoft fallback — i.e. current behaviour, not a regression.

2.7.11 is the newest stable WSL release (2.9.x are prereleases) and covers Windows 10 (build 19041+) and Windows 11 alike. That floor is recommendedMinBuild in the manifest — see below.

The availability warning reads the manifest too

recommendedMinBuild was in the manifest schema and read by nobody. It now drives the availability warning: a machine below the published installer's floor is told so, naming both the build it has and the one it needs, instead of getting the generic "this Windows version is out of support" line (or, on 19044, nothing specific at all). More specific than the hardcoded tiers, so it wins over them; a machine above the floor keeps the tier it was in, since satisfying what WSL needs to install says nothing about the sandbox on Windows 10. Windows stays warn-never-block throughout.

The floor reaches the provider as resolveInstallerMinBuild, injected the way resolveImage and resolveEgress already are. The desktop caches it for the app's lifetime, bounds it at 5s, and only asks below the first Windows 11 build — every Windows 11 build clears every floor Microsoft has published, so the common machine pays no round trip. An unreachable manifest resolves null and leaves the policy exactly where it was.

So raising the floor is also just an upload.

🤖 Generated with Claude Code

quickbeard and others added 3 commits August 18, 2026 16:44
A sandbox on Windows runs inside WSL 2, and a machine without it hit a dead
end: the dialog reported "WSL 2 is not installed", offered nothing but Cancel,
and told the user to install it and try again — which could not work either,
since availability is answered once per app run and cached forever.

Both create-sandbox dialogs now offer Install WSL. The runtime is downloaded
as a standalone per-architecture MSI from the codev storage host and installed
behind the existing single UAC prompt, falling back to Microsoft's installer
when that host cannot be reached. ARM64 is covered for the first time; the
bundled wsl.zip is x64-only.

Nothing about the install itself is new: the elevated dism + msiexec script,
the 3010 reboot-required handling and the acquisition-only fallback policy are
the same code the Code side's WSL servers already use. What changed is where
the MSI comes from, so that side gets ARM64 support and a 250MB download in
place of 643MB for free. The two paths share one cache directory and filename
deliberately — on x64 it is the same file, so whichever ran first, the other
downloads nothing.

Install state lives in the main process rather than in the dialog: the
download is hundreds of megabytes on links measured in tens of KB/s, and
closing the dialog must not lose track of it. After a clean install the
sandbox availability probe runs again, because the elevated script maps 3010
to success and only the probe distinguishes installed from needs-a-reboot.
That probe's cached verdict is now invalidated on completion, so the dialog
carries straight on to creating the sandbox instead of repeating the message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MSI was fetched by a URL built from a version pinned in code, straight to
disk with no digest and no resume. A dropped connection at 90% of 250MB — on
links observed at ~35 KB/s — started the whole download again, and a truncated
file would have reached msiexec.

The installers now ship a manifest of their own
(/codev/installers/wsl/manifest.json, assets under
/codev/installers/wsl/<version>/), and acquisition goes through stageAsset:
the same resumable, content-addressed, sha256-verified downloader the guest
images already use. Resume is Range-based and picks up where it stopped;
a file that does not match its digest is rejected before it is installed.

The manifest is standalone rather than the sandbox image manifest's existing
wslInstaller slot, which is parsed but has no consumer: build-image.ts
regenerates that whole document with wslInstaller null on every image publish,
so an installer recorded there is erased the next time the guest image is
rebuilt. Separate documents keep the two release cadences independent. The
shape is shared — parseWslInstaller and its asset validation are reused, not
copied — and the slot keeps a comment saying which one is live.

The version pin moves out of the code and into the manifest, so bumping WSL is
an upload rather than an app release. script/publish-wsl-installer.ts computes
the digests, writes the manifest and uploads artifacts before the pointer, the
way publish-image.ts does — hand-computed digests would fail verification only
after a full download.

wsl.zip is no longer asked for its x64 MSI member. Nothing reads it now that
the runtime comes from the manifest for every architecture, so extracting it
cost 250MB of disk for a file with no reader; the zip keeps serving the Ubuntu
distro image for WSL servers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
recommendedMinBuild was recorded in the installer manifest and read by nobody;
the availability warning carried its own hardcoded tiers instead. A machine
below what the published installer supports was told the generic "this Windows
version is out of support" line, or on 19044 nothing specific at all, and only
found out for certain when the install failed.

windowsSandboxAvailability now takes the manifest's floor and, when the machine
is under it, names both builds — the one it has and the one it needs. That is
more specific than the tiers, so it wins over them; a machine above the floor
keeps the tier it was already in, since satisfying what WSL needs to install
says nothing about the sandbox on Windows 10. Windows stays warn-never-block
throughout: an old build may still work, and a real incompatibility surfaces as
an install error rather than a refusal.

The floor reaches the provider as resolveInstallerMinBuild, injected the way
resolveImage and resolveEgress already are, so the provider keeps no URLs and
no fetch policy of its own. The desktop caches it for the app's lifetime and
bounds it at 5s, and only asks below the first Windows 11 build — every
Windows 11 build clears every floor Microsoft has published, so the common
machine pays no round trip. An unreachable manifest resolves null and leaves
the policy exactly where it was before the manifest existed.

Raising the floor is now an upload: publishing a WSL version with a higher
recommendedMinBuild re-words the warning without an app release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard merged commit 31efa5f into main Aug 18, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the feat/wsl-auto-install branch August 18, 2026 10:03
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.

1 participant